diff --git a/cli/azd/internal/appdetect/appdetect.go b/cli/azd/internal/appdetect/appdetect.go index e6103d3cbd7..e05222a9ca3 100644 --- a/cli/azd/internal/appdetect/appdetect.go +++ b/cli/azd/internal/appdetect/appdetect.go @@ -14,6 +14,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/tools/dotnet" + "github.com/azure/azure-dev/cli/azd/pkg/tools/maven" "github.com/bmatcuk/doublestar/v4" ) @@ -179,7 +180,8 @@ type projectDetector interface { var allDetectors = []projectDetector{ // Order here determines precedence when two projects are in the same directory. // This is unlikely to occur in practice, but reordering could help to break the tie in these cases. - &javaDetector{}, + &javaDetector{ + mvnCli: maven.NewCli(exec.NewCommandRunner(nil))}, &dotNetAppHostDetector{ // TODO(ellismg): Remove ambient authority. dotnetCli: dotnet.NewCli(exec.NewCommandRunner(nil)), diff --git a/cli/azd/internal/appdetect/java.go b/cli/azd/internal/appdetect/java.go index fe6fec3ea65..cc4e4fc49f9 100644 --- a/cli/azd/internal/appdetect/java.go +++ b/cli/azd/internal/appdetect/java.go @@ -2,18 +2,16 @@ package appdetect import ( "context" - "encoding/xml" "fmt" "io/fs" - "maps" - "os" "path/filepath" - "slices" "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/tools/maven" ) type javaDetector struct { - rootProjects []mavenProject + mvnCli *maven.Cli } func (jd *javaDetector) Language() Language { @@ -23,28 +21,18 @@ 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" { - pomFile := filepath.Join(path, entry.Name()) - project, err := readMavenProject(pomFile) + pomFilePath := filepath.Join(path, entry.Name()) + project, err := toMavenProject(ctx, jd.mvnCli, pomFilePath) if err != nil { return nil, fmt.Errorf("error reading pom.xml: %w", err) } - if len(project.Modules) > 0 { + if len(project.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) return nil, nil } - var currentRoot *mavenProject - for _, rootProject := range jd.rootProjects { - // we can say that the project is in the root project if the path is under the project - if inRoot := strings.HasPrefix(pomFile, rootProject.path); inRoot { - currentRoot = &rootProject - } - } - - _ = currentRoot // use currentRoot here in the analysis result, err := detectDependencies(project, &Project{ Language: Java, Path: path, @@ -60,84 +48,3 @@ func (jd *javaDetector) DetectProject(ctx context.Context, path string, entries return nil, nil } - -// mavenProject represents the top-level structure of a Maven POM file. -type mavenProject struct { - 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)) - }) - } - - return project, nil -} diff --git a/cli/azd/internal/appdetect/maven_project.go b/cli/azd/internal/appdetect/maven_project.go new file mode 100644 index 00000000000..746113e7e3c --- /dev/null +++ b/cli/azd/internal/appdetect/maven_project.go @@ -0,0 +1,45 @@ +package appdetect + +import ( + "context" + "maps" + "slices" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/tools/maven" +) + +type mavenProject struct { + pom pom +} + +func toMavenProject(ctx context.Context, mvnCli *maven.Cli, pomFilePath string) (mavenProject, error) { + pom, err := toPom(ctx, mvnCli, pomFilePath) + if err != nil { + return mavenProject{}, err + } + return mavenProject{pom: pom}, nil +} + +func detectDependencies(mavenProject mavenProject, project *Project) (*Project, error) { + databaseDepMap := map[DatabaseDep]struct{}{} + for _, dep := range mavenProject.pom.Dependencies { + if dep.GroupId == "com.mysql" && dep.ArtifactId == "mysql-connector-j" { + databaseDepMap[DbMySql] = struct{}{} + } + + if dep.GroupId == "org.postgresql" && dep.ArtifactId == "postgresql" || + dep.GroupId == "com.azure.spring" && dep.ArtifactId == "spring-cloud-azure-starter-jdbc-postgresql" { + databaseDepMap[DbPostgres] = struct{}{} + } + } + + if len(databaseDepMap) > 0 { + project.DatabaseDeps = slices.SortedFunc(maps.Keys(databaseDepMap), + func(a, b DatabaseDep) int { + return strings.Compare(string(a), string(b)) + }) + } + + return project, nil +} diff --git a/cli/azd/internal/appdetect/pom.go b/cli/azd/internal/appdetect/pom.go new file mode 100644 index 00000000000..737f8ac1db2 --- /dev/null +++ b/cli/azd/internal/appdetect/pom.go @@ -0,0 +1,71 @@ +package appdetect + +import ( + "context" + "encoding/xml" + "path/filepath" + + "github.com/azure/azure-dev/cli/azd/pkg/tools/maven" +) + +func toPom(ctx context.Context, mvnCli *maven.Cli, pomFilePath string) (pom, error) { + result, err := toEffectivePom(ctx, mvnCli, pomFilePath) + if err != nil { + return pom{}, err + } + result.path = filepath.Dir(pomFilePath) + return result, err +} + +func toEffectivePom(ctx context.Context, mvnCli *maven.Cli, pomFilePath string) (pom, error) { + effectivePom, err := mvnCli.EffectivePom(ctx, pomFilePath) + if err != nil { + return pom{}, err + } + var resultPom pom + err = xml.Unmarshal([]byte(effectivePom), &resultPom) + return resultPom, err +} + +// 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 + 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"` +} diff --git a/cli/azd/internal/appdetect/pom_test.go b/cli/azd/internal/appdetect/pom_test.go new file mode 100644 index 00000000000..8bc2b8f8cd7 --- /dev/null +++ b/cli/azd/internal/appdetect/pom_test.go @@ -0,0 +1,227 @@ +package appdetect + +import ( + "context" + "log/slog" + "os" + osexec "os/exec" + "path/filepath" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/tools/maven" +) + +func TestCreateEffectivePom(t *testing.T) { + path, err := osexec.LookPath("java") + if err != nil { + t.Skip("Skip TestCreateEffectivePom because java command doesn't exist.") + } else { + slog.Info("Java command found.", "path", path) + } + path, err = osexec.LookPath("mvn") + if err != nil { + t.Skip("Skip TestCreateEffectivePom because mvn command doesn't exist.") + } else { + slog.Info("Java command found.", "path", path) + } + tests := []struct { + name string + testPoms []testPom + expected []dependency + }{ + { + name: "Test with two dependencies", + testPoms: []testPom{ + { + pomFilePath: "pom.xml", + pomContentString: ` + + 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", + testPoms: []testPom{ + { + pomFilePath: "pom.xml", + pomContentString: ` + + 4.0.0 + com.example + example-project + 1.0.0 + + + + `, + }, + }, + expected: []dependency{}, + }, + { + name: "Test with one dependency which version is decided by dependencyManagement", + testPoms: []testPom{ + { + pomFilePath: "pom.xml", + pomContentString: ` + + 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", + testPoms: []testPom{ + { + pomFilePath: "pom.xml", + pomContentString: ` + + + 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) { + t.Parallel() + workingDir, err := prepareTestPomFiles(tt.testPoms) + if err != nil { + t.Fatalf("%v", err) + } + for _, testPom := range tt.testPoms { + pomFilePath := filepath.Join(workingDir, testPom.pomFilePath) + + effectivePom, err := toEffectivePom(context.TODO(), maven.NewCli(exec.NewCommandRunner(nil)), + pomFilePath) + if err != nil { + t.Fatalf("createEffectivePom failed: %v", err) + } + + if len(effectivePom.Dependencies) != len(tt.expected) { + t.Fatalf("Expected: %d\nActual: %d", len(tt.expected), len(effectivePom.Dependencies)) + } + + for i, dep := range effectivePom.Dependencies { + if dep != tt.expected[i] { + t.Errorf("\nExpected: %s\nActual: %s", tt.expected[i], dep) + } + } + } + }) + } +} + +type testPom struct { + pomFilePath string + pomContentString string +} + +func prepareTestPomFiles(testPoms []testPom) (string, error) { + tempDir, err := os.MkdirTemp("", "prepareTestPomFiles") + if err != nil { + return "", err + } + for _, testPom := range testPoms { + pomPath := filepath.Join(tempDir, testPom.pomFilePath) + err := os.MkdirAll(filepath.Dir(pomPath), 0755) + if err != nil { + return "", err + } + err = os.WriteFile(pomPath, []byte(testPom.pomContentString), 0600) + if err != nil { + return "", err + } + } + return tempDir, nil +} diff --git a/cli/azd/internal/appdetect/testdata/java/pom.xml b/cli/azd/internal/appdetect/testdata/java/pom.xml index 09cb26061ae..1ff4713938a 100644 --- a/cli/azd/internal/appdetect/testdata/java/pom.xml +++ b/cli/azd/internal/appdetect/testdata/java/pom.xml @@ -2,7 +2,6 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd"> 4.0.0 - nothing basic-pom 0.0.1-SNAPSHOT diff --git a/cli/azd/pkg/tools/maven/maven.go b/cli/azd/pkg/tools/maven/maven.go index 43d8d91db59..beee3b4a12a 100644 --- a/cli/azd/pkg/tools/maven/maven.go +++ b/cli/azd/pkg/tools/maven/maven.go @@ -1,6 +1,7 @@ package maven import ( + "bufio" "context" "errors" "fmt" @@ -30,47 +31,47 @@ type Cli struct { mvnCmdErr error } -func (m *Cli) Name() string { +func (cli *Cli) Name() string { return "Maven" } -func (m *Cli) InstallUrl() string { +func (cli *Cli) InstallUrl() string { return "https://maven.apache.org" } -func (m *Cli) CheckInstalled(ctx context.Context) error { - _, err := m.mvnCmd() +func (cli *Cli) CheckInstalled(ctx context.Context) error { + _, err := cli.mvnCmd() if err != nil { return err } - if ver, err := m.extractVersion(ctx); err == nil { + if ver, err := cli.extractVersion(ctx); err == nil { log.Printf("maven version: %s", ver) } return nil } -func (m *Cli) SetPath(projectPath string, rootProjectPath string) { - m.projectPath = projectPath - m.rootProjectPath = rootProjectPath +func (cli *Cli) SetPath(projectPath string, rootProjectPath string) { + cli.projectPath = projectPath + cli.rootProjectPath = rootProjectPath } -func (m *Cli) mvnCmd() (string, error) { - m.mvnCmdOnce.Do(func() { - mvnCmd, err := getMavenPath(m.projectPath, m.rootProjectPath) +func (cli *Cli) mvnCmd() (string, error) { + cli.mvnCmdOnce.Do(func() { + mvnCmd, err := getMavenPath(cli.projectPath, cli.rootProjectPath) if err != nil { - m.mvnCmdErr = err + cli.mvnCmdErr = err } else { - m.mvnCmdStr = mvnCmd + cli.mvnCmdStr = mvnCmd } }) - if m.mvnCmdErr != nil { - return "", m.mvnCmdErr + if cli.mvnCmdErr != nil { + return "", cli.mvnCmdErr } - return m.mvnCmdStr, nil + return cli.mvnCmdStr, nil } func getMavenPath(projectPath string, rootProjectPath string) (string, error) { @@ -237,6 +238,51 @@ func (cli *Cli) GetProperty(ctx context.Context, propertyPath string, projectPat return result, nil } +func (cli *Cli) EffectivePom(ctx context.Context, pomPath string) (string, error) { + mvnCmd, err := cli.mvnCmd() + if err != nil { + return "", err + } + pomDir := filepath.Dir(pomPath) + runArgs := exec.NewRunArgs(mvnCmd, "help:effective-pom", "-f", pomPath).WithCwd(pomDir) + result, err := cli.commandRunner.Run(ctx, runArgs) + if err != nil { + return "", fmt.Errorf("mvn help:effective-pom on project '%s' failed: %w", pomPath, err) + } + return getEffectivePomFromConsoleOutput(result.Stdout) +} + +var projectStart = regexp.MustCompile(`^\s*\s*$`) + +func getEffectivePomFromConsoleOutput(consoleOutput string) (string, error) { + var builder strings.Builder + scanner := bufio.NewScanner(strings.NewReader(consoleOutput)) + inProject := false + + for scanner.Scan() { + line := scanner.Text() + if projectStart.MatchString(line) { + inProject = true + builder.Reset() // for a pom which contains submodule, the effective pom for root pom appears at last. + } else if projectEnd.MatchString(line) { + builder.WriteString(line) + inProject = false + } + if inProject { + builder.WriteString(line) + } + } + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("failed to scan console output: %w", err) + } + result := builder.String() + if result == "" { + return "", fmt.Errorf("failed to get effective pom from console: empty content") + } + return result, nil +} + func NewCli(commandRunner exec.CommandRunner) *Cli { return &Cli{ commandRunner: commandRunner,