Skip to content

Commit

Permalink
WIP: aspire: Use bicep for container app deployment
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
ellismg committed Sep 3, 2024
1 parent b5bbb0c commit f9ce84d
Show file tree
Hide file tree
Showing 4 changed files with 353 additions and 27 deletions.
155 changes: 129 additions & 26 deletions cli/azd/pkg/apphost/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -430,6 +473,8 @@ func GenerateProjectArtifacts(
}

type infraGenerator struct {
skipAsYamlString bool

containers map[string]genContainer
dapr map[string]genDapr
dockerfiles map[string]genDockerfile
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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:
//
Expand All @@ -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 {

Check failure on line 1677 in cli/azd/pkg/apphost/generate.go

View workflow job for this annotation

GitHub Actions / azd-lint (ubuntu-latest)

S1002: should omit comparison to bool constant, can be simplified to `b.skipAsYamlString` (gosimple)

Check failure on line 1677 in cli/azd/pkg/apphost/generate.go

View workflow job for this annotation

GitHub Actions / azd-lint (windows-latest)

S1002: should omit comparison to bool constant, can be simplified to `b.skipAsYamlString` (gosimple)
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 {
Expand All @@ -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)
}
Expand All @@ -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 {

Check failure on line 1736 in cli/azd/pkg/apphost/generate.go

View workflow job for this annotation

GitHub Actions / azd-lint (ubuntu-latest)

S1002: should omit comparison to bool constant, can be simplified to `b.skipAsYamlString` (gosimple)

Check failure on line 1736 in cli/azd/pkg/apphost/generate.go

View workflow job for this annotation

GitHub Actions / azd-lint (windows-latest)

S1002: should omit comparison to bool constant, can be simplified to `b.skipAsYamlString` (gosimple)
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)
}
Expand Down Expand Up @@ -1698,6 +1800,7 @@ func (b *infraGenerator) buildEnvBlock(env map[string]string, manifestCtx *genCo
manifestCtx.Secrets[k] = resolvedValue
continue
}

manifestCtx.Env[k] = resolvedValue
}

Expand Down
3 changes: 3 additions & 0 deletions cli/azd/pkg/apphost/generate_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 12 additions & 1 deletion cli/azd/pkg/project/dotnet_importer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down
Loading

0 comments on commit f9ce84d

Please sign in to comment.