From f9ce84d5f65c4220faaab9dae3f5f1359c9cbd33 Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Tue, 3 Sep 2024 14:29:01 -0700 Subject: [PATCH] WIP: aspire: Use bicep for container app deployment The plan for Aspire 9 is to support customization of the container app for services by leveraging the CDK to generate the bicep IaC for a given container app. To prepare for that new world, we'd like `azd infra synth` to start writing bicep and to modify our deployment strategy for container apps to be based on ARM deployments instead of directly PUTing a copy of the resource to the RP. This change starts to work through that. --- cli/azd/pkg/apphost/generate.go | 155 ++++++++++--- cli/azd/pkg/apphost/generate_types.go | 3 + cli/azd/pkg/project/dotnet_importer.go | 13 +- .../apphost/templates/containerApp.bicept | 209 ++++++++++++++++++ 4 files changed, 353 insertions(+), 27 deletions(-) create mode 100644 cli/azd/resources/apphost/templates/containerApp.bicept diff --git a/cli/azd/pkg/apphost/generate.go b/cli/azd/pkg/apphost/generate.go index 4323124bebd..c7aa184f40c 100644 --- a/cli/azd/pkg/apphost/generate.go +++ b/cli/azd/pkg/apphost/generate.go @@ -226,6 +226,49 @@ func ContainerAppManifestTemplateForProject( return buf.String(), nil } +// BicepModuleForProject returns the bicep module for the container app manifest for a given project. +// It can be used (after evaluation) to deploy the service to a container app environment. +func BicepModuleForProject( + manifest *Manifest, projectName string, options AppHostOptions) (string, error) { + generator := newInfraGenerator() + generator.skipAsYamlString = true + + 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) + } + } + + err := genTemplates.ExecuteTemplate(&buf, "containerApp.bicep", tmplCtx) + if err != nil { + return "", fmt.Errorf("executing template: %w", err) + } + + return buf.String(), 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) { @@ -430,6 +473,8 @@ func GenerateProjectArtifacts( } type infraGenerator struct { + skipAsYamlString bool + containers map[string]genContainer dapr map[string]genDapr dockerfiles map[string]genDockerfile @@ -1147,13 +1192,14 @@ func (b *infraGenerator) Compile() error { b.bicepContext.ContainerApps[resourceName] = cs projectTemplateCtx := genContainerAppManifestTemplateContext{ - Name: resourceName, - 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, + Name: resourceName, + Env: make(map[string]string), + Secrets: make(map[string]string), + KeyVaultSecrets: make(map[string]string), + BindingExpressions: make(map[string]string), + Ingress: b.allServicesIngress[resourceName].ingress, + Volumes: container.Volumes, + BindMounts: bMounts, } if err := b.buildEnvBlock(container.Env, &projectTemplateCtx); err != nil { @@ -1175,12 +1221,13 @@ func (b *infraGenerator) Compile() error { b.bicepContext.ContainerApps[resourceName] = cs projectTemplateCtx := genContainerAppManifestTemplateContext{ - Name: resourceName, - Env: make(map[string]string), - Secrets: make(map[string]string), - KeyVaultSecrets: make(map[string]string), - Ingress: b.allServicesIngress[resourceName].ingress, - Volumes: bc.Volumes, + Name: resourceName, + Env: make(map[string]string), + Secrets: make(map[string]string), + KeyVaultSecrets: make(map[string]string), + BindingExpressions: make(map[string]string), + Ingress: b.allServicesIngress[resourceName].ingress, + Volumes: bc.Volumes, } if err := b.buildEnvBlock(bc.Env, &projectTemplateCtx); err != nil { @@ -1196,11 +1243,12 @@ func (b *infraGenerator) Compile() error { 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, + Name: resourceName, + Env: make(map[string]string), + Secrets: make(map[string]string), + KeyVaultSecrets: make(map[string]string), + BindingExpressions: make(map[string]string), + Ingress: b.allServicesIngress[resourceName].ingress, } if err := b.buildEnvBlock(docker.Env, &projectTemplateCtx); err != nil { @@ -1216,11 +1264,12 @@ func (b *infraGenerator) Compile() error { 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), - Ingress: b.allServicesIngress[resourceName].ingress, + Name: resourceName, + Env: make(map[string]string), + Secrets: make(map[string]string), + KeyVaultSecrets: make(map[string]string), + BindingExpressions: make(map[string]string), + Ingress: b.allServicesIngress[resourceName].ingress, } for _, dapr := range b.dapr { @@ -1594,7 +1643,11 @@ func urlPortFromTargetPort(binding *Binding, bindingMappedToMainIngress bool) (s } // 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) { +func (b *infraGenerator) asYamlString(s string) (string, error) { + if b.skipAsYamlString { + return s, nil + } + // 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: // @@ -1620,6 +1673,31 @@ func asYamlString(s string) (string, error) { } func (b *infraGenerator) buildArgsBlock(args []string, manifestCtx *genContainerAppManifestTemplateContext) error { + // TODO(ellismg): This needs to consider secrets and stuff like the regular version does. + if b.skipAsYamlString == true { + for argN, arg := range args { + res, err := EvalString(arg, func(s string) (string, error) { + resolved, err := b.evalBindingRef(s, inputEmitTypeYaml) + if err != nil { + return "", err + } + + bindingName := strings.Replace(s, ".", "_", -1) + + manifestCtx.BindingExpressions[bindingName] = resolved + return fmt.Sprintf("${%s}", bindingName), nil + + }) + if err != nil { + return fmt.Errorf("evaluating value for argument %d: %w", argN, err) + } + + manifestCtx.Args = append(manifestCtx.Args, res) + } + + return nil + } + for argN, arg := range args { resolvedArg, err := EvalString(arg, func(s string) (string, error) { return b.evalBindingRef(s, inputEmitTypeYaml) }) if err != nil { @@ -1640,7 +1718,7 @@ func (b *infraGenerator) buildArgsBlock(args []string, manifestCtx *genContainer "environment variables instead", argN) } - yamlString, err := asYamlString(resolvedArg) + yamlString, err := b.asYamlString(resolvedArg) if err != nil { return fmt.Errorf("marshalling arg value: %w", err) } @@ -1654,13 +1732,37 @@ func (b *infraGenerator) buildArgsBlock(args []string, manifestCtx *genContainer // 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 { + // TODO(ellismg): This needs to consider secrets and stuff like the regular version does. + if b.skipAsYamlString == true { + for k, value := range env { + res, err := EvalString(value, func(s string) (string, error) { + resolved, err := b.evalBindingRef(s, inputEmitTypeYaml) + if err != nil { + return "", err + } + + bindingName := strings.Replace(s, ".", "_", -1) + + manifestCtx.BindingExpressions[bindingName] = resolved + return fmt.Sprintf("${%s}", bindingName), nil + }) + if err != nil { + return fmt.Errorf("evaluating value for %s: %w", k, err) + } + + manifestCtx.Env[k] = res + } + + return nil + } + 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) + resolvedValue, err := b.asYamlString(res) if err != nil { return fmt.Errorf("marshalling env value: %w", err) } @@ -1698,6 +1800,7 @@ func (b *infraGenerator) buildEnvBlock(env map[string]string, manifestCtx *genCo manifestCtx.Secrets[k] = resolvedValue continue } + manifestCtx.Env[k] = resolvedValue } diff --git a/cli/azd/pkg/apphost/generate_types.go b/cli/azd/pkg/apphost/generate_types.go index 677c8dd8756..109fd47edc8 100644 --- a/cli/azd/pkg/apphost/generate_types.go +++ b/cli/azd/pkg/apphost/generate_types.go @@ -147,6 +147,9 @@ type genContainerAppManifestTemplateContext struct { Args []string Volumes []*Volume BindMounts []*BindMount + + // BindingExpressions are a map of of binding expression paths to their corresponding values (as go templates) + BindingExpressions map[string]string } type genProjectFileContext struct { diff --git a/cli/azd/pkg/project/dotnet_importer.go b/cli/azd/pkg/project/dotnet_importer.go index 524072debd9..bf812f5aa1d 100644 --- a/cli/azd/pkg/project/dotnet_importer.go +++ b/cli/azd/pkg/project/dotnet_importer.go @@ -517,6 +517,11 @@ func (ai *DotNetImporter) SynthAllInfrastructure( return fmt.Errorf("generating containerApp.tmpl.yaml for resource %s: %w", name, err) } + bicepManifest, err := apphost.BicepModuleForProject(manifest, name, apphost.AppHostOptions{}) + if err != nil { + return fmt.Errorf("generating bicep module for resource %s: %w", name, err) + } + normalPath, err := filepath.EvalSymlinks(svcConfig.Path()) if err != nil { return err @@ -528,12 +533,18 @@ func (ai *DotNetImporter) SynthAllInfrastructure( } manifestPath := filepath.Join(filepath.Dir(projectRelPath), "infra", fmt.Sprintf("%s.tmpl.yaml", name)) + bicepPath := filepath.Join(filepath.Dir(projectRelPath), "infra", fmt.Sprintf("%s.bicep", 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 generatedFS.WriteFile(bicepPath, []byte(bicepManifest), osutil.PermissionFileOwnerOnly) } for name := range apphost.ProjectPaths(manifest) { diff --git a/cli/azd/resources/apphost/templates/containerApp.bicept b/cli/azd/resources/apphost/templates/containerApp.bicept new file mode 100644 index 00000000000..0212ee58b98 --- /dev/null +++ b/cli/azd/resources/apphost/templates/containerApp.bicept @@ -0,0 +1,209 @@ +{{define "containerApp.bicep" -}} +@description('') +param location string = resourceGroup().location + +@metadata({azd: { defaultValueExpr: '{{ "{{ .Env.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID }}" }}' } }) +param principalId string + +@metadata({azd : { defaultValueExpr: '{{ "{{ .Env.MANAGED_IDENTITY_CLIENT_ID }}" }}' } }) +param principalClientId string + +@metadata({azd: { defaultValueExpr: '{{ "{{ .Env.AZURE_CONTAINER_APPS_ENVIRONMENT_ID }}" }}' } }) +param environmentId string + +@metadata({azd: { defaultValueExpr: '{{ "{{ .Env.AZURE_CONTAINER_REGISTRY_ENDPOINT }}" }}' } }) +param containerRegistryEndpoint string + +@metadata({azd: { defaultValueExpr: '{{ "{{ .Image }}" }}' } }) +param image string + +@metadata({azd: { defaultValueExpr: '{{ .TargetPortExpression }}' } }) +param targetPort int + +{{- range $volume := .Volumes }} +@metadata({azd: { defaultValueExpr: '{{ "{{ .Env.SERVICE_" }}{{ alphaSnakeUpper $.Name}}_VOLUME_{{ removeDot $volume.Name | alphaSnakeUpper }}{{ "_NAME }}"}}' } }) +param volume_{{ $volume.Name }} string + +{{- end}} +{{- range $bMount := .BindMounts}} +@metadata({azd: { defaultValueExpr: '{{ "{{ .Env.SERVICE_" }}{{ alphaSnakeUpper $.Name}}_VOLUME_{{ removeDot $bMount.Name | alphaSnakeUpper }}{{ "_NAME }}"}}' } }) +param bindMount_{{ $bMount.Name }} string + +{{- end}} + +{{- range $key, $value := .BindingExpressions}} +@metadata({azd: { defaultValueExpr: '{{ $value }}' } }) +param {{ $key }} string + +{{- end}} + +resource app 'Microsoft.App/containerApps@2024-02-02-preview' = { + name: '{{ .Name }}' + location: location + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${principalId}': {} + } + } + properties: { + environmentId: environmentId + configuration: { + activeRevisionsMode: 'Single' + runtime: { + dotnet: { + autoConfigureDataProtection: true + } + } +{{- if .Dapr}} + dapr: { + appId: '{{ .Dapr.AppId }}' +{{- if .Dapr.AppPort}} + appPort: {{ .Dapr.AppPort }} +{{- end}} +{{- if .Dapr.AppProtocol}} + appProtocol: '{{ .Dapr.AppProtocol }}' +{{- end}} +{{- if .Dapr.EnableApiLogging}} + enableApiLogging: {{ .Dapr.EnableApiLogging }} +{{- end}} + enabled: true +{{- if .Dapr.HttpMaxRequestSize}} + httpMaxRequestSize: {{ .Dapr.HttpMaxRequestSize }} +{{- end}} +{{- if .Dapr.HttpReadBufferSize}} + httpReadBufferSize: {{ .Dapr.HttpReadBufferSize }} +{{- end}} +{{- if .Dapr.LogLevel}} + logLevel: '{{ .Dapr.LogLevel }}' +{{- end}} + } +{{- end}} +{{- if .Ingress}} + ingress: { +{{- if .Ingress.AdditionalPortMappings }} + additionalPortMappings: [ +{{- range $additionalPort := .Ingress.AdditionalPortMappings }} + { + targetPort: {{ $additionalPort.TargetPort }} + external: {{ $additionalPort.External }} + } +{{- end}} + ] +{{- end}} + external: {{ .Ingress.External }} + targetPort: targetPort +{{- if gt .Ingress.ExposedPort 0 }} + exposedPort: {{ .Ingress.ExposedPort }} +{{- end}} + transport: '{{ .Ingress.Transport }}' + allowInsecure: {{ .Ingress.AllowInsecure }} + } +{{- end }} + registries: [ + { + server: containerRegistryEndpoint + identity: principalId + } + ] +{{- if or (gt (len .Secrets) 0) (gt (len .KeyVaultSecrets) 0) }} + secrets: [ +{{- range $name, $value := .Secrets}} + { + name: '{{containerAppSecretName $name}}' + value: '{{$value}}' + } +{{- end}} +{{- range $name, $value := .KeyVaultSecrets}} + { + name: '{{containerAppSecretName $name}}' + keyVaultUrl: '{{$value}}' + identity: principalId + } +{{- end}} + ] +{{- end}} + } + template: { +{{- if or (.Volumes) (.BindMounts) }} + volumes: [ +{{- range $volume := .Volumes }} + { + name: {{ toLower $.Name}}-{{ removeDot $volume.Name | toLower }} + storageType: AzureFile + storageName: volume_{{ $volume.Name }} + } +{{- end}} +{{- range $bMount := .BindMounts}} + { + name: {{ toLower $.Name}}-{{ removeDot $bMount.Name | toLower }} + storageType: AzureFile + storageName: bindMount_{{ $bMount.Name }} + } +{{- end}} + ] +{{- end}} + containers: [ + { + image: image + name: '{{ .Name }}' +{{- if .Args }} + args: [ +{{- range $arg := .Args}} + '{{$arg}}' +{{- end}} + ] +{{- end}} + env: [ + { + name: 'AZURE_CLIENT_ID' + value: principalClientId + } +{{- range $name, $value := .Env}} + { + name: '{{$name}}' + value: '{{$value}}' + } +{{- end}} +{{- range $name, $value := .Secrets}} + { + name: '{{$name}}' + secretRef: '{{containerAppSecretName $name}}' + } +{{- end}} +{{- range $name, $value := .KeyVaultSecrets}} + { + name: '{{$name}}' + secretRef: '{{containerAppSecretName $name}}' + } +{{- end}} + ] +{{- if or (.Volumes) (.BindMounts) }} + volumeMounts: [ +{{- range $volume := .Volumes }} + { + volumeName: '{{ toLower $.Name}}-{{ removeDot $volume.Name | toLower }}' + mountPath: '{{ $volume.Target }}' + } +{{- end}} +{{- range $bMount := .BindMounts }} + { + volumeName: '{{ toLower $.Name}}-{{ removeDot $bMount.Name | toLower }}' + mountPath: '{{ $bMount.Target }}' + } +{{- end}} + ] +{{- end}} + } + ] + scale: { + minReplicas: 1 + } + } + } + tags: { + 'azd-service-name': '{{ .Name }}' + 'aspire-resource-name': '{{ .Name }}' + } +} +{{end}} \ No newline at end of file