diff --git a/cli/azd/cmd/orchestrate.go b/cli/azd/cmd/orchestrate.go new file mode 100644 index 00000000000..d7413f93e24 --- /dev/null +++ b/cli/azd/cmd/orchestrate.go @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "github.com/azure/azure-dev/cli/azd/cmd/actions" + "github.com/azure/azure-dev/cli/azd/internal" + "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/spf13/cobra" + "os" + "path/filepath" +) + +func newOrchestrateFlags(cmd *cobra.Command, global *internal.GlobalCommandOptions) *orchestrateFlags { + flags := &orchestrateFlags{} + return flags +} + +func newOrchestrateCmd() *cobra.Command { + return &cobra.Command{ + Use: "orchestrate", + Short: "Orchestrate an existing application. (Beta)", + } +} + +type orchestrateFlags struct { + global *internal.GlobalCommandOptions +} + +type orchestrateAction struct { +} + +func (action orchestrateAction) Run(ctx context.Context) (*actions.ActionResult, error) { + azureYamlFile, err := os.Create("azure.yaml") + if err != nil { + return nil, fmt.Errorf("creating azure.yaml: %w", err) + } + defer azureYamlFile.Close() + + files, err := findPomFiles(".") + if err != nil { + fmt.Println("Error:", err) + return nil, fmt.Errorf("find pom files: %w", err) + } + + for _, file := range files { + if _, err := azureYamlFile.WriteString(file + "\n"); err != nil { + return nil, fmt.Errorf("writing azure.yaml: %w", err) + } + } + + if err := azureYamlFile.Sync(); err != nil { + return nil, fmt.Errorf("saving azure.yaml: %w", err) + } + return nil, nil +} + +func newOrchestrateAction() actions.Action { + return &orchestrateAction{} +} + +func getCmdOrchestrateHelpDescription(*cobra.Command) string { + return generateCmdHelpDescription("Orchestrate an existing application in your current directory.", + []string{ + formatHelpNote( + fmt.Sprintf("Running %s without flags specified will prompt "+ + "you to orchestrate using your existing code.", + output.WithHighLightFormat("orchestrate"), + )), + }) +} + +func getCmdOrchestrateHelpFooter(*cobra.Command) string { + return generateCmdHelpSamplesBlock(map[string]string{ + "Orchestrate a existing project.": fmt.Sprintf("%s", + output.WithHighLightFormat("azd orchestrate"), + ), + }) +} + +func findPomFiles(root string) ([]string, error) { + var files []string + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() && filepath.Base(path) == "pom.xml" { + files = append(files, path) + } + return nil + }) + return files, err +} diff --git a/cli/azd/cmd/root.go b/cli/azd/cmd/root.go index 7b0c9f0b501..01ae9295dac 100644 --- a/cli/azd/cmd/root.go +++ b/cli/azd/cmd/root.go @@ -178,6 +178,19 @@ func NewRootCmd( ActionResolver: newLogoutAction, }) + root.Add("orchestrate", &actions.ActionDescriptorOptions{ + Command: newOrchestrateCmd(), + FlagsResolver: newOrchestrateFlags, + ActionResolver: newOrchestrateAction, + HelpOptions: actions.ActionHelpOptions{ + Description: getCmdOrchestrateHelpDescription, + Footer: getCmdOrchestrateHelpFooter, + }, + GroupingOptions: actions.CommandGroupOptions{ + RootLevelHelp: actions.CmdGroupConfig, + }, + }) + 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 b903f92dad5..88bc4286f45 100644 --- a/cli/azd/internal/appdetect/appdetect.go +++ b/cli/azd/internal/appdetect/appdetect.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" + "github.com/azure/azure-dev/cli/azd/internal/appdetect/javaanalyze" "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/tools/dotnet" "github.com/bmatcuk/doublestar/v4" @@ -131,6 +132,20 @@ 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 Project struct { // The language associated with the project. Language Language @@ -141,6 +156,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 @@ -243,6 +261,9 @@ func detectUnder(ctx context.Context, root string, config detectConfig) ([]Proje return nil, fmt.Errorf("scanning directories: %w", err) } + // call the java analyzer + projects = analyze(projects) + return projects, nil } @@ -306,3 +327,50 @@ func walkDirectories(path string, fn walkDirFunc) error { return nil } + +func analyze(projects []Project) []Project { + result := []Project{} + for _, project := range projects { + if project.Language == Java { + fmt.Printf("Java project [%s] found", project.Path) + _javaProjects := javaanalyze.Analyze(project.Path) + + if len(_javaProjects) == 1 { + enrichFromJavaProject(_javaProjects[0], &project) + result = append(result, project) + } else { + for _, _project := range _javaProjects { + copiedProject := project + enrichFromJavaProject(_project, &copiedProject) + result = append(result, copiedProject) + } + } + } else { + result = append(result, project) + } + } + return result +} + +func enrichFromJavaProject(azureYaml javaanalyze.AzureYaml, project *Project) { + // if there is only one project, we can safely assume that it is the main project + for _, resource := range azureYaml.Resources { + if resource.GetType() == "Azure Storage" { + // project.DatabaseDeps = append(project.DatabaseDeps, Db) + } else if resource.GetType() == "MySQL" { + project.DatabaseDeps = append(project.DatabaseDeps, DbMySql) + } else if resource.GetType() == "PostgreSQL" { + project.DatabaseDeps = append(project.DatabaseDeps, DbPostgres) + } else if resource.GetType() == "MongoDB" { + project.DatabaseDeps = append(project.DatabaseDeps, DbMongo) + } else if resource.GetType() == "SQL Server" { + project.DatabaseDeps = append(project.DatabaseDeps, DbSqlServer) + } else if resource.GetType() == "Redis" { + project.DatabaseDeps = append(project.DatabaseDeps, DbRedis) + } else if resource.GetType() == "Azure Service Bus" { + project.AzureDeps = append(project.AzureDeps, AzureDepServiceBus{ + Queues: resource.(*javaanalyze.ServiceBusResource).Queues, + }) + } + } +} diff --git a/cli/azd/internal/appdetect/javaanalyze/azure_yaml.go b/cli/azd/internal/appdetect/javaanalyze/azure_yaml.go new file mode 100644 index 00000000000..41e848c88cd --- /dev/null +++ b/cli/azd/internal/appdetect/javaanalyze/azure_yaml.go @@ -0,0 +1,91 @@ +package javaanalyze + +type AzureYaml struct { + Service *Service `json:"service"` + Resources []IResource `json:"resources"` + ServiceBindings []ServiceBinding `json:"serviceBindings"` +} + +type IResource interface { + GetName() string + GetType() string + GetBicepParameters() []BicepParameter + GetBicepProperties() []BicepProperty +} + +type Resource struct { + Name string `json:"name"` + Type string `json:"type"` + BicepParameters []BicepParameter `json:"bicepParameters"` + BicepProperties []BicepProperty `json:"bicepProperties"` +} + +func (r *Resource) GetName() string { + return r.Name +} + +func (r *Resource) GetType() string { + return r.Type +} + +func (r *Resource) GetBicepParameters() []BicepParameter { + return r.BicepParameters +} + +func (r *Resource) GetBicepProperties() []BicepProperty { + return r.BicepProperties +} + +type ServiceBusResource struct { + Resource + Queues []string `json:"queues"` + TopicAndSubscriptions []string `json:"topicAndSubscriptions"` +} + +type BicepParameter struct { + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` +} + +type BicepProperty struct { + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` +} + +type ResourceType int32 + +const ( + RESOURCE_TYPE_MYSQL ResourceType = 0 + RESOURCE_TYPE_AZURE_STORAGE ResourceType = 1 +) + +// Service represents a specific service's configuration. +type Service struct { + Name string `json:"name"` + Path string `json:"path"` + ResourceURI string `json:"resourceUri"` + Description string `json:"description"` + Environment []Environment `json:"environment"` +} + +type Environment struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type ServiceBinding struct { + Name string `json:"name"` + ResourceURI string `json:"resourceUri"` + AuthType AuthType `json:"authType"` +} + +type AuthType int32 + +const ( + // Authentication type not specified. + AuthType_SYSTEM_MANAGED_IDENTITY AuthType = 0 + // Username and Password Authentication. + AuthType_USER_PASSWORD AuthType = 1 +) diff --git a/cli/azd/internal/appdetect/javaanalyze/project_analyzer_java.go b/cli/azd/internal/appdetect/javaanalyze/project_analyzer_java.go new file mode 100644 index 00000000000..dd3fbe37665 --- /dev/null +++ b/cli/azd/internal/appdetect/javaanalyze/project_analyzer_java.go @@ -0,0 +1,41 @@ +package javaanalyze + +import "os" + +type javaProject struct { + springProject springProject + mavenProject mavenProject +} + +func Analyze(path string) []AzureYaml { + var result []AzureYaml + rules := []rule{ + &ruleService{}, + &ruleMysql{}, + &rulePostgresql{}, + &ruleMongo{}, + &ruleRedis{}, + &ruleStorage{}, + &ruleServiceBusScsb{}, + } + + entries, err := os.ReadDir(path) + if err == nil { + for _, entry := range entries { + if "pom.xml" == entry.Name() { + mavenProjects, _ := analyzeMavenProject(path) + + for _, mavenProject := range mavenProjects { + javaProject := &javaProject{ + mavenProject: mavenProject, + springProject: analyzeSpringProject(mavenProject.path), + } + azureYaml, _ := applyRules(javaProject, rules) + result = append(result, *azureYaml) + } + } + } + } + + return result +} diff --git a/cli/azd/internal/appdetect/javaanalyze/project_analyzer_maven.go b/cli/azd/internal/appdetect/javaanalyze/project_analyzer_maven.go new file mode 100644 index 00000000000..6f79d0f73bd --- /dev/null +++ b/cli/azd/internal/appdetect/javaanalyze/project_analyzer_maven.go @@ -0,0 +1,92 @@ +package javaanalyze + +import ( + "encoding/xml" + "fmt" + "io/ioutil" + "os" + "path/filepath" +) + +// mavenProject represents the top-level structure of a Maven POM file. +type mavenProject struct { + XmlName xml.Name `xml:"project"` + Parent parent `xml:"parent"` + Modules []string `xml:"modules>module"` // Capture the modules + Dependencies []dependency `xml:"dependencies>dependency"` + DependencyManagement dependencyManagement `xml:"dependencyManagement"` + Build build `xml:"build"` + path string + spring springProject +} + +// Parent represents the parent POM if this project is a module. +type parent struct { + GroupId string `xml:"groupId"` + ArtifactId string `xml:"artifactId"` + Version string `xml:"version"` +} + +// Dependency represents a single Maven dependency. +type dependency struct { + GroupId string `xml:"groupId"` + ArtifactId string `xml:"artifactId"` + Version string `xml:"version"` + Scope string `xml:"scope,omitempty"` +} + +// DependencyManagement includes a list of dependencies that are managed. +type dependencyManagement struct { + Dependencies []dependency `xml:"dependencies>dependency"` +} + +// Build represents the build configuration which can contain plugins. +type build struct { + Plugins []plugin `xml:"plugins>plugin"` +} + +// Plugin represents a build plugin. +type plugin struct { + GroupId string `xml:"groupId"` + ArtifactId string `xml:"artifactId"` + Version string `xml:"version"` + //Configuration xml.Node `xml:"configuration"` +} + +func analyzeMavenProject(projectPath string) ([]mavenProject, error) { + rootProject, _ := analyze(projectPath + "/pom.xml") + var result []mavenProject + + // if it has submodules + if len(rootProject.Modules) > 0 { + for _, m := range rootProject.Modules { + subModule, _ := analyze(projectPath + "/" + m + "/pom.xml") + result = append(result, *subModule) + } + } else { + result = append(result, *rootProject) + } + return result, nil +} + +func analyze(filePath string) (*mavenProject, error) { + xmlFile, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("error opening file: %w", err) + } + defer xmlFile.Close() + + bytes, err := ioutil.ReadAll(xmlFile) + if err != nil { + return nil, fmt.Errorf("error reading file: %w", err) + } + + var project mavenProject + if err := xml.Unmarshal(bytes, &project); err != nil { + return nil, fmt.Errorf("error parsing XML: %w", err) + } + + project.path = filepath.Dir(filePath) + + return &project, nil +} diff --git a/cli/azd/internal/appdetect/javaanalyze/project_analyzer_spring.go b/cli/azd/internal/appdetect/javaanalyze/project_analyzer_spring.go new file mode 100644 index 00000000000..3370477b551 --- /dev/null +++ b/cli/azd/internal/appdetect/javaanalyze/project_analyzer_spring.go @@ -0,0 +1,115 @@ +package javaanalyze + +import ( + "bufio" + "fmt" + "gopkg.in/yaml.v3" + "log" + "os" + "path/filepath" + "strings" +) + +type springProject struct { + applicationProperties map[string]string +} + +func analyzeSpringProject(projectPath string) springProject { + return springProject{ + applicationProperties: getProperties(projectPath), + } +} + +func getProperties(projectPath string) map[string]string { + result := make(map[string]string) + getPropertiesInPropertiesFile(filepath.Join(projectPath, "/src/main/resources/application.properties"), result) + getPropertiesInYamlFile(filepath.Join(projectPath, "/src/main/resources/application.yml"), result) + getPropertiesInYamlFile(filepath.Join(projectPath, "/src/main/resources/application.yaml"), result) + profile, profileSet := result["spring.profiles.active"] + if profileSet { + getPropertiesInPropertiesFile(filepath.Join(projectPath, "/src/main/resources/application-"+profile+".properties"), result) + getPropertiesInYamlFile(filepath.Join(projectPath, "/src/main/resources/application-"+profile+".yml"), result) + getPropertiesInYamlFile(filepath.Join(projectPath, "/src/main/resources/application-"+profile+".yaml"), result) + } + return result +} + +func getPropertiesInYamlFile(yamlFilePath string, result map[string]string) { + data, err := os.ReadFile(yamlFilePath) + if err != nil { + // Ignore the error if file not exist. + 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) + } + + 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 getPropertiesInPropertiesFile(propertiesFilePath string, result map[string]string) { + file, err := os.Open(propertiesFilePath) + if err != nil { + // Ignore the error if file not exist. + 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 + } + } +} diff --git a/cli/azd/internal/appdetect/javaanalyze/project_analyzer_spring_test.go b/cli/azd/internal/appdetect/javaanalyze/project_analyzer_spring_test.go new file mode 100644 index 00000000000..833645e4b52 --- /dev/null +++ b/cli/azd/internal/appdetect/javaanalyze/project_analyzer_spring_test.go @@ -0,0 +1,26 @@ +package javaanalyze + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAnalyzeSpringProject(t *testing.T) { + var project = analyzeSpringProject(filepath.Join("testdata", "project-one")) + require.Equal(t, "", project.applicationProperties["not.exist"]) + require.Equal(t, "jdbc:h2:mem:testdb", project.applicationProperties["spring.datasource.url"]) + + project = analyzeSpringProject(filepath.Join("testdata", "project-two")) + require.Equal(t, "", project.applicationProperties["not.exist"]) + require.Equal(t, "jdbc:h2:mem:testdb", project.applicationProperties["spring.datasource.url"]) + + project = analyzeSpringProject(filepath.Join("testdata", "project-three")) + require.Equal(t, "", project.applicationProperties["not.exist"]) + require.Equal(t, "HTML", project.applicationProperties["spring.thymeleaf.mode"]) + + project = analyzeSpringProject(filepath.Join("testdata", "project-four")) + require.Equal(t, "", project.applicationProperties["not.exist"]) + require.Equal(t, "mysql", project.applicationProperties["database"]) +} diff --git a/cli/azd/internal/appdetect/javaanalyze/rule_engine.go b/cli/azd/internal/appdetect/javaanalyze/rule_engine.go new file mode 100644 index 00000000000..630d2d0ebf4 --- /dev/null +++ b/cli/azd/internal/appdetect/javaanalyze/rule_engine.go @@ -0,0 +1,17 @@ +package javaanalyze + +type rule interface { + match(project *javaProject) bool + apply(azureYaml *AzureYaml) +} + +func applyRules(javaProject *javaProject, rules []rule) (*AzureYaml, error) { + azureYaml := &AzureYaml{} + + for _, r := range rules { + if r.match(javaProject) { + r.apply(azureYaml) + } + } + return azureYaml, nil +} diff --git a/cli/azd/internal/appdetect/javaanalyze/rule_mongo.go b/cli/azd/internal/appdetect/javaanalyze/rule_mongo.go new file mode 100644 index 00000000000..74e282bde2f --- /dev/null +++ b/cli/azd/internal/appdetect/javaanalyze/rule_mongo.go @@ -0,0 +1,30 @@ +package javaanalyze + +type ruleMongo struct { +} + +func (mr *ruleMongo) match(javaProject *javaProject) bool { + if javaProject.mavenProject.Dependencies != nil { + for _, dep := range javaProject.mavenProject.Dependencies { + if dep.GroupId == "org.springframework.boot" && dep.ArtifactId == "spring-boot-starter-data-mongodb" { + return true + } + if dep.GroupId == "org.springframework.boot" && dep.ArtifactId == "spring-boot-starter-data-mongodb-reactive" { + return true + } + } + } + return false +} + +func (mr *ruleMongo) apply(azureYaml *AzureYaml) { + azureYaml.Resources = append(azureYaml.Resources, &Resource{ + Name: "MongoDB", + Type: "MongoDB", + }) + + azureYaml.ServiceBindings = append(azureYaml.ServiceBindings, ServiceBinding{ + Name: "MongoDB", + AuthType: AuthType_SYSTEM_MANAGED_IDENTITY, + }) +} diff --git a/cli/azd/internal/appdetect/javaanalyze/rule_mysql.go b/cli/azd/internal/appdetect/javaanalyze/rule_mysql.go new file mode 100644 index 00000000000..c98d317b101 --- /dev/null +++ b/cli/azd/internal/appdetect/javaanalyze/rule_mysql.go @@ -0,0 +1,27 @@ +package javaanalyze + +type ruleMysql struct { +} + +func (mr *ruleMysql) match(javaProject *javaProject) bool { + if javaProject.mavenProject.Dependencies != nil { + for _, dep := range javaProject.mavenProject.Dependencies { + if dep.GroupId == "com.mysql" && dep.ArtifactId == "mysql-connector-j" { + return true + } + } + } + return false +} + +func (mr *ruleMysql) apply(azureYaml *AzureYaml) { + azureYaml.Resources = append(azureYaml.Resources, &Resource{ + Name: "MySQL", + Type: "MySQL", + }) + + azureYaml.ServiceBindings = append(azureYaml.ServiceBindings, ServiceBinding{ + Name: "MySQL", + AuthType: AuthType_SYSTEM_MANAGED_IDENTITY, + }) +} diff --git a/cli/azd/internal/appdetect/javaanalyze/rule_postgresql.go b/cli/azd/internal/appdetect/javaanalyze/rule_postgresql.go new file mode 100644 index 00000000000..bfe58533428 --- /dev/null +++ b/cli/azd/internal/appdetect/javaanalyze/rule_postgresql.go @@ -0,0 +1,27 @@ +package javaanalyze + +type rulePostgresql struct { +} + +func (mr *rulePostgresql) match(javaProject *javaProject) bool { + if javaProject.mavenProject.Dependencies != nil { + for _, dep := range javaProject.mavenProject.Dependencies { + if dep.GroupId == "org.postgresql" && dep.ArtifactId == "postgresql" { + return true + } + } + } + return false +} + +func (mr *rulePostgresql) apply(azureYaml *AzureYaml) { + azureYaml.Resources = append(azureYaml.Resources, &Resource{ + Name: "PostgreSQL", + Type: "PostgreSQL", + }) + + azureYaml.ServiceBindings = append(azureYaml.ServiceBindings, ServiceBinding{ + Name: "PostgreSQL", + AuthType: AuthType_SYSTEM_MANAGED_IDENTITY, + }) +} diff --git a/cli/azd/internal/appdetect/javaanalyze/rule_redis.go b/cli/azd/internal/appdetect/javaanalyze/rule_redis.go new file mode 100644 index 00000000000..7e87a57afa8 --- /dev/null +++ b/cli/azd/internal/appdetect/javaanalyze/rule_redis.go @@ -0,0 +1,25 @@ +package javaanalyze + +type ruleRedis struct { +} + +func (r *ruleRedis) match(javaProject *javaProject) bool { + if javaProject.mavenProject.Dependencies != nil { + for _, dep := range javaProject.mavenProject.Dependencies { + if dep.GroupId == "org.springframework.boot" && dep.ArtifactId == "spring-boot-starter-data-redis" { + return true + } + if dep.GroupId == "org.springframework.boot" && dep.ArtifactId == "spring-boot-starter-data-redis-reactive" { + return true + } + } + } + return false +} + +func (r *ruleRedis) apply(azureYaml *AzureYaml) { + azureYaml.Resources = append(azureYaml.Resources, &Resource{ + Name: "Redis", + Type: "Redis", + }) +} diff --git a/cli/azd/internal/appdetect/javaanalyze/rule_service.go b/cli/azd/internal/appdetect/javaanalyze/rule_service.go new file mode 100644 index 00000000000..8203848830f --- /dev/null +++ b/cli/azd/internal/appdetect/javaanalyze/rule_service.go @@ -0,0 +1,17 @@ +package javaanalyze + +type ruleService struct { + javaProject *javaProject +} + +func (r *ruleService) match(javaProject *javaProject) bool { + r.javaProject = javaProject + return true +} + +func (r *ruleService) apply(azureYaml *AzureYaml) { + if azureYaml.Service == nil { + azureYaml.Service = &Service{} + } + azureYaml.Service.Path = r.javaProject.mavenProject.path +} diff --git a/cli/azd/internal/appdetect/javaanalyze/rule_servicebus_scsb.go b/cli/azd/internal/appdetect/javaanalyze/rule_servicebus_scsb.go new file mode 100644 index 00000000000..242d22560ff --- /dev/null +++ b/cli/azd/internal/appdetect/javaanalyze/rule_servicebus_scsb.go @@ -0,0 +1,62 @@ +package javaanalyze + +import ( + "fmt" + "strings" +) + +type ruleServiceBusScsb struct { + javaProject *javaProject +} + +func (r *ruleServiceBusScsb) match(javaProject *javaProject) bool { + if javaProject.mavenProject.Dependencies != nil { + for _, dep := range javaProject.mavenProject.Dependencies { + if dep.GroupId == "com.azure.spring" && dep.ArtifactId == "spring-cloud-azure-stream-binder-servicebus" { + r.javaProject = javaProject + return true + } + } + } + return false +} + +// 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 (r *ruleServiceBusScsb) apply(azureYaml *AzureYaml) { + bindingDestinations := findBindingDestinations(r.javaProject.springProject.applicationProperties) + destinations := make([]string, 0, len(bindingDestinations)) + for bindingName, destination := range bindingDestinations { + destinations = append(destinations, destination) + fmt.Printf("Service Bus queue [%s] found for binding [%s]", destination, bindingName) + } + resource := ServiceBusResource{ + Resource: Resource{ + Name: "Azure Service Bus", + Type: "Azure Service Bus", + }, + Queues: destinations, + } + azureYaml.Resources = append(azureYaml.Resources, &resource) + + azureYaml.ServiceBindings = append(azureYaml.ServiceBindings, ServiceBinding{ + Name: "Azure Service Bus", + AuthType: AuthType_SYSTEM_MANAGED_IDENTITY, + }) +} diff --git a/cli/azd/internal/appdetect/javaanalyze/rule_storage.go b/cli/azd/internal/appdetect/javaanalyze/rule_storage.go new file mode 100644 index 00000000000..557733ebb7b --- /dev/null +++ b/cli/azd/internal/appdetect/javaanalyze/rule_storage.go @@ -0,0 +1,39 @@ +package javaanalyze + +type ruleStorage struct { +} + +func (r *ruleStorage) match(javaProject *javaProject) bool { + if javaProject.mavenProject.Dependencies != nil { + for _, dep := range javaProject.mavenProject.Dependencies { + if dep.GroupId == "com.azure" && dep.ArtifactId == "" { + return true + } + if dep.GroupId == "com.azure.spring" && dep.ArtifactId == "spring-cloud-azure-starter-storage" { + return true + } + if dep.GroupId == "com.azure.spring" && dep.ArtifactId == "spring-cloud-azure-starter-storage-blob" { + return true + } + if dep.GroupId == "com.azure.spring" && dep.ArtifactId == "spring-cloud-azure-starter-storage-file-share" { + return true + } + if dep.GroupId == "com.azure.spring" && dep.ArtifactId == "spring-cloud-azure-starter-storage-queue" { + return true + } + } + } + return false +} + +func (r *ruleStorage) apply(azureYaml *AzureYaml) { + azureYaml.Resources = append(azureYaml.Resources, &Resource{ + Name: "Azure Storage", + Type: "Azure Storage", + }) + + azureYaml.ServiceBindings = append(azureYaml.ServiceBindings, ServiceBinding{ + Name: "Azure Storage", + AuthType: AuthType_SYSTEM_MANAGED_IDENTITY, + }) +} diff --git a/cli/azd/internal/appdetect/javaanalyze/testdata/project-four/src/main/resources/application-mysql.properties b/cli/azd/internal/appdetect/javaanalyze/testdata/project-four/src/main/resources/application-mysql.properties new file mode 100644 index 00000000000..33ec21d3c95 --- /dev/null +++ b/cli/azd/internal/appdetect/javaanalyze/testdata/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/javaanalyze/testdata/project-four/src/main/resources/application-postgres.properties b/cli/azd/internal/appdetect/javaanalyze/testdata/project-four/src/main/resources/application-postgres.properties new file mode 100644 index 00000000000..7d9676e3aad --- /dev/null +++ b/cli/azd/internal/appdetect/javaanalyze/testdata/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/javaanalyze/testdata/project-four/src/main/resources/application.properties b/cli/azd/internal/appdetect/javaanalyze/testdata/project-four/src/main/resources/application.properties new file mode 100644 index 00000000000..59d5368e73c --- /dev/null +++ b/cli/azd/internal/appdetect/javaanalyze/testdata/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/javaanalyze/testdata/project-one/src/main/resources/application.yml b/cli/azd/internal/appdetect/javaanalyze/testdata/project-one/src/main/resources/application.yml new file mode 100644 index 00000000000..09d0cc057c5 --- /dev/null +++ b/cli/azd/internal/appdetect/javaanalyze/testdata/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/javaanalyze/testdata/project-three/src/main/resources/application.properties b/cli/azd/internal/appdetect/javaanalyze/testdata/project-three/src/main/resources/application.properties new file mode 100644 index 00000000000..59d5368e73c --- /dev/null +++ b/cli/azd/internal/appdetect/javaanalyze/testdata/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/javaanalyze/testdata/project-two/src/main/resources/application.yaml b/cli/azd/internal/appdetect/javaanalyze/testdata/project-two/src/main/resources/application.yaml new file mode 100644 index 00000000000..09d0cc057c5 --- /dev/null +++ b/cli/azd/internal/appdetect/javaanalyze/testdata/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 7837a40c1c1..712782808b8 100644 --- a/cli/azd/internal/repository/app_init.go +++ b/cli/azd/internal/repository/app_init.go @@ -34,9 +34,14 @@ 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(): {}, +} + // 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 52fb19ad6c4..cefce4f8e6c 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 57488b077dc..d1b6d2ce7c0 100644 --- a/cli/azd/internal/repository/infra_confirm.go +++ b/cli/azd/internal/repository/infra_confirm.go @@ -1,8 +1,10 @@ package repository import ( + "bufio" "context" "fmt" + "os" "path/filepath" "regexp" "strconv" @@ -77,22 +79,49 @@ func (i *Initializer) infraSpecFromDetect( spec.DbCosmosMongo = &scaffold.DatabaseCosmosMongo{ DatabaseName: dbName, } - break dbPrompt case appdetect.DbPostgres: if dbName == "" { 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 := filepath.Base(svc.Path) serviceSpec := scaffold.ServiceSpec{ @@ -103,10 +132,11 @@ func (i *Initializer) infraSpecFromDetect( if svc.Docker == nil || svc.Docker.Path == "" { // default builder always specifies port 80 serviceSpec.Port = 80 - if svc.Language == appdetect.Java { serviceSpec.Port = 8080 } + } else { + serviceSpec.Port = i.detectPortInDockerfile(svc.Docker.Path) } for _, framework := range svc.Dependencies { @@ -128,7 +158,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{ @@ -136,6 +174,13 @@ func (i *Initializer) infraSpecFromDetect( } } } + + for _, azureDep := range svc.AzureDeps { + switch azureDep.(type) { + case appdetect.AzureDepServiceBus: + serviceSpec.AzureServiceBus = spec.AzureServiceBus + } + } spec.Services = append(spec.Services, serviceSpec) } @@ -194,3 +239,135 @@ func (i *Initializer) infraSpecFromDetect( return spec, 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) detectPortInDockerfile( + filePath string) int { + file, err := os.Open(filePath) + if err != nil { + return -1 + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "EXPOSE") { + var port int + _, err := fmt.Sscanf(line, "EXPOSE %d", &port) + if err == nil { + return port + } + } + } + return -1 +} + +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..21873df7c7d 100644 --- a/cli/azd/internal/repository/infra_confirm_test.go +++ b/cli/azd/internal/repository/infra_confirm_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "path/filepath" "strings" "testing" @@ -225,3 +226,26 @@ func TestInitializer_infraSpecFromDetect(t *testing.T) { }) } } + +func TestDetectPortInDockerfile(t *testing.T) { + i := &Initializer{ + console: input.NewConsole( + false, + false, + input.Writers{Output: os.Stdout}, + input.ConsoleHandles{ + Stderr: os.Stderr, + Stdin: os.Stdin, + Stdout: os.Stdout, + }, + nil, + nil), + } + var port int + port = i.detectPortInDockerfile(filepath.Join("testdata", "Dockerfile", "Dockerfile1")) + require.Equal(t, 80, port) + port = i.detectPortInDockerfile(filepath.Join("testdata", "Dockerfile", "Dockerfile2")) + require.Equal(t, 3100, port) + port = i.detectPortInDockerfile(filepath.Join("testdata", "Dockerfile", "Dockerfile3")) + require.Equal(t, -1, port) +} 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/scaffold/scaffold.go b/cli/azd/internal/scaffold/scaffold.go index b0b4b838969..b89a94ce317 100644 --- a/cli/azd/internal/scaffold/scaffold.go +++ b/cli/azd/internal/scaffold/scaffold.go @@ -129,6 +129,20 @@ func ExecInfra( } } + if spec.DbMySql != nil { + err = Execute(t, "db-mysql.bicep", spec.DbMySql, filepath.Join(infraApp, "db-mysql.bicep")) + if err != nil { + return fmt.Errorf("scaffolding mysql: %w", err) + } + } + + if spec.AzureServiceBus != nil { + err = Execute(t, "azure-service-bus.bicep", spec.AzureServiceBus, filepath.Join(infraApp, "azure-service-bus.bicep")) + if err != nil { + return fmt.Errorf("scaffolding service bus: %w", err) + } + } + for _, svc := range spec.Services { err = Execute(t, "host-containerapp.bicep", svc, filepath.Join(infraApp, svc.Name+".bicep")) if err != nil { @@ -150,8 +164,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 9788f8c247c..20a9f61b57c 100644 --- a/cli/azd/internal/scaffold/spec.go +++ b/cli/azd/internal/scaffold/spec.go @@ -11,7 +11,11 @@ type InfraSpec struct { // Databases to create DbPostgres *DatabasePostgres + DbMySql *DatabaseMySql DbCosmosMongo *DatabaseCosmosMongo + + // Azure Service Bus + AzureServiceBus *AzureDepServiceBus } type Parameter struct { @@ -22,14 +26,42 @@ 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 { DatabaseName string } +type AzureDepServiceBus struct { + Name string + Queues []string + TopicsAndSubscriptions map[string][]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 @@ -42,8 +74,12 @@ type ServiceSpec struct { // Connection to a database DbPostgres *DatabaseReference + DbMySql *DatabaseReference DbCosmosMongo *DatabaseReference DbRedis *DatabaseReference + + // Azure Service Bus + AzureServiceBus *AzureDepServiceBus } type Frontend struct { @@ -59,7 +95,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/shared/monitoring.bicep b/cli/azd/resources/scaffold/base/shared/monitoring.bicep index 4ae9796cc3b..7b50e45ec24 100644 --- a/cli/azd/resources/scaffold/base/shared/monitoring.bicep +++ b/cli/azd/resources/scaffold/base/shared/monitoring.bicep @@ -30,5 +30,6 @@ resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { } output applicationInsightsName string = applicationInsights.name +output connectionString string = applicationInsights.properties.ConnectionString output logAnalyticsWorkspaceId string = logAnalytics.id output logAnalyticsWorkspaceName string = logAnalytics.name 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 index 54866987449..b6ebb5a87b8 100644 --- a/cli/azd/resources/scaffold/templates/db-postgres.bicept +++ b/cli/azd/resources/scaffold/templates/db-postgres.bicept @@ -73,6 +73,7 @@ resource dbPasswordKey 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { } } +output databaseId string = database.id output databaseHost string = postgreServer.properties.fullyQualifiedDomainName output databaseName string = databaseName output databaseUser string = databaseUser diff --git a/cli/azd/resources/scaffold/templates/host-containerapp.bicept b/cli/azd/resources/scaffold/templates/host-containerapp.bicept index 452402f4a96..9991fdea940 100644 --- a/cli/azd/resources/scaffold/templates/host-containerapp.bicept +++ b/cli/azd/resources/scaffold/templates/host-containerapp.bicept @@ -11,12 +11,34 @@ param applicationInsightsName string @secure() param cosmosDbConnectionString string {{- end}} -{{- if .DbPostgres}} -param databaseHost string -param databaseUser string -param databaseName string +{{- 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 databasePassword string +param azureServiceBusConnectionString string +{{- end}} +{{- if (and .AzureServiceBus .AzureServiceBus.AuthUsingManagedIdentity)}} +@secure() +param azureServiceBusNamespace string {{- end}} {{- if .DbRedis}} param redisName string @@ -30,6 +52,7 @@ param allowedOrigins array 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 => { @@ -123,6 +146,7 @@ resource app 'Microsoft.App/containerApps@2023-05-02-preview' = { allowedOrigins: union(allowedOrigins, [ // define additional allowed origins here ]) + allowedMethods: ['GET', 'PUT', 'POST', 'DELETE'] } {{- end}} } @@ -140,10 +164,22 @@ resource app 'Microsoft.App/containerApps@2023-05-02-preview' = { value: cosmosDbConnectionString } {{- end}} - {{- if .DbPostgres}} + {{- if (and .DbPostgres .DbPostgres.AuthUsingUsernamePassword)}} + { + name: 'postgres-db-pass' + value: postgresDatabasePassword + } + {{- end}} + {{- if (and .DbMySql .DbMySql.AuthUsingUsernamePassword)}} { - name: 'db-pass' - value: databasePassword + name: 'mysql-db-pass' + value: mysqlDatabasePassword + } + {{- end}} + {{- if (and .AzureServiceBus .AzureServiceBus.AuthUsingConnectionString)}} + { + name: 'spring-cloud-azure-servicebus-connection-string' + value: azureServiceBusConnectionString } {{- end}} ], @@ -168,26 +204,72 @@ resource app 'Microsoft.App/containerApps@2023-05-02-preview' = { secretRef: 'azure-cosmos-connection-string' } {{- end}} - {{- if .DbPostgres}} + {{- if (and .DbPostgres .DbPostgres.AuthUsingUsernamePassword)}} { name: 'POSTGRES_HOST' - value: databaseHost + value: postgresDatabaseHost } { - name: 'POSTGRES_USERNAME' - value: databaseUser + name: 'POSTGRES_PORT' + value: '5432' } { name: 'POSTGRES_DATABASE' - value: databaseName + value: postgresDatabaseName + } + { + name: 'POSTGRES_USERNAME' + value: postgresDatabaseUser } { name: 'POSTGRES_PASSWORD' - secretRef: 'db-pass' + secretRef: 'postgres-db-pass' } + {{- end}} + {{- if (and .DbMySql .DbMySql.AuthUsingUsernamePassword)}} { - name: 'POSTGRES_PORT' - value: '5432' + 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}} @@ -203,6 +285,10 @@ resource app 'Microsoft.App/containerApps@2023-05-02-preview' = { name: 'PORT' value: '{{ .Port }}' } + { + name: 'SERVER_PORT' + value: '{{ .Port }}' + } {{- end}} ], env, @@ -231,6 +317,96 @@ resource app 'Microsoft.App/containerApps@2023-05-02-preview' = { } } } +{{- 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 diff --git a/cli/azd/resources/scaffold/templates/main.bicept b/cli/azd/resources/scaffold/templates/main.bicept index b86550124c2..9e1e149c126 100644 --- a/cli/azd/resources/scaffold/templates/main.bicept +++ b/cli/azd/resources/scaffold/templates/main.bicept @@ -91,13 +91,14 @@ module appsEnv './shared/apps-env.bicep' = { } scope: rg } -{{- if (or .DbCosmosMongo .DbPostgres)}} +{{- if (or (or (or .DbCosmosMongo .DbPostgres) .DbMySql) (and .AzureServiceBus .AzureServiceBus.AuthUsingConnectionString))}} resource vault 'Microsoft.KeyVault/vaults@2022-07-01' existing = { name: keyVault.outputs.name scope: rg } {{- end}} + {{- if .DbCosmosMongo}} module cosmosDb './app/db-cosmos-mongo.bicep' = { @@ -126,6 +127,38 @@ module postgresDb './app/db-postgres.bicep' = { scope: rg } {{- end}} +{{- if .DbMySql}} + +module mysqlDb './app/db-mysql.bicep' = { + name: 'mysqlDb' + params: { + serverName: '${abbrs.dBforMySQLServers}${resourceToken}' + location: location + tags: tags + identityName: '${abbrs.managedIdentityUserAssignedIdentities}mysql-${resourceToken}' + databasePassword: databasePassword + keyVaultName: keyVault.outputs.name + allowAllIPsFirewall: true + } + scope: rg +} +{{- end}} + +{{- if .AzureServiceBus }} +module serviceBus './app/azure-service-bus.bicep' = { + name: 'serviceBus' + params: { + serviceBusNamespaceName: '${abbrs.serviceBusNamespaces}${resourceToken}' + location: location + tags: tags + {{- if .AzureServiceBus.AuthUsingConnectionString}} + keyVaultName: keyVault.outputs.name + {{end}} + } + scope: rg +} +{{- end}} + {{- range .Services}} module {{bicepName .Name}} './app/{{.Name}}.bicep' = { @@ -146,11 +179,32 @@ module {{bicepName .Name}} './app/{{.Name}}.bicep' = { {{- if .DbCosmosMongo}} cosmosDbConnectionString: vault.getSecret(cosmosDb.outputs.connectionStringKey) {{- end}} - {{- if .DbPostgres}} - databaseName: postgresDb.outputs.databaseName - databaseHost: postgresDb.outputs.databaseHost - databaseUser: postgresDb.outputs.databaseUser - databasePassword: vault.getSecret(postgresDb.outputs.databaseConnectionKey) + {{- if (and .DbPostgres .DbPostgres.AuthUsingManagedIdentity)}} + postgresDatabaseId: postgresDb.outputs.databaseId + {{- end}} + {{- if (and .DbPostgres .DbPostgres.AuthUsingUsernamePassword)}} + postgresDatabaseHost: postgresDb.outputs.databaseHost + postgresDatabaseName: postgresDb.outputs.databaseName + postgresDatabaseUser: postgresDb.outputs.databaseUser + postgresDatabasePassword: vault.getSecret(postgresDb.outputs.databaseConnectionKey) + {{- end}} + {{- if (and .DbMySql .DbMySql.AuthUsingManagedIdentity)}} + mysqlDatabaseId: mysqlDb.outputs.databaseId + mysqlIdentityName: mysqlDb.outputs.identityName + {{- end}} + {{- if (and .DbMySql .DbMySql.AuthUsingUsernamePassword)}} + mysqlDatabaseHost: mysqlDb.outputs.databaseHost + mysqlDatabaseName: mysqlDb.outputs.databaseName + mysqlDatabaseUser: mysqlDb.outputs.databaseUser + mysqlDatabasePassword: vault.getSecret(mysqlDb.outputs.databaseConnectionKey) + {{- end}} + {{- if (and .AzureServiceBus .AzureServiceBus.AuthUsingConnectionString)}} + azureServiceBusConnectionString: vault.getSecret(serviceBus.outputs.serviceBusConnectionStringKey) + {{- end}} + {{- if (and .AzureServiceBus .AzureServiceBus.AuthUsingManagedIdentity)}} + azureServiceBusNamespace: '${abbrs.serviceBusNamespaces}${resourceToken}' + {{- end}} + {{- if (and .AzureServiceBus .AzureServiceBus.AuthUsingManagedIdentity)}} {{- end}} {{- if (and .Frontend .Frontend.Backends)}} apiUrls: [ diff --git a/cli/azd/resources/scaffold/templates/next-steps.mdt b/cli/azd/resources/scaffold/templates/next-steps.mdt index 14be7cbbbfe..f2e46041137 100644 --- a/cli/azd/resources/scaffold/templates/next-steps.mdt +++ b/cli/azd/resources/scaffold/templates/next-steps.mdt @@ -21,13 +21,16 @@ 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}}` {{ end}} {{- if .DbPostgres }} - `POSTGRES_*` environment variables are configured in [{{.Name}}.bicep](./infra/app/{{.Name}}.bicep) to connect to the Postgres database. Modify these variables to match your application's needs. {{- 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 }} - `AZURE_COSMOS_MONGODB_CONNECTION_STRING` environment variable is configured in [{{.Name}}.bicep](./infra/app/{{.Name}}.bicep) to connect to the MongoDB database. Modify this variable to match your application's needs. {{- end}} @@ -65,6 +68,9 @@ Each bicep file declares resources to be provisioned. The resources are provisio {{- if .DbPostgres}} - [app/db-postgre.bicep](./infra/app/db-postgre.bicep) - 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}} - [app/db-cosmos.bicep](./infra/app/db-cosmos.bicep) - Azure Cosmos DB (MongoDB) to host the '{{.DbCosmosMongo.DatabaseName}}' database. {{- end}} 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",