diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 47d1607fd70..57c3fd1144a 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -17,6 +17,8 @@ words: - unmarshals - usgovcloudapi - jdbc + - bicept + - springframework languageSettings: - languageId: go ignoreRegExpList: diff --git a/cli/azd/cmd/show.go b/cli/azd/cmd/show.go index 0fe9af63dc4..dbfcb005dd9 100644 --- a/cli/azd/cmd/show.go +++ b/cli/azd/cmd/show.go @@ -421,12 +421,13 @@ func showModelDeployment( if account.Properties.Endpoint != nil { console.Message(ctx, color.HiMagentaString("%s (Azure AI Services Model Deployment)", id.Name)) console.Message(ctx, " Endpoint:") - console.Message(ctx, color.HiBlueString(fmt.Sprintf(" AZURE_OPENAI_ENDPOINT=%s", *account.Properties.Endpoint))) + console.Message(ctx, + color.HiBlueString(fmt.Sprintf(" %s=%s", internal.EnvNameAzureOpenAiUrl, *account.Properties.Endpoint))) console.Message(ctx, " Access:") console.Message(ctx, " Keyless (Microsoft Entra ID)") //nolint:lll - console.Message(ctx, output.WithGrayFormat(" Hint: To access locally, use DefaultAzureCredential. To learn more, visit https://learn.microsoft.com/en-us/azure/ai-services/openai/supported-languages")) - + console.Message(ctx, output.WithGrayFormat(" Hint: To access locally, use DefaultAzureCredential. "+ + "To learn more, visit https://learn.microsoft.com/en-us/azure/ai-services/openai/supported-languages")) console.Message(ctx, "") } diff --git a/cli/azd/internal/appdetect/appdetect.go b/cli/azd/internal/appdetect/appdetect.go index b35dc531000..5a1d9663db9 100644 --- a/cli/azd/internal/appdetect/appdetect.go +++ b/cli/azd/internal/appdetect/appdetect.go @@ -103,6 +103,9 @@ func (f Dependency) IsWebUIFramework() bool { return false } +type RawProject interface { +} + // A type of database that is inferred through heuristics while scanning project information. type DatabaseDep string @@ -139,6 +142,9 @@ type Project struct { // Dependencies scanned in the project. Dependencies []Dependency + // RawProject scanned in the project. + RawProject RawProject + // Experimental: Database dependencies inferred through heuristics while scanning dependencies in the project. DatabaseDeps []DatabaseDep diff --git a/cli/azd/internal/appdetect/appdetect_test.go b/cli/azd/internal/appdetect/appdetect_test.go index 16ee825b7ea..e9c7d920b3b 100644 --- a/cli/azd/internal/appdetect/appdetect_test.go +++ b/cli/azd/internal/appdetect/appdetect_test.go @@ -229,7 +229,14 @@ func TestDetect(t *testing.T) { tt.want[i].Path = filepath.Join(dir, tt.want[i].Path) } - require.Equal(t, tt.want, projects) + require.Equal(t, len(tt.want), len(projects)) + for i := range tt.want { + require.Equal(t, tt.want[i].Language, projects[i].Language) + require.Equal(t, tt.want[i].Path, projects[i].Path) + require.Equal(t, tt.want[i].DetectionRule, projects[i].DetectionRule) + require.Equal(t, tt.want[i].Dependencies, projects[i].Dependencies) + require.Equal(t, tt.want[i].DatabaseDeps, projects[i].DatabaseDeps) + } }) } } diff --git a/cli/azd/internal/appdetect/java.go b/cli/azd/internal/appdetect/java.go index 6a33fd77cf7..e3b6c639de7 100644 --- a/cli/azd/internal/appdetect/java.go +++ b/cli/azd/internal/appdetect/java.go @@ -18,7 +18,7 @@ import ( type javaDetector struct { mvnCli *maven.Cli - rootProjects []mavenProject + rootProjects []MavenProject } func (jd *javaDetector) Language() Language { @@ -41,7 +41,7 @@ func (jd *javaDetector) DetectProject(ctx context.Context, path string, entries return nil, nil } - var currentRoot *mavenProject + var currentRoot *MavenProject for _, rootProject := range jd.rootProjects { // we can say that the project is in the root project if the path is under the project if inRoot := strings.HasPrefix(pomFile, rootProject.path); inRoot { @@ -66,8 +66,8 @@ func (jd *javaDetector) DetectProject(ctx context.Context, path string, entries return nil, nil } -// mavenProject represents the top-level structure of a Maven POM file. -type mavenProject struct { +// 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 @@ -109,12 +109,12 @@ type plugin struct { Version string `xml:"version"` } -func readMavenProject(ctx context.Context, mvnCli *maven.Cli, filePath string) (*mavenProject, error) { +func readMavenProject(ctx context.Context, mvnCli *maven.Cli, filePath string) (*MavenProject, error) { effectivePom, err := mvnCli.EffectivePom(ctx, filePath) if err != nil { return nil, err } - var project mavenProject + var project MavenProject if err := xml.Unmarshal([]byte(effectivePom), &project); err != nil { return nil, fmt.Errorf("parsing xml: %w", err) } @@ -122,24 +122,24 @@ func readMavenProject(ctx context.Context, mvnCli *maven.Cli, filePath string) ( return &project, nil } -func detectDependencies(mavenProject *mavenProject, project *Project) (*Project, error) { +func detectDependencies(mavenProject *MavenProject, project *Project) (*Project, error) { databaseDepMap := map[DatabaseDep]struct{}{} for _, dep := range mavenProject.Dependencies { - if dep.GroupId == "com.mysql" && dep.ArtifactId == "mysql-connector-j" { + name := dep.GroupId + ":" + dep.ArtifactId + switch name { + case "com.mysql:mysql-connector-j": databaseDepMap[DbMySql] = struct{}{} - } - - if dep.GroupId == "org.postgresql" && dep.ArtifactId == "postgresql" { + case "org.postgresql:postgresql", + "com.azure.spring:spring-cloud-azure-starter-jdbc-postgresql": databaseDepMap[DbPostgres] = struct{}{} } } - if len(databaseDepMap) > 0 { project.DatabaseDeps = slices.SortedFunc(maps.Keys(databaseDepMap), func(a, b DatabaseDep) int { return strings.Compare(string(a), string(b)) }) } - + project.RawProject = *mavenProject return project, nil } diff --git a/cli/azd/internal/appdetect/java_test.go b/cli/azd/internal/appdetect/java_test.go index 0d62858ae19..174d26a8968 100644 --- a/cli/azd/internal/appdetect/java_test.go +++ b/cli/azd/internal/appdetect/java_test.go @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + package appdetect import ( diff --git a/cli/azd/internal/cmd/add/add_preview.go b/cli/azd/internal/cmd/add/add_preview.go index 4433d166894..f2a66c354be 100644 --- a/cli/azd/internal/cmd/add/add_preview.go +++ b/cli/azd/internal/cmd/add/add_preview.go @@ -12,6 +12,7 @@ import ( "strings" "text/tabwriter" + "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/output" @@ -41,31 +42,31 @@ func Metadata(r *project.ResourceConfig) resourceMeta { case project.ResourceTypeDbRedis: res.AzureResourceType = "Microsoft.Cache/redis" res.UseEnvVars = []string{ - "REDIS_HOST", - "REDIS_PORT", - "REDIS_ENDPOINT", - "REDIS_PASSWORD", - "REDIS_URL", + internal.EnvNameRedisHost, + internal.EnvNameRedisPort, + internal.EnvNameRedisEndpoint, + internal.EnvNameRedisPassword, + internal.EnvNameRedisUrl, } case project.ResourceTypeDbPostgres: res.AzureResourceType = "Microsoft.DBforPostgreSQL/flexibleServers/databases" res.UseEnvVars = []string{ - "POSTGRES_HOST", - "POSTGRES_USERNAME", - "POSTGRES_DATABASE", - "POSTGRES_PASSWORD", - "POSTGRES_PORT", - "POSTGRES_URL", + internal.EnvNamePostgresHost, + internal.EnvNamePostgresUsername, + internal.EnvNamePostgresDatabase, + internal.EnvNamePostgresPassword, + internal.EnvNamePostgresPort, + internal.EnvNamePostgresUrl, } case project.ResourceTypeDbMongo: res.AzureResourceType = "Microsoft.DocumentDB/databaseAccounts/mongodbDatabases" res.UseEnvVars = []string{ - "MONGODB_URL", + internal.EnvNameMongoDbUrl, } case project.ResourceTypeOpenAiModel: res.AzureResourceType = "Microsoft.CognitiveServices/accounts/deployments" res.UseEnvVars = []string{ - "AZURE_OPENAI_ENDPOINT", + internal.EnvNameAzureOpenAiUrl, } } return res diff --git a/cli/azd/internal/env.go b/cli/azd/internal/env.go new file mode 100644 index 00000000000..2648bd98a94 --- /dev/null +++ b/cli/azd/internal/env.go @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package internal + +// todo: keep single source for env name in go lang code and resources.bicept + +const EnvNamePostgresHost = "POSTGRES_HOST" +const EnvNamePostgresPort = "POSTGRES_PORT" +const EnvNamePostgresUrl = "POSTGRES_URL" +const EnvNamePostgresDatabase = "POSTGRES_DATABASE" +const EnvNamePostgresUsername = "POSTGRES_USERNAME" + +// nolint:gosec +const EnvNamePostgresPassword = "POSTGRES_PASSWORD" + +const EnvNameRedisHost = "REDIS_HOST" +const EnvNameRedisPort = "REDIS_PORT" +const EnvNameRedisEndpoint = "REDIS_ENDPOINT" +const EnvNameRedisPassword = "REDIS_PASSWORD" +const EnvNameRedisUrl = "REDIS_URL" + +const EnvNameMongoDbUrl = "MONGODB_URL" + +const EnvNameAzureOpenAiUrl = "AZURE_OPENAI_ENDPOINT" + +func ToEnvPlaceHolder(envName string) string { + return "${" + envName + "}" +} diff --git a/cli/azd/internal/repository/app_init.go b/cli/azd/internal/repository/app_init.go index e4abcc53c15..379b9ab70c7 100644 --- a/cli/azd/internal/repository/app_init.go +++ b/cli/azd/internal/repository/app_init.go @@ -4,6 +4,7 @@ package repository import ( + "bufio" "context" "fmt" "maps" @@ -290,6 +291,35 @@ func (i *Initializer) InitFromApp( }) } + for _, proj := range projects { + isSpringBootProject := false + mavenProject, ok := proj.RawProject.(appdetect.MavenProject) + if !ok { + continue + } + for _, dep := range mavenProject.Build.Plugins { + if dep.GroupId == "org.springframework.boot" && + dep.ArtifactId == "spring-boot-maven-plugin" { + isSpringBootProject = true + break + } + } + if !isSpringBootProject { + continue + } + for _, dep := range mavenProject.Dependencies { + name := dep.GroupId + ":" + dep.ArtifactId + switch name { + case "org.postgresql:postgresql", + "com.azure.spring:spring-cloud-azure-starter-jdbc-postgresql": + err := addPostgresqlConnectionProperties(proj.Path) + if err != nil { + return err + } + } + } + } + return nil } @@ -467,3 +497,179 @@ func (i *Initializer) prjConfigFromDetect( return config, nil } + +type property struct { + key string + value string +} +type propertyMergeFunc func(string, string) string + +const azureProfileName = "azure" + +var placeholderPostgresJdbcUrl = "jdbc:postgresql://" + internal.ToEnvPlaceHolder(internal.EnvNamePostgresHost) + + ":" + internal.ToEnvPlaceHolder(internal.EnvNamePostgresPort) + + "/" + internal.ToEnvPlaceHolder(internal.EnvNamePostgresDatabase) + +var postgresqlProperties = []property{ + {"spring.datasource.url", placeholderPostgresJdbcUrl}, + {"spring.datasource.username", internal.ToEnvPlaceHolder(internal.EnvNamePostgresUsername)}, + {"spring.datasource.password", internal.ToEnvPlaceHolder(internal.EnvNamePostgresPassword)}, +} + +var applicationPropertiesRelativePath = filepath.Join("src", "main", "resources", + "application.properties") +var applicationAzurePropertiesRelativePath = filepath.Join("src", "main", "resources", + "application-"+azureProfileName+".properties") + +// todo: support other file suffix. Example: application.yml, application.yaml +func addPostgresqlConnectionProperties(projectPath string) error { + err := addPostgresqlConnectionPropertiesIntoPropertyFile(projectPath) + if err != nil { + return err + } + return activeAzureProfile(projectPath) +} + +func addPostgresqlConnectionPropertiesIntoPropertyFile(projectPath string) error { + filePath := filepath.Join(projectPath, applicationAzurePropertiesRelativePath) + return updatePropertyFile(filePath, postgresqlProperties, keepNewValue) +} + +func keepNewValue(_ string, newValue string) string { + return newValue +} + +func activeAzureProfile(projectPath string) error { + filePath := filepath.Join(projectPath, applicationPropertiesRelativePath) + var newProperties = []property{ + {"spring.profiles.active", azureProfileName}, + } + return updatePropertyFile(filePath, newProperties, appendToCommaSeparatedValues) +} + +func appendToCommaSeparatedValues(commaSeparatedValues string, newValue string) string { + if commaSeparatedValues == "" { + return newValue + } + var values []string + for _, value := range strings.SplitN(commaSeparatedValues, ",", -1) { + value = strings.TrimSpace(value) + if value != "" { + values = append(values, value) + } + } + if !contains(values, azureProfileName) { + values = append(values, azureProfileName) + } + return strings.Join(values, ",") +} + +func contains(a []string, x string) bool { + for _, n := range a { + if x == n { + return true + } + } + return false +} + +func updatePropertyFile(filePath string, newProperties []property, function propertyMergeFunc) error { + err := createFileIfNotExist(filePath) + if err != nil { + return err + } + properties, err := readProperties(filePath) + if err != nil { + return err + } + properties = updateProperties(properties, newProperties, function) + err = writeProperties(filePath, properties) + if err != nil { + return err + } + return nil +} + +func createFileIfNotExist(filePath string) error { + dir := filepath.Dir(filePath) + if _, err := os.Stat(dir); os.IsNotExist(err) { + if err := os.MkdirAll(dir, os.ModePerm); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + } + if _, err := os.Stat(filePath); os.IsNotExist(err) { + file, err := os.Create(filePath) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer file.Close() + } + return nil +} + +func readProperties(filePath string) ([]property, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer file.Close() + + var properties []property + 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]) + properties = append(properties, property{key, value}) + } + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return properties, nil +} + +func updateProperties(properties []property, + newProperties []property, function propertyMergeFunc) []property { + for _, newProperty := range newProperties { + if index := getKeyIndex(properties, newProperty.key); index != -1 { + properties[index].value = function(properties[index].value, newProperty.value) + } else { + properties = append(properties, newProperty) + } + } + return properties +} + +func getKeyIndex(properties []property, key string) int { + for i, prop := range properties { + if prop.key == key { + return i + } + } + return -1 +} + +func writeProperties(filePath string, properties []property) error { + file, err := os.Create(filePath) + if err != nil { + return err + } + defer file.Close() + + writer := bufio.NewWriter(file) + for _, p := range properties { + _, err := writer.WriteString(fmt.Sprintf("%s=%s\n", p.key, p.value)) + if err != nil { + return err + } + } + return writer.Flush() +} diff --git a/cli/azd/internal/repository/app_init_test.go b/cli/azd/internal/repository/app_init_test.go index dad43680123..be75e778739 100644 --- a/cli/azd/internal/repository/app_init_test.go +++ b/cli/azd/internal/repository/app_init_test.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "reflect" "strings" "testing" @@ -15,6 +16,7 @@ import ( "github.com/azure/azure-dev/cli/azd/internal/appdetect" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/project" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -322,3 +324,134 @@ func TestInitializer_prjConfigFromDetect(t *testing.T) { }) } } + +const postgresPropertiesOriginalContent = `spring.datasource.url=jdbc:postgresql://localhost:5432/dbname?sslmode=require +spring.datasource.username=admin +spring.datasource.password=secret +` + +var postgresPropertiesUpdatedContent = `spring.datasource.url=` + placeholderPostgresJdbcUrl + ` +spring.datasource.username=` + internal.ToEnvPlaceHolder(internal.EnvNamePostgresUsername) + ` +spring.datasource.password=` + internal.ToEnvPlaceHolder(internal.EnvNamePostgresPassword) + ` +` + +var postgresPropertiesOriginalMap = []property{ + {"spring.datasource.url", "jdbc:postgresql://localhost:5432/dbname?sslmode=require"}, + {"spring.datasource.username", "admin"}, + {"spring.datasource.password", "secret"}, +} + +func TestAddPostgresqlConnectionProperties(t *testing.T) { + tests := []struct { + name string + inputApplicationPropertiesContent string + inputApplicationAzurePropertiesContent string + outputApplicationPropertiesContent string + outputApplicationAzurePropertiesContent string + }{ + { + name: "no content", + inputApplicationPropertiesContent: "", + inputApplicationAzurePropertiesContent: "", + outputApplicationPropertiesContent: "spring.profiles.active=" + azureProfileName + "\n", + outputApplicationAzurePropertiesContent: postgresPropertiesUpdatedContent, + }, + { + name: "override original content", + inputApplicationPropertiesContent: "spring.profiles.active=" + azureProfileName, + inputApplicationAzurePropertiesContent: postgresPropertiesOriginalContent, + outputApplicationPropertiesContent: "spring.profiles.active=" + azureProfileName + "\n", + outputApplicationAzurePropertiesContent: postgresPropertiesUpdatedContent, + }, + { + name: "append original content", + inputApplicationPropertiesContent: "aaa=xxx\n" + "spring.profiles.active=production , cloud,,", + inputApplicationAzurePropertiesContent: "bbb=yyy\n" + postgresPropertiesOriginalContent, + outputApplicationPropertiesContent: "aaa=xxx\n" + "spring.profiles.active=production,cloud," + + azureProfileName + "\n", + outputApplicationAzurePropertiesContent: "bbb=yyy\n" + postgresPropertiesUpdatedContent, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir := t.TempDir() + applicationPropertiesPath := filepath.Join(tempDir, applicationPropertiesRelativePath) + createFileIfContentIsNotEmpty(t, applicationPropertiesPath, tt.inputApplicationPropertiesContent) + applicationAzurePropertiesPath := filepath.Join(tempDir, applicationAzurePropertiesRelativePath) + createFileIfContentIsNotEmpty(t, applicationAzurePropertiesPath, tt.inputApplicationAzurePropertiesContent) + err := addPostgresqlConnectionProperties(tempDir) + assert.NoError(t, err) + assertFileContent(t, applicationPropertiesPath, tt.outputApplicationPropertiesContent) + assertFileContent(t, applicationAzurePropertiesPath, tt.outputApplicationAzurePropertiesContent) + }) + } +} + +func createFileIfContentIsNotEmpty(t *testing.T, path string, content string) { + if content == "" { + return + } + + dir := filepath.Dir(path) + if _, err := os.Stat(dir); os.IsNotExist(err) { + err := os.MkdirAll(dir, os.ModePerm) + assert.NoError(t, err) + } + file, err := os.Create(path) + assert.NoError(t, err) + defer file.Close() + err = os.WriteFile(path, []byte(content), 0600) + assert.NoError(t, err) +} + +func assertFileContent(t *testing.T, path string, content string) { + actualContent, err := os.ReadFile(path) + assert.NoError(t, err) + assert.Equal(t, content, string(actualContent)) +} + +func TestReadProperties(t *testing.T) { + tempFile, err := os.CreateTemp("", "test.properties") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tempFile.Name()) + + if _, err := tempFile.Write([]byte(postgresPropertiesOriginalContent)); err != nil { + t.Fatal(err) + } + if err := tempFile.Close(); err != nil { + t.Fatal(err) + } + + properties, err := readProperties(tempFile.Name()) + if err != nil { + t.Fatalf("readProperties() error = %v", err) + } + + if !reflect.DeepEqual(properties, postgresPropertiesOriginalMap) { + t.Errorf("readProperties() = %v, want %v", properties, postgresPropertiesOriginalMap) + } +} + +func TestWriteProperties(t *testing.T) { + tempFile, err := os.CreateTemp("", "test.properties") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tempFile.Name()) + + if err := writeProperties(tempFile.Name(), postgresPropertiesOriginalMap); err != nil { + t.Fatalf("writeProperties() error = %v", err) + } + + content, err := os.ReadFile(tempFile.Name()) + if err != nil { + t.Fatal(err) + } + + if string(content) != postgresPropertiesOriginalContent { + t.Errorf("writeProperties() = %v, want %v", string(content), postgresPropertiesOriginalContent) + } +} diff --git a/cli/azd/resources/scaffold/templates/resources.bicept b/cli/azd/resources/scaffold/templates/resources.bicept index 92d9826a550..7c2ac2b079b 100644 --- a/cli/azd/resources/scaffold/templates/resources.bicept +++ b/cli/azd/resources/scaffold/templates/resources.bicept @@ -199,7 +199,7 @@ module {{bicepName .Name}}FetchLatestImage './modules/fetch-container-image.bice name: '{{bicepName .Name}}-fetch-image' params: { exists: {{bicepName .Name}}Exists - name: '{{.Name}}' + name: '{{containerAppName .Name}}' } } @@ -217,7 +217,7 @@ var {{bicepName .Name}}Env = map(filter({{bicepName .Name}}AppSettingsArray, i = module {{bicepName .Name}} 'br/public:avm/res/app/container-app:0.8.0' = { name: '{{bicepName .Name}}' params: { - name: '{{.Name}}' + name: '{{containerAppName .Name}}' {{- if ne .Port 0}} ingressTargetPort: {{.Port}} {{- end}} @@ -353,7 +353,7 @@ module {{bicepName .Name}} 'br/public:avm/res/app/container-app:0.8.0' = { {{- range $i, $e := .Frontend.Backends}} { name: '{{upper .Name}}_BASE_URL' - value: 'https://{{.Name}}.${containerAppsEnvironment.outputs.defaultDomain}' + value: 'https://{{containerAppName .Name}}.${containerAppsEnvironment.outputs.defaultDomain}' } {{- end}} {{- end}}