diff --git a/cli/azd/internal/appdetect/appdetect.go b/cli/azd/internal/appdetect/appdetect.go
index e6103d3cbd7..0a1c282adbf 100644
--- a/cli/azd/internal/appdetect/appdetect.go
+++ b/cli/azd/internal/appdetect/appdetect.go
@@ -59,13 +59,16 @@ const (
PyFlask Dependency = "flask"
PyDjango Dependency = "django"
PyFastApi Dependency = "fastapi"
+
+ SpringFrontend Dependency = "springFrontend"
)
var WebUIFrameworks = map[Dependency]struct{}{
- JsReact: {},
- JsAngular: {},
- JsJQuery: {},
- JsVite: {},
+ JsReact: {},
+ JsAngular: {},
+ JsJQuery: {},
+ JsVite: {},
+ SpringFrontend: {},
}
func (f Dependency) Language() Language {
@@ -112,6 +115,7 @@ const (
DbMySql DatabaseDep = "mysql"
DbSqlServer DatabaseDep = "sqlserver"
DbRedis DatabaseDep = "redis"
+ DbCosmos DatabaseDep = "cosmos"
)
func (db DatabaseDep) Display() string {
@@ -126,11 +130,60 @@ func (db DatabaseDep) Display() string {
return "SQL Server"
case DbRedis:
return "Redis"
+ case DbCosmos:
+ return "Cosmos DB"
}
return ""
}
+//type AzureDep string
+
+type AzureDep interface {
+ ResourceDisplay() string
+}
+
+type AzureDepServiceBus struct {
+ Queues []string
+ IsJms bool
+}
+
+func (a AzureDepServiceBus) ResourceDisplay() string {
+ return "Azure Service Bus"
+}
+
+type AzureDepEventHubs struct {
+ EventHubsNamePropertyMap map[string]string
+ UseKafka bool
+ SpringBootVersion string
+}
+
+func (a AzureDepEventHubs) ResourceDisplay() string {
+ return "Azure Event Hubs"
+}
+
+type AzureDepStorageAccount struct {
+ ContainerNamePropertyMap map[string]string
+}
+
+func (a AzureDepStorageAccount) ResourceDisplay() string {
+ return "Azure Storage Account"
+}
+
+type Metadata struct {
+ ApplicationName string
+ DatabaseNameInPropertySpringDatasourceUrl map[DatabaseDep]string
+ ContainsDependencySpringCloudAzureStarter bool
+ ContainsDependencySpringCloudAzureStarterJdbcPostgresql bool
+ ContainsDependencySpringCloudAzureStarterJdbcMysql bool
+ ContainsDependencySpringCloudEurekaServer bool
+ ContainsDependencySpringCloudEurekaClient bool
+ ContainsDependencySpringCloudConfigServer bool
+ ContainsDependencySpringCloudConfigClient bool
+}
+
+const UnknownSpringBootVersion string = "unknownSpringBootVersion"
+
type Project struct {
// The language associated with the project.
Language Language
@@ -141,9 +194,18 @@ 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
+
+ // Experimental: Metadata inferred through heuristics while scanning the project.
+ Metadata Metadata
+
// The path to the project directory.
Path string
+ // Options for the project.
+ Options map[string]interface{}
+
// A short description of the detection rule applied.
DetectionRule string
diff --git a/cli/azd/internal/appdetect/java.go b/cli/azd/internal/appdetect/java.go
index fe6fec3ea65..424f805f2d3 100644
--- a/cli/azd/internal/appdetect/java.go
+++ b/cli/azd/internal/appdetect/java.go
@@ -2,20 +2,34 @@ package appdetect
import (
"context"
- "encoding/xml"
- "fmt"
"io/fs"
- "maps"
- "os"
+ "log"
"path/filepath"
- "slices"
"strings"
+
+ "github.com/azure/azure-dev/cli/azd/internal/tracing"
+ "github.com/azure/azure-dev/cli/azd/internal/tracing/fields"
)
type javaDetector struct {
- rootProjects []mavenProject
+ parentPoms []pom
+ mavenWrapperPaths []mavenWrapper
+}
+
+type mavenWrapper struct {
+ posixPath string
+ winPath string
}
+// JavaProjectOptionMavenParentPath The parent module path of the maven multi-module project
+const JavaProjectOptionMavenParentPath = "parentPath"
+
+// JavaProjectOptionPosixMavenWrapperPath The path to the maven wrapper script for POSIX systems
+const JavaProjectOptionPosixMavenWrapperPath = "posixMavenWrapperPath"
+
+// JavaProjectOptionWinMavenWrapperPath The path to the maven wrapper script for Windows systems
+const JavaProjectOptionWinMavenWrapperPath = "winMavenWrapperPath"
+
func (jd *javaDetector) Language() Language {
return Java
}
@@ -23,121 +37,61 @@ func (jd *javaDetector) Language() Language {
func (jd *javaDetector) DetectProject(ctx context.Context, path string, entries []fs.DirEntry) (*Project, error) {
for _, entry := range entries {
if strings.ToLower(entry.Name()) == "pom.xml" {
+ tracing.SetUsageAttributes(fields.AppInitJavaDetect.String("start"))
pomFile := filepath.Join(path, entry.Name())
- project, err := readMavenProject(pomFile)
+ mavenProject, err := toMavenProject(pomFile)
if err != nil {
- return nil, fmt.Errorf("error reading pom.xml: %w", err)
+ log.Printf("Please edit azure.yaml manually to satisfy your requirement. azd can not help you "+
+ "to that by detect your java project because error happened when reading pom.xml: %s. ", err)
+ return nil, nil
}
- if len(project.Modules) > 0 {
+ if len(mavenProject.pom.Modules) > 0 {
// This is a multi-module project, we will capture the analysis, but return nil
// to continue recursing
- jd.rootProjects = append(jd.rootProjects, *project)
+ jd.parentPoms = append(jd.parentPoms, mavenProject.pom)
+ jd.mavenWrapperPaths = append(jd.mavenWrapperPaths, mavenWrapper{
+ posixPath: detectMavenWrapper(path, "mvnw"),
+ winPath: detectMavenWrapper(path, "mvnw.cmd"),
+ })
return nil, nil
}
- var currentRoot *mavenProject
- for _, rootProject := range jd.rootProjects {
+ var parentPom *pom
+ var currentWrapper mavenWrapper
+ for i, parentPomItem := range jd.parentPoms {
// 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 {
- currentRoot = &rootProject
+ if inRoot := strings.HasPrefix(pomFile, parentPomItem.path); inRoot {
+ parentPom = &parentPomItem
+ currentWrapper = jd.mavenWrapperPaths[i]
}
}
- _ = currentRoot // use currentRoot here in the analysis
- result, err := detectDependencies(project, &Project{
+ project := Project{
Language: Java,
Path: path,
DetectionRule: "Inferred by presence of: pom.xml",
- })
- if err != nil {
- return nil, fmt.Errorf("detecting dependencies: %w", err)
+ }
+ detectAzureDependenciesByAnalyzingSpringBootProject(parentPom, &mavenProject.pom, &project)
+ if parentPom != nil {
+ project.Options = map[string]interface{}{
+ JavaProjectOptionMavenParentPath: parentPom.path,
+ JavaProjectOptionPosixMavenWrapperPath: currentWrapper.posixPath,
+ JavaProjectOptionWinMavenWrapperPath: currentWrapper.winPath,
+ }
}
- return result, nil
+ tracing.SetUsageAttributes(fields.AppInitJavaDetect.String("finish"))
+ return &project, nil
}
}
-
return nil, nil
}
-// 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
-}
-
-// 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"`
-}
-
-func readMavenProject(filePath string) (*mavenProject, error) {
- bytes, err := os.ReadFile(filePath)
- if err != nil {
- return nil, err
- }
-
- var project mavenProject
- if err := xml.Unmarshal(bytes, &project); err != nil {
- return nil, fmt.Errorf("parsing xml: %w", err)
- }
-
- project.path = filepath.Dir(filePath)
-
- return &project, nil
-}
-
-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" {
- databaseDepMap[DbMySql] = struct{}{}
- }
-
- if dep.GroupId == "org.postgresql" && dep.ArtifactId == "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))
- })
+func detectMavenWrapper(path string, executable string) string {
+ wrapperPath := filepath.Join(path, executable)
+ if fileExists(wrapperPath) {
+ return wrapperPath
}
-
- return project, nil
+ return ""
}
diff --git a/cli/azd/internal/appdetect/maven_command.go b/cli/azd/internal/appdetect/maven_command.go
new file mode 100644
index 00000000000..fb7b553e2e9
--- /dev/null
+++ b/cli/azd/internal/appdetect/maven_command.go
@@ -0,0 +1,220 @@
+package appdetect
+
+import (
+ "archive/zip"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+func getMvnCommand() (string, error) {
+ cwd, err := os.Getwd()
+ if err != nil {
+ return "", fmt.Errorf("can not get working directory")
+ }
+ return getMvnCommandFromPath(cwd)
+}
+
+func getMvnCommandFromPath(path string) (string, error) {
+ mvnwCommand, err := getMvnwCommand(path)
+ if err == nil {
+ return mvnwCommand, nil
+ }
+ if commandExistsInPath("mvn") {
+ return "mvn", nil
+ }
+ return getDownloadedMvnCommand()
+}
+
+func getMvnwCommand(path string) (string, error) {
+ mvnwCommand := "mvnw"
+ fileInfo, err := os.Stat(path)
+ if err != nil {
+ return "", err
+ }
+ dir := filepath.Dir(path)
+ if fileInfo.IsDir() {
+ dir = path
+ }
+ for {
+ commandPath := filepath.Join(dir, mvnwCommand)
+ if fileExists(commandPath) {
+ return commandPath, nil
+ }
+ parentDir := filepath.Dir(dir)
+ if parentDir == dir {
+ break
+ }
+ dir = parentDir
+ }
+ return "", fmt.Errorf("failed to find mvnw command in project")
+}
+
+const mavenVersion = "3.9.9"
+const mavenZipFileName = "apache-maven-" + mavenVersion + "-bin.zip"
+const mavenURL = "https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/" +
+ mavenVersion + "/" + mavenZipFileName
+
+func getDownloadedMvnCommand() (string, error) {
+ mavenCommand, err := getAzdMvnCommand(mavenVersion)
+ if err != nil {
+ return "", err
+ }
+ if fileExists(mavenCommand) {
+ log.Println("Skip downloading maven because it already exists.")
+ return mavenCommand, nil
+ }
+ log.Println("Downloading maven")
+ mavenDir, err := getAzdMvnDir()
+ if err != nil {
+ return "", err
+ }
+ if _, err := os.Stat(mavenDir); os.IsNotExist(err) {
+ err = os.MkdirAll(mavenDir, os.ModePerm)
+ if err != nil {
+ return "", fmt.Errorf("unable to create directory: %w", err)
+ }
+ }
+
+ mavenZipFilePath := filepath.Join(mavenDir, mavenZipFileName)
+ err = downloadMaven(mavenZipFilePath)
+ if err != nil {
+ return "", err
+ }
+ err = unzip(mavenZipFilePath, mavenDir)
+ if err != nil {
+ return "", fmt.Errorf("failed to unzip maven bin.zip: %w", err)
+ }
+ return mavenCommand, nil
+}
+
+func getAzdMvnDir() (string, error) {
+ userHome, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("unable to get user home directory: %w", err)
+ }
+ return filepath.Join(userHome, ".azd", "java", "maven"), nil
+}
+
+func getAzdMvnCommand(mavenVersion string) (string, error) {
+ mavenDir, err := getAzdMvnDir()
+ if err != nil {
+ return "", err
+ }
+ azdMvnCommand := filepath.Join(mavenDir, "apache-maven-"+mavenVersion, "bin", "mvn")
+ return azdMvnCommand, nil
+}
+
+func downloadMaven(filepath string) error {
+ out, err := os.Create(filepath)
+ if err != nil {
+ return err
+ }
+ defer func(out *os.File) {
+ err := out.Close()
+ if err != nil {
+ log.Println("failed to close file. %w", err)
+ }
+ }(out)
+
+ resp, err := http.Get(mavenURL)
+ if err != nil {
+ return err
+ }
+ defer func(Body io.ReadCloser) {
+ err := Body.Close()
+ if err != nil {
+ log.Println("failed to close ReadCloser. %w", err)
+ }
+ }(resp.Body)
+
+ _, err = io.Copy(out, resp.Body)
+ return err
+}
+
+func unzip(src string, destinationFolder string) error {
+ reader, err := zip.OpenReader(src)
+ if err != nil {
+ return err
+ }
+ defer func(reader *zip.ReadCloser) {
+ err := reader.Close()
+ if err != nil {
+ log.Println("failed to close ReadCloser. %w", err)
+ }
+ }(reader)
+
+ for _, file := range reader.File {
+ destinationPath, err := getValidDestPath(destinationFolder, file.Name)
+ if err != nil {
+ return err
+ }
+ if file.FileInfo().IsDir() {
+ err := os.MkdirAll(destinationPath, os.ModePerm)
+ if err != nil {
+ return err
+ }
+ } else {
+ if err = os.MkdirAll(filepath.Dir(destinationPath), os.ModePerm); err != nil {
+ return err
+ }
+
+ outFile, err := os.OpenFile(destinationPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode())
+ if err != nil {
+ return err
+ }
+ defer func(outFile *os.File) {
+ err := outFile.Close()
+ if err != nil {
+ log.Println("failed to close file. %w", err)
+ }
+ }(outFile)
+
+ rc, err := file.Open()
+ if err != nil {
+ return err
+ }
+ defer func(rc io.ReadCloser) {
+ err := rc.Close()
+ if err != nil {
+ log.Println("failed to close file. %w", err)
+ }
+ }(rc)
+
+ for {
+ _, err = io.CopyN(outFile, rc, 1_000_000)
+ if err != nil {
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ return err
+ }
+ }
+ }
+ }
+ return nil
+}
+
+func getValidDestPath(destinationFolder string, fileName string) (string, error) {
+ destinationPath := filepath.Clean(filepath.Join(destinationFolder, fileName))
+ if !strings.HasPrefix(destinationPath, destinationFolder+string(os.PathSeparator)) {
+ return "", fmt.Errorf("%s: illegal file path", fileName)
+ }
+ return destinationPath, nil
+}
+
+func fileExists(path string) bool {
+ if path == "" {
+ return false
+ }
+ if _, err := os.Stat(path); err == nil {
+ return true
+ } else {
+ return false
+ }
+}
diff --git a/cli/azd/internal/appdetect/maven_command_test.go b/cli/azd/internal/appdetect/maven_command_test.go
new file mode 100644
index 00000000000..6e39b927c56
--- /dev/null
+++ b/cli/azd/internal/appdetect/maven_command_test.go
@@ -0,0 +1,81 @@
+package appdetect
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestGetMvnwCommandInProject(t *testing.T) {
+ cases := []struct {
+ pomPath string
+ expected string
+ description string
+ }{
+ {"project1/pom.xml", "project1/mvnw", "Wrapper in same directory"},
+ {"project2/sub-dir/pom.xml", "project2/mvnw", "Wrapper in parent directory"},
+ {"project3/sub-dir/sub-sub-dir/pom.xml", "project3/mvnw", "Wrapper in grandparent directory"},
+ {"project4/pom.xml", "", "No wrapper found"},
+ }
+
+ for _, c := range cases {
+ t.Run(c.description, func(t *testing.T) {
+ tempDir, err := os.MkdirTemp("", "testdata")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func(path string) {
+ err := os.RemoveAll(path)
+ if err != nil {
+ t.Errorf("failed to remove temp directory")
+ }
+ }(tempDir)
+
+ pomPath := filepath.Join(tempDir, c.pomPath)
+ err = os.MkdirAll(filepath.Dir(pomPath), os.ModePerm)
+ if err != nil {
+ t.Errorf("failed to mkdir")
+ }
+ err = os.WriteFile(pomPath, []byte(""), 0600)
+ if err != nil {
+ t.Errorf("failed to write file")
+ }
+ if c.expected != "" {
+ expectedPath := filepath.Join(tempDir, c.expected)
+ err = os.WriteFile(expectedPath, []byte("#!/bin/sh"), 0600)
+ if err != nil {
+ t.Errorf("failed to write file")
+ }
+ }
+
+ result, _ := getMvnwCommand(pomPath)
+ expectedResult := ""
+ if c.expected != "" {
+ expectedResult = filepath.Join(tempDir, c.expected)
+ }
+ if result != expectedResult {
+ t.Errorf("getMvnw(%q) == %q, expected %q", pomPath, result, expectedResult)
+ }
+ })
+ }
+}
+
+func TestGetDownloadedMvnCommand(t *testing.T) {
+ maven, err := getDownloadedMvnCommand()
+ if err != nil {
+ t.Errorf("getDownloadedMvnCommand failed, %v", err)
+ }
+ if maven == "" {
+ t.Errorf("getDownloadedMvnCommand failed")
+ }
+}
+
+func TestGetMvnCommand(t *testing.T) {
+ maven, err := getMvnCommand()
+ if err != nil {
+ t.Errorf("getMvnCommand failed, %v", err)
+ }
+ if maven == "" {
+ t.Errorf("getMvnCommand failed")
+ }
+}
diff --git a/cli/azd/internal/appdetect/maven_project.go b/cli/azd/internal/appdetect/maven_project.go
new file mode 100644
index 00000000000..9b55d74dedf
--- /dev/null
+++ b/cli/azd/internal/appdetect/maven_project.go
@@ -0,0 +1,15 @@
+package appdetect
+
+type mavenProject struct {
+ pom pom
+}
+
+func toMavenProject(pomFilePath string) (*mavenProject, error) {
+ pom, err := toPom(pomFilePath)
+ if err != nil {
+ return nil, err
+ }
+ return &mavenProject{
+ pom: *pom,
+ }, nil
+}
diff --git a/cli/azd/internal/appdetect/pom.go b/cli/azd/internal/appdetect/pom.go
new file mode 100644
index 00000000000..03613477955
--- /dev/null
+++ b/cli/azd/internal/appdetect/pom.go
@@ -0,0 +1,162 @@
+package appdetect
+
+import (
+ "bufio"
+ "encoding/xml"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "strings"
+)
+
+// pom represents the top-level structure of a Maven POM file.
+type pom struct {
+ XmlName xml.Name `xml:"project"`
+ Parent parent `xml:"parent"`
+ Modules []string `xml:"modules>module"` // Capture the modules
+ Properties Properties `xml:"properties"`
+ Dependencies []dependency `xml:"dependencies>dependency"`
+ DependencyManagement dependencyManagement `xml:"dependencyManagement"`
+ Build build `xml:"build"`
+ path string
+}
+
+// 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"`
+}
+
+type Properties struct {
+ Entries []Property `xml:",any"` // Capture all elements inside
+}
+
+type Property struct {
+ XMLName xml.Name
+ Value string `xml:",chardata"`
+}
+
+// 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"`
+}
+
+func toPom(filePath string) (*pom, error) {
+ bytes, err := os.ReadFile(filePath)
+ if err != nil {
+ return nil, err
+ }
+
+ var unmarshalledPom pom
+ if err := xml.Unmarshal(bytes, &unmarshalledPom); err != nil {
+ return nil, fmt.Errorf("parsing xml: %w", err)
+ }
+
+ // replace all placeholders with properties
+ str := replaceAllPlaceholders(unmarshalledPom, string(bytes))
+
+ var resultPom pom
+ if err := xml.Unmarshal([]byte(str), &resultPom); err != nil {
+ return nil, fmt.Errorf("parsing xml: %w", err)
+ }
+
+ resultPom.path = filepath.Dir(filePath)
+
+ return &resultPom, nil
+}
+
+func replaceAllPlaceholders(pom pom, input string) string {
+ propsMap := parseProperties(pom.Properties)
+
+ re := regexp.MustCompile(`\$\{([A-Za-z0-9-_.]+)}`)
+ return re.ReplaceAllStringFunc(input, func(match string) string {
+ // Extract the key inside ${}
+ key := re.FindStringSubmatch(match)[1]
+ if value, exists := propsMap[key]; exists {
+ return value
+ }
+ return match
+ })
+}
+
+func parseProperties(properties Properties) map[string]string {
+ result := make(map[string]string)
+ for _, entry := range properties.Entries {
+ result[entry.XMLName.Local] = entry.Value
+ }
+ return result
+}
+
+func toEffectivePom(pomPath string) (pom, error) {
+ if !commandExistsInPath("java") {
+ return pom{}, fmt.Errorf("can not get effective pom because java command not exist")
+ }
+ mvn, err := getMvnCommandFromPath(pomPath)
+ if err != nil {
+ return pom{}, err
+ }
+ cmd := exec.Command(mvn, "help:effective-pom", "-f", pomPath)
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return pom{}, err
+ }
+ effectivePom, err := getEffectivePomFromConsoleOutput(string(output))
+ if err != nil {
+ return pom{}, err
+ }
+ var project pom
+ if err := xml.Unmarshal([]byte(effectivePom), &project); err != nil {
+ return pom{}, fmt.Errorf("parsing xml: %w", err)
+ }
+ return project, nil
+}
+
+func commandExistsInPath(command string) bool {
+ _, err := exec.LookPath(command)
+ return err == nil
+}
+
+func getEffectivePomFromConsoleOutput(consoleOutput string) (string, error) {
+ var effectivePom strings.Builder
+ scanner := bufio.NewScanner(strings.NewReader(consoleOutput))
+ inProject := false
+ for scanner.Scan() {
+ line := scanner.Text()
+ if strings.HasPrefix(strings.TrimSpace(line), "") {
+ effectivePom.WriteString(line)
+ break
+ }
+ if inProject {
+ effectivePom.WriteString(line)
+ }
+ }
+ if err := scanner.Err(); err != nil {
+ return "", fmt.Errorf("failed to scan console output. %w", err)
+ }
+ return effectivePom.String(), nil
+}
diff --git a/cli/azd/internal/appdetect/pom_test.go b/cli/azd/internal/appdetect/pom_test.go
new file mode 100644
index 00000000000..ac0f3defa4f
--- /dev/null
+++ b/cli/azd/internal/appdetect/pom_test.go
@@ -0,0 +1,235 @@
+package appdetect
+
+import (
+ "encoding/xml"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestReplaceAllPlaceholders(t *testing.T) {
+ tests := []struct {
+ name string
+ pom pom
+ input string
+ output string
+ }{
+ {
+ "empty.input",
+ pom{
+ Properties: Properties{
+ Entries: []Property{
+ {
+ XMLName: xml.Name{
+ Local: "version.spring-boot_2.x",
+ },
+ Value: "2.x",
+ },
+ },
+ },
+ },
+ "",
+ "",
+ },
+ {
+ "empty.properties",
+ pom{
+ Properties: Properties{
+ Entries: []Property{},
+ },
+ },
+ "org.springframework.boot:spring-boot-dependencies:${version.spring-boot_2.x}",
+ "org.springframework.boot:spring-boot-dependencies:${version.spring-boot_2.x}",
+ },
+ {
+ "dependency.version",
+ pom{
+ Properties: Properties{
+ Entries: []Property{
+ {
+ XMLName: xml.Name{
+ Local: "version.spring-boot_2.x",
+ },
+ Value: "2.x",
+ },
+ },
+ },
+ },
+ "org.springframework.boot:spring-boot-dependencies:${version.spring-boot_2.x}",
+ "org.springframework.boot:spring-boot-dependencies:2.x",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ output := replaceAllPlaceholders(tt.pom, tt.input)
+ assert.Equal(t, tt.output, output)
+ })
+ }
+}
+
+func TestToEffectivePom(t *testing.T) {
+ tests := []struct {
+ name string
+ pomContent string
+ expected []dependency
+ }{
+ {
+ name: "Test with two dependencies",
+ pomContent: `
+
+ 4.0.0
+ com.example
+ example-project
+ 1.0.0
+
+
+ org.springframework
+ spring-core
+ 5.3.8
+ compile
+
+
+ junit
+ junit
+ 4.13.2
+ test
+
+
+
+ `,
+ expected: []dependency{
+ {
+ GroupId: "org.springframework",
+ ArtifactId: "spring-core",
+ Version: "5.3.8",
+ Scope: "compile",
+ },
+ {
+ GroupId: "junit",
+ ArtifactId: "junit",
+ Version: "4.13.2",
+ Scope: "test",
+ },
+ },
+ },
+ {
+ name: "Test with no dependencies",
+ pomContent: `
+
+ 4.0.0
+ com.example
+ example-project
+ 1.0.0
+
+
+
+ `,
+ expected: []dependency{},
+ },
+ {
+ name: "Test with one dependency which version is decided by dependencyManagement",
+ pomContent: `
+
+ 4.0.0
+ com.example
+ example-project
+ 1.0.0
+
+
+ org.slf4j
+ slf4j-api
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-dependencies
+ 3.0.0
+ pom
+ import
+
+
+
+
+ `,
+ expected: []dependency{
+ {
+ GroupId: "org.slf4j",
+ ArtifactId: "slf4j-api",
+ Version: "2.0.4",
+ Scope: "compile",
+ },
+ },
+ },
+ {
+ name: "Test with one dependency which version is decided by parent",
+ pomContent: `
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.0.0
+
+
+ 4.0.0
+ com.example
+ example-project
+ 1.0.0
+
+
+ org.slf4j
+ slf4j-api
+
+
+
+ `,
+ expected: []dependency{
+ {
+ GroupId: "org.slf4j",
+ ArtifactId: "slf4j-api",
+ Version: "2.0.4",
+ Scope: "compile",
+ },
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tempDir, err := os.MkdirTemp("", "test")
+ if err != nil {
+ t.Fatalf("Failed to create temp directory: %v", err)
+ }
+ defer func(path string) {
+ err := os.RemoveAll(path)
+ if err != nil {
+ t.Fatalf("Failed to remove all in directory: %v", err)
+ }
+ }(tempDir)
+
+ pomPath := filepath.Join(tempDir, "pom.xml")
+ err = os.WriteFile(pomPath, []byte(tt.pomContent), 0600)
+ if err != nil {
+ t.Fatalf("Failed to write temp POM file: %v", err)
+ }
+
+ effectivePom, err := toEffectivePom(pomPath)
+ if err != nil {
+ t.Fatalf("toEffectivePom failed: %v", err)
+ }
+
+ if len(effectivePom.Dependencies) != len(tt.expected) {
+ t.Fatalf("Expected %d dependencies, got %d", len(tt.expected), len(effectivePom.Dependencies))
+ }
+
+ for i, dep := range effectivePom.Dependencies {
+ if dep != tt.expected[i] {
+ t.Errorf("Expected dependency %v, got %v", tt.expected[i], dep)
+ }
+ }
+ })
+ }
+}
diff --git a/cli/azd/internal/appdetect/spring_boot.go b/cli/azd/internal/appdetect/spring_boot.go
new file mode 100644
index 00000000000..ac6f96febbc
--- /dev/null
+++ b/cli/azd/internal/appdetect/spring_boot.go
@@ -0,0 +1,598 @@
+package appdetect
+
+import (
+ "fmt"
+ "log"
+ "maps"
+ "path/filepath"
+ "regexp"
+ "slices"
+ "strings"
+)
+
+type SpringBootProject struct {
+ springBootVersion string // todo: delete this, because it's only used once.
+ applicationProperties map[string]string
+ pom pom
+}
+
+type DatabaseDependencyRule struct {
+ databaseDep DatabaseDep
+ mavenDependencies []MavenDependency
+}
+
+type MavenDependency struct {
+ groupId string
+ artifactId string
+}
+
+var databaseDependencyRules = []DatabaseDependencyRule{
+ {
+ databaseDep: DbPostgres,
+ mavenDependencies: []MavenDependency{
+ {
+ groupId: "org.postgresql",
+ artifactId: "postgresql",
+ },
+ {
+ groupId: "com.azure.spring",
+ artifactId: "spring-cloud-azure-starter-jdbc-postgresql",
+ },
+ },
+ },
+ {
+ databaseDep: DbMySql,
+ mavenDependencies: []MavenDependency{
+ {
+ groupId: "com.mysql",
+ artifactId: "mysql-connector-j",
+ },
+ {
+ groupId: "com.azure.spring",
+ artifactId: "spring-cloud-azure-starter-jdbc-mysql",
+ },
+ },
+ },
+ {
+ databaseDep: DbRedis,
+ mavenDependencies: []MavenDependency{
+ {
+ groupId: "org.springframework.boot",
+ artifactId: "spring-boot-starter-data-redis",
+ },
+ {
+ groupId: "org.springframework.boot",
+ artifactId: "spring-boot-starter-data-redis-reactive",
+ },
+ },
+ },
+ {
+ databaseDep: DbMongo,
+ mavenDependencies: []MavenDependency{
+ {
+ groupId: "org.springframework.boot",
+ artifactId: "spring-boot-starter-data-mongodb",
+ },
+ {
+ groupId: "org.springframework.boot",
+ artifactId: "spring-boot-starter-data-mongodb-reactive",
+ },
+ },
+ },
+ {
+ databaseDep: DbCosmos,
+ mavenDependencies: []MavenDependency{
+ {
+ groupId: "com.azure.spring",
+ artifactId: "spring-cloud-azure-starter-data-cosmos",
+ },
+ },
+ },
+}
+
+// todo: remove parentPom, when passed in the pom is the effective pom.
+func detectAzureDependenciesByAnalyzingSpringBootProject(parentPom *pom, currentPom *pom, azdProject *Project) {
+ effectivePom, err := toEffectivePom(filepath.Join(currentPom.path, "pom.xml"))
+ if err == nil {
+ currentPom = &effectivePom
+ }
+ if !isSpringBootApplication(currentPom) {
+ log.Printf("Skip analyzing spring boot project. path = %s.", currentPom.path)
+ return
+ }
+ var springBootProject = SpringBootProject{
+ springBootVersion: detectSpringBootVersion(parentPom, currentPom),
+ applicationProperties: readProperties(azdProject.Path),
+ pom: *currentPom,
+ }
+ detectDatabases(azdProject, &springBootProject)
+ detectServiceBus(azdProject, &springBootProject)
+ detectEventHubs(azdProject, &springBootProject)
+ detectStorageAccount(azdProject, &springBootProject)
+ detectMetadata(azdProject, &springBootProject)
+ detectSpringFrontend(azdProject, &springBootProject)
+}
+
+func detectSpringFrontend(azdProject *Project, springBootProject *SpringBootProject) {
+ for _, p := range springBootProject.pom.Build.Plugins {
+ if p.GroupId == "com.github.eirslett" && p.ArtifactId == "frontend-maven-plugin" {
+ azdProject.Dependencies = append(azdProject.Dependencies, SpringFrontend)
+ break
+ }
+ }
+}
+
+func detectDatabases(azdProject *Project, springBootProject *SpringBootProject) {
+ databaseDepMap := map[DatabaseDep]struct{}{}
+ for _, rule := range databaseDependencyRules {
+ for _, targetDependency := range rule.mavenDependencies {
+ var targetGroupId = targetDependency.groupId
+ var targetArtifactId = targetDependency.artifactId
+ if hasDependency(springBootProject, targetGroupId, targetArtifactId) {
+ databaseDepMap[rule.databaseDep] = struct{}{}
+ logServiceAddedAccordingToMavenDependency(rule.databaseDep.Display(),
+ targetGroupId, targetArtifactId)
+ break
+ }
+ }
+ }
+ if len(databaseDepMap) > 0 {
+ azdProject.DatabaseDeps = slices.SortedFunc(maps.Keys(databaseDepMap),
+ func(a, b DatabaseDep) int {
+ return strings.Compare(string(a), string(b))
+ })
+ }
+}
+
+func detectServiceBus(azdProject *Project, springBootProject *SpringBootProject) {
+ // we need to figure out multiple projects are using the same service bus
+ detectServiceBusAccordingToJMSMavenDependency(azdProject, springBootProject)
+ detectServiceBusAccordingToSpringCloudStreamBinderMavenDependency(azdProject, springBootProject)
+}
+
+func detectServiceBusAccordingToJMSMavenDependency(azdProject *Project, springBootProject *SpringBootProject) {
+ var targetGroupId = "com.azure.spring"
+ var targetArtifactId = "spring-cloud-azure-starter-servicebus-jms"
+ if hasDependency(springBootProject, targetGroupId, targetArtifactId) {
+ newDependency := AzureDepServiceBus{
+ IsJms: true,
+ }
+ azdProject.AzureDeps = append(azdProject.AzureDeps, newDependency)
+ logServiceAddedAccordingToMavenDependency(newDependency.ResourceDisplay(), targetGroupId, targetArtifactId)
+ }
+}
+
+func detectServiceBusAccordingToSpringCloudStreamBinderMavenDependency(
+ azdProject *Project, springBootProject *SpringBootProject) {
+ var targetGroupId = "com.azure.spring"
+ var targetArtifactId = "spring-cloud-azure-stream-binder-servicebus"
+ if hasDependency(springBootProject, targetGroupId, targetArtifactId) {
+ bindingDestinations := getBindingDestinationMap(springBootProject.applicationProperties)
+ var destinations = DistinctValues(bindingDestinations)
+ newDep := AzureDepServiceBus{
+ Queues: destinations,
+ IsJms: false,
+ }
+ azdProject.AzureDeps = append(azdProject.AzureDeps, newDep)
+ logServiceAddedAccordingToMavenDependency(newDep.ResourceDisplay(), targetGroupId, targetArtifactId)
+ for bindingName, destination := range bindingDestinations {
+ log.Printf(" Detected Service Bus queue [%s] for binding [%s] by analyzing property file.",
+ destination, bindingName)
+ }
+ }
+}
+
+func detectEventHubs(azdProject *Project, springBootProject *SpringBootProject) {
+ // we need to figure out multiple projects are using the same event hub
+ detectEventHubsAccordingToSpringCloudStreamBinderMavenDependency(azdProject, springBootProject)
+ detectEventHubsAccordingToSpringCloudEventhubsStarterDependency(azdProject, springBootProject)
+ detectEventHubsAccordingToSpringCloudStreamKafkaMavenDependency(azdProject, springBootProject)
+}
+
+func detectEventHubsAccordingToSpringCloudStreamBinderMavenDependency(
+ azdProject *Project, springBootProject *SpringBootProject) {
+ var targetGroupId = "com.azure.spring"
+ var targetArtifactId = "spring-cloud-azure-stream-binder-eventhubs"
+ if hasDependency(springBootProject, targetGroupId, targetArtifactId) {
+ bindingDestinations := getBindingDestinationMap(springBootProject.applicationProperties)
+ newDep := AzureDepEventHubs{
+ EventHubsNamePropertyMap: bindingDestinations,
+ UseKafka: false,
+ }
+ azdProject.AzureDeps = append(azdProject.AzureDeps, newDep)
+ logServiceAddedAccordingToMavenDependency(newDep.ResourceDisplay(), targetGroupId, targetArtifactId)
+ for bindingName, destination := range bindingDestinations {
+ log.Printf(" Detected Event Hub [%s] for binding [%s] by analyzing property file.",
+ destination, bindingName)
+ }
+ }
+}
+
+func detectEventHubsAccordingToSpringCloudEventhubsStarterDependency(
+ azdProject *Project, springBootProject *SpringBootProject) {
+ var targetGroupId = "com.azure.spring"
+ var targetArtifactId = "spring-cloud-azure-starter-eventhubs"
+ var targetPropertyName = "spring.cloud.azure.eventhubs.event-hub-name"
+ if hasDependency(springBootProject, targetGroupId, targetArtifactId) {
+ eventHubsNamePropertyMap := map[string]string{
+ targetPropertyName: springBootProject.applicationProperties[targetPropertyName],
+ }
+ newDep := AzureDepEventHubs{
+ EventHubsNamePropertyMap: eventHubsNamePropertyMap,
+ UseKafka: false,
+ }
+ azdProject.AzureDeps = append(azdProject.AzureDeps, newDep)
+ logServiceAddedAccordingToMavenDependency(newDep.ResourceDisplay(), targetGroupId, targetArtifactId)
+ for property, name := range eventHubsNamePropertyMap {
+ log.Printf(" Detected Event Hub [%s] for [%s] by analyzing property file.", property, name)
+ }
+ }
+}
+
+func detectEventHubsAccordingToSpringCloudStreamKafkaMavenDependency(
+ azdProject *Project, springBootProject *SpringBootProject) {
+ var targetGroupId = "org.springframework.cloud"
+ var targetArtifactId = "spring-cloud-starter-stream-kafka"
+ if hasDependency(springBootProject, targetGroupId, targetArtifactId) {
+ bindingDestinations := getBindingDestinationMap(springBootProject.applicationProperties)
+ newDep := AzureDepEventHubs{
+ EventHubsNamePropertyMap: bindingDestinations,
+ UseKafka: true,
+ SpringBootVersion: springBootProject.springBootVersion,
+ }
+ azdProject.AzureDeps = append(azdProject.AzureDeps, newDep)
+ logServiceAddedAccordingToMavenDependency(newDep.ResourceDisplay(), targetGroupId, targetArtifactId)
+ for bindingName, destination := range bindingDestinations {
+ log.Printf(" Detected Kafka Topic [%s] for binding [%s] by analyzing property file.",
+ destination, bindingName)
+ }
+ }
+}
+
+func detectStorageAccount(azdProject *Project, springBootProject *SpringBootProject) {
+ detectStorageAccountAccordingToSpringCloudStreamBinderMavenDependencyAndProperty(azdProject, springBootProject)
+}
+
+func detectStorageAccountAccordingToSpringCloudStreamBinderMavenDependencyAndProperty(
+ azdProject *Project, springBootProject *SpringBootProject) {
+ var targetGroupId = "com.azure.spring"
+ var targetArtifactId = "spring-cloud-azure-stream-binder-eventhubs"
+ var targetPropertyName = "spring.cloud.azure.eventhubs.processor.checkpoint-store.container-name"
+ if hasDependency(springBootProject, targetGroupId, targetArtifactId) {
+ bindingDestinations := getBindingDestinationMap(springBootProject.applicationProperties)
+ containsInBindingName := ""
+ for bindingName := range bindingDestinations {
+ if strings.Contains(bindingName, "-in-") { // Example: consume-in-0
+ containsInBindingName = bindingName
+ break
+ }
+ }
+ if containsInBindingName != "" {
+ containerNamePropertyMap := make(map[string]string)
+ for key, value := range springBootProject.applicationProperties {
+ if strings.HasSuffix(key, targetPropertyName) {
+ containerNamePropertyMap[key] = value
+ }
+ }
+ newDep := AzureDepStorageAccount{
+ ContainerNamePropertyMap: containerNamePropertyMap,
+ }
+ azdProject.AzureDeps = append(azdProject.AzureDeps, newDep)
+ logServiceAddedAccordingToMavenDependencyAndExtraCondition(newDep.ResourceDisplay(), targetGroupId,
+ targetArtifactId, "binding name ["+containsInBindingName+"] contains '-in-'")
+ for property, containerName := range containerNamePropertyMap {
+ log.Printf(" Detected Storage container name: [%s] for [%s] by analyzing property file.",
+ containerName, property)
+ }
+ }
+ }
+}
+
+func detectMetadata(azdProject *Project, springBootProject *SpringBootProject) {
+ detectPropertySpringApplicationName(azdProject, springBootProject)
+ detectPropertySpringCloudAzureCosmosDatabase(azdProject, springBootProject)
+ detectPropertySpringDataMongodbDatabase(azdProject, springBootProject)
+ detectPropertySpringDataMongodbUri(azdProject, springBootProject)
+ detectPropertySpringDatasourceUrl(azdProject, springBootProject)
+
+ detectDependencySpringCloudAzureStarter(azdProject, springBootProject)
+ detectDependencySpringCloudAzureStarterJdbcMysql(azdProject, springBootProject)
+ detectDependencySpringCloudAzureStarterJdbcPostgresql(azdProject, springBootProject)
+ detectDependencySpringCloudConfig(azdProject, springBootProject)
+ detectDependencySpringCloudEureka(azdProject, springBootProject)
+}
+
+func detectPropertySpringCloudAzureCosmosDatabase(azdProject *Project, springBootProject *SpringBootProject) {
+ var targetPropertyName = "spring.cloud.azure.cosmos.database"
+ propertyValue, ok := springBootProject.applicationProperties[targetPropertyName]
+ if !ok {
+ log.Printf("%s property not exist in project. Path = %s", targetPropertyName, azdProject.Path)
+ return
+ }
+ databaseName := ""
+ if IsValidDatabaseName(propertyValue) {
+ databaseName = propertyValue
+ } else {
+ return
+ }
+ if azdProject.Metadata.DatabaseNameInPropertySpringDatasourceUrl == nil {
+ azdProject.Metadata.DatabaseNameInPropertySpringDatasourceUrl = map[DatabaseDep]string{}
+ }
+ if azdProject.Metadata.DatabaseNameInPropertySpringDatasourceUrl[DbCosmos] == "" {
+ // spring.data.mongodb.database has lower priority than spring.data.mongodb.uri
+ azdProject.Metadata.DatabaseNameInPropertySpringDatasourceUrl[DbCosmos] = databaseName
+ }
+}
+
+func detectPropertySpringDatasourceUrl(azdProject *Project, springBootProject *SpringBootProject) {
+ var targetPropertyName = "spring.datasource.url"
+ propertyValue, ok := springBootProject.applicationProperties[targetPropertyName]
+ if !ok {
+ log.Printf("%s property not exist in project. Path = %s", targetPropertyName, azdProject.Path)
+ return
+ }
+ databaseName := getDatabaseName(propertyValue)
+ if databaseName == "" {
+ log.Printf("can not get database name from property: %s", targetPropertyName)
+ return
+ }
+ if azdProject.Metadata.DatabaseNameInPropertySpringDatasourceUrl == nil {
+ azdProject.Metadata.DatabaseNameInPropertySpringDatasourceUrl = map[DatabaseDep]string{}
+ }
+ if strings.HasPrefix(propertyValue, "jdbc:postgresql") {
+ azdProject.Metadata.DatabaseNameInPropertySpringDatasourceUrl[DbPostgres] = databaseName
+ } else if strings.HasPrefix(propertyValue, "jdbc:mysql") {
+ azdProject.Metadata.DatabaseNameInPropertySpringDatasourceUrl[DbMySql] = databaseName
+ }
+}
+
+func detectPropertySpringDataMongodbUri(azdProject *Project, springBootProject *SpringBootProject) {
+ var targetPropertyName = "spring.data.mongodb.uri"
+ propertyValue, ok := springBootProject.applicationProperties[targetPropertyName]
+ if !ok {
+ log.Printf("%s property not exist in project. Path = %s", targetPropertyName, azdProject.Path)
+ return
+ }
+ databaseName := getDatabaseName(propertyValue)
+ if databaseName == "" {
+ log.Printf("can not get database name from property: %s", targetPropertyName)
+ return
+ }
+ if azdProject.Metadata.DatabaseNameInPropertySpringDatasourceUrl == nil {
+ azdProject.Metadata.DatabaseNameInPropertySpringDatasourceUrl = map[DatabaseDep]string{}
+ }
+ azdProject.Metadata.DatabaseNameInPropertySpringDatasourceUrl[DbMongo] = databaseName
+}
+
+func detectPropertySpringDataMongodbDatabase(azdProject *Project, springBootProject *SpringBootProject) {
+ var targetPropertyName = "spring.data.mongodb.database"
+ propertyValue, ok := springBootProject.applicationProperties[targetPropertyName]
+ if !ok {
+ log.Printf("%s property not exist in project. Path = %s", targetPropertyName, azdProject.Path)
+ return
+ }
+ databaseName := ""
+ if IsValidDatabaseName(propertyValue) {
+ databaseName = propertyValue
+ } else {
+ return
+ }
+ if azdProject.Metadata.DatabaseNameInPropertySpringDatasourceUrl == nil {
+ azdProject.Metadata.DatabaseNameInPropertySpringDatasourceUrl = map[DatabaseDep]string{}
+ }
+ if azdProject.Metadata.DatabaseNameInPropertySpringDatasourceUrl[DbMongo] == "" {
+ // spring.data.mongodb.database has lower priority than spring.data.mongodb.uri
+ azdProject.Metadata.DatabaseNameInPropertySpringDatasourceUrl[DbMongo] = databaseName
+ }
+}
+
+func getDatabaseName(datasourceURL string) string {
+ lastSlashIndex := strings.LastIndex(datasourceURL, "/")
+ if lastSlashIndex == -1 {
+ return ""
+ }
+ result := datasourceURL[lastSlashIndex+1:]
+ if idx := strings.Index(result, "?"); idx != -1 {
+ result = result[:idx]
+ }
+ if IsValidDatabaseName(result) {
+ return result
+ }
+ return ""
+}
+
+func IsValidDatabaseName(name string) bool {
+ if len(name) < 3 || len(name) > 63 {
+ return false
+ }
+ re := regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
+ return re.MatchString(name)
+}
+
+func detectDependencySpringCloudAzureStarter(azdProject *Project, springBootProject *SpringBootProject) {
+ var targetGroupId = "com.azure.spring"
+ var targetArtifactId = "spring-cloud-azure-starter"
+ if hasDependency(springBootProject, targetGroupId, targetArtifactId) {
+ azdProject.Metadata.ContainsDependencySpringCloudAzureStarter = true
+ logMetadataUpdated("ContainsDependencySpringCloudAzureStarter = true")
+ }
+}
+
+func detectDependencySpringCloudAzureStarterJdbcPostgresql(azdProject *Project, springBootProject *SpringBootProject) {
+ var targetGroupId = "com.azure.spring"
+ var targetArtifactId = "spring-cloud-azure-starter-jdbc-postgresql"
+ if hasDependency(springBootProject, targetGroupId, targetArtifactId) {
+ azdProject.Metadata.ContainsDependencySpringCloudAzureStarterJdbcPostgresql = true
+ logMetadataUpdated("ContainsDependencySpringCloudAzureStarterJdbcPostgresql = true")
+ }
+}
+
+func detectDependencySpringCloudAzureStarterJdbcMysql(azdProject *Project, springBootProject *SpringBootProject) {
+ var targetGroupId = "com.azure.spring"
+ var targetArtifactId = "spring-cloud-azure-starter-jdbc-mysql"
+ if hasDependency(springBootProject, targetGroupId, targetArtifactId) {
+ azdProject.Metadata.ContainsDependencySpringCloudAzureStarterJdbcMysql = true
+ logMetadataUpdated("ContainsDependencySpringCloudAzureStarterJdbcMysql = true")
+ }
+}
+
+func detectPropertySpringApplicationName(azdProject *Project, springBootProject *SpringBootProject) {
+ var targetPropertyName = "spring.application.name"
+ if appName, ok := springBootProject.applicationProperties[targetPropertyName]; ok {
+ azdProject.Metadata.ApplicationName = appName
+ }
+}
+
+func detectDependencySpringCloudEureka(azdProject *Project, springBootProject *SpringBootProject) {
+ var targetGroupId = "org.springframework.cloud"
+ var targetArtifactId = "spring-cloud-starter-netflix-eureka-server"
+ if hasDependency(springBootProject, targetGroupId, targetArtifactId) {
+ azdProject.Metadata.ContainsDependencySpringCloudEurekaServer = true
+ logMetadataUpdated("ContainsDependencySpringCloudEurekaServer = true")
+ }
+
+ targetGroupId = "org.springframework.cloud"
+ targetArtifactId = "spring-cloud-starter-netflix-eureka-client"
+ if hasDependency(springBootProject, targetGroupId, targetArtifactId) {
+ azdProject.Metadata.ContainsDependencySpringCloudEurekaClient = true
+ logMetadataUpdated("ContainsDependencySpringCloudEurekaClient = true")
+ }
+}
+
+func detectDependencySpringCloudConfig(azdProject *Project, springBootProject *SpringBootProject) {
+ var targetGroupId = "org.springframework.cloud"
+ var targetArtifactId = "spring-cloud-config-server"
+ if hasDependency(springBootProject, targetGroupId, targetArtifactId) {
+ azdProject.Metadata.ContainsDependencySpringCloudConfigServer = true
+ logMetadataUpdated("ContainsDependencySpringCloudConfigServer = true")
+ }
+
+ targetGroupId = "org.springframework.cloud"
+ targetArtifactId = "spring-cloud-starter-config"
+ if hasDependency(springBootProject, targetGroupId, targetArtifactId) {
+ azdProject.Metadata.ContainsDependencySpringCloudConfigClient = true
+ logMetadataUpdated("ContainsDependencySpringCloudConfigClient = true")
+ }
+}
+
+func logServiceAddedAccordingToMavenDependency(resourceName, groupId string, artifactId string) {
+ logServiceAddedAccordingToMavenDependencyAndExtraCondition(resourceName, groupId, artifactId, "")
+}
+
+func logServiceAddedAccordingToMavenDependencyAndExtraCondition(
+ resourceName, groupId string, artifactId string, extraCondition string) {
+ insertedString := ""
+ extraCondition = strings.TrimSpace(extraCondition)
+ if extraCondition != "" {
+ insertedString = " and " + extraCondition
+ }
+ log.Printf("Detected '%s' because found dependency '%s:%s' in pom.xml file%s.",
+ resourceName, groupId, artifactId, insertedString)
+}
+
+func logMetadataUpdated(info string) {
+ log.Printf("Metadata updated. %s.", info)
+}
+
+func detectSpringBootVersion(parentPom *pom, currentPom *pom) string {
+ // currentPom prioritize than parentPom
+ if currentPom != nil {
+ if version := detectSpringBootVersionFromPom(currentPom); version != UnknownSpringBootVersion {
+ return version
+ }
+ }
+ // fallback to detect parentPom
+ if parentPom != nil {
+ return detectSpringBootVersionFromPom(parentPom)
+ }
+ return UnknownSpringBootVersion
+}
+
+func detectSpringBootVersionFromPom(pom *pom) string {
+ if pom.Parent.ArtifactId == "spring-boot-starter-parent" {
+ return pom.Parent.Version
+ } else {
+ for _, dep := range pom.DependencyManagement.Dependencies {
+ if dep.ArtifactId == "spring-boot-dependencies" {
+ return dep.Version
+ }
+ }
+ for _, dep := range pom.Dependencies {
+ if dep.GroupId == "org.springframework.boot" {
+ return dep.Version
+ }
+ }
+ }
+ return UnknownSpringBootVersion
+}
+
+func isSpringBootApplication(pom *pom) bool {
+ // how can we tell it's a Spring Boot project?
+ // 1. It has a parent with a groupId of org.springframework.boot and an artifactId of spring-boot-starter-parent
+ // 2. It has a dependency management with a groupId of org.springframework.boot and an artifactId of
+ // spring-boot-dependencies
+ // 3. It has a dependency with a groupId of org.springframework.boot and an artifactId that starts with
+ // spring-boot-starter
+ if pom.Parent.GroupId == "org.springframework.boot" &&
+ pom.Parent.ArtifactId == "spring-boot-starter-parent" {
+ return true
+ }
+ for _, dep := range pom.DependencyManagement.Dependencies {
+ if dep.GroupId == "org.springframework.boot" &&
+ dep.ArtifactId == "spring-boot-dependencies" {
+ return true
+ }
+ }
+ for _, dep := range pom.Dependencies {
+ if dep.GroupId == "org.springframework.boot" &&
+ strings.HasPrefix(dep.ArtifactId, "spring-boot-starter") { // maybe delete condition of this line
+ return true
+ }
+ }
+ for _, dep := range pom.Build.Plugins {
+ if dep.GroupId == "org.springframework.boot" &&
+ dep.ArtifactId == "spring-boot-maven-plugin" {
+ return true
+ }
+ }
+ return false
+}
+
+func DistinctValues(input map[string]string) []string {
+ valueSet := make(map[string]struct{})
+ for _, value := range input {
+ valueSet[value] = struct{}{}
+ }
+
+ var result []string
+ for value := range valueSet {
+ result = append(result, value)
+ }
+
+ return result
+}
+
+// Function to find all properties that match the pattern `spring.cloud.stream.bindings..destination`
+func getBindingDestinationMap(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") {
+ // Store the binding name and destination value
+ result[key] = fmt.Sprintf("%v", value)
+ }
+ }
+
+ return result
+}
+
+func hasDependency(project *SpringBootProject, groupId string, artifactId string) bool {
+ for _, projectDependency := range project.pom.Dependencies {
+ if projectDependency.GroupId == groupId && projectDependency.ArtifactId == artifactId {
+ return true
+ }
+ }
+ return false
+}
diff --git a/cli/azd/internal/appdetect/spring_boot_property.go b/cli/azd/internal/appdetect/spring_boot_property.go
new file mode 100644
index 00000000000..d597019fc34
--- /dev/null
+++ b/cli/azd/internal/appdetect/spring_boot_property.go
@@ -0,0 +1,138 @@
+package appdetect
+
+import (
+ "bufio"
+ "fmt"
+ "github.com/azure/azure-dev/cli/azd/pkg/osutil"
+ "github.com/braydonk/yaml"
+ "log"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+)
+
+func readProperties(projectPath string) map[string]string {
+ // todo: do we need to consider the bootstrap.properties
+ result := make(map[string]string)
+ readPropertiesInPropertiesFile(filepath.Join(projectPath, "/src/main/resources/application.properties"), result)
+ readPropertiesInYamlFile(filepath.Join(projectPath, "/src/main/resources/application.yml"), result)
+ readPropertiesInYamlFile(filepath.Join(projectPath, "/src/main/resources/application.yaml"), result)
+ profile, profileSet := result["spring.profiles.active"]
+ if profileSet {
+ readPropertiesInPropertiesFile(
+ filepath.Join(projectPath, "/src/main/resources/application-"+profile+".properties"), result)
+ readPropertiesInYamlFile(filepath.Join(projectPath, "/src/main/resources/application-"+profile+".yml"), result)
+ readPropertiesInYamlFile(filepath.Join(projectPath, "/src/main/resources/application-"+profile+".yaml"), result)
+ }
+ return result
+}
+
+func readPropertiesInYamlFile(yamlFilePath string, result map[string]string) {
+ if !osutil.FileExists(yamlFilePath) {
+ return
+ }
+ data, err := os.ReadFile(yamlFilePath)
+ if err != nil {
+ log.Fatalf("error reading YAML file: %v", err)
+ return
+ }
+
+ // Parse the YAML into a yaml.Node
+ var root yaml.Node
+ err = yaml.Unmarshal(data, &root)
+ if err != nil {
+ log.Fatalf("error unmarshalling YAML: %v", err)
+ return
+ }
+
+ parseYAML("", &root, result)
+}
+
+// Recursively parse the YAML and build dot-separated keys into a map
+func parseYAML(prefix string, node *yaml.Node, result map[string]string) {
+ switch node.Kind {
+ case yaml.DocumentNode:
+ // Process each document's content
+ for _, contentNode := range node.Content {
+ parseYAML(prefix, contentNode, result)
+ }
+ case yaml.MappingNode:
+ // Process key-value pairs in a map
+ for i := 0; i < len(node.Content); i += 2 {
+ keyNode := node.Content[i]
+ valueNode := node.Content[i+1]
+
+ // Ensure the key is a scalar
+ if keyNode.Kind != yaml.ScalarNode {
+ continue
+ }
+
+ keyStr := keyNode.Value
+ newPrefix := keyStr
+ if prefix != "" {
+ newPrefix = prefix + "." + keyStr
+ }
+ parseYAML(newPrefix, valueNode, result)
+ }
+ case yaml.SequenceNode:
+ // Process items in a sequence (list)
+ for i, item := range node.Content {
+ newPrefix := fmt.Sprintf("%s[%d]", prefix, i)
+ parseYAML(newPrefix, item, result)
+ }
+ case yaml.ScalarNode:
+ // If it's a scalar value, add it to the result map
+ result[prefix] = getEnvironmentVariablePlaceholderHandledValue(node.Value)
+ default:
+ // Handle other node types if necessary
+ }
+}
+
+func readPropertiesInPropertiesFile(propertiesFilePath string, result map[string]string) {
+ if !osutil.FileExists(propertiesFilePath) {
+ return
+ }
+ file, err := os.Open(propertiesFilePath)
+ if err != nil {
+ log.Fatalf("error opening properties file: %v", err)
+ return
+ }
+ defer file.Close()
+
+ scanner := bufio.NewScanner(file)
+ for scanner.Scan() {
+ line := scanner.Text()
+ if strings.TrimSpace(line) == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ parts := strings.SplitN(line, "=", 2)
+ if len(parts) == 2 {
+ key := strings.TrimSpace(parts[0])
+ value := getEnvironmentVariablePlaceholderHandledValue(parts[1])
+ result[key] = value
+ }
+ }
+}
+
+var environmentVariableRegex = regexp.MustCompile(`\$\{([^:}]+)(?::([^}]+))?}`)
+
+func getEnvironmentVariablePlaceholderHandledValue(rawValue string) string {
+ trimmedRawValue := strings.TrimSpace(rawValue)
+ matches := environmentVariableRegex.FindAllStringSubmatch(trimmedRawValue, -1)
+ result := trimmedRawValue
+ for _, match := range matches {
+ if len(match) < 2 {
+ continue
+ }
+ envVar := match[1]
+ defaultValue := match[2]
+ value := os.Getenv(envVar)
+ if value == "" {
+ value = defaultValue
+ }
+ placeholder := match[0]
+ result = strings.Replace(result, placeholder, value, -1)
+ }
+ return result
+}
diff --git a/cli/azd/internal/appdetect/spring_boot_property_test.go b/cli/azd/internal/appdetect/spring_boot_property_test.go
new file mode 100644
index 00000000000..40216c6c77d
--- /dev/null
+++ b/cli/azd/internal/appdetect/spring_boot_property_test.go
@@ -0,0 +1,89 @@
+package appdetect
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestReadProperties(t *testing.T) {
+ var properties = readProperties(filepath.Join("testdata", "java-spring", "project-one"))
+ require.Equal(t, "", properties["not.exist"])
+ require.Equal(t, "jdbc:h2:mem:testdb", properties["spring.datasource.url"])
+
+ properties = readProperties(filepath.Join("testdata", "java-spring", "project-two"))
+ require.Equal(t, "", properties["not.exist"])
+ require.Equal(t, "jdbc:h2:mem:testdb", properties["spring.datasource.url"])
+
+ properties = readProperties(filepath.Join("testdata", "java-spring", "project-three"))
+ require.Equal(t, "", properties["not.exist"])
+ require.Equal(t, "HTML", properties["spring.thymeleaf.mode"])
+
+ properties = readProperties(filepath.Join("testdata", "java-spring", "project-four"))
+ require.Equal(t, "", properties["not.exist"])
+ require.Equal(t, "mysql", properties["database"])
+}
+
+func TestGetEnvironmentVariablePlaceholderHandledValue(t *testing.T) {
+ tests := []struct {
+ name string
+ inputValue string
+ environmentVariables map[string]string
+ expectedValue string
+ }{
+ {
+ "No environment variable placeholder",
+ "valueOne",
+ map[string]string{},
+ "valueOne",
+ },
+ {
+ "Has invalid environment variable placeholder",
+ "${VALUE_ONE",
+ map[string]string{},
+ "${VALUE_ONE",
+ },
+ {
+ "Has valid environment variable placeholder, but environment variable not set",
+ "${VALUE_TWO}",
+ map[string]string{},
+ "",
+ },
+ {
+ "Has valid environment variable placeholder, and environment variable set",
+ "${VALUE_THREE}",
+ map[string]string{"VALUE_THREE": "valueThree"},
+ "valueThree",
+ },
+ {
+ "Has valid environment variable placeholder with default value, but environment variable not set",
+ "${VALUE_TWO:defaultValue}",
+ map[string]string{},
+ "defaultValue",
+ },
+ {
+ "Has valid environment variable placeholder with default value, and environment variable set",
+ "${VALUE_THREE:defaultValue}",
+ map[string]string{"VALUE_THREE": "valueThree"},
+ "valueThree",
+ },
+ {
+ "Has multiple environment variable placeholder with default value, and environment variable not set",
+ "jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:pet-clinic}",
+ map[string]string{},
+ "jdbc:mysql://localhost:3306/pet-clinic",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ for k, v := range tt.environmentVariables {
+ err := os.Setenv(k, v)
+ require.NoError(t, err)
+ }
+ handledValue := getEnvironmentVariablePlaceholderHandledValue(tt.inputValue)
+ require.Equal(t, tt.expectedValue, handledValue)
+ })
+ }
+}
diff --git a/cli/azd/internal/appdetect/spring_boot_test.go b/cli/azd/internal/appdetect/spring_boot_test.go
new file mode 100644
index 00000000000..1ca7bad7143
--- /dev/null
+++ b/cli/azd/internal/appdetect/spring_boot_test.go
@@ -0,0 +1,232 @@
+package appdetect
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestDetectSpringBootVersion(t *testing.T) {
+ tests := []struct {
+ name string
+ parentPom *pom
+ currentPom *pom
+ expectedVersion string
+ }{
+ {
+ "unknown",
+ nil,
+ nil,
+ UnknownSpringBootVersion,
+ },
+ {
+ "project.parent",
+ nil,
+ &pom{
+ Parent: parent{
+ GroupId: "org.springframework.boot",
+ ArtifactId: "spring-boot-starter-parent",
+ Version: "2.x",
+ },
+ },
+ "2.x",
+ },
+ {
+ "project.dependencyManagement",
+ nil,
+ &pom{
+ DependencyManagement: dependencyManagement{
+ Dependencies: []dependency{
+ {
+ GroupId: "org.springframework.boot",
+ ArtifactId: "spring-boot-dependencies",
+ Version: "2.x",
+ },
+ },
+ },
+ },
+ "2.x",
+ },
+ {
+ "root.parent",
+ &pom{
+ Parent: parent{
+ GroupId: "org.springframework.boot",
+ ArtifactId: "spring-boot-starter-parent",
+ Version: "3.x",
+ },
+ },
+ nil,
+ "3.x",
+ },
+ {
+ "root.dependencyManagement",
+ &pom{
+ DependencyManagement: dependencyManagement{
+ Dependencies: []dependency{
+ {
+ GroupId: "org.springframework.boot",
+ ArtifactId: "spring-boot-dependencies",
+ Version: "3.x",
+ },
+ },
+ },
+ },
+ nil,
+ "3.x",
+ },
+ {
+ "both.root.and.project.parent",
+ &pom{
+ Parent: parent{
+ GroupId: "org.springframework.boot",
+ ArtifactId: "spring-boot-starter-parent",
+ Version: "2.x",
+ },
+ },
+ &pom{
+ Parent: parent{
+ GroupId: "org.springframework.boot",
+ ArtifactId: "spring-boot-starter-parent",
+ Version: "3.x",
+ },
+ },
+ "3.x",
+ },
+ {
+ "both.root.and.project.dependencyManagement",
+ &pom{
+ DependencyManagement: dependencyManagement{
+ Dependencies: []dependency{
+ {
+ GroupId: "org.springframework.boot",
+ ArtifactId: "spring-boot-dependencies",
+ Version: "2.x",
+ },
+ },
+ },
+ },
+ &pom{
+ DependencyManagement: dependencyManagement{
+ Dependencies: []dependency{
+ {
+ GroupId: "org.springframework.boot",
+ ArtifactId: "spring-boot-dependencies",
+ Version: "3.x",
+ },
+ },
+ },
+ },
+ "3.x",
+ },
+ {
+ "detect.root.parent.when.project.not.found",
+ &pom{
+ Parent: parent{
+ GroupId: "org.springframework.boot",
+ ArtifactId: "spring-boot-starter-parent",
+ Version: "2.x",
+ },
+ },
+ &pom{
+ Parent: parent{
+ GroupId: "org.test",
+ ArtifactId: "test-parent",
+ Version: "3.x",
+ },
+ },
+ "2.x",
+ },
+ {
+ "detect.root.dependencyManagement.when.project.not.found",
+ &pom{
+ DependencyManagement: dependencyManagement{
+ Dependencies: []dependency{
+ {
+ GroupId: "org.springframework.boot",
+ ArtifactId: "spring-boot-dependencies",
+ Version: "2.x",
+ },
+ },
+ },
+ },
+ &pom{
+ DependencyManagement: dependencyManagement{
+ Dependencies: []dependency{
+ {
+ GroupId: "org.test",
+ ArtifactId: "test-dependencies",
+ Version: "3.x",
+ },
+ },
+ },
+ },
+ "2.x",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ version := detectSpringBootVersion(tt.parentPom, tt.currentPom)
+ assert.Equal(t, tt.expectedVersion, version)
+ })
+ }
+}
+
+func TestGetDatabaseName(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"jdbc:postgresql://localhost:5432/your-database-name", "your-database-name"},
+ {"jdbc:postgresql://remote_host:5432/your-database-name", "your-database-name"},
+ {"jdbc:postgresql://your_postgresql_server:5432/your-database-name?sslmode=require", "your-database-name"},
+ {
+ "jdbc:postgresql://your_postgresql_server.postgres.database.azure.com:5432/your-database-name?sslmode=require",
+ "your-database-name",
+ },
+ {
+ "jdbc:postgresql://your_postgresql_server:5432/your-database-name?user=your_username&password=your_password",
+ "your-database-name",
+ },
+ {
+ "jdbc:postgresql://your_postgresql_server.postgres.database.azure.com:5432/your-database-name" +
+ "?sslmode=require&spring.datasource.azure.passwordless-enabled=true", "your-database-name",
+ },
+ }
+ for _, test := range tests {
+ result := getDatabaseName(test.input)
+ if result != test.expected {
+ t.Errorf("For input '%s', expected '%s', but got '%s'", test.input, test.expected, result)
+ }
+ }
+}
+
+func TestIsValidDatabaseName(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected bool
+ }{
+ {"InvalidNameWithUnderscore", "invalid_name", false},
+ {"TooShortName", "sh", false},
+ {
+ "TooLongName", "this-name-is-way-too-long-to-be-considered-valid-" +
+ "because-it-exceeds-sixty-three-characters", false,
+ },
+ {"InvalidStartWithHyphen", "-invalid-start", false},
+ {"InvalidEndWithHyphen", "invalid-end-", false},
+ {"ValidName", "valid-name", true},
+ {"ValidNameWithNumbers", "valid123-name", true},
+ {"ValidNameWithOnlyLetters", "valid-name", true},
+ {"ValidNameWithOnlyNumbers", "123456", true},
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ result := IsValidDatabaseName(test.input)
+ if result != test.expected {
+ t.Errorf("For input '%s', expected %v, but got %v", test.input, test.expected, result)
+ }
+ })
+ }
+}
diff --git a/cli/azd/internal/tracing/fields/fields.go b/cli/azd/internal/tracing/fields/fields.go
index 52562e181c6..6b3bf726609 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")
@@ -249,6 +250,9 @@ const (
AppInitModifyAddCount = attribute.Key("appinit.modify_add.count")
AppInitModifyRemoveCount = attribute.Key("appinit.modify_remove.count")
+ // AppInitJavaDetect indicates if java detector has started or finished
+ AppInitJavaDetect = attribute.Key("appinit.java.detect")
+
// The last step recorded during the app init process.
AppInitLastStep = attribute.Key("appinit.lastStep")
)