Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 144 additions & 3 deletions pkg/cli/bicep/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,24 @@ package bicep
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"time"

"github.com/radius-project/radius/pkg/cli/filesystem"
"github.com/radius-project/radius/pkg/cli/output"
"github.com/radius-project/radius/pkg/cli/setup"
"github.com/radius-project/radius/pkg/version"
)

// remoteTemplateTimeout bounds how long we wait when downloading a remote template.
const remoteTemplateTimeout = 60 * time.Second

// Interface is the interface for interacting with Bicep.
type Interface interface {
PrepareTemplate(filePath string) (map[string]any, error)
Expand All @@ -44,12 +54,26 @@ type Impl struct {
}

// PrepareTemplate checks if the file is a .json or .bicep file, downloads Bicep if it is not installed, checks if the file
// exists, and builds the template if it does. It returns a map of strings to any and an error if one occurs.
// exists, and builds the template if it does. The file may be a local path or an http(s) URL; remote templates are
// downloaded to a temporary local file first. It returns a map of strings to any and an error if one occurs.
func (i *Impl) PrepareTemplate(filePath string) (map[string]any, error) {
// A remote URL is downloaded to a temporary local file so it can be read or compiled like a
// local template. This mirrors the behavior users expect from tools such as kubectl.
originalPath := filePath
remote := isRemoteURL(filePath)
if remote {
localPath, cleanup, err := i.downloadTemplate(filePath)
if err != nil {
return nil, err
}
defer cleanup()
filePath = localPath
}

if strings.EqualFold(path.Ext(filePath), ".json") {
return ReadARMJSON(filePath)
} else if !strings.EqualFold(path.Ext(filePath), ".bicep") {
return nil, fmt.Errorf("the provided file %q must be a .json or .bicep file", filePath)
return nil, fmt.Errorf("the provided file %q must be a .json or .bicep file", originalPath)
}
Comment thread
zachcasper marked this conversation as resolved.

ok, err := IsBicepInstalled()
Expand All @@ -71,10 +95,15 @@ func (i *Impl) PrepareTemplate(filePath string) (map[string]any, error) {
return nil, fmt.Errorf("could not find file: %w", err)
}

step := i.Output.BeginStep("Building %s...", filePath)
step := i.Output.BeginStep("Building %s...", originalPath)
bytes, err := i.Call("build", "--stdout", filePath)
if err != nil {
i.Output.CompleteStep(step)
if remote {
// The bicep compiler prints detailed diagnostics to stderr, so keep the wrapper
// error focused on identifying the remote source rather than guessing the cause.
return nil, fmt.Errorf("failed to build remote template %q: %w", originalPath, err)
}
return nil, fmt.Errorf("failed to build template: %w", err)
}

Expand All @@ -88,6 +117,118 @@ func (i *Impl) PrepareTemplate(filePath string) (map[string]any, error) {
return template, nil
}

// isRemoteURL reports whether filePath is an http or https URL. Local paths, including Windows
// paths such as C:\foo.bicep, are not treated as remote URLs.
func isRemoteURL(filePath string) bool {
parsed, err := url.Parse(filePath)
if err != nil {
Comment thread
zachcasper marked this conversation as resolved.
return false
}
return parsed.Scheme == "http" || parsed.Scheme == "https"
}

// downloadTemplate retrieves a remote template referenced by an http(s) URL and writes it to a
// temporary local file so it can be read or compiled like a local template. It returns the local
// file path and a cleanup function that removes the temporary directory.
func (i *Impl) downloadTemplate(templateURL string) (string, func(), error) {
parsed, err := url.Parse(templateURL)
if err != nil {
return "", nil, fmt.Errorf("invalid template URL %q: %w", templateURL, err)
}

ext := path.Ext(parsed.Path)
if !strings.EqualFold(ext, ".bicep") && !strings.EqualFold(ext, ".json") {
return "", nil, fmt.Errorf("the provided URL %q must reference a .json or .bicep file", templateURL)
}

i.Output.LogInfo("Downloading template from %s...", templateURL)
Comment thread
zachcasper marked this conversation as resolved.
Outdated

client := &http.Client{Timeout: remoteTemplateTimeout}
Comment thread
zachcasper marked this conversation as resolved.
Outdated
resp, err := client.Get(templateURL)
Comment thread
zachcasper marked this conversation as resolved.
Outdated
if err != nil {
return "", nil, fmt.Errorf("failed to download template from %q: %w", templateURL, err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
Comment thread
zachcasper marked this conversation as resolved.
Outdated
return "", nil, fmt.Errorf("failed to download template from %q: unexpected status %s", templateURL, resp.Status)
}

body, err := io.ReadAll(resp.Body)
if err != nil {
return "", nil, fmt.Errorf("failed to read template from %q: %w", templateURL, err)
}
Comment thread
zachcasper marked this conversation as resolved.
Outdated

dir, err := i.FileSystem.MkdirTemp("", "rad-remote-template-")
if err != nil {
return "", nil, fmt.Errorf("failed to create temporary directory for remote template: %w", err)
}
cleanup := func() {
_ = i.FileSystem.RemoveAll(dir)
}

// Preserve the original file name so compiler diagnostics reference a recognizable file.
localPath := filepath.Join(dir, path.Base(parsed.Path))
if err := i.FileSystem.WriteFile(localPath, body, 0600); err != nil {
cleanup()
return "", nil, fmt.Errorf("failed to write remote template to temporary file: %w", err)
}

// Bicep discovers bicepconfig.json by walking up from the source file's directory, which for a
// downloaded template is an isolated temp dir. Provide one so extension declarations such as
// `extension radius` resolve, preferring the user's own config over a generated default.
if strings.EqualFold(ext, ".bicep") {
if err := i.writeBicepConfig(dir); err != nil {
cleanup()
return "", nil, err
}
}

return localPath, cleanup, nil
}

// writeBicepConfig places a bicepconfig.json in destDir so extension declarations in a downloaded
// template resolve. It reuses the nearest bicepconfig.json found by searching upward from the
// current working directory, falling back to the default Radius extensions configuration.
func (i *Impl) writeBicepConfig(destDir string) error {
dest := filepath.Join(destDir, "bicepconfig.json")

if wd, err := os.Getwd(); err == nil {
if found := findBicepConfig(i.FileSystem, wd); found != "" {
data, err := i.FileSystem.ReadFile(found)
if err != nil {
return fmt.Errorf("failed to read %q: %w", found, err)
}
if err := i.FileSystem.WriteFile(dest, data, 0600); err != nil {
return fmt.Errorf("failed to write bicepconfig.json: %w", err)
}
return nil
}
}

if err := i.FileSystem.WriteFile(dest, []byte(setup.GetVersionedBicepConfig()), 0600); err != nil {
return fmt.Errorf("failed to write bicepconfig.json: %w", err)
}
return nil
}

// findBicepConfig walks up from startDir looking for a bicepconfig.json, mirroring how the Bicep
// compiler discovers configuration. It returns "" if none is found.
func findBicepConfig(fs filesystem.FileSystem, startDir string) string {
dir := startDir
for {
candidate := filepath.Join(dir, "bicepconfig.json")
if _, err := fs.Stat(candidate); err == nil {
return candidate
}
parent := filepath.Dir(dir)
if parent == dir {
return ""
}
dir = parent
}
}

// Call runs `bicep` with the given arguments.
func (i *Impl) Call(args ...string) ([]byte, error) {
return runBicepRaw(args...)
Expand Down
175 changes: 175 additions & 0 deletions pkg/cli/bicep/types_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/*
Copyright 2023 The Radius Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package bicep

import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"

"github.com/radius-project/radius/pkg/cli/filesystem"
"github.com/radius-project/radius/pkg/cli/output"
"github.com/stretchr/testify/require"
)

func Test_isRemoteURL(t *testing.T) {
tests := []struct {
name string
filePath string
expected bool
}{
{name: "https URL", filePath: "https://example.com/app.bicep", expected: true},
{name: "http URL", filePath: "http://example.com/app.bicep", expected: true},
{name: "relative path", filePath: "app.bicep", expected: false},
{name: "relative dot path", filePath: "./app.bicep", expected: false},
{name: "absolute unix path", filePath: "/tmp/app.bicep", expected: false},
{name: "windows path", filePath: `C:\Users\app.bicep`, expected: false},
{name: "unsupported scheme", filePath: "ftp://example.com/app.bicep", expected: false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.expected, isRemoteURL(tt.filePath))
})
}
}

func newTestImpl() *Impl {
return &Impl{
FileSystem: filesystem.NewOSFS(),
Output: &output.OutputWriter{Writer: io.Discard},
}
}

func Test_downloadTemplate_Success(t *testing.T) {
content := []byte("resource foo 'Foo' = {}\n")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/dir/app.bicep", r.URL.Path)
_, _ = w.Write(content)
}))
defer server.Close()

i := newTestImpl()
localPath, cleanup, err := i.downloadTemplate(server.URL + "/dir/app.bicep")
require.NoError(t, err)
defer cleanup()
Comment thread
zachcasper marked this conversation as resolved.

// The original file name is preserved so compiler diagnostics stay recognizable.
require.Equal(t, "app.bicep", filepath.Base(localPath))

got, err := os.ReadFile(localPath)
require.NoError(t, err)
require.Equal(t, content, got)

// A bicepconfig.json is written alongside the template so `extension` declarations resolve.
configBytes, err := os.ReadFile(filepath.Join(filepath.Dir(localPath), "bicepconfig.json"))
require.NoError(t, err)
require.Contains(t, string(configBytes), "extensions")
require.Contains(t, string(configBytes), "radius")

// Cleanup removes the temporary directory.
cleanup()
_, err = os.Stat(localPath)
require.True(t, os.IsNotExist(err))
}

func Test_downloadTemplate_JSONHasNoBicepConfig(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{"resources":[]}`)
}))
defer server.Close()

i := newTestImpl()
localPath, cleanup, err := i.downloadTemplate(server.URL + "/template.json")
require.NoError(t, err)
defer cleanup()

// ARM JSON templates do not use bicepconfig.json, so none is written.
_, err = os.Stat(filepath.Join(filepath.Dir(localPath), "bicepconfig.json"))
require.True(t, os.IsNotExist(err))
}

func Test_findBicepConfig(t *testing.T) {
fs := filesystem.NewOSFS()

// Config discovered in a parent directory of the start directory.
root := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(root, "bicepconfig.json"), []byte("{}"), 0600))
nested := filepath.Join(root, "a", "b")
require.NoError(t, os.MkdirAll(nested, 0755))
require.Equal(t, filepath.Join(root, "bicepconfig.json"), findBicepConfig(fs, nested))

// No config anywhere up the tree from an isolated directory.
isolated := filepath.Join(t.TempDir(), "sub")
require.NoError(t, os.MkdirAll(isolated, 0755))
require.Equal(t, "", findBicepConfig(fs, isolated))
}

func Test_downloadTemplate_NotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()

i := newTestImpl()
_, _, err := i.downloadTemplate(server.URL + "/missing.bicep")
require.Error(t, err)
require.Contains(t, err.Error(), "unexpected status")
require.Contains(t, err.Error(), "404")
}

func Test_downloadTemplate_UnsupportedExtension(t *testing.T) {
i := newTestImpl()
_, _, err := i.downloadTemplate("https://example.com/app.txt")
require.Error(t, err)
require.Contains(t, err.Error(), "must reference a .json or .bicep file")
}

func Test_downloadTemplate_DownloadFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
url := server.URL + "/app.bicep"
server.Close() // Close immediately so the connection is refused.

i := newTestImpl()
_, _, err := i.downloadTemplate(url)
require.Error(t, err)
require.Contains(t, err.Error(), "failed to download template")
}

func Test_PrepareTemplate_RemoteJSON(t *testing.T) {
template := `{"$schema":"https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#","resources":[]}`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, template)
}))
defer server.Close()

i := newTestImpl()
result, err := i.PrepareTemplate(server.URL + "/template.json")
require.NoError(t, err)
require.Equal(t, "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", result["$schema"])
require.Empty(t, result["resources"])
}

func Test_PrepareTemplate_RemoteUnsupportedExtension(t *testing.T) {
i := newTestImpl()
_, err := i.PrepareTemplate("https://example.com/app.txt")
require.Error(t, err)
require.Contains(t, err.Error(), "must reference a .json or .bicep file")
}
7 changes: 7 additions & 0 deletions pkg/cli/cmd/deploy/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ func NewCommand(factory framework.Factory) (*cobra.Command, framework.Runner) {

The deploy command compiles a Bicep or ARM template and deploys it to your default environment (unless otherwise specified).

The template can be a local file path or an http(s) URL. Remote templates are downloaded before
being compiled and deployed, similar to how tools such as kubectl accept remote URLs. Remote
templates must be self-contained; relative imports and other local file dependencies are not supported.

You can combine Radius types as as well as other types that are available in Bicep such as Azure resources. See
the Radius documentation for information about describing your application and resources with Bicep.

Expand All @@ -95,6 +99,9 @@ rad deploy myapp.bicep
# deploy an ARM template (json)
rad deploy myapp.json

# deploy a Bicep template from a remote URL
rad deploy https://example.com/myapp.bicep

# deploy to a specific workspace
rad deploy myapp.bicep --workspace production

Expand Down
Loading
Loading