diff --git a/cli/azd/internal/scaffold/funcs.go b/cli/azd/internal/scaffold/funcs.go index 539dc119a93..9e124a572da 100644 --- a/cli/azd/internal/scaffold/funcs.go +++ b/cli/azd/internal/scaffold/funcs.go @@ -45,6 +45,10 @@ func BicepName(name string) string { return sb.String() } +func RemoveDotAndDash(name string) string { + return strings.ReplaceAll(strings.ReplaceAll(name, ".", ""), "-", "") +} + // UpperSnakeAlpha returns a name in upper-snake case alphanumeric name separated only by underscores. // // Non-alphanumeric characters are discarded, while consecutive separators ('-', '_', and '.') are treated diff --git a/cli/azd/pkg/apphost/generate.go b/cli/azd/pkg/apphost/generate.go index c2e900d25ed..ff3914882ff 100644 --- a/cli/azd/pkg/apphost/generate.go +++ b/cli/azd/pkg/apphost/generate.go @@ -86,9 +86,7 @@ func init() { "bicepParameterName": func(src string) string { return strings.ReplaceAll(src, "-", "_") }, - "removeDot": func(src string) string { - return strings.ReplaceAll(src, ".", "") - }, + "removeDot": scaffold.RemoveDotAndDash, "envFormat": scaffold.EnvFormat, }, ). @@ -111,7 +109,7 @@ func ProjectPaths(manifest *Manifest) map[string]string { for name, comp := range manifest.Resources { switch comp.Type { - case "project.v0": + case "project.v0", "project.v1": res[name] = *comp.Path } } @@ -184,18 +182,46 @@ 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, error) { + manifest *Manifest, projectName string, options AppHostOptions) (string, ContainerAppManifestType, error) { generator := newInfraGenerator() if err := generator.LoadManifest(manifest); err != nil { - return "", err + return "", "", err } if err := generator.Compile(); err != nil { - return "", err + return "", "", err } var buf bytes.Buffer @@ -218,12 +244,29 @@ func ContainerAppManifestTemplateForProject( } } - err := genTemplates.ExecuteTemplate(&buf, "containerApp.tmpl.yaml", tmplCtx) - if err != nil { - return "", fmt.Errorf("executing template: %w", err) + // replace the containerPort with the targetPort expression + for p, v := range tmplCtx.DeployParams { + if v == "'{{ containerPort }}'" { + tmplCtx.DeployParams[p] = fmt.Sprintf("'%s'", tmplCtx.TargetPortExpression) + } } - return buf.String(), nil + 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 @@ -430,9 +473,7 @@ func GenerateProjectArtifacts( } type infraGenerator struct { - containers map[string]genContainer dapr map[string]genDapr - dockerfiles map[string]genDockerfile projects map[string]genProject connectionStrings map[string]string // keeps the value from value.v0 resources if provided. @@ -442,7 +483,8 @@ type infraGenerator struct { bicepContext genBicepTemplateContext containerAppTemplateContexts map[string]genContainerAppManifestTemplateContext allServicesIngress map[string]ingressDetails - buildContainers map[string]genBuildContainer + // works for container.v0, container.v1 and dockerfile.v0 + buildContainers map[string]genBuildContainer } func newInfraGenerator() *infraGenerator { @@ -457,9 +499,7 @@ func newInfraGenerator() *infraGenerator { OutputParameters: make(map[string]genOutputParameter), OutputSecretParameters: make(map[string]genOutputParameter), }, - containers: make(map[string]genContainer), dapr: make(map[string]genDapr), - dockerfiles: make(map[string]genDockerfile), projects: make(map[string]genProject), connectionStrings: make(map[string]string), resourceTypes: make(map[string]string), @@ -542,9 +582,20 @@ func (b *infraGenerator) LoadManifest(m *Manifest) error { switch comp.Type { case "project.v0": - b.addProject(name, *comp.Path, comp.Env, comp.Bindings, comp.Args) + 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": - b.addContainer(name, *comp.Image, comp.Env, comp.Bindings, comp.Inputs, comp.Volumes, comp.BindMounts, comp.Args) + err := b.addBuildContainer(name, comp) + if err != nil { + return err + } case "dapr.v0": err := b.addDapr(name, comp.Dapr) if err != nil { @@ -561,7 +612,10 @@ func (b *infraGenerator) LoadManifest(m *Manifest) error { return err } case "dockerfile.v0": - b.addDockerfile(name, *comp.Path, *comp.Context, comp.Env, comp.Bindings, comp.BuildArgs, comp.Args) + 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) @@ -802,16 +856,24 @@ func uniqueFnvNumber(val string) string { } func (b *infraGenerator) addProject( - name string, path string, env map[string]string, bindings custommaps.WithOrder[Binding], args []string, + 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, + Path: path, + Env: env, + Bindings: bindings, + Args: args, + DeploymentParams: deploymentParams, + DeploymentSource: deploymentSource, } } @@ -830,37 +892,6 @@ func (b *infraGenerator) addKeyVault(name string, noTags, readAccessPrincipalId } } -func (b *infraGenerator) addContainer( - name string, - image string, - env map[string]string, - bindings custommaps.WithOrder[Binding], - inputs map[string]Input, - volumes []*Volume, - bindMounts []*BindMount, - args []string) { - b.requireCluster() - - if len(volumes) > 0 { - b.requireStorageVolume() - } - - if len(bindMounts) > 0 { - b.requireStorageVolume() - b.hasBindMounts() - } - - b.containers[name] = genContainer{ - Image: image, - Env: env, - Bindings: bindings, - Inputs: inputs, - Volumes: volumes, - BindMounts: bindMounts, - Args: args, - } -} - // 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 @@ -882,6 +913,11 @@ func (b *infraGenerator) addBuildContainer( 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) @@ -895,12 +931,27 @@ func (b *infraGenerator) addBuildContainer( 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, + 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 @@ -1035,24 +1086,6 @@ func (b *infraGenerator) addDaprStateStoreComponent(name string) { b.addDaprRedisComponent(name, DaprStateStoreComponentType) } -func (b *infraGenerator) addDockerfile( - name string, path string, context string, env map[string]string, - bindings custommaps.WithOrder[Binding], buildArgs map[string]string, - args []string, -) { - b.requireCluster() - b.requireContainerRegistry() - - b.dockerfiles[name] = genDockerfile{ - Path: path, - Context: context, - Env: env, - Bindings: bindings, - BuildArgs: buildArgs, - Args: args, - } -} - // singleQuotedStringRegex is a regular expression pattern used to match single-quoted strings. var singleQuotedStringRegex = regexp.MustCompile(`'[^']*'`) var propertyNameRegex = regexp.MustCompile(`'([^']*)':`) @@ -1067,26 +1100,6 @@ type ingressDetails struct { func (b *infraGenerator) compileIngress() error { result := make(map[string]ingressDetails) - for name, container := range b.containers { - ingress, bindingsFromIngress, err := buildAcaIngress(container.Bindings, 80) - if err != nil { - return fmt.Errorf("configuring ingress for resource %s: %w", name, err) - } - result[name] = ingressDetails{ - ingress: ingress, - ingressBindings: bindingsFromIngress, - } - } - for name, docker := range b.dockerfiles { - ingress, bindingsFromIngress, err := buildAcaIngress(docker.Bindings, 80) - if err != nil { - return fmt.Errorf("configuring ingress for resource %s: %w", name, err) - } - result[name] = ingressDetails{ - ingress: ingress, - ingressBindings: bindingsFromIngress, - } - } for name, project := range b.projects { ingress, bindingsFromIngress, err := buildAcaIngress(project.Bindings, 8080) if err != nil { @@ -1098,7 +1111,7 @@ func (b *infraGenerator) compileIngress() error { } } for name, bc := range b.buildContainers { - ingress, bindingsFromIngress, err := buildAcaIngress(bc.Bindings, 8080) + ingress, bindingsFromIngress, err := buildAcaIngress(bc.Bindings, bc.DefaultTargetPort) if err != nil { return fmt.Errorf("configuring ingress for resource %s: %w", name, err) } @@ -1121,13 +1134,13 @@ func (b *infraGenerator) Compile() error { return err } - for resourceName, container := range b.containers { + for resourceName, bc := range b.buildContainers { var bMounts []*BindMount - if len(container.BindMounts) > 0 { + 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 container.BindMounts { + 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), @@ -1140,7 +1153,7 @@ func (b *infraGenerator) Compile() error { } cs := genContainerApp{ - Volumes: container.Volumes, + Volumes: bc.Volumes, BindMounts: bMounts, } @@ -1151,36 +1164,11 @@ func (b *infraGenerator) Compile() error { Env: make(map[string]string), Secrets: make(map[string]string), KeyVaultSecrets: make(map[string]string), - Ingress: b.allServicesIngress[resourceName].ingress, - Volumes: container.Volumes, - BindMounts: bMounts, - } - - if err := b.buildEnvBlock(container.Env, &projectTemplateCtx); err != nil { - return fmt.Errorf("configuring environment for resource %s: %w", resourceName, err) - } - - if err := b.buildArgsBlock(container.Args, &projectTemplateCtx); err != nil { - return err - } - - b.containerAppTemplateContexts[resourceName] = projectTemplateCtx - } - - for resourceName, bc := range b.buildContainers { - cs := genContainerApp{ - Volumes: bc.Volumes, - } - - 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, } if err := b.buildEnvBlock(bc.Env, &projectTemplateCtx); err != nil { @@ -1191,23 +1179,7 @@ func (b *infraGenerator) Compile() error { return err } - b.containerAppTemplateContexts[resourceName] = projectTemplateCtx - } - - for resourceName, docker := range b.dockerfiles { - projectTemplateCtx := genContainerAppManifestTemplateContext{ - Name: resourceName, - Env: make(map[string]string), - Secrets: make(map[string]string), - KeyVaultSecrets: make(map[string]string), - Ingress: b.allServicesIngress[resourceName].ingress, - } - - if err := b.buildEnvBlock(docker.Env, &projectTemplateCtx); err != nil { - return fmt.Errorf("configuring environment for resource %s: %w", resourceName, err) - } - - if err := b.buildArgsBlock(docker.Args, &projectTemplateCtx); err != nil { + if err := b.buildDeployBlock(bc.DeploymentParams, &projectTemplateCtx); err != nil { return err } @@ -1220,7 +1192,9 @@ func (b *infraGenerator) Compile() error { 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 { @@ -1253,6 +1227,10 @@ func (b *infraGenerator) Compile() error { return err } + if err := b.buildDeployBlock(project.DeploymentParams, &projectTemplateCtx); err != nil { + return err + } + b.containerAppTemplateContexts[resourceName] = projectTemplateCtx } @@ -1327,6 +1305,16 @@ func (b infraGenerator) evalBindingRef(v string, emitType inputEmitType) (string } 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) @@ -1373,7 +1361,57 @@ func (b infraGenerator) evalBindingRef(v string, emitType inputEmitType) (string } switch { - case targetType == "project.v0" || targetType == "container.v0" || targetType == "dockerfile.v0": + case targetType == "project.v0" || + targetType == "container.v0" || + targetType == "container.v1" || + targetType == "dockerfile.v0" || + targetType == "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.. 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.. 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.. 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) } @@ -1390,14 +1428,11 @@ func (b infraGenerator) evalBindingRef(v string, emitType inputEmitType) (string bindingName := parts[0] bindingProperty := parts[1] - if targetType == "project.v0" { + if targetType == "project.v0" || targetType == "project.v1" { bindings := b.projects[resource].Bindings binding, has = bindings.Get(bindingName) - } else if targetType == "container.v0" { - bindings := b.containers[resource].Bindings - binding, has = bindings.Get(bindingName) - } else if targetType == "dockerfile.v0" { - bindings := b.dockerfiles[resource].Bindings + } else if targetType == "container.v0" || targetType == "container.v1" || targetType == "dockerfile.v0" { + bindings := b.buildContainers[resource].Bindings binding, has = bindings.Get(bindingName) } @@ -1479,13 +1514,20 @@ func (b infraGenerator) evalBindingRef(v string, emitType inputEmitType) (string "bindings..[scheme|protocol|transport|external|host|targetPort|port|url] but was: %s", v) } case targetType == "azure.bicep.v0": - if !strings.HasPrefix(prop, "outputs.") && !strings.HasPrefix(prop, "secretOutputs.") { + if !strings.HasPrefix(prop, "outputs.") && !strings.HasPrefix(prop, "secretOutputs") { return "", fmt.Errorf("unsupported property referenced in binding expression: %s for %s", prop, targetType) } replaceDash := strings.ReplaceAll(resource, "-", "_") outputParts := strings.SplitN(prop, ".", 2) - outputType := outputParts[0] - outputName := outputParts[1] + 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 @@ -1497,6 +1539,11 @@ func (b infraGenerator) evalBindingRef(v string, emitType inputEmitType) (string 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)), @@ -1698,12 +1745,60 @@ func (b *infraGenerator) buildEnvBlock(env map[string]string, manifestCtx *genCo 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) + } + } + } + + 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/(.*) }}`) diff --git a/cli/azd/pkg/apphost/generate_test.go b/cli/azd/pkg/apphost/generate_test.go index ec9ab250461..07c2758f9d3 100644 --- a/cli/azd/pkg/apphost/generate_test.go +++ b/cli/azd/pkg/apphost/generate_test.go @@ -36,6 +36,9 @@ var aspireContainerManifest []byte //go:embed testdata/aspire-container-args.json var aspireContainerArgsManifest []byte +//go:embed testdata/aspire-projectv1.json +var aspireProjectV1Manifet []byte + // mockPublishManifest mocks the dotnet run --publisher manifest command to return a fixed manifest. func mockPublishManifest(mockCtx *mocks.MockContext, manifest []byte, files map[string]string) { mockCtx.CommandRunner.When(func(args exec.RunArgs, command string) bool { @@ -105,8 +108,9 @@ func TestAspireBicepGeneration(t *testing.T) { for _, name := range []string{"frontend"} { t.Run(name, func(t *testing.T) { - tmpl, err := ContainerAppManifestTemplateForProject(m, name, AppHostOptions{}) + tmpl, mType, err := ContainerAppManifestTemplateForProject(m, name, AppHostOptions{}) require.NoError(t, err) + require.Equal(t, ContainerAppManifestTypeYAML, mType) snapshot.SnapshotT(t, tmpl) }) } @@ -127,8 +131,9 @@ func TestAspireDockerGeneration(t *testing.T) { for _, name := range []string{"nodeapp", "api"} { t.Run(name, func(t *testing.T) { - tmpl, err := ContainerAppManifestTemplateForProject(m, name, AppHostOptions{}) + tmpl, mType, err := ContainerAppManifestTemplateForProject(m, name, AppHostOptions{}) require.NoError(t, err) + require.Equal(t, ContainerAppManifestTypeYAML, mType) snapshot.SnapshotT(t, tmpl) }) } @@ -203,7 +208,8 @@ func TestAspireArgsGeneration(t *testing.T) { m, err := ManifestFromAppHost(ctx, filepath.Join("testdata", "AspireArgs.AppHost.csproj"), mockCli, "") require.NoError(t, err) - manifest, err := ContainerAppManifestTemplateForProject(m, "apiservice", AppHostOptions{}) + manifest, mType, err := ContainerAppManifestTemplateForProject(m, "apiservice", AppHostOptions{}) + require.Equal(t, ContainerAppManifestTypeYAML, mType) require.NoError(t, err) snapshot.SnapshotT(t, manifest) @@ -224,7 +230,8 @@ func TestAspireContainerGeneration(t *testing.T) { for _, name := range []string{"mysqlabstract", "my-sql-abstract", "noVolume", "kafka"} { t.Run(name, func(t *testing.T) { - tmpl, err := ContainerAppManifestTemplateForProject(m, name, AppHostOptions{}) + tmpl, mType, err := ContainerAppManifestTemplateForProject(m, name, AppHostOptions{}) + require.Equal(t, ContainerAppManifestTypeYAML, mType) require.NoError(t, err) snapshot.SnapshotT(t, tmpl) }) @@ -276,7 +283,8 @@ func TestAspireContainerArgs(t *testing.T) { for _, name := range []string{"container0", "container1"} { t.Run(name, func(t *testing.T) { - tmpl, err := ContainerAppManifestTemplateForProject(m, name, AppHostOptions{}) + tmpl, mType, err := ContainerAppManifestTemplateForProject(m, name, AppHostOptions{}) + require.Equal(t, ContainerAppManifestTypeYAML, mType) require.NoError(t, err) snapshot.SnapshotT(t, tmpl) }) @@ -480,3 +488,55 @@ func TestHasInputs(t *testing.T) { }) } } + +func TestAspireProjectV1Generation(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Skipping due to EOL issues on Windows with the baselines") + } + + ctx := context.Background() + mockCtx := mocks.NewMockContext(ctx) + filesFromManifest := make(map[string]string) + ignoredBicepContent := "bicep file contents" + filesFromManifest["test.bicep"] = ignoredBicepContent + filesFromManifest["storage.module.bicep"] = ignoredBicepContent + filesFromManifest["cache.module.bicep"] = ignoredBicepContent + filesFromManifest["api.module.bicep"] = ignoredBicepContent + filesFromManifest["account.module.bicep"] = ignoredBicepContent + mockPublishManifest(mockCtx, aspireProjectV1Manifet, filesFromManifest) + mockCli := dotnet.NewCli(mockCtx.CommandRunner) + + m, err := ManifestFromAppHost(ctx, filepath.Join("testdata", "AspireDocker.AppHost.csproj"), mockCli, "") + require.NoError(t, err) + + for _, name := range []string{"api", "cache"} { + t.Run(name, func(t *testing.T) { + tmpl, mType, err := ContainerAppManifestTemplateForProject(m, name, AppHostOptions{}) + require.Equal(t, ContainerAppManifestTypeBicep, mType) + require.NoError(t, err) + snapshot.SnapshotT(t, tmpl) + }) + } + + files, err := BicepTemplate("main", m, AppHostOptions{}) + require.NoError(t, err) + + err = fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + contents, err := fs.ReadFile(files, path) + if err != nil { + return err + } + t.Run(path, func(t *testing.T) { + snapshot.SnapshotT(t, string(contents)) + }) + return nil + }) + require.NoError(t, err) + +} diff --git a/cli/azd/pkg/apphost/generate_types.go b/cli/azd/pkg/apphost/generate_types.go index 677c8dd8756..90399eedb4a 100644 --- a/cli/azd/pkg/apphost/generate_types.go +++ b/cli/azd/pkg/apphost/generate_types.go @@ -38,32 +38,40 @@ type genContainerAppIngress struct { } type genContainer struct { - Image string - Env map[string]string - Bindings custommaps.WithOrder[Binding] - Inputs map[string]Input - Volumes []*Volume - BindMounts []*BindMount - Args []string + Image string + Env map[string]string + Bindings custommaps.WithOrder[Binding] + Inputs map[string]Input + Volumes []*Volume + BindMounts []*BindMount + Args []string + DeploymentParams map[string]any + DeploymentSource string } type genDockerfile struct { - Path string - Context string - Env map[string]string - Bindings custommaps.WithOrder[Binding] - BuildArgs map[string]string - Args []string + Path string + Context string + Env map[string]string + Bindings custommaps.WithOrder[Binding] + BuildArgs map[string]string + Args []string + DeploymentParams map[string]any + DeploymentSource string } type genBuildContainer struct { - Image string - Entrypoint string - Args []string - Env map[string]string - Bindings custommaps.WithOrder[Binding] - Volumes []*Volume - Build *genBuildContainerDetails + Image string + Entrypoint string + Args []string + Env map[string]string + Bindings custommaps.WithOrder[Binding] + Volumes []*Volume + Build *genBuildContainerDetails + DeploymentParams map[string]any + DeploymentSource string + BindMounts []*BindMount + DefaultTargetPort int } type genBuildContainerDetails struct { @@ -74,10 +82,12 @@ type genBuildContainerDetails struct { } type genProject struct { - Path string - Env map[string]string - Args []string - Bindings custommaps.WithOrder[Binding] + Path string + Env map[string]string + Args []string + Bindings custommaps.WithOrder[Binding] + DeploymentParams map[string]any + DeploymentSource string } type genDapr struct { @@ -147,6 +157,8 @@ type genContainerAppManifestTemplateContext struct { Args []string Volumes []*Volume BindMounts []*BindMount + DeployParams map[string]string + DeploySource string } type genProjectFileContext struct { diff --git a/cli/azd/pkg/apphost/manifest.go b/cli/azd/pkg/apphost/manifest.go index 8f60cee43b0..b09cbe0f768 100644 --- a/cli/azd/pkg/apphost/manifest.go +++ b/cli/azd/pkg/apphost/manifest.go @@ -94,6 +94,20 @@ type Resource struct { // container.v0 uses bind mounts field to define the volumes with initial data of the container. BindMounts []*BindMount `json:"bindMounts,omitempty"` + + // project.v1 and container.v1 uses deployment when the AppHost owns the ACA bicep definitions. + Deployment *DeploymentMetadata `json:"deployment,omitempty"` +} + +type DeploymentMetadata struct { + // Type is the type of deployment. For now, only bicep.v0 is supported. + Type string `json:"type"` + + // Path is present for a bicep.v0 deployment type, and the path to the bicep file. + Path *string `json:"path,omitempty"` + + // For a bicep.v0 deployment type, defines the input parameters for the bicep file. + Params map[string]any `json:"params,omitempty"` } type ContainerV1Build struct { @@ -252,6 +266,27 @@ func ManifestFromAppHost( } } + if res.Deployment != nil { + if res.Deployment.Type != "azure.bicep.v0" { + return nil, fmt.Errorf( + "unexpected deployment type %q. Supported types: [azure.bicep.v0]", res.Deployment.Type) + } + // use a folder with the name of the resource + e := manifest.BicepFiles.MkdirAll(resourceName, osutil.PermissionDirectory) + if e != nil { + return nil, e + } + content, e := os.ReadFile(filepath.Join(manifestDir, *res.Deployment.Path)) + if e != nil { + return nil, fmt.Errorf("reading bicep file from deployment property: %w", e) + } + *res.Deployment.Path = filepath.Join(resourceName, filepath.Base(*res.Deployment.Path)) + e = manifest.BicepFiles.WriteFile(*res.Deployment.Path, content, osutil.PermissionFile) + if e != nil { + return nil, e + } + } + if res.Type == "dockerfile.v0" { if !filepath.IsAbs(*res.Context) { *res.Context = filepath.Join(manifestDir, *res.Context) diff --git a/cli/azd/pkg/apphost/testdata/TestAspireBicepGeneration-main.bicep.snap b/cli/azd/pkg/apphost/testdata/TestAspireBicepGeneration-main.bicep.snap index e8422ea0617..920ad17615c 100644 --- a/cli/azd/pkg/apphost/testdata/TestAspireBicepGeneration-main.bicep.snap +++ b/cli/azd/pkg/apphost/testdata/TestAspireBicepGeneration-main.bicep.snap @@ -103,6 +103,7 @@ output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = resources.outputs.AZURE_CO output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = resources.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = resources.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN output SERVICE_BINDING_KV854251F1_ENDPOINT string = resources.outputs.SERVICE_BINDING_KV854251F1_ENDPOINT +output SERVICE_BINDING_KV854251F1_NAME string = resources.outputs.SERVICE_BINDING_KV854251F1_NAME output AI_APPINSIGHTSCONNECTIONSTRING string = ai.outputs.appInsightsConnectionString output S_B_SERVICEBUSENDPOINT string = s_b.outputs.serviceBusEndpoint output SQL_SQLSERVERFQDN string = sql.outputs.sqlServerFqdn diff --git a/cli/azd/pkg/apphost/testdata/TestAspireContainerGeneration-main.bicep.snap b/cli/azd/pkg/apphost/testdata/TestAspireContainerGeneration-main.bicep.snap index 31b2a636023..1d0a89c4e2d 100644 --- a/cli/azd/pkg/apphost/testdata/TestAspireContainerGeneration-main.bicep.snap +++ b/cli/azd/pkg/apphost/testdata/TestAspireContainerGeneration-main.bicep.snap @@ -81,8 +81,8 @@ output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = resources.outputs.AZURE_LOG_A output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = resources.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_NAME output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = resources.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = resources.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN -output SERVICE_MY_SQL_ABSTRACT_VOLUME_PROJECTAPPHOST_VOLUME_TWO_NAME string = resources.outputs.SERVICE_MY_SQL_ABSTRACT_VOLUME_PROJECTAPPHOST_VOLUME_TWO_NAME -output SERVICE_MYSQLABSTRACT_VOLUME_PROJECTAPPHOST_VOLUMEONE_NAME string = resources.outputs.SERVICE_MYSQLABSTRACT_VOLUME_PROJECTAPPHOST_VOLUMEONE_NAME +output SERVICE_MY_SQL_ABSTRACT_VOLUME_PROJECTAPPHOSTVOLUMETWO_NAME string = resources.outputs.SERVICE_MY_SQL_ABSTRACT_VOLUME_PROJECTAPPHOSTVOLUMETWO_NAME +output SERVICE_MYSQLABSTRACT_VOLUME_PROJECTAPPHOSTVOLUMEONE_NAME string = resources.outputs.SERVICE_MYSQLABSTRACT_VOLUME_PROJECTAPPHOSTVOLUMEONE_NAME output SERVICE_NOVOLUME_VOLUME_BM0_NAME string = resources.outputs.SERVICE_NOVOLUME_VOLUME_BM0_NAME output SERVICE_NOVOLUME_FILE_SHARE_BM0_NAME string = resources.outputs.SERVICE_NOVOLUME_FILE_SHARE_BM0_NAME output SERVICE_NOVOLUME_VOLUME_BM1_NAME string = resources.outputs.SERVICE_NOVOLUME_VOLUME_BM1_NAME diff --git a/cli/azd/pkg/apphost/testdata/TestAspireContainerGeneration-my-sql-abstract.snap b/cli/azd/pkg/apphost/testdata/TestAspireContainerGeneration-my-sql-abstract.snap index 32f741b9eb1..47a1aca1d34 100644 --- a/cli/azd/pkg/apphost/testdata/TestAspireContainerGeneration-my-sql-abstract.snap +++ b/cli/azd/pkg/apphost/testdata/TestAspireContainerGeneration-my-sql-abstract.snap @@ -27,9 +27,9 @@ properties: value: '{{ securedParameter "my_sql_abstract_pas_sw_ord" }}' template: volumes: - - name: my-sql-abstract-projectapphost-volume-two + - name: my-sql-abstract-projectapphostvolumetwo storageType: AzureFile - storageName: {{ .Env.SERVICE_MY_SQL_ABSTRACT_VOLUME_PROJECTAPPHOST_VOLUME_TWO_NAME }} + storageName: {{ .Env.SERVICE_MY_SQL_ABSTRACT_VOLUME_PROJECTAPPHOSTVOLUMETWO_NAME }} containers: - image: {{ .Image }} name: my-sql-abstract @@ -45,7 +45,7 @@ properties: - name: SpecialChar secretRef: specialchar volumeMounts: - - volumeName: my-sql-abstract-projectapphost-volume-two + - volumeName: my-sql-abstract-projectapphostvolumetwo mountPath: /data/db scale: minReplicas: 1 diff --git a/cli/azd/pkg/apphost/testdata/TestAspireContainerGeneration-mysqlabstract.snap b/cli/azd/pkg/apphost/testdata/TestAspireContainerGeneration-mysqlabstract.snap index b279f1fd0ec..2bab7d4678e 100644 --- a/cli/azd/pkg/apphost/testdata/TestAspireContainerGeneration-mysqlabstract.snap +++ b/cli/azd/pkg/apphost/testdata/TestAspireContainerGeneration-mysqlabstract.snap @@ -30,9 +30,9 @@ properties: value: '{{ securedParameter "mysqlabstract_pas_sw_ord" }}' template: volumes: - - name: mysqlabstract-projectapphost-volumeone + - name: mysqlabstract-projectapphostvolumeone storageType: AzureFile - storageName: {{ .Env.SERVICE_MYSQLABSTRACT_VOLUME_PROJECTAPPHOST_VOLUMEONE_NAME }} + storageName: {{ .Env.SERVICE_MYSQLABSTRACT_VOLUME_PROJECTAPPHOSTVOLUMEONE_NAME }} containers: - image: {{ .Image }} name: mysqlabstract @@ -44,7 +44,7 @@ properties: - name: SpecialChar secretRef: specialchar volumeMounts: - - volumeName: mysqlabstract-projectapphost-volumeone + - volumeName: mysqlabstract-projectapphostvolumeone mountPath: /data/db scale: minReplicas: 1 diff --git a/cli/azd/pkg/apphost/testdata/TestAspireContainerGeneration-resources.bicep.snap b/cli/azd/pkg/apphost/testdata/TestAspireContainerGeneration-resources.bicep.snap index 2510fe6fe94..93c7e5d2f23 100644 --- a/cli/azd/pkg/apphost/testdata/TestAspireContainerGeneration-resources.bicep.snap +++ b/cli/azd/pkg/apphost/testdata/TestAspireContainerGeneration-resources.bicep.snap @@ -53,7 +53,7 @@ resource volumesAccountRoleAssignment 'Microsoft.Authorization/roleAssignments@2 resource mySqlAbstractProjectAppHostVolumeTwoFileShare 'Microsoft.Storage/storageAccounts/fileServices/shares@2022-05-01' = { parent: storageVolumeFileService - name: take('${toLower('my-sql-abstract')}-${toLower('ProjectAppHost-volume-two')}', 32) + name: take('${toLower('my-sql-abstract')}-${toLower('ProjectAppHostvolumetwo')}', 60) properties: { shareQuota: 1024 enabledProtocols: 'SMB' @@ -61,7 +61,7 @@ resource mySqlAbstractProjectAppHostVolumeTwoFileShare 'Microsoft.Storage/storag } resource mysqlabstractProjectAppHostVolumeOneFileShare 'Microsoft.Storage/storageAccounts/fileServices/shares@2022-05-01' = { parent: storageVolumeFileService - name: take('${toLower('mysqlabstract')}-${toLower('ProjectAppHost-volumeOne')}', 32) + name: take('${toLower('mysqlabstract')}-${toLower('ProjectAppHostvolumeOne')}', 60) properties: { shareQuota: 1024 enabledProtocols: 'SMB' @@ -69,7 +69,7 @@ resource mysqlabstractProjectAppHostVolumeOneFileShare 'Microsoft.Storage/storag } resource noVolumeBm0FileShare 'Microsoft.Storage/storageAccounts/fileServices/shares@2022-05-01' = { parent: storageVolumeFileService - name: take('${toLower('noVolume')}-${toLower('bm0')}', 32) + name: take('${toLower('noVolume')}-${toLower('bm0')}', 60) properties: { shareQuota: 1024 enabledProtocols: 'SMB' @@ -77,7 +77,7 @@ resource noVolumeBm0FileShare 'Microsoft.Storage/storageAccounts/fileServices/sh } resource noVolumeBm1FileShare 'Microsoft.Storage/storageAccounts/fileServices/shares@2022-05-01' = { parent: storageVolumeFileService - name: take('${toLower('noVolume')}-${toLower('bm1')}', 32) + name: take('${toLower('noVolume')}-${toLower('bm1')}', 60) properties: { shareQuota: 1024 enabledProtocols: 'SMB' @@ -122,10 +122,10 @@ resource explicitContributorUserRoleAssignment 'Microsoft.Authorization/roleAssi resource mySqlAbstractProjectAppHostVolumeTwoStore 'Microsoft.App/managedEnvironments/storages@2023-05-01' = { parent: containerAppEnvironment - name: take('${toLower('my-sql-abstract')}-${toLower('ProjectAppHost-volume-two')}', 32) + name: take('${toLower('my-sql-abstract')}-${toLower('ProjectAppHostvolumetwo')}', 32) properties: { azureFile: { - shareName: '${toLower('my-sql-abstract')}-${toLower('ProjectAppHost-volume-two')}' + shareName: mySqlAbstractProjectAppHostVolumeTwoFileShare.name accountName: storageVolume.name accountKey: storageVolume.listKeys().keys[0].value accessMode: 'ReadOnly' @@ -135,10 +135,10 @@ resource mySqlAbstractProjectAppHostVolumeTwoStore 'Microsoft.App/managedEnviron resource mysqlabstractProjectAppHostVolumeOneStore 'Microsoft.App/managedEnvironments/storages@2023-05-01' = { parent: containerAppEnvironment - name: take('${toLower('mysqlabstract')}-${toLower('ProjectAppHost-volumeOne')}', 32) + name: take('${toLower('mysqlabstract')}-${toLower('ProjectAppHostvolumeOne')}', 32) properties: { azureFile: { - shareName: '${toLower('mysqlabstract')}-${toLower('ProjectAppHost-volumeOne')}' + shareName: mysqlabstractProjectAppHostVolumeOneFileShare.name accountName: storageVolume.name accountKey: storageVolume.listKeys().keys[0].value accessMode: 'ReadWrite' @@ -151,7 +151,7 @@ resource noVolumeBm0Store 'Microsoft.App/managedEnvironments/storages@2023-05-01 name: take('${toLower('noVolume')}-${toLower('bm0')}', 32) properties: { azureFile: { - shareName: '${toLower('noVolume')}-${toLower('bm0')}' + shareName: noVolumeBm0FileShare.name accountName: storageVolume.name accountKey: storageVolume.listKeys().keys[0].value accessMode: 'ReadWrite' @@ -164,7 +164,7 @@ resource noVolumeBm1Store 'Microsoft.App/managedEnvironments/storages@2023-05-01 name: take('${toLower('noVolume')}-${toLower('bm1')}', 32) properties: { azureFile: { - shareName: '${toLower('noVolume')}-${toLower('bm1')}' + shareName: noVolumeBm1FileShare.name accountName: storageVolume.name accountKey: storageVolume.listKeys().keys[0].value accessMode: 'ReadOnly' @@ -180,8 +180,8 @@ output AZURE_LOG_ANALYTICS_WORKSPACE_ID string = logAnalyticsWorkspace.id output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = containerAppEnvironment.name output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = containerAppEnvironment.id output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = containerAppEnvironment.properties.defaultDomain -output SERVICE_MY_SQL_ABSTRACT_VOLUME_PROJECTAPPHOST_VOLUME_TWO_NAME string = mySqlAbstractProjectAppHostVolumeTwoStore.name -output SERVICE_MYSQLABSTRACT_VOLUME_PROJECTAPPHOST_VOLUMEONE_NAME string = mysqlabstractProjectAppHostVolumeOneStore.name +output SERVICE_MY_SQL_ABSTRACT_VOLUME_PROJECTAPPHOSTVOLUMETWO_NAME string = mySqlAbstractProjectAppHostVolumeTwoStore.name +output SERVICE_MYSQLABSTRACT_VOLUME_PROJECTAPPHOSTVOLUMEONE_NAME string = mysqlabstractProjectAppHostVolumeOneStore.name output SERVICE_NOVOLUME_VOLUME_BM0_NAME string = noVolumeBm0Store.name output SERVICE_NOVOLUME_FILE_SHARE_BM0_NAME string = noVolumeBm0FileShare.name output SERVICE_NOVOLUME_VOLUME_BM1_NAME string = noVolumeBm1Store.name diff --git a/cli/azd/pkg/apphost/testdata/TestAspireProjectV1Generation-account-account.module.bicep.snap b/cli/azd/pkg/apphost/testdata/TestAspireProjectV1Generation-account-account.module.bicep.snap new file mode 100644 index 00000000000..94467e1abd5 --- /dev/null +++ b/cli/azd/pkg/apphost/testdata/TestAspireProjectV1Generation-account-account.module.bicep.snap @@ -0,0 +1 @@ +bicep file contents diff --git a/cli/azd/pkg/apphost/testdata/TestAspireProjectV1Generation-api-api.module.bicep.snap b/cli/azd/pkg/apphost/testdata/TestAspireProjectV1Generation-api-api.module.bicep.snap new file mode 100644 index 00000000000..94467e1abd5 --- /dev/null +++ b/cli/azd/pkg/apphost/testdata/TestAspireProjectV1Generation-api-api.module.bicep.snap @@ -0,0 +1 @@ +bicep file contents diff --git a/cli/azd/pkg/apphost/testdata/TestAspireProjectV1Generation-api.snap b/cli/azd/pkg/apphost/testdata/TestAspireProjectV1Generation-api.snap new file mode 100644 index 00000000000..65fdb7a320c --- /dev/null +++ b/cli/azd/pkg/apphost/testdata/TestAspireProjectV1Generation-api.snap @@ -0,0 +1,12 @@ +using './api.module.bicep' + +param account_secretoutputs = '{{ .Env.SERVICE_BINDING_KV294FC75C_NAME }}' +param api_containerimage = '{{ .Image }}' +param api_containerport = '{{ targetPortOrDefault 8080 }}' +param outputs_azure_container_apps_environment_id = '{{ .Env.AZURE_CONTAINER_APPS_ENVIRONMENT_ID }}' +param outputs_azure_container_registry_endpoint = '{{ .Env.AZURE_CONTAINER_REGISTRY_ENDPOINT }}' +param outputs_azure_container_registry_managed_identity_id = '{{ .Env.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID }}' +param outputs_managed_identity_client_id = '{{ .Env.MANAGED_IDENTITY_CLIENT_ID }}' +param secretparam_value = '{{ securedParameter "secretparam" }}' +param storage_outputs_blobendpoint = '{{ .Env.STORAGE_BLOBENDPOINT }}' + diff --git a/cli/azd/pkg/apphost/testdata/TestAspireProjectV1Generation-cache-cache.module.bicep.snap b/cli/azd/pkg/apphost/testdata/TestAspireProjectV1Generation-cache-cache.module.bicep.snap new file mode 100644 index 00000000000..94467e1abd5 --- /dev/null +++ b/cli/azd/pkg/apphost/testdata/TestAspireProjectV1Generation-cache-cache.module.bicep.snap @@ -0,0 +1 @@ +bicep file contents diff --git a/cli/azd/pkg/apphost/testdata/TestAspireProjectV1Generation-cache.snap b/cli/azd/pkg/apphost/testdata/TestAspireProjectV1Generation-cache.snap new file mode 100644 index 00000000000..b4b9d397295 --- /dev/null +++ b/cli/azd/pkg/apphost/testdata/TestAspireProjectV1Generation-cache.snap @@ -0,0 +1,7 @@ +using './cache.module.bicep' + +param cache_volumes_0_storage = '{{ .Env.SERVICE_CACHE_VOLUME_AZURECONTAINERAPPSAPPHOST8F235654EDCACHEDATA_NAME }}' +param outputs_azure_container_apps_environment_id = '{{ .Env.AZURE_CONTAINER_APPS_ENVIRONMENT_ID }}' +param outputs_azure_container_registry_managed_identity_id = '{{ .Env.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID }}' +param outputs_managed_identity_client_id = '{{ .Env.MANAGED_IDENTITY_CLIENT_ID }}' + diff --git a/cli/azd/pkg/apphost/testdata/TestAspireProjectV1Generation-main.bicep.snap b/cli/azd/pkg/apphost/testdata/TestAspireProjectV1Generation-main.bicep.snap new file mode 100644 index 00000000000..5b7fdfb1eb6 --- /dev/null +++ b/cli/azd/pkg/apphost/testdata/TestAspireProjectV1Generation-main.bicep.snap @@ -0,0 +1,68 @@ +targetScope = 'subscription' + +@minLength(1) +@maxLength(64) +@description('Name of the environment that can be used as part of naming resource convention, the name of the resource group for your application will use this name, prefixed with rg-') +param environmentName string + +@minLength(1) +@description('The location used for all deployed resources') +param location string + +@description('Id of the user or app to assign application roles') +param principalId string = '' + +@secure() +param secretparam string + +var tags = { + 'azd-env-name': environmentName +} + +resource rg 'Microsoft.Resources/resourceGroups@2022-09-01' = { + name: 'rg-${environmentName}' + location: location + tags: tags +} + +module resources 'resources.bicep' = { + scope: rg + name: 'resources' + params: { + location: location + tags: tags + principalId: principalId + } +} + +module account 'account/account.module.bicep' = { + name: 'account' + scope: rg + params: { + keyVaultName: resources.outputs.SERVICE_BINDING_KV294FC75C_NAME + location: location + } +} +module storage 'storage/storage.module.bicep' = { + name: 'storage' + scope: rg + params: { + location: location + principalId: resources.outputs.MANAGED_IDENTITY_PRINCIPAL_ID + principalType: 'ServicePrincipal' + } +} +output MANAGED_IDENTITY_CLIENT_ID string = resources.outputs.MANAGED_IDENTITY_CLIENT_ID +output MANAGED_IDENTITY_NAME string = resources.outputs.MANAGED_IDENTITY_NAME +output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = resources.outputs.AZURE_LOG_ANALYTICS_WORKSPACE_NAME +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = resources.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = resources.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID +output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = resources.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_NAME +output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = resources.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID +output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = resources.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN +output SERVICE_CACHE_VOLUME_AZURECONTAINERAPPSAPPHOST8F235654EDCACHEDATA_NAME string = resources.outputs.SERVICE_CACHE_VOLUME_AZURECONTAINERAPPSAPPHOST8F235654EDCACHEDATA_NAME +output SERVICE_BINDING_KV294FC75C_ENDPOINT string = resources.outputs.SERVICE_BINDING_KV294FC75C_ENDPOINT +output SERVICE_BINDING_KV294FC75C_NAME string = resources.outputs.SERVICE_BINDING_KV294FC75C_NAME +output STORAGE_BLOBENDPOINT string = storage.outputs.blobEndpoint +output AZURE_VOLUMES_STORAGE_ACCOUNT string = resources.outputs.AZURE_VOLUMES_STORAGE_ACCOUNT + diff --git a/cli/azd/pkg/apphost/testdata/TestAspireProjectV1Generation-main.parameters.json.snap b/cli/azd/pkg/apphost/testdata/TestAspireProjectV1Generation-main.parameters.json.snap new file mode 100644 index 00000000000..cb9619d395e --- /dev/null +++ b/cli/azd/pkg/apphost/testdata/TestAspireProjectV1Generation-main.parameters.json.snap @@ -0,0 +1,19 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "principalId": { + "value": "${AZURE_PRINCIPAL_ID}" + }, + "secretparam": { + "value": "${AZURE_SECRETPARAM}" + }, + "environmentName": { + "value": "${AZURE_ENV_NAME}" + }, + "location": { + "value": "${AZURE_LOCATION}" + } + } + } + diff --git a/cli/azd/pkg/apphost/testdata/TestAspireProjectV1Generation-resources.bicep.snap b/cli/azd/pkg/apphost/testdata/TestAspireProjectV1Generation-resources.bicep.snap new file mode 100644 index 00000000000..3021d183d75 --- /dev/null +++ b/cli/azd/pkg/apphost/testdata/TestAspireProjectV1Generation-resources.bicep.snap @@ -0,0 +1,169 @@ +@description('The location used for all deployed resources') +param location string = resourceGroup().location +@description('Id of the user or app to assign application roles') +param principalId string = '' + + +@description('Tags that will be applied to all resources') +param tags object = {} + +var resourceToken = uniqueString(resourceGroup().id) + +resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: 'mi-${resourceToken}' + location: location + tags: tags +} + +resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-07-01' = { + name: replace('acr-${resourceToken}', '-', '') + location: location + sku: { + name: 'Basic' + } + tags: tags +} + +resource caeMiRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(containerRegistry.id, managedIdentity.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) + scope: containerRegistry + properties: { + principalId: managedIdentity.properties.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + } +} + +resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2022-10-01' = { + name: 'law-${resourceToken}' + location: location + properties: { + sku: { + name: 'PerGB2018' + } + } + tags: tags +} + +resource storageVolume 'Microsoft.Storage/storageAccounts@2022-05-01' = { + name: 'vol${resourceToken}' + location: location + kind: 'StorageV2' + sku: { + name: 'Standard_LRS' + } + properties: { + largeFileSharesState: 'Enabled' + } +} + +resource storageVolumeFileService 'Microsoft.Storage/storageAccounts/fileServices@2022-05-01' = { + parent: storageVolume + name: 'default' +} + +resource cacheAzurecontainerappsapphost8f235654edCacheDataFileShare 'Microsoft.Storage/storageAccounts/fileServices/shares@2022-05-01' = { + parent: storageVolumeFileService + name: take('${toLower('cache')}-${toLower('azurecontainerappsapphost8f235654edcachedata')}', 60) + properties: { + shareQuota: 1024 + enabledProtocols: 'SMB' + } +} + +resource containerAppEnvironment 'Microsoft.App/managedEnvironments@2024-02-02-preview' = { + name: 'cae-${resourceToken}' + location: location + properties: { + workloadProfiles: [{ + workloadProfileType: 'Consumption' + name: 'consumption' + }] + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: logAnalyticsWorkspace.properties.customerId + sharedKey: logAnalyticsWorkspace.listKeys().primarySharedKey + } + } + } + tags: tags + + resource aspireDashboard 'dotNetComponents' = { + name: 'aspire-dashboard' + properties: { + componentType: 'AspireDashboard' + } + } + +} + +resource explicitContributorUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(containerAppEnvironment.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b24988ac-6180-42a0-ab88-20f7382dd24c')) + scope: containerAppEnvironment + properties: { + principalId: principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b24988ac-6180-42a0-ab88-20f7382dd24c') + } +} + +resource cacheAzurecontainerappsapphost8f235654edCacheDataStore 'Microsoft.App/managedEnvironments/storages@2023-05-01' = { + parent: containerAppEnvironment + name: take('${toLower('cache')}-${toLower('azurecontainerappsapphost8f235654edcachedata')}', 32) + properties: { + azureFile: { + shareName: cacheAzurecontainerappsapphost8f235654edCacheDataFileShare.name + accountName: storageVolume.name + accountKey: storageVolume.listKeys().keys[0].value + accessMode: 'ReadWrite' + } + } +} + +resource kv294fc75c 'Microsoft.KeyVault/vaults@2023-07-01' = { + name: replace('kv294fc75c-${resourceToken}', '-', '') + location: location + properties: { + sku: { + name: 'standard' + family: 'A' + } + tenantId: subscription().tenantId + enableRbacAuthorization: true + } +} + +resource kv294fc75cRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(kv294fc75c.id, managedIdentity.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '00482a5a-887f-4fb3-b363-3b7fe8e74483')) + scope: kv294fc75c + properties: { + principalId: managedIdentity.properties.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '00482a5a-887f-4fb3-b363-3b7fe8e74483') + } +} + +resource kv294fc75cUserReadRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(kv294fc75c.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6')) + scope: kv294fc75c + properties: { + principalId: principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6') + } +} + +output MANAGED_IDENTITY_CLIENT_ID string = managedIdentity.properties.clientId +output MANAGED_IDENTITY_NAME string = managedIdentity.name +output MANAGED_IDENTITY_PRINCIPAL_ID string = managedIdentity.properties.principalId +output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = logAnalyticsWorkspace.name +output AZURE_LOG_ANALYTICS_WORKSPACE_ID string = logAnalyticsWorkspace.id +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerRegistry.properties.loginServer +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = managedIdentity.id +output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = containerAppEnvironment.name +output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = containerAppEnvironment.id +output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = containerAppEnvironment.properties.defaultDomain +output SERVICE_CACHE_VOLUME_AZURECONTAINERAPPSAPPHOST8F235654EDCACHEDATA_NAME string = cacheAzurecontainerappsapphost8f235654edCacheDataStore.name +output SERVICE_BINDING_KV294FC75C_ENDPOINT string = kv294fc75c.properties.vaultUri +output SERVICE_BINDING_KV294FC75C_NAME string = kv294fc75c.name +output AZURE_VOLUMES_STORAGE_ACCOUNT string = storageVolume.name + diff --git a/cli/azd/pkg/apphost/testdata/TestAspireProjectV1Generation-storage-storage.module.bicep.snap b/cli/azd/pkg/apphost/testdata/TestAspireProjectV1Generation-storage-storage.module.bicep.snap new file mode 100644 index 00000000000..94467e1abd5 --- /dev/null +++ b/cli/azd/pkg/apphost/testdata/TestAspireProjectV1Generation-storage-storage.module.bicep.snap @@ -0,0 +1 @@ +bicep file contents diff --git a/cli/azd/pkg/apphost/testdata/aspire-projectv1.json b/cli/azd/pkg/apphost/testdata/aspire-projectv1.json new file mode 100644 index 00000000000..bdf8c512527 --- /dev/null +++ b/cli/azd/pkg/apphost/testdata/aspire-projectv1.json @@ -0,0 +1,114 @@ +{ + "$schema": "https://json.schemastore.org/aspire-8.0.json", + "resources": { + "secretparam": { + "type": "parameter.v0", + "value": "{secretparam.inputs.value}", + "inputs": { + "value": { + "type": "string", + "secret": true + } + } + }, + "cache": { + "type": "container.v1", + "connectionString": "{cache.bindings.tcp.host}:{cache.bindings.tcp.port}", + "image": "docker.io/library/redis:7.4", + "deployment": { + "type": "azure.bicep.v0", + "path": "cache.module.bicep", + "params": { + "cache_volumes_0_storage": "{cache.volumes.0.storage}", + "outputs_azure_container_registry_managed_identity_id": "{.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", + "outputs_managed_identity_client_id": "{.outputs.MANAGED_IDENTITY_CLIENT_ID}", + "outputs_azure_container_apps_environment_id": "{.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID}" + } + }, + "args": [ + "--save", + "60", + "1" + ], + "volumes": [ + { + "name": "azurecontainerapps.apphost-8f235654ed-cache-data", + "target": "/data", + "readOnly": false + } + ], + "bindings": { + "tcp": { + "scheme": "tcp", + "protocol": "tcp", + "transport": "tcp", + "targetPort": 6379 + } + } + }, + "account": { + "type": "azure.bicep.v0", + "connectionString": "{account.secretOutputs.connectionString}", + "path": "account.module.bicep", + "params": { + "keyVaultName": "" + } + }, + "storage": { + "type": "azure.bicep.v0", + "path": "storage.module.bicep", + "params": { + "principalId": "", + "principalType": "" + } + }, + "blobs": { + "type": "value.v0", + "connectionString": "{storage.outputs.blobEndpoint}" + }, + "api": { + "type": "project.v1", + "path": "../AzureContainerApps.ApiService/AzureContainerApps.ApiService.csproj", + "deployment": { + "type": "azure.bicep.v0", + "path": "api.module.bicep", + "params": { + "api_containerport": "{api.containerPort}", + "storage_outputs_blobendpoint": "{storage.outputs.blobEndpoint}", + "account_secretoutputs": "{account.secretOutputs}", + "outputs_azure_container_registry_managed_identity_id": "{.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID}", + "secretparam_value": "{secretparam.value}", + "outputs_managed_identity_client_id": "{.outputs.MANAGED_IDENTITY_CLIENT_ID}", + "outputs_azure_container_apps_environment_id": "{.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID}", + "outputs_azure_container_registry_endpoint": "{.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT}", + "api_containerimage": "{api.containerImage}" + } + }, + "env": { + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory", + "ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true", + "HTTP_PORTS": "{api.bindings.http.targetPort}", + "ConnectionStrings__blobs": "{blobs.connectionString}", + "ConnectionStrings__cache": "{cache.connectionString}", + "ConnectionStrings__account": "{account.connectionString}", + "VALUE": "{secretparam.value}" + }, + "bindings": { + "http": { + "scheme": "http", + "protocol": "tcp", + "transport": "http", + "external": true + }, + "https": { + "scheme": "https", + "protocol": "tcp", + "transport": "http", + "external": true + } + } + } + } +} \ No newline at end of file diff --git a/cli/azd/pkg/azure/arm_template.go b/cli/azd/pkg/azure/arm_template.go index d6f46b1ad41..153d352f439 100644 --- a/cli/azd/pkg/azure/arm_template.go +++ b/cli/azd/pkg/azure/arm_template.go @@ -106,6 +106,7 @@ const AzdMetadataTypeGenerateOrManual AzdMetadataType = "generateOrManual" type AzdMetadata struct { Type *AzdMetadataType `json:"type,omitempty"` AutoGenerateConfig *AutoGenInput `json:"config,omitempty"` + DefaultValueExpr *string `json:"defaultValueExpr,omitempty"` } // Description returns the value of the "Description" string metadata for this parameter or empty if it can not be found. diff --git a/cli/azd/pkg/project/dotnet_importer.go b/cli/azd/pkg/project/dotnet_importer.go index 1c3cdba9269..833d1c7f1c5 100644 --- a/cli/azd/pkg/project/dotnet_importer.go +++ b/cli/azd/pkg/project/dotnet_importer.go @@ -459,8 +459,7 @@ func evaluateSingleExpressionMatch( return fmt.Sprintf("{%s%s}", infraParametersKey, resourceName), nil } -func (ai *DotNetImporter) SynthAllInfrastructure( - ctx context.Context, p *ProjectConfig, svcConfig *ServiceConfig, +func (ai *DotNetImporter) SynthAllInfrastructure(ctx context.Context, p *ProjectConfig, svcConfig *ServiceConfig, ) (fs.FS, error) { manifest, err := ai.ReadManifest(ctx, svcConfig) if err != nil { @@ -524,16 +523,11 @@ func (ai *DotNetImporter) SynthAllInfrastructure( return nil, err } - // writeManifestForResource writes the containerApp.tmpl.yaml for the given resource to the generated filesystem. The - // manifest is written to a file name "containerApp.tmpl.yaml" in the same directory as the project that produces the + // writeManifestForResource writes the containerApp.tmpl.yaml or containerApp.bicepparam for the given resource to the + // generated filesystem. The manifest is written to a file name "containerApp.tmpl.yaml" or + // "containerApp.tmpl.bicepparam" in the same directory as the project that produces the // container we will deploy. writeManifestForResource := func(name string) error { - containerAppManifest, err := apphost.ContainerAppManifestTemplateForProject( - manifest, name, apphost.AppHostOptions{}) - if err != nil { - return fmt.Errorf("generating containerApp.tmpl.yaml for resource %s: %w", name, err) - } - normalPath, err := filepath.EvalSymlinks(svcConfig.Path()) if err != nil { return err @@ -544,13 +538,28 @@ func (ai *DotNetImporter) SynthAllInfrastructure( return err } + containerAppManifest, manifestType, err := apphost.ContainerAppManifestTemplateForProject( + manifest, name, apphost.AppHostOptions{}) + if err != nil { + return fmt.Errorf("generating containerApp deployment manifest for resource %s: %w", name, err) + } + manifestPath := filepath.Join(filepath.Dir(projectRelPath), "infra", fmt.Sprintf("%s.tmpl.yaml", name)) + if manifestType == apphost.ContainerAppManifestTypeBicep { + manifestPath = filepath.Join( + filepath.Dir(projectRelPath), "infra", name, fmt.Sprintf("%s.tmpl.bicepparam", name)) + } if err := generatedFS.MkdirAll(filepath.Dir(manifestPath), osutil.PermissionDirectoryOwnerOnly); err != nil { return err } - return generatedFS.WriteFile(manifestPath, []byte(containerAppManifest), osutil.PermissionFileOwnerOnly) + err = generatedFS.WriteFile(manifestPath, []byte(containerAppManifest), osutil.PermissionFileOwnerOnly) + if err != nil { + return err + } + + return nil } for name := range apphost.ProjectPaths(manifest) { diff --git a/cli/azd/pkg/project/service_target_dotnet_containerapp.go b/cli/azd/pkg/project/service_target_dotnet_containerapp.go index f21f68335d9..02e7c7f575b 100644 --- a/cli/azd/pkg/project/service_target_dotnet_containerapp.go +++ b/cli/azd/pkg/project/service_target_dotnet_containerapp.go @@ -5,7 +5,9 @@ package project import ( "context" + "encoding/json" "fmt" + "io" "log" "net/url" "os" @@ -24,6 +26,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/keyvault" "github.com/azure/azure-dev/cli/azd/pkg/sqldb" "github.com/azure/azure-dev/cli/azd/pkg/tools" + "github.com/azure/azure-dev/cli/azd/pkg/tools/bicep" "github.com/azure/azure-dev/cli/azd/pkg/tools/dotnet" ) @@ -33,10 +36,12 @@ type dotnetContainerAppTarget struct { containerAppService containerapps.ContainerAppService resourceManager ResourceManager dotNetCli *dotnet.Cli + bicepCli *bicep.Cli cosmosDbService cosmosdb.CosmosDbService sqlDbService sqldb.SqlDbService keyvaultService keyvault.KeyVaultService alphaFeatureManager *alpha.FeatureManager + deploymentService azapi.DeploymentService } // NewDotNetContainerAppTarget creates the Service Target for a Container App that is written in .NET. Unlike @@ -53,10 +58,12 @@ func NewDotNetContainerAppTarget( containerAppService containerapps.ContainerAppService, resourceManager ResourceManager, dotNetCli *dotnet.Cli, + bicepCli *bicep.Cli, cosmosDbService cosmosdb.CosmosDbService, sqlDbService sqldb.SqlDbService, keyvaultService keyvault.KeyVaultService, alphaFeatureManager *alpha.FeatureManager, + deploymentService azapi.DeploymentService, ) ServiceTarget { return &dotnetContainerAppTarget{ env: env, @@ -64,10 +71,12 @@ func NewDotNetContainerAppTarget( containerAppService: containerAppService, resourceManager: resourceManager, dotNetCli: dotNetCli, + bicepCli: bicepCli, cosmosDbService: cosmosDbService, sqlDbService: sqlDbService, keyvaultService: keyvaultService, alphaFeatureManager: alphaFeatureManager, + deploymentService: deploymentService, } } @@ -164,40 +173,73 @@ func (at *dotnetContainerAppTarget) Deploy( progress.SetProgress(NewServiceProgress("Updating container app")) - var manifest string + var manifestTemplate string + var armTemplate *azure.RawArmTemplate + var armParams azure.ArmParameters appHostRoot := serviceConfig.DotNetContainerApp.AppHostPath if f, err := os.Stat(appHostRoot); err == nil && !f.IsDir() { appHostRoot = filepath.Dir(appHostRoot) } - manifestPath := filepath.Join( - appHostRoot, "infra", fmt.Sprintf("%s.tmpl.yaml", serviceConfig.DotNetContainerApp.ProjectName)) - if _, err := os.Stat(manifestPath); err == nil { - log.Printf("using container app manifest from %s", manifestPath) - - contents, err := os.ReadFile(manifestPath) - if err != nil { - return nil, fmt.Errorf("reading container app manifest: %w", err) + deploymentConfig := serviceConfig.DotNetContainerApp.Manifest.Resources[serviceConfig.Name].Deployment + useBicepForContainerApps := deploymentConfig != nil + projectName := serviceConfig.DotNetContainerApp.ProjectName + + if useBicepForContainerApps { + bicepParamPath := filepath.Join( + appHostRoot, "infra", projectName, fmt.Sprintf("%s.tmpl.bicepparam", projectName)) + if _, err := os.Stat(bicepParamPath); err == nil { + // read the file into manifestContents + contents, err := os.ReadFile(bicepParamPath) + if err != nil { + return nil, fmt.Errorf("reading container app manifest: %w", err) + } + manifestTemplate = string(contents) + } else { + // missing bicepparam template file, generate it + contents, _, err := apphost.ContainerAppManifestTemplateForProject( + serviceConfig.DotNetContainerApp.Manifest, + projectName, + apphost.AppHostOptions{}, + ) + if err != nil { + return nil, fmt.Errorf("generating container app manifest: %w", err) + } + manifestTemplate = contents } - manifest = string(contents) } else { - log.Printf( - "generating container app manifest from %s for project %s", - serviceConfig.DotNetContainerApp.AppHostPath, - serviceConfig.DotNetContainerApp.ProjectName) - - generatedManifest, err := apphost.ContainerAppManifestTemplateForProject( - serviceConfig.DotNetContainerApp.Manifest, - serviceConfig.DotNetContainerApp.ProjectName, - apphost.AppHostOptions{}, - ) - if err != nil { - return nil, fmt.Errorf("generating container app manifest: %w", err) + manifestPath := filepath.Join( + appHostRoot, "infra", fmt.Sprintf("%s.tmpl.yaml", projectName)) + + if _, err := os.Stat(manifestPath); err == nil { + log.Printf("using container app manifest from %s", manifestPath) + + contents, err := os.ReadFile(manifestPath) + if err != nil { + return nil, fmt.Errorf("reading container app manifest: %w", err) + } + manifestTemplate = string(contents) + } else { + log.Printf( + "generating container app manifest from %s for project %s", + serviceConfig.DotNetContainerApp.AppHostPath, + projectName) + + generatedManifest, _, err := apphost.ContainerAppManifestTemplateForProject( + serviceConfig.DotNetContainerApp.Manifest, + projectName, + apphost.AppHostOptions{}, + ) + if err != nil { + return nil, fmt.Errorf("generating container app manifest: %w", err) + } + manifestTemplate = generatedManifest } - manifest = generatedManifest } + log.Printf("Resolve the manifest template for project %s", projectName) + fns := &containerAppTemplateManifestFuncs{ ctx: ctx, manifest: serviceConfig.DotNetContainerApp.Manifest, @@ -209,27 +251,21 @@ func (at *dotnetContainerAppTarget) Deploy( keyvaultService: at.keyvaultService, } - tmpl, err := template.New("containerApp.tmpl.yaml"). - Option("missingkey=error"). - Funcs(template.FuncMap{ - "urlHost": fns.UrlHost, - "parameter": fns.Parameter, - // securedParameter gets a parameter the same way as parameter, but supporting the securedParameter - // allows to update the logic of pulling secret parameters in the future, if azd changes the way it - // stores the parameter value. - "securedParameter": fns.Parameter, - "secretOutput": fns.kvSecret, - "targetPortOrDefault": func(targetPortFromManifest int) int { - // portNumber is 0 for dockerfile.v0, so we use the targetPort from the manifest - if portNumber == 0 { - return targetPortFromManifest - } - return portNumber - }, - }). - Parse(manifest) - if err != nil { - return nil, fmt.Errorf("failing parsing containerApp.tmpl.yaml: %w", err) + funcMap := template.FuncMap{ + "urlHost": fns.UrlHost, + "parameter": fns.Parameter, + // securedParameter gets a parameter the same way as parameter, but supporting the securedParameter + // allows to update the logic of pulling secret parameters in the future, if azd changes the way it + // stores the parameter value. + "securedParameter": fns.Parameter, + "secretOutput": fns.kvSecret, + "targetPortOrDefault": func(targetPortFromManifest int) int { + // portNumber is 0 for dockerfile.v0, so we use the targetPort from the manifest + if portNumber == 0 { + return targetPortFromManifest + } + return portNumber + }, } var inputs map[string]any @@ -240,6 +276,14 @@ func (at *dotnetContainerAppTarget) Deploy( inputs = make(map[string]any) } + tmpl, err := template.New("manifest template"). + Option("missingkey=error"). + Funcs(funcMap). + Parse(manifestTemplate) + if err != nil { + return nil, fmt.Errorf("failing parsing manifest template: %w", err) + } + builder := strings.Builder{} err = tmpl.Execute(&builder, struct { Env map[string]string @@ -254,20 +298,115 @@ func (at *dotnetContainerAppTarget) Deploy( return nil, fmt.Errorf("failed executing template file: %w", err) } - containerAppOptions := containerapps.ContainerAppOptions{ - ApiVersion: serviceConfig.ApiVersion, - } + if useBicepForContainerApps { + // Compile the bicep template + compiled, params, err := func() (azure.RawArmTemplate, azure.ArmParameters, error) { + tempFolder, err := os.MkdirTemp("", fmt.Sprintf("%s-build*", projectName)) + if err != nil { + return azure.RawArmTemplate{}, nil, fmt.Errorf("creating temporary build folder: %w", err) + } + defer func() { + _ = os.RemoveAll(tempFolder) + }() + // write bicepparam content to a new file in the temp folder + f, err := os.Create(filepath.Join(tempFolder, "main.bicepparam")) + if err != nil { + return azure.RawArmTemplate{}, nil, fmt.Errorf("creating bicepparam file: %w", err) + } + _, err = io.Copy(f, strings.NewReader(builder.String())) + if err != nil { + return azure.RawArmTemplate{}, nil, fmt.Errorf("writing bicepparam file: %w", err) + } + err = f.Close() + if err != nil { + return azure.RawArmTemplate{}, nil, fmt.Errorf("closing bicepparam file: %w", err) + } + + // copy module to same path as bicepparam so it can be compiled from the temp folder + bicepSourceFileName := filepath.Base(*deploymentConfig.Path) + bicepContent, err := os.ReadFile(filepath.Join(appHostRoot, "infra", projectName, bicepSourceFileName)) + if err != nil { + // when source bicep is not found, we generate it from the manifest + generatedBicep, err := apphost.ContainerSourceBicepContent( + serviceConfig.DotNetContainerApp.Manifest, + projectName, + apphost.AppHostOptions{}, + ) + if err != nil { + return azure.RawArmTemplate{}, nil, fmt.Errorf("generating bicep file: %w", err) + } + bicepContent = []byte(generatedBicep) + } + sourceFile, err := os.Create(filepath.Join(tempFolder, bicepSourceFileName)) + if err != nil { + return azure.RawArmTemplate{}, nil, fmt.Errorf("creating bicep file: %w", err) + } + _, err = io.Copy(sourceFile, strings.NewReader(string(bicepContent))) + if err != nil { + return azure.RawArmTemplate{}, nil, fmt.Errorf("writing bicep file: %w", err) + } + err = sourceFile.Close() + if err != nil { + return azure.RawArmTemplate{}, nil, fmt.Errorf("closing bicep file: %w", err) + } + + res, err := at.bicepCli.BuildBicepParam(ctx, f.Name(), at.env.Environ()) + if err != nil { + return azure.RawArmTemplate{}, nil, fmt.Errorf("building container app bicep: %w", err) + } + type compiledBicepParamResult struct { + TemplateJson string `json:"templateJson"` + ParametersJson string `json:"parametersJson"` + } + var bicepParamOutput compiledBicepParamResult + if err := json.Unmarshal([]byte(res.Compiled), &bicepParamOutput); err != nil { + log.Printf( + "failed unmarshalling compiled bicepparam (err: %v), template contents:\n%s", err, res.Compiled) + return nil, nil, fmt.Errorf("failed unmarshalling arm template from json: %w", err) + } + var params azure.ArmParameterFile + if err := json.Unmarshal([]byte(bicepParamOutput.ParametersJson), ¶ms); err != nil { + log.Printf( + "failed unmarshalling compiled bicepparam parameters(err: %v), template contents:\n%s", + err, + res.Compiled) + return nil, nil, fmt.Errorf("failed unmarshalling arm parameters template from json: %w", err) + } + return azure.RawArmTemplate(bicepParamOutput.TemplateJson), params.Parameters, nil + }() + if err != nil { + return nil, err + } + armTemplate = &compiled + armParams = params - err = at.containerAppService.DeployYaml( - ctx, - targetResource.SubscriptionId(), - targetResource.ResourceGroupName(), - serviceConfig.Name, - []byte(builder.String()), - &containerAppOptions, - ) - if err != nil { - return nil, fmt.Errorf("updating container app service: %w", err) + _, err = at.deploymentService.DeployToResourceGroup( + ctx, + at.env.GetSubscriptionId(), + targetResource.ResourceGroupName(), + at.deploymentService.GenerateDeploymentName(serviceConfig.Name), + *armTemplate, + armParams, + nil, nil) + if err != nil { + return nil, fmt.Errorf("deploying bicep template: %w", err) + } + } else { + containerAppOptions := containerapps.ContainerAppOptions{ + ApiVersion: serviceConfig.ApiVersion, + } + + err = at.containerAppService.DeployYaml( + ctx, + targetResource.SubscriptionId(), + targetResource.ResourceGroupName(), + serviceConfig.Name, + []byte(builder.String()), + &containerAppOptions, + ) + if err != nil { + return nil, fmt.Errorf("updating container app service: %w", err) + } } progress.SetProgress(NewServiceProgress("Fetching endpoints for container app service")) diff --git a/cli/azd/resources/apphost/templates/containerApp.tmpl.bicepparamt b/cli/azd/resources/apphost/templates/containerApp.tmpl.bicepparamt new file mode 100644 index 00000000000..5b400a3fdb0 --- /dev/null +++ b/cli/azd/resources/apphost/templates/containerApp.tmpl.bicepparamt @@ -0,0 +1,6 @@ +{{define "containerApp.tmpl.bicepparam" -}} +using './{{ .DeploySource }}' +{{ range $name, $exp := .DeployParams }} +param {{ $name }} = {{ $exp }} +{{- end}} +{{ end}} \ No newline at end of file diff --git a/cli/azd/resources/apphost/templates/main.bicept b/cli/azd/resources/apphost/templates/main.bicept index 43969c44094..c93fe50d2f7 100644 --- a/cli/azd/resources/apphost/templates/main.bicept +++ b/cli/azd/resources/apphost/templates/main.bicept @@ -87,6 +87,7 @@ output SERVICE_{{alphaSnakeUpper $name}}_FILE_SHARE_{{removeDot $bMount.Name | a {{end -}} {{range $name, $value := .KeyVaults -}} output SERVICE_BINDING_{{alphaSnakeUpper $name}}_ENDPOINT string = resources.outputs.SERVICE_BINDING_{{alphaSnakeUpper $name}}_ENDPOINT +output SERVICE_BINDING_{{alphaSnakeUpper $name}}_NAME string = resources.outputs.SERVICE_BINDING_{{alphaSnakeUpper $name}}_NAME {{end -}} {{range $param, $value := .OutputParameters -}} output {{bicepParameterName $param}} {{$value.Type}} = {{bicepParameterName $value.Value}} diff --git a/cli/azd/resources/apphost/templates/resources.bicept b/cli/azd/resources/apphost/templates/resources.bicept index ef475c7910e..41f8cc94e25 100644 --- a/cli/azd/resources/apphost/templates/resources.bicept +++ b/cli/azd/resources/apphost/templates/resources.bicept @@ -86,7 +86,7 @@ resource volumesAccountRoleAssignment 'Microsoft.Authorization/roleAssignments@2 {{- range $volume := $value.Volumes}} resource {{mergeBicepName $name $volume.Name}}FileShare 'Microsoft.Storage/storageAccounts/fileServices/shares@2022-05-01' = { parent: storageVolumeFileService - name: take('${toLower('{{$name}}')}-${toLower('{{removeDot $volume.Name}}')}', 32) + name: take('${toLower('{{$name}}')}-${toLower('{{removeDot $volume.Name}}')}', 60) properties: { shareQuota: 1024 enabledProtocols: 'SMB' @@ -96,7 +96,7 @@ resource {{mergeBicepName $name $volume.Name}}FileShare 'Microsoft.Storage/stora {{- range $bMount := $value.BindMounts}} resource {{mergeBicepName $name $bMount.Name}}FileShare 'Microsoft.Storage/storageAccounts/fileServices/shares@2022-05-01' = { parent: storageVolumeFileService - name: take('${toLower('{{$name}}')}-${toLower('{{removeDot $bMount.Name}}')}', 32) + name: take('${toLower('{{$name}}')}-${toLower('{{removeDot $bMount.Name}}')}', 60) properties: { shareQuota: 1024 enabledProtocols: 'SMB' @@ -148,7 +148,7 @@ resource {{mergeBicepName $name $volume.Name}}Store 'Microsoft.App/managedEnviro name: take('${toLower('{{$name}}')}-${toLower('{{removeDot $volume.Name}}')}', 32) properties: { azureFile: { - shareName: '${toLower('{{$name}}')}-${toLower('{{removeDot $volume.Name}}')}' + shareName: {{mergeBicepName $name $volume.Name}}FileShare.name accountName: storageVolume.name accountKey: storageVolume.listKeys().keys[0].value {{- if $volume.ReadOnly }} @@ -167,7 +167,7 @@ resource {{mergeBicepName $name $bMount.Name}}Store 'Microsoft.App/managedEnviro name: take('${toLower('{{$name}}')}-${toLower('{{removeDot $bMount.Name}}')}', 32) properties: { azureFile: { - shareName: '${toLower('{{$name}}')}-${toLower('{{removeDot $bMount.Name}}')}' + shareName: {{mergeBicepName $name $bMount.Name}}FileShare.name accountName: storageVolume.name accountKey: storageVolume.listKeys().keys[0].value {{- if $bMount.ReadOnly }} diff --git a/cli/azd/test/functional/testdata/snaps/aspire-full/infra/main.bicep b/cli/azd/test/functional/testdata/snaps/aspire-full/infra/main.bicep index 4617b14802a..c3f783725ab 100644 --- a/cli/azd/test/functional/testdata/snaps/aspire-full/infra/main.bicep +++ b/cli/azd/test/functional/testdata/snaps/aspire-full/infra/main.bicep @@ -59,6 +59,7 @@ output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = resources.outputs.AZURE_CO output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = resources.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = resources.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN output SERVICE_BINDING_KVF2EDECB5_ENDPOINT string = resources.outputs.SERVICE_BINDING_KVF2EDECB5_ENDPOINT +output SERVICE_BINDING_KVF2EDECB5_NAME string = resources.outputs.SERVICE_BINDING_KVF2EDECB5_NAME output STORAGE_BLOBENDPOINT string = storage.outputs.blobEndpoint output STORAGE_QUEUEENDPOINT string = storage.outputs.queueEndpoint output STORAGE_TABLEENDPOINT string = storage.outputs.tableEndpoint