cli/azd/pkg/apphost/generate.go (1,584 lines of code) (raw):
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package apphost
import (
"bytes"
"context"
"encoding/json"
"fmt"
"hash/fnv"
"io/fs"
"log"
"maps"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"text/template"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
"github.com/azure/azure-dev/cli/azd/internal/scaffold"
"github.com/azure/azure-dev/cli/azd/pkg/alpha"
"github.com/azure/azure-dev/cli/azd/pkg/azure"
"github.com/azure/azure-dev/cli/azd/pkg/convert"
"github.com/azure/azure-dev/cli/azd/pkg/custommaps"
"github.com/azure/azure-dev/cli/azd/pkg/environment"
"github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning"
"github.com/azure/azure-dev/cli/azd/pkg/osutil"
"github.com/azure/azure-dev/cli/azd/pkg/output"
"github.com/azure/azure-dev/cli/azd/resources"
"github.com/braydonk/yaml"
"github.com/psanford/memfs"
)
const RedisContainerAppService = "redis"
const DaprStateStoreComponentType = "state"
const DaprPubSubComponentType = "pubsub"
// genTemplates is the collection of templates that are used when generating infrastructure files from a manifest.
var genTemplates *template.Template
type AspireDashboard struct {
Link string
}
func (aspireD *AspireDashboard) ToString(currentIndentation string) string {
return fmt.Sprintf("%sAspire Dashboard: %s", currentIndentation, output.WithLinkFormat(aspireD.Link))
}
func (aspireD *AspireDashboard) MarshalJSON() ([]byte, error) {
return json.Marshal(*aspireD)
}
func AspireDashboardUrl(
ctx context.Context,
env *environment.Environment,
alphaFeatureManager *alpha.FeatureManager) *AspireDashboard {
ContainersManagedEnvHost, exists := env.LookupEnv("AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN")
if !exists {
return nil
}
return &AspireDashboard{
Link: fmt.Sprintf("https://aspire-dashboard.ext.%s", ContainersManagedEnvHost),
}
}
func init() {
tmpl, err := template.New("templates").
Option("missingkey=error").
Funcs(
template.FuncMap{
"toLower": strings.ToLower,
"bicepName": scaffold.BicepName,
"mergeBicepName": func(src ...string) string {
return scaffold.BicepName(strings.Join(src, "-"))
},
"alphaSnakeUpper": scaffold.AlphaSnakeUpper,
"containerAppName": scaffold.ContainerAppName,
"containerAppSecretName": scaffold.ContainerAppSecretName,
"fixBackSlash": func(src string) string {
return strings.ReplaceAll(src, "\\", "/")
},
"bicepParameterName": func(src string) string {
return strings.ReplaceAll(src, "-", "_")
},
"removeDot": scaffold.RemoveDotAndDash,
"envFormat": scaffold.EnvFormat,
"bicepParameterValue": func(value *string) string {
if value == nil {
return ""
}
return fmt.Sprintf(" = '%s'", *value)
},
},
).
ParseFS(resources.AppHostTemplates, "apphost/templates/*")
if err != nil {
panic("failed to parse generator templates: " + err.Error())
}
genTemplates = tmpl
}
type ContentsAndMode struct {
Contents string
Mode fs.FileMode
}
// ProjectPaths returns a map of project names to their paths.
func ProjectPaths(manifest *Manifest) map[string]string {
res := make(map[string]string)
for name, comp := range manifest.Resources {
switch comp.Type {
case "project.v0", "project.v1":
res[name] = *comp.Path
}
}
return res
}
// Dockerfiles returns information about all dockerfile.v0 resources from a manifest.
func Dockerfiles(manifest *Manifest) map[string]genDockerfile {
res := make(map[string]genDockerfile)
for name, comp := range manifest.Resources {
switch comp.Type {
case "dockerfile.v0":
res[name] = genDockerfile{
Path: *comp.Path,
Context: *comp.Context,
Env: comp.Env,
Bindings: comp.Bindings,
BuildArgs: comp.BuildArgs,
Args: comp.Args,
}
}
}
return res
}
// Containers returns information about all container.v0 resources from a manifest.
func Containers(manifest *Manifest) map[string]genContainer {
res := make(map[string]genContainer)
for name, comp := range manifest.Resources {
switch comp.Type {
case "container.v0":
res[name] = genContainer{
Image: *comp.Image,
Env: comp.Env,
Bindings: comp.Bindings,
Inputs: comp.Inputs,
Volumes: comp.Volumes,
BindMounts: comp.BindMounts,
Args: comp.Args,
}
}
}
return res
}
// BuildContainers returns information about all container.v1 resources from a manifest.
func BuildContainers(manifest *Manifest) (map[string]genBuildContainer, error) {
res := make(map[string]genBuildContainer)
for name, comp := range manifest.Resources {
switch comp.Type {
case "container.v1":
bc, err := buildContainerFromResource(comp)
if err != nil {
return nil, fmt.Errorf("building container from resource %s: %w", name, err)
}
res[name] = *bc
}
}
return res, nil
}
type AppHostOptions struct {
AzdOperations bool
}
type ContainerAppManifestType string
const (
ContainerAppManifestTypeYAML ContainerAppManifestType = "yaml"
ContainerAppManifestTypeBicep ContainerAppManifestType = "bicep"
)
func ContainerSourceBicepContent(
manifest *Manifest, projectName string, options AppHostOptions) (string, error) {
templateFs, err := BicepTemplate(projectName, manifest, options)
if err != nil {
return "", err
}
sourceName := filepath.Base(*manifest.Resources[projectName].Deployment.Path)
file, err := templateFs.Open(filepath.Join(projectName, sourceName))
if err != nil {
return "", fmt.Errorf("opening bicep source file: %w", err)
}
defer file.Close()
// read the file content into a string
buf := new(bytes.Buffer)
if _, err := buf.ReadFrom(file); err != nil {
return "", fmt.Errorf("reading bicep source file: %w", err)
}
return buf.String(), nil
}
// ContainerAppManifestTemplateForProject returns the container app manifest template for a given project.
// It can be used (after evaluation) to deploy the service to a container app environment.
// When the projectName contains `Deployment` it will generate a bicepparam template instead of the yaml template.
func ContainerAppManifestTemplateForProject(
manifest *Manifest, projectName string, options AppHostOptions) (string, ContainerAppManifestType, error) {
generator := newInfraGenerator()
if err := generator.LoadManifest(manifest); err != nil {
return "", "", err
}
if err := generator.Compile(); err != nil {
return "", "", err
}
var buf bytes.Buffer
type yamlTemplateCtx struct {
genContainerAppManifestTemplateContext
TargetPortExpression string
}
tCtx := generator.containerAppTemplateContexts[projectName]
tmplCtx := yamlTemplateCtx{
genContainerAppManifestTemplateContext: tCtx,
}
if tCtx.Ingress != nil {
if tCtx.Ingress.TargetPort != 0 && !tCtx.Ingress.UsingDefaultPort {
// not using default port makes this to be a non-changing value
tmplCtx.TargetPortExpression = fmt.Sprintf("%d", tCtx.Ingress.TargetPort)
} else {
tmplCtx.TargetPortExpression = fmt.Sprintf("{{ targetPortOrDefault %d }}", tCtx.Ingress.TargetPort)
}
}
// replace the containerPort with the targetPort expression
for p, v := range tmplCtx.DeployParams {
if v == "'{{ containerPort }}'" {
tmplCtx.DeployParams[p] = fmt.Sprintf("'%s'", tmplCtx.TargetPortExpression)
}
}
var manifestType ContainerAppManifestType
if len(tCtx.DeployParams) == 0 {
manifestType = ContainerAppManifestTypeYAML
err := genTemplates.ExecuteTemplate(&buf, "containerApp.tmpl.yaml", tmplCtx)
if err != nil {
return "", "", fmt.Errorf("executing template: %w", err)
}
} else {
manifestType = ContainerAppManifestTypeBicep
err := genTemplates.ExecuteTemplate(&buf, "containerApp.tmpl.bicepparam", tmplCtx)
if err != nil {
return "", "", fmt.Errorf("executing bicepparam template: %w", err)
}
}
return buf.String(), manifestType, nil
}
// BicepTemplate returns a filesystem containing the generated bicep files for the given manifest. These files represent
// the shared infrastructure that would normally be under the `infra/` folder for the given manifest.
func BicepTemplate(name string, manifest *Manifest, options AppHostOptions) (*memfs.FS, error) {
generator := newInfraGenerator()
if err := generator.LoadManifest(manifest); err != nil {
return nil, err
}
if err := generator.Compile(); err != nil {
return nil, err
}
// Aspire Dashboard workaround
// By setting this, we will give Contributor role to the user running azd for the Container Apps Environment
// See: https://github.com/Azure/azure-dev/issues/3928
generator.bicepContext.RequiresPrincipalId = true
// use the filesystem coming from the manifest
// the in-memory filesystem from the manifest is guaranteed to be initialized and contains all the bicep files
// referenced by the Aspire manifest.
fs := manifest.BicepFiles
// bicepContext merges the bicepContext with the inputs from the manifest to execute the main.bicep template
// this allows the template to access the auto-gen inputs from the generator
type genInput struct {
Name string
Secret bool
Type string
Value *string
}
type autoGenInput struct {
genInput
MetadataConfig string
MetadataType azure.AzdMetadataType
}
type bicepContext struct {
genBicepTemplateContext
WithMetadataParameters []autoGenInput
MainToResourcesParams []genInput
// when true, azd migrates to AppHost base-compute infrastructure and does not generate resources.bicep with
// ManagedIdentity, ACE, ACA, etc.
AppHostInfraMigration bool
}
var parameters []autoGenInput
var mapToResourceParams []genInput
// order to be deterministic when writing bicep
genParametersKeys := slices.Sorted(maps.Keys(generator.bicepContext.InputParameters))
metadataType := azure.AzdMetadataTypeGenerate
for _, key := range genParametersKeys {
parameter := generator.bicepContext.InputParameters[key]
parameterMetadata := ""
var parameterDefaultValue *string
if parameter.Default != nil {
// main.bicep template handles *string for default.Value. If the value is nil, it will be ignored.
// if not nil, like empty string or any other string, it is used as `= '<value>'`
if parameter.Default.Value != nil {
parameterDefaultValue = parameter.Default.Value
metadataType = azure.AzdMetadataTypeNeedForDeploy
parameterMetadata = "{}"
} else if parameter.Default.Generate != nil { // Note: .Value and .Generate are mutually exclusive
pMetadata, err := inputMetadata(*parameter.Default.Generate)
if err != nil {
return nil, fmt.Errorf("generating input metadata for %s: %w", key, err)
}
parameterMetadata = pMetadata
}
// Note: azd is not checking or validating that Default.Generate and Default.Value are not both set.
// The AppHost prevents this from happening by not allowing both to be set at the same time.
}
if parameter.scope != nil {
metadataType = azure.AzdMetadataTypeResourceGroup
parameterMetadata = "{}"
}
input := genInput{Name: key, Secret: parameter.Secret, Type: parameter.Type, Value: parameterDefaultValue}
parameters = append(parameters, autoGenInput{
genInput: input,
MetadataConfig: parameterMetadata,
MetadataType: metadataType})
if slices.Contains(generator.bicepContext.mappedParameters, strings.ReplaceAll(key, "-", "_")) {
mapToResourceParams = append(mapToResourceParams, input)
}
}
context := bicepContext{
genBicepTemplateContext: generator.bicepContext,
WithMetadataParameters: parameters,
MainToResourcesParams: mapToResourceParams,
AppHostInfraMigration: generator.options.appHostOwnsCompute,
}
if err := executeToFS(fs, genTemplates, "main.bicep", name+".bicep", context); err != nil {
return nil, fmt.Errorf("generating infra/main.bicep: %w", err)
}
if !generator.options.appHostOwnsCompute {
if err := executeToFS(fs, genTemplates, "resources.bicep", "resources.bicep", context); err != nil {
return nil, fmt.Errorf("generating infra/resources.bicep: %w", err)
}
}
if err := executeToFS(
fs, genTemplates, "main.parameters.json", name+".parameters.json", generator.bicepContext); err != nil {
return nil, fmt.Errorf("generating infra/resources.bicep: %w", err)
}
// azd operations
if generator.bicepContext.HasBindMounts {
if options.AzdOperations {
if err := executeToFS(
fs, genTemplates, "azd.operations.yaml", "azd.operations.yaml", generator.bicepContext); err != nil {
return nil, fmt.Errorf("generating infra/azd.operations.yaml: %w", err)
}
} else {
// returning fs because this error can be handled by the caller as expected
return fs, provisioning.ErrBindMountOperationDisabled
}
}
return fs, nil
}
func inputMetadata(config InputDefaultGenerate) (string, error) {
finalLength := convert.ToValueWithDefault(config.MinLength, 0)
clusterLength := convert.ToValueWithDefault(config.MinLower, 0) +
convert.ToValueWithDefault(config.MinUpper, 0) +
convert.ToValueWithDefault(config.MinNumeric, 0) +
convert.ToValueWithDefault(config.MinSpecial, 0)
if clusterLength > finalLength {
finalLength = clusterLength
}
adaptBool := func(b *bool) *bool {
if b == nil {
return b
}
return to.Ptr(!*b)
}
metadataModel := azure.AutoGenInput{
Length: finalLength,
MinLower: config.MinLower,
MinUpper: config.MinUpper,
MinNumeric: config.MinNumeric,
MinSpecial: config.MinSpecial,
NoLower: adaptBool(config.Lower),
NoUpper: adaptBool(config.Upper),
NoNumeric: adaptBool(config.Numeric),
NoSpecial: adaptBool(config.Special),
}
metadataBytes, err := json.Marshal(metadataModel)
if err != nil {
return "", fmt.Errorf("marshalling metadata: %w", err)
}
// key identifiers for objects on bicep don't need quotes, unless they have special characters, like `-` or `.`.
// jsonSimpleKeyRegex is used to remove the quotes from the key if no needed to avoid a bicep lint warning.
return jsonSimpleKeyRegex.ReplaceAllString(string(metadataBytes), "${1}:"), nil
}
// GenerateProjectArtifacts generates all the artifacts to manage a project with `azd`. The azure.yaml file as well as
// a helpful next-steps.md file.
func GenerateProjectArtifacts(
ctx context.Context,
projectDir string,
projectName string,
manifest *Manifest,
appHostProject string,
) (map[string]ContentsAndMode, error) {
appHostRel, err := filepath.Rel(projectDir, appHostProject)
if err != nil {
return nil, err
}
generatedFS := memfs.New()
projectFileContext := genProjectFileContext{
Name: projectName,
Services: map[string]string{
"app": fmt.Sprintf("./%s", filepath.ToSlash(appHostRel)),
},
}
if err := executeToFS(generatedFS, genTemplates, "azure.yaml", "azure.yaml", projectFileContext); err != nil {
return nil, fmt.Errorf("generating azure.yaml: %w", err)
}
if err := executeToFS(generatedFS, genTemplates, "next-steps.md", "next-steps.md", nil); err != nil {
return nil, fmt.Errorf("generating next-steps.md: %w", err)
}
files := make(map[string]ContentsAndMode)
err = fs.WalkDir(generatedFS, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
contents, err := fs.ReadFile(generatedFS, path)
if err != nil {
return err
}
info, err := d.Info()
if err != nil {
return err
}
files[path] = ContentsAndMode{
Contents: string(contents),
Mode: info.Mode(),
}
return nil
})
if err != nil {
return nil, err
}
return files, nil
}
type infraGenerator struct {
dapr map[string]genDapr
projects map[string]genProject
connectionStrings map[string]string
// keeps the value from value.v0 resources if provided.
valueStrings map[string]string
resourceTypes map[string]string
bicepContext genBicepTemplateContext
containerAppTemplateContexts map[string]genContainerAppManifestTemplateContext
allServicesIngress map[string]ingressDetails
// works for container.v0, container.v1 and dockerfile.v0
buildContainers map[string]genBuildContainer
options infraGeneratorOptions
}
type infraGeneratorOptions struct {
// When true, generator will include a bicep module to create a container app environment and identity.
appHostOwnsCompute bool
}
func newInfraGenerator() *infraGenerator {
return &infraGenerator{
bicepContext: genBicepTemplateContext{
ContainerAppEnvironmentServices: make(map[string]genContainerAppEnvironmentServices),
KeyVaults: make(map[string]genKeyVault),
ContainerApps: make(map[string]genContainerApp),
DaprComponents: make(map[string]genDaprComponent),
InputParameters: make(map[string]Input),
BicepModules: make(map[string]genBicepModules),
OutputParameters: make(map[string]genOutputParameter),
OutputSecretParameters: make(map[string]genOutputParameter),
},
dapr: make(map[string]genDapr),
projects: make(map[string]genProject),
connectionStrings: make(map[string]string),
resourceTypes: make(map[string]string),
containerAppTemplateContexts: make(map[string]genContainerAppManifestTemplateContext),
buildContainers: make(map[string]genBuildContainer),
}
}
// withOutputsExpRegex is a regular expression used to match expressions in the format "{<resource>.outputs.<outputName>}" or
// "{<resource>.secretOutputs.<outputName>}".
var withOutputsExpRegex = regexp.MustCompile(`\{[a-zA-Z0-9\-]+\.(outputs|secretOutputs)\.[a-zA-Z0-9\-\_]+\}`)
// evaluateForOutputs is a function that evaluates a given value and extracts output parameters from it.
// It searches for patterns in the form of "{<resource>.outputs.<outputName>}" or "{<resource>.secretOutputs.<outputName>}"
// and creates a map of output parameters with their corresponding values.
// The output parameter names are generated by concatenating the uppercase versions of the resource and output names,
// separated by an underscore.
// The function returns the map of output parameters and an error, if any.
func evaluateForOutputs(value string, appHostOwnsCompute bool) (map[string]genOutputParameter, error) {
outputs := make(map[string]genOutputParameter)
matches := withOutputsExpRegex.FindAllString(value, -1)
for _, match := range matches {
noBrackets := strings.TrimRight(strings.TrimLeft(match, "{"), "}")
parts := strings.Split(noBrackets, ".")
resourceName, outputName := parts[0], parts[2]
// On migration mode, the name of the container registry endpoint can be defined from any bicep.v0 resource
// typically from a resources.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT
// If the AZURE_CONTAINER_REGISTRY_ENDPOINT is spotted, it is promoted as the output to sync with the ACR created
// by the appHost.
// AZD has currently no support for handling multiple ACR endpoints.
if strings.Contains(outputName, environment.ContainerRegistryEndpointEnvVarName) && appHostOwnsCompute {
outputs[environment.ContainerRegistryEndpointEnvVarName] = genOutputParameter{
Type: "string",
Value: noBrackets,
}
}
// Same for AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN as it is required to display the Aspire Dashboard link
// at the end of the deployment.
if strings.Contains(outputName, environment.ContainerEnvironmentEndpointEnvVarName) && appHostOwnsCompute {
outputs[environment.ContainerEnvironmentEndpointEnvVarName] = genOutputParameter{
Type: "string",
Value: noBrackets,
}
}
name := fmt.Sprintf("%s_%s", strings.ToUpper(resourceName), strings.ToUpper(outputName))
outputs[name] = genOutputParameter{
Type: "string",
Value: noBrackets,
}
}
return outputs, nil
}
// extractOutputs evaluates the known fields where a Resource can reference an output and persist it.
func (b *infraGenerator) extractOutputs(resource *Resource) error {
// from connection string
if resource.ConnectionString != nil {
outputs, err := evaluateForOutputs(*resource.ConnectionString, b.options.appHostOwnsCompute)
if err != nil {
return err
}
for key, output := range outputs {
if strings.Contains(output.Value, ".outputs.") {
b.bicepContext.OutputParameters[key] = output
} else {
b.bicepContext.OutputSecretParameters[key] = output
}
}
}
feedFrom := func(from map[string]string, to *genBicepTemplateContext) error {
for _, value := range from {
outputs, err := evaluateForOutputs(value, b.options.appHostOwnsCompute)
if err != nil {
return err
}
for key, output := range outputs {
if strings.Contains(output.Value, ".outputs.") {
to.OutputParameters[key] = output
} else {
to.OutputSecretParameters[key] = output
}
}
}
return nil
}
err := feedFrom(resource.Env, &b.bicepContext)
if err != nil {
return err
}
if resource.Deployment != nil {
// Taking only the string values from the deployment parameters. There could be other types like int or object there.
// Only string type could be referencing outputs.
deploymentParams := map[string]string{}
for k, v := range resource.Deployment.Params {
stringValue, castOk := v.(string)
if !castOk {
continue
}
deploymentParams[k] = stringValue
}
err = feedFrom(deploymentParams, &b.bicepContext)
if err != nil {
return err
}
}
return nil
}
// LoadManifest loads the given manifest into the generator. It should be called before [Compile].
func (b *infraGenerator) LoadManifest(m *Manifest) error {
// initCompilerOptions defines the way the infraGenerator loads and compiles the manifest.
if err := b.initCompilerOptions(m); err != nil {
return fmt.Errorf("initializing compiler options: %w", err)
}
for name, comp := range m.Resources {
if err := b.extractOutputs(comp); err != nil {
return fmt.Errorf("extracting outputs: %w", err)
}
b.resourceTypes[name] = comp.Type
if comp.ConnectionString != nil {
b.connectionStrings[name] = *comp.ConnectionString
}
switch comp.Type {
case "project.v0":
b.addProject(name, *comp.Path, comp.Env, comp.Bindings, comp.Args, nil, "")
case "project.v1":
var deploymentParams map[string]any
var deploymentSource string
if comp.Deployment != nil {
deploymentParams = comp.Deployment.Params
deploymentSource = filepath.Base(*comp.Deployment.Path)
}
b.addProject(name, *comp.Path, comp.Env, comp.Bindings, comp.Args, deploymentParams, deploymentSource)
case "container.v0":
err := b.addBuildContainer(name, comp)
if err != nil {
return err
}
case "dapr.v0":
err := b.addDapr(name, comp.Dapr)
if err != nil {
return err
}
case "dapr.component.v0":
err := b.addDaprComponent(name, comp.DaprComponent)
if err != nil {
return err
}
case "container.v1":
err := b.addBuildContainer(name, comp)
if err != nil {
return err
}
case "dockerfile.v0":
err := b.addBuildContainer(name, comp)
if err != nil {
return err
}
case "parameter.v0":
if err := b.addInputParameter(name, comp); err != nil {
return fmt.Errorf("adding bicep parameter from resource %s (%s): %w", name, comp.Type, err)
}
case "value.v0":
if comp.Value != "" && !hasInputs(comp.Value) {
// a value.v0 resource with value not referencing inputs doesn't need any further processing
b.valueStrings[name] = comp.Value
continue
}
if err := b.addInputParameter(name, comp); err != nil {
return fmt.Errorf("adding bicep parameter from resource %s (%s): %w", name, comp.Type, err)
}
case "azure.bicep.v0", "azure.bicep.v1":
if err := b.addBicep(name, comp); err != nil {
return fmt.Errorf("adding bicep resource %s: %w", name, err)
}
default:
ignore, err := strconv.ParseBool(os.Getenv("AZD_DEBUG_DOTNET_APPHOST_IGNORE_UNSUPPORTED_RESOURCES"))
if err == nil && ignore {
log.Printf(
"ignoring resource of type %s since AZD_DEBUG_DOTNET_APPHOST_IGNORE_UNSUPPORTED_RESOURCES is set",
comp.Type)
continue
}
return fmt.Errorf("unsupported resource type: %s", comp.Type)
}
}
return nil
}
func (b *infraGenerator) requireCluster() {
b.requireLogAnalyticsWorkspace()
b.bicepContext.HasContainerEnvironment = true
}
func (b *infraGenerator) requireContainerRegistry() {
b.bicepContext.HasContainerRegistry = true
}
func (b *infraGenerator) requireDaprStore() string {
daprStoreName := "daprStore"
if !b.bicepContext.HasDaprStore {
b.requireCluster()
// A single store can be shared across all Dapr components, so we only need to create one.
b.addContainerAppService(daprStoreName, RedisContainerAppService)
b.bicepContext.HasDaprStore = true
}
return daprStoreName
}
func (b *infraGenerator) requireLogAnalyticsWorkspace() {
b.bicepContext.HasLogAnalyticsWorkspace = true
}
func (b *infraGenerator) requireStorageVolume() {
b.bicepContext.RequiresStorageVolume = true
}
func (b *infraGenerator) hasBindMounts() {
b.bicepContext.HasBindMounts = true
}
func (b *infraGenerator) addInputParameter(name string, comp *Resource) error {
input, err := InputParameter(name, comp)
if err != nil {
return fmt.Errorf("resolving input for parameter %s: %w", name, err)
}
if input == nil {
// no inputs in the value, nothing to do
return nil
}
b.bicepContext.InputParameters[name] = *input
return nil
}
// InputParameter gets the Input from a parameter. If the parameter does not have an input, it returns nil.
func InputParameter(name string, comp *Resource) (*Input, error) {
pValue := comp.Value
if !hasInputs(pValue) {
// no inputs in the value, nothing to do
return nil, nil
}
input, err := resolveResourceInput(name, comp)
if err != nil {
return nil, fmt.Errorf("resolving input for parameter %s: %w", name, err)
}
return &input, nil
}
func hasInputs(value string) bool {
matched, _ := regexp.MatchString(`{[a-zA-Z][a-zA-Z0-9\-]*\.inputs\.[a-zA-Z][a-zA-Z0-9\-]*}`, value)
return matched
}
func resolveResourceInput(fromResource string, comp *Resource) (Input, error) {
value := comp.Value
valueParts := strings.Split(
strings.TrimRight(strings.TrimLeft(value, "{"), "}"),
".inputs.")
// regex from above ensure parts 0 and 1 exists
resourceName, inputName := valueParts[0], valueParts[1]
if fromResource != resourceName {
return Input{}, fmt.Errorf(
"parameter %s does not use inputs from its own resource. This is not supported", fromResource)
}
input, exists := comp.Inputs[inputName]
if !exists {
return Input{}, fmt.Errorf("parameter %s does not have input %s", fromResource, inputName)
}
if input.Type == "" {
input.Type = "string"
}
return input, nil
}
func (b *infraGenerator) addBicep(name string, comp *Resource) error {
if comp.Path == nil {
if comp.Parent == nil {
return fmt.Errorf("bicep resource %s does not have a path or a parent", name)
}
// module uses parent
return nil
}
if comp.Params == nil {
comp.Params = make(map[string]any)
}
// afterInjectionParams is used to know which params where actually injected
autoInjectedParams := make(map[string]any)
// params from resource are type-less (any), injectValueForBicepParameter() will convert them to string
// by converting to string, we can evaluate arrays and objects with placeholders.
stringParams := make(map[string]string)
for p, pVal := range comp.Params {
paramValue, injected, err := injectValueForBicepParameter(name, p, pVal, b.options.appHostOwnsCompute)
if err != nil {
return fmt.Errorf("injecting value for bicep parameter %s: %w", p, err)
}
stringParams[p] = paramValue
if injected {
autoInjectedParams[p] = struct{}{}
}
}
if _, keyVaultInjected := autoInjectedParams[knownParameterKeyVault]; keyVaultInjected {
b.addKeyVault("kv"+uniqueFnvNumber(name), true, true)
}
if _, hasLocation := stringParams["location"]; !hasLocation {
// if location is not provided, add it as a link to location parameter
stringParams["location"] = "location"
}
bicepScope := defaultBicepModuleScope
if comp.Scope != nil {
if comp.Scope.ResourceGroup == nil {
return fmt.Errorf("bicep resource %s has a scope without a resource group", name)
}
paramValue, _, err := injectValueForBicepParameter(
name, "scope", *comp.Scope.ResourceGroup, b.options.appHostOwnsCompute)
if err != nil {
return err
}
bicepScope = paramValue
}
b.bicepContext.BicepModules[name] = genBicepModules{Path: *comp.Path, Params: stringParams, Scope: bicepScope}
return nil
}
const (
knownParameterKeyVault string = "keyVaultName"
knownParameterPrincipalId string = "principalId"
knownParameterUserPrincipalId string = "userPrincipalId"
knownParameterPrincipalType string = "principalType"
knownParameterPrincipalName string = "principalName"
knownParameterLogAnalytics string = "logAnalyticsWorkspaceId"
knownParameterContainerEnvName string = "containerAppEnvironmentName"
knownParameterContainerEnvId string = "containerAppEnvironmentId"
knownInjectedValuePrincipalId string = "resources.outputs.MANAGED_IDENTITY_PRINCIPAL_ID"
knownInjectedValuePrincipalType string = "'ServicePrincipal'"
knownInjectedValuePrincipalName string = "resources.outputs.MANAGED_IDENTITY_NAME"
knownInjectedValueLogAnalytics string = "resources.outputs.AZURE_LOG_ANALYTICS_WORKSPACE_ID"
knownInjectedValueContainerEnvName string = "resources.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_NAME"
knownInjectedValueContainerEnvId string = "resources.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID"
defaultBicepModuleScope string = "rg"
)
// injectValueForBicepParameter checks for aspire-manifest and azd conventions rules for auto injecting values for
// the bicep.v0 parameters.
// Conventions examples:
// - for a `keyVaultName` parameter, set the value to the output of the keyVault resource to be created.
// - for `principalName`, set the value to the managed identity created by azd.
// Note: The value is only injected when it is an empty string.
// injectValueForBicepParameter returns the final value for the parameter, a boolean indicating if the value was injected
// and an error if any.
func injectValueForBicepParameter(resourceName, p string, parameter any, appHostOwnsCompute bool) (string, bool, error) {
// using json.Marshal to parse any type of value (array, bool, etc)
jsonBytes, err := json.Marshal(parameter)
if err != nil {
return "", false, fmt.Errorf("marshalling param %s. error: %w", p, err)
}
finalParamValue := string(jsonBytes)
emptyJsonString := "\"\""
if finalParamValue != emptyJsonString {
// injection not required
return finalParamValue, false, nil
}
// disable injection for app host migration
if appHostOwnsCompute {
if p == knownParameterUserPrincipalId {
return "principalId", true, nil
}
return finalParamValue, false, nil
}
if p == knownParameterKeyVault {
uniqueName := "kv" + uniqueFnvNumber(resourceName)
return fmt.Sprintf("resources.outputs.SERVICE_BINDING_%s_NAME", strings.ToUpper(uniqueName)), true, nil
}
if p == knownParameterPrincipalId {
return knownInjectedValuePrincipalId, true, nil
}
if p == knownParameterPrincipalType {
return knownInjectedValuePrincipalType, true, nil
}
if p == knownParameterPrincipalName {
return knownInjectedValuePrincipalName, true, nil
}
if p == knownParameterLogAnalytics {
return knownInjectedValueLogAnalytics, true, nil
}
if p == knownParameterContainerEnvName {
return knownInjectedValueContainerEnvName, true, nil
}
if p == knownParameterContainerEnvId {
return knownInjectedValueContainerEnvId, true, nil
}
return finalParamValue, false, nil
}
// uniqueFnvNumber generates a unique FNV hash number for the given string value.
// It uses the FNV-1a hash algorithm to calculate a 32-bit hash value.
// The generated 32-bit hash number is returned as an 8-length hexadecimal string.
func uniqueFnvNumber(val string) string {
hash := fnv.New32a()
// #nosec G104 // FNV-1a hash is not cryptographically secure, but it is suitable for generating a unique hash.
hash.Write([]byte(val))
return fmt.Sprintf("%x", hash.Sum32())
}
func (b *infraGenerator) addProject(
name string,
path string,
env map[string]string,
bindings custommaps.WithOrder[Binding],
args []string,
deploymentParams map[string]any,
deploymentSource string,
) {
b.requireCluster()
b.requireContainerRegistry()
b.projects[name] = genProject{
Path: path,
Env: env,
Bindings: bindings,
Args: args,
DeploymentParams: deploymentParams,
DeploymentSource: deploymentSource,
}
}
func (b *infraGenerator) addContainerAppService(name string, serviceType string) {
b.requireCluster()
b.bicepContext.ContainerAppEnvironmentServices[name] = genContainerAppEnvironmentServices{
Type: serviceType,
}
}
func (b *infraGenerator) addKeyVault(name string, noTags, readAccessPrincipalId bool) {
b.bicepContext.KeyVaults[name] = genKeyVault{
NoTags: noTags,
ReadAccessPrincipalId: readAccessPrincipalId,
}
}
// buildContainer represents a container defined with a pre-build image or a build context.
// container.v0 resources are used to define containers with pre-built images.
// - uses image field
//
// dockerfile.v0 resources are used to define containers with build context.
// - uses path and context fields
//
// container.v1 resources are used to define containers with either build context or pre-built images.
// - uses image field or build field
func (b *infraGenerator) addBuildContainer(
name string,
r *Resource) error {
if r.Image != nil && r.Build != nil {
return fmt.Errorf("Resource '%s' cannot have both an image and a build", name)
}
b.requireCluster()
if len(r.Volumes) > 0 {
b.requireStorageVolume()
}
if len(r.BindMounts) > 0 {
b.requireStorageVolume()
b.hasBindMounts()
}
bc, err := buildContainerFromResource(r)
if err != nil {
return fmt.Errorf("container resource '%s': %w", name, err)
}
if bc.Build != nil {
b.requireContainerRegistry()
}
b.buildContainers[name] = *bc
return nil
}
func buildContainerFromResource(r *Resource) (*genBuildContainer, error) {
// common fields for all build containers
var deploymentParams map[string]any
var deploymentSource string
defaultTargetPort := 80
// container.v1 uses default target port 8080
if r.Type == "container.v1" {
defaultTargetPort = 8080
}
if r.Deployment != nil {
deploymentParams = r.Deployment.Params
deploymentSource = filepath.Base(*r.Deployment.Path)
}
bc := &genBuildContainer{
Entrypoint: r.Entrypoint,
Args: r.Args,
Env: r.Env,
Bindings: r.Bindings,
Volumes: r.Volumes,
DeploymentParams: deploymentParams,
DeploymentSource: deploymentSource,
BindMounts: r.BindMounts,
DefaultTargetPort: defaultTargetPort,
}
// container.v0 and container.v1+pre-build image
if r.Image != nil {
bc.Image = *r.Image
return bc, nil
}
// details to build container, either from dockerfile.v0 or container.v1
var build *genBuildContainerDetails
// dockerfile.v0
if r.Context != nil {
build = &genBuildContainerDetails{
Context: *r.Context,
Args: nil, // dockerfile.v0 does not support build args, it only has top level args []string
}
if r.Path != nil {
build.Dockerfile = *r.Path
}
} else
// container.v1+build
if r.Build != nil {
build = &genBuildContainerDetails{
Context: r.Build.Context,
Dockerfile: r.Build.Dockerfile,
Args: r.Build.Args,
Secrets: r.Build.Secrets,
}
}
if build == nil {
return nil, fmt.Errorf("container resource must have either an image, context or a build")
}
bc.Build = build
return bc, nil
}
func (b *infraGenerator) addDapr(name string, metadata *DaprResourceMetadata) error {
if metadata == nil || metadata.Application == nil || metadata.AppId == nil {
return fmt.Errorf("dapr resource '%s' did not include required metadata", name)
}
b.requireCluster()
// NOTE: ACA only supports a small subset of the Dapr sidecar configuration options.
b.dapr[name] = genDapr{
AppId: *metadata.AppId,
Application: *metadata.Application,
AppPort: metadata.AppPort,
AppProtocol: metadata.AppProtocol,
DaprHttpMaxRequestSize: metadata.DaprHttpMaxRequestSize,
DaprHttpReadBufferSize: metadata.DaprHttpReadBufferSize,
EnableApiLogging: metadata.EnableApiLogging,
LogLevel: metadata.LogLevel,
}
return nil
}
func (b *infraGenerator) addDaprComponent(name string, metadata *DaprComponentResourceMetadata) error {
if metadata == nil || metadata.Type == nil {
return fmt.Errorf("dapr component resource '%s' did not include required metadata", name)
}
switch *metadata.Type {
case DaprPubSubComponentType:
b.addDaprPubSubComponent(name)
case DaprStateStoreComponentType:
b.addDaprStateStoreComponent(name)
default:
return fmt.Errorf("dapr component resource '%s' has unsupported type '%s'", name, *metadata.Type)
}
return nil
}
func (b *infraGenerator) addDaprRedisComponent(componentName string, componentType string) {
redisName := b.requireDaprStore()
component := genDaprComponent{
Metadata: make(map[string]genDaprComponentMetadata),
Secrets: make(map[string]genDaprComponentSecret),
Type: fmt.Sprintf("%s.redis", componentType),
Version: "v1",
}
redisPort := 6379
// The Redis component expects the host to be in the format <host>:<port>.
// NOTE: the "short name" should suffice rather than the FQDN.
redisHost := fmt.Sprintf(`'${%s.name}:%d'`, redisName, redisPort)
// The Redis add-on exposes its configuration as an ACA secret with the form:
// 'requirepass <128 character password>dir ...'
//
// We need to extract the password from this secret and pass it to the Redis component.
// While apps could "service bind" to the Redis add-on, in which case the password would be
// available as an environment variable, the Dapr environment variable secret store is not
// currently available in ACA.
redisPassword := fmt.Sprintf(`substring(%s.listSecrets().value[0].value, 12, 128)`, redisName)
redisPasswordKey := "password"
redisSecretKeyRef := fmt.Sprintf(`'%s'`, redisPasswordKey)
component.Metadata["redisHost"] = genDaprComponentMetadata{
Value: &redisHost,
}
//
// Create a secret for the Redis password. This secret will be then be referenced by the component metadata.
//
component.Metadata["redisPassword"] = genDaprComponentMetadata{
SecretKeyRef: &redisSecretKeyRef,
}
component.Secrets[redisPasswordKey] = genDaprComponentSecret{
Value: redisPassword,
}
b.bicepContext.DaprComponents[componentName] = component
}
func (b *infraGenerator) addDaprPubSubComponent(name string) {
b.addDaprRedisComponent(name, DaprPubSubComponentType)
}
func (b *infraGenerator) addDaprStateStoreComponent(name string) {
b.addDaprRedisComponent(name, DaprStateStoreComponentType)
}
// singleQuotedStringRegex is a regular expression pattern used to match single-quoted strings.
var singleQuotedStringRegex = regexp.MustCompile(`'[^']*'`)
var propertyNameRegex = regexp.MustCompile(`'([^']*)':`)
var jsonSimpleKeyRegex = regexp.MustCompile(`"([a-zA-Z0-9]*)":`)
var resourceValueOnlyRefRegex = regexp.MustCompile(`^"{([a-zA-Z0-9\-]+)\.[vV]alue}"$`)
type ingressDetails struct {
// aca ingress definition
ingress *genContainerAppIngress
// list of bindings from the service which are bind to the the ingress
ingressBindings []string
}
func (b *infraGenerator) compileIngress() error {
result := make(map[string]ingressDetails)
for name, project := range b.projects {
ingress, bindingsFromIngress, err := buildAcaIngress(project.Bindings, 8080)
if err != nil {
return fmt.Errorf("configuring ingress for resource %s: %w", name, err)
}
result[name] = ingressDetails{
ingress: ingress,
ingressBindings: bindingsFromIngress,
}
}
for name, bc := range b.buildContainers {
ingress, bindingsFromIngress, err := buildAcaIngress(bc.Bindings, bc.DefaultTargetPort)
if err != nil {
return fmt.Errorf("configuring ingress for resource %s: %w", name, err)
}
result[name] = ingressDetails{
ingress: ingress,
ingressBindings: bindingsFromIngress,
}
}
b.allServicesIngress = result
return nil
}
// initCompilerOptions initializes the compiler options for the infrastructure generator. This is used to set up
// any required options or configurations needed for the compilation process.
func (b *infraGenerator) initCompilerOptions(m *Manifest) error {
// start by assuming the manifest requires a compute environment
generatorOwnsComputeEnv := true
for _, comp := range m.Resources {
if comp.Type == "azure.bicep.v0" || comp.Type == "azure.bicep.v1" {
for pKey, pValue := range comp.Params {
if pKey == knownParameterUserPrincipalId {
if pValue != nil {
if value, castOk := pValue.(string); castOk && value == "" {
// Found a bicep resource asking for a principalId. This is the convention Aspire follows
// when the AppHost is owning the compute environment.
generatorOwnsComputeEnv = false
}
}
}
}
}
}
// Set options
b.options = infraGeneratorOptions{
appHostOwnsCompute: !generatorOwnsComputeEnv,
}
return nil
}
// Compile compiles the loaded manifest into the internal representation used to generate the infrastructure files. Once
// called the context objects on the infraGenerator can be passed to the text templates to generate the required
// infrastructure.
func (b *infraGenerator) Compile() error {
// compile the ingress for all services
// All services's ingress must be compiled before resolving the environment variables below.
if err := b.compileIngress(); err != nil {
return err
}
for resourceName, bc := range b.buildContainers {
var bMounts []*BindMount
if len(bc.BindMounts) > 0 {
// must grant write role to the Storage File Share to upload data
b.bicepContext.RequiresPrincipalId = true
}
for count, bm := range bc.BindMounts {
bMounts = append(bMounts, &BindMount{
// adding a name using the index. This name is used for naming the resource in bicep.
Name: fmt.Sprintf("bm%d", count),
// mount bind is not supported across devices, as it depends on a local path which might be missing in
// another device.
Source: bm.Source,
Target: bm.Target,
ReadOnly: bm.ReadOnly,
})
}
cs := genContainerApp{
Volumes: bc.Volumes,
BindMounts: bMounts,
}
b.bicepContext.ContainerApps[resourceName] = cs
projectTemplateCtx := genContainerAppManifestTemplateContext{
Name: resourceName,
Env: make(map[string]string),
Secrets: make(map[string]string),
KeyVaultSecrets: make(map[string]string),
DeployParams: make(map[string]string),
Ingress: b.allServicesIngress[resourceName].ingress,
Volumes: bc.Volumes,
DeploySource: bc.DeploymentSource,
BindMounts: bMounts,
Entrypoint: bc.Entrypoint,
}
if err := b.buildEnvBlock(bc.Env, &projectTemplateCtx); err != nil {
return fmt.Errorf("configuring environment for resource %s: %w", resourceName, err)
}
if err := b.buildArgsBlock(bc.Args, &projectTemplateCtx); err != nil {
return err
}
if err := b.buildDeployBlock(bc.DeploymentParams, &projectTemplateCtx); err != nil {
return err
}
b.containerAppTemplateContexts[resourceName] = projectTemplateCtx
}
for resourceName, project := range b.projects {
projectTemplateCtx := genContainerAppManifestTemplateContext{
Name: resourceName,
Env: make(map[string]string),
Secrets: make(map[string]string),
KeyVaultSecrets: make(map[string]string),
DeployParams: make(map[string]string),
Ingress: b.allServicesIngress[resourceName].ingress,
DeploySource: project.DeploymentSource,
}
for _, dapr := range b.dapr {
if dapr.Application == resourceName {
appPort := dapr.AppPort
if appPort == nil && projectTemplateCtx.Ingress != nil {
appPort = &projectTemplateCtx.Ingress.TargetPort
}
projectTemplateCtx.Dapr = &genContainerAppManifestTemplateContextDapr{
AppId: dapr.AppId,
AppPort: appPort,
AppProtocol: dapr.AppProtocol,
EnableApiLogging: dapr.EnableApiLogging,
HttpMaxRequestSize: dapr.DaprHttpMaxRequestSize,
HttpReadBufferSize: dapr.DaprHttpReadBufferSize,
LogLevel: dapr.LogLevel,
}
break
}
}
if err := b.buildEnvBlock(project.Env, &projectTemplateCtx); err != nil {
return err
}
if err := b.buildArgsBlock(project.Args, &projectTemplateCtx); err != nil {
return err
}
if err := b.buildDeployBlock(project.DeploymentParams, &projectTemplateCtx); err != nil {
return err
}
b.containerAppTemplateContexts[resourceName] = projectTemplateCtx
}
for moduleName, module := range b.bicepContext.BicepModules {
for paramName, paramValue := range module.Params {
value, err := b.resolveBicepReference(paramValue)
if err != nil {
return fmt.Errorf(
"resolving bicep module %s, param: %s, reference %s: %w", moduleName, paramName, paramValue, err)
}
module.Params[paramName] = value
}
if module.Scope != defaultBicepModuleScope {
rgScope := "resourceGroup"
if matches := resourceValueOnlyRefRegex.FindStringSubmatch(module.Scope); len(matches) == 2 {
manifestResourceName := matches[1]
// Check if the scope is an input
input, hasInput := b.bicepContext.InputParameters[manifestResourceName]
if hasInput {
input.scope = &rgScope
b.bicepContext.InputParameters[manifestResourceName] = input
}
}
scope, err := b.resolveBicepReference(module.Scope)
if err != nil {
return fmt.Errorf("resolving bicep module %s scope %s: %w", moduleName, module.Scope, err)
}
scope = fmt.Sprintf("%s(%s)", rgScope, scope)
module.Scope = scope
}
b.bicepContext.BicepModules[moduleName] = module
}
for _, kv := range b.bicepContext.KeyVaults {
if kv.ReadAccessPrincipalId {
b.bicepContext.RequiresPrincipalId = true
break
}
}
return nil
}
// resolve a reference for bicep types. The reference can be from a parameter or from the scope
func (b *infraGenerator) resolveBicepReference(ref string) (string, error) {
// bicep uses ' instead of " for strings, so we need to replace all " with '
singleQuoted := strings.ReplaceAll(ref, "\"", "'")
var evaluationError error
evaluatedString := singleQuotedStringRegex.ReplaceAllStringFunc(singleQuoted, func(s string) string {
evaluatedString, err := EvalString(s, func(s string) (string, error) {
return b.evalBindingRef(s, inputEmitTypeBicep)
})
if err != nil {
evaluationError = err
}
return evaluatedString
})
if evaluationError != nil {
return "", evaluationError
}
// quick check to know if evaluatedString is only holding one only reference. If so, we don't need to use
// the form of '%{ref}' and we can directly use ref alone.
if isComplexExp, val := isComplexExpression(evaluatedString); !isComplexExp {
return val, nil
}
// Property names that are valid identifiers should be declared without quotation marks and accessed
// using dot notation.
evaluatedString = propertyNameRegex.ReplaceAllString(evaluatedString, "${1}:")
// restore double {{ }} to single { } for bicep output
// we used double only during the evaluation to scape single brackets
return strings.ReplaceAll(strings.ReplaceAll(evaluatedString, "'{{", "${"), "}}'", "}"), nil
}
// isComplexExpression checks if the evaluatedString is in the form of '{{ expr }}' or if it is a complex expression like
// 'foo {{ expr }} bar {{ expr2 }}' and returns true if it is a complex expression.
// When the expression is not complex, it returns false and the evaluatedString without the special characters.
func isComplexExpression(evaluatedString string) (bool, string) {
removeSpecialChars := strings.ReplaceAll(strings.ReplaceAll(evaluatedString, "'{{", ""), "}}'", "")
if evaluatedString == fmt.Sprintf("'{{%s}}'", removeSpecialChars) {
return false, removeSpecialChars
}
return true, ""
}
// inputEmitType controls how references to inputs are emitted in the generated file.
type inputEmitType string
const inputEmitTypeBicep inputEmitType = "bicep"
const inputEmitTypeYaml inputEmitType = "yaml"
// evalBindingRef evaluates a binding reference expression based on the state of the manifest loaded into the generator.
func (b infraGenerator) evalBindingRef(v string, emitType inputEmitType) (string, error) {
parts := strings.SplitN(v, ".", 2)
if len(parts) != 2 {
return "", fmt.Errorf("malformed binding expression, expected <resourceName>.<propertyPath> but was: %s", v)
}
resource, prop := parts[0], parts[1]
if resource == "" {
// empty resource name means is used for global properties like outputs (currently only outputs is supported)
if !strings.HasPrefix(prop, "outputs.") {
return "", fmt.Errorf("unsupported global property referenced in binding expression: %s", prop)
}
output := prop[len("outputs."):]
return fmt.Sprintf(`{{ .Env.%s }}`, output), nil
}
targetType, ok := b.resourceTypes[resource]
if !ok {
return "", fmt.Errorf("unknown resource referenced in binding expression: %s", resource)
}
if connectionString, has := b.connectionStrings[resource]; has && prop == "connectionString" {
// The connection string can be a expression itself, so we need to evaluate it.
res, err := EvalString(connectionString, func(s string) (string, error) {
return b.evalBindingRef(s, emitType)
})
if err != nil {
return "", fmt.Errorf("evaluating connection string for %s: %w", resource, err)
}
return res, nil
}
if valueString, has := b.valueStrings[resource]; has && prop == "value" {
// The value string can be a expression itself, so we need to evaluate it.
res, err := EvalString(valueString, func(s string) (string, error) {
return b.evalBindingRef(s, emitType)
})
if err != nil {
return "", fmt.Errorf("evaluating value.v0's value string for %s: %w", resource, err)
}
return res, nil
}
if strings.HasPrefix(prop, "inputs.") {
parts := strings.Split(prop[len("inputs."):], ".")
if len(parts) != 1 {
return "", fmt.Errorf("malformed binding expression, expected inputs.<input-name> but was: %s", v)
}
switch emitType {
case inputEmitTypeBicep:
return fmt.Sprintf("${inputs['%s']['%s']}", resource, parts[0]), nil
case inputEmitTypeYaml:
return fmt.Sprintf("{{ index .Inputs `%s` `%s` }}", resource, parts[0]), nil
default:
panic(fmt.Sprintf("unexpected inputEmitType %s", string(emitType)))
}
}
switch targetType {
case "project.v0",
"container.v0",
"container.v1",
"dockerfile.v0",
"project.v1":
if strings.HasPrefix(prop, "containerImage") {
return `{{ .Image }}`, nil
}
if strings.HasPrefix(prop, "containerPort") {
return `{{ containerPort }}`, nil
}
if strings.HasPrefix(prop, "bindMounts.") {
parts := strings.Split(prop[len("bindMounts."):], ".")
if len(parts) != 2 {
return "", fmt.Errorf("malformed binding expression, expected "+
"bindMounts.<index>.<property> but was: %s", v)
}
index, property := parts[0], parts[1]
if property == "storage" {
return fmt.Sprintf(
`{{ .Env.SERVICE_%s_VOLUME_%s_NAME }}`,
scaffold.AlphaSnakeUpper(scaffold.RemoveDotAndDash(resource)),
fmt.Sprintf("BM%s", index)),
nil
}
return "", fmt.Errorf("unsupported property referenced in binding expression: %s for %s", prop, targetType)
}
if strings.HasPrefix(prop, "volumes.") {
parts := strings.Split(prop[len("volumes."):], ".")
if len(parts) != 2 {
return "", fmt.Errorf("malformed binding expression, expected "+
"volumes.<index>.<property> but was: %s", v)
}
index, property := parts[0], parts[1]
if property == "storage" {
// find the name of the volume
// convert index string to integer
indexInt, err := strconv.Atoi(index)
if err != nil {
return "", fmt.Errorf("malformed binding expression, expected "+
"volumes.<index>.<property> but was: %s", v)
}
volName := b.buildContainers[resource].Volumes[indexInt].Name
return fmt.Sprintf(
`{{ .Env.SERVICE_%s_VOLUME_%s_NAME }}`,
scaffold.AlphaSnakeUpper(resource),
scaffold.AlphaSnakeUpper(scaffold.RemoveDotAndDash(volName))),
nil
}
return "", fmt.Errorf("unsupported property referenced in binding expression: %s for %s", prop, targetType)
}
if !strings.HasPrefix(prop, "bindings.") {
return "", fmt.Errorf("unsupported property referenced in binding expression: %s for %s", prop, targetType)
}
parts := strings.Split(prop[len("bindings."):], ".")
if len(parts) != 2 {
return "", fmt.Errorf("malformed binding expression, expected "+
"bindings.<binding-name>.<property> but was: %s", v)
}
var binding *Binding
var has bool
bindingName := parts[0]
bindingProperty := parts[1]
if targetType == "project.v0" || targetType == "project.v1" {
bindings := b.projects[resource].Bindings
binding, has = bindings.Get(bindingName)
} else if targetType == "container.v0" || targetType == "container.v1" || targetType == "dockerfile.v0" {
bindings := b.buildContainers[resource].Bindings
binding, has = bindings.Get(bindingName)
}
if !has {
return "", fmt.Errorf("unknown binding referenced in binding expression: %s for resource %s", parts[0], resource)
}
bindingDetails, exists := b.allServicesIngress[resource]
if !exists {
return "", fmt.Errorf("binding reference to resource %s without ingress", resource)
}
var bindingMappedToMainIngress bool
if slices.Contains(bindingDetails.ingressBindings, bindingName) {
bindingMappedToMainIngress = true
}
hostNameSuffix := func(external bool) string {
var suffix string
switch emitType {
case inputEmitTypeYaml:
suffix = "{{ .Env.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN }}"
case inputEmitTypeBicep:
suffix = "${resources.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN}"
default:
panic(fmt.Sprintf("unexpected inputEmitType %s", string(emitType)))
}
if !external {
suffix = "internal." + suffix
}
return suffix
}
switch bindingProperty {
case "scheme":
return binding.Scheme, nil
case "protocol":
return binding.Protocol, nil
case "transport":
return binding.Scheme, nil
case "external":
return fmt.Sprintf("%t", binding.External), nil
case "host":
// If the binding is mapped to the main ingress (internal or external) and it is http/https, resolution
// expects full domain name, like `resource.internal.FQDN` or `resource.FQDN`.
if bindingMappedToMainIngress &&
(binding.Scheme == acaIngressSchemaHttp || binding.Scheme == acaIngressSchemaHttps) {
return fmt.Sprintf("%s.%s", resource, hostNameSuffix(binding.External)), nil
}
return resource, nil
case "targetPort":
if binding.TargetPort != nil {
return fmt.Sprintf("%d", *binding.TargetPort), nil
}
return acaTemplatedTargetPort, nil
case "port":
return bindingPort(binding, bindingMappedToMainIngress)
case "url":
var urlFormatString string
if bindingMappedToMainIngress {
urlFormatString = "%s://%s." + hostNameSuffix(binding.External) + "%s"
} else {
urlFormatString = "%s://%s%s"
}
var port string
resolvedPort, err := urlPort(binding, bindingMappedToMainIngress)
if err != nil {
return "", err
}
if resolvedPort != "" {
port = fmt.Sprintf(":%s", resolvedPort)
}
return fmt.Sprintf(urlFormatString, binding.Scheme, resource, port), nil
default:
return "",
fmt.Errorf("malformed binding expression, expected "+
"bindings.<binding-name>.[scheme|protocol|transport|external|host|targetPort|port|url] but was: %s", v)
}
case "azure.bicep.v0", "azure.bicep.v1":
if !strings.HasPrefix(prop, "outputs.") &&
!strings.HasPrefix(prop, "secretOutputs") &&
!strings.HasPrefix(prop, "secrets") {
return "", fmt.Errorf("unsupported property referenced in binding expression: %s for %s", prop, targetType)
}
replaceDash := strings.ReplaceAll(resource, "-", "_")
outputParts := strings.SplitN(prop, ".", 2)
var outputType string
var outputName string
noOutputName := len(outputParts) == 1
if noOutputName {
outputType = outputParts[0]
} else {
outputType = outputParts[0]
outputName = outputParts[1]
}
if outputType == "outputs" {
if emitType == inputEmitTypeYaml {
return fmt.Sprintf("{{ .Env.%s_%s }}", strings.ToUpper(replaceDash), strings.ToUpper(outputName)), nil
}
if emitType == inputEmitTypeBicep {
// using `{{ }}` helps to check if the result of evaluating a string is a complex expression or not.
return fmt.Sprintf("{{%s.outputs.%s}}", replaceDash, outputName), nil
}
return "", fmt.Errorf("unexpected output type %s", string(emitType))
} else if outputType == "secrets" {
// resource.secrets.<secret-name> was introduced after resource.outputs and resource.secretOutputs
// (few releases after).
// It enables Aspire to control the Key Vault used to save secrets.
// Before this, `secretOutputs` would:
// - Create a KeyVault, Assigned read-role for azd-user, output Key Vault Endpoint and resolve the secret during
// deployment
// Now, using `secrets`, Aspire owns creating the Key Vault and assigning the read-role to it.
if emitType == inputEmitTypeYaml {
// Get the ENV VAR Name for the Keyvault URL from the resource.connectionString
kvConnString, hasConString := b.connectionStrings[resource]
if !hasConString {
return "", fmt.Errorf(
"expecting to find connectionString for resource %s because it provides secrets", resource)
}
kvConnString = strings.TrimRight(strings.TrimLeft(kvConnString, "{"), "}")
if hasOutputs := strings.Contains(kvConnString, "outputs."); !hasOutputs {
return "", fmt.Errorf(
"expecting connectionString for resource %s to contains outputs reference", resource)
}
parts := strings.Split(kvConnString, ".outputs.")
if len(parts) != 2 {
return "", fmt.Errorf(
"unexpected connectionString for resource %s. Expecting the form of resource.outputs.name", resource)
}
if parts[0] != resource {
return "", fmt.Errorf(
"expecting to find connectionString for resource %s to auto-referenced itself"+
", like %s.outputs.name. But found: %s",
resource,
resource,
kvConnString,
)
}
envNarName := strings.ToUpper(parts[1])
return fmt.Sprintf(
"{{ secretOutput {{ .Env.%s_%s }}secrets/%s }}",
strings.ToUpper(replaceDash),
envNarName,
outputName), nil
}
if emitType == inputEmitTypeBicep {
return "", fmt.Errorf("secretOutputs not supported as inputs for bicep modules")
}
return "", fmt.Errorf("unexpected output type %s", string(emitType))
} else {
if emitType == inputEmitTypeYaml {
if noOutputName {
return fmt.Sprintf(
"{{ .Env.SERVICE_BINDING_%s_NAME }}",
strings.ToUpper("kv"+uniqueFnvNumber(resource))), nil
}
return fmt.Sprintf(
"{{ secretOutput {{ .Env.SERVICE_BINDING_%s_ENDPOINT }}secrets/%s }}",
strings.ToUpper("kv"+uniqueFnvNumber(resource)),
outputName), nil
}
if emitType == inputEmitTypeBicep {
return "", fmt.Errorf("secretOutputs not supported as inputs for bicep modules")
}
return "", fmt.Errorf("unexpected output type %s", string(emitType))
}
case "parameter.v0":
param := b.bicepContext.InputParameters[resource]
inputType := "parameter"
if param.Secret {
inputType = "securedParameter"
}
replaceDash := strings.ReplaceAll(resource, "-", "_")
switch emitType {
case inputEmitTypeBicep:
return fmt.Sprintf("{{%s}}", replaceDash), nil
case inputEmitTypeYaml:
if param.Default != nil && param.Default.Value != nil {
if param.Secret {
return "", fmt.Errorf("default value for secured parameter %s is not supported", resource)
}
inputType = "parameterWithDefault"
// parameter with default value will either use the default value or the value passed in the environment
return fmt.Sprintf(`{{ %s "%s" "%s"}}`, inputType, replaceDash, *param.Default.Value), nil
}
// parameter without default value
return fmt.Sprintf(`{{ %s "%s" }}`, inputType, replaceDash), nil
default:
panic(fmt.Sprintf("unexpected parameter %s", string(emitType)))
}
default:
ignore, err := strconv.ParseBool(os.Getenv("AZD_DEBUG_DOTNET_APPHOST_IGNORE_UNSUPPORTED_RESOURCES"))
if err == nil && ignore {
log.Printf("ignoring binding reference to resource of type %s since "+
"AZD_DEBUG_DOTNET_APPHOST_IGNORE_UNSUPPORTED_RESOURCES is set", targetType)
return fmt.Sprintf("!!! expression '%s' to type '%s' unsupported by azd !!!", v, targetType), nil
}
return "", fmt.Errorf("unsupported resource type %s referenced in binding expression", targetType)
}
}
// urlPort returns the port to be used when resolving a binding.
// The port for the url is not always the same as the port for the binding It depends on the Ingress configuration.
// If the binding is mapped to the ingress and it is http, the port is not used in the URL, as it would be the default
// (80 or 443).
// When not mapped to main ingress, but as an additional port, if the binding has a port defined, it is used. Otherwise
// the port is calculated from the target port.
func urlPort(binding *Binding, bindingMappedToMainIngress bool) (string, error) {
if bindingMappedToMainIngress && (binding.Scheme == acaIngressSchemaHttp || binding.Scheme == acaIngressSchemaHttps) {
// main ingress with Http doesn't use a port in url
return "", nil
}
if binding.Port != nil {
return fmt.Sprintf("%d", *binding.Port), nil
}
// additionalPorts not defining a `port` means they use the target port as the port and target port
return urlPortFromTargetPort(binding, bindingMappedToMainIngress)
}
func bindingPort(binding *Binding, bindingMappedToMainIngress bool) (string, error) {
if bindingMappedToMainIngress && (binding.Scheme == acaIngressSchemaHttp || binding.Scheme == acaIngressSchemaHttps) {
if binding.Scheme == acaIngressSchemaHttp {
return acaDefaultHttpPort, nil
}
if binding.Scheme == acaIngressSchemaHttps {
return acaDefaultHttpsPort, nil
}
}
if binding.Port != nil {
return fmt.Sprintf("%d", *binding.Port), nil
}
if binding.TargetPort != nil {
// Case: non-http binding w/o a port defined, but with a target port defined. (dockerfile.v0, container.v0)
// with non-external ingress is an example here.
return fmt.Sprintf("%d", *binding.TargetPort), nil
}
// no port or target port. This is the case for project.v0 where azd would get the port.
return acaTemplatedTargetPort, nil
}
// urlPortFromTargetPort returns the port to be used when resolving a binding from the target port.
func urlPortFromTargetPort(binding *Binding, bindingMappedToMainIngress bool) (string, error) {
if bindingMappedToMainIngress {
if binding.Scheme == acaIngressSchemaHttp {
return acaDefaultHttpPort, nil
}
if binding.Scheme == acaIngressSchemaHttps {
return acaDefaultHttpsPort, nil
}
}
if binding.TargetPort != nil {
return fmt.Sprintf("%d", *binding.TargetPort), nil
}
// if the binding is not mapped to the main ingress and doesn't have a port defined, it uses the templated target port,
// which is resolved on deployment time, after building the container (dotnet publish for project.v0)
// dockerfile.v0 and container.v0 always have the target port defined in the binding.
return acaTemplatedTargetPort, nil
}
// asYamlString converts a string to the YAML representation of the string, ensuring that it is quoted and escaped as needed.
func asYamlString(s string) (string, error) {
// We want to ensure that we render these values in the YAML as strings. If `res` was the string "true"
// (without the quotes), we would naturally create a value directive in yaml that looks like this:
//
// - name: OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES
// value: true
//
// And YAML rules would treat the above as the value being a boolean instead of a string, which the container
// app service expects.
//
// YAML marshalling the string value will give us something like `"true"` (with the quotes, and any escaping
// that needs to be done), which is what we want here.
// Do not use JSON marshall as it would escape the quotes within the string, breaking the meaning of the value.
// yaml marshall will use 'some text "quoted" more text' as a valid yaml string.
yamlString, err := yaml.Marshal(s)
if err != nil {
return "", fmt.Errorf("marshalling env value: %w", err)
}
// remove the trailing newline. yaml marshall will add a newline at the end of the string, as the new line is
// expected at the end of the yaml document. But we are getting a single value with valid yaml here, so we don't
// need the newline
return string(yamlString[0 : len(yamlString)-1]), nil
}
func (b *infraGenerator) buildArgsBlock(args []string, manifestCtx *genContainerAppManifestTemplateContext) error {
for argN, arg := range args {
resolvedArg, err := EvalString(arg, func(s string) (string, error) { return b.evalBindingRef(s, inputEmitTypeYaml) })
if err != nil {
return fmt.Errorf("evaluating value for argument %d: %w", argN, err)
}
// Unlike environment variables, ACA doesn't provide a way to pass secret values without baking them into the args
// array directly. We don't want folks to accidentally bake the plaintext value of these secrets into the container
// definition, so for now, we block this.
//
// This logic is similar to what we do in buildEnvBlock to detect when we need to take values and treat them as ACA
// secrets.
if strings.Contains(arg, ".connectionString}") ||
strings.Contains(resolvedArg, "{{ securedParameter ") ||
strings.Contains(resolvedArg, "{{ secretOutput ") {
return fmt.Errorf("argument %d cannot contain connection strings, secured parameters, or secret outputs. Use "+
"environment variables instead", argN)
}
yamlString, err := asYamlString(resolvedArg)
if err != nil {
return fmt.Errorf("marshalling arg value: %w", err)
}
manifestCtx.Args = append(manifestCtx.Args, yamlString)
}
return nil
}
// buildEnvBlock creates the environment map in the template context. It does this by copying the values from the given map,
// evaluating any binding expressions that are present. It writes the result of the evaluation after calling json.Marshal
// so the values may be emitted into YAML as is without worrying about escaping.
func (b *infraGenerator) buildEnvBlock(env map[string]string, manifestCtx *genContainerAppManifestTemplateContext) error {
for k, value := range env {
res, err := EvalString(value, func(s string) (string, error) { return b.evalBindingRef(s, inputEmitTypeYaml) })
if err != nil {
return fmt.Errorf("evaluating value for %s: %w", k, err)
}
resolvedValue, err := asYamlString(res)
if err != nil {
return fmt.Errorf("marshalling env value: %w", err)
}
// connectionString detection, either of:
// a) explicit connection string key for env, like "ConnectionStrings__resource": "XXXXX"
// b) a connection string field references in the value, like "FOO": "{resource.connectionString}"
// c) found placeholder for a secured-param, like "{{ securedParameter param }}"
// d) found placeholder for a secret output, like "{{ secretOutput kv secret }}"
if strings.Contains(k, "ConnectionStrings__") || // a)
strings.Contains(value, ".connectionString}") || // b)
strings.Contains(resolvedValue, "{{ securedParameter ") || // c)
strings.Contains(resolvedValue, "{{ secretOutput ") { // d)
// handle secret-outputs:
// secret outputs can be set either as a direct reference to a key vault secret, or as secret within the
// container apps. Below code checks if the the resolved value is a complex expression like:
// `key:{{ secretOutput kv secret }};foo;bar`.
// If the resolved value is not complex, it can become a direct reference to key vault secret, otherwise it
// is set as a secret within the container app.
if strings.Contains(resolvedValue, "{{ secretOutput ") {
if isComplexExp, _ := isComplexExpression(resolvedValue); !isComplexExp {
removeBrackets := strings.ReplaceAll(
strings.ReplaceAll(resolvedValue, " }}'", "'"), "{{ secretOutput ", "")
manifestCtx.KeyVaultSecrets[k] = removeBrackets
continue
}
// complex expression using secretOutput:
// The secretOutput is a reference to a KeyVault secret but can't be set as KeyVault secret reference
// because the secret is just part of the full value.
// For such case, the secret value is pulled during deployment and replaced in the containerApp.yaml file
// as a secret within the containerApp.
resolvedValue = secretOutputForDeployTemplate(resolvedValue)
}
manifestCtx.Secrets[k] = resolvedValue
continue
}
manifestCtx.Env[k] = resolvedValue
}
return nil
}
// buildDeployBlock is like buildEnvBlock but supports additional conventions for referencing secrets
// It could be merged with buildEnvBlock, but it's kept separate for clarity until we have a better understanding of
// what the final implementation will look like.
func (b *infraGenerator) buildDeployBlock(
deployParams map[string]any, manifestCtx *genContainerAppManifestTemplateContext) error {
for k, valueAny := range deployParams {
value, ok := valueAny.(string)
if !ok {
return fmt.Errorf("expected string value for %s, got %T", k, valueAny)
}
res, err := EvalString(value, func(s string) (string, error) { return b.evalBindingRef(s, inputEmitTypeYaml) })
if err != nil {
return fmt.Errorf("evaluating value for %s: %w", k, err)
}
resolvedValue, err := asYamlString(res)
if err != nil {
return fmt.Errorf("marshalling env value: %w", err)
}
if strings.Contains(k, "ConnectionStrings__") || // a)
strings.Contains(value, ".connectionString}") || // b)
strings.Contains(resolvedValue, "{{ securedParameter ") || // c)
strings.Contains(resolvedValue, "{{ secretOutput ") { // d)
// handle secret-outputs:
// secretOutputs can be either complex expressions or direct references to key vault secrets.
// A complex expression is like `key:{{ secretOutput kv secret }};foo;bar`.
// For non complex expressions, like `{{ secretOutput kv secret }}`, the resolved value is set without the
// secretOutput function. The caller can use the value as a reference to a key vault secret.
// For complex expressions, the value includes the `secretOutput` function to pull the value during deployment.
if strings.Contains(resolvedValue, "{{ secretOutput ") {
if isComplexExp, _ := isComplexExpression(resolvedValue); !isComplexExp {
removeBrackets := strings.ReplaceAll(
strings.ReplaceAll(resolvedValue, " }}'", "'"), "{{ secretOutput ", "")
resolvedValue = removeBrackets
} else {
resolvedValue = secretOutputForDeployTemplate(resolvedValue)
}
}
}
// make sure resolved value is quoted.
// We can't ask EvalString() to quote strings b/c it depends on evalBindingRef() which can return complex
// expressions where each part might be quoted or not.
// Instead, before setting the deploy parameter, we just verify that it's quoted.
if !strings.HasPrefix(resolvedValue, "'") {
resolvedValue = "'" + resolvedValue + "'"
}
manifestCtx.DeployParams[k] = resolvedValue
}
return nil
}
// secretOutputRegex is a regular expression used to match and extract secret output references in a specific format.
var secretOutputRegex = regexp.MustCompile(`{{ secretOutput {{ \.Env\.(.*) }}secrets/(.*) }}`)
// secretOutputForDeployTemplate replaces all the instances like `{{ secretOutput {{ .Env.[host] }}secrets/[secretName] }}`
// with `{{ secretOutput [host] "secretName" }}`, creating a placeholder to be resolved during the deployment.
func secretOutputForDeployTemplate(secretName string) string {
return secretOutputRegex.ReplaceAllString(secretName, `{{ secretOutput "$1" "$2" }}`)
}
// executeToFS executes the given template with the given name and context, and writes the result to the given path in
// the given target filesystem.
func executeToFS(targetFS *memfs.FS, tmpl *template.Template, name string, path string, context any) error {
buf := bytes.NewBufferString("")
if err := tmpl.ExecuteTemplate(buf, name, context); err != nil {
return fmt.Errorf("executing template: %w", err)
}
if err := targetFS.MkdirAll(filepath.Dir(path), osutil.PermissionDirectory); err != nil {
return fmt.Errorf("creating directory: %w", err)
}
if err := targetFS.WriteFile(path, buf.Bytes(), osutil.PermissionFile); err != nil {
return fmt.Errorf("writing file: %w", err)
}
return nil
}