From 40c305d4e97a67d621b3a3c62bd394a7b66e93ee Mon Sep 17 00:00:00 2001 From: Rujun Chen Date: Thu, 28 Nov 2024 12:26:31 +0800 Subject: [PATCH] Avoid maintain env list in multiple place (#46) --- cli/azd/internal/appdetect/spring_boot.go | 2 +- cli/azd/internal/scaffold/bicep_env.go | 214 +++++++ cli/azd/internal/scaffold/bicep_env_test.go | 172 ++++++ cli/azd/internal/scaffold/scaffold.go | 16 +- cli/azd/internal/scaffold/spec.go | 70 ++- cli/azd/internal/scaffold/spec_test.go | 94 +++ cli/azd/pkg/project/importer.go | 9 +- cli/azd/pkg/project/importer_test.go | 13 +- cli/azd/pkg/project/scaffold_gen.go | 363 ++++-------- .../scaffold_gen_environment_variables.go | 552 ++++++++++++++++++ ...scaffold_gen_environment_variables_test.go | 92 +++ cli/azd/pkg/project/scaffold_gen_test.go | 12 +- ...ent-hubs-namespace-connection-string.bicep | 2 + .../base/modules/set-redis-conn.bicep | 3 + ...rvicebus-namespace-connection-string.bicep | 2 + ...et-storage-account-connection-string.bicep | 2 + .../scaffold/templates/resources.bicept | 379 ++---------- 17 files changed, 1380 insertions(+), 617 deletions(-) create mode 100644 cli/azd/internal/scaffold/bicep_env.go create mode 100644 cli/azd/internal/scaffold/bicep_env_test.go create mode 100644 cli/azd/internal/scaffold/spec_test.go create mode 100644 cli/azd/pkg/project/scaffold_gen_environment_variables.go create mode 100644 cli/azd/pkg/project/scaffold_gen_environment_variables_test.go diff --git a/cli/azd/internal/appdetect/spring_boot.go b/cli/azd/internal/appdetect/spring_boot.go index d2e4d03ba4e..4f97c1ff23b 100644 --- a/cli/azd/internal/appdetect/spring_boot.go +++ b/cli/azd/internal/appdetect/spring_boot.go @@ -188,7 +188,7 @@ func detectEventHubsAccordingToSpringCloudStreamBinderMavenDependency( func detectEventHubsAccordingToSpringCloudStreamKafkaMavenDependency( azdProject *Project, springBootProject *SpringBootProject) { - var targetGroupId = "com.azure.spring" + var targetGroupId = "org.springframework.cloud" var targetArtifactId = "spring-cloud-starter-stream-kafka" if hasDependency(springBootProject, targetGroupId, targetArtifactId) { bindingDestinations := getBindingDestinationMap(springBootProject.applicationProperties) diff --git a/cli/azd/internal/scaffold/bicep_env.go b/cli/azd/internal/scaffold/bicep_env.go new file mode 100644 index 00000000000..330f83cbb60 --- /dev/null +++ b/cli/azd/internal/scaffold/bicep_env.go @@ -0,0 +1,214 @@ +package scaffold + +import ( + "fmt" + "github.com/azure/azure-dev/cli/azd/internal" + "strings" +) + +func ToBicepEnv(env Env) BicepEnv { + if isResourceConnectionEnv(env.Value) { + resourceType, resourceInfoType := toResourceConnectionInfo(env.Value) + value, ok := bicepEnv[resourceType][resourceInfoType] + if !ok { + panic(unsupportedType(env)) + } + if isSecret(resourceInfoType) { + if isKeyVaultSecret(value) { + return BicepEnv{ + BicepEnvType: BicepEnvTypeKeyVaultSecret, + Name: env.Name, + SecretName: secretName(env), + SecretValue: unwrapKeyVaultSecretValue(value), + } + } else { + return BicepEnv{ + BicepEnvType: BicepEnvTypeSecret, + Name: env.Name, + SecretName: secretName(env), + SecretValue: value, + } + } + } else { + return BicepEnv{ + BicepEnvType: BicepEnvTypePlainText, + Name: env.Name, + PlainTextValue: value, + } + } + } else { + return BicepEnv{ + BicepEnvType: BicepEnvTypePlainText, + Name: env.Name, + PlainTextValue: toBicepEnvPlainTextValue(env.Value), + } + } +} + +func ShouldAddToBicepFile(spec ServiceSpec, name string) bool { + return !willBeAddedByServiceConnector(spec, name) +} + +func willBeAddedByServiceConnector(spec ServiceSpec, name string) bool { + if (spec.DbPostgres != nil && spec.DbPostgres.AuthType == internal.AuthTypeUserAssignedManagedIdentity) || + (spec.DbMySql != nil && spec.DbMySql.AuthType == internal.AuthTypeUserAssignedManagedIdentity) { + return name == "spring.datasource.url" || + name == "spring.datasource.username" || + name == "spring.datasource.azure.passwordless-enabled" + } else { + return false + } +} + +// inputStringExample -> 'inputStringExample' +func addQuotation(input string) string { + return fmt.Sprintf("'%s'", input) +} + +// 'inputStringExample' -> 'inputStringExample' +// '${inputSingleVariableExample}' -> inputSingleVariableExample +// '${HOST}:${PORT}' -> '${HOST}:${PORT}' +func removeQuotationIfItIsASingleVariable(input string) string { + prefix := "'${" + suffix := "}'" + if strings.HasPrefix(input, prefix) && strings.HasSuffix(input, suffix) { + prefixTrimmed := strings.TrimPrefix(input, prefix) + trimmed := strings.TrimSuffix(prefixTrimmed, suffix) + if strings.IndexAny(trimmed, "}") == -1 { + return trimmed + } else { + return input + } + } else { + return input + } +} + +// The BicepEnv.PlainTextValue is handled as variable by default. +// If the value is string, it should contain ('). +// Here are some examples of input and output: +// inputStringExample -> 'inputStringExample' +// ${inputSingleVariableExample} -> inputSingleVariableExample +// ${HOST}:${PORT} -> '${HOST}:${PORT}' +func toBicepEnvPlainTextValue(input string) string { + return removeQuotationIfItIsASingleVariable(addQuotation(input)) +} + +// BicepEnv +// +// For Name and SecretName, they are handled as string by default. +// Which means quotation will be added before they are used in bicep file, because they are always string value. +// +// For PlainTextValue and SecretValue, they are handled as variable by default. +// When they are string value, quotation should be contained by themselves. +// Set variable as default is mainly to avoid this problem: +// https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/linter-rule-simplify-interpolation +type BicepEnv struct { + BicepEnvType BicepEnvType + Name string + PlainTextValue string + SecretName string + SecretValue string +} + +type BicepEnvType string + +const ( + BicepEnvTypePlainText BicepEnvType = "plainText" + BicepEnvTypeSecret BicepEnvType = "secret" + BicepEnvTypeKeyVaultSecret BicepEnvType = "keyVaultSecret" +) + +// Note: The value is handled as variable. +// If the value is string, it should contain quotation inside itself. +var bicepEnv = map[ResourceType]map[ResourceInfoType]string{ + ResourceTypeDbPostgres: { + ResourceInfoTypeHost: "postgreServer.outputs.fqdn", + ResourceInfoTypePort: "'5432'", + ResourceInfoTypeDatabaseName: "postgreSqlDatabaseName", + ResourceInfoTypeUsername: "postgreSqlDatabaseUser", + ResourceInfoTypePassword: "postgreSqlDatabasePassword", + ResourceInfoTypeUrl: "'postgresql://${postgreSqlDatabaseUser}:${postgreSqlDatabasePassword}@${postgreServer.outputs.fqdn}:5432/${postgreSqlDatabaseName}'", + ResourceInfoTypeJdbcUrl: "'jdbc:postgresql://${postgreServer.outputs.fqdn}:5432/${postgreSqlDatabaseName}'", + }, + ResourceTypeDbMySQL: { + ResourceInfoTypeHost: "mysqlServer.outputs.fqdn", + ResourceInfoTypePort: "'3306'", + ResourceInfoTypeDatabaseName: "mysqlDatabaseName", + ResourceInfoTypeUsername: "mysqlDatabaseUser", + ResourceInfoTypePassword: "mysqlDatabasePassword", + ResourceInfoTypeUrl: "'mysql://${mysqlDatabaseUser}:${mysqlDatabasePassword}@${mysqlServer.outputs.fqdn}:3306/${mysqlDatabaseName}'", + ResourceInfoTypeJdbcUrl: "'jdbc:mysql://${mysqlServer.outputs.fqdn}:3306/${mysqlDatabaseName}'", + }, + ResourceTypeDbRedis: { + ResourceInfoTypeHost: "redis.outputs.hostName", + ResourceInfoTypePort: "string(redis.outputs.sslPort)", + ResourceInfoTypeEndpoint: "'${redis.outputs.hostName}:${redis.outputs.sslPort}'", + ResourceInfoTypePassword: wrapToKeyVaultSecretValue("redisConn.outputs.keyVaultUrlForPass"), + ResourceInfoTypeUrl: wrapToKeyVaultSecretValue("redisConn.outputs.keyVaultUrlForUrl"), + }, + ResourceTypeDbMongo: { + ResourceInfoTypeDatabaseName: "mongoDatabaseName", + ResourceInfoTypeUrl: wrapToKeyVaultSecretValue("cosmos.outputs.exportedSecrets['MONGODB-URL'].secretUri"), + }, + ResourceTypeDbCosmos: { + ResourceInfoTypeEndpoint: "cosmos.outputs.endpoint", + ResourceInfoTypeDatabaseName: "cosmosDatabaseName", + }, + ResourceTypeMessagingServiceBus: { + ResourceInfoTypeNamespace: "serviceBusNamespace.outputs.name", + ResourceInfoTypeConnectionString: wrapToKeyVaultSecretValue("serviceBusConnectionString.outputs.keyVaultUrl"), + }, + ResourceTypeMessagingEventHubs: { + ResourceInfoTypeNamespace: "eventHubNamespace.outputs.name", + ResourceInfoTypeConnectionString: wrapToKeyVaultSecretValue("eventHubsConnectionString.outputs.keyVaultUrl"), + }, + ResourceTypeMessagingKafka: { + ResourceInfoTypeEndpoint: "'${eventHubNamespace.outputs.name}.servicebus.windows.net:9093'", + ResourceInfoTypeConnectionString: wrapToKeyVaultSecretValue("eventHubsConnectionString.outputs.keyVaultUrl"), + }, + ResourceTypeStorage: { + ResourceInfoTypeAccountName: "storageAccountName", + ResourceInfoTypeConnectionString: wrapToKeyVaultSecretValue("storageAccountConnectionString.outputs.keyVaultUrl"), + }, + ResourceTypeOpenAiModel: { + ResourceInfoTypeEndpoint: "account.outputs.endpoint", + }, + ResourceTypeHostContainerApp: {}, +} + +func unsupportedType(env Env) string { + return fmt.Sprintf("unsupported connection info type for resource type. "+ + "value = %s", env.Value) +} + +func PlaceHolderForServiceIdentityClientId() string { + return "__PlaceHolderForServiceIdentityClientId" +} + +func isSecret(info ResourceInfoType) bool { + return info == ResourceInfoTypePassword || info == ResourceInfoTypeUrl || info == ResourceInfoTypeConnectionString +} + +func secretName(env Env) string { + resourceType, resourceInfoType := toResourceConnectionInfo(env.Value) + name := fmt.Sprintf("%s-%s", resourceType, resourceInfoType) + lowerCaseName := strings.ToLower(name) + noDotName := strings.Replace(lowerCaseName, ".", "-", -1) + noUnderscoreName := strings.Replace(noDotName, "_", "-", -1) + return noUnderscoreName +} + +var keyVaultSecretPrefix = "keyvault:" + +func isKeyVaultSecret(value string) bool { + return strings.HasPrefix(value, keyVaultSecretPrefix) +} + +func wrapToKeyVaultSecretValue(value string) string { + return fmt.Sprintf("%s%s", keyVaultSecretPrefix, value) +} + +func unwrapKeyVaultSecretValue(value string) string { + return strings.TrimPrefix(value, keyVaultSecretPrefix) +} diff --git a/cli/azd/internal/scaffold/bicep_env_test.go b/cli/azd/internal/scaffold/bicep_env_test.go new file mode 100644 index 00000000000..d93efd57e98 --- /dev/null +++ b/cli/azd/internal/scaffold/bicep_env_test.go @@ -0,0 +1,172 @@ +package scaffold + +import ( + "github.com/azure/azure-dev/cli/azd/internal" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestToBicepEnv(t *testing.T) { + tests := []struct { + name string + in Env + want BicepEnv + }{ + { + name: "Plain text", + in: Env{ + Name: "enable-customer-related-feature", + Value: "true", + }, + want: BicepEnv{ + BicepEnvType: BicepEnvTypePlainText, + Name: "enable-customer-related-feature", + PlainTextValue: "'true'", // Note: Quotation add automatically + }, + }, + { + name: "Plain text from EnvTypeResourceConnectionPlainText", + in: Env{ + Name: "spring.jms.servicebus.pricing-tier", + Value: "premium", + }, + want: BicepEnv{ + BicepEnvType: BicepEnvTypePlainText, + Name: "spring.jms.servicebus.pricing-tier", + PlainTextValue: "'premium'", // Note: Quotation add automatically + }, + }, + { + name: "Plain text from EnvTypeResourceConnectionResourceInfo", + in: Env{ + Name: "POSTGRES_PORT", + Value: ToResourceConnectionEnv(ResourceTypeDbPostgres, ResourceInfoTypePort), + }, + want: BicepEnv{ + BicepEnvType: BicepEnvTypePlainText, + Name: "POSTGRES_PORT", + PlainTextValue: "'5432'", + }, + }, + { + name: "Secret", + in: Env{ + Name: "POSTGRES_PASSWORD", + Value: ToResourceConnectionEnv(ResourceTypeDbPostgres, ResourceInfoTypePassword), + }, + want: BicepEnv{ + BicepEnvType: BicepEnvTypeSecret, + Name: "POSTGRES_PASSWORD", + SecretName: "db-postgres-password", + SecretValue: "postgreSqlDatabasePassword", + }, + }, + { + name: "KeuVault Secret", + in: Env{ + Name: "REDIS_PASSWORD", + Value: ToResourceConnectionEnv(ResourceTypeDbRedis, ResourceInfoTypePassword), + }, + want: BicepEnv{ + BicepEnvType: BicepEnvTypeKeyVaultSecret, + Name: "REDIS_PASSWORD", + SecretName: "db-redis-password", + SecretValue: "redisConn.outputs.keyVaultUrlForPass", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := ToBicepEnv(tt.in) + assert.Equal(t, tt.want, actual) + }) + } +} + +func TestToBicepEnvPlainTextValue(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + { + name: "string", + in: "inputStringExample", + want: "'inputStringExample'", + }, + { + name: "single variable", + in: "${inputSingleVariableExample}", + want: "inputSingleVariableExample", + }, + { + name: "multiple variable", + in: "${HOST}:${PORT}", + want: "'${HOST}:${PORT}'", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := toBicepEnvPlainTextValue(tt.in) + assert.Equal(t, tt.want, actual) + }) + } +} + +func TestShouldAddToBicepFile(t *testing.T) { + tests := []struct { + name string + infraSpec ServiceSpec + propertyName string + want bool + }{ + { + name: "not related property and not using mysql and postgres", + infraSpec: ServiceSpec{}, + propertyName: "test", + want: true, + }, + { + name: "not using mysql and postgres", + infraSpec: ServiceSpec{}, + propertyName: "spring.datasource.url", + want: true, + }, + { + name: "not using user assigned managed identity", + infraSpec: ServiceSpec{ + DbMySql: &DatabaseMySql{ + AuthType: internal.AuthTypePassword, + }, + }, + propertyName: "spring.datasource.url", + want: true, + }, + { + name: "not service connector added property", + infraSpec: ServiceSpec{ + DbMySql: &DatabaseMySql{ + AuthType: internal.AuthTypePassword, + }, + }, + propertyName: "test", + want: true, + }, + { + name: "should not added", + infraSpec: ServiceSpec{ + DbMySql: &DatabaseMySql{ + AuthType: internal.AuthTypePassword, + }, + }, + propertyName: "spring.datasource.url", + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := ShouldAddToBicepFile(tt.infraSpec, tt.propertyName) + assert.Equal(t, tt.want, actual) + }) + } +} diff --git a/cli/azd/internal/scaffold/scaffold.go b/cli/azd/internal/scaffold/scaffold.go index b8042ffd88b..2b9d94a6abb 100644 --- a/cli/azd/internal/scaffold/scaffold.go +++ b/cli/azd/internal/scaffold/scaffold.go @@ -25,13 +25,15 @@ const templateRoot = "scaffold/templates" // To execute a named template, call Execute with the defined name. func Load() (*template.Template, error) { funcMap := template.FuncMap{ - "bicepName": BicepName, - "containerAppName": ContainerAppName, - "upper": strings.ToUpper, - "lower": strings.ToLower, - "alphaSnakeUpper": AlphaSnakeUpper, - "formatParam": FormatParameter, - "hasPrefix": strings.HasPrefix, + "bicepName": BicepName, + "containerAppName": ContainerAppName, + "upper": strings.ToUpper, + "lower": strings.ToLower, + "alphaSnakeUpper": AlphaSnakeUpper, + "formatParam": FormatParameter, + "hasPrefix": strings.HasPrefix, + "toBicepEnv": ToBicepEnv, + "shouldAddToBicepFile": ShouldAddToBicepFile, } t, err := template.New("templates"). diff --git a/cli/azd/internal/scaffold/spec.go b/cli/azd/internal/scaffold/spec.go index e1c5aa2c597..198556c5798 100644 --- a/cli/azd/internal/scaffold/spec.go +++ b/cli/azd/internal/scaffold/spec.go @@ -98,7 +98,7 @@ type ServiceSpec struct { Name string Port int - Env map[string]string + Envs []Env // Front-end properties. Frontend *Frontend @@ -121,6 +121,74 @@ type ServiceSpec struct { AzureStorageAccount *AzureDepStorageAccount } +type Env struct { + Name string + Value string +} + +var resourceConnectionEnvPrefix = "$resource.connection" + +func isResourceConnectionEnv(env string) bool { + if !strings.HasPrefix(env, resourceConnectionEnvPrefix) { + return false + } + a := strings.Split(env, ":") + if len(a) != 3 { + return false + } + return a[0] != "" && a[1] != "" && a[2] != "" +} + +func ToResourceConnectionEnv(resourceType ResourceType, resourceInfoType ResourceInfoType) string { + return fmt.Sprintf("%s:%s:%s", resourceConnectionEnvPrefix, resourceType, resourceInfoType) +} + +func toResourceConnectionInfo(resourceConnectionEnv string) (resourceType ResourceType, + resourceInfoType ResourceInfoType) { + if !isResourceConnectionEnv(resourceConnectionEnv) { + return "", "" + } + a := strings.Split(resourceConnectionEnv, ":") + return ResourceType(a[1]), ResourceInfoType(a[2]) +} + +// todo merge ResourceType and project.ResourceType +// Not use project.ResourceType because it will cause cycle import. +// Not merge it in current PR to avoid conflict with upstream main branch. +// Solution proposal: define a ResourceType in lower level that can be used both in scaffold and project package. + +type ResourceType string + +const ( + ResourceTypeDbRedis ResourceType = "db.redis" + ResourceTypeDbPostgres ResourceType = "db.postgres" + ResourceTypeDbMySQL ResourceType = "db.mysql" + ResourceTypeDbMongo ResourceType = "db.mongo" + ResourceTypeDbCosmos ResourceType = "db.cosmos" + ResourceTypeHostContainerApp ResourceType = "host.containerapp" + ResourceTypeOpenAiModel ResourceType = "ai.openai.model" + ResourceTypeMessagingServiceBus ResourceType = "messaging.servicebus" + ResourceTypeMessagingEventHubs ResourceType = "messaging.eventhubs" + ResourceTypeMessagingKafka ResourceType = "messaging.kafka" + ResourceTypeStorage ResourceType = "storage" +) + +type ResourceInfoType string + +const ( + ResourceInfoTypeHost ResourceInfoType = "host" + ResourceInfoTypePort ResourceInfoType = "port" + ResourceInfoTypeEndpoint ResourceInfoType = "endpoint" + ResourceInfoTypeDatabaseName ResourceInfoType = "databaseName" + ResourceInfoTypeNamespace ResourceInfoType = "namespace" + ResourceInfoTypeAccountName ResourceInfoType = "accountName" + ResourceInfoTypeUsername ResourceInfoType = "username" + ResourceInfoTypePassword ResourceInfoType = "password" + ResourceInfoTypeUrl ResourceInfoType = "url" + ResourceInfoTypeJdbcUrl ResourceInfoType = "jdbcUrl" + ResourceInfoTypeConnectionString ResourceInfoType = "connectionString" +) + type Frontend struct { Backends []ServiceReference } diff --git a/cli/azd/internal/scaffold/spec_test.go b/cli/azd/internal/scaffold/spec_test.go new file mode 100644 index 00000000000..34f69f07222 --- /dev/null +++ b/cli/azd/internal/scaffold/spec_test.go @@ -0,0 +1,94 @@ +package scaffold + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestToResourceConnectionEnv(t *testing.T) { + tests := []struct { + name string + inputResourceType ResourceType + inputResourceInfoType ResourceInfoType + want string + }{ + { + name: "mysql username", + inputResourceType: ResourceTypeDbMySQL, + inputResourceInfoType: ResourceInfoTypeUsername, + want: "$resource.connection:db.mysql:username", + }, + { + name: "postgres password", + inputResourceType: ResourceTypeDbPostgres, + inputResourceInfoType: ResourceInfoTypePassword, + want: "$resource.connection:db.postgres:password", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := ToResourceConnectionEnv(tt.inputResourceType, tt.inputResourceInfoType) + assert.Equal(t, tt.want, actual) + }) + } +} + +func TestIsResourceConnectionEnv(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + { + name: "valid", + input: "$resource.connection:db.postgres:password", + want: true, + }, + { + name: "invalid", + input: "$resource.connection:db.postgres:", + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isResourceConnectionEnv(tt.input) + assert.Equal(t, tt.want, result) + }) + } +} + +func TestToResourceConnectionInfo(t *testing.T) { + tests := []struct { + name string + input string + wantResourceType ResourceType + wantResourceInfoType ResourceInfoType + }{ + { + name: "invalid input", + input: "$resource.connection:db.mysql::username", + wantResourceType: "", + wantResourceInfoType: "", + }, + { + name: "mysql username", + input: "$resource.connection:db.mysql:username", + wantResourceType: ResourceTypeDbMySQL, + wantResourceInfoType: ResourceInfoTypeUsername, + }, + { + name: "postgres password", + input: "$resource.connection:db.postgres:password", + wantResourceType: ResourceTypeDbPostgres, + wantResourceInfoType: ResourceInfoTypePassword, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resourceType, resourceInfoType := toResourceConnectionInfo(tt.input) + assert.Equal(t, tt.wantResourceType, resourceType) + assert.Equal(t, tt.wantResourceInfoType, resourceInfoType) + }) + } +} diff --git a/cli/azd/pkg/project/importer.go b/cli/azd/pkg/project/importer.go index 7262e7f85d9..3494d76b81c 100644 --- a/cli/azd/pkg/project/importer.go +++ b/cli/azd/pkg/project/importer.go @@ -6,7 +6,6 @@ package project import ( "context" "fmt" - "github.com/azure/azure-dev/cli/azd/pkg/input" "io/fs" "log" "os" @@ -20,13 +19,11 @@ import ( type ImportManager struct { dotNetImporter *DotNetImporter - console input.Console } -func NewImportManager(dotNetImporter *DotNetImporter, console input.Console) *ImportManager { +func NewImportManager(dotNetImporter *DotNetImporter) *ImportManager { return &ImportManager{ dotNetImporter: dotNetImporter, - console: console, } } @@ -170,7 +167,7 @@ func (im *ImportManager) ProjectInfrastructure(ctx context.Context, projectConfi composeEnabled := im.dotNetImporter.alphaFeatureManager.IsEnabled(featureCompose) if composeEnabled && len(projectConfig.Resources) > 0 { - return tempInfra(ctx, projectConfig, &im.console, &ctx) + return tempInfra(ctx, projectConfig, im.dotNetImporter.console) } if !composeEnabled && len(projectConfig.Resources) > 0 { @@ -212,7 +209,7 @@ func (im *ImportManager) SynthAllInfrastructure(ctx context.Context, projectConf composeEnabled := im.dotNetImporter.alphaFeatureManager.IsEnabled(featureCompose) if composeEnabled && len(projectConfig.Resources) > 0 { - return infraFsForProject(ctx, projectConfig, &im.console, &ctx) + return infraFsForProject(ctx, projectConfig, im.dotNetImporter.console) } if !composeEnabled && len(projectConfig.Resources) > 0 { diff --git a/cli/azd/pkg/project/importer_test.go b/cli/azd/pkg/project/importer_test.go index 03ab794d06f..420ee2a564b 100644 --- a/cli/azd/pkg/project/importer_test.go +++ b/cli/azd/pkg/project/importer_test.go @@ -6,7 +6,6 @@ package project import ( "context" _ "embed" - "github.com/azure/azure-dev/cli/azd/test/mocks/mockinput" "os" "path/filepath" "slices" @@ -44,7 +43,7 @@ func TestImportManagerHasService(t *testing.T) { lazyEnvManager: lazy.NewLazy(func() (environment.Manager, error) { return mockEnv, nil }), - }, mockinput.NewMockConsole()) + }) // has service r, e := manager.HasService(*mockContext.Context, &ProjectConfig{ @@ -86,7 +85,7 @@ func TestImportManagerHasServiceErrorNoMultipleServicesWithAppHost(t *testing.T) return mockEnv, nil }), hostCheck: make(map[string]hostCheckResult), - }, mockinput.NewMockConsole()) + }) mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { return strings.Contains(command, "dotnet") && @@ -139,7 +138,7 @@ func TestImportManagerHasServiceErrorAppHostMustTargetContainerApp(t *testing.T) return mockEnv, nil }), hostCheck: make(map[string]hostCheckResult), - }, mockinput.NewMockConsole()) + }) mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { return strings.Contains(command, "dotnet") && @@ -186,7 +185,7 @@ func TestImportManagerProjectInfrastructureDefaults(t *testing.T) { }), hostCheck: make(map[string]hostCheckResult), alphaFeatureManager: mockContext.AlphaFeaturesManager, - }, mockinput.NewMockConsole()) + }) // Get defaults and error b/c no infra found and no Aspire project r, e := manager.ProjectInfrastructure(*mockContext.Context, &ProjectConfig{}) @@ -235,7 +234,7 @@ func TestImportManagerProjectInfrastructure(t *testing.T) { return mockEnv, nil }), hostCheck: make(map[string]hostCheckResult), - }, mockinput.NewMockConsole()) + }) // Do not use defaults expectedDefaultFolder := "customFolder" @@ -317,7 +316,7 @@ func TestImportManagerProjectInfrastructureAspire(t *testing.T) { hostCheck: make(map[string]hostCheckResult), cache: make(map[manifestCacheKey]*apphost.Manifest), alphaFeatureManager: alpha.NewFeaturesManagerWithConfig(config.NewEmptyConfig()), - }, mockinput.NewMockConsole()) + }) // adding infra folder to test defaults err := os.Mkdir(DefaultPath, os.ModePerm) diff --git a/cli/azd/pkg/project/scaffold_gen.go b/cli/azd/pkg/project/scaffold_gen.go index 479e9ab8c9c..b86b49e54e5 100644 --- a/cli/azd/pkg/project/scaffold_gen.go +++ b/cli/azd/pkg/project/scaffold_gen.go @@ -21,14 +21,13 @@ import ( ) // Generates the in-memory contents of an `infra` directory. -func infraFs(_ context.Context, prjConfig *ProjectConfig, - console *input.Console, context *context.Context) (fs.FS, error) { +func infraFs(cxt context.Context, prjConfig *ProjectConfig, console input.Console) (fs.FS, error) { t, err := scaffold.Load() if err != nil { return nil, fmt.Errorf("loading scaffold templates: %w", err) } - infraSpec, err := infraSpec(prjConfig, console, context) + infraSpec, err := infraSpec(prjConfig, console, cxt) if err != nil { return nil, fmt.Errorf("generating infrastructure spec: %w", err) } @@ -45,14 +44,13 @@ func infraFs(_ context.Context, prjConfig *ProjectConfig, func tempInfra( ctx context.Context, prjConfig *ProjectConfig, - console *input.Console, - context *context.Context) (*Infra, error) { + console input.Console) (*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, console, context) + files, err := infraFs(ctx, prjConfig, console) if err != nil { return nil, err } @@ -95,8 +93,8 @@ func tempInfra( // 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, - console *input.Console, context *context.Context) (fs.FS, error) { - infraFS, err := infraFs(ctx, prjConfig, console, context) + console input.Console) (fs.FS, error) { + infraFS, err := infraFs(ctx, prjConfig, console) if err != nil { return nil, err } @@ -137,7 +135,7 @@ func infraFsForProject(ctx context.Context, prjConfig *ProjectConfig, } func infraSpec(projectConfig *ProjectConfig, - console *input.Console, context *context.Context) (*scaffold.InfraSpec, error) { + console input.Console, ctx context.Context) (*scaffold.InfraSpec, error) { infraSpec := scaffold.InfraSpec{} for _, resource := range projectConfig.Resources { switch resource.Type { @@ -233,7 +231,7 @@ func infraSpec(projectConfig *ProjectConfig, return nil, err } - err = printHintsAboutUses(&infraSpec, projectConfig, console, context) + err = printEnvListAboutUses(&infraSpec, projectConfig, console, ctx) if err != nil { return nil, err } @@ -263,27 +261,63 @@ func mapUses(infraSpec *scaffold.InfraSpec, projectConfig *ProjectConfig) error switch usedResource.Type { case ResourceTypeDbPostgres: userSpec.DbPostgres = infraSpec.DbPostgres + err := addUsageByEnv(infraSpec, userSpec, usedResource) + if err != nil { + return err + } case ResourceTypeDbMySQL: userSpec.DbMySql = infraSpec.DbMySql + err := addUsageByEnv(infraSpec, userSpec, usedResource) + if err != nil { + return err + } case ResourceTypeDbRedis: userSpec.DbRedis = infraSpec.DbRedis + err := addUsageByEnv(infraSpec, userSpec, usedResource) + if err != nil { + return err + } case ResourceTypeDbMongo: userSpec.DbCosmosMongo = infraSpec.DbCosmosMongo + err := addUsageByEnv(infraSpec, userSpec, usedResource) + if err != nil { + return err + } case ResourceTypeDbCosmos: userSpec.DbCosmos = infraSpec.DbCosmos + err := addUsageByEnv(infraSpec, userSpec, usedResource) + if err != nil { + return err + } case ResourceTypeMessagingServiceBus: userSpec.AzureServiceBus = infraSpec.AzureServiceBus + err := addUsageByEnv(infraSpec, userSpec, usedResource) + if err != nil { + return err + } case ResourceTypeMessagingEventHubs, ResourceTypeMessagingKafka: userSpec.AzureEventHubs = infraSpec.AzureEventHubs + err := addUsageByEnv(infraSpec, userSpec, usedResource) + if err != nil { + return err + } case ResourceTypeStorage: userSpec.AzureStorageAccount = infraSpec.AzureStorageAccount - case ResourceTypeHostContainerApp: - err := fulfillFrontendBackend(userSpec, usedResource, infraSpec) + err := addUsageByEnv(infraSpec, userSpec, usedResource) if err != nil { return err } case ResourceTypeOpenAiModel: userSpec.AIModels = append(userSpec.AIModels, scaffold.AIModelReference{Name: usedResource.Name}) + err := addUsageByEnv(infraSpec, userSpec, usedResource) + if err != nil { + return err + } + case ResourceTypeHostContainerApp: + err := fulfillFrontendBackend(userSpec, usedResource, infraSpec) + if err != nil { + return err + } default: return fmt.Errorf("resource (%s) uses (%s), but the type of (%s) is (%s), which is unsupported", userResource.Name, usedResource.Name, usedResource.Name, usedResource.Type) @@ -293,9 +327,44 @@ func mapUses(infraSpec *scaffold.InfraSpec, projectConfig *ProjectConfig) error return nil } -func printHintsAboutUses(infraSpec *scaffold.InfraSpec, projectConfig *ProjectConfig, - console *input.Console, - context *context.Context) error { +func getAuthType(infraSpec *scaffold.InfraSpec, resourceType ResourceType) (internal.AuthType, error) { + switch resourceType { + case ResourceTypeDbPostgres: + return infraSpec.DbPostgres.AuthType, nil + case ResourceTypeDbMySQL: + return infraSpec.DbMySql.AuthType, nil + case ResourceTypeDbRedis: + return internal.AuthTypePassword, nil + case ResourceTypeDbMongo, + ResourceTypeDbCosmos, + ResourceTypeOpenAiModel, + ResourceTypeHostContainerApp: + return internal.AuthTypeUserAssignedManagedIdentity, nil + case ResourceTypeMessagingServiceBus: + return infraSpec.AzureServiceBus.AuthType, nil + case ResourceTypeMessagingEventHubs, ResourceTypeMessagingKafka: + return infraSpec.AzureEventHubs.AuthType, nil + case ResourceTypeStorage: + return infraSpec.AzureStorageAccount.AuthType, nil + default: + return internal.AuthTypeUnspecified, fmt.Errorf("can not get authType, resource type: %s", resourceType) + } +} + +func addUsageByEnv(infraSpec *scaffold.InfraSpec, userSpec *scaffold.ServiceSpec, usedResource *ResourceConfig) error { + envs, err := getResourceConnectionEnvs(usedResource, infraSpec) + if err != nil { + return err + } + userSpec.Envs, err = mergeEnvWithDuplicationCheck(userSpec.Envs, envs) + if err != nil { + return err + } + return nil +} + +func printEnvListAboutUses(infraSpec *scaffold.InfraSpec, projectConfig *ProjectConfig, + console input.Console, ctx context.Context) error { for i := range infraSpec.Services { userSpec := &infraSpec.Services[i] userResourceName := userSpec.Name @@ -310,62 +379,40 @@ func printHintsAboutUses(infraSpec *scaffold.InfraSpec, projectConfig *ProjectCo return fmt.Errorf("in azure.yaml, (%s) uses (%s), but (%s) doesn't", userResourceName, usedResourceName, usedResourceName) } - if *console != nil { - (*console).Message(*context, fmt.Sprintf("CAUTION: In azure.yaml, '%s' uses '%s'. "+ - "After deployed, the 'uses' is achieved by providing these environment variables: ", - userResourceName, usedResourceName)) - } + console.Message(ctx, fmt.Sprintf("\nInformation about environment variables:\n"+ + "In azure.yaml, '%s' uses '%s'. \n"+ + "The 'uses' relashipship is implemented by environment variables. \n"+ + "Please make sure your application used the right environment variable. \n"+ + "Here is the list of environment variables: ", + userResourceName, usedResourceName)) switch usedResource.Type { - case ResourceTypeDbPostgres: - err := printHintsAboutUsePostgres(userSpec.DbPostgres.AuthType, console, context) + case ResourceTypeDbPostgres, // do nothing. todo: add all other types + ResourceTypeDbMySQL, + ResourceTypeDbRedis, + ResourceTypeDbMongo, + ResourceTypeDbCosmos, + ResourceTypeMessagingServiceBus, + ResourceTypeMessagingEventHubs, + ResourceTypeMessagingKafka, + ResourceTypeStorage: + variables, err := getResourceConnectionEnvs(usedResource, infraSpec) if err != nil { return err } - case ResourceTypeDbMySQL: - err := printHintsAboutUseMySql(userSpec.DbPostgres.AuthType, console, context) - if err != nil { - return err - } - case ResourceTypeDbRedis: - printHintsAboutUseRedis(console, context) - case ResourceTypeDbMongo: - printHintsAboutUseMongo(console, context) - case ResourceTypeDbCosmos: - printHintsAboutUseCosmos(console, context) - case ResourceTypeMessagingServiceBus: - err := printHintsAboutUseServiceBus(userSpec.AzureServiceBus.IsJms, - userSpec.AzureServiceBus.AuthType, console, context) - if err != nil { - return err - } - case ResourceTypeMessagingEventHubs, ResourceTypeMessagingKafka: - err := printHintsAboutUseEventHubs(userSpec.AzureEventHubs.UseKafka, - userSpec.AzureEventHubs.AuthType, userSpec.AzureEventHubs.SpringBootVersion, console, context) - if err != nil { - return err - } - case ResourceTypeStorage: - err := printHintsAboutUseStorageAccount(userSpec.AzureStorageAccount.AuthType, console, context) - if err != nil { - return err + for _, variable := range variables { + console.Message(ctx, fmt.Sprintf(" %s=xxx", variable.Name)) } case ResourceTypeHostContainerApp: - printHintsAboutUseHostContainerApp(userResourceName, usedResourceName, console, context) - case ResourceTypeOpenAiModel: - printHintsAboutUseOpenAiModel(console, context) + printHintsAboutUseHostContainerApp(userResourceName, usedResourceName, console, ctx) default: return fmt.Errorf("resource (%s) uses (%s), but the type of (%s) is (%s), "+ "which is doen't add necessary environment variable", userResource.Name, usedResource.Name, usedResource.Name, usedResource.Type) } - if *console != nil { - (*console).Message(*context, "Please make sure your application used the right environment variable name.\n") - } - + console.Message(ctx, "\n") } } return nil - } func handleContainerAppProps( @@ -398,7 +445,11 @@ func handleContainerAppProps( // 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) - serviceSpec.Env[envVar.Name] = evaluatedValue + err := addNewEnvironmentVariable(serviceSpec, envVar.Name, evaluatedValue) + if err != nil { + return err + } + return nil } port := props.Port @@ -442,6 +493,7 @@ func setParameter(spec *scaffold.InfraSpec, name string, value string, isSecret // // If the string is a literal, it is returned as is. // If isSecret is true, the parameter is marked as a secret. +// The returned value is string, all expression inside are wrapped by "${}". func genBicepParamsFromEnvSubst( s string, isSecret bool, @@ -456,16 +508,16 @@ func genBicepParamsFromEnvSubst( var result string if len(names) == 0 { - // literal string with no expressions, quote the value as a Bicep string - result = "'" + s + "'" + // literal string with no expressions + result = s } else if len(names) == 1 { // single expression, return the bicep parameter name to reference the expression - result = scaffold.BicepName(names[0]) + 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 = "'" + result = "" for i, loc := range locations { // replace each expression with references by variable name result += s[previous:loc.start] @@ -474,7 +526,6 @@ func genBicepParamsFromEnvSubst( result += "}" previous = loc.stop + 1 } - result += "'" } return result @@ -509,187 +560,13 @@ func getServiceSpecByName(infraSpec *scaffold.InfraSpec, name string) *scaffold. return nil } -func printHintsAboutUsePostgres(authType internal.AuthType, - console *input.Console, context *context.Context) error { - if *console == nil { - return nil - } - (*console).Message(*context, "POSTGRES_HOST=xxx") - (*console).Message(*context, "POSTGRES_DATABASE=xxx") - (*console).Message(*context, "POSTGRES_PORT=xxx") - (*console).Message(*context, "spring.datasource.url=xxx") - (*console).Message(*context, "spring.datasource.username=xxx") - if authType == internal.AuthTypePassword { - (*console).Message(*context, "POSTGRES_URL=xxx") - (*console).Message(*context, "POSTGRES_USERNAME=xxx") - (*console).Message(*context, "POSTGRES_PASSWORD=xxx") - (*console).Message(*context, "spring.datasource.password=xxx") - } else if authType == internal.AuthTypeUserAssignedManagedIdentity { - (*console).Message(*context, "spring.datasource.azure.passwordless-enabled=true") - (*console).Message(*context, "CAUTION: To make sure passwordless work well in your spring boot application, ") - (*console).Message(*context, "make sure the following 2 things:") - (*console).Message(*context, "1. Add required dependency: spring-cloud-azure-starter-jdbc-postgresql.") - (*console).Message(*context, "2. Delete property 'spring.datasource.password' in your property file.") - (*console).Message(*context, "Refs: https://learn.microsoft.com/en-us/azure/service-connector/") - (*console).Message(*context, "how-to-integrate-mysql?tabs=springBoot#sample-code-1") - } else { - return fmt.Errorf("unsupported auth type for PostgreSQL. Supported types: %s, %s", - internal.GetAuthTypeDescription(internal.AuthTypePassword), - internal.GetAuthTypeDescription(internal.AuthTypeUserAssignedManagedIdentity)) - } - return nil -} - -func printHintsAboutUseMySql(authType internal.AuthType, - console *input.Console, context *context.Context) error { - if *console == nil { - return nil - } - (*console).Message(*context, "MYSQL_HOST=xxx") - (*console).Message(*context, "MYSQL_DATABASE=xxx") - (*console).Message(*context, "MYSQL_PORT=xxx") - (*console).Message(*context, "spring.datasource.url=xxx") - (*console).Message(*context, "spring.datasource.username=xxx") - if authType == internal.AuthTypePassword { - (*console).Message(*context, "MYSQL_URL=xxx") - (*console).Message(*context, "MYSQL_USERNAME=xxx") - (*console).Message(*context, "MYSQL_PASSWORD=xxx") - (*console).Message(*context, "spring.datasource.password=xxx") - } else if authType == internal.AuthTypeUserAssignedManagedIdentity { - (*console).Message(*context, "spring.datasource.azure.passwordless-enabled=true") - (*console).Message(*context, "CAUTION: To make sure passwordless work well in your spring boot application, ") - (*console).Message(*context, "Make sure the following 2 things:") - (*console).Message(*context, "1. Add required dependency: spring-cloud-azure-starter-jdbc-postgresql.") - (*console).Message(*context, "2. Delete property 'spring.datasource.password' in your property file.") - (*console).Message(*context, "Refs: https://learn.microsoft.com/en-us/azure/service-connector/how-to-integrate-postgres?tabs=springBoot#sample-code-1") - } else { - return fmt.Errorf("unsupported auth type for MySql. Supported types are: %s, %s", - internal.GetAuthTypeDescription(internal.AuthTypePassword), - internal.GetAuthTypeDescription(internal.AuthTypeUserAssignedManagedIdentity)) - } - return nil -} - -func printHintsAboutUseRedis(console *input.Console, context *context.Context) { - if *console == nil { - return - } - (*console).Message(*context, "REDIS_HOST=xxx") - (*console).Message(*context, "REDIS_PORT=xxx") - (*console).Message(*context, "REDIS_URL=xxx") - (*console).Message(*context, "REDIS_ENDPOINT=xxx") - (*console).Message(*context, "REDIS_PASSWORD=xxx") - (*console).Message(*context, "spring.data.redis.url=xxx") -} - -func printHintsAboutUseMongo(console *input.Console, context *context.Context) { - if *console == nil { - return - } - (*console).Message(*context, "MONGODB_URL=xxx") - (*console).Message(*context, "spring.data.mongodb.uri=xxx") - (*console).Message(*context, "spring.data.mongodb.database=xxx") -} - -func printHintsAboutUseCosmos(console *input.Console, context *context.Context) { - if *console == nil { - return - } - (*console).Message(*context, "spring.cloud.azure.cosmos.endpoint=xxx") - (*console).Message(*context, "spring.cloud.azure.cosmos.database=xxx") -} - -func printHintsAboutUseServiceBus(isJms bool, authType internal.AuthType, - console *input.Console, context *context.Context) error { - if *console == nil { - return nil - } - if !isJms { - (*console).Message(*context, "spring.cloud.azure.servicebus.namespace=xxx") - } - if authType == internal.AuthTypeUserAssignedManagedIdentity { - (*console).Message(*context, "spring.cloud.azure.servicebus.connection-string=''") - (*console).Message(*context, "spring.cloud.azure.servicebus.credential.managed-identity-enabled=true") - (*console).Message(*context, "spring.cloud.azure.servicebus.credential.client-id=xxx") - } else if authType == internal.AuthTypeConnectionString { - (*console).Message(*context, "spring.cloud.azure.servicebus.connection-string=xxx") - (*console).Message(*context, "spring.cloud.azure.servicebus.credential.managed-identity-enabled=false") - (*console).Message(*context, "spring.cloud.azure.eventhubs.credential.client-id=xxx") - } else { - return fmt.Errorf("unsupported auth type for Service Bus. Supported types are: %s, %s", - internal.GetAuthTypeDescription(internal.AuthTypeUserAssignedManagedIdentity), - internal.GetAuthTypeDescription(internal.AuthTypeConnectionString)) - } - return nil -} - -func printHintsAboutUseEventHubs(UseKafka bool, authType internal.AuthType, springBootVersion string, - console *input.Console, context *context.Context) error { - if *console == nil { - return nil - } - if !UseKafka { - (*console).Message(*context, "spring.cloud.azure.eventhubs.namespace=xxx") - } else { - (*console).Message(*context, "spring.cloud.stream.kafka.binder.brokers=xxx") - if strings.HasPrefix(springBootVersion, "2.") { - (*console).Message(*context, "spring.cloud.stream.binders.kafka.environment.spring.main.sources=com.azure.spring.cloud.autoconfigure.eventhubs.kafka.AzureEventHubsKafkaAutoConfiguration") - } else if strings.HasPrefix(springBootVersion, "3.") { - (*console).Message(*context, "spring.cloud.stream.binders.kafka.environment.spring.main.sources=com.azure.spring.cloud.autoconfigure.implementation.eventhubs.kafka.AzureEventHubsKafkaAutoConfiguration") - } - } - if authType == internal.AuthTypeUserAssignedManagedIdentity { - (*console).Message(*context, "spring.cloud.azure.eventhubs.connection-string=''") - (*console).Message(*context, "spring.cloud.azure.eventhubs.credential.managed-identity-enabled=true") - (*console).Message(*context, "spring.cloud.azure.eventhubs.credential.client-id=xxx") - } else if authType == internal.AuthTypeConnectionString { - (*console).Message(*context, "spring.cloud.azure.eventhubs.connection-string=xxx") - (*console).Message(*context, "spring.cloud.azure.eventhubs.credential.managed-identity-enabled=false") - (*console).Message(*context, "spring.cloud.azure.eventhubs.credential.client-id=xxx") - } else { - return fmt.Errorf("unsupported auth type for Event Hubs. Supported types: %s, %s", - internal.GetAuthTypeDescription(internal.AuthTypeUserAssignedManagedIdentity), - internal.GetAuthTypeDescription(internal.AuthTypeConnectionString)) - } - return nil -} - -func printHintsAboutUseStorageAccount(authType internal.AuthType, - console *input.Console, context *context.Context) error { - if *console == nil { - return nil - } - (*console).Message(*context, "spring.cloud.azure.eventhubs.processor.checkpoint-store.account-name=xxx") - if authType == internal.AuthTypeUserAssignedManagedIdentity { - (*console).Message(*context, "spring.cloud.azure.eventhubs.processor.checkpoint-store.connection-string=''") - (*console).Message(*context, "spring.cloud.azure.eventhubs.processor.checkpoint-store.credential.managed-identity-enabled=true") - (*console).Message(*context, "spring.cloud.azure.eventhubs.processor.checkpoint-store.credential.client-id=xxx") - } else if authType == internal.AuthTypeConnectionString { - (*console).Message(*context, "spring.cloud.azure.eventhubs.processor.checkpoint-store.connection-string=xxx") - (*console).Message(*context, "spring.cloud.azure.eventhubs.processor.checkpoint-store.credential.managed-identity-enabled=false") - (*console).Message(*context, "spring.cloud.azure.eventhubs.processor.checkpoint-store.credential.client-id=xxx") - } else { - return fmt.Errorf("unsupported auth type for Storage Account. Supported types: %s, %s", - internal.GetAuthTypeDescription(internal.AuthTypeUserAssignedManagedIdentity), - internal.GetAuthTypeDescription(internal.AuthTypeConnectionString)) - } - return nil -} - func printHintsAboutUseHostContainerApp(userResourceName string, usedResourceName string, - console *input.Console, context *context.Context) { - if *console == nil { - return - } - (*console).Message(*context, fmt.Sprintf("Environemnt variables in %s:", userResourceName)) - (*console).Message(*context, fmt.Sprintf("%s_BASE_URL=xxx", strings.ToUpper(usedResourceName))) - (*console).Message(*context, fmt.Sprintf("Environemnt variables in %s:", usedResourceName)) - (*console).Message(*context, fmt.Sprintf("%s_BASE_URL=xxx", strings.ToUpper(userResourceName))) -} - -func printHintsAboutUseOpenAiModel(console *input.Console, context *context.Context) { - if *console == nil { + console input.Console, ctx context.Context) { + if console == nil { return } - (*console).Message(*context, "AZURE_OPENAI_ENDPOINT") + console.Message(ctx, fmt.Sprintf("Environemnt variables in %s:", userResourceName)) + console.Message(ctx, fmt.Sprintf("%s_BASE_URL=xxx", strings.ToUpper(usedResourceName))) + console.Message(ctx, fmt.Sprintf("Environemnt variables in %s:", usedResourceName)) + console.Message(ctx, fmt.Sprintf("%s_BASE_URL=xxx", strings.ToUpper(userResourceName))) } diff --git a/cli/azd/pkg/project/scaffold_gen_environment_variables.go b/cli/azd/pkg/project/scaffold_gen_environment_variables.go new file mode 100644 index 00000000000..f02ea084f6c --- /dev/null +++ b/cli/azd/pkg/project/scaffold_gen_environment_variables.go @@ -0,0 +1,552 @@ +package project + +import ( + "fmt" + "github.com/azure/azure-dev/cli/azd/internal" + "github.com/azure/azure-dev/cli/azd/internal/scaffold" + "strings" +) + +func getResourceConnectionEnvs(usedResource *ResourceConfig, + infraSpec *scaffold.InfraSpec) ([]scaffold.Env, error) { + resourceType := usedResource.Type + authType, err := getAuthType(infraSpec, usedResource.Type) + if err != nil { + return []scaffold.Env{}, err + } + switch resourceType { + case ResourceTypeDbPostgres: + switch authType { + case internal.AuthTypePassword: + return []scaffold.Env{ + { + Name: "POSTGRES_USERNAME", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbPostgres, scaffold.ResourceInfoTypeUsername), + }, + { + Name: "POSTGRES_PASSWORD", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbPostgres, scaffold.ResourceInfoTypePassword), + }, + { + Name: "POSTGRES_HOST", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbPostgres, scaffold.ResourceInfoTypeHost), + }, + { + Name: "POSTGRES_DATABASE", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbPostgres, scaffold.ResourceInfoTypeDatabaseName), + }, + { + Name: "POSTGRES_PORT", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbPostgres, scaffold.ResourceInfoTypePort), + }, + { + Name: "POSTGRES_URL", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbPostgres, scaffold.ResourceInfoTypeUrl), + }, + { + Name: "spring.datasource.url", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbPostgres, scaffold.ResourceInfoTypeJdbcUrl), + }, + { + Name: "spring.datasource.username", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbPostgres, scaffold.ResourceInfoTypeUsername), + }, + { + Name: "spring.datasource.password", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbPostgres, scaffold.ResourceInfoTypePassword), + }, + }, nil + case internal.AuthTypeUserAssignedManagedIdentity: + return []scaffold.Env{ + { + Name: "POSTGRES_USERNAME", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbPostgres, scaffold.ResourceInfoTypeUsername), + }, + { + Name: "POSTGRES_HOST", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbPostgres, scaffold.ResourceInfoTypeHost), + }, + { + Name: "POSTGRES_DATABASE", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbPostgres, scaffold.ResourceInfoTypeDatabaseName), + }, + { + Name: "POSTGRES_PORT", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbPostgres, scaffold.ResourceInfoTypePort), + }, + { + Name: "spring.datasource.url", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbPostgres, scaffold.ResourceInfoTypeJdbcUrl), + }, + { + Name: "spring.datasource.username", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbPostgres, scaffold.ResourceInfoTypeUsername), + }, + { + Name: "spring.datasource.azure.passwordless-enabled", + Value: "true", + }, + }, nil + default: + return []scaffold.Env{}, unsupportedAuthTypeError(resourceType, authType) + } + case ResourceTypeDbMySQL: + switch authType { + case internal.AuthTypePassword: + return []scaffold.Env{ + { + Name: "MYSQL_USERNAME", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbMySQL, scaffold.ResourceInfoTypeUsername), + }, + { + Name: "MYSQL_PASSWORD", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbMySQL, scaffold.ResourceInfoTypePassword), + }, + { + Name: "MYSQL_HOST", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbMySQL, scaffold.ResourceInfoTypeHost), + }, + { + Name: "MYSQL_DATABASE", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbMySQL, scaffold.ResourceInfoTypeDatabaseName), + }, + { + Name: "MYSQL_PORT", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbMySQL, scaffold.ResourceInfoTypePort), + }, + { + Name: "MYSQL_URL", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbMySQL, scaffold.ResourceInfoTypeUrl), + }, + { + Name: "spring.datasource.url", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbMySQL, scaffold.ResourceInfoTypeJdbcUrl), + }, + { + Name: "spring.datasource.username", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbMySQL, scaffold.ResourceInfoTypeUsername), + }, + { + Name: "spring.datasource.password", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbMySQL, scaffold.ResourceInfoTypePassword), + }, + }, nil + case internal.AuthTypeUserAssignedManagedIdentity: + return []scaffold.Env{ + { + Name: "MYSQL_USERNAME", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbMySQL, scaffold.ResourceInfoTypeUsername), + }, + { + Name: "MYSQL_HOST", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbMySQL, scaffold.ResourceInfoTypeHost), + }, + { + Name: "MYSQL_PORT", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbMySQL, scaffold.ResourceInfoTypePort), + }, + { + Name: "MYSQL_DATABASE", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbMySQL, scaffold.ResourceInfoTypeDatabaseName), + }, + { + Name: "spring.datasource.url", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbMySQL, scaffold.ResourceInfoTypeJdbcUrl), + }, + { + Name: "spring.datasource.username", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbMySQL, scaffold.ResourceInfoTypeUsername), + }, + { + Name: "spring.datasource.azure.passwordless-enabled", + Value: "true", + }, + }, nil + default: + return []scaffold.Env{}, unsupportedAuthTypeError(resourceType, authType) + } + case ResourceTypeDbRedis: + switch authType { + case internal.AuthTypePassword: + return []scaffold.Env{ + { + Name: "REDIS_HOST", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbRedis, scaffold.ResourceInfoTypeHost), + }, + { + Name: "REDIS_PORT", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbRedis, scaffold.ResourceInfoTypePort), + }, + { + Name: "REDIS_ENDPOINT", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbRedis, scaffold.ResourceInfoTypeEndpoint), + }, + { + Name: "REDIS_URL", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbRedis, scaffold.ResourceInfoTypeUrl), + }, + { + Name: "REDIS_PASSWORD", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbRedis, scaffold.ResourceInfoTypePassword), + }, + { + Name: "spring.data.redis.url", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbRedis, scaffold.ResourceInfoTypeUrl), + }, + }, nil + default: + return []scaffold.Env{}, unsupportedAuthTypeError(resourceType, authType) + } + case ResourceTypeDbMongo: + switch authType { + case internal.AuthTypeUserAssignedManagedIdentity: + return []scaffold.Env{ + { + Name: "MONGODB_URL", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbMongo, scaffold.ResourceInfoTypeUrl), + }, + { + Name: "spring.data.mongodb.uri", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbMongo, scaffold.ResourceInfoTypeUrl), + }, + { + Name: "spring.data.mongodb.database", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbMongo, scaffold.ResourceInfoTypeDatabaseName), + }, + }, nil + default: + return []scaffold.Env{}, unsupportedAuthTypeError(resourceType, authType) + } + case ResourceTypeDbCosmos: + switch authType { + case internal.AuthTypeUserAssignedManagedIdentity: + return []scaffold.Env{ + { + Name: "spring.cloud.azure.cosmos.endpoint", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbCosmos, scaffold.ResourceInfoTypeEndpoint), + }, + { + Name: "spring.cloud.azure.cosmos.database", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeDbCosmos, scaffold.ResourceInfoTypeDatabaseName), + }, + }, nil + default: + return []scaffold.Env{}, unsupportedAuthTypeError(resourceType, authType) + } + case ResourceTypeMessagingServiceBus: + if infraSpec.AzureServiceBus.IsJms { + switch authType { + case internal.AuthTypeUserAssignedManagedIdentity: + return []scaffold.Env{ + { + Name: "spring.jms.servicebus.pricing-tier", + Value: "premium", + }, + { + Name: "spring.jms.servicebus.passwordless-enabled", + Value: "true", + }, + { + Name: "spring.jms.servicebus.credential.managed-identity-enabled", + Value: "true", + }, + { + Name: "spring.jms.servicebus.credential.client-id", + Value: scaffold.PlaceHolderForServiceIdentityClientId(), + }, + { + Name: "spring.jms.servicebus.namespace", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeMessagingServiceBus, scaffold.ResourceInfoTypeNamespace), + }, + { + Name: "spring.jms.servicebus.connection-string", + Value: "", + }, + }, nil + case internal.AuthTypeConnectionString: + return []scaffold.Env{ + { + Name: "spring.jms.servicebus.pricing-tier", + Value: "premium", + }, + { + Name: "spring.jms.servicebus.connection-string", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeMessagingServiceBus, scaffold.ResourceInfoTypeConnectionString), + }, + { + Name: "spring.jms.servicebus.passwordless-enabled", + Value: "false", + }, + { + Name: "spring.jms.servicebus.credential.managed-identity-enabled", + Value: "false", + }, + { + Name: "spring.jms.servicebus.credential.client-id", + Value: "", + }, + { + Name: "spring.jms.servicebus.namespace", + Value: "", + }, + }, nil + default: + return []scaffold.Env{}, unsupportedResourceTypeError(resourceType) + } + } else { + // service bus, not jms + switch authType { + case internal.AuthTypeUserAssignedManagedIdentity: + return []scaffold.Env{ + // Not add this: spring.cloud.azure.servicebus.connection-string = "" + // because of this: https://github.com/Azure/azure-sdk-for-java/issues/42880 + { + Name: "spring.cloud.azure.servicebus.credential.managed-identity-enabled", + Value: "true", + }, + { + Name: "spring.cloud.azure.servicebus.credential.client-id", + Value: scaffold.PlaceHolderForServiceIdentityClientId(), + }, + { + Name: "spring.cloud.azure.servicebus.namespace", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeMessagingServiceBus, scaffold.ResourceInfoTypeNamespace), + }, + }, nil + case internal.AuthTypeConnectionString: + return []scaffold.Env{ + { + Name: "spring.cloud.azure.servicebus.namespace", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeMessagingServiceBus, scaffold.ResourceInfoTypeNamespace), + }, + { + Name: "spring.cloud.azure.servicebus.connection-string", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeMessagingServiceBus, scaffold.ResourceInfoTypeConnectionString), + }, + { + Name: "spring.cloud.azure.servicebus.credential.managed-identity-enabled", + Value: "false", + }, + { + Name: "spring.cloud.azure.servicebus.credential.client-id", + Value: "", + }, + }, nil + default: + return []scaffold.Env{}, unsupportedResourceTypeError(resourceType) + } + } + case ResourceTypeMessagingKafka: + // event hubs for kafka + var springBootVersionDecidedInformation []scaffold.Env + if strings.HasPrefix(infraSpec.AzureEventHubs.SpringBootVersion, "2.") { + springBootVersionDecidedInformation = []scaffold.Env{ + { + Name: "spring.cloud.stream.binders.kafka.environment.spring.main.sources", + Value: "com.azure.spring.cloud.autoconfigure.eventhubs.kafka.AzureEventHubsKafkaAutoConfiguration", + }, + } + } else { + springBootVersionDecidedInformation = []scaffold.Env{ + { + Name: "spring.cloud.stream.binders.kafka.environment.spring.main.sources", + Value: "com.azure.spring.cloud.autoconfigure.implementation.eventhubs.kafka.AzureEventHubsKafkaAutoConfiguration", + }, + } + } + var commonInformation []scaffold.Env + switch authType { + case internal.AuthTypeUserAssignedManagedIdentity: + commonInformation = []scaffold.Env{ + // Not add this: spring.cloud.azure.eventhubs.connection-string = "" + // because of this: https://github.com/Azure/azure-sdk-for-java/issues/42880 + { + Name: "spring.cloud.stream.kafka.binder.brokers", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeMessagingKafka, scaffold.ResourceInfoTypeEndpoint), + }, + { + Name: "spring.cloud.azure.eventhubs.credential.managed-identity-enabled", + Value: "true", + }, + { + Name: "spring.cloud.azure.eventhubs.credential.client-id", + Value: scaffold.PlaceHolderForServiceIdentityClientId(), + }, + } + case internal.AuthTypeConnectionString: + commonInformation = []scaffold.Env{ + { + Name: "spring.cloud.stream.kafka.binder.brokers", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeMessagingKafka, scaffold.ResourceInfoTypeEndpoint), + }, + { + Name: "spring.cloud.azure.eventhubs.connection-string", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeMessagingKafka, scaffold.ResourceInfoTypeConnectionString), + }, + { + Name: "spring.cloud.azure.eventhubs.credential.managed-identity-enabled", + Value: "false", + }, + { + Name: "spring.cloud.azure.eventhubs.credential.client-id", + Value: "", + }, + } + default: + return []scaffold.Env{}, unsupportedAuthTypeError(resourceType, authType) + } + return mergeEnvWithDuplicationCheck(springBootVersionDecidedInformation, commonInformation) + case ResourceTypeMessagingEventHubs: + switch authType { + case internal.AuthTypeUserAssignedManagedIdentity: + return []scaffold.Env{ + // Not add this: spring.cloud.azure.eventhubs.connection-string = "" + // because of this: https://github.com/Azure/azure-sdk-for-java/issues/42880 + { + Name: "spring.cloud.azure.eventhubs.credential.managed-identity-enabled", + Value: "true", + }, + { + Name: "spring.cloud.azure.eventhubs.credential.client-id", + Value: scaffold.PlaceHolderForServiceIdentityClientId(), + }, + { + Name: "spring.cloud.azure.eventhubs.namespace", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeMessagingEventHubs, scaffold.ResourceInfoTypeNamespace), + }, + }, nil + case internal.AuthTypeConnectionString: + return []scaffold.Env{ + { + Name: "spring.cloud.azure.eventhubs.namespace", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeMessagingEventHubs, scaffold.ResourceInfoTypeNamespace), + }, + { + Name: "spring.cloud.azure.eventhubs.connection-string", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeMessagingEventHubs, scaffold.ResourceInfoTypeConnectionString), + }, + { + Name: "spring.cloud.azure.eventhubs.credential.managed-identity-enabled", + Value: "false", + }, + { + Name: "spring.cloud.azure.eventhubs.credential.client-id", + Value: "", + }, + }, nil + default: + return []scaffold.Env{}, unsupportedResourceTypeError(resourceType) + } + case ResourceTypeStorage: + switch authType { + case internal.AuthTypeUserAssignedManagedIdentity: + return []scaffold.Env{ + { + Name: "spring.cloud.azure.eventhubs.processor.checkpoint-store.account-name", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeStorage, scaffold.ResourceInfoTypeAccountName), + }, + { + Name: "spring.cloud.azure.eventhubs.processor.checkpoint-store.credential.managed-identity-enabled", + Value: "true", + }, + { + Name: "spring.cloud.azure.eventhubs.processor.checkpoint-store.credential.client-id", + Value: scaffold.PlaceHolderForServiceIdentityClientId(), + }, + { + Name: "spring.cloud.azure.eventhubs.processor.checkpoint-store.connection-string", + Value: "", + }, + }, nil + case internal.AuthTypeConnectionString: + return []scaffold.Env{ + { + Name: "spring.cloud.azure.eventhubs.processor.checkpoint-store.account-name", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeStorage, scaffold.ResourceInfoTypeAccountName), + }, + { + Name: "spring.cloud.azure.eventhubs.processor.checkpoint-store.connection-string", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeStorage, scaffold.ResourceInfoTypeConnectionString), + }, + { + Name: "spring.cloud.azure.eventhubs.processor.checkpoint-store.credential.managed-identity-enabled", + Value: "false", + }, + { + Name: "spring.cloud.azure.eventhubs.processor.checkpoint-store.credential.client-id", + Value: "", + }, + }, nil + default: + return []scaffold.Env{}, unsupportedResourceTypeError(resourceType) + } + case ResourceTypeOpenAiModel: + switch authType { + case internal.AuthTypeUserAssignedManagedIdentity: + return []scaffold.Env{ + { + Name: "AZURE_OPENAI_ENDPOINT", + Value: scaffold.ToResourceConnectionEnv(scaffold.ResourceTypeOpenAiModel, scaffold.ResourceInfoTypeEndpoint), + }, + }, nil + default: + return []scaffold.Env{}, unsupportedResourceTypeError(resourceType) + } + case ResourceTypeHostContainerApp: // todo improve this and delete Frontend and Backend in scaffold.ServiceSpec + switch authType { + case internal.AuthTypeUserAssignedManagedIdentity: + return []scaffold.Env{}, nil + default: + return []scaffold.Env{}, unsupportedAuthTypeError(resourceType, authType) + } + default: + return []scaffold.Env{}, unsupportedResourceTypeError(resourceType) + } +} + +func unsupportedResourceTypeError(resourceType ResourceType) error { + return fmt.Errorf("unsupported resource type, resourceType = %s", resourceType) +} + +func unsupportedAuthTypeError(resourceType ResourceType, authType internal.AuthType) error { + return fmt.Errorf("unsupported auth type, resourceType = %s, authType = %s", resourceType, authType) +} + +func mergeEnvWithDuplicationCheck(a []scaffold.Env, + b []scaffold.Env) ([]scaffold.Env, error) { + ab := append(a, b...) + var result []scaffold.Env + seenName := make(map[string]scaffold.Env) + for _, value := range ab { + if existingValue, exist := seenName[value.Name]; exist { + if value != existingValue { + return []scaffold.Env{}, duplicatedEnvError(existingValue, value) + } + } else { + seenName[value.Name] = value + result = append(result, value) + } + } + return result, nil +} + +func addNewEnvironmentVariable(serviceSpec *scaffold.ServiceSpec, name string, value string) error { + merged, err := mergeEnvWithDuplicationCheck(serviceSpec.Envs, + []scaffold.Env{ + { + Name: name, + Value: value, + }, + }, + ) + if err != nil { + return err + } + serviceSpec.Envs = merged + return nil +} + +func duplicatedEnvError(existingValue scaffold.Env, newValue scaffold.Env) error { + return fmt.Errorf("duplicated environment variable. existingValue = %s, newValue = %s", + existingValue, newValue) +} diff --git a/cli/azd/pkg/project/scaffold_gen_environment_variables_test.go b/cli/azd/pkg/project/scaffold_gen_environment_variables_test.go new file mode 100644 index 00000000000..6bd79a94c44 --- /dev/null +++ b/cli/azd/pkg/project/scaffold_gen_environment_variables_test.go @@ -0,0 +1,92 @@ +package project + +import ( + "fmt" + "github.com/azure/azure-dev/cli/azd/internal/scaffold" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestMergeEnvWithDuplicationCheck(t *testing.T) { + var empty []scaffold.Env + name1Value1 := []scaffold.Env{ + { + Name: "name1", + Value: "value1", + }, + } + name1Value2 := []scaffold.Env{ + { + Name: "name1", + Value: "value2", + }, + } + name2Value2 := []scaffold.Env{ + { + Name: "name2", + Value: "value2", + }, + } + name1Value1Name2Value2 := []scaffold.Env{ + { + Name: "name1", + Value: "value1", + }, + { + Name: "name2", + Value: "value2", + }, + } + + tests := []struct { + name string + a []scaffold.Env + b []scaffold.Env + wantEnv []scaffold.Env + wantError error + }{ + { + name: "2 empty array", + a: empty, + b: empty, + wantEnv: empty, + wantError: nil, + }, + { + name: "one is empty, another is not", + a: empty, + b: name1Value1, + wantEnv: name1Value1, + wantError: nil, + }, + { + name: "no duplication", + a: name1Value1, + b: name2Value2, + wantEnv: name1Value1Name2Value2, + wantError: nil, + }, + { + name: "duplicated name but same value", + a: name1Value1, + b: name1Value1, + wantEnv: name1Value1, + wantError: nil, + }, + { + name: "duplicated name, different value", + a: name1Value1, + b: name1Value2, + wantEnv: []scaffold.Env{}, + wantError: fmt.Errorf("duplicated environment variable. existingValue = %s, newValue = %s", + name1Value1[0], name1Value2[0]), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env, err := mergeEnvWithDuplicationCheck(tt.a, tt.b) + assert.Equal(t, tt.wantEnv, env) + assert.Equal(t, tt.wantError, err) + }) + } +} diff --git a/cli/azd/pkg/project/scaffold_gen_test.go b/cli/azd/pkg/project/scaffold_gen_test.go index 85cf4125075..a3c11a38119 100644 --- a/cli/azd/pkg/project/scaffold_gen_test.go +++ b/cli/azd/pkg/project/scaffold_gen_test.go @@ -18,23 +18,23 @@ func Test_genBicepParamsFromEnvSubst(t *testing.T) { want string wantParams []scaffold.Parameter }{ - {"foo", false, "'foo'", nil}, - {"${MY_VAR}", false, "myVar", []scaffold.Parameter{{Name: "myVar", Value: "${MY_VAR}", Type: "string"}}}, + {"foo", false, "foo", nil}, + {"${MY_VAR}", false, "${myVar}", []scaffold.Parameter{{Name: "myVar", Value: "${MY_VAR}", Type: "string"}}}, - {"${MY_SECRET}", true, "mySecret", + {"${MY_SECRET}", true, "${mySecret}", []scaffold.Parameter{ {Name: "mySecret", Value: "${MY_SECRET}", Type: "string", Secret: true}}}, - {"Hello, ${world:=okay}!", false, "world", + {"Hello, ${world:=okay}!", false, "${world}", []scaffold.Parameter{ {Name: "world", Value: "${world:=okay}", Type: "string"}}}, - {"${CAT} and ${DOG}", false, "'${cat} and ${dog}'", + {"${CAT} and ${DOG}", false, "${cat} and ${dog}", []scaffold.Parameter{ {Name: "cat", Value: "${CAT}", Type: "string"}, {Name: "dog", Value: "${DOG}", Type: "string"}}}, - {"${DB_HOST:='local'}:${DB_USERNAME:='okay'}", true, "'${dbHost}:${dbUsername}'", + {"${DB_HOST:='local'}:${DB_USERNAME:='okay'}", true, "${dbHost}:${dbUsername}", []scaffold.Parameter{ {Name: "dbHost", Value: "${DB_HOST:='local'}", Type: "string", Secret: true}, {Name: "dbUsername", Value: "${DB_USERNAME:='okay'}", Type: "string", Secret: true}}}, diff --git a/cli/azd/resources/scaffold/base/modules/set-event-hubs-namespace-connection-string.bicep b/cli/azd/resources/scaffold/base/modules/set-event-hubs-namespace-connection-string.bicep index 7eee8d73cdc..64245640096 100644 --- a/cli/azd/resources/scaffold/base/modules/set-event-hubs-namespace-connection-string.bicep +++ b/cli/azd/resources/scaffold/base/modules/set-event-hubs-namespace-connection-string.bicep @@ -17,3 +17,5 @@ resource connectionStringSecret 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = value: listKeys(concat(resourceId('Microsoft.EventHub/namespaces', eventHubsNamespaceName), '/AuthorizationRules/RootManageSharedAccessKey'), eventHubsNamespace.apiVersion).primaryConnectionString } } + +output keyVaultUrl string = 'https://${keyVaultName}${environment().suffixes.keyvaultDns}/secrets/${connectionStringSecretName}' diff --git a/cli/azd/resources/scaffold/base/modules/set-redis-conn.bicep b/cli/azd/resources/scaffold/base/modules/set-redis-conn.bicep index 813f96fbcbf..fbe41132a20 100644 --- a/cli/azd/resources/scaffold/base/modules/set-redis-conn.bicep +++ b/cli/azd/resources/scaffold/base/modules/set-redis-conn.bicep @@ -27,3 +27,6 @@ resource urlSecret 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { } } +output keyVaultUrlForPass string = 'https://${keyVaultName}${environment().suffixes.keyvaultDns}/secrets/${passwordSecretName}' +output keyVaultUrlForUrl string = 'https://${keyVaultName}${environment().suffixes.keyvaultDns}/secrets/${urlSecretName}' + diff --git a/cli/azd/resources/scaffold/base/modules/set-servicebus-namespace-connection-string.bicep b/cli/azd/resources/scaffold/base/modules/set-servicebus-namespace-connection-string.bicep index 1152b5dcc12..b58a707370d 100644 --- a/cli/azd/resources/scaffold/base/modules/set-servicebus-namespace-connection-string.bicep +++ b/cli/azd/resources/scaffold/base/modules/set-servicebus-namespace-connection-string.bicep @@ -17,3 +17,5 @@ resource serviceBusConnectionStringSecret 'Microsoft.KeyVault/vaults/secrets@202 value: listKeys(concat(resourceId('Microsoft.ServiceBus/namespaces', serviceBusNamespaceName), '/AuthorizationRules/RootManageSharedAccessKey'), serviceBusNamespace.apiVersion).primaryConnectionString } } + +output keyVaultUrl string = 'https://${keyVaultName}${environment().suffixes.keyvaultDns}/secrets/${connectionStringSecretName}' diff --git a/cli/azd/resources/scaffold/base/modules/set-storage-account-connection-string.bicep b/cli/azd/resources/scaffold/base/modules/set-storage-account-connection-string.bicep index 2b04668f17b..6e0a7da7912 100644 --- a/cli/azd/resources/scaffold/base/modules/set-storage-account-connection-string.bicep +++ b/cli/azd/resources/scaffold/base/modules/set-storage-account-connection-string.bicep @@ -17,3 +17,5 @@ resource connectionStringSecret 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};AccountKey=${storageAccount.listKeys().keys[0].value};EndpointSuffix=${environment().suffixes.storage}' } } + +output keyVaultUrl string = 'https://${keyVaultName}${environment().suffixes.keyvaultDns}/secrets/${connectionStringSecretName}' diff --git a/cli/azd/resources/scaffold/templates/resources.bicept b/cli/azd/resources/scaffold/templates/resources.bicept index cf949011783..d011af5fa22 100644 --- a/cli/azd/resources/scaffold/templates/resources.bicept +++ b/cli/azd/resources/scaffold/templates/resources.bicept @@ -75,6 +75,7 @@ module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.4.5 {{- end}} {{- if .DbCosmosMongo}} +var mongoDatabaseName = '{{ .DbCosmosMongo.DatabaseName }}' module cosmos 'br/public:avm/res/document-db/database-account:0.8.1' = { name: 'cosmos' params: { @@ -93,13 +94,11 @@ module cosmos 'br/public:avm/res/document-db/database-account:0.8.1' = { virtualNetworkRules: [] publicNetworkAccess: 'Enabled' } - {{- if .DbCosmosMongo.DatabaseName}} mongodbDatabases: [ { - name: '{{ .DbCosmosMongo.DatabaseName }}' + name: mongoDatabaseName } ] - {{- end}} secretsExportConfiguration: { keyVaultResourceId: keyVault.outputs.resourceId primaryWriteConnectionStringSecretName: 'MONGODB-URL' @@ -109,7 +108,7 @@ module cosmos 'br/public:avm/res/document-db/database-account:0.8.1' = { } {{- end}} {{- if .DbCosmos }} - +var cosmosDatabaseName = '{{ .DbCosmos.DatabaseName }}' module cosmos 'br/public:avm/res/document-db/database-account:0.8.1' = { name: 'cosmos' params: { @@ -147,8 +146,10 @@ module cosmos 'br/public:avm/res/document-db/database-account:0.8.1' = { ] sqlRoleAssignmentsPrincipalIds: [ {{- range .Services}} + {{- if .DbCosmos }} {{bicepName .Name}}Identity.outputs.principalId {{- end}} + {{- end}} ] sqlRoleDefinitions: [ { @@ -321,8 +322,8 @@ module eventHubNamespace 'br/public:avm/res/event-hub/namespace:0.7.1' = { name: '${abbrs.eventHubNamespaces}${resourceToken}' location: location roleAssignments: [ - {{- if (and .AzureEventHubs (eq .AzureEventHubs.AuthType "USER_ASSIGNED_MANAGED_IDENTITY")) }} {{- range .Services}} + {{- if (and .AzureEventHubs (eq .AzureEventHubs.AuthType "USER_ASSIGNED_MANAGED_IDENTITY")) }} { principalId: {{bicepName .Name}}Identity.outputs.principalId principalType: 'ServicePrincipal' @@ -372,8 +373,8 @@ module storageAccount 'br/public:avm/res/storage/storage-account:0.14.3' = { } location: location roleAssignments: [ - {{- if (and .AzureStorageAccount (eq .AzureStorageAccount.AuthType "USER_ASSIGNED_MANAGED_IDENTITY")) }} {{- range .Services}} + {{- if (and .AzureStorageAccount (eq .AzureStorageAccount.AuthType "USER_ASSIGNED_MANAGED_IDENTITY")) }} { principalId: {{bicepName .Name}}Identity.outputs.principalId principalType: 'ServicePrincipal' @@ -411,8 +412,8 @@ module serviceBusNamespace 'br/public:avm/res/service-bus/namespace:0.10.0' = { // Non-required parameters location: location roleAssignments: [ - {{- if (and .AzureServiceBus (eq .AzureServiceBus.AuthType "USER_ASSIGNED_MANAGED_IDENTITY")) }} {{- range .Services}} + {{- if (and .AzureServiceBus (eq .AzureServiceBus.AuthType "USER_ASSIGNED_MANAGED_IDENTITY")) }} { principalId: {{bicepName .Name}}Identity.outputs.principalId principalType: 'ServicePrincipal' @@ -486,7 +487,7 @@ resource localUserOpenAIIdentity 'Microsoft.Authorization/roleAssignments@2022-0 } {{- end}} -{{- range .Services}} +{{- range $service := .Services}} module {{bicepName .Name}}Identity 'br/public:avm/res/managed-identity/user-assigned-identity:0.2.1' = { name: '{{bicepName .Name}}identity' @@ -558,65 +559,22 @@ module {{bicepName .Name}} 'br/public:avm/res/app/container-app:0.8.0' = { scaleMaxReplicas: 10 secrets: { secureList: union([ - {{- if .DbCosmosMongo}} + {{- range $env := .Envs}} + {{- if (shouldAddToBicepFile $service $env.Name) }} + {{- if (eq (toBicepEnv $env).BicepEnvType "keyVaultSecret") }} { - name: 'mongodb-url' - identity:{{bicepName .Name}}Identity.outputs.resourceId - keyVaultUrl: cosmos.outputs.exportedSecrets['MONGODB-URL'].secretUri + name: '{{ (toBicepEnv $env).SecretName }}' + identity:{{bicepName $service.Name}}Identity.outputs.resourceId + keyVaultUrl: {{ (toBicepEnv $env).SecretValue }} } {{- end}} - {{- if (and .DbPostgres (eq .DbPostgres.AuthType "PASSWORD")) }} - { - name: 'postgresql-password' - value: postgreSqlDatabasePassword - } + {{- if (eq (toBicepEnv $env).BicepEnvType "secret") }} { - name: 'postgresql-db-url' - value: 'postgresql://${postgreSqlDatabaseUser}:${postgreSqlDatabasePassword}@${postgreServer.outputs.fqdn}:5432/${postgreSqlDatabaseName}' + name: '{{ (toBicepEnv $env).SecretName }}' + value: {{ (toBicepEnv $env).SecretValue }} } {{- end}} - {{- if (and .DbMySql (eq .DbMySql.AuthType "PASSWORD")) }} - { - name: 'mysql-password' - value: mysqlDatabasePassword - } - { - name: 'mysql-db-url' - value: 'mysql://${mysqlDatabaseUser}:${mysqlDatabasePassword}@${mysqlServer.outputs.fqdn}:3306/${mysqlDatabaseName}' - } {{- end}} - {{- if .DbRedis}} - { - name: 'redis-pass' - identity:{{bicepName .Name}}Identity.outputs.resourceId - keyVaultUrl: '${keyVault.outputs.uri}secrets/REDIS-PASSWORD' - } - { - name: 'redis-url' - identity:{{bicepName .Name}}Identity.outputs.resourceId - keyVaultUrl: '${keyVault.outputs.uri}secrets/REDIS-URL' - } - {{- end}} - {{- if (and .AzureEventHubs (eq .AzureEventHubs.AuthType "CONNECTION_STRING")) }} - { - name: 'event-hubs-connection-string' - identity:{{bicepName .Name}}Identity.outputs.resourceId - keyVaultUrl: '${keyVault.outputs.uri}secrets/EVENT-HUBS-CONNECTION-STRING' - } - {{- end}} - {{- if (and .AzureServiceBus (eq .AzureServiceBus.AuthType "CONNECTION_STRING")) }} - { - name: 'servicebus-connection-string' - identity:{{bicepName .Name}}Identity.outputs.resourceId - keyVaultUrl: '${keyVault.outputs.uri}secrets/SERVICEBUS-CONNECTION-STRING' - } - {{- end}} - {{- if (and .AzureStorageAccount (eq .AzureStorageAccount.AuthType "CONNECTION_STRING")) }} - { - name: 'storage-account-connection-string' - identity:{{bicepName .Name}}Identity.outputs.resourceId - keyVaultUrl: '${keyVault.outputs.uri}secrets/STORAGE-ACCOUNT-CONNECTION-STRING' - } {{- end}} ], map({{bicepName .Name}}Secrets, secret => { @@ -633,305 +591,34 @@ module {{bicepName .Name}} 'br/public:avm/res/app/container-app:0.8.0' = { memory: '1.0Gi' } env: union([ + {{- range $env := .Envs }} + {{- if (shouldAddToBicepFile $service $env.Name) }} + {{- if (or (eq (toBicepEnv $env).BicepEnvType "keyVaultSecret") (eq (toBicepEnv $env).BicepEnvType "secret")) }} { - name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' - value: monitoring.outputs.applicationInsightsConnectionString - } - { - name: 'AZURE_CLIENT_ID' - value: {{bicepName .Name}}Identity.outputs.clientId - } - {{- if .DbCosmosMongo}} - { - name: 'MONGODB_URL' - secretRef: 'mongodb-url' - } - { - name: 'spring.data.mongodb.uri' - secretRef: 'mongodb-url' - } - { - name: 'spring.data.mongodb.database' - value: '{{ .DbCosmosMongo.DatabaseName }}' - } - {{- end}} - {{- if .DbPostgres}} - { - name: 'POSTGRES_HOST' - value: postgreServer.outputs.fqdn - } - { - name: 'POSTGRES_DATABASE' - value: postgreSqlDatabaseName - } - { - name: 'POSTGRES_PORT' - value: '5432' - } - {{- end}} - {{- if (and .DbPostgres (eq .DbPostgres.AuthType "PASSWORD")) }} - { - name: 'POSTGRES_URL' - secretRef: 'postgresql-db-url' - } - { - name: 'POSTGRES_USERNAME' - value: postgreSqlDatabaseUser - } - { - name: 'POSTGRES_PASSWORD' - secretRef: 'postgresql-password' - } - { - name: 'spring.datasource.url' - value: 'jdbc:postgresql://${postgreServer.outputs.fqdn}:5432/${postgreSqlDatabaseName}' - } - { - name: 'spring.datasource.username' - value: postgreSqlDatabaseUser - } - { - name: 'spring.datasource.password' - secretRef: 'postgresql-password' - } - {{- end}} - {{- if .DbMySql}} - { - name: 'MYSQL_HOST' - value: mysqlServer.outputs.fqdn - } - { - name: 'MYSQL_DATABASE' - value: mysqlDatabaseName - } - { - name: 'MYSQL_PORT' - value: '3306' - } - {{- end}} - {{- if (and .DbMySql (eq .DbMySql.AuthType "PASSWORD")) }} - { - name: 'MYSQL_URL' - secretRef: 'mysql-db-url' - } - { - name: 'MYSQL_USERNAME' - value: mysqlDatabaseUser - } - { - name: 'MYSQL_PASSWORD' - secretRef: 'mysql-password' - } - { - name: 'spring.datasource.url' - value: 'jdbc:mysql://${mysqlServer.outputs.fqdn}:3306/${mysqlDatabaseName}' - } - { - name: 'spring.datasource.username' - value: mysqlDatabaseUser - } - { - name: 'spring.datasource.password' - secretRef: 'mysql-password' - } - {{- end}} - {{- if .DbCosmos }} - { - name: 'spring.cloud.azure.cosmos.endpoint' - value: cosmos.outputs.endpoint - } - { - name: 'spring.cloud.azure.cosmos.database' - value: '{{ .DbCosmos.DatabaseName }}' - } - {{- end}} - {{- if .DbRedis}} - { - name: 'REDIS_HOST' - value: redis.outputs.hostName - } - { - name: 'REDIS_PORT' - value: string(redis.outputs.sslPort) - } - { - name: 'REDIS_URL' - secretRef: 'redis-url' - } - { - name: 'REDIS_ENDPOINT' - value: '${redis.outputs.hostName}:${string(redis.outputs.sslPort)}' - } - { - name: 'REDIS_PASSWORD' - secretRef: 'redis-pass' - } - { - name: 'spring.data.redis.url' - secretRef: 'redis-url' + name: '{{ (toBicepEnv $env).Name }}' + secretRef: '{{ (toBicepEnv $env).SecretName }}' } {{- end}} - {{- if .AIModels}} + {{- if (eq (toBicepEnv $env).BicepEnvType "plainText") }} { - name: 'AZURE_OPENAI_ENDPOINT' - value: account.outputs.endpoint + name: '{{ (toBicepEnv $env).Name }}' + {{- if (eq (toBicepEnv $env).PlainTextValue "'__PlaceHolderForServiceIdentityClientId'")}} + value: {{bicepName $service.Name}}Identity.outputs.clientId + {{- else}} + value: {{ (toBicepEnv $env).PlainTextValue }} + {{- end}} } {{- end}} - {{- if (and .AzureEventHubs (not .AzureEventHubs.UseKafka)) }} - { - name: 'spring.cloud.azure.eventhubs.namespace' - value: eventHubNamespace.outputs.name - } {{- end}} - {{- if (and .AzureEventHubs .AzureEventHubs.UseKafka) }} - { - name: 'spring.cloud.stream.kafka.binder.brokers' - value: '${eventHubNamespace.outputs.name}.servicebus.windows.net:9093' - } - {{- if (hasPrefix .AzureEventHubs.SpringBootVersion "2.") }} - { - name: 'spring.cloud.stream.binders.kafka.environment.spring.main.sources' - value: 'com.azure.spring.cloud.autoconfigure.eventhubs.kafka.AzureEventHubsKafkaAutoConfiguration' - } {{- end}} - {{- if (hasPrefix .AzureEventHubs.SpringBootVersion "3.") }} { - name: 'spring.cloud.stream.binders.kafka.environment.spring.main.sources' - value: 'com.azure.spring.cloud.autoconfigure.implementation.eventhubs.kafka.AzureEventHubsKafkaAutoConfiguration' - } - {{- end}} - {{- end}} - {{- if (and .AzureEventHubs (eq .AzureEventHubs.AuthType "USER_ASSIGNED_MANAGED_IDENTITY")) }} - { - name: 'spring.cloud.azure.eventhubs.credential.managed-identity-enabled' - value: 'true' - } - { - name: 'spring.cloud.azure.eventhubs.credential.client-id' - value: {{bicepName .Name}}Identity.outputs.clientId - } - { - name: 'spring.cloud.azure.eventhubs.connection-string' - value: '' - } - {{- end}} - {{- if (and .AzureEventHubs (eq .AzureEventHubs.AuthType "CONNECTION_STRING")) }} - { - name: 'spring.cloud.azure.eventhubs.connection-string' - secretRef: 'event-hubs-connection-string' - } - { - name: 'spring.cloud.azure.eventhubs.credential.managed-identity-enabled' - value: 'false' - } - { - name: 'spring.cloud.azure.eventhubs.credential.client-id' - value: '' - } - {{- end}} - {{- if .AzureStorageAccount }} - { - name: 'spring.cloud.azure.eventhubs.processor.checkpoint-store.account-name' - value: storageAccountName - } - {{- end}} - {{- if (and .AzureStorageAccount (eq .AzureStorageAccount.AuthType "USER_ASSIGNED_MANAGED_IDENTITY")) }} - { - name: 'spring.cloud.azure.eventhubs.processor.checkpoint-store.connection-string' - value: '' - } - { - name: 'spring.cloud.azure.eventhubs.processor.checkpoint-store.credential.managed-identity-enabled' - value: 'true' - } - { - name: 'spring.cloud.azure.eventhubs.processor.checkpoint-store.credential.client-id' - value: {{bicepName .Name}}Identity.outputs.clientId - } - {{- end}} - {{- if (and .AzureStorageAccount (eq .AzureStorageAccount.AuthType "CONNECTION_STRING")) }} - { - name: 'spring.cloud.azure.eventhubs.processor.checkpoint-store.connection-string' - secretRef: 'storage-account-connection-string' - } - { - name: 'spring.cloud.azure.eventhubs.processor.checkpoint-store.credential.managed-identity-enabled' - value: 'false' - } - { - name: 'spring.cloud.azure.eventhubs.processor.checkpoint-store.credential.client-id' - value: '' - } - {{- end}} - - {{- if and .AzureServiceBus (not .AzureServiceBus.IsJms)}} - { - name: 'spring.cloud.azure.servicebus.namespace' - value: serviceBusNamespace.outputs.name - } - {{- end}} - {{- if (and (and .AzureServiceBus (eq .AzureServiceBus.AuthType "USER_ASSIGNED_MANAGED_IDENTITY")) (not .AzureServiceBus.IsJms)) }} - { - name: 'spring.cloud.azure.servicebus.connection-string' - value: '' - } - { - name: 'spring.cloud.azure.servicebus.credential.managed-identity-enabled' - value: 'true' - } - { - name: 'spring.cloud.azure.servicebus.credential.client-id' - value: {{bicepName .Name}}Identity.outputs.clientId - } - {{- end}} - {{- if (and (and .AzureServiceBus (eq .AzureServiceBus.AuthType "CONNECTION_STRING")) (not .AzureServiceBus.IsJms)) }} - { - name: 'spring.cloud.azure.servicebus.connection-string' - secretRef: 'servicebus-connection-string' - } - { - name: 'spring.cloud.azure.servicebus.credential.managed-identity-enabled' - value: 'false' - } - { - name: 'spring.cloud.azure.eventhubs.credential.client-id' - value: '' - } - {{- end}} - {{- if (and (and .AzureServiceBus (eq .AzureServiceBus.AuthType "CONNECTION_STRING")) .AzureServiceBus.IsJms) }} - { - name: 'spring.jms.servicebus.connection-string' - secretRef: 'servicebus-connection-string' - } - { - name: 'spring.jms.servicebus.pricing-tier' - value: 'premium' - } - {{- end}} - - {{- if (and (and .AzureServiceBus (eq .AzureServiceBus.AuthType "USER_ASSIGNED_MANAGED_IDENTITY")) .AzureServiceBus.IsJms) }} - { - name: 'spring.jms.servicebus.passwordless-enabled' - value: 'true' - } - { - name: 'spring.jms.servicebus.namespace' - value: serviceBusNamespace.outputs.name - } - { - name: 'spring.jms.servicebus.credential.managed-identity-enabled' - value: 'true' + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: monitoring.outputs.applicationInsightsConnectionString } { - name: 'spring.jms.servicebus.credential.client-id' + name: 'AZURE_CLIENT_ID' value: {{bicepName .Name}}Identity.outputs.clientId } - { - name: 'spring.jms.servicebus.pricing-tier' - value: 'premium' - } - {{- end}} - {{- if .Frontend}} {{- range $i, $e := .Frontend.Backends}} {