diff --git a/cli/azd/cmd/root.go b/cli/azd/cmd/root.go index 14c98cb7beb..e397aa61fc4 100644 --- a/cli/azd/cmd/root.go +++ b/cli/azd/cmd/root.go @@ -177,7 +177,7 @@ func NewRootCmd( Command: logout, ActionResolver: newLogoutAction, }) - + root.Add("init", &actions.ActionDescriptorOptions{ Command: newInitCmd(), FlagsResolver: newInitFlags, diff --git a/cli/azd/internal/appdetect/appdetect.go b/cli/azd/internal/appdetect/appdetect.go index 90cdf3297c4..aff6c7a3328 100644 --- a/cli/azd/internal/appdetect/appdetect.go +++ b/cli/azd/internal/appdetect/appdetect.go @@ -131,6 +131,36 @@ func (db DatabaseDep) Display() string { return "" } +//type AzureDep string + +type AzureDep interface { + ResourceDisplay() string +} + +type AzureDepServiceBus struct { + Queues []string +} + +func (a AzureDepServiceBus) ResourceDisplay() string { + return "Azure Service Bus" +} + +type AzureDepEventHubs struct { + Names []string +} + +func (a AzureDepEventHubs) ResourceDisplay() string { + return "Azure Event Hubs" +} + +type AzureDepStorageAccount struct { + ContainerNames []string +} + +func (a AzureDepStorageAccount) ResourceDisplay() string { + return "Azure Storage Account" +} + type Project struct { // The language associated with the project. Language Language @@ -141,6 +171,9 @@ type Project struct { // Experimental: Database dependencies inferred through heuristics while scanning dependencies in the project. DatabaseDeps []DatabaseDep + // Experimental: Azure dependencies inferred through heuristics while scanning dependencies in the project. + AzureDeps []AzureDep + // The path to the project directory. Path string diff --git a/cli/azd/internal/appdetect/appdetect_test.go b/cli/azd/internal/appdetect/appdetect_test.go index b356a151ace..a51222cad2d 100644 --- a/cli/azd/internal/appdetect/appdetect_test.go +++ b/cli/azd/internal/appdetect/appdetect_test.go @@ -46,8 +46,10 @@ func TestDetect(t *testing.T) { Path: "java-multimodules/application", DetectionRule: "Inferred by presence of: pom.xml", DatabaseDeps: []DatabaseDep{ + DbMongo, DbMySql, DbPostgres, + DbRedis, }, }, { @@ -130,8 +132,10 @@ func TestDetect(t *testing.T) { Path: "java-multimodules/application", DetectionRule: "Inferred by presence of: pom.xml", DatabaseDeps: []DatabaseDep{ + DbMongo, DbMySql, DbPostgres, + DbRedis, }, }, { @@ -163,8 +167,10 @@ func TestDetect(t *testing.T) { Path: "java-multimodules/application", DetectionRule: "Inferred by presence of: pom.xml", DatabaseDeps: []DatabaseDep{ + DbMongo, DbMySql, DbPostgres, + DbRedis, }, }, { @@ -199,8 +205,10 @@ func TestDetect(t *testing.T) { Path: "java-multimodules/application", DetectionRule: "Inferred by presence of: pom.xml", DatabaseDeps: []DatabaseDep{ + DbMongo, DbMySql, DbPostgres, + DbRedis, }, }, { @@ -279,6 +287,24 @@ func TestDetectNested(t *testing.T) { }) } +func TestAnalyzeJavaSpringProject(t *testing.T) { + var properties = readProperties(filepath.Join("testdata", "java-spring", "project-one")) + require.Equal(t, "", properties["not.exist"]) + require.Equal(t, "jdbc:h2:mem:testdb", properties["spring.datasource.url"]) + + properties = readProperties(filepath.Join("testdata", "java-spring", "project-two")) + require.Equal(t, "", properties["not.exist"]) + require.Equal(t, "jdbc:h2:mem:testdb", properties["spring.datasource.url"]) + + properties = readProperties(filepath.Join("testdata", "java-spring", "project-three")) + require.Equal(t, "", properties["not.exist"]) + require.Equal(t, "HTML", properties["spring.thymeleaf.mode"]) + + properties = readProperties(filepath.Join("testdata", "java-spring", "project-four")) + require.Equal(t, "", properties["not.exist"]) + require.Equal(t, "mysql", properties["database"]) +} + func copyTestDataDir(glob string, dst string) error { root := "testdata" return fs.WalkDir(testDataFs, root, func(name string, d fs.DirEntry, err error) error { diff --git a/cli/azd/internal/appdetect/java.go b/cli/azd/internal/appdetect/java.go index fe6fec3ea65..960ecd2fcbb 100644 --- a/cli/azd/internal/appdetect/java.go +++ b/cli/azd/internal/appdetect/java.go @@ -1,10 +1,14 @@ package appdetect import ( + "bufio" "context" "encoding/xml" "fmt" + "github.com/azure/azure-dev/cli/azd/pkg/osutil" + "github.com/braydonk/yaml" "io/fs" + "log" "maps" "os" "path/filepath" @@ -121,6 +125,24 @@ func readMavenProject(filePath string) (*mavenProject, error) { } func detectDependencies(mavenProject *mavenProject, project *Project) (*Project, error) { + // how can we tell it's a Spring Boot project? + // 1. It has a parent with a groupId of org.springframework.boot and an artifactId of spring-boot-starter-parent + // 2. It has a dependency with a groupId of org.springframework.boot and an artifactId that starts with spring-boot-starter + isSpringBoot := false + if mavenProject.Parent.GroupId == "org.springframework.boot" && mavenProject.Parent.ArtifactId == "spring-boot-starter-parent" { + isSpringBoot = true + } + for _, dep := range mavenProject.Dependencies { + if dep.GroupId == "org.springframework.boot" && strings.HasPrefix(dep.ArtifactId, "spring-boot-starter") { + isSpringBoot = true + break + } + } + applicationProperties := make(map[string]string) + if isSpringBoot { + applicationProperties = readProperties(project.Path) + } + databaseDepMap := map[DatabaseDep]struct{}{} for _, dep := range mavenProject.Dependencies { if dep.GroupId == "com.mysql" && dep.ArtifactId == "mysql-connector-j" { @@ -130,6 +152,55 @@ func detectDependencies(mavenProject *mavenProject, project *Project) (*Project, if dep.GroupId == "org.postgresql" && dep.ArtifactId == "postgresql" { databaseDepMap[DbPostgres] = struct{}{} } + + if dep.GroupId == "org.springframework.boot" && dep.ArtifactId == "spring-boot-starter-data-redis" { + databaseDepMap[DbRedis] = struct{}{} + } + if dep.GroupId == "org.springframework.boot" && dep.ArtifactId == "spring-boot-starter-data-redis-reactive" { + databaseDepMap[DbRedis] = struct{}{} + } + + if dep.GroupId == "org.springframework.boot" && dep.ArtifactId == "spring-boot-starter-data-mongodb" { + databaseDepMap[DbMongo] = struct{}{} + } + if dep.GroupId == "org.springframework.boot" && dep.ArtifactId == "spring-boot-starter-data-mongodb-reactive" { + databaseDepMap[DbMongo] = struct{}{} + } + + if dep.GroupId == "com.azure.spring" && dep.ArtifactId == "spring-cloud-azure-stream-binder-servicebus" { + bindingDestinations := findBindingDestinations(applicationProperties) + destinations := make([]string, 0, len(bindingDestinations)) + for bindingName, destination := range bindingDestinations { + destinations = append(destinations, destination) + log.Printf("Service Bus queue [%s] found for binding [%s]", destination, bindingName) + } + project.AzureDeps = append(project.AzureDeps, AzureDepServiceBus{ + Queues: destinations, + }) + } + + if dep.GroupId == "com.azure.spring" && dep.ArtifactId == "spring-cloud-azure-stream-binder-eventhubs" { + bindingDestinations := findBindingDestinations(applicationProperties) + var destinations []string + containsInBinding := false + for bindingName, destination := range bindingDestinations { + if strings.Contains(bindingName, "-in-") { // Example: consume-in-0 + containsInBinding = true + } + if !contains(destinations, destination) { + destinations = append(destinations, destination) + log.Printf("Event Hubs [%s] found for binding [%s]", destination, bindingName) + } + } + project.AzureDeps = append(project.AzureDeps, AzureDepEventHubs{ + Names: destinations, + }) + if containsInBinding { + project.AzureDeps = append(project.AzureDeps, AzureDepStorageAccount{ + ContainerNames: []string{applicationProperties["spring.cloud.azure.eventhubs.processor.checkpoint-store.container-name"]}, + }) + } + } } if len(databaseDepMap) > 0 { @@ -141,3 +212,132 @@ func detectDependencies(mavenProject *mavenProject, project *Project) (*Project, return project, nil } + +func readProperties(projectPath string) map[string]string { + // todo: do we need to consider the bootstrap.properties + result := make(map[string]string) + readPropertiesInPropertiesFile(filepath.Join(projectPath, "/src/main/resources/application.properties"), result) + readPropertiesInYamlFile(filepath.Join(projectPath, "/src/main/resources/application.yml"), result) + readPropertiesInYamlFile(filepath.Join(projectPath, "/src/main/resources/application.yaml"), result) + profile, profileSet := result["spring.profiles.active"] + if profileSet { + readPropertiesInPropertiesFile(filepath.Join(projectPath, "/src/main/resources/application-"+profile+".properties"), result) + readPropertiesInYamlFile(filepath.Join(projectPath, "/src/main/resources/application-"+profile+".yml"), result) + readPropertiesInYamlFile(filepath.Join(projectPath, "/src/main/resources/application-"+profile+".yaml"), result) + } + return result +} + +func readPropertiesInYamlFile(yamlFilePath string, result map[string]string) { + if !osutil.FileExists(yamlFilePath) { + return + } + data, err := os.ReadFile(yamlFilePath) + if err != nil { + log.Fatalf("error reading YAML file: %v", err) + return + } + + // Parse the YAML into a yaml.Node + var root yaml.Node + err = yaml.Unmarshal(data, &root) + if err != nil { + log.Fatalf("error unmarshalling YAML: %v", err) + return + } + + parseYAML("", &root, result) +} + +// Recursively parse the YAML and build dot-separated keys into a map +func parseYAML(prefix string, node *yaml.Node, result map[string]string) { + switch node.Kind { + case yaml.DocumentNode: + // Process each document's content + for _, contentNode := range node.Content { + parseYAML(prefix, contentNode, result) + } + case yaml.MappingNode: + // Process key-value pairs in a map + for i := 0; i < len(node.Content); i += 2 { + keyNode := node.Content[i] + valueNode := node.Content[i+1] + + // Ensure the key is a scalar + if keyNode.Kind != yaml.ScalarNode { + continue + } + + keyStr := keyNode.Value + newPrefix := keyStr + if prefix != "" { + newPrefix = prefix + "." + keyStr + } + parseYAML(newPrefix, valueNode, result) + } + case yaml.SequenceNode: + // Process items in a sequence (list) + for i, item := range node.Content { + newPrefix := fmt.Sprintf("%s[%d]", prefix, i) + parseYAML(newPrefix, item, result) + } + case yaml.ScalarNode: + // If it's a scalar value, add it to the result map + result[prefix] = node.Value + default: + // Handle other node types if necessary + } +} + +func readPropertiesInPropertiesFile(propertiesFilePath string, result map[string]string) { + if !osutil.FileExists(propertiesFilePath) { + return + } + file, err := os.Open(propertiesFilePath) + if err != nil { + log.Fatalf("error opening properties file: %v", err) + return + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if strings.TrimSpace(line) == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + result[key] = value + } + } +} + +// Function to find all properties that match the pattern `spring.cloud.stream.bindings..destination` +func findBindingDestinations(properties map[string]string) map[string]string { + result := make(map[string]string) + + // Iterate through the properties map and look for matching keys + for key, value := range properties { + // Check if the key matches the pattern `spring.cloud.stream.bindings..destination` + if strings.HasPrefix(key, "spring.cloud.stream.bindings.") && strings.HasSuffix(key, ".destination") { + // Extract the binding name + bindingName := key[len("spring.cloud.stream.bindings.") : len(key)-len(".destination")] + // Store the binding name and destination value + result[bindingName] = fmt.Sprintf("%v", value) + } + } + + return result +} + +func contains(array []string, str string) bool { + for _, v := range array { + if v == str { + return true + } + } + return false +} diff --git a/cli/azd/internal/appdetect/testdata/java-multimodules/application/pom.xml b/cli/azd/internal/appdetect/testdata/java-multimodules/application/pom.xml index e4ddaa858b5..a63cc042486 100644 --- a/cli/azd/internal/appdetect/testdata/java-multimodules/application/pom.xml +++ b/cli/azd/internal/appdetect/testdata/java-multimodules/application/pom.xml @@ -38,6 +38,16 @@ com.mysql mysql-connector-j + + + org.springframework.boot + spring-boot-starter-data-redis + + + + org.springframework.boot + spring-boot-starter-data-mongodb + org.postgresql diff --git a/cli/azd/internal/appdetect/testdata/java-spring/project-four/src/main/resources/application-mysql.properties b/cli/azd/internal/appdetect/testdata/java-spring/project-four/src/main/resources/application-mysql.properties new file mode 100644 index 00000000000..33ec21d3c95 --- /dev/null +++ b/cli/azd/internal/appdetect/testdata/java-spring/project-four/src/main/resources/application-mysql.properties @@ -0,0 +1,7 @@ +# database init, supports mysql too +database=mysql +spring.datasource.url=jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:petclinic} +spring.datasource.username=${MYSQL_USERNAME:petclinic} +spring.datasource.password=${MYSQL_PASSWORD:} +# SQL is written to be idempotent so this is safe +spring.sql.init.mode=always diff --git a/cli/azd/internal/appdetect/testdata/java-spring/project-four/src/main/resources/application-postgres.properties b/cli/azd/internal/appdetect/testdata/java-spring/project-four/src/main/resources/application-postgres.properties new file mode 100644 index 00000000000..7d9676e3aad --- /dev/null +++ b/cli/azd/internal/appdetect/testdata/java-spring/project-four/src/main/resources/application-postgres.properties @@ -0,0 +1,6 @@ +database=postgres +spring.datasource.url=jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_HOST:5432}/${POSTGRES_DATABASE:petclinic} +spring.datasource.username=${POSTGRES_USERNAME:petclinic} +spring.datasource.password=${POSTGRES_PASSWORD:} +# SQL is written to be idempotent so this is safe +spring.sql.init.mode=always diff --git a/cli/azd/internal/appdetect/testdata/java-spring/project-four/src/main/resources/application.properties b/cli/azd/internal/appdetect/testdata/java-spring/project-four/src/main/resources/application.properties new file mode 100644 index 00000000000..59d5368e73c --- /dev/null +++ b/cli/azd/internal/appdetect/testdata/java-spring/project-four/src/main/resources/application.properties @@ -0,0 +1,29 @@ +# database init, supports mysql too +database=h2 +spring.sql.init.schema-locations=classpath*:db/${database}/schema.sql +spring.sql.init.data-locations=classpath*:db/${database}/data.sql + +# Web +spring.thymeleaf.mode=HTML + +# JPA +spring.jpa.hibernate.ddl-auto=none +spring.jpa.open-in-view=true + +# Internationalization +spring.messages.basename=messages/messages + +spring.profiles.active=mysql + +# Actuator +management.endpoints.web.exposure.include=* + +# Logging +logging.level.org.springframework=INFO +# logging.level.org.springframework.web=DEBUG +# logging.level.org.springframework.context.annotation=TRACE + +# Maximum time static resources should be cached +spring.web.resources.cache.cachecontrol.max-age=12h + +server.port=8081 diff --git a/cli/azd/internal/appdetect/testdata/java-spring/project-one/src/main/resources/application.yml b/cli/azd/internal/appdetect/testdata/java-spring/project-one/src/main/resources/application.yml new file mode 100644 index 00000000000..09d0cc057c5 --- /dev/null +++ b/cli/azd/internal/appdetect/testdata/java-spring/project-one/src/main/resources/application.yml @@ -0,0 +1,12 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb + jackson: + date-format: com.microsoft.azure.simpletodo.configuration.RFC3339DateFormat + serialization: + write-dates-as-timestamps: false + jpa: + hibernate: + ddl-auto: update + show-sql: true + diff --git a/cli/azd/internal/appdetect/testdata/java-spring/project-three/src/main/resources/application.properties b/cli/azd/internal/appdetect/testdata/java-spring/project-three/src/main/resources/application.properties new file mode 100644 index 00000000000..59d5368e73c --- /dev/null +++ b/cli/azd/internal/appdetect/testdata/java-spring/project-three/src/main/resources/application.properties @@ -0,0 +1,29 @@ +# database init, supports mysql too +database=h2 +spring.sql.init.schema-locations=classpath*:db/${database}/schema.sql +spring.sql.init.data-locations=classpath*:db/${database}/data.sql + +# Web +spring.thymeleaf.mode=HTML + +# JPA +spring.jpa.hibernate.ddl-auto=none +spring.jpa.open-in-view=true + +# Internationalization +spring.messages.basename=messages/messages + +spring.profiles.active=mysql + +# Actuator +management.endpoints.web.exposure.include=* + +# Logging +logging.level.org.springframework=INFO +# logging.level.org.springframework.web=DEBUG +# logging.level.org.springframework.context.annotation=TRACE + +# Maximum time static resources should be cached +spring.web.resources.cache.cachecontrol.max-age=12h + +server.port=8081 diff --git a/cli/azd/internal/appdetect/testdata/java-spring/project-two/src/main/resources/application.yaml b/cli/azd/internal/appdetect/testdata/java-spring/project-two/src/main/resources/application.yaml new file mode 100644 index 00000000000..09d0cc057c5 --- /dev/null +++ b/cli/azd/internal/appdetect/testdata/java-spring/project-two/src/main/resources/application.yaml @@ -0,0 +1,12 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb + jackson: + date-format: com.microsoft.azure.simpletodo.configuration.RFC3339DateFormat + serialization: + write-dates-as-timestamps: false + jpa: + hibernate: + ddl-auto: update + show-sql: true + diff --git a/cli/azd/internal/repository/app_init.go b/cli/azd/internal/repository/app_init.go index 86a4f65d9ba..2ea0c0b942c 100644 --- a/cli/azd/internal/repository/app_init.go +++ b/cli/azd/internal/repository/app_init.go @@ -35,9 +35,16 @@ var languageMap = map[appdetect.Language]project.ServiceLanguageKind{ var dbMap = map[appdetect.DatabaseDep]struct{}{ appdetect.DbMongo: {}, appdetect.DbPostgres: {}, + appdetect.DbMySql: {}, appdetect.DbRedis: {}, } +var azureDepMap = map[string]struct{}{ + appdetect.AzureDepServiceBus{}.ResourceDisplay(): {}, + appdetect.AzureDepEventHubs{}.ResourceDisplay(): {}, + appdetect.AzureDepStorageAccount{}.ResourceDisplay(): {}, +} + // InitFromApp initializes the infra directory and project file from the current existing app. func (i *Initializer) InitFromApp( ctx context.Context, diff --git a/cli/azd/internal/repository/detect_confirm.go b/cli/azd/internal/repository/detect_confirm.go index 24969951dc7..900ba3e8820 100644 --- a/cli/azd/internal/repository/detect_confirm.go +++ b/cli/azd/internal/repository/detect_confirm.go @@ -42,11 +42,17 @@ const ( EntryKindModified EntryKind = "modified" ) +type Pair struct { + first appdetect.AzureDep + second EntryKind +} + // detectConfirm handles prompting for confirming the detected services and databases type detectConfirm struct { // detected services and databases Services []appdetect.Project Databases map[appdetect.DatabaseDep]EntryKind + AzureDeps map[string]Pair // the root directory of the project root string @@ -59,6 +65,7 @@ type detectConfirm struct { // Init initializes state from initial detection output func (d *detectConfirm) Init(projects []appdetect.Project, root string) { d.Databases = make(map[appdetect.DatabaseDep]EntryKind) + d.AzureDeps = make(map[string]Pair) d.Services = make([]appdetect.Project, 0, len(projects)) d.modified = false d.root = root @@ -73,16 +80,24 @@ func (d *detectConfirm) Init(projects []appdetect.Project, root string) { d.Databases[dbType] = EntryKindDetected } } + + for _, azureDep := range project.AzureDeps { + if _, supported := azureDepMap[azureDep.ResourceDisplay()]; supported { + d.AzureDeps[azureDep.ResourceDisplay()] = Pair{azureDep, EntryKindDetected} + } + } } d.captureUsage( fields.AppInitDetectedDatabase, - fields.AppInitDetectedServices) + fields.AppInitDetectedServices, + fields.AppInitDetectedAzureDeps) } func (d *detectConfirm) captureUsage( databases attribute.Key, - services attribute.Key) { + services attribute.Key, + azureDeps attribute.Key) { names := make([]string, 0, len(d.Services)) for _, svc := range d.Services { names = append(names, string(svc.Language)) @@ -93,9 +108,16 @@ func (d *detectConfirm) captureUsage( dbNames = append(dbNames, string(db)) } + azureDepNames := make([]string, 0, len(d.AzureDeps)) + + for _, pair := range d.AzureDeps { + azureDepNames = append(azureDepNames, pair.first.ResourceDisplay()) + } + tracing.SetUsageAttributes( databases.StringSlice(dbNames), services.StringSlice(names), + azureDeps.StringSlice(azureDepNames), ) } @@ -146,7 +168,8 @@ func (d *detectConfirm) Confirm(ctx context.Context) error { case 0: d.captureUsage( fields.AppInitConfirmedDatabases, - fields.AppInitConfirmedServices) + fields.AppInitConfirmedServices, + fields.AppInitDetectedAzureDeps) return nil case 1: if err := d.remove(ctx); err != nil { @@ -203,10 +226,15 @@ func (d *detectConfirm) render(ctx context.Context) error { } } + if len(d.Databases) > 0 { + d.console.Message(ctx, "\n"+output.WithBold("Detected databases:")+"\n") + } for db, entry := range d.Databases { switch db { case appdetect.DbPostgres: recommendedServices = append(recommendedServices, "Azure Database for PostgreSQL flexible server") + case appdetect.DbMySql: + recommendedServices = append(recommendedServices, "Azure Database for MySQL flexible server") case appdetect.DbMongo: recommendedServices = append(recommendedServices, "Azure CosmosDB API for MongoDB") case appdetect.DbRedis: @@ -224,6 +252,23 @@ func (d *detectConfirm) render(ctx context.Context) error { d.console.Message(ctx, "") } + if len(d.AzureDeps) > 0 { + d.console.Message(ctx, "\n"+output.WithBold("Detected Azure dependencies:")+"\n") + } + for azureDep, entry := range d.AzureDeps { + recommendedServices = append(recommendedServices, azureDep) + + status := "" + if entry.second == EntryKindModified { + status = " " + output.WithSuccessFormat("[Updated]") + } else if entry.second == EntryKindManual { + status = " " + output.WithSuccessFormat("[Added]") + } + + d.console.Message(ctx, " "+color.BlueString(azureDep)+status) + d.console.Message(ctx, "") + } + displayedServices := make([]string, 0, len(recommendedServices)) for _, svc := range recommendedServices { displayedServices = append(displayedServices, color.MagentaString(svc)) diff --git a/cli/azd/internal/repository/infra_confirm.go b/cli/azd/internal/repository/infra_confirm.go index a9404b2c340..178c0f2fc8b 100644 --- a/cli/azd/internal/repository/infra_confirm.go +++ b/cli/azd/internal/repository/infra_confirm.go @@ -86,15 +86,43 @@ func (i *Initializer) infraSpecFromDetect( i.console.Message(ctx, "Database name is required.") continue } - + authType, err := i.getAuthType(ctx) + if err != nil { + return scaffold.InfraSpec{}, err + } spec.DbPostgres = &scaffold.DatabasePostgres{ - DatabaseName: dbName, + DatabaseName: dbName, + AuthUsingManagedIdentity: authType == scaffold.AuthType_TOKEN_CREDENTIAL, + AuthUsingUsernamePassword: authType == scaffold.AuthType_PASSWORD, + } + break dbPrompt + case appdetect.DbMySql: + if dbName == "" { + i.console.Message(ctx, "Database name is required.") + continue + } + authType, err := i.getAuthType(ctx) + if err != nil { + return scaffold.InfraSpec{}, err + } + spec.DbMySql = &scaffold.DatabaseMySql{ + DatabaseName: dbName, + AuthUsingManagedIdentity: authType == scaffold.AuthType_TOKEN_CREDENTIAL, + AuthUsingUsernamePassword: authType == scaffold.AuthType_PASSWORD, } + break dbPrompt } break dbPrompt } } + for _, azureDep := range detect.AzureDeps { + err := i.promptForAzureResource(ctx, azureDep.first, &spec) + if err != nil { + return scaffold.InfraSpec{}, err + } + } + for _, svc := range detect.Services { name := names.LabelName(filepath.Base(svc.Path)) serviceSpec := scaffold.ServiceSpec{ @@ -163,7 +191,15 @@ func (i *Initializer) infraSpecFromDetect( } case appdetect.DbPostgres: serviceSpec.DbPostgres = &scaffold.DatabaseReference{ - DatabaseName: spec.DbPostgres.DatabaseName, + DatabaseName: spec.DbPostgres.DatabaseName, + AuthUsingManagedIdentity: spec.DbPostgres.AuthUsingManagedIdentity, + AuthUsingUsernamePassword: spec.DbPostgres.AuthUsingUsernamePassword, + } + case appdetect.DbMySql: + serviceSpec.DbMySql = &scaffold.DatabaseReference{ + DatabaseName: spec.DbMySql.DatabaseName, + AuthUsingManagedIdentity: spec.DbMySql.AuthUsingManagedIdentity, + AuthUsingUsernamePassword: spec.DbMySql.AuthUsingUsernamePassword, } case appdetect.DbRedis: serviceSpec.DbRedis = &scaffold.DatabaseReference{ @@ -171,6 +207,17 @@ func (i *Initializer) infraSpecFromDetect( } } } + + for _, azureDep := range svc.AzureDeps { + switch azureDep.(type) { + case appdetect.AzureDepServiceBus: + serviceSpec.AzureServiceBus = spec.AzureServiceBus + case appdetect.AzureDepEventHubs: + serviceSpec.AzureEventHubs = spec.AzureEventHubs + case appdetect.AzureDepStorageAccount: + serviceSpec.AzureStorageAccount = spec.AzureStorageAccount + } + } spec.Services = append(spec.Services, serviceSpec) } @@ -229,3 +276,113 @@ func (i *Initializer) getPortByPrompt(ctx context.Context, promptMessage string) } return port, nil } + +func (i *Initializer) getAuthType(ctx context.Context) (scaffold.AuthType, error) { + authType := scaffold.AuthType(0) + selection, err := i.console.Select(ctx, input.ConsoleOptions{ + Message: "Input the authentication type you want:", + Options: []string{ + "Use user assigned managed identity", + "Use username and password", + }, + }) + if err != nil { + return authType, err + } + switch selection { + case 0: + authType = scaffold.AuthType_TOKEN_CREDENTIAL + case 1: + authType = scaffold.AuthType_PASSWORD + default: + panic("unhandled selection") + } + return authType, nil +} + +func (i *Initializer) promptForAzureResource( + ctx context.Context, + azureDep appdetect.AzureDep, + spec *scaffold.InfraSpec) error { +azureDepPrompt: + for { + azureDepName, err := i.console.Prompt(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf("Input the name of the Azure dependency (%s)", azureDep.ResourceDisplay()), + Help: "Azure dependency name\n\n" + + "Name of the Azure dependency that the app connects to. " + + "This dependency will be created after running azd provision or azd up." + + "\nYou may be able to skip this step by hitting enter, in which case the dependency will not be created.", + }) + if err != nil { + return err + } + + if strings.ContainsAny(azureDepName, " ") { + i.console.MessageUxItem(ctx, &ux.WarningMessage{ + Description: "Dependency name contains whitespace. This might not be allowed by the Azure service.", + }) + confirm, err := i.console.Confirm(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf("Continue with name '%s'?", azureDepName), + }) + if err != nil { + return err + } + + if !confirm { + continue azureDepPrompt + } + } else if !wellFormedDbNameRegex.MatchString(azureDepName) { + i.console.MessageUxItem(ctx, &ux.WarningMessage{ + Description: "Dependency name contains special characters. " + + "This might not be allowed by the Azure service.", + }) + confirm, err := i.console.Confirm(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf("Continue with name '%s'?", azureDepName), + }) + if err != nil { + return err + } + + if !confirm { + continue azureDepPrompt + } + } + + authType := scaffold.AuthType(0) + switch azureDep.(type) { + case appdetect.AzureDepServiceBus: + _authType, err := i.console.Prompt(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf("Input the authentication type you want for (%s), 1 for connection string, 2 for managed identity", azureDep.ResourceDisplay()), + Help: "Authentication type:\n\n" + + "Enter 1 if you want to use connection string to connect to the Service Bus.\n" + + "Enter 2 if you want to use user assigned managed identity to connect to the Service Bus.", + }) + if err != nil { + return err + } + + if _authType != "1" && _authType != "2" { + i.console.Message(ctx, "Invalid authentication type. Please enter 0 or 1.") + continue azureDepPrompt + } + if _authType == "1" { + authType = scaffold.AuthType_PASSWORD + } else { + authType = scaffold.AuthType_TOKEN_CREDENTIAL + } + } + + switch azureDep.(type) { + case appdetect.AzureDepServiceBus: + spec.AzureServiceBus = &scaffold.AzureDepServiceBus{ + Name: azureDepName, + Queues: azureDep.(appdetect.AzureDepServiceBus).Queues, + AuthUsingConnectionString: authType == scaffold.AuthType_PASSWORD, + AuthUsingManagedIdentity: authType == scaffold.AuthType_TOKEN_CREDENTIAL, + } + break azureDepPrompt + } + break azureDepPrompt + } + return nil +} diff --git a/cli/azd/internal/repository/infra_confirm_test.go b/cli/azd/internal/repository/infra_confirm_test.go index 1cd3a28664c..ca9d5b51112 100644 --- a/cli/azd/internal/repository/infra_confirm_test.go +++ b/cli/azd/internal/repository/infra_confirm_test.go @@ -162,11 +162,13 @@ func TestInitializer_infraSpecFromDetect(t *testing.T) { }, }, interactions: []string{ - "myappdb", // fill in db name + "myappdb", // fill in db name + "Use user assigned managed identity", // confirm db authentication }, want: scaffold.InfraSpec{ DbPostgres: &scaffold.DatabasePostgres{ - DatabaseName: "myappdb", + DatabaseName: "myappdb", + AuthUsingManagedIdentity: true, }, Services: []scaffold.ServiceSpec{ { @@ -180,7 +182,8 @@ func TestInitializer_infraSpecFromDetect(t *testing.T) { }, }, DbPostgres: &scaffold.DatabaseReference{ - DatabaseName: "myappdb", + DatabaseName: "myappdb", + AuthUsingManagedIdentity: true, }, }, { diff --git a/cli/azd/internal/repository/testdata/Dockerfile/Dockerfile1 b/cli/azd/internal/repository/testdata/Dockerfile/Dockerfile1 new file mode 100644 index 00000000000..0b10c650d8d --- /dev/null +++ b/cli/azd/internal/repository/testdata/Dockerfile/Dockerfile1 @@ -0,0 +1,20 @@ +FROM node:20-alpine AS build + +# make the 'app' folder the current working directory +WORKDIR /app + +COPY . . + +# install project dependencies +RUN npm ci +RUN npm run build + +FROM nginx:alpine + +WORKDIR /usr/share/nginx/html +COPY --from=build /app/dist . +COPY --from=build /app/nginx/nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["/bin/sh", "-c", "sed -i \"s|http://localhost:3100|${API_BASE_URL}|g\" -i ./**/*.js && nginx -g \"daemon off;\""] diff --git a/cli/azd/internal/repository/testdata/Dockerfile/Dockerfile2 b/cli/azd/internal/repository/testdata/Dockerfile/Dockerfile2 new file mode 100644 index 00000000000..c1925937d2d --- /dev/null +++ b/cli/azd/internal/repository/testdata/Dockerfile/Dockerfile2 @@ -0,0 +1,22 @@ +FROM mcr.microsoft.com/openjdk/jdk:17-mariner AS build + +WORKDIR /workspace/app +EXPOSE 3100 + +COPY mvnw . +COPY .mvn .mvn +COPY pom.xml . +COPY src src + +RUN chmod +x ./mvnw +RUN ./mvnw package -DskipTests +RUN mkdir -p target/dependency && (cd target/dependency; jar -xf ../*.jar) + +FROM mcr.microsoft.com/openjdk/jdk:17-mariner + +ARG DEPENDENCY=/workspace/app/target/dependency +COPY --from=build ${DEPENDENCY}/BOOT-INF/lib /app/lib +COPY --from=build ${DEPENDENCY}/META-INF /app/META-INF +COPY --from=build ${DEPENDENCY}/BOOT-INF/classes /app + +ENTRYPOINT ["java","-noverify", "-XX:MaxRAMPercentage=70", "-XX:+UseParallelGC", "-XX:ActiveProcessorCount=2", "-cp","app:app/lib/*","com.microsoft.azure.simpletodo.SimpleTodoApplication"] \ No newline at end of file diff --git a/cli/azd/internal/repository/testdata/Dockerfile/Dockerfile3 b/cli/azd/internal/repository/testdata/Dockerfile/Dockerfile3 new file mode 100644 index 00000000000..1ecad8a32f2 --- /dev/null +++ b/cli/azd/internal/repository/testdata/Dockerfile/Dockerfile3 @@ -0,0 +1,21 @@ +FROM mcr.microsoft.com/openjdk/jdk:17-mariner AS build + +WORKDIR /workspace/app + +COPY mvnw . +COPY .mvn .mvn +COPY pom.xml . +COPY src src + +RUN chmod +x ./mvnw +RUN ./mvnw package -DskipTests +RUN mkdir -p target/dependency && (cd target/dependency; jar -xf ../*.jar) + +FROM mcr.microsoft.com/openjdk/jdk:17-mariner + +ARG DEPENDENCY=/workspace/app/target/dependency +COPY --from=build ${DEPENDENCY}/BOOT-INF/lib /app/lib +COPY --from=build ${DEPENDENCY}/META-INF /app/META-INF +COPY --from=build ${DEPENDENCY}/BOOT-INF/classes /app + +ENTRYPOINT ["java","-noverify", "-XX:MaxRAMPercentage=70", "-XX:+UseParallelGC", "-XX:ActiveProcessorCount=2", "-cp","app:app/lib/*","com.microsoft.azure.simpletodo.SimpleTodoApplication"] \ No newline at end of file diff --git a/cli/azd/internal/repository/util.go b/cli/azd/internal/repository/util.go new file mode 100644 index 00000000000..3e5f563646a --- /dev/null +++ b/cli/azd/internal/repository/util.go @@ -0,0 +1,106 @@ +package repository + +import "strings" + +//cspell:disable + +// LabelName cleans up a string to be used as a RFC 1123 Label name. +// It does not enforce the 63 character limit. +// +// RFC 1123 Label name: +// - contain only lowercase alphanumeric characters or '-' +// - start with an alphanumeric character +// - end with an alphanumeric character +// +// Examples: +// - myproject, MYPROJECT -> myproject +// - myProject, myProjecT, MyProject, MyProjecT -> my-project +// - my.project, My.Project, my-project, My-Project -> my-project +func LabelName(name string) string { + hasSeparator, n := cleanAlphaNumeric(name) + if hasSeparator { + return labelNameFromSeparators(n) + } + + return labelNameFromCasing(name) +} + +//cspell:enable + +// cleanAlphaNumeric removes non-alphanumeric characters from the name. +// +// It also returns whether the name uses word separators. +func cleanAlphaNumeric(name string) (hasSeparator bool, cleaned string) { + sb := strings.Builder{} + hasSeparator = false + for _, c := range name { + if isAsciiAlphaNumeric(c) { + sb.WriteRune(c) + } else if isSeparator(c) { + hasSeparator = true + sb.WriteRune(c) + } + } + + return hasSeparator, sb.String() +} + +func isAsciiAlphaNumeric(r rune) bool { + return ('0' <= r && r <= '9') || ('A' <= r && r <= 'Z') || ('a' <= r && r <= 'z') +} + +func isSeparator(r rune) bool { + return r == '-' || r == '_' || r == '.' +} + +func lowerCase(r rune) rune { + if 'A' <= r && r <= 'Z' { + r += 'a' - 'A' + } + return r +} + +// Converts camel-cased or Pascal-cased names into lower-cased dash-separated names. +// Example: MyProject, myProject -> my-project +func labelNameFromCasing(name string) string { + result := strings.Builder{} + // previously seen upper-case character + prevUpperCase := -2 // -2 to avoid matching the first character + + for i, c := range name { + if 'A' <= c && c <= 'Z' { + if prevUpperCase == i-1 { // handle runs of upper-case word + prevUpperCase = i + result.WriteRune(lowerCase(c)) + continue + } + + if i > 0 && i != len(name)-1 { + result.WriteRune('-') + } + + prevUpperCase = i + } + + if isAsciiAlphaNumeric(c) { + result.WriteRune(lowerCase(c)) + } + } + + return result.String() +} + +// Converts all word-separated names into lower-cased dash-separated names. +// Examples: my.project, my_project, My-Project -> my-project +func labelNameFromSeparators(name string) string { + result := strings.Builder{} + for i, c := range name { + if isAsciiAlphaNumeric(c) { + result.WriteRune(lowerCase(c)) + } else if i > 0 && i != len(name)-1 && isSeparator(c) { + result.WriteRune('-') + } + } + + return result.String() +} diff --git a/cli/azd/internal/repository/util_test.go b/cli/azd/internal/repository/util_test.go new file mode 100644 index 00000000000..56a2c467756 --- /dev/null +++ b/cli/azd/internal/repository/util_test.go @@ -0,0 +1,67 @@ +package repository + +import ( + "testing" +) + +//cspell:disable + +func TestLabelName(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"Lowercase", "myproject", "myproject"}, + {"Uppercase", "MYPROJECT", "myproject"}, + {"MixedCase", "myProject", "my-project"}, + {"MixedCaseEnd", "myProjecT", "my-project"}, + {"TitleCase", "MyProject", "my-project"}, + {"TitleCaseEnd", "MyProjecT", "my-project"}, + {"WithDot", "my.project", "my-project"}, + {"WithDotTitleCase", "My.Project", "my-project"}, + {"WithHyphen", "my-project", "my-project"}, + {"WithHyphenTitleCase", "My-Project", "my-project"}, + {"StartWithNumber", "1myproject", "1myproject"}, + {"EndWithNumber", "myproject2", "myproject2"}, + {"MixedWithNumbers", "my2Project3", "my2-project3"}, + {"SpecialCharacters", "my_project!@#", "my-project"}, + {"EmptyString", "", ""}, + {"OnlySpecialCharacters", "@#$%^&*", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := LabelName(tt.input) + if result != tt.expected { + t.Errorf("LabelName(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestLabelNameEdgeCases(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"SingleCharacter", "A", "a"}, + {"TwoCharacters", "Ab", "ab"}, + {"StartEndHyphens", "-abc-", "abc"}, + {"LongString", + "ThisIsOneVeryLongStringThatExceedsTheSixtyThreeCharacterLimitForRFC1123LabelNames", + "this-is-one-very-long-string-that-exceeds-the-sixty-three-character-limit-for-rfc1123-label-names"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := LabelName(tt.input) + if result != tt.expected { + t.Errorf("LabelName(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +//cspell:enable diff --git a/cli/azd/internal/scaffold/scaffold.go b/cli/azd/internal/scaffold/scaffold.go index 4a10e2a1d37..b75c93b6db6 100644 --- a/cli/azd/internal/scaffold/scaffold.go +++ b/cli/azd/internal/scaffold/scaffold.go @@ -134,8 +134,8 @@ func ExecInfra( } func preExecExpand(spec *InfraSpec) { - // postgres requires specific password seeding parameters - if spec.DbPostgres != nil { + // postgres and mysql requires specific password seeding parameters + if spec.DbPostgres != nil || spec.DbMySql != nil { spec.Parameters = append(spec.Parameters, Parameter{ Name: "databasePassword", diff --git a/cli/azd/internal/scaffold/spec.go b/cli/azd/internal/scaffold/spec.go index b50afc4fd60..7d60cb22680 100644 --- a/cli/azd/internal/scaffold/spec.go +++ b/cli/azd/internal/scaffold/spec.go @@ -11,8 +11,13 @@ type InfraSpec struct { // Databases to create DbPostgres *DatabasePostgres + DbMySql *DatabaseMySql DbCosmosMongo *DatabaseCosmosMongo DbRedis *DatabaseRedis + + AzureServiceBus *AzureDepServiceBus + AzureEventHubs *AzureDepEventHubs + AzureStorageAccount *AzureDepStorageAccount } type Parameter struct { @@ -23,8 +28,17 @@ type Parameter struct { } type DatabasePostgres struct { - DatabaseUser string - DatabaseName string + DatabaseUser string + DatabaseName string + AuthUsingManagedIdentity bool + AuthUsingUsernamePassword bool +} + +type DatabaseMySql struct { + DatabaseUser string + DatabaseName string + AuthUsingManagedIdentity bool + AuthUsingUsernamePassword bool } type DatabaseCosmosMongo struct { @@ -34,6 +48,39 @@ type DatabaseCosmosMongo struct { type DatabaseRedis struct { } +type AzureDepServiceBus struct { + Name string + Queues []string + TopicsAndSubscriptions map[string][]string + AuthUsingConnectionString bool + AuthUsingManagedIdentity bool +} + +type AzureDepEventHubs struct { + Name string + EventHubNames []string + AuthUsingConnectionString bool + AuthUsingManagedIdentity bool +} + +type AzureDepStorageAccount struct { + Name string + ContainerNames []string + AuthUsingConnectionString bool + AuthUsingManagedIdentity bool +} + +// AuthType defines different authentication types. +type AuthType int32 + +const ( + AUTH_TYPE_UNSPECIFIED AuthType = 0 + // Username and password, or key based authentication, or connection string + AuthType_PASSWORD AuthType = 1 + // Microsoft EntraID token credential + AuthType_TOKEN_CREDENTIAL AuthType = 2 +) + type ServiceSpec struct { Name string Port int @@ -46,8 +93,13 @@ type ServiceSpec struct { // Connection to a database DbPostgres *DatabaseReference + DbMySql *DatabaseReference DbCosmosMongo *DatabaseReference DbRedis *DatabaseReference + + AzureServiceBus *AzureDepServiceBus + AzureEventHubs *AzureDepEventHubs + AzureStorageAccount *AzureDepStorageAccount } type Frontend struct { @@ -63,7 +115,9 @@ type ServiceReference struct { } type DatabaseReference struct { - DatabaseName string + DatabaseName string + AuthUsingManagedIdentity bool + AuthUsingUsernamePassword bool } func containerAppExistsParameter(serviceName string) Parameter { diff --git a/cli/azd/internal/tracing/fields/fields.go b/cli/azd/internal/tracing/fields/fields.go index 52562e181c6..c264acafe66 100644 --- a/cli/azd/internal/tracing/fields/fields.go +++ b/cli/azd/internal/tracing/fields/fields.go @@ -240,8 +240,9 @@ const ( const ( InitMethod = attribute.Key("init.method") - AppInitDetectedDatabase = attribute.Key("appinit.detected.databases") - AppInitDetectedServices = attribute.Key("appinit.detected.services") + AppInitDetectedDatabase = attribute.Key("appinit.detected.databases") + AppInitDetectedServices = attribute.Key("appinit.detected.services") + AppInitDetectedAzureDeps = attribute.Key("appinit.detected.azuredeps") AppInitConfirmedDatabases = attribute.Key("appinit.confirmed.databases") AppInitConfirmedServices = attribute.Key("appinit.confirmed.services") 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 new file mode 100644 index 00000000000..82d13f9a83d --- /dev/null +++ b/cli/azd/resources/scaffold/base/modules/set-event-hubs-namespace-connection-string.bicep @@ -0,0 +1,20 @@ +param eventHubsNamespaceName string +param connectionStringSecretName string +param keyVaultName string + +resource eventHubsNamespace 'Microsoft.EventHub/namespaces@2024-01-01' existing = { + name: eventHubsNamespaceName +} + +resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = { + name: keyVaultName +} + +resource connectionStringSecret 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { + name: connectionStringSecretName + parent: keyVault + properties: { + value: listKeys(concat(resourceId('Microsoft.EventHub/namespaces', eventHubsNamespaceName), '/AuthorizationRules/RootManageSharedAccessKey'), '2024-01-01').primaryConnectionString + } +} + diff --git a/cli/azd/resources/scaffold/templates/azure-service-bus.bicept b/cli/azd/resources/scaffold/templates/azure-service-bus.bicept new file mode 100644 index 00000000000..1504934841f --- /dev/null +++ b/cli/azd/resources/scaffold/templates/azure-service-bus.bicept @@ -0,0 +1,60 @@ +{{define "azure-service-bus.bicep" -}} +param serviceBusNamespaceName string +{{- if .AuthUsingConnectionString }} +param keyVaultName string +{{end}} +param location string +param tags object = {} + +resource serviceBusNamespace 'Microsoft.ServiceBus/namespaces@2022-10-01-preview' = { + name: serviceBusNamespaceName + location: location + tags: tags + sku: { + name: 'Standard' + tier: 'Standard' + capacity: 1 + } +} + +{{- range $index, $element := .Queues }} +resource serviceBusQueue_{{ $index }} 'Microsoft.ServiceBus/namespaces/queues@2022-01-01-preview' = { + parent: serviceBusNamespace + name: '{{ $element }}' + properties: { + lockDuration: 'PT5M' + maxSizeInMegabytes: 1024 + requiresDuplicateDetection: false + requiresSession: false + defaultMessageTimeToLive: 'P10675199DT2H48M5.4775807S' + deadLetteringOnMessageExpiration: false + duplicateDetectionHistoryTimeWindow: 'PT10M' + maxDeliveryCount: 10 + autoDeleteOnIdle: 'P10675199DT2H48M5.4775807S' + enablePartitioning: false + enableExpress: false + } +} +{{end}} + +{{- if .AuthUsingConnectionString }} + +resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = { + name: keyVaultName +} + +resource serviceBusConnectionString 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { + parent: keyVault + name: 'serviceBusConnectionString' + properties: { + value: listKeys('${serviceBusNamespace.id}/AuthorizationRules/RootManageSharedAccessKey', serviceBusNamespace.apiVersion).primaryConnectionString + } +} +{{end}} + +output serviceBusNamespaceId string = serviceBusNamespace.id +output serviceBusNamespaceApiVersion string = serviceBusNamespace.apiVersion +{{- if .AuthUsingConnectionString }} +output serviceBusConnectionStringKey string = 'serviceBusConnectionString' +{{end}} +{{ end}} \ No newline at end of file diff --git a/cli/azd/resources/scaffold/templates/db-mysql.bicept b/cli/azd/resources/scaffold/templates/db-mysql.bicept new file mode 100644 index 00000000000..dcd9dad0618 --- /dev/null +++ b/cli/azd/resources/scaffold/templates/db-mysql.bicept @@ -0,0 +1,88 @@ +{{define "db-mysql.bicep" -}} +param serverName string +param location string = resourceGroup().location +param tags object = {} + +param keyVaultName string +param identityName string + +param databaseUser string = 'mysqladmin' +param databaseName string = '{{.DatabaseName}}' +@secure() +param databasePassword string + +param allowAllIPsFirewall bool = false + +resource userAssignedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: identityName + location: location +} + +resource mysqlServer 'Microsoft.DBforMySQL/flexibleServers@2023-06-30' = { + location: location + tags: tags + name: serverName + sku: { + name: 'Standard_B1ms' + tier: 'Burstable' + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${userAssignedIdentity.id}': {} + } + } + properties: { + version: '8.0.21' + administratorLogin: databaseUser + administratorLoginPassword: databasePassword + storage: { + storageSizeGB: 128 + } + backup: { + backupRetentionDays: 7 + geoRedundantBackup: 'Disabled' + } + highAvailability: { + mode: 'Disabled' + } + } + + resource firewall_all 'firewallRules' = if (allowAllIPsFirewall) { + name: 'allow-all-IPs' + properties: { + startIpAddress: '0.0.0.0' + endIpAddress: '255.255.255.255' + } + } +} + +resource database 'Microsoft.DBforMySQL/flexibleServers/databases@2023-06-30' = { + parent: mysqlServer + name: databaseName + properties: { + // Azure defaults to UTF-8 encoding, override if required. + // charset: 'string' + // collation: 'string' + } +} + +resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = { + name: keyVaultName +} + +resource dbPasswordKey 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { + parent: keyVault + name: 'databasePassword' + properties: { + value: databasePassword + } +} + +output databaseId string = database.id +output identityName string = userAssignedIdentity.name +output databaseHost string = mysqlServer.properties.fullyQualifiedDomainName +output databaseName string = databaseName +output databaseUser string = databaseUser +output databaseConnectionKey string = 'databasePassword' +{{ end}} diff --git a/cli/azd/resources/scaffold/templates/db-postgres.bicept b/cli/azd/resources/scaffold/templates/db-postgres.bicept new file mode 100644 index 00000000000..b6ebb5a87b8 --- /dev/null +++ b/cli/azd/resources/scaffold/templates/db-postgres.bicept @@ -0,0 +1,81 @@ +{{define "db-postgres.bicep" -}} +param serverName string +param location string = resourceGroup().location +param tags object = {} + +param keyVaultName string + +param databaseUser string = 'psqladmin' +param databaseName string = '{{.DatabaseName}}' +@secure() +param databasePassword string + +param allowAllIPsFirewall bool = false + +resource postgreServer'Microsoft.DBforPostgreSQL/flexibleServers@2022-01-20-preview' = { + location: location + tags: tags + name: serverName + sku: { + name: 'Standard_B1ms' + tier: 'Burstable' + } + properties: { + version: '13' + administratorLogin: databaseUser + administratorLoginPassword: databasePassword + storage: { + storageSizeGB: 128 + } + backup: { + backupRetentionDays: 7 + geoRedundantBackup: 'Disabled' + } + highAvailability: { + mode: 'Disabled' + } + maintenanceWindow: { + customWindow: 'Disabled' + dayOfWeek: 0 + startHour: 0 + startMinute: 0 + } + } + + resource firewall_all 'firewallRules' = if (allowAllIPsFirewall) { + name: 'allow-all-IPs' + properties: { + startIpAddress: '0.0.0.0' + endIpAddress: '255.255.255.255' + } + } +} + +resource database 'Microsoft.DBforPostgreSQL/flexibleServers/databases@2022-01-20-preview' = { + parent: postgreServer + name: databaseName + properties: { + // Azure defaults to UTF-8 encoding, override if required. + // charset: 'string' + // collation: 'string' + } +} + +resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = { + name: keyVaultName +} + +resource dbPasswordKey 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { + parent: keyVault + name: 'databasePassword' + properties: { + value: databasePassword + } +} + +output databaseId string = database.id +output databaseHost string = postgreServer.properties.fullyQualifiedDomainName +output databaseName string = databaseName +output databaseUser string = databaseUser +output databaseConnectionKey string = 'databasePassword' +{{ end}} diff --git a/cli/azd/resources/scaffold/templates/host-containerapp.bicept b/cli/azd/resources/scaffold/templates/host-containerapp.bicept new file mode 100644 index 00000000000..9991fdea940 --- /dev/null +++ b/cli/azd/resources/scaffold/templates/host-containerapp.bicept @@ -0,0 +1,415 @@ +{{define "host-containerapp.bicep" -}} +param name string +param location string = resourceGroup().location +param tags object = {} + +param identityName string +param containerRegistryName string +param containerAppsEnvironmentName string +param applicationInsightsName string +{{- if .DbCosmosMongo}} +@secure() +param cosmosDbConnectionString string +{{- end}} +{{- if (and .DbPostgres .DbPostgres.AuthUsingManagedIdentity)}} +param postgresDatabaseId string +{{- end}} +{{- if (and .DbPostgres .DbPostgres.AuthUsingUsernamePassword)}} +param postgresDatabaseHost string +param postgresDatabaseName string +param postgresDatabaseUser string +@secure() +param postgresDatabasePassword string +{{- end}} +{{- if (and .DbMySql .DbMySql.AuthUsingManagedIdentity)}} +param mysqlDatabaseId string +param mysqlIdentityName string +{{- end}} +{{- if (and .DbMySql .DbMySql.AuthUsingUsernamePassword)}} +param mysqlDatabaseHost string +param mysqlDatabaseName string +param mysqlDatabaseUser string +@secure() +param mysqlDatabasePassword string +{{- end}} +{{- if (and .AzureServiceBus .AzureServiceBus.AuthUsingConnectionString)}} +@secure() +param azureServiceBusConnectionString string +{{- end}} +{{- if (and .AzureServiceBus .AzureServiceBus.AuthUsingManagedIdentity)}} +@secure() +param azureServiceBusNamespace string +{{- end}} +{{- if .DbRedis}} +param redisName string +{{- end}} +{{- if (and .Frontend .Frontend.Backends)}} +param apiUrls array +{{- end}} +{{- if (and .Backend .Backend.Frontends)}} +param allowedOrigins array +{{- end}} +param exists bool +@secure() +param appDefinition object +param currentTime string = utcNow() + +var appSettingsArray = filter(array(appDefinition.settings), i => i.name != '') +var secrets = map(filter(appSettingsArray, i => i.?secret != null), i => { + name: i.name + value: i.value + secretRef: i.?secretRef ?? take(replace(replace(toLower(i.name), '_', '-'), '.', '-'), 32) +}) +var env = map(filter(appSettingsArray, i => i.?secret == null), i => { + name: i.name + value: i.value +}) + +resource identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: identityName + location: location +} + +resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-01-01-preview' existing = { + name: containerRegistryName +} + +resource containerAppsEnvironment 'Microsoft.App/managedEnvironments@2023-05-01' existing = { + name: containerAppsEnvironmentName +} + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = { + name: applicationInsightsName +} + +resource acrPullRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: containerRegistry + name: guid(subscription().id, resourceGroup().id, identity.id, 'acrPullRole') + properties: { + roleDefinitionId: subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + principalType: 'ServicePrincipal' + principalId: identity.properties.principalId + } +} + +module fetchLatestImage '../modules/fetch-container-image.bicep' = { + name: '${name}-fetch-image' + params: { + exists: exists + name: name + } +} +{{- if .DbRedis}} + +resource redis 'Microsoft.App/containerApps@2023-05-02-preview' = { + name: redisName + location: location + properties: { + environmentId: containerAppsEnvironment.id + configuration: { + service: { + type: 'redis' + } + } + template: { + containers: [ + { + image: 'redis' + name: 'redis' + } + ] + } + } +} +{{- end}} + +resource app 'Microsoft.App/containerApps@2023-05-02-preview' = { + name: name + location: location + tags: union(tags, {'azd-service-name': '{{.Name}}' }) + dependsOn: [ acrPullRole ] + identity: { + type: 'UserAssigned' + userAssignedIdentities: { '${identity.id}': {} } + } + properties: { + managedEnvironmentId: containerAppsEnvironment.id + configuration: { + {{- if ne .Port 0}} + ingress: { + external: true + targetPort: {{.Port}} + transport: 'auto' + {{- if (and .Backend .Backend.Frontends)}} + corsPolicy: { + allowedOrigins: union(allowedOrigins, [ + // define additional allowed origins here + ]) + allowedMethods: ['GET', 'PUT', 'POST', 'DELETE'] + } + {{- end}} + } + {{- end}} + registries: [ + { + server: '${containerRegistryName}.azurecr.io' + identity: identity.id + } + ] + secrets: union([ + {{- if .DbCosmosMongo}} + { + name: 'azure-cosmos-connection-string' + value: cosmosDbConnectionString + } + {{- end}} + {{- if (and .DbPostgres .DbPostgres.AuthUsingUsernamePassword)}} + { + name: 'postgres-db-pass' + value: postgresDatabasePassword + } + {{- end}} + {{- if (and .DbMySql .DbMySql.AuthUsingUsernamePassword)}} + { + name: 'mysql-db-pass' + value: mysqlDatabasePassword + } + {{- end}} + {{- if (and .AzureServiceBus .AzureServiceBus.AuthUsingConnectionString)}} + { + name: 'spring-cloud-azure-servicebus-connection-string' + value: azureServiceBusConnectionString + } + {{- end}} + ], + map(secrets, secret => { + name: secret.secretRef + value: secret.value + })) + } + template: { + containers: [ + { + image: fetchLatestImage.outputs.?containers[?0].?image ?? 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' + name: 'main' + env: union([ + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: applicationInsights.properties.ConnectionString + } + {{- if .DbCosmosMongo}} + { + name: 'AZURE_COSMOS_MONGODB_CONNECTION_STRING' + secretRef: 'azure-cosmos-connection-string' + } + {{- end}} + {{- if (and .DbPostgres .DbPostgres.AuthUsingUsernamePassword)}} + { + name: 'POSTGRES_HOST' + value: postgresDatabaseHost + } + { + name: 'POSTGRES_PORT' + value: '5432' + } + { + name: 'POSTGRES_DATABASE' + value: postgresDatabaseName + } + { + name: 'POSTGRES_USERNAME' + value: postgresDatabaseUser + } + { + name: 'POSTGRES_PASSWORD' + secretRef: 'postgres-db-pass' + } + {{- end}} + {{- if (and .DbMySql .DbMySql.AuthUsingUsernamePassword)}} + { + name: 'MYSQL_HOST' + value: mysqlDatabaseHost + } + { + name: 'MYSQL_PORT' + value: '3306' + } + { + name: 'MYSQL_DATABASE' + value: mysqlDatabaseName + } + { + name: 'MYSQL_USERNAME' + value: mysqlDatabaseUser + } + { + name: 'MYSQL_PASSWORD' + secretRef: 'mysql-db-pass' + } + {{- end}} + {{- if (and .AzureServiceBus .AzureServiceBus.AuthUsingConnectionString)}} + { + name: 'SPRING_CLOUD_AZURE_SERVICEBUS_CONNECTION_STRING' + secretRef: 'spring-cloud-azure-servicebus-connection-string' + } + {{- end}} + {{- if (and .AzureServiceBus .AzureServiceBus.AuthUsingManagedIdentity)}} + { + name: 'SPRING_CLOUD_AZURE_SERVICEBUS_CONNECTION_STRING' + value: '' + } + { + name: 'SPRING_CLOUD_AZURE_SERVICEBUS_NAMESPACE' + value: azureServiceBusNamespace + } + { + name: 'SPRING_CLOUD_AZURE_SERVICEBUS_CREDENTIAL_MANAGEDIDENTITYENABLED' + value: 'true' + } + { + name: 'SPRING_CLOUD_AZURE_SERVICEBUS_CREDENTIAL_CLIENTID' + value: identity.properties.clientId + } + {{- end}} + {{- if .Frontend}} + {{- range $i, $e := .Frontend.Backends}} + { + name: '{{upper .Name}}_BASE_URL' + value: apiUrls[{{$i}}] + } + {{- end}} + {{- end}} + {{- if ne .Port 0}} + { + name: 'PORT' + value: '{{ .Port }}' + } + { + name: 'SERVER_PORT' + value: '{{ .Port }}' + } + {{- end}} + ], + env, + map(secrets, secret => { + name: secret.name + secretRef: secret.secretRef + })) + resources: { + cpu: json('1.0') + memory: '2.0Gi' + } + } + ] + {{- if .DbRedis}} + serviceBinds: [ + { + serviceId: redis.id + name: 'redis' + } + ] + {{- end}} + scale: { + minReplicas: 1 + maxReplicas: 10 + } + } + } +} +{{- if (or (and .DbMySql .DbMySql.AuthUsingManagedIdentity) (and .DbPostgres .DbPostgres.AuthUsingManagedIdentity))}} + +resource linkerCreatorIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: '${name}-linker-creator-identity' + location: location +} + +resource linkerCreatorRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: resourceGroup() + name: guid(subscription().id, resourceGroup().id, linkerCreatorIdentity.id, 'linkerCreatorRole') + properties: { + roleDefinitionId: subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', 'b24988ac-6180-42a0-ab88-20f7382dd24c') + principalType: 'ServicePrincipal' + principalId: linkerCreatorIdentity.properties.principalId + } +} +{{- end}} +{{- if (and .DbPostgres .DbPostgres.AuthUsingManagedIdentity)}} + +resource appLinkToPostgres 'Microsoft.Resources/deploymentScripts@2023-08-01' = { + dependsOn: [ linkerCreatorRole ] + name: '${name}-link-to-postgres' + location: location + kind: 'AzureCLI' + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${linkerCreatorIdentity.id}': {} + } + } + properties: { + azCliVersion: '2.63.0' + timeout: 'PT10M' + forceUpdateTag: currentTime + scriptContent: 'apk update; apk add g++; apk add unixodbc-dev; az extension add --name containerapp; az extension add --name serviceconnector-passwordless --upgrade; az containerapp connection create postgres-flexible --connection appLinkToPostgres --source-id ${app.id} --target-id ${postgresDatabaseId} --client-type springBoot --user-identity client-id=${identity.properties.clientId} subs-id=${subscription().subscriptionId} user-object-id=${linkerCreatorIdentity.properties.principalId} -c main --yes;' + cleanupPreference: 'OnSuccess' + retentionInterval: 'P1D' + } +} +{{- end}} +{{- if (and .DbMySql .DbMySql.AuthUsingManagedIdentity)}} + +resource appLinkToMySql 'Microsoft.Resources/deploymentScripts@2023-08-01' = { + dependsOn: [ linkerCreatorRole ] + name: '${name}-link-to-mysql' + location: location + kind: 'AzureCLI' + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${linkerCreatorIdentity.id}': {} + } + } + properties: { + azCliVersion: '2.63.0' + timeout: 'PT10M' + forceUpdateTag: currentTime + scriptContent: 'apk update; apk add g++; apk add unixodbc-dev; az extension add --name containerapp; az extension add --name serviceconnector-passwordless --upgrade; az containerapp connection create mysql-flexible --connection appLinkToMySql --source-id ${app.id} --target-id ${mysqlDatabaseId} --client-type springBoot --user-identity client-id=${identity.properties.clientId} subs-id=${subscription().subscriptionId} user-object-id=${linkerCreatorIdentity.properties.principalId} mysql-identity-id=${mysqlIdentityName} -c main --yes;' + cleanupPreference: 'OnSuccess' + retentionInterval: 'P1D' + } +} +{{- end}} +{{- if (and .AzureServiceBus .AzureServiceBus.AuthUsingManagedIdentity) }} + +resource servicebus 'Microsoft.ServiceBus/namespaces@2022-01-01-preview' existing = { + name: azureServiceBusNamespace +} + +resource serviceBusReceiverRoleAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = { + name: guid(servicebus.id, '4f6d3b9b-027b-4f4c-9142-0e5a2a2247e0', identity.name) + scope: servicebus + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4f6d3b9b-027b-4f4c-9142-0e5a2a2247e0') // Azure Service Bus Data Receiver + principalId: identity.properties.principalId + principalType: 'ServicePrincipal' + } +} + +resource serviceBusSenderRoleAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = { + name: guid(servicebus.id, '69a216fc-b8fb-44d8-bc22-1f3c2cd27a39', identity.name) + scope: servicebus + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '69a216fc-b8fb-44d8-bc22-1f3c2cd27a39') // Azure Service Bus Data Sender + principalId: identity.properties.principalId + principalType: 'ServicePrincipal' + } +} +{{end}} + +output defaultDomain string = containerAppsEnvironment.properties.defaultDomain +output name string = app.name +output uri string = 'https://${app.properties.configuration.ingress.fqdn}' +output id string = app.id +{{ end}} diff --git a/cli/azd/resources/scaffold/templates/main.bicept b/cli/azd/resources/scaffold/templates/main.bicept index 6a574ab55e1..527b5d08c48 100644 --- a/cli/azd/resources/scaffold/templates/main.bicept +++ b/cli/azd/resources/scaffold/templates/main.bicept @@ -59,4 +59,7 @@ output AZURE_CACHE_REDIS_ID string = resources.outputs.AZURE_CACHE_REDIS_ID {{- if .DbPostgres}} output AZURE_POSTGRES_FLEXIBLE_SERVER_ID string = resources.outputs.AZURE_POSTGRES_FLEXIBLE_SERVER_ID {{- end}} +{{- if .AzureEventHubs }} +output AZURE_EVENT_HUBS_ID string = resources.outputs.AZURE_EVENT_HUBS_ID +{{- end}} {{ end}} diff --git a/cli/azd/resources/scaffold/templates/next-steps.mdt b/cli/azd/resources/scaffold/templates/next-steps.mdt index 7fe72dec118..932f0c80f20 100644 --- a/cli/azd/resources/scaffold/templates/next-steps.mdt +++ b/cli/azd/resources/scaffold/templates/next-steps.mdt @@ -21,7 +21,7 @@ To troubleshoot any issues, see [troubleshooting](#troubleshooting). Configure environment variables for running services by updating `settings` in [main.parameters.json](./infra/main.parameters.json). {{- range .Services}} -{{- if or .DbPostgres .DbCosmosMongo .DbRedis }} +{{- if or .DbPostgres .DbMySql .DbCosmosMongo .DbRedis }} #### Database connections for `{{.Name}}` @@ -32,6 +32,9 @@ They allow connection to the database instances, and can be modified or adapted - `POSTGRES_URL` - The URL of the Azure Postgres Flexible Server database instance. Individual components are also available as: `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DATABASE`, `POSTGRES_USERNAME`, `POSTGRES_PASSWORD`. {{- end}} +{{- if .DbMySql }} +- `MYSQL_*` environment variables are configured in [{{.Name}}.bicep](./infra/app/{{.Name}}.bicep) to connect to the Mysql database. Modify these variables to match your application's needs. +{{- end}} {{- if .DbCosmosMongo }} - `MONGODB_URL` - The URL of the Azure Cosmos DB (MongoDB) instance. {{- end}} @@ -71,6 +74,9 @@ This includes: {{- if .DbPostgres}} - Azure Postgres Flexible Server to host the '{{.DbPostgres.DatabaseName}}' database. {{- end}} +{{- if .DbMySql}} +- [app/db-mysql.bicep](./infra/app/db-mysql.bicep) - Azure MySQL Flexible Server to host the '{{.DbMySql.DatabaseName}}' database. +{{- end}} {{- if .DbCosmosMongo}} - Azure Cosmos DB (MongoDB) to host the '{{.DbCosmosMongo.DatabaseName}}' database. {{- end}} diff --git a/cli/azd/resources/scaffold/templates/resources.bicept b/cli/azd/resources/scaffold/templates/resources.bicept index 7d39c83c534..b1b655333ee 100644 --- a/cli/azd/resources/scaffold/templates/resources.bicept +++ b/cli/azd/resources/scaffold/templates/resources.bicept @@ -126,7 +126,82 @@ module postgreServer 'br/public:avm/res/db-for-postgre-sql/flexible-server:0.1.4 } } {{- end}} +{{- if .AzureEventHubs }} +module eventHubNamespace 'br/public:avm/res/event-hub/namespace:0.7.1' = { + name: 'eventHubNamespace' + params: { + name: '${abbrs.eventHubNamespaces}${resourceToken}' + location: location + roleAssignments: [ + {{- if (and .AzureEventHubs .AzureEventHubs.AuthUsingManagedIdentity) }} + {{- range .Services}} + { + principalId: {{bicepName .Name}}Identity.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'f526a384-b230-433a-b45c-95f59c4a2dec') + } + {{- end}} + {{- end}} + ] + {{- if (and .AzureEventHubs .AzureEventHubs.AuthUsingConnectionString) }} + disableLocalAuth: false + {{- end}} + eventhubs: [ + {{- range $eventHubName := .AzureEventHubs.EventHubNames}} + { + name: '{{ $eventHubName }}' + } + {{- end}} + ] + } +} +{{- if (and .AzureEventHubs .AzureEventHubs.AuthUsingConnectionString) }} +module eventHubsConnectionString './modules/set-event-hubs-namespace-connection-string.bicep' = { + name: 'eventHubsConnectionString' + params: { + eventHubsNamespaceName: eventHubNamespace.outputs.name + connectionStringSecretName: 'EVENT-HUBS-CONNECTION-STRING' + keyVaultName: keyVault.outputs.name + } +} +{{end}} +{{end}} +{{- if .AzureStorageAccount }} +var storageAccountName = '${abbrs.storageStorageAccounts}${resourceToken}' +module storageAccount 'br/public:avm/res/storage/storage-account:0.14.3' = { + name: 'storageAccount' + params: { + name: storageAccountName + publicNetworkAccess: 'Enabled' + blobServices: { + containers: [ + {{- range $index, $element := .AzureStorageAccount.ContainerNames}} + { + name: '{{ $element }}' + } + {{- end}} + ] + } + location: location + roleAssignments: [ + {{- if (and .AzureStorageAccount .AzureStorageAccount.AuthUsingManagedIdentity) }} + {{- range .Services}} + { + principalId: {{bicepName .Name}}Identity.outputs.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b') + } + {{- end}} + {{- end}} + ] + networkAcls: { + defaultAction: 'Allow' + } + tags: tags + } +} +{{end}} {{- range .Services}} module {{bicepName .Name}}Identity 'br/public:avm/res/managed-identity/user-assigned-identity:0.2.1' = { @@ -205,6 +280,13 @@ module {{bicepName .Name}} 'br/public:avm/res/app/container-app:0.8.0' = { keyVaultUrl: '${keyVault.outputs.uri}secrets/REDIS-URL' } {{- end}} + {{- if (and .AzureEventHubs .AzureEventHubs.AuthUsingConnectionString) }} + { + name: 'event-hubs-connection-string' + identity:{{bicepName .Name}}Identity.outputs.resourceId + keyVaultUrl: '${keyVault.outputs.uri}secrets/EVENT-HUBS-CONNECTION-STRING' + } + {{- end}} ], map({{bicepName .Name}}Secrets, secret => { name: secret.secretRef @@ -282,6 +364,56 @@ module {{bicepName .Name}} 'br/public:avm/res/app/container-app:0.8.0' = { secretRef: 'redis-pass' } {{- end}} + {{- if .AzureEventHubs }} + { + name: 'SPRING_CLOUD_AZURE_EVENTHUBS_NAMESPACE' + value: eventHubNamespace.outputs.name + } + {{- end}} + {{- if (and .AzureEventHubs .AzureEventHubs.AuthUsingManagedIdentity) }} + { + name: 'SPRING_CLOUD_AZURE_EVENTHUBS_CONNECTION_STRING' + value: '' + } + { + name: 'SPRING_CLOUD_AZURE_EVENTHUBS_CREDENTIAL_MANAGEDIDENTITYENABLED' + value: 'true' + } + { + name: 'SPRING_CLOUD_AZURE_EVENTHUBS_CREDENTIAL_CLIENTID' + value: {{bicepName .Name}}Identity.outputs.clientId + } + {{- end}} + {{- if (and .AzureEventHubs .AzureEventHubs.AuthUsingConnectionString) }} + { + name: 'SPRING_CLOUD_AZURE_EVENTHUBS_CONNECTION_STRING' + secretRef: 'event-hubs-connection-string' + } + { + name: 'SPRING_CLOUD_AZURE_EVENTHUBS_CREDENTIAL_MANAGEDIDENTITYENABLED' + value: 'false' + } + { + name: 'SPRING_CLOUD_AZURE_EVENTHUBS_CREDENTIAL_CLIENTID' + value: '' + } + {{- end}} + {{- if .AzureStorageAccount }} + { + name: 'SPRING_CLOUD_AZURE_EVENTHUBS_PROCESSOR_CHECKPOINTSTORE_ACCOUNTNAME' + value: storageAccountName + } + {{- end}} + {{- if (and .AzureStorageAccount .AzureStorageAccount.AuthUsingManagedIdentity) }} + { + name: 'SPRING_CLOUD_AZURE_STORAGE_CREDENTIAL_MANAGEDIDENTITYENABLED' + value: 'true' + } + { + name: 'SPRING_CLOUD_AZURE_STORAGE_CREDENTIAL_CLIENTID' + value: {{bicepName .Name}}Identity.outputs.clientId + } + {{- end}} {{- if .Frontend}} {{- range $i, $e := .Frontend.Backends}} { @@ -392,4 +524,7 @@ output AZURE_CACHE_REDIS_ID string = redis.outputs.resourceId {{- if .DbPostgres}} output AZURE_POSTGRES_FLEXIBLE_SERVER_ID string = postgreServer.outputs.resourceId {{- end}} +{{- if .AzureEventHubs }} +output AZURE_EVENT_HUBS_ID string = eventHubNamespace.outputs.resourceId +{{- end}} {{ end}} diff --git a/cli/azd/test/functional/init_test.go b/cli/azd/test/functional/init_test.go index db81819476a..48d7a46d619 100644 --- a/cli/azd/test/functional/init_test.go +++ b/cli/azd/test/functional/init_test.go @@ -199,6 +199,7 @@ func Test_CLI_Init_From_App(t *testing.T) { "Use code in the current directory\n"+ "Confirm and continue initializing my app\n"+ "appdb\n"+ + "Use user assigned managed identity\n"+ "TESTENV\n", "init", ) diff --git a/ext/vscode/package.json b/ext/vscode/package.json index f9f06a3f6f2..83261a28432 100644 --- a/ext/vscode/package.json +++ b/ext/vscode/package.json @@ -185,11 +185,16 @@ "explorer/context": [ { "submenu": "azure-dev.explorer.submenu", - "when": "resourceFilename =~ /azure.yaml/i", + "when": "resourceFilename =~ /(azure.yaml|pom.xml)/i", "group": "azure-dev" } ], "azure-dev.explorer.submenu": [ + { + "when": "resourceFilename =~ /pom.xml/i", + "command": "azure-dev.commands.cli.init", + "group": "10provision@10" + }, { "when": "resourceFilename =~ /azure.yaml/i", "command": "azure-dev.commands.cli.provision",