diff --git a/artifactory/commands/mvn/mvn.go b/artifactory/commands/mvn/mvn.go index 153abfb4..2e6012fd 100644 --- a/artifactory/commands/mvn/mvn.go +++ b/artifactory/commands/mvn/mvn.go @@ -146,17 +146,17 @@ func (mc *MvnCommand) isDeploymentRequested() bool { if strings.HasSuffix(goal, ":help") || goal == "help" { continue } - + // Exact match for standard Maven phases (most common case) if goal == "install" || goal == "deploy" { return true } - + // Prefix match for plugin:goal format (e.g., deploy:deploy-file, install:install-file) if strings.HasPrefix(goal, "deploy:") || strings.HasPrefix(goal, "install:") { return true } - + // Suffix match for full plugin name format (e.g., maven-deploy-plugin:deploy, maven-install-plugin:install) // Note: Using suffix instead of Contains() to avoid false positives like "uninstall", "reinstall" if strings.HasSuffix(goal, ":deploy") || strings.HasSuffix(goal, ":install") { @@ -199,7 +199,8 @@ func (mc *MvnCommand) Run() error { SetGoals(mc.goals). SetInsecureTls(mc.insecureTls). SetDisableDeploy(mc.deploymentDisabled). - SetThreads(mc.threads) + SetThreads(mc.threads). + SetServerDetails(mc.serverDetails) if err = RunMvn(mvnParams); err != nil { return err } diff --git a/artifactory/commands/mvn/utils.go b/artifactory/commands/mvn/utils.go index 2888358a..a6ca25ed 100644 --- a/artifactory/commands/mvn/utils.go +++ b/artifactory/commands/mvn/utils.go @@ -1,6 +1,8 @@ package mvn import ( + "context" + "errors" "io" "os" "os/exec" @@ -34,6 +36,7 @@ type MvnUtils struct { insecureTls bool disableDeploy bool outputWriter io.Writer + serverDetails *config.ServerDetails } func NewMvnUtils() *MvnUtils { @@ -85,6 +88,11 @@ func (mu *MvnUtils) SetOutputWriter(writer io.Writer) *MvnUtils { return mu } +func (mu *MvnUtils) SetServerDetails(serverDetails *config.ServerDetails) *MvnUtils { + mu.serverDetails = serverDetails + return mu +} + func RunMvn(mu *MvnUtils) error { // FlexPack completely bypasses traditional Maven Build Info Extractor if utils.ShouldRunNative(mu.configPath) { @@ -156,6 +164,20 @@ func RunMvn(mu *MvnUtils) error { if err != nil { return err } + workingDir, wdErr := os.Getwd() + if wdErr != nil { + return errorutils.CheckError(wdErr) + } + var restoreHealing func() error + restoreHealing, _, err = mu.runXrayComponentHealing(context.Background(), workingDir, mu.serverDetails) + if err != nil { + return err + } + defer func() { + if err != nil && restoreHealing != nil { + err = errors.Join(err, restoreHealing()) + } + }() var mvnOpts []string if v := os.Getenv("MAVEN_OPTS"); v != "" { mvnOpts = strings.Fields(v) diff --git a/artifactory/commands/mvn/xrayheal.go b/artifactory/commands/mvn/xrayheal.go new file mode 100644 index 00000000..916ee9fa --- /dev/null +++ b/artifactory/commands/mvn/xrayheal.go @@ -0,0 +1,55 @@ +package mvn + +import ( + "context" + + "github.com/jfrog/jfrog-cli-artifactory/artifactory/healcomponents" + cmaven "github.com/jfrog/jfrog-cli-artifactory/artifactory/healcomponents/maven" + buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build" + "github.com/jfrog/jfrog-cli-core/v2/common/project" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-cli-core/v2/utils/xray" +) + +func (mu *MvnUtils) runXrayComponentHealing(ctx context.Context, workingDir string, serverDetails *config.ServerDetails) (restore func() error, healed bool, err error) { + if healcomponents.IsComponentResolutionDisabled() { + return healcomponents.SkipHealing("Xray component healing disabled", nil) + } + command := cmaven.DeriveResolutionCommand(mu.goals) + if command == "" || mu.vConfig == nil || !mu.vConfig.IsSet("resolver") { + return healcomponents.SkipHealing("Xray component healing skipped", nil) + } + resolverRepo, resolverErr := mu.resolverRepoForHealing() + if resolverErr != nil { + return healcomponents.SkipHealing("Xray component healing skipped: could not determine resolver repo: ", resolverErr) + } + if resolverRepo == "" { + return healcomponents.SkipHealing("Xray component healing skipped: resolver repo is empty", nil) + } + if serverDetails == nil { + serverDetails, err = buildUtils.GetServerDetails(mu.vConfig) + if err != nil { + return healcomponents.SkipHealing("Xray component healing skipped: could not determine server details: ", err) + } + if serverDetails == nil { + return healcomponents.SkipHealing("Xray component healing skipped: could not determine server details", nil) + } + } + var projectKey string + if mu.buildConf != nil { + projectKey = mu.buildConf.GetProject() + } + xrayManager, xrayErr := xray.CreateXrayServiceManager(serverDetails, xray.WithScopedProjectKey(projectKey)) + if xrayErr != nil { + return healcomponents.SkipHealing("Xray component healing skipped: could not create Xray service manager: ", xrayErr) + } + return healcomponents.RunIfEnabled(ctx, xrayManager, resolverRepo, cmaven.NewBuildToolWithGoals(mu.goals), command, workingDir, nil) +} + +func (mu *MvnUtils) resolverRepoForHealing() (string, error) { + repoConfig, err := project.GetRepoConfigByPrefix(mu.configPath, project.ProjectConfigResolverPrefix, mu.vConfig) + if err != nil { + return "", err + } + return repoConfig.TargetRepo(), nil +} diff --git a/artifactory/commands/mvn/xrayheal_test.go b/artifactory/commands/mvn/xrayheal_test.go new file mode 100644 index 00000000..2a65b3eb --- /dev/null +++ b/artifactory/commands/mvn/xrayheal_test.go @@ -0,0 +1,17 @@ +package mvn + +import ( + "testing" + + "github.com/jfrog/jfrog-cli-artifactory/artifactory/healcomponents" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" +) + +func TestRunXrayComponentHealing_RespectsDisabledEnv(t *testing.T) { + t.Setenv(healcomponents.HealComponentsDisabledEnvVar, "true") + mu := NewMvnUtils().SetGoals([]string{"install"}).SetConfig(viper.New()) + _, healed, err := mu.runXrayComponentHealing(t.Context(), t.TempDir(), nil) + assert.NoError(t, err) + assert.False(t, healed) +} diff --git a/artifactory/commands/npm/common.go b/artifactory/commands/npm/common.go index ef1acde7..4aa59e5f 100644 --- a/artifactory/commands/npm/common.go +++ b/artifactory/commands/npm/common.go @@ -14,6 +14,8 @@ type CommonArgs struct { npmArgs []string serverDetails *config.ServerDetails useNative bool + configFilePath string + executablePath string } func (ca *CommonArgs) SetServerDetails(serverDetails *config.ServerDetails) *CommonArgs { @@ -45,6 +47,16 @@ func (ca *CommonArgs) SetUseNative(useNpmRc bool) *CommonArgs { return ca } +func (ca *CommonArgs) SetConfigFilePath(configFilePath string) *CommonArgs { + ca.configFilePath = configFilePath + return ca +} + +func (ca *CommonArgs) SetExecutablePath(executablePath string) *CommonArgs { + ca.executablePath = executablePath + return ca +} + // CheckIsNativeAndFetchFilteredArgs checks if native mode should be enabled. // It first checks the JFROG_RUN_NATIVE environment variable (preferred), // then falls back to the deprecated --run-native flag for backward compatibility. diff --git a/artifactory/commands/npm/npmcommand.go b/artifactory/commands/npm/npmcommand.go index 47ae45e5..ed93b760 100644 --- a/artifactory/commands/npm/npmcommand.go +++ b/artifactory/commands/npm/npmcommand.go @@ -2,6 +2,7 @@ package npm import ( "bufio" + "context" "errors" "fmt" "net/http" @@ -56,9 +57,8 @@ var ( type NpmCommand struct { CommonArgs - cmdName string - jsonOutput bool - executablePath string + cmdName string + jsonOutput bool // Function to be called to restore the user's old npmrc and delete the one we created. restoreNpmrcFunc func() error workingDirectory string @@ -73,6 +73,8 @@ type NpmCommand struct { collectBuildInfo bool buildInfoModule *build.NpmModule installHandler *NpmInstallStrategy + // When true, the subsequent install uses npm ci to honor healed lockfile integrity. + healedLockfile bool // When true, skips the 404 error handling that checks if packages are blocked by curation disableCVSCheck bool } @@ -135,7 +137,7 @@ func (nc *NpmCommand) Init() error { if err != nil { return err } - + repoConfig, err := nc.getRepoConfig(vConfig) if err != nil { return err @@ -348,6 +350,16 @@ func (nc *NpmCommand) Run() (err error) { if err = nc.PreparePrerequisites(nc.repo); err != nil { return } + var restoreResolution func() error + restoreResolution, nc.healedLockfile, err = nc.runXrayComponentHealing(context.Background(), nc.cmdName, nc.workingDirectory, nc.npmArgs) + if err != nil { + return err + } + defer func() { + if err != nil && restoreResolution != nil { + err = errors.Join(err, restoreResolution()) + } + }() defer func() { err = errors.Join(err, nc.installHandler.RestoreNpmrc()) }() @@ -508,8 +520,19 @@ func (nc *NpmCommand) prepareBuildInfoModule() error { return nil } +func (nc *NpmCommand) effectiveNpmCommand() string { + if nc.healedLockfile && nc.cmdName == "install" { + return "ci" + } + return nc.cmdName +} + func (nc *NpmCommand) collectDependencies() error { - nc.buildInfoModule.SetNpmArgs(append([]string{nc.cmdName}, nc.npmArgs...)) + npmCommand := nc.effectiveNpmCommand() + if npmCommand != nc.cmdName { + log.Info("Using npm ci after component resolution to install from the healed lockfile") + } + nc.buildInfoModule.SetNpmArgs(append([]string{npmCommand}, nc.npmArgs...)) return errorutils.CheckError(nc.buildInfoModule.Build()) } diff --git a/artifactory/commands/npm/publish.go b/artifactory/commands/npm/publish.go index 2624a435..3b3176c8 100644 --- a/artifactory/commands/npm/publish.go +++ b/artifactory/commands/npm/publish.go @@ -3,6 +3,7 @@ package npm import ( "archive/tar" "compress/gzip" + "context" "encoding/json" "errors" "fmt" @@ -37,7 +38,6 @@ const ( type NpmPublishCommandArgs struct { CommonArgs - executablePath string workingDirectory string collectBuildInfo bool packedFilePaths []string @@ -79,6 +79,7 @@ func (npc *NpmPublishCommand) ServerDetails() (*config.ServerDetails, error) { func (npc *NpmPublishCommand) SetConfigFilePath(configFilePath string) *NpmPublishCommand { npc.configFilePath = configFilePath + npc.CommonArgs.SetConfigFilePath(configFilePath) return npc } @@ -172,6 +173,16 @@ func (npc *NpmPublishCommand) Run() (err error) { if err != nil { return err } + var restoreResolution func() error + restoreResolution, _, err = npc.runXrayComponentHealingForPublish(context.Background(), "publish", npc.workingDirectory, npc.publishPath, npc.npmArgs) + if err != nil { + return err + } + defer func() { + if err != nil && restoreResolution != nil { + err = errors.Join(err, restoreResolution()) + } + }() var npmBuild *build.Build var buildName, buildNumber, projectKey string diff --git a/artifactory/commands/npm/xrayheal.go b/artifactory/commands/npm/xrayheal.go new file mode 100644 index 00000000..643f40d7 --- /dev/null +++ b/artifactory/commands/npm/xrayheal.go @@ -0,0 +1,112 @@ +package npm + +import ( + "context" + "fmt" + "strings" + + gofrogcmd "github.com/jfrog/gofrog/io" + + "github.com/jfrog/jfrog-cli-core/v2/common/project" + "github.com/jfrog/jfrog-cli-core/v2/utils/xray" + + "github.com/jfrog/jfrog-cli-artifactory/artifactory/healcomponents" + cnpm "github.com/jfrog/jfrog-cli-artifactory/artifactory/healcomponents/npm" +) + +func (ca *CommonArgs) runXrayComponentHealing(ctx context.Context, command, workingDir string, npmArgs []string) (restore func() error, healed bool, err error) { + if command == "install" && isSinglePackageInstall(npmArgs) { + return func() error { return nil }, false, nil + } + return ca.runXrayComponentHealingWithTool(ctx, command, workingDir, cnpm.NewBuildToolWithArgs(npmArgs), cnpm.BootstrapArgsFrom(npmArgs)...) +} + +func (ca *CommonArgs) runXrayComponentHealingForPublish(ctx context.Context, command, workingDir, publishPath string, npmArgs []string) (restore func() error, healed bool, err error) { + return ca.runXrayComponentHealingWithTool(ctx, command, workingDir, cnpm.NewBuildToolForPublish(workingDir, publishPath, npmArgs), cnpm.BootstrapArgsFrom(npmArgs)...) +} + +func (ca *CommonArgs) runXrayComponentHealingWithTool(ctx context.Context, command, workingDir string, tool cnpm.BuildTool, bootstrapArgs ...string) (restore func() error, healed bool, err error) { + if healcomponents.IsComponentResolutionDisabled() { + return healcomponents.SkipHealing("Xray component healing disabled", nil) + } + resolverRepo, resolverErr := ca.resolverRepoForResolution(command) + if resolverErr != nil { + return healcomponents.SkipHealing("Xray component healing skipped: could not determine resolver repo: ", resolverErr) + } + if resolverRepo == "" { + return healcomponents.SkipHealing("Xray component healing skipped: resolver repo is empty", nil) + } + var projectKey string + if ca.buildConfiguration != nil { + projectKey = ca.buildConfiguration.GetProject() + } + xrayManager, xrayErr := xray.CreateXrayServiceManager(ca.serverDetails, xray.WithScopedProjectKey(projectKey)) + if xrayErr != nil { + return healcomponents.SkipHealing("Xray component healing skipped: could not create Xray service manager: ", xrayErr) + } + return healcomponents.RunIfEnabled(ctx, xrayManager, resolverRepo, tool, command, workingDir, ca.npmBootstrapRunner(), bootstrapArgs...) +} + +// resolverRepoForResolution returns the Artifactory virtual repo for dependency policy scope. +func (ca *CommonArgs) resolverRepoForResolution(command string) (string, error) { + if command != "publish" && ca.repo != "" { + return ca.repo, nil + } + if ca.configFilePath != "" { + vConfig, err := project.ReadConfigFile(ca.configFilePath, project.YAML) + if err != nil { + return "", fmt.Errorf("failed to read config file: %w", err) + } + resolverConfig, err := project.GetRepoConfigByPrefix(ca.configFilePath, project.ProjectConfigResolverPrefix, vConfig) + if err != nil { + return "", fmt.Errorf("failed to get resolver config: %w", err) + } + return resolverConfig.TargetRepo(), nil + } + if ca.executablePath != "" { + registryURL, err := ca.getNpmRegistryURL() + if err != nil { + return "", fmt.Errorf("failed to get registry URL: %w", err) + } + return extractRepoName(registryURL) + } + return ca.repo, nil +} + +func (ca *CommonArgs) getNpmRegistryURL() (string, error) { + configCommand := gofrogcmd.Command{ + Executable: ca.executablePath, + CmdName: "config", + CmdArgs: []string{"get", "registry"}, + } + data, err := configCommand.RunWithOutput() + if err != nil { + return "", err + } + return strings.TrimSpace(string(data)), nil +} + +func (ca *CommonArgs) npmBootstrapRunner() healcomponents.CommandRunner { + return func(ctx context.Context, projectRoot string, args ...string) error { + return runNpmAt(ctx, ca.executablePath, projectRoot, args...) + } +} + +func runNpmAt(_ context.Context, executablePath, projectRoot string, args ...string) error { + if len(args) == 0 { + return nil + } + cmd := gofrogcmd.NewCommand(executablePath, args[0], args[1:]) + cmd.Dir = projectRoot + _, err := cmd.RunWithOutput() + return err +} + +func isSinglePackageInstall(npmArgs []string) bool { + for _, arg := range npmArgs { + if !strings.HasPrefix(arg, "-") { + return true + } + } + return false +} diff --git a/artifactory/commands/npm/xrayheal_test.go b/artifactory/commands/npm/xrayheal_test.go new file mode 100644 index 00000000..1b5569e0 --- /dev/null +++ b/artifactory/commands/npm/xrayheal_test.go @@ -0,0 +1,37 @@ +package npm + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/jfrog/jfrog-cli-artifactory/artifactory/healcomponents" +) + +func TestRunComponentResolution_RespectsDisabledEnv(t *testing.T) { + t.Setenv(healcomponents.HealComponentsDisabledEnvVar, "true") + ca := &CommonArgs{} + ca.SetRepo("npm-virtual").SetServerDetails(nil) + _, healed, err := ca.runXrayComponentHealing(t.Context(), "install", t.TempDir(), nil) + assert.NoError(t, err) + assert.False(t, healed) +} + +func TestEffectiveNpmCommandAfterHeal(t *testing.T) { + nc := &NpmCommand{cmdName: "install", healedLockfile: true} + assert.Equal(t, "ci", nc.effectiveNpmCommand()) + + nc.healedLockfile = false + assert.Equal(t, "install", nc.effectiveNpmCommand()) + + nc.cmdName = "ci" + nc.healedLockfile = true + assert.Equal(t, "ci", nc.effectiveNpmCommand()) +} + +func TestIsSinglePackageInstall(t *testing.T) { + assert.True(t, isSinglePackageInstall([]string{"lodash"})) + assert.True(t, isSinglePackageInstall([]string{"--save", "lodash"})) + assert.False(t, isSinglePackageInstall([]string{"--verbose"})) + assert.False(t, isSinglePackageInstall(nil)) +} diff --git a/artifactory/healcomponents/buildtool.go b/artifactory/healcomponents/buildtool.go new file mode 100644 index 00000000..470be2fa --- /dev/null +++ b/artifactory/healcomponents/buildtool.go @@ -0,0 +1,26 @@ +package healcomponents + +import ( + "context" + "slices" +) + +// CommandRunner runs a build-tool subprocess (injected for tests). +type CommandRunner func(ctx context.Context, projectRoot string, args ...string) error + +// BuildTool describes a package manager for the generic resolution flow. +type BuildTool interface { + ToolName() string + RelevantCommands() []string + // ProjectRoot resolves monorepo/reactor/solution root from workingDir. + ProjectRoot(workingDir string) (string, error) + // EnsureLockfiles materializes expected lock artifacts when absent and the command allows it. + // Returns paths (relative to project root) that were bootstrapped and did not exist before. + EnsureLockfiles(ctx context.Context, projectRoot, command string, runner CommandRunner, bootstrapArgs ...string) (bootstrapped []string, err error) + // DiscoverLockfiles returns all lockfiles (paths relative to project root). + DiscoverLockfiles(workingDir string) ([]Lockfile, error) +} + +func IsRelevantCommand(tool BuildTool, command string) bool { + return slices.Contains(tool.RelevantCommands(), command) +} diff --git a/artifactory/healcomponents/buildtool_test.go b/artifactory/healcomponents/buildtool_test.go new file mode 100644 index 00000000..9dcaf3e9 --- /dev/null +++ b/artifactory/healcomponents/buildtool_test.go @@ -0,0 +1,30 @@ +package healcomponents + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +type fakeTool struct { + name string + commands []string +} + +func (f fakeTool) ToolName() string { return f.name } +func (f fakeTool) RelevantCommands() []string { return f.commands } +func (f fakeTool) ProjectRoot(dir string) (string, error) { return dir, nil } +func (f fakeTool) EnsureLockfiles(_ context.Context, _, _ string, _ CommandRunner, _ ...string) ([]string, error) { + return nil, nil +} +func (f fakeTool) DiscoverLockfiles(_ string) ([]Lockfile, error) { + return []Lockfile{{Path: "lock.json", Content: []byte(`{}`)}}, nil +} + +func TestIsRelevantCommand(t *testing.T) { + tool := fakeTool{name: "fake", commands: []string{"install", "ci"}} + assert.True(t, IsRelevantCommand(tool, "install")) + assert.True(t, IsRelevantCommand(tool, "ci")) + assert.False(t, IsRelevantCommand(tool, "publish")) +} diff --git a/artifactory/healcomponents/maven/discover.go b/artifactory/healcomponents/maven/discover.go new file mode 100644 index 00000000..13a3e9f5 --- /dev/null +++ b/artifactory/healcomponents/maven/discover.go @@ -0,0 +1,147 @@ +package maven + +import ( + "os" + "path/filepath" + "strings" + + "github.com/jfrog/jfrog-client-go/utils/errorutils" +) + +const pomFileName = "pom.xml" + +type discoveryOptions struct { + pomFile string + projectList []string +} + +func discoverProjectRoot(workingDir string) (string, error) { + return discoverProjectRootWithOptions(workingDir, discoveryOptions{}) +} + +func discoverProjectRootWithOptions(workingDir string, opts discoveryOptions) (string, error) { + startDir, err := effectiveStartDir(workingDir, opts.pomFile) + if err != nil { + return "", err + } + dir := startDir + var firstPomDir string + var topAggregator string + for { + pomPath := filepath.Join(dir, pomFileName) + if _, statErr := os.Stat(pomPath); statErr == nil { + if firstPomDir == "" { + firstPomDir = dir + } + data, readErr := os.ReadFile(pomPath) + if readErr == nil { + pom, parseErr := parsePom(data) + if parseErr == nil && pom.isAggregator() { + topAggregator = dir + } + } + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + if topAggregator != "" { + return topAggregator, nil + } + if firstPomDir != "" { + return firstPomDir, nil + } + return "", errorutils.CheckErrorf("no %s found from %s", pomFileName, startDir) +} + +func effectiveStartDir(workingDir, pomFile string) (string, error) { + abs, err := filepath.Abs(workingDir) + if err != nil { + return "", errorutils.CheckError(err) + } + if pomFile == "" { + return abs, nil + } + pomPath := pomFile + if !filepath.IsAbs(pomPath) { + pomPath = filepath.Join(abs, pomPath) + } + pomPath = filepath.Clean(pomPath) + if _, statErr := os.Stat(pomPath); statErr != nil { + return "", errorutils.CheckErrorf("%s not found: %s", pomFileName, pomPath) + } + return filepath.Dir(pomPath), nil +} + +func discoverPomPaths(projectRoot string) ([]string, error) { + return discoverPomPathsWithOptions(projectRoot, discoveryOptions{}) +} + +func discoverPomPathsWithOptions(projectRoot string, opts discoveryOptions) ([]string, error) { + rootPom := filepath.Join(projectRoot, pomFileName) + if _, err := os.Stat(rootPom); err != nil { + return nil, errorutils.CheckErrorf("expected %s under %s", pomFileName, projectRoot) + } + all, err := collectReactorPoms(projectRoot, pomFileName) + if err != nil { + return nil, err + } + if len(opts.projectList) == 0 { + return all, nil + } + return filterByProjectList(all, opts.projectList) +} + +func collectReactorPoms(projectRoot, relPom string) ([]string, error) { + full := filepath.Join(projectRoot, relPom) + data, err := os.ReadFile(full) + if err != nil { + return nil, errorutils.CheckError(err) + } + pom, err := parsePom(data) + if err != nil { + return nil, errorutils.CheckError(err) + } + paths := []string{relPom} + if !pom.isAggregator() { + return paths, nil + } + baseDir := filepath.Dir(relPom) + for _, mod := range pom.modulePaths() { + mod = filepath.FromSlash(strings.TrimSpace(mod)) + if mod == "" { + continue + } + childRel := filepath.Join(baseDir, mod, pomFileName) + childRel = filepath.Clean(childRel) + sub, err := collectReactorPoms(projectRoot, childRel) + if err != nil { + return nil, err + } + paths = append(paths, sub...) + } + return paths, nil +} + +func filterByProjectList(relPoms []string, projectList []string) ([]string, error) { + want := make(map[string]bool, len(projectList)) + for _, p := range projectList { + want[filepath.Clean(filepath.FromSlash(p))] = true + } + var out []string + for _, rel := range relPoms { + modDir := filepath.Dir(rel) + if modDir == "." { + modDir = "" + } + if want[modDir] { + out = append(out, rel) + } + } + if len(out) == 0 { + return nil, errorutils.CheckErrorf("no reactor modules matched -pl: %s", strings.Join(projectList, ",")) + } + return out, nil +} diff --git a/artifactory/healcomponents/maven/discover_test.go b/artifactory/healcomponents/maven/discover_test.go new file mode 100644 index 00000000..95ddfaff --- /dev/null +++ b/artifactory/healcomponents/maven/discover_test.go @@ -0,0 +1,107 @@ +package maven + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDiscoverProjectRoot_FromSubmodule(t *testing.T) { + root := t.TempDir() + sub := filepath.Join(root, "module-a") + require.NoError(t, os.MkdirAll(sub, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "pom.xml"), []byte(`pommodule-a`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(sub, "pom.xml"), []byte(`module-a`), 0644)) + + got, err := discoverProjectRoot(sub) + require.NoError(t, err) + assert.Equal(t, root, got) +} + +func TestDiscoverPomPaths_MultiModule(t *testing.T) { + root := t.TempDir() + modA := filepath.Join(root, "module-a") + require.NoError(t, os.MkdirAll(modA, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "pom.xml"), []byte(`pommodule-a`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(modA, "pom.xml"), []byte(``), 0644)) + require.NoError(t, os.MkdirAll(filepath.Join(modA, "target"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(modA, "target", "pom.xml"), []byte(``), 0644)) + + paths, err := discoverPomPaths(root) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"pom.xml", filepath.Join("module-a", "pom.xml")}, paths) +} + +func TestDiscoverPomPaths_ExcludesStrayPomNotInReactor(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "mod-a"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "src", "test", "resources"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "pom.xml"), []byte(`pommod-a`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "mod-a", "pom.xml"), []byte(``), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "src", "test", "resources", "pom.xml"), []byte(``), 0644)) + + paths, err := discoverPomPaths(root) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"pom.xml", filepath.Join("mod-a", "pom.xml")}, paths) +} + +func TestDiscoverPomPaths_NestedAggregator(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "services", "api"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "pom.xml"), []byte(`pomservices`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "services", "pom.xml"), []byte(`pomapi`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "services", "api", "pom.xml"), []byte(``), 0644)) + + paths, err := discoverPomPaths(root) + require.NoError(t, err) + assert.ElementsMatch(t, []string{ + "pom.xml", + filepath.Join("services", "pom.xml"), + filepath.Join("services", "api", "pom.xml"), + }, paths) +} + +func TestDiscoverProjectRoot_FromFileFlag(t *testing.T) { + root := t.TempDir() + sub := filepath.Join(root, "sub", "mod") + require.NoError(t, os.MkdirAll(sub, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "pom.xml"), []byte(`pomsub`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "sub", "pom.xml"), []byte(`pommod`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(sub, "pom.xml"), []byte(``), 0644)) + + cwd := t.TempDir() + got, err := discoverProjectRootWithOptions(cwd, discoveryOptions{pomFile: filepath.Join(root, "sub", "pom.xml")}) + require.NoError(t, err) + assert.Equal(t, root, got) +} + +func TestDiscoverPomPaths_ProjectListFilter(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "mod-a"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "mod-b"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "pom.xml"), []byte(`pommod-amod-b`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "mod-a", "pom.xml"), []byte(``), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "mod-b", "pom.xml"), []byte(``), 0644)) + + paths, err := discoverPomPathsWithOptions(root, discoveryOptions{projectList: []string{"mod-a"}}) + require.NoError(t, err) + assert.ElementsMatch(t, []string{filepath.Join("mod-a", "pom.xml")}, paths) +} + +func TestDiscoverPomPaths_MavenExampleTestdata(t *testing.T) { + root := filepath.Join("..", "..", "..", "tests", "testdata", "maven-example") + if _, err := os.Stat(root); err != nil { + t.Skip("testdata not available") + } + paths, err := discoverPomPaths(root) + require.NoError(t, err) + assert.ElementsMatch(t, []string{ + "pom.xml", + filepath.Join("multi1", "pom.xml"), + filepath.Join("multi2", "pom.xml"), + filepath.Join("multi3", "pom.xml"), + }, paths) +} diff --git a/artifactory/healcomponents/maven/goals.go b/artifactory/healcomponents/maven/goals.go new file mode 100644 index 00000000..c8ac45dd --- /dev/null +++ b/artifactory/healcomponents/maven/goals.go @@ -0,0 +1,100 @@ +package maven + +import "strings" + +type mavenCLIArgs struct { + pomFile string // relative or absolute path from -f/--file + projectList []string // from -pl/--projects (comma-separated segments already split) +} + +func parseMavenCLIArgs(goals []string) (pomFile string, projectList []string) { + a := parseMavenCLIArgsStruct(goals) + return a.pomFile, a.projectList +} + +func parseMavenCLIArgsStruct(goals []string) mavenCLIArgs { + var out mavenCLIArgs + for i := 0; i < len(goals); i++ { + g := goals[i] + switch { + case g == "-f" || g == "--file": + if i+1 < len(goals) { + i++ + out.pomFile = goals[i] + } + case strings.HasPrefix(g, "-f"): + out.pomFile = strings.TrimPrefix(g, "-f") + case strings.HasPrefix(g, "--file="): + out.pomFile = strings.TrimPrefix(g, "--file=") + case g == "-pl" || g == "--projects": + if i+1 < len(goals) { + i++ + out.projectList = splitProjectList(goals[i]) + } + case strings.HasPrefix(g, "-pl"): + out.projectList = splitProjectList(strings.TrimPrefix(g, "-pl")) + case strings.HasPrefix(g, "--projects="): + out.projectList = splitProjectList(strings.TrimPrefix(g, "--projects=")) + } + } + return out +} + +func splitProjectList(s string) []string { + parts := strings.Split(s, ",") + var out []string + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +func DeriveResolutionCommand(goals []string) string { + if ShouldSkipResolution(goals) { + return "" + } + for _, g := range goals { + if isDependencyResolvingGoal(g) { + return "resolve" + } + } + return "" +} + +func ShouldSkipResolution(goals []string) bool { + if len(goals) == 0 { + return true + } + if len(goals) == 1 && goals[0] == "clean" { + return true + } + for _, g := range goals { + lower := strings.ToLower(g) + switch { + case lower == "help", lower == "-v", lower == "-version", lower == "version": + return true + case lower == "dependency:tree", strings.HasSuffix(lower, ":tree"): + return true + } + } + return false +} + +func isDependencyResolvingGoal(goal string) bool { + lower := strings.ToLower(goal) + phases := []string{"validate", "initialize", "generate-sources", "process-sources", + "generate-resources", "process-resources", "compile", "process-classes", + "generate-test-sources", "process-test-sources", "generate-test-resources", + "process-test-resources", "test-compile", "process-test-classes", "test", + "prepare-package", "package", "pre-integration-test", "integration-test", + "post-integration-test", "verify", "install", "deploy"} + for _, phase := range phases { + if lower == phase || strings.HasSuffix(lower, ":"+phase) { + return true + } + } + return false +} diff --git a/artifactory/healcomponents/maven/goals_test.go b/artifactory/healcomponents/maven/goals_test.go new file mode 100644 index 00000000..4f9e0bdb --- /dev/null +++ b/artifactory/healcomponents/maven/goals_test.go @@ -0,0 +1,43 @@ +package maven + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDeriveResolutionCommand(t *testing.T) { + assert.Equal(t, "resolve", DeriveResolutionCommand([]string{"install"})) + assert.Equal(t, "resolve", DeriveResolutionCommand([]string{"clean", "verify"})) + assert.Equal(t, "", DeriveResolutionCommand([]string{"help"})) + assert.Equal(t, "", DeriveResolutionCommand([]string{"clean"})) + assert.Equal(t, "", DeriveResolutionCommand([]string{"dependency:tree"})) +} + +func TestShouldSkipResolution(t *testing.T) { + assert.True(t, ShouldSkipResolution([]string{"help"})) + assert.False(t, ShouldSkipResolution([]string{"install"})) +} + +func TestParseMavenCLIArgs_FileFlag(t *testing.T) { + pom, projects := parseMavenCLIArgs([]string{"-f", "sub/pom.xml", "install"}) + assert.Equal(t, "sub/pom.xml", pom) + assert.Nil(t, projects) + + pom, _ = parseMavenCLIArgs([]string{"--file", "other/pom.xml", "clean", "install"}) + assert.Equal(t, "other/pom.xml", pom) +} + +func TestParseMavenCLIArgs_ProjectsFlag(t *testing.T) { + _, projects := parseMavenCLIArgs([]string{"-pl", "mod-a,mod-b", "install"}) + assert.Equal(t, []string{"mod-a", "mod-b"}, projects) + + _, projects = parseMavenCLIArgs([]string{"--projects", "services/api", "verify"}) + assert.Equal(t, []string{"services/api"}, projects) +} + +func TestParseMavenCLIArgs_NoFlags(t *testing.T) { + pom, projects := parseMavenCLIArgs([]string{"clean", "install"}) + assert.Empty(t, pom) + assert.Nil(t, projects) +} diff --git a/artifactory/healcomponents/maven/integration_test.go b/artifactory/healcomponents/maven/integration_test.go new file mode 100644 index 00000000..28ca8bf5 --- /dev/null +++ b/artifactory/healcomponents/maven/integration_test.go @@ -0,0 +1,46 @@ +package maven_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/jfrog/jfrog-cli-artifactory/artifactory/healcomponents" + cmaven "github.com/jfrog/jfrog-cli-artifactory/artifactory/healcomponents/maven" + "github.com/jfrog/jfrog-client-go/xray/services" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type integrationMockClient struct { + resp services.ComponentResolutionResponse +} + +func (m *integrationMockClient) GetVersion() (string, error) { + return healcomponents.HealComponentsMinVersion, nil +} + +func (m *integrationMockClient) HealComponents(_ services.ComponentResolutionRequest) (*services.ComponentResolutionResponse, bool, error) { + return &m.resp, false, nil +} + +func TestIntegration_MavenHealingWritesPOM(t *testing.T) { + dir := t.TempDir() + orig := `before` + require.NoError(t, os.WriteFile(filepath.Join(dir, "pom.xml"), []byte(orig), 0644)) + + healed := `after` + client := &integrationMockClient{resp: services.ComponentResolutionResponse{ + Lockfile: healed, + Changes: []services.Change{{Package: "com.demo:lib:1.0", BeforeIntegrity: "a", AfterIntegrity: "b"}}, + }} + _, healedFlag, err := healcomponents.RunIfEnabled(context.Background(), client, "maven-virtual", + cmaven.NewBuildTool(), "resolve", dir, nil) + require.NoError(t, err) + assert.True(t, healedFlag) + + data, err := os.ReadFile(filepath.Join(dir, "pom.xml")) + require.NoError(t, err) + assert.Equal(t, healed, string(data)) +} diff --git a/artifactory/healcomponents/maven/maven.go b/artifactory/healcomponents/maven/maven.go new file mode 100644 index 00000000..e0a3c87e --- /dev/null +++ b/artifactory/healcomponents/maven/maven.go @@ -0,0 +1,69 @@ +package maven + +import ( + "context" + "os" + "path/filepath" + + "github.com/jfrog/jfrog-cli-artifactory/artifactory/healcomponents" + "github.com/jfrog/jfrog-client-go/utils/errorutils" +) + +const toolName = "maven" + +type BuildTool struct { + goals []string +} + +func NewBuildTool() BuildTool { return BuildTool{} } + +func NewBuildToolWithGoals(goals []string) BuildTool { + return BuildTool{goals: goals} +} + +func (t BuildTool) discoveryOptions() discoveryOptions { + pomFile, projectList := parseMavenCLIArgs(t.goals) + return discoveryOptions{pomFile: pomFile, projectList: projectList} +} + +func (BuildTool) ToolName() string { return toolName } + +func (BuildTool) RelevantCommands() []string { return []string{"resolve"} } + +func (t BuildTool) ProjectRoot(workingDir string) (string, error) { + return discoverProjectRootWithOptions(workingDir, t.discoveryOptions()) +} + +func (t BuildTool) EnsureLockfiles(_ context.Context, projectRoot, _ string, _ healcomponents.CommandRunner, _ ...string) ([]string, error) { + paths, err := discoverPomPathsWithOptions(projectRoot, t.discoveryOptions()) + if err != nil { + return nil, err + } + if len(paths) == 0 { + return nil, errorutils.CheckErrorf("component resolution requires %s under %s", pomFileName, projectRoot) + } + return nil, nil +} + +func (t BuildTool) DiscoverLockfiles(workingDir string) ([]healcomponents.Lockfile, error) { + root, err := discoverProjectRootWithOptions(workingDir, t.discoveryOptions()) + if err != nil { + return nil, err + } + relPaths, err := discoverPomPathsWithOptions(root, t.discoveryOptions()) + if err != nil { + return nil, err + } + if len(relPaths) == 0 { + return nil, errorutils.CheckErrorf("expected %s under %s", pomFileName, root) + } + var lockfiles []healcomponents.Lockfile + for _, rel := range relPaths { + data, readErr := os.ReadFile(filepath.Join(root, rel)) + if readErr != nil { + return nil, errorutils.CheckError(readErr) + } + lockfiles = append(lockfiles, healcomponents.Lockfile{Path: rel, Content: data}) + } + return lockfiles, nil +} diff --git a/artifactory/healcomponents/maven/maven_test.go b/artifactory/healcomponents/maven/maven_test.go new file mode 100644 index 00000000..aa030187 --- /dev/null +++ b/artifactory/healcomponents/maven/maven_test.go @@ -0,0 +1,55 @@ +package maven + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMavenBuildTool_DiscoverLockfiles_MultiModule(t *testing.T) { + root := t.TempDir() + modA := filepath.Join(root, "module-a") + require.NoError(t, os.MkdirAll(modA, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "pom.xml"), []byte(`pommodule-a`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(modA, "pom.xml"), []byte(`mod`), 0644)) + + tool := NewBuildTool() + files, err := tool.DiscoverLockfiles(root) + require.NoError(t, err) + require.Len(t, files, 2) +} + +func TestMavenBuildTool_EnsureLockfiles_ErrorsWhenNoPom(t *testing.T) { + dir := t.TempDir() + tool := NewBuildTool() + _, err := tool.EnsureLockfiles(context.Background(), dir, "resolve", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "pom.xml") +} + +func TestMavenBuildTool_DiscoverLockfiles_RespectsFileFlag(t *testing.T) { + repoRoot := t.TempDir() + sub := filepath.Join(repoRoot, "child") + require.NoError(t, os.MkdirAll(sub, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(sub, "pom.xml"), []byte(`child`), 0644)) + + cwd := t.TempDir() + tool := NewBuildToolWithGoals([]string{"-f", filepath.Join(sub, "pom.xml"), "install"}) + files, err := tool.DiscoverLockfiles(cwd) + require.NoError(t, err) + require.Len(t, files, 1) + assert.Equal(t, "pom.xml", files[0].Path) +} + +func TestMavenBuildTool_EnsureLockfiles_NoOpWhenPomsExist(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "pom.xml"), []byte(``), 0644)) + tool := NewBuildTool() + bootstrapped, err := tool.EnsureLockfiles(context.Background(), dir, "resolve", nil) + require.NoError(t, err) + assert.Empty(t, bootstrapped) +} diff --git a/artifactory/healcomponents/maven/pom.go b/artifactory/healcomponents/maven/pom.go new file mode 100644 index 00000000..a1b10d80 --- /dev/null +++ b/artifactory/healcomponents/maven/pom.go @@ -0,0 +1,37 @@ +package maven + +import "encoding/xml" + +type pomDocument struct { + XMLName xml.Name `xml:"project"` + Packaging string `xml:"packaging"` + Modules struct { + Module []string `xml:"module"` + } `xml:"modules"` +} + +func parsePom(data []byte) (*pomDocument, error) { + var p pomDocument + if err := xml.Unmarshal(data, &p); err != nil { + return nil, err + } + return &p, nil +} + +func (p *pomDocument) isAggregator() bool { + if p == nil { + return false + } + if len(p.Modules.Module) == 0 { + return false + } + // Maven requires packaging=pom for aggregators; treat missing packaging as jar (not aggregator). + return p.Packaging == "" || p.Packaging == "pom" +} + +func (p *pomDocument) modulePaths() []string { + if p == nil { + return nil + } + return append([]string(nil), p.Modules.Module...) +} diff --git a/artifactory/healcomponents/maven/pom_test.go b/artifactory/healcomponents/maven/pom_test.go new file mode 100644 index 00000000..80fddfd5 --- /dev/null +++ b/artifactory/healcomponents/maven/pom_test.go @@ -0,0 +1,38 @@ +package maven + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParsePom_Aggregator(t *testing.T) { + data := []byte(` + + pom + + mod-a + services/api + +`) + p, err := parsePom(data) + require.NoError(t, err) + assert.True(t, p.isAggregator()) + assert.Equal(t, []string{"mod-a", "services/api"}, p.modulePaths()) +} + +func TestParsePom_SingleModule(t *testing.T) { + data := []byte(`app`) + p, err := parsePom(data) + require.NoError(t, err) + assert.False(t, p.isAggregator()) + assert.Empty(t, p.modulePaths()) +} + +func TestParsePom_IgnoresCommentModules(t *testing.T) { + data := []byte(`jar`) + p, err := parsePom(data) + require.NoError(t, err) + assert.False(t, p.isAggregator()) +} diff --git a/artifactory/healcomponents/npm/args_parse.go b/artifactory/healcomponents/npm/args_parse.go new file mode 100644 index 00000000..9237fa0c --- /dev/null +++ b/artifactory/healcomponents/npm/args_parse.go @@ -0,0 +1,84 @@ +package npm + +import ( + "path/filepath" + "strings" + + "github.com/jfrog/jfrog-client-go/utils/errorutils" +) + +type discoveryOptions struct { + prefixDir string + publishPath string +} + +type npmCLIArgs struct { + prefixDir string + bootstrapArgs []string +} + +func parseNpmCLIArgs(args []string) npmCLIArgs { + var out npmCLIArgs + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--prefix" || arg == "--cwd" || arg == "-C": + if i+1 < len(args) { + i++ + out.prefixDir = args[i] + } + case strings.HasPrefix(arg, "--prefix="): + out.prefixDir = strings.TrimPrefix(arg, "--prefix=") + case strings.HasPrefix(arg, "--cwd="): + out.prefixDir = strings.TrimPrefix(arg, "--cwd=") + case strings.HasPrefix(arg, "-C"): + if arg == "-C" { + continue + } + out.prefixDir = strings.TrimPrefix(arg, "-C") + case arg == "--workspaces" || arg == "-w": + out.bootstrapArgs = append(out.bootstrapArgs, arg) + case strings.HasPrefix(arg, "--workspace="): + out.bootstrapArgs = append(out.bootstrapArgs, arg) + case arg == "--workspace" && i+1 < len(args): + out.bootstrapArgs = append(out.bootstrapArgs, arg, args[i+1]) + i++ + } + } + return out +} + +// BootstrapArgsFrom extracts workspace flags to pass to npm install --package-lock-only. +func BootstrapArgsFrom(npmArgs []string) []string { + return parseNpmCLIArgs(npmArgs).bootstrapArgs +} + +func effectiveStartDir(workingDir string, opts discoveryOptions) (string, error) { + abs, err := filepath.Abs(workingDir) + if err != nil { + return "", errorutils.CheckError(err) + } + if opts.publishPath != "" { + return resolveDiscoveryPath(abs, opts.publishPath) + } + if opts.prefixDir != "" { + return resolveDiscoveryPath(abs, opts.prefixDir) + } + return abs, nil +} + +// resolveDiscoveryPath joins base and p unless p is already absolute. +// On Windows, Unix-style paths (e.g. /repo/pkg) are not filepath.IsAbs but must not be joined with base. +func resolveDiscoveryPath(base, p string) (string, error) { + if filepath.IsAbs(p) { + return filepath.Clean(p), nil + } + if strings.HasPrefix(filepath.ToSlash(p), "/") { + abs, err := filepath.Abs(p) + if err != nil { + return "", errorutils.CheckError(err) + } + return filepath.Clean(abs), nil + } + return filepath.Clean(filepath.Join(base, p)), nil +} diff --git a/artifactory/healcomponents/npm/args_parse_test.go b/artifactory/healcomponents/npm/args_parse_test.go new file mode 100644 index 00000000..e63019a6 --- /dev/null +++ b/artifactory/healcomponents/npm/args_parse_test.go @@ -0,0 +1,63 @@ +package npm + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseNpmCLIArgs_Prefix(t *testing.T) { + opts := parseNpmCLIArgs([]string{"install", "--prefix", "sub/pkg"}) + assert.Equal(t, "sub/pkg", opts.prefixDir) +} + +func TestParseNpmCLIArgs_CShort(t *testing.T) { + opts := parseNpmCLIArgs([]string{"-C", "services/api", "ci"}) + assert.Equal(t, "services/api", opts.prefixDir) +} + +func TestParseNpmCLIArgs_PrefixEquals(t *testing.T) { + opts := parseNpmCLIArgs([]string{"install", "--prefix=frontend"}) + assert.Equal(t, "frontend", opts.prefixDir) +} + +func TestParseNpmCLIArgs_WorkspaceBootstrap(t *testing.T) { + opts := parseNpmCLIArgs([]string{"install", "--workspace", "@scope/pkg", "-w"}) + assert.Equal(t, []string{"--workspace", "@scope/pkg", "-w"}, opts.bootstrapArgs) +} + +func TestEffectiveStartDir_PublishPathOverridesCwd(t *testing.T) { + root := t.TempDir() + publishPath := filepath.Join(root, "packages", "foo") + got, err := effectiveStartDir(root, discoveryOptions{publishPath: publishPath}) + assert.NoError(t, err) + assert.Equal(t, publishPath, got) +} + +func TestEffectiveStartDir_PublishPathUnixAbsolute(t *testing.T) { + got, err := effectiveStartDir("/repo", discoveryOptions{publishPath: "/repo/packages/foo"}) + assert.NoError(t, err) + want, err := filepath.Abs("/repo/packages/foo") + assert.NoError(t, err) + assert.Equal(t, want, got) +} + +func TestEffectiveStartDir_PrefixFromArgs(t *testing.T) { + root := t.TempDir() + got, err := effectiveStartDir(root, discoveryOptions{prefixDir: "sub"}) + assert.NoError(t, err) + assert.Equal(t, filepath.Join(root, "sub"), got) +} + +func TestEffectiveStartDir_PrefixFromArgsUnixRoot(t *testing.T) { + got, err := effectiveStartDir("/repo", discoveryOptions{prefixDir: "sub"}) + assert.NoError(t, err) + root, err := filepath.Abs("/repo") + assert.NoError(t, err) + assert.Equal(t, filepath.Join(root, "sub"), got) +} + +func TestBootstrapArgsFrom(t *testing.T) { + assert.Equal(t, []string{"-w"}, BootstrapArgsFrom([]string{"install", "-w"})) +} diff --git a/artifactory/healcomponents/npm/discover.go b/artifactory/healcomponents/npm/discover.go new file mode 100644 index 00000000..832c943b --- /dev/null +++ b/artifactory/healcomponents/npm/discover.go @@ -0,0 +1,68 @@ +package npm + +import ( + "os" + "path/filepath" + + "github.com/jfrog/jfrog-client-go/utils/errorutils" +) + +const ( + lockfileName = "package-lock.json" + shrinkwrapFileName = "npm-shrinkwrap.json" +) + +func discoverProjectRoot(workingDir string) (string, error) { + return discoverProjectRootWithOptions(workingDir, discoveryOptions{}) +} + +func discoverProjectRootWithOptions(workingDir string, opts discoveryOptions) (string, error) { + startDir, err := effectiveStartDir(workingDir, opts) + if err != nil { + return "", err + } + dir := startDir + var firstPackageJSON string + var topWorkspaceRoot string + for { + pkgPath := filepath.Join(dir, "package.json") + if _, statErr := os.Stat(pkgPath); statErr == nil { + if firstPackageJSON == "" { + firstPackageJSON = dir + } + if data, readErr := os.ReadFile(pkgPath); readErr == nil { + if pkg, parseErr := parsePackageJSON(data); parseErr == nil && pkg.hasWorkspaces() { + topWorkspaceRoot = dir + } + } + } + if _, statErr := os.Stat(filepath.Join(dir, shrinkwrapFileName)); statErr == nil { + return dir, nil + } + if _, statErr := os.Stat(filepath.Join(dir, lockfileName)); statErr == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + if topWorkspaceRoot != "" { + return topWorkspaceRoot, nil + } + if firstPackageJSON != "" { + return firstPackageJSON, nil + } + return "", errorutils.CheckErrorf("no %s or lockfile found from %s", "package.json", startDir) +} + +func lockfileNameInDir(dir string) (string, error) { + if _, err := os.Stat(filepath.Join(dir, shrinkwrapFileName)); err == nil { + return shrinkwrapFileName, nil + } + if _, err := os.Stat(filepath.Join(dir, lockfileName)); err == nil { + return lockfileName, nil + } + return "", errorutils.CheckErrorf("no %s or %s under %s", shrinkwrapFileName, lockfileName, dir) +} diff --git a/artifactory/healcomponents/npm/discover_test.go b/artifactory/healcomponents/npm/discover_test.go new file mode 100644 index 00000000..2ba27bb2 --- /dev/null +++ b/artifactory/healcomponents/npm/discover_test.go @@ -0,0 +1,92 @@ +package npm + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDiscoverProjectRoot_FromWorkspacePackage(t *testing.T) { + root := t.TempDir() + pkgDir := filepath.Join(root, "packages", "app") + require.NoError(t, os.MkdirAll(pkgDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"workspaces":["packages/*"]}`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "package-lock.json"), []byte(`{}`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(pkgDir, "package.json"), []byte(`{"name":"app"}`), 0644)) + + got, err := discoverProjectRoot(pkgDir) + require.NoError(t, err) + assert.Equal(t, root, got) +} + +func TestDiscoverProjectRoot_PrefixFlag(t *testing.T) { + root := t.TempDir() + sub := filepath.Join(root, "frontend") + require.NoError(t, os.MkdirAll(sub, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(sub, "package.json"), []byte(`{"name":"fe"}`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(sub, "package-lock.json"), []byte(`{}`), 0644)) + + got, err := discoverProjectRootWithOptions(root, discoveryOptions{prefixDir: "frontend"}) + require.NoError(t, err) + assert.Equal(t, sub, got) +} + +func TestDiscoverProjectRoot_ShrinkwrapPrecedence(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"app"}`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte(`{"lock":1}`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "npm-shrinkwrap.json"), []byte(`{"shrink":1}`), 0644)) + + name, err := lockfileNameInDir(dir) + require.NoError(t, err) + assert.Equal(t, shrinkwrapFileName, name) +} + +func TestDiscoverProjectRoot_IndependentPackageLock(t *testing.T) { + root := t.TempDir() + svc := filepath.Join(root, "svc-a") + require.NoError(t, os.MkdirAll(svc, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"name":"root-no-ws"}`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(svc, "package.json"), []byte(`{"name":"svc-a"}`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(svc, "package-lock.json"), []byte(`{}`), 0644)) + + got, err := discoverProjectRoot(svc) + require.NoError(t, err) + assert.Equal(t, svc, got) +} + +func TestDiscoverProjectRoot_NoProjectFound(t *testing.T) { + dir := t.TempDir() + _, err := discoverProjectRoot(dir) + require.Error(t, err) + assert.Contains(t, err.Error(), "package.json") +} + +func TestDiscoverProjectRoot_IgnoresStrayPackageJSONWhenLockAbove(t *testing.T) { + root := t.TempDir() + svc := filepath.Join(root, "svc") + stray := filepath.Join(svc, "samples") + require.NoError(t, os.MkdirAll(stray, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(svc, "package.json"), []byte(`{"name":"svc"}`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(svc, "package-lock.json"), []byte(`{}`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(stray, "package.json"), []byte(`{"name":"sample"}`), 0644)) + + got, err := discoverProjectRoot(stray) + require.NoError(t, err) + assert.Equal(t, svc, got) +} + +func TestDiscoverProjectRoot_PublishPath(t *testing.T) { + root := t.TempDir() + pkg := filepath.Join(root, "packages", "lib") + require.NoError(t, os.MkdirAll(pkg, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(pkg, "package.json"), []byte(`{"name":"lib"}`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(pkg, "package-lock.json"), []byte(`{}`), 0644)) + + got, err := discoverProjectRootWithOptions(root, discoveryOptions{publishPath: filepath.Join("packages", "lib")}) + require.NoError(t, err) + assert.Equal(t, pkg, got) +} diff --git a/artifactory/healcomponents/npm/npm.go b/artifactory/healcomponents/npm/npm.go new file mode 100644 index 00000000..f47eb56f --- /dev/null +++ b/artifactory/healcomponents/npm/npm.go @@ -0,0 +1,91 @@ +package npm + +import ( + "context" + "os" + "path/filepath" + + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/log" + + "github.com/jfrog/jfrog-cli-artifactory/artifactory/healcomponents" +) + +const toolName = "npm" + +type BuildTool struct { + opts discoveryOptions +} + +func NewBuildTool() BuildTool { + return BuildTool{} +} + +func NewBuildToolWithArgs(npmArgs []string) BuildTool { + parsed := parseNpmCLIArgs(npmArgs) + return BuildTool{opts: discoveryOptions{prefixDir: parsed.prefixDir}} +} + +func NewBuildToolForPublish(workingDir, publishPath string, npmArgs []string) BuildTool { + parsed := parseNpmCLIArgs(npmArgs) + return BuildTool{opts: discoveryOptions{ + prefixDir: parsed.prefixDir, + publishPath: publishPath, + }} +} + +func (BuildTool) ToolName() string { return toolName } + +func (BuildTool) RelevantCommands() []string { + return []string{"install", "ci", "publish"} +} + +func (t BuildTool) ProjectRoot(workingDir string) (string, error) { + return discoverProjectRootWithOptions(workingDir, t.opts) +} + +func (t BuildTool) EnsureLockfiles(ctx context.Context, projectRoot, command string, runner healcomponents.CommandRunner, bootstrapArgs ...string) ([]string, error) { + if _, err := os.Stat(filepath.Join(projectRoot, shrinkwrapFileName)); err == nil { + return nil, nil + } else if !os.IsNotExist(err) { + return nil, errorutils.CheckError(err) + } + lockPath := filepath.Join(projectRoot, lockfileName) + if _, err := os.Stat(lockPath); err == nil { + return nil, nil + } else if !os.IsNotExist(err) { + return nil, errorutils.CheckError(err) + } + switch command { + case "ci": + return nil, errorutils.CheckErrorf("component resolution requires %s or %s for npm ci (generate with npm install first)", lockfileName, shrinkwrapFileName) + case "install", "publish": + if runner == nil { + return nil, errorutils.CheckErrorf("npm runner required to bootstrap %s", lockfileName) + } + log.Info("Component resolution: generating ", lockfileName, " (lockfile was missing)") + args := append([]string{"install", "--package-lock-only"}, bootstrapArgs...) + if err := runner(ctx, projectRoot, args...); err != nil { + return nil, errorutils.CheckError(err) + } + return []string{lockfileName}, nil + default: + return nil, nil + } +} + +func (t BuildTool) DiscoverLockfiles(workingDir string) ([]healcomponents.Lockfile, error) { + root, err := discoverProjectRootWithOptions(workingDir, t.opts) + if err != nil { + return nil, err + } + name, err := lockfileNameInDir(root) + if err != nil { + return nil, err + } + data, err := os.ReadFile(filepath.Join(root, name)) + if err != nil { + return nil, errorutils.CheckError(err) + } + return []healcomponents.Lockfile{{Path: name, Content: data}}, nil +} diff --git a/artifactory/healcomponents/npm/npm_test.go b/artifactory/healcomponents/npm/npm_test.go new file mode 100644 index 00000000..04aeb13b --- /dev/null +++ b/artifactory/healcomponents/npm/npm_test.go @@ -0,0 +1,125 @@ +package npm + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNpmBuildTool_DiscoverLockfiles_SingleRootLock(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte(`{"lock":true}`), 0644)) + + tool := NewBuildTool() + files, err := tool.DiscoverLockfiles(dir) + require.NoError(t, err) + require.Len(t, files, 1) + assert.Equal(t, "package-lock.json", files[0].Path) + assert.JSONEq(t, `{"lock":true}`, string(files[0].Content)) +} + +func TestNpmBuildTool_DiscoverLockfiles_Shrinkwrap(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"a"}`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "npm-shrinkwrap.json"), []byte(`{"s":1}`), 0644)) + + tool := NewBuildTool() + files, err := tool.DiscoverLockfiles(dir) + require.NoError(t, err) + require.Len(t, files, 1) + assert.Equal(t, "npm-shrinkwrap.json", files[0].Path) +} + +func TestNpmBuildTool_EnsureLockfiles_SkipsWhenShrinkwrapExists(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"a"}`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "npm-shrinkwrap.json"), []byte(`{}`), 0644)) + + called := false + runner := func(context.Context, string, ...string) error { called = true; return nil } + + tool := NewBuildTool() + _, err := tool.EnsureLockfiles(context.Background(), dir, "install", runner) + require.NoError(t, err) + assert.False(t, called) +} + +func TestNpmBuildTool_EnsureLockfiles_CiRequiresExistingLock(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"app"}`), 0644)) + + tool := NewBuildTool() + _, err := tool.EnsureLockfiles(context.Background(), dir, "ci", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "package-lock.json") +} + +func TestNpmBuildTool_EnsureLockfiles_InstallBootstrapsWhenMissing(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"app"}`), 0644)) + + var ran []string + runner := func(_ context.Context, root string, args ...string) error { + ran = append(ran, root+":"+strings.Join(args, " ")) + require.NoError(t, os.WriteFile(filepath.Join(root, "package-lock.json"), []byte(`{"bootstrapped":true}`), 0644)) + return nil + } + + tool := NewBuildTool() + bootstrapped, err := tool.EnsureLockfiles(context.Background(), dir, "install", runner) + require.NoError(t, err) + assert.Equal(t, []string{"package-lock.json"}, bootstrapped) + assert.Contains(t, ran[0], "install --package-lock-only") +} + +func TestNpmBuildTool_EnsureLockfiles_SkipsBootstrapWhenLockExists(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"app"}`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte(`{}`), 0644)) + + called := false + runner := func(context.Context, string, ...string) error { + called = true + return nil + } + + tool := NewBuildTool() + _, err := tool.EnsureLockfiles(context.Background(), dir, "install", runner) + require.NoError(t, err) + assert.False(t, called) +} + +func TestNpmBuildTool_EnsureLockfiles_PassesWorkspaceFlags(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"app"}`), 0644)) + + var ran []string + runner := func(_ context.Context, root string, args ...string) error { + ran = append(ran, strings.Join(args, " ")) + require.NoError(t, os.WriteFile(filepath.Join(root, "package-lock.json"), []byte(`{}`), 0644)) + return nil + } + + tool := NewBuildTool() + _, err := tool.EnsureLockfiles(context.Background(), dir, "publish", runner, "--workspaces") + require.NoError(t, err) + assert.Contains(t, ran[0], "--workspaces") +} + +func TestNewBuildToolWithArgs_Prefix(t *testing.T) { + tool := NewBuildToolWithArgs([]string{"install", "--prefix", "frontend"}) + root := t.TempDir() + sub := filepath.Join(root, "frontend") + require.NoError(t, os.MkdirAll(sub, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(sub, "package.json"), []byte(`{"name":"fe"}`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(sub, "package-lock.json"), []byte(`{}`), 0644)) + + got, err := tool.ProjectRoot(root) + require.NoError(t, err) + assert.Equal(t, sub, got) +} diff --git a/artifactory/healcomponents/npm/package_json.go b/artifactory/healcomponents/npm/package_json.go new file mode 100644 index 00000000..6e66210a --- /dev/null +++ b/artifactory/healcomponents/npm/package_json.go @@ -0,0 +1,35 @@ +package npm + +import "encoding/json" + +type packageJSON struct { + Workspaces json.RawMessage `json:"workspaces"` +} + +func parsePackageJSON(data []byte) (packageJSON, error) { + var p packageJSON + if err := json.Unmarshal(data, &p); err != nil { + return packageJSON{}, err + } + return p, nil +} + +func (p packageJSON) hasWorkspaces() bool { + if len(p.Workspaces) == 0 || string(p.Workspaces) == "null" { + return false + } + if string(p.Workspaces) == "[]" { + return false + } + var arr []any + if json.Unmarshal(p.Workspaces, &arr) == nil { + return len(arr) > 0 + } + var obj struct { + Packages []any `json:"packages"` + } + if json.Unmarshal(p.Workspaces, &obj) == nil { + return len(obj.Packages) > 0 + } + return true +} diff --git a/artifactory/healcomponents/npm/package_json_test.go b/artifactory/healcomponents/npm/package_json_test.go new file mode 100644 index 00000000..92247ffc --- /dev/null +++ b/artifactory/healcomponents/npm/package_json_test.go @@ -0,0 +1,32 @@ +package npm + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParsePackageJSON_WorkspacesArray(t *testing.T) { + p, err := parsePackageJSON([]byte(`{"workspaces":["packages/*"]}`)) + require.NoError(t, err) + assert.True(t, p.hasWorkspaces()) +} + +func TestParsePackageJSON_WorkspacesObject(t *testing.T) { + p, err := parsePackageJSON([]byte(`{"workspaces":{"packages":["apps/*"],"nohoist":["**/react-native"]}}`)) + require.NoError(t, err) + assert.True(t, p.hasWorkspaces()) +} + +func TestParsePackageJSON_NoWorkspaces(t *testing.T) { + p, err := parsePackageJSON([]byte(`{"name":"app"}`)) + require.NoError(t, err) + assert.False(t, p.hasWorkspaces()) +} + +func TestParsePackageJSON_EmptyWorkspacesArray(t *testing.T) { + p, err := parsePackageJSON([]byte(`{"workspaces":[]}`)) + require.NoError(t, err) + assert.False(t, p.hasWorkspaces()) +} diff --git a/artifactory/healcomponents/service.go b/artifactory/healcomponents/service.go new file mode 100644 index 00000000..e29f876c --- /dev/null +++ b/artifactory/healcomponents/service.go @@ -0,0 +1,186 @@ +package healcomponents + +import ( + "context" + "os" + "path/filepath" + + clientutils "github.com/jfrog/jfrog-client-go/utils" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/log" + "github.com/jfrog/jfrog-client-go/xray/services" +) + +// HealComponentsDisabledEnvVar disables Xray heal components when set to "true". +// Feature is enabled by default; users run normal jf commands with no extra setup. +const HealComponentsDisabledEnvVar = "JFROG_CLI_HEAL_COMPONENTS_DISABLED" + +const HealComponentsMinVersion = "3.133.0" + +var noopRestore = func() error { return nil } + +// SkipHealing logs and returns without error. Component healing is best-effort and must not fail the caller's build. +func SkipHealing(message string, cause error) (func() error, bool, error) { + if cause != nil { + log.Debug(message + cause.Error()) + } else { + log.Debug(message) + } + return noopRestore, false, nil +} + +func skipHealingWarn(message string, cause error) (func() error, bool, error) { + if cause != nil { + log.Warn(message + cause.Error()) + } else { + log.Warn(message) + } + return noopRestore, false, nil +} + +func IsComponentResolutionDisabled() bool { + return os.Getenv(HealComponentsDisabledEnvVar) == "true" +} + +// Lockfile is a CLI-side lock artifact. Path is relative to project root (read/write only — not sent to Xray). +type Lockfile struct { + Path string + Content []byte +} + +type lockfileBackup struct { + path string + content []byte // nil means the file did not exist before apply +} + +// ComponentResolutionClient resolves a single lockfile via Xray. +type ComponentResolutionClient interface { + GetVersion() (string, error) + HealComponents(req services.ComponentResolutionRequest) (*services.ComponentResolutionResponse, bool, error) +} + +// RunIfEnabled ensures lockfiles exist, discovers them, calls Xray once per file, writes healed lockfiles when changes returned. +// Returns a restore function to revert lockfile writes if the subsequent build-tool command fails, and healed=true when at least one lockfile was updated. +func RunIfEnabled(ctx context.Context, client ComponentResolutionClient, repo string, tool BuildTool, command, workingDir string, runner CommandRunner, bootstrapArgs ...string) (restore func() error, healed bool, err error) { + if IsComponentResolutionDisabled() || !IsRelevantCommand(tool, command) { + log.Debug("Xray heal components disabled or not relevant command: ", command) + return noopRestore, false, nil + } + version, err := client.GetVersion() + if err != nil { + return noopRestore, false, err + } + log.Debug("Xray version: ", version) + if versionErr := clientutils.ValidateMinimumVersion(clientutils.Xray, version, HealComponentsMinVersion); versionErr != nil { + return skipHealingWarn("Xray heal components are not supported on the current Xray version. ", versionErr) + } + log.Debug("Running Xray heal components at '"+repo+"' RT repository for tool:", tool.ToolName()) + projectRoot, err := tool.ProjectRoot(workingDir) + if err != nil { + return noopRestore, false, err + } + log.Debug("Ensuring lockfiles in project root: ", projectRoot) + bootstrapped, err := tool.EnsureLockfiles(ctx, projectRoot, command, runner, bootstrapArgs...) + if err != nil { + return noopRestore, false, err + } + lockfiles, err := tool.DiscoverLockfiles(workingDir) + if err != nil { + return noopRestore, false, err + } + log.Debug("Discovered lockfiles: ", getLockfilePaths(lockfiles)) + var toWrite []Lockfile + var totalChanges int + for _, lf := range lockfiles { + resp, disabled, err := client.HealComponents(services.ComponentResolutionRequest{ + BuildTool: tool.ToolName(), + Repo: repo, + Lockfile: string(lf.Content), + }) + if err != nil { + return noopRestore, false, errorutils.CheckError(err) + } + if disabled { + log.Debug("Xray component healing skipped: self-heal is disabled on the server") + return noopRestore, false, nil + } + if len(resp.Changes) == 0 { + log.Debug("No changes for ", lf.Path) + continue + } + toWrite = append(toWrite, Lockfile{Path: lf.Path, Content: []byte(resp.Lockfile)}) + totalChanges += len(resp.Changes) + log.Debug("Healed", lf.Path, "with", len(resp.Changes), "package change(s)") + for _, ch := range resp.Changes { + log.Debug(" ", ch.Package, ":", ch.BeforeIntegrity, "→", ch.AfterIntegrity) + } + } + if len(toWrite) == 0 { + return noopRestore, false, nil + } + log.Debug("Applying", len(toWrite), "healed lockfile(s)...") + restore, err = ApplyLockfiles(projectRoot, toWrite, bootstrapped) + if err != nil { + return noopRestore, false, err + } + log.Info("Xray component resolution healed", totalChanges, "package change(s) across", len(toWrite), "lockfile(s)") + return restore, true, nil +} + +func getLockfilePaths(lockfiles []Lockfile) []string { + paths := make([]string, 0, len(lockfiles)) + for _, lf := range lockfiles { + paths = append(paths, lf.Path) + } + return paths +} + +// ApplyLockfiles backs up existing lockfiles under projectRoot, writes updates, returns restore func. +// Paths listed in treatAsAbsent are restored by deletion even if they exist on disk (bootstrapped locks). +func ApplyLockfiles(projectRoot string, lockfiles []Lockfile, treatAsAbsent []string) (restore func() error, err error) { + if len(lockfiles) == 0 { + return func() error { return nil }, nil + } + + absent := make(map[string]bool, len(treatAsAbsent)) + for _, path := range treatAsAbsent { + absent[path] = true + } + + var backups []lockfileBackup + for _, lf := range lockfiles { + fullPath := filepath.Join(projectRoot, lf.Path) + backup := lockfileBackup{path: fullPath} + if !absent[lf.Path] { + data, readErr := os.ReadFile(fullPath) + if readErr == nil { + backup.content = data + } else if !os.IsNotExist(readErr) { + return nil, errorutils.CheckError(readErr) + } + } + backups = append(backups, backup) + + if err = os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + return nil, errorutils.CheckError(err) + } + if err = os.WriteFile(fullPath, lf.Content, 0644); err != nil { + return nil, errorutils.CheckError(err) + } + } + + return func() error { + for _, backup := range backups { + if backup.content == nil { + if err = os.Remove(backup.path); err != nil && !os.IsNotExist(err) { + return errorutils.CheckError(err) + } + continue + } + if err = os.WriteFile(backup.path, backup.content, 0644); err != nil { + return errorutils.CheckError(err) + } + } + return nil + }, nil +} diff --git a/artifactory/healcomponents/service_test.go b/artifactory/healcomponents/service_test.go new file mode 100644 index 00000000..767a2d4d --- /dev/null +++ b/artifactory/healcomponents/service_test.go @@ -0,0 +1,322 @@ +package healcomponents + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + testsutils "github.com/jfrog/jfrog-client-go/utils/tests" + "github.com/jfrog/jfrog-client-go/xray/services" +) + +type mockClient struct { + callCount int + lastReq services.ComponentResolutionRequest + resp services.ComponentResolutionResponse + disabled bool + version string + versionErr error +} + +func (m *mockClient) GetVersion() (string, error) { + if m.versionErr != nil { + return "", m.versionErr + } + if m.version != "" { + return m.version, nil + } + return HealComponentsMinVersion, nil +} + +func (m *mockClient) HealComponents(req services.ComponentResolutionRequest) (*services.ComponentResolutionResponse, bool, error) { + m.callCount++ + m.lastReq = req + return &m.resp, m.disabled, nil +} + +type mockTool struct { + name string + commands []string + root string + lockfiles []Lockfile + ensureErr error +} + +func (m mockTool) ToolName() string { + if m.name != "" { + return m.name + } + return "npm" +} +func (m mockTool) RelevantCommands() []string { + if len(m.commands) > 0 { + return m.commands + } + return []string{"install", "ci"} +} +func (m mockTool) ProjectRoot(_ string) (string, error) { + return m.root, nil +} +func (m mockTool) EnsureLockfiles(_ context.Context, _, _ string, _ CommandRunner, _ ...string) ([]string, error) { + if m.ensureErr != nil { + return nil, m.ensureErr + } + return nil, nil +} +func (m mockTool) DiscoverLockfiles(_ string) ([]Lockfile, error) { + return m.lockfiles, nil +} + +func TestIsComponentResolutionDisabled(t *testing.T) { + t.Run("enabled by default", func(t *testing.T) { + t.Setenv(HealComponentsDisabledEnvVar, "") + assert.False(t, IsComponentResolutionDisabled()) + }) + t.Run("disabled when env true", func(t *testing.T) { + t.Setenv(HealComponentsDisabledEnvVar, "true") + assert.True(t, IsComponentResolutionDisabled()) + }) + t.Run("not disabled for other values", func(t *testing.T) { + t.Setenv(HealComponentsDisabledEnvVar, "false") + assert.False(t, IsComponentResolutionDisabled()) + }) +} + +func TestRunIfEnabled_WritesHealedLockfiles(t *testing.T) { + dir := t.TempDir() + lockPath := filepath.Join(dir, "package-lock.json") + require.NoError(t, os.WriteFile(lockPath, []byte("orig"), 0644)) + + client := &mockClient{resp: services.ComponentResolutionResponse{ + Lockfile: "healed", + Changes: []services.Change{{Package: "lodash", BeforeIntegrity: "a", AfterIntegrity: "b"}}, + }} + tool := mockTool{root: dir, lockfiles: []Lockfile{{Path: "package-lock.json", Content: []byte("orig")}}} + + _, healed, err := RunIfEnabled(context.Background(), client, "npm-virtual", tool, "install", dir, nil) + require.NoError(t, err) + assert.True(t, healed) + assert.Equal(t, 1, client.callCount) + assert.Equal(t, "npm", client.lastReq.BuildTool) + assert.Equal(t, "npm-virtual", client.lastReq.Repo) + assert.Equal(t, "orig", client.lastReq.Lockfile) + + data, err := os.ReadFile(lockPath) + require.NoError(t, err) + assert.Equal(t, "healed", string(data)) +} + +func TestRunIfEnabled_WritesHealedNpmLockAsString(t *testing.T) { + dir := t.TempDir() + lockPath := filepath.Join(dir, "package-lock.json") + orig := `{"lockfileVersion":3,"name":"app"}` + require.NoError(t, os.WriteFile(lockPath, []byte(orig), 0644)) + + healed := `{"lockfileVersion":3,"name":"app","healed":true}` + client := &mockClient{resp: services.ComponentResolutionResponse{ + Lockfile: healed, + Changes: []services.Change{{Package: "lodash", BeforeIntegrity: "a", AfterIntegrity: "b"}}, + }} + tool := mockTool{root: dir, lockfiles: []Lockfile{{Path: "package-lock.json", Content: []byte(orig)}}} + + _, healedFlag, err := RunIfEnabled(context.Background(), client, "npm-virtual", tool, "install", dir, nil) + require.NoError(t, err) + assert.True(t, healedFlag) + + data, err := os.ReadFile(lockPath) + require.NoError(t, err) + assert.JSONEq(t, healed, string(data)) + assert.Equal(t, orig, client.lastReq.Lockfile) +} + +func TestRunIfEnabled_SkipsWhenServiceDisabled(t *testing.T) { + dir := t.TempDir() + lockPath := filepath.Join(dir, "package-lock.json") + require.NoError(t, os.WriteFile(lockPath, []byte("orig"), 0644)) + + client := &mockClient{disabled: true} + tool := mockTool{root: dir, lockfiles: []Lockfile{{Path: "package-lock.json", Content: []byte("orig")}}} + + _, healed, err := RunIfEnabled(context.Background(), client, "npm-virtual", tool, "install", dir, nil) + require.NoError(t, err) + assert.False(t, healed) + assert.Equal(t, 1, client.callCount) + + data, err := os.ReadFile(lockPath) + require.NoError(t, err) + assert.Equal(t, "orig", string(data)) +} + +func TestRunIfEnabled_SkipsWhenDisabled(t *testing.T) { + t.Setenv(HealComponentsDisabledEnvVar, "true") + client := &mockClient{} + tool := mockTool{} + _, healed, err := RunIfEnabled(context.Background(), client, "npm-virtual", tool, "install", t.TempDir(), nil) + require.NoError(t, err) + assert.False(t, healed) + assert.Equal(t, 0, client.callCount) +} + +func TestRunIfEnabled_SkipsWhenNoChanges(t *testing.T) { + dir := t.TempDir() + lockPath := filepath.Join(dir, "package-lock.json") + require.NoError(t, os.WriteFile(lockPath, []byte("orig"), 0644)) + + client := &mockClient{resp: services.ComponentResolutionResponse{ + Lockfile: "orig", + Changes: nil, + }} + tool := mockTool{root: dir, lockfiles: []Lockfile{{Path: "package-lock.json", Content: []byte("orig")}}} + + _, healed, err := RunIfEnabled(context.Background(), client, "npm-virtual", tool, "install", dir, nil) + require.NoError(t, err) + assert.False(t, healed) + + data, err := os.ReadFile(lockPath) + require.NoError(t, err) + assert.Equal(t, "orig", string(data)) +} + +func TestRunIfEnabled_LoopsPerDiscoveredFile(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.lock"), []byte("a"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "b.lock"), []byte("b"), 0644)) + + client := &mockClient{resp: services.ComponentResolutionResponse{Lockfile: "x", Changes: nil}} + tool := mockTool{root: dir, lockfiles: []Lockfile{ + {Path: "a.lock", Content: []byte("a")}, + {Path: "b.lock", Content: []byte("b")}, + }} + _, _, err := RunIfEnabled(context.Background(), client, "npm-virtual", tool, "install", dir, nil) + require.NoError(t, err) + assert.Equal(t, 2, client.callCount) +} + +func TestRunIfEnabled_SkipsIrrelevantCommand(t *testing.T) { + client := &mockClient{} + tool := mockTool{} + _, _, err := RunIfEnabled(context.Background(), client, "npm-virtual", tool, "version", t.TempDir(), nil) + require.NoError(t, err) + assert.Equal(t, 0, client.callCount) +} + +func TestRunIfEnabled_WritesHealedMavenPOM(t *testing.T) { + dir := t.TempDir() + pomPath := filepath.Join(dir, "pom.xml") + orig := []byte(`before`) + require.NoError(t, os.WriteFile(pomPath, orig, 0644)) + + healedXML := `after` + client := &mockClient{resp: services.ComponentResolutionResponse{ + Lockfile: healedXML, + Changes: []services.Change{{Package: "com.demo:lib:1.0", BeforeIntegrity: "a", AfterIntegrity: "b"}}, + }} + tool := mockTool{ + name: "maven", + commands: []string{"resolve"}, + root: dir, + lockfiles: []Lockfile{{Path: "pom.xml", Content: orig}}, + } + + _, healed, err := RunIfEnabled(context.Background(), client, "maven-virtual", tool, "resolve", dir, nil) + require.NoError(t, err) + assert.True(t, healed) + assert.Equal(t, string(orig), client.lastReq.Lockfile) + + data, err := os.ReadFile(pomPath) + require.NoError(t, err) + assert.Equal(t, healedXML, string(data)) +} + +func TestRunIfEnabled_PropagatesEnsureLockfilesError(t *testing.T) { + client := &mockClient{} + tool := mockTool{ensureErr: errors.New("package-lock.json is required for npm ci")} + _, _, err := RunIfEnabled(context.Background(), client, "npm-virtual", tool, "ci", t.TempDir(), nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "package-lock.json") + assert.Equal(t, 0, client.callCount) +} + +func TestRunIfEnabled_PropagatesGetVersionError(t *testing.T) { + client := &mockClient{versionErr: errors.New("xray unavailable")} + tool := mockTool{root: t.TempDir(), lockfiles: []Lockfile{{Path: "package-lock.json", Content: []byte("orig")}}} + _, healed, err := RunIfEnabled(context.Background(), client, "npm-virtual", tool, "install", t.TempDir(), nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "xray unavailable") + assert.False(t, healed) + assert.Equal(t, 0, client.callCount) +} + +func TestRunIfEnabled_SkipsWhenXrayVersionTooLow(t *testing.T) { + dir := t.TempDir() + lockPath := filepath.Join(dir, "package-lock.json") + require.NoError(t, os.WriteFile(lockPath, []byte("orig"), 0644)) + + client := &mockClient{ + version: "3.100.0", + resp: services.ComponentResolutionResponse{ + Lockfile: "healed", + Changes: []services.Change{{Package: "lodash", BeforeIntegrity: "a", AfterIntegrity: "b"}}, + }, + } + tool := mockTool{root: dir, lockfiles: []Lockfile{{Path: "package-lock.json", Content: []byte("orig")}}} + + _, healed, err := RunIfEnabled(context.Background(), client, "npm-virtual", tool, "install", dir, nil) + require.NoError(t, err) + assert.False(t, healed) + assert.Equal(t, 0, client.callCount) + + data, err := os.ReadFile(lockPath) + require.NoError(t, err) + assert.Equal(t, "orig", string(data)) +} + +func TestRunIfEnabled_AllowsXrayDevVersion(t *testing.T) { + dir := t.TempDir() + lockPath := filepath.Join(dir, "package-lock.json") + require.NoError(t, os.WriteFile(lockPath, []byte("orig"), 0644)) + + client := &mockClient{ + version: "3.x-dev", + resp: services.ComponentResolutionResponse{ + Lockfile: "healed", + Changes: []services.Change{{Package: "lodash", BeforeIntegrity: "a", AfterIntegrity: "b"}}, + }, + } + tool := mockTool{root: dir, lockfiles: []Lockfile{{Path: "package-lock.json", Content: []byte("orig")}}} + + _, healed, err := RunIfEnabled(context.Background(), client, "npm-virtual", tool, "install", dir, nil) + require.NoError(t, err) + assert.True(t, healed) + assert.Equal(t, 1, client.callCount) + + data, err := os.ReadFile(lockPath) + require.NoError(t, err) + assert.Equal(t, "healed", string(data)) +} + +func TestApplyLockfiles_WritesMultipleFiles(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte("a"), 0644)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "app"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "app/gradle.lockfile"), []byte("b"), 0644)) + + restore, err := ApplyLockfiles(dir, []Lockfile{ + {Path: "package-lock.json", Content: []byte("a-healed")}, + {Path: "app/gradle.lockfile", Content: []byte("b-healed")}, + }, nil) + require.NoError(t, err) + defer testsutils.RemoveAllAndAssert(t, dir) + + a, _ := os.ReadFile(filepath.Join(dir, "package-lock.json")) + b, _ := os.ReadFile(filepath.Join(dir, "app/gradle.lockfile")) + assert.Equal(t, "a-healed", string(a)) + assert.Equal(t, "b-healed", string(b)) + + require.NoError(t, restore()) +} diff --git a/go.mod b/go.mod index 118c7b34..a93c4c3e 100644 --- a/go.mod +++ b/go.mod @@ -198,6 +198,16 @@ require ( sigs.k8s.io/yaml v1.6.0 // indirect ) +// replace github.com/jfrog/jfrog-client-go => /Users/assafa/code/jfrog/jfrog-client-go + +// replace github.com/jfrog/jfrog-cli-core/v2 => /Users/assafa/code/jfrog/jfrog-cli-core + +// attiasas:xray_component_resolution +replace github.com/jfrog/jfrog-client-go => github.com/attiasas/jfrog-client-go v0.0.0-20260623101014-8f8f09cbd450 + +// attiasas:expend_xray_manager +replace github.com/jfrog/jfrog-cli-core/v2 => github.com/attiasas/jfrog-cli-core/v2 v2.0.0-20260623101547-db7e26e572b3 + // replace github.com/jfrog/jfrog-cli-core/v2 => github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260604085947-7c110b77b4b4 // replace github.com/gfleury/go-bitbucket-v1 => github.com/gfleury/go-bitbucket-v1 v0.0.0-20230825095122-9bc1711434ab diff --git a/go.sum b/go.sum index 359c541c..f9feaadc 100644 --- a/go.sum +++ b/go.sum @@ -59,6 +59,10 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/attiasas/jfrog-cli-core/v2 v2.0.0-20260623101547-db7e26e572b3 h1:3zg6St+tq0Mt6NsmrY9PlTvD5y5YgdRfxL2qf5pI7/c= +github.com/attiasas/jfrog-cli-core/v2 v2.0.0-20260623101547-db7e26e572b3/go.mod h1:X4RdJAWdHMXZLBcvBeC9dYR12JKysbVNIRngtk+N7M8= +github.com/attiasas/jfrog-client-go v0.0.0-20260623101014-8f8f09cbd450 h1:Kk04jzpBs31RdhaHVZ+WBY8WSQgCKZCDcymc2M/5IoI= +github.com/attiasas/jfrog-client-go v0.0.0-20260623101014-8f8f09cbd450/go.mod h1:FHpjN1nTDoj96xd6obe27EOgGErqzU0rQgC96L3Ch9E= github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ= github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk= github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= @@ -384,12 +388,8 @@ github.com/jfrog/froggit-go v1.21.1 h1:I/XUOO6GQ1d/rmBlM361F8T654C3ohIWrpw23xNL9 github.com/jfrog/froggit-go v1.21.1/go.mod h1:umBiakJB0CSPFfe0AHVaC3n9xsmUT7NGkDCny3bRchI= github.com/jfrog/gofrog v1.7.6 h1:QmfAiRzVyaI7JYGsB7cxfAJePAZTzFz0gRWZSE27c6s= github.com/jfrog/gofrog v1.7.6/go.mod h1:ntr1txqNOZtHplmaNd7rS4f8jpA5Apx8em70oYEe7+4= -github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260609101026-df3091b39d06 h1:A8hWKHyvqzGXfWmh+8lXv3waAkim4xiucBfGhl7ZOeQ= -github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260609101026-df3091b39d06/go.mod h1:9R90mhbczGXwW5EGlDs7F08ejQU/xdoDhYHMvzBiqgE= github.com/jfrog/jfrog-cli-evidence v0.9.0 h1:i9DhkQUxSZkhpp5oGR+N+SVAaqWDiUylbJcoDhM91uQ= github.com/jfrog/jfrog-cli-evidence v0.9.0/go.mod h1:R9faPfyQESBmKrdZCmHvlpmYSHmffswjNnFeT3RMq8I= -github.com/jfrog/jfrog-client-go v1.55.1-0.20260508101905-a17af78a38d7 h1:o8fk4yWLqNMldarXyh/4NbmdbYbuM+lKYobdJK7shqM= -github.com/jfrog/jfrog-client-go v1.55.1-0.20260508101905-a17af78a38d7/go.mod h1:sCE06+GngPoyrGO0c+vmhgMoVSP83UMNiZnIuNPzU8U= github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 h1:liMMTbpW34dhU4az1GN0pTPADwNmvoRSeoZ6PItiqnY= github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=