Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
161 changes: 157 additions & 4 deletions pkg/cli/bicep/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,28 @@ 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

// maxRemoteTemplateSize bounds how many bytes are read from a remote template so a large or
// malicious response cannot exhaust memory. It is a variable so tests can lower it.
var maxRemoteTemplateSize int64 = 100 << 20 // 100 MiB

// Interface is the interface for interacting with Bicep.
type Interface interface {
PrepareTemplate(filePath string) (map[string]any, error)
Expand All @@ -44,12 +58,30 @@ 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)
template, err := ReadARMJSON(filePath)
if err != nil && remote {
return nil, fmt.Errorf("failed to read remote template %q: %w", originalPath, err)
}
return template, err
} 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 +103,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 +125,122 @@ 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)
}

// Bound the read so a large or malicious response cannot exhaust memory.
body, err := io.ReadAll(io.LimitReader(resp.Body, maxRemoteTemplateSize+1))
if err != nil {
return "", nil, fmt.Errorf("failed to read template from %q: %w", templateURL, err)
}
if int64(len(body)) > maxRemoteTemplateSize {
return "", nil, fmt.Errorf("template from %q exceeds the maximum allowed size of %d bytes", templateURL, maxRemoteTemplateSize)
}

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
Loading
Loading