cli/azd/pkg/project/scaffold_gen.go (593 lines of code) (raw):

// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. package project import ( "context" "fmt" "io/fs" "maps" "os" "path/filepath" "slices" "strings" "github.com/azure/azure-dev/cli/azd/internal/scaffold" "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/infra" "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/psanford/memfs" ) // Generates the in-memory contents of an `infra` directory. func infraFs(_ context.Context, prjConfig *ProjectConfig) (fs.FS, error) { t, err := scaffold.Load() if err != nil { return nil, fmt.Errorf("loading scaffold templates: %w", err) } infraSpec, err := infraSpec(prjConfig) if err != nil { return nil, fmt.Errorf("generating infrastructure spec: %w", err) } files, err := scaffold.ExecInfraFs(t, *infraSpec) if err != nil { return nil, fmt.Errorf("executing scaffold templates: %w", err) } return files, nil } // Returns the infrastructure configuration that points to a temporary, generated `infra` directory on the filesystem. func tempInfra( ctx context.Context, prjConfig *ProjectConfig) (*Infra, error) { tmpDir, err := os.MkdirTemp("", "azd-infra") if err != nil { return nil, fmt.Errorf("creating temporary directory: %w", err) } files, err := infraFs(ctx, prjConfig) if err != nil { return nil, err } err = fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } target := filepath.Join(tmpDir, path) if err := os.MkdirAll(filepath.Dir(target), osutil.PermissionDirectoryOwnerOnly); err != nil { return err } contents, err := fs.ReadFile(files, path) if err != nil { return err } return os.WriteFile(target, contents, osutil.PermissionFile) }) if err != nil { return nil, fmt.Errorf("writing infrastructure: %w", err) } return &Infra{ Options: provisioning.Options{ Provider: provisioning.Bicep, Path: tmpDir, Module: DefaultModule, }, cleanupDir: tmpDir, IsCompose: true, }, nil } // Generates the filesystem of all infrastructure files to be placed, rooted at the project directory. // The content only includes `./infra` currently. func infraFsForProject(ctx context.Context, prjConfig *ProjectConfig) (fs.FS, error) { infraFS, err := infraFs(ctx, prjConfig) if err != nil { return nil, err } infraPathPrefix := DefaultPath if prjConfig.Infra.Path != "" { infraPathPrefix = prjConfig.Infra.Path } // root the generated content at the project directory generatedFS := memfs.New() err = fs.WalkDir(infraFS, ".", func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } err = generatedFS.MkdirAll(filepath.Join(infraPathPrefix, filepath.Dir(path)), osutil.PermissionDirectoryOwnerOnly) if err != nil { return err } contents, err := fs.ReadFile(infraFS, path) if err != nil { return err } return generatedFS.WriteFile(filepath.Join(infraPathPrefix, path), contents, d.Type().Perm()) }) if err != nil { return nil, err } return generatedFS, nil } func infraSpec(projectConfig *ProjectConfig) (*scaffold.InfraSpec, error) { infraSpec := scaffold.InfraSpec{} existingMap := map[string]*scaffold.ExistingResource{} // backends -> frontends backendMapping := map[string]string{} // Create a "virtual" copy since we're adding any implicitly dependent resources // that are unrepresented by the current user-provided schema resources := maps.Clone(projectConfig.Resources) keys := slices.Sorted(maps.Keys(resources)) // First pass for _, k := range keys { res := resources[k] // Add any implicit dependencies dependencies := DependentResourcesOf(res) for _, dep := range dependencies { if _, exists := resources[dep.Name]; !exists { resources[dep.Name] = dep } } if res.Existing { // handle existing flow resourceMeta, ok := scaffold.ResourceMetaFromType(res.Type.AzureResourceType()) if !ok { return nil, fmt.Errorf("resource type '%s' is not currently supported for existing", string(res.Type)) } existing := scaffold.ExistingResource{ Name: "existing" + scaffold.BicepNameInfix(res.Name), ApiVersion: resourceMeta.ApiVersion, ResourceIdEnvVar: infra.ResourceIdName(res.Name), ResourceType: resourceMeta.ResourceType, RoleAssignments: resourceMeta.RoleAssignments.Write, } if resourceMeta.ParentForEval != "" { existing.ResourceType = resourceMeta.ParentForEval } if res.Type == ResourceTypeKeyVault { // For Key Vault, we grant read access to secrets by default existing.RoleAssignments = resourceMeta.RoleAssignments.Read } infraSpec.Existing = append(infraSpec.Existing, existing) existingMap[res.Name] = &existing continue } } for _, k := range keys { res := resources[k] if res.Existing { continue } switch res.Type { case ResourceTypeDbRedis: infraSpec.DbRedis = &scaffold.DatabaseRedis{} case ResourceTypeDbMongo: infraSpec.DbCosmosMongo = &scaffold.DatabaseCosmosMongo{ DatabaseName: res.Name, } case ResourceTypeDbCosmos: props := res.Props.(CosmosDBProps) containers := make([]scaffold.CosmosSqlDatabaseContainer, 0) for _, c := range props.Containers { containers = append(containers, scaffold.CosmosSqlDatabaseContainer{ ContainerName: c.Name, PartitionKeyPaths: c.PartitionKeys, }) } infraSpec.DbCosmos = &scaffold.DatabaseCosmos{ DatabaseName: res.Name, Containers: containers, } case ResourceTypeDbPostgres: infraSpec.DbPostgres = &scaffold.DatabasePostgres{ DatabaseName: res.Name, } case ResourceTypeDbMySql: infraSpec.DbMySql = &scaffold.DatabaseMysql{ DatabaseName: res.Name, } case ResourceTypeHostAppService: svcConfig, ok := projectConfig.Services[res.Name] if !ok { return nil, fmt.Errorf("service %s not found in project config", res.Name) } svcSpec := scaffold.ServiceSpec{ Name: res.Name, Port: -1, Env: map[string]string{}, Host: scaffold.AppServiceKind, } err := mapAppService(res, &svcSpec, &infraSpec, svcConfig) if err != nil { return nil, err } err = mapHostUses(res, &svcSpec, backendMapping, existingMap, projectConfig) if err != nil { return nil, err } infraSpec.Services = append(infraSpec.Services, svcSpec) case ResourceTypeHostContainerApp: svcSpec := scaffold.ServiceSpec{ Name: res.Name, Port: -1, Env: map[string]string{}, Host: scaffold.ContainerAppKind, } err := mapContainerApp(res, &svcSpec, &infraSpec) if err != nil { return nil, err } err = mapHostUses(res, &svcSpec, backendMapping, existingMap, projectConfig) if err != nil { return nil, err } infraSpec.Services = append(infraSpec.Services, svcSpec) case ResourceTypeOpenAiModel: props := res.Props.(AIModelProps) if len(props.Model.Name) == 0 { return nil, fmt.Errorf("resources.%s.model is required", res.Name) } if len(props.Model.Version) == 0 { return nil, fmt.Errorf("resources.%s.version is required", res.Name) } infraSpec.AIModels = append(infraSpec.AIModels, scaffold.AIModel{ Name: res.Name, Model: scaffold.AIModelModel{ Name: props.Model.Name, Version: props.Model.Version, }, }) case ResourceTypeMessagingEventHubs: if infraSpec.EventHubs != nil { return nil, fmt.Errorf("only one event hubs resource is currently allowed") } props := res.Props.(EventHubsProps) infraSpec.EventHubs = &scaffold.EventHubs{ Hubs: props.Hubs, } case ResourceTypeMessagingServiceBus: if infraSpec.ServiceBus != nil { return nil, fmt.Errorf("only one service bus resource is currently allowed") } props := res.Props.(ServiceBusProps) infraSpec.ServiceBus = &scaffold.ServiceBus{ Queues: props.Queues, Topics: props.Topics, } case ResourceTypeStorage: if infraSpec.StorageAccount != nil { return nil, fmt.Errorf("only one storage account resource is currently allowed") } props := res.Props.(StorageProps) infraSpec.StorageAccount = &scaffold.StorageAccount{ Containers: props.Containers, } case ResourceTypeAiProject: // It's okay to forcefully panic here. The only way we would land here is that the marshal/unmarshal // in resources.go was not done right. props := res.Props.(AiFoundryModelProps) foundryName := res.Name var foundryModels []scaffold.AiFoundryModel foundrySpec := scaffold.AiFoundrySpec{ Name: foundryName, } for _, model := range props.Models { foundryModels = append(foundryModels, scaffold.AiFoundryModel{ AIModelModel: scaffold.AIModelModel{ Name: model.Name, Version: model.Version, }, Format: model.Format, Sku: scaffold.AiFoundryModelSku{ Name: model.Sku.Name, UsageName: model.Sku.UsageName, Capacity: model.Sku.Capacity, }, }) } foundrySpec.Models = foundryModels infraSpec.AiFoundryProject = &foundrySpec case ResourceTypeKeyVault: infraSpec.KeyVault = &scaffold.KeyVault{} case ResourceTypeAiSearch: infraSpec.AISearch = &scaffold.AISearch{} } } // create reverse frontends -> backends mapping for i := range infraSpec.Services { svc := &infraSpec.Services[i] if front, ok := backendMapping[svc.Name]; ok { if svc.Backend == nil { svc.Backend = &scaffold.Backend{} } svc.Backend.Frontends = append(svc.Backend.Frontends, scaffold.ServiceReference{Name: front}) } } slices.SortFunc(infraSpec.Services, func(a, b scaffold.ServiceSpec) int { return strings.Compare(a.Name, b.Name) }) return &infraSpec, nil } // mergeDefaultEnvVars combines default environment variables with user-provided ones. func mergeDefaultEnvVars(defaultEnv map[string]string, userEnv []ServiceEnvVar) []ServiceEnvVar { // Map to track which env vars are provided by the user userEnvMap := make(map[string]struct{}, len(userEnv)) for _, env := range userEnv { userEnvMap[env.Name] = struct{}{} } combinedEnv := make([]ServiceEnvVar, 0, len(defaultEnv)+len(userEnv)) // Add default env vars that aren't overridden for name, value := range defaultEnv { if _, overridden := userEnvMap[name]; !overridden { combinedEnv = append(combinedEnv, ServiceEnvVar{ Name: name, Value: value, }) } } // Add user-provided env vars combinedEnv = append(combinedEnv, userEnv...) return combinedEnv } func mapHostProps( res *ResourceConfig, svcSpec *scaffold.ServiceSpec, infraSpec *scaffold.InfraSpec, port int, env []ServiceEnvVar, ) error { for _, envVar := range env { if len(envVar.Value) == 0 && len(envVar.Secret) == 0 { return fmt.Errorf( "environment variable %s for host %s is invalid: both value and secret are empty", envVar.Name, res.Name) } if len(envVar.Value) > 0 && len(envVar.Secret) > 0 { return fmt.Errorf( "environment variable %s for host %s is invalid: both value and secret are set", envVar.Name, res.Name) } isSecret := len(envVar.Secret) > 0 value := envVar.Value if isSecret { value = envVar.Secret } // Notice that we derive isSecret from its usage. // This is generally correct, except for the case where: // - CONNECTION_STRING: ${DB_HOST}:${DB_SECRET} // Here, DB_HOST is not a secret, but DB_SECRET is. And yet, DB_HOST will be marked as a secret. // This is a limitation of the current implementation, but it's safer to mark both as secrets above. evaluatedValue := genBicepParamsFromEnvSubst(value, isSecret, infraSpec) svcSpec.Env[envVar.Name] = evaluatedValue } if port < 1 || port > 65535 { return fmt.Errorf("port value %d for host %s must be between 1 and 65535", port, res.Name) } svcSpec.Port = port return nil } func mapContainerApp(res *ResourceConfig, svcSpec *scaffold.ServiceSpec, infraSpec *scaffold.InfraSpec) error { props := res.Props.(ContainerAppProps) return mapHostProps(res, svcSpec, infraSpec, props.Port, props.Env) } func mapAppService( res *ResourceConfig, svcSpec *scaffold.ServiceSpec, infraSpec *scaffold.InfraSpec, svcConfig *ServiceConfig, ) error { props := res.Props.(AppServiceProps) if len(props.Runtime.Stack) == 0 { return fmt.Errorf("resources.%s.runtime.type is required", res.Name) } if len(props.Runtime.Version) == 0 { return fmt.Errorf("resources.%s.runtime.version is required", res.Name) } svcSpec.Runtime = &scaffold.RuntimeInfo{ Type: string(props.Runtime.Stack), Version: props.Runtime.Version, } svcSpec.StartupCommand = props.StartupCommand defaultEnv := map[string]string{ "SCM_DO_BUILD_DURING_DEPLOYMENT": "true", "ENABLE_ORYX_BUILD": "true", } // Language-specific environment variables if svcConfig.Language == ServiceLanguagePython { defaultEnv["PYTHON_ENABLE_GUNICORN_MULTIWORKERS"] = "true" } combinedEnv := mergeDefaultEnvVars(defaultEnv, props.Env) if err := mapHostProps(res, svcSpec, infraSpec, props.Port, combinedEnv); err != nil { return err } return nil } func mapHostUses( res *ResourceConfig, svcSpec *scaffold.ServiceSpec, backendMapping map[string]string, existingMap map[string]*scaffold.ExistingResource, prj *ProjectConfig) error { for _, use := range res.Uses { useRes, ok := prj.Resources[use] if !ok { return fmt.Errorf("resource %s uses %s, which does not exist", res.Name, use) } if useRes.Existing { resourceMeta, ok := scaffold.ResourceMetaFromType(useRes.Type.AzureResourceType()) if !ok { return fmt.Errorf("resource type '%s' is not currently supported for existing", string(res.Type)) } existingDecl := existingMap[use] emitEnv := EmitEnv{FuncMap: scaffold.BaseEmitBicepFuncMap(), ResourceVarName: existingDecl.Name} emitter := func(val *scaffold.ExpressionVar, results map[string]string) error { return emitVariable(emitEnv, val, results) } results, err := scaffold.EmitBicep(resourceMeta.Variables, emitter) if err != nil { return fmt.Errorf("emitting bicep bindings for '%s': %w", useRes.Name, err) } for key, value := range results { envKey := scaffold.EnvVarName( fmt.Sprintf("%s_%s", resourceMeta.StandardVarPrefix, environment.Key(use)), key) if envValue, exists := svcSpec.Env[envKey]; exists { panic(fmt.Sprintf( "env collision: env value %s already set to %s, cannot set to %s", envKey, envValue, value)) } svcSpec.Env[envKey] = value } svcSpec.Existing = append(svcSpec.Existing, existingDecl) continue } switch useRes.Type { case ResourceTypeDbMongo: svcSpec.DbCosmosMongo = &scaffold.DatabaseReference{DatabaseName: useRes.Name} case ResourceTypeDbCosmos: svcSpec.DbCosmos = &scaffold.DatabaseReference{DatabaseName: useRes.Name} case ResourceTypeDbPostgres: svcSpec.DbPostgres = &scaffold.DatabaseReference{DatabaseName: useRes.Name} case ResourceTypeDbMySql: svcSpec.DbMySql = &scaffold.DatabaseReference{DatabaseName: useRes.Name} case ResourceTypeDbRedis: svcSpec.DbRedis = &scaffold.DatabaseReference{DatabaseName: useRes.Name} case ResourceTypeHostAppService, ResourceTypeHostContainerApp: if svcSpec.Frontend == nil { svcSpec.Frontend = &scaffold.Frontend{} } svcSpec.Frontend.Backends = append(svcSpec.Frontend.Backends, scaffold.ServiceReference{Name: use}) backendMapping[use] = res.Name // record the backend -> frontend mapping case ResourceTypeOpenAiModel: svcSpec.AIModels = append(svcSpec.AIModels, scaffold.AIModelReference{Name: use}) case ResourceTypeMessagingEventHubs: svcSpec.EventHubs = &scaffold.EventHubs{} case ResourceTypeMessagingServiceBus: svcSpec.ServiceBus = &scaffold.ServiceBus{} case ResourceTypeStorage: svcSpec.StorageAccount = &scaffold.StorageReference{} case ResourceTypeAiProject: svcSpec.AiFoundryProject = &scaffold.AiFoundrySpec{} case ResourceTypeAiSearch: svcSpec.AISearch = &scaffold.AISearchReference{} case ResourceTypeKeyVault: svcSpec.KeyVault = &scaffold.KeyVaultReference{} } } return nil } type EmitEnv struct { // The function map to use for evaluating expressions. FuncMap scaffold.FuncMap // ResourceVarName is the name of Bicep symbol to assign property expressions. ResourceVarName string } func emitVariable(emitEnv EmitEnv, val *scaffold.ExpressionVar, results map[string]string) error { if len(val.Expressions) == 0 { // literal value, surround with quotes val.Value = fmt.Sprintf("'%s'", val.Value) return nil } // by default, surround each expression with ${} within a Bicep interpolated string surround := func(s string) string { return fmt.Sprintf("${%s}", s) } // when the expression is a single expression that covers the entire value, don't surround it if len(val.Expressions) == 1 && val.Expressions[0].Start == 0 && val.Expressions[0].End == len(val.Value) { surround = func(s string) string { return s } } for _, expr := range val.Expressions { err := emitVariableExpression(emitEnv, val.Key, expr, surround, results) if err != nil { return fmt.Errorf("evaluating expression '%s': %w", val.Key, err) } } if isBicepInterpolatedString(val.Value) { // If the final value contains any interpolation ${}, we wrap the final value with quotes. // // By doing this "reflection-like" behavior of examining the output string, // we allow functions to be composable while respecting string-interpolation rules. // // Regardless of whether an interpolation was emitted as part of a function expression, // or because we surrounded values here, we will detect all cases and wrap the final value nicely. val.Value = fmt.Sprintf("'%s'", val.Value) } return nil } func emitVariableExpression( env EmitEnv, key string, expr *scaffold.Expression, surround func(string) string, results map[string]string) error { switch expr.Kind { case scaffold.PropertyExpr: path := expr.Data.(scaffold.PropertyExprData).PropertyPath expr.Replace(surround(fmt.Sprintf("%s.%s", env.ResourceVarName, path))) case scaffold.VarExpr: name := expr.Data.(scaffold.VarExprData).Name expr.Replace(surround(results[name])) case scaffold.FuncExpr: funcData := expr.Data.(scaffold.FuncExprData) funcName := funcData.FuncName // Check if function exists fn, ok := env.FuncMap[funcName] if !ok { return fmt.Errorf("unknown function: %s", funcName) } // for arguments of a function, we return the value as literal. // the interpolation (if any) is done in the function itself. id := func(s string) string { return s } // Evaluate all arguments args := make([]interface{}, 0, len(funcData.Args)) for _, arg := range funcData.Args { err := emitVariableExpression(env, key, arg, id, results) if err != nil { return fmt.Errorf("evaluating arguments for '%s': %w", funcName, err) } args = append(args, arg.Value) } // Call the function funcResult, err := scaffold.CallFn(fn, funcName, args) if err != nil { return fmt.Errorf("calling '%s' failed: %w", funcName, err) } resultString := fmt.Sprintf("%v", funcResult) expr.Replace(surround(resultString)) case scaffold.SpecExpr: return fmt.Errorf("spec expressions are not currently supported in existing resources") case scaffold.VaultExpr: return fmt.Errorf("vault expressions are not currently supported in existing resources") } return nil } // isBicepInterpolatedString checks if a string contains any Bicep interpolation expressions. // // Bicep interpolation expressions are of the form ${expression}, // and are not escaped with a backslash. func isBicepInterpolatedString(s string) bool { for i, r := range s { if r == '$' && i+1 < len(s) && s[i+1] == '{' && // we see '${' i-1 >= 0 && s[i-1] != '\\' { // we do not see escaped '\${' return true } } return false } func setParameter(spec *scaffold.InfraSpec, name string, value string, isSecret bool) { for _, parameters := range spec.Parameters { if parameters.Name == name { // handle existing parameter if isSecret && !parameters.Secret { // escalate the parameter to a secret parameters.Secret = true } // prevent auto-generated parameters from being overwritten with different values if valStr, ok := parameters.Value.(string); !ok || ok && valStr != value { // if you are a maintainer and run into this error, consider using a different, unique name panic(fmt.Sprintf( "parameter collision: parameter %s already set to %s, cannot set to %s", name, parameters.Value, value)) } return } } spec.Parameters = append(spec.Parameters, scaffold.Parameter{ Name: name, Value: value, Type: "string", Secret: isSecret, }) } // genBicepParamsFromEnvSubst generates Bicep input parameters from a string containing envsubst expression(s), // returning the substituted string that references these parameters. // // If the string is a literal, it is returned as is. // If isSecret is true, the parameter is marked as a secret. func genBicepParamsFromEnvSubst( s string, isSecret bool, infraSpec *scaffold.InfraSpec) string { names, locations := parseEnvSubstVariables(s) // add all expressions as parameters for i, name := range names { expression := s[locations[i].start : locations[i].stop+1] setParameter(infraSpec, scaffold.BicepName(name), expression, isSecret) } var result string if len(names) == 0 { // literal string with no expressions, quote the value as a Bicep string result = "'" + s + "'" } else if len(names) == 1 { // single expression, return the bicep parameter name to reference the expression result = scaffold.BicepName(names[0]) } else { // multiple expressions // construct the string with all expressions replaced by parameter references as a Bicep interpolated string previous := 0 result = "'" for i, loc := range locations { // replace each expression with references by variable name result += s[previous:loc.start] result += "${" result += scaffold.BicepName(names[i]) result += "}" previous = loc.stop + 1 } result += "'" } return result } // DependentResourcesOf returns implicit resource dependencies for a given resource type. // These dependencies (like Key Vault to store connection strings, passwords for databases) // are automatically added to the project configuration. Returns an empty slice if none exist. func DependentResourcesOf(resource *ResourceConfig) []*ResourceConfig { switch resource.Type { case ResourceTypeDbMongo, ResourceTypeDbMySql, ResourceTypeDbPostgres, ResourceTypeDbRedis: return []*ResourceConfig{{Name: "vault", Type: ResourceTypeKeyVault}} default: return nil } }