cli/azd/pkg/tools/bicep/bicep.go (229 lines of code) (raw):
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package bicep
import (
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"runtime"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/azure/azure-dev/cli/azd/pkg/config"
"github.com/azure/azure-dev/cli/azd/pkg/exec"
"github.com/azure/azure-dev/cli/azd/pkg/input"
"github.com/azure/azure-dev/cli/azd/pkg/osutil"
"github.com/azure/azure-dev/cli/azd/pkg/tools"
"github.com/blang/semver/v4"
)
// Version is the minimum version of bicep that we require (and the one we fetch when we fetch bicep on behalf of a
// user).
var Version semver.Version = semver.MustParse("0.35.1")
// NewCli creates a new Bicep CLI. Azd manages its own copy of the bicep CLI, stored in `$AZD_CONFIG_DIR/bin`. If
// bicep is not present at this location, or if it is present but is older than the minimum supported version, it is
// downloaded.
func NewCli(
ctx context.Context,
console input.Console,
commandRunner exec.CommandRunner,
) (*Cli, error) {
return newCliWithTransporter(ctx, console, commandRunner, http.DefaultClient)
}
// newCliWithTransporter is like NewBicepCli but allows providing a custom transport to use when downloading the
// Bicep CLI, for testing purposes.
func newCliWithTransporter(
ctx context.Context,
console input.Console,
commandRunner exec.CommandRunner,
transporter policy.Transporter,
) (*Cli, error) {
if override := os.Getenv("AZD_BICEP_TOOL_PATH"); override != "" {
log.Printf("using external bicep tool: %s", override)
return &Cli{
path: override,
runner: commandRunner,
}, nil
}
bicepPath, err := azdBicepPath()
if err != nil {
return nil, fmt.Errorf("finding bicep: %w", err)
}
if _, err = os.Stat(bicepPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("finding bicep: %w", err)
}
if errors.Is(err, os.ErrNotExist) {
if err := os.MkdirAll(filepath.Dir(bicepPath), osutil.PermissionDirectory); err != nil {
return nil, fmt.Errorf("downloading bicep: %w", err)
}
if err := runStep(
ctx, console, "Downloading Bicep", func() error {
return downloadBicep(ctx, transporter, Version, bicepPath)
},
); err != nil {
return nil, fmt.Errorf("downloading bicep: %w", err)
}
}
cli := &Cli{
path: bicepPath,
runner: commandRunner,
}
ver, err := cli.version(ctx)
if err != nil {
return nil, fmt.Errorf("checking bicep version: %w", err)
}
log.Printf("bicep version: %s", ver)
if ver.LT(Version) {
log.Printf("installed bicep version %s is older than %s; updating.", ver.String(), Version.String())
if err := runStep(
ctx, console, "Upgrading Bicep", func() error {
return downloadBicep(ctx, transporter, Version, bicepPath)
},
); err != nil {
return nil, fmt.Errorf("upgrading bicep: %w", err)
}
}
log.Printf("using local bicep: %s", bicepPath)
return cli, nil
}
// runStep runs a long running operation, using the console to show a spinner for progress and status.
func runStep(ctx context.Context, console input.Console, title string, action func() error) error {
console.ShowSpinner(ctx, title, input.Step)
err := action()
if err != nil {
console.StopSpinner(ctx, title, input.StepFailed)
return err
}
console.StopSpinner(ctx, title, input.StepDone)
return nil
}
type Cli struct {
path string
runner exec.CommandRunner
}
// azdBicepPath returns the path where we store our local copy of bicep ($AZD_CONFIG_DIR/bin).
func azdBicepPath() (string, error) {
configDir, err := config.GetUserConfigDir()
if err != nil {
return "", err
}
if runtime.GOOS == "windows" {
return filepath.Join(configDir, "bin", "bicep.exe"), nil
}
return filepath.Join(configDir, "bin", "bicep"), nil
}
// downloadBicep downloads a given version of bicep from the release site, writing the output to name.
func downloadBicep(ctx context.Context, transporter policy.Transporter, bicepVersion semver.Version, name string) error {
var arch string
switch runtime.GOARCH {
case "amd64":
arch = "x64"
case "arm64":
arch = "arm64"
default:
return fmt.Errorf("unsupported architecture: %s", runtime.GOARCH)
}
var releaseName string
switch runtime.GOOS {
case "windows":
releaseName = fmt.Sprintf("bicep-win-%s.exe", arch)
case "darwin":
releaseName = fmt.Sprintf("bicep-osx-%s", arch)
case "linux":
if preferMuslBicep(os.Stat) {
if runtime.GOARCH != "arm64" {
return fmt.Errorf("unsupported architecture: %s", runtime.GOARCH)
}
releaseName = "bicep-linux-musl-x64"
} else {
releaseName = fmt.Sprintf("bicep-linux-%s", arch)
}
default:
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
}
bicepReleaseUrl := fmt.Sprintf("https://downloads.bicep.azure.com/v%s/%s", bicepVersion, releaseName)
log.Printf("downloading bicep release %s -> %s", bicepReleaseUrl, name)
req, err := http.NewRequestWithContext(ctx, "GET", bicepReleaseUrl, nil)
if err != nil {
return err
}
resp, err := transporter.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("http error %d", resp.StatusCode)
}
f, err := os.CreateTemp(filepath.Dir(name), fmt.Sprintf("%s.tmp*", filepath.Base(name)))
if err != nil {
return err
}
defer func() {
_ = f.Close()
_ = os.Remove(f.Name())
}()
if _, err := io.Copy(f, resp.Body); err != nil {
return err
}
if err := f.Chmod(osutil.PermissionExecutableFile); err != nil {
return err
}
if err := f.Close(); err != nil {
return err
}
if err := osutil.Rename(ctx, f.Name(), name); err != nil {
return err
}
return nil
}
type stater func(name string) (os.FileInfo, error)
// preferMuslBicep determines if we should install the version of bicep that used musl instead of glibc. We prefer
// musl bicep on linux systems that have musl installed and do not have glibc installed. If both musl and glibc are
// installed, we prefer the glibc based version. This behavior matches the `az` CLI (see: Azure/azure-cli#23040)
func preferMuslBicep(stat stater) bool {
if _, err := stat("/lib/ld-musl-x86_64.so.1"); err == nil {
if _, err := stat("/lib/x86_64-linux-gnu/libc.so.6"); err == nil {
return false
}
return true
}
return false
}
func (cli *Cli) version(ctx context.Context) (semver.Version, error) {
bicepRes, err := cli.runCommand(ctx, nil, "--version")
if err != nil {
return semver.Version{}, err
}
bicepSemver, err := tools.ExtractVersion(bicepRes.Stdout)
if err != nil {
return semver.Version{}, err
}
return bicepSemver, nil
}
type BuildResult struct {
// The compiled ARM template
Compiled string
// Lint error message, if any
LintErr string
}
func (cli *Cli) Build(ctx context.Context, file string) (BuildResult, error) {
args := []string{"build", file, "--stdout"}
buildRes, err := cli.runCommand(ctx, nil, args...)
if err != nil {
return BuildResult{}, fmt.Errorf(
"failed running bicep build: %w",
err,
)
}
return BuildResult{
Compiled: buildRes.Stdout,
LintErr: buildRes.Stderr,
}, nil
}
func (cli *Cli) BuildBicepParam(ctx context.Context, file string, env []string) (BuildResult, error) {
args := []string{"build-params", file, "--stdout"}
buildRes, err := cli.runCommand(ctx, env, args...)
if err != nil {
return BuildResult{}, fmt.Errorf(
"failed running bicep build: %w",
err,
)
}
return BuildResult{
Compiled: buildRes.Stdout,
LintErr: buildRes.Stderr,
}, nil
}
func (cli *Cli) runCommand(ctx context.Context, env []string, args ...string) (exec.RunResult, error) {
runArgs := exec.NewRunArgs(cli.path, args...)
if env != nil {
runArgs = runArgs.WithEnv(env)
}
return cli.runner.Run(ctx, runArgs)
}