From db2031a279fcfb6528b2ef078a8443cfde189827 Mon Sep 17 00:00:00 2001 From: Xiaolu Dai Date: Thu, 19 Dec 2024 14:17:40 +0800 Subject: [PATCH] the Java analyzer feature --- cli/azd/internal/appdetect/appdetect.go | 70 +- cli/azd/internal/appdetect/java.go | 152 ++--- cli/azd/internal/appdetect/maven_command.go | 220 +++++++ .../internal/appdetect/maven_command_test.go | 81 +++ cli/azd/internal/appdetect/maven_project.go | 15 + cli/azd/internal/appdetect/pom.go | 162 +++++ cli/azd/internal/appdetect/pom_test.go | 235 +++++++ cli/azd/internal/appdetect/spring_boot.go | 598 ++++++++++++++++++ .../appdetect/spring_boot_property.go | 138 ++++ .../appdetect/spring_boot_property_test.go | 89 +++ .../internal/appdetect/spring_boot_test.go | 232 +++++++ cli/azd/internal/tracing/fields/fields.go | 8 +- 12 files changed, 1895 insertions(+), 105 deletions(-) create mode 100644 cli/azd/internal/appdetect/maven_command.go create mode 100644 cli/azd/internal/appdetect/maven_command_test.go create mode 100644 cli/azd/internal/appdetect/maven_project.go create mode 100644 cli/azd/internal/appdetect/pom.go create mode 100644 cli/azd/internal/appdetect/pom_test.go create mode 100644 cli/azd/internal/appdetect/spring_boot.go create mode 100644 cli/azd/internal/appdetect/spring_boot_property.go create mode 100644 cli/azd/internal/appdetect/spring_boot_property_test.go create mode 100644 cli/azd/internal/appdetect/spring_boot_test.go 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") )