From 2449217b5fc012fbe8111dfd8f7f93864ddb8018 Mon Sep 17 00:00:00 2001 From: Uday Date: Mon, 27 Jul 2026 22:58:13 +0530 Subject: [PATCH 01/40] RTECO-1648 - Implement jf agent apm command with JFrog Artifactory authentication Adds jf agent apm install/publish/update/passthrough, wrapping Microsoft's APM CLI with per-run Artifactory registry authentication (env-var credentials, never written to disk) and build-info collection from apm.lock.yaml/apm.yml. Also fixes jf setup agent-apm to search local (not virtual) repositories for the agentpackages package type, since Artifactory has no remote/virtual support for it. --- agent/apm/cli/cli.go | 43 ++ agent/apm/commands/install/install.go | 96 ++++ agent/apm/commands/passthrough/passthrough.go | 112 +++++ agent/apm/commands/publish/publish.go | 139 ++++++ agent/apm/commands/publish/publish_test.go | 71 +++ agent/apm/commands/update/update.go | 100 ++++ agent/apm/common/apmenv.go | 465 ++++++++++++++++++ agent/apm/common/apmenv_test.go | 109 ++++ agent/apm/common/build_info.go | 207 ++++++++ agent/apm/common/build_info_test.go | 29 ++ agent/apm/common/checksums.go | 104 ++++ agent/apm/common/dependency_resolver.go | 140 ++++++ agent/apm/common/dependency_resolver_test.go | 95 ++++ agent/apm/common/lockfile.go | 66 +++ agent/apm/common/lockfile_test.go | 71 +++ agent/apm/common/manifest.go | 59 +++ agent/apm/common/subcommand_options.go | 101 ++++ agent/apm/common/subcommand_options_test.go | 86 ++++ agent/apm/common/utils.go | 42 ++ agent/apm/common/utils_test.go | 80 +++ agent/cli/cli.go | 10 + agent/cli/cli_test.go | 14 +- agent/common/evd.go | 4 +- agent/common/server.go | 6 +- agent/common/server_test.go | 6 +- artifactory/commands/repository/template.go | 65 +-- artifactory/commands/setup/setup.go | 35 +- cliutils/flagkit/flags.go | 12 + go.mod | 2 +- 29 files changed, 2321 insertions(+), 48 deletions(-) create mode 100644 agent/apm/cli/cli.go create mode 100644 agent/apm/commands/install/install.go create mode 100644 agent/apm/commands/passthrough/passthrough.go create mode 100644 agent/apm/commands/publish/publish.go create mode 100644 agent/apm/commands/publish/publish_test.go create mode 100644 agent/apm/commands/update/update.go create mode 100644 agent/apm/common/apmenv.go create mode 100644 agent/apm/common/apmenv_test.go create mode 100644 agent/apm/common/build_info.go create mode 100644 agent/apm/common/build_info_test.go create mode 100644 agent/apm/common/checksums.go create mode 100644 agent/apm/common/dependency_resolver.go create mode 100644 agent/apm/common/dependency_resolver_test.go create mode 100644 agent/apm/common/lockfile.go create mode 100644 agent/apm/common/lockfile_test.go create mode 100644 agent/apm/common/manifest.go create mode 100644 agent/apm/common/subcommand_options.go create mode 100644 agent/apm/common/subcommand_options_test.go create mode 100644 agent/apm/common/utils.go create mode 100644 agent/apm/common/utils_test.go diff --git a/agent/apm/cli/cli.go b/agent/apm/cli/cli.go new file mode 100644 index 00000000..88277288 --- /dev/null +++ b/agent/apm/cli/cli.go @@ -0,0 +1,43 @@ +package cli + +import ( + "github.com/jfrog/jfrog-cli-artifactory/agent/apm/commands/install" + "github.com/jfrog/jfrog-cli-artifactory/agent/apm/commands/publish" + "github.com/jfrog/jfrog-cli-artifactory/agent/apm/commands/update" + "github.com/jfrog/jfrog-cli-artifactory/cliutils/flagkit" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" +) + +// GetSubCommands returns the leaf commands for `jf agent apm`. +// Commands not listed here fall through to the passthrough handler set on the parent. +// "lock" is deliberately not listed here — it doesn't deploy anything, so there's nothing +// a build actually consumed to report; it's served by the generic passthrough like every +// other read/resolve-only apm command. +func GetSubCommands() []components.Command { + return []components.Command{ + { + Name: "install", + Flags: flagkit.GetCommandFlags(flagkit.ApmSubcommand), + // SkipFlagParsing so apm-native flags (e.g. --frozen) that aren't in jf's own + // declared flag set above aren't rejected by urfave/cli before reaching apm. + // RunInstall extracts jf's own flags manually via ExtractApmSubcommandOptions. + SkipFlagParsing: true, + Description: "Install APM packages with JFrog Artifactory authentication.", + Action: install.RunInstall, + }, + { + Name: "publish", + Flags: flagkit.GetCommandFlags(flagkit.ApmSubcommand), + SkipFlagParsing: true, + Description: "Publish an APM package to JFrog Artifactory.", + Action: publish.RunPublish, + }, + { + Name: "update", + Flags: flagkit.GetCommandFlags(flagkit.ApmSubcommand), + SkipFlagParsing: true, + Description: "Refresh APM dependencies to their latest matching refs, with build-info collection.", + Action: update.RunUpdate, + }, + } +} diff --git a/agent/apm/commands/install/install.go b/agent/apm/commands/install/install.go new file mode 100644 index 00000000..4c195b61 --- /dev/null +++ b/agent/apm/commands/install/install.go @@ -0,0 +1,96 @@ +package install + +import ( + "fmt" + "os" + "path/filepath" + + apmcommon "github.com/jfrog/jfrog-cli-artifactory/agent/apm/common" + buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build" + "github.com/jfrog/jfrog-cli-core/v2/common/commands" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +// ApmInstallCommand runs `apm install` with JFrog Artifactory authentication and collects +// build-info from the resulting apm.lock.yaml. +// +// Unlike passthrough commands, install never accepts --repo: no other package-manager +// integration in this CLI supports declaring a new repository at run time either - they all +// require the one-time `jf setup ` step first (§3, "jf setup agent-apm"). A registry +// must already be declared (via jf setup agent-apm or apm.yml's own registries: block) before +// install can authenticate against it. +type ApmInstallCommand struct { + args []string + serverDetails *config.ServerDetails + buildConfiguration *buildUtils.BuildConfiguration +} + +func NewApmInstallCommand() *ApmInstallCommand { + return &ApmInstallCommand{} +} + +func (c *ApmInstallCommand) SetArgs(args []string) *ApmInstallCommand { + c.args = args + return c +} + +func (c *ApmInstallCommand) SetServerDetails(sd *config.ServerDetails) *ApmInstallCommand { + c.serverDetails = sd + return c +} + +func (c *ApmInstallCommand) SetBuildConfiguration(bc *buildUtils.BuildConfiguration) *ApmInstallCommand { + c.buildConfiguration = bc + return c +} + +func (c *ApmInstallCommand) CommandName() string { + return "rt_agent_apm_install" +} + +func (c *ApmInstallCommand) ServerDetails() (*config.ServerDetails, error) { + return c.serverDetails, nil +} + +func (c *ApmInstallCommand) Run() error { + log.Info("Running apm install...") + + if err := apmcommon.RunApmSubcommandWithAuth("install", c.args, c.serverDetails, ""); err != nil { + return fmt.Errorf("run apm install: %w", err) + } + + workingDir, err := os.Getwd() + if err != nil { + log.Warn("apm install completed, but could not determine working directory for build info:", err.Error()) + } else { + lockfilePath := filepath.Join(workingDir, apmcommon.ApmLockfileName) + manifestPath := filepath.Join(workingDir, apmcommon.ApmManifestName) + if biErr := apmcommon.CollectAndSaveInstallBuildInfo(lockfilePath, manifestPath, c.serverDetails, c.buildConfiguration); biErr != nil { + log.Warn("apm install completed, but build info collection failed:", biErr.Error()) + } + } + + log.Info("apm install finished successfully.") + return nil +} + +// RunInstall is the CLI action handler for `jf agent apm install`. +func RunInstall(c *components.Context) error { + if apmcommon.IsHelpRequest(c.Arguments) { + return apmcommon.RunApmCommand(nil, "install", []string{"--help"}) + } + + opts, err := apmcommon.ExtractApmSubcommandOptions(c.Arguments) + if err != nil { + return err + } + + cmd := NewApmInstallCommand(). + SetArgs(opts.RemainingArgs). + SetServerDetails(opts.ServerDetails). + SetBuildConfiguration(opts.BuildConfig) + + return commands.ExecWithPackageManager(cmd, "agent-apm") +} diff --git a/agent/apm/commands/passthrough/passthrough.go b/agent/apm/commands/passthrough/passthrough.go new file mode 100644 index 00000000..455fe6ed --- /dev/null +++ b/agent/apm/commands/passthrough/passthrough.go @@ -0,0 +1,112 @@ +package passthrough + +import ( + apmcommon "github.com/jfrog/jfrog-cli-artifactory/agent/apm/common" + agentcommon "github.com/jfrog/jfrog-cli-artifactory/agent/common" + "github.com/jfrog/jfrog-cli-core/v2/common/commands" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +// ApmPassthroughCommand forwards any apm subcommand with auth environment injected. +type ApmPassthroughCommand struct { + subcmd string + args []string + serverDetails *config.ServerDetails + repoName string +} + +func NewApmPassthroughCommand() *ApmPassthroughCommand { + return &ApmPassthroughCommand{} +} + +func (c *ApmPassthroughCommand) SetSubcmd(subcmd string) *ApmPassthroughCommand { + c.subcmd = subcmd + return c +} + +func (c *ApmPassthroughCommand) SetArgs(args []string) *ApmPassthroughCommand { + c.args = args + return c +} + +func (c *ApmPassthroughCommand) SetServerDetails(sd *config.ServerDetails) *ApmPassthroughCommand { + c.serverDetails = sd + return c +} + +func (c *ApmPassthroughCommand) SetRepoName(repo string) *ApmPassthroughCommand { + c.repoName = repo + return c +} + +func (c *ApmPassthroughCommand) CommandName() string { + return "rt_agent_apm_" + c.subcmd +} + +func (c *ApmPassthroughCommand) ServerDetails() (*config.ServerDetails, error) { + return c.serverDetails, nil +} + +func (c *ApmPassthroughCommand) Run() error { + log.Info("Running apm " + c.subcmd + "...") + return apmcommon.RunApmSubcommandWithAuth(c.subcmd, c.args, c.serverDetails, c.repoName) +} + +// RunApmPassthroughDefault handles any `jf agent apm ` where is not +// one of the registered subcommands (install, publish). The subcmd is extracted +// from the first element of c.Arguments; all remaining elements are forwarded to apm. +// +// The parent "apm" command can't declare SkipFlagParsing (jfrog-cli-core rejects that +// combined with registered Subcommands, since urfave/cli would then stop routing to +// install/publish entirely), so the framework's automatic flag parsing only reliably +// captures --server-id/--repo when they're placed BEFORE the subcommand name. Placed +// after — the position install/publish/every other jf command actually uses — they land +// here unconsumed. Extract them manually, position-independent, the same way every other +// passthrough-style command in the CLI (npm, pnpm, yarn, ...) does via +// coreutils.ExtractServerIdFromCommand, before forwarding the rest to apm. +func RunApmPassthroughDefault(c *components.Context) error { + if len(c.Arguments) == 0 { + return apmcommon.RunApmCommand(nil, "--help", nil) + } + + subcmd := c.Arguments[0] + if apmcommon.IsHelpRequest([]string{subcmd}) { + return apmcommon.RunApmCommand(nil, "--help", nil) + } + + rest, serverID, err := coreutils.ExtractServerIdFromCommand(c.Arguments[1:]) + if err != nil { + return err + } + rest, repoOverride, err := coreutils.ExtractStringOptionFromArgs(rest, "repo") + if err != nil { + return err + } + + sd, sdErr := agentcommon.GetServerDetails(c) + if sdErr != nil || serverID != "" { + // Either the framework-based lookup found nothing configured (flags were placed after + // the subcommand, so agentcommon.GetServerDetails saw none of them), or an explicit + // --server-id turned up in the manual scan above — resolve from that instead. + sd, err = config.GetSpecificConfig(serverID, true, true) + if err != nil { + return err + } + } + + repoName := c.GetStringFlagValue("repo") + if repoOverride != "" { + repoName = repoOverride + } + + cmd := NewApmPassthroughCommand(). + SetSubcmd(subcmd). + SetArgs(rest). + SetServerDetails(sd). + SetRepoName(repoName) + + return commands.ExecWithPackageManager(cmd, "agent-apm") +} diff --git a/agent/apm/commands/publish/publish.go b/agent/apm/commands/publish/publish.go new file mode 100644 index 00000000..9991ac4d --- /dev/null +++ b/agent/apm/commands/publish/publish.go @@ -0,0 +1,139 @@ +package publish + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + apmcommon "github.com/jfrog/jfrog-cli-artifactory/agent/apm/common" + buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build" + "github.com/jfrog/jfrog-cli-core/v2/common/commands" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +// ApmPublishCommand runs `apm publish` with JFrog Artifactory authentication and records the +// published package in build-info. +// +// Unlike passthrough commands, publish never accepts --repo: no other package-manager +// integration in this CLI supports declaring a new repository at run time either - they all +// require the one-time `jf setup ` step first. A registry must already be declared +// (via jf setup agent-apm or apm.yml's own registries: block) before publish can authenticate +// against it; the repo name used for build-info enrichment is derived from that same +// declaration (see ResolveRepoNameFromRegistry). +type ApmPublishCommand struct { + args []string + serverDetails *config.ServerDetails + buildConfiguration *buildUtils.BuildConfiguration +} + +func NewApmPublishCommand() *ApmPublishCommand { + return &ApmPublishCommand{} +} + +func (c *ApmPublishCommand) SetArgs(args []string) *ApmPublishCommand { + c.args = args + return c +} + +func (c *ApmPublishCommand) SetServerDetails(sd *config.ServerDetails) *ApmPublishCommand { + c.serverDetails = sd + return c +} + +func (c *ApmPublishCommand) SetBuildConfiguration(bc *buildUtils.BuildConfiguration) *ApmPublishCommand { + c.buildConfiguration = bc + return c +} + +func (c *ApmPublishCommand) CommandName() string { + return "rt_agent_apm_publish" +} + +func (c *ApmPublishCommand) ServerDetails() (*config.ServerDetails, error) { + return c.serverDetails, nil +} + +// withPackageFlag promotes a bare positional package spec (e.g. "jfrog/proj3") into the +// --package flag apm publish requires. If --package is already present, args are left untouched. +func withPackageFlag(args []string) []string { + for _, a := range args { + if a == "--package" || strings.HasPrefix(a, "--package=") { + return args + } + } + for i, a := range args { + if !strings.HasPrefix(a, "-") { + rest := make([]string, 0, len(args)-1) + rest = append(rest, args[:i]...) + rest = append(rest, args[i+1:]...) + return append([]string{"--package", a}, rest...) + } + } + return args +} + +func (c *ApmPublishCommand) Run() error { + log.Info("Running apm publish...") + + args := withPackageFlag(c.args) + if err := apmcommon.RunApmSubcommandWithAuth("publish", args, c.serverDetails, ""); err != nil { + return fmt.Errorf("run apm publish: %w", err) + } + + workingDir, err := os.Getwd() + if err != nil { + log.Warn("apm publish completed, but could not determine working directory for build info:", err.Error()) + } else { + manifestPath := filepath.Join(workingDir, apmcommon.ApmManifestName) + owner := ownerFromArgs(args) + repoName := apmcommon.ResolveRepoNameFromRegistry(c.serverDetails, manifestPath) + if biErr := apmcommon.CollectAndSavePublishBuildInfo(manifestPath, owner, repoName, c.serverDetails, c.buildConfiguration); biErr != nil { + log.Warn("apm publish completed, but build info recording failed:", biErr.Error()) + } + } + + log.Info("apm publish finished successfully.") + return nil +} + +// ownerFromArgs extracts the owner segment from a "--package owner/name" pair in args. +// Returns "" if --package isn't present or doesn't contain a "/". +func ownerFromArgs(args []string) string { + for i, a := range args { + var pkg string + if a == "--package" && i+1 < len(args) { + pkg = args[i+1] + } else if cut, ok := strings.CutPrefix(a, "--package="); ok { + pkg = cut + } + if pkg == "" { + continue + } + if owner, _, ok := strings.Cut(pkg, "/"); ok { + return owner + } + } + return "" +} + +// RunPublish is the CLI action handler for `jf agent apm publish`. +func RunPublish(c *components.Context) error { + if apmcommon.IsHelpRequest(c.Arguments) { + return apmcommon.RunApmCommand(nil, "publish", []string{"--help"}) + } + + opts, err := apmcommon.ExtractApmSubcommandOptions(c.Arguments) + if err != nil { + return err + } + + cmd := NewApmPublishCommand(). + SetArgs(opts.RemainingArgs). + SetServerDetails(opts.ServerDetails). + SetBuildConfiguration(opts.BuildConfig) + + return commands.ExecWithPackageManager(cmd, "agent-apm") +} diff --git a/agent/apm/commands/publish/publish_test.go b/agent/apm/commands/publish/publish_test.go new file mode 100644 index 00000000..a335c639 --- /dev/null +++ b/agent/apm/commands/publish/publish_test.go @@ -0,0 +1,71 @@ +package publish + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWithPackageFlag(t *testing.T) { + tests := []struct { + name string + args []string + want []string + }{ + { + name: "bare positional package spec gets promoted", + args: []string{"jfrog/proj3"}, + want: []string{"--package", "jfrog/proj3"}, + }, + { + name: "positional spec mixed with flags", + args: []string{"--dry-run", "jfrog/proj3"}, + want: []string{"--package", "jfrog/proj3", "--dry-run"}, + }, + { + name: "already has --package flag - untouched", + args: []string{"--package", "jfrog/proj3"}, + want: []string{"--package", "jfrog/proj3"}, + }, + { + name: "already has --package= form - untouched", + args: []string{"--package=jfrog/proj3"}, + want: []string{"--package=jfrog/proj3"}, + }, + { + name: "no positional args - untouched", + args: []string{"--dry-run"}, + want: []string{"--dry-run"}, + }, + { + name: "empty args", + args: []string{}, + want: []string{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, withPackageFlag(tt.args)) + }) + } +} + +func TestOwnerFromArgs(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {name: "--package with space form", args: []string{"--package", "jfrog/proj3"}, want: "jfrog"}, + {name: "--package= form", args: []string{"--package=acme/skills-pack"}, want: "acme"}, + {name: "no --package flag", args: []string{"--dry-run"}, want: ""}, + {name: "--package without a slash", args: []string{"--package", "standalone-name"}, want: ""}, + {name: "--package as last arg with no value", args: []string{"--package"}, want: ""}, + {name: "empty args", args: []string{}, want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, ownerFromArgs(tt.args)) + }) + } +} diff --git a/agent/apm/commands/update/update.go b/agent/apm/commands/update/update.go new file mode 100644 index 00000000..8cf248cd --- /dev/null +++ b/agent/apm/commands/update/update.go @@ -0,0 +1,100 @@ +package update + +import ( + "fmt" + "os" + "path/filepath" + + apmcommon "github.com/jfrog/jfrog-cli-artifactory/agent/apm/common" + buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build" + "github.com/jfrog/jfrog-cli-core/v2/common/commands" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +// ApmUpdateCommand runs `apm update` with JFrog Artifactory authentication and collects +// build-info from the resulting apm.lock.yaml, reusing install's exact reader. +// +// Unlike passthrough commands, update never accepts --repo: no other package-manager +// integration in this CLI supports declaring a new repository at run time either - they all +// require the one-time `jf setup ` step first. A registry must already be declared +// (via jf setup agent-apm or apm.yml's own registries: block) before update can authenticate +// against it. +type ApmUpdateCommand struct { + args []string + serverDetails *config.ServerDetails + buildConfiguration *buildUtils.BuildConfiguration +} + +func NewApmUpdateCommand() *ApmUpdateCommand { + return &ApmUpdateCommand{} +} + +func (c *ApmUpdateCommand) SetArgs(args []string) *ApmUpdateCommand { + c.args = args + return c +} + +func (c *ApmUpdateCommand) SetServerDetails(sd *config.ServerDetails) *ApmUpdateCommand { + c.serverDetails = sd + return c +} + +func (c *ApmUpdateCommand) SetBuildConfiguration(bc *buildUtils.BuildConfiguration) *ApmUpdateCommand { + c.buildConfiguration = bc + return c +} + +func (c *ApmUpdateCommand) CommandName() string { + return "rt_agent_apm_update" +} + +func (c *ApmUpdateCommand) ServerDetails() (*config.ServerDetails, error) { + return c.serverDetails, nil +} + +// Run wraps "apm update", which re-resolves dependencies to their latest matching refs and, on +// acceptance (interactive confirmation, or --yes for CI), rewrites both apm.yml and +// apm.lock.yaml. Build-info collection reuses install's exact reader — same resolved-dependency +// shape, whether the lockfile just changed or update reported nothing new. +func (c *ApmUpdateCommand) Run() error { + log.Info("Running apm update...") + + if err := apmcommon.RunApmSubcommandWithAuth("update", c.args, c.serverDetails, ""); err != nil { + return fmt.Errorf("run apm update: %w", err) + } + + workingDir, err := os.Getwd() + if err != nil { + log.Warn("apm update completed, but could not determine working directory for build info:", err.Error()) + } else { + lockfilePath := filepath.Join(workingDir, apmcommon.ApmLockfileName) + manifestPath := filepath.Join(workingDir, apmcommon.ApmManifestName) + if biErr := apmcommon.CollectAndSaveInstallBuildInfo(lockfilePath, manifestPath, c.serverDetails, c.buildConfiguration); biErr != nil { + log.Warn("apm update completed, but build info collection failed:", biErr.Error()) + } + } + + log.Info("apm update finished successfully.") + return nil +} + +// RunUpdate is the CLI action handler for `jf agent apm update`. +func RunUpdate(c *components.Context) error { + if apmcommon.IsHelpRequest(c.Arguments) { + return apmcommon.RunApmCommand(nil, "update", []string{"--help"}) + } + + opts, err := apmcommon.ExtractApmSubcommandOptions(c.Arguments) + if err != nil { + return err + } + + cmd := NewApmUpdateCommand(). + SetArgs(opts.RemainingArgs). + SetServerDetails(opts.ServerDetails). + SetBuildConfiguration(opts.BuildConfig) + + return commands.ExecWithPackageManager(cmd, "agent-apm") +} diff --git a/agent/apm/common/apmenv.go b/agent/apm/common/apmenv.go new file mode 100644 index 00000000..8ab47f2e --- /dev/null +++ b/agent/apm/common/apmenv.go @@ -0,0 +1,465 @@ +package apmcommon + +import ( + "encoding/json" + "fmt" + "maps" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +const agentPackagesAPIPrefix = "/api/agentpackages/" + +// AgentPackagesBaseURL returns the Artifactory agentpackages base URL for a repo. +func AgentPackagesBaseURL(sd *config.ServerDetails, repoName string) string { + base := strings.TrimSuffix(sd.ArtifactoryUrl, "/") + return base + agentPackagesAPIPrefix + repoName + "/" +} + +// BuildRegistryEntry returns (registryURL, token) for APM config. +// AccessToken set → Bearer auth via token field. +// User+Password only → Basic auth via URL-embedded credentials. +func BuildRegistryEntry(sd *config.ServerDetails, repoName string) (registryURL, token string) { + base := AgentPackagesBaseURL(sd, repoName) + if sd.AccessToken != "" { + return base, sd.AccessToken + } + if sd.User != "" && sd.Password != "" { + u, err := url.Parse(base) + if err == nil { + u.User = url.UserPassword(sd.User, sd.Password) + return u.String(), "" + } + } + return base, "" +} + +// apmConfigJSON models ~/.apm/config.json. Real-world files carry other top-level keys +// too (e.g. "default_client", "install_target") that belong entirely to the apm CLI and +// aren't understood here — Extra preserves them byte-for-byte across the read-merge-write +// cycle so this code never silently destroys settings it doesn't know about. +type apmConfigJSON struct { + Experimental experimentalConfig `json:"-"` + Registries map[string]registryConfig `json:"-"` + Extra map[string]json.RawMessage +} + +type experimentalConfig struct { + Registries bool `json:"registries,omitempty"` +} + +type registryConfig struct { + URL string `json:"url"` + Token string `json:"token,omitempty"` + Default bool `json:"default,omitempty"` +} + +func (c *apmConfigJSON) UnmarshalJSON(data []byte) error { + raw := make(map[string]json.RawMessage) + if len(data) > 0 { + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + } + if v, ok := raw["experimental"]; ok { + if err := json.Unmarshal(v, &c.Experimental); err != nil { + return err + } + delete(raw, "experimental") + } + if v, ok := raw["registries"]; ok { + if err := json.Unmarshal(v, &c.Registries); err != nil { + return err + } + delete(raw, "registries") + } + c.Extra = raw + return nil +} + +func (c apmConfigJSON) MarshalJSON() ([]byte, error) { + out := make(map[string]json.RawMessage, len(c.Extra)+2) + maps.Copy(out, c.Extra) + expJSON, err := json.Marshal(c.Experimental) + if err != nil { + return nil, err + } + out["experimental"] = expJSON + if len(c.Registries) > 0 { + regJSON, err := json.Marshal(c.Registries) + if err != nil { + return nil, err + } + out["registries"] = regJSON + } + return json.Marshal(out) +} + +// discoveredRegistry pairs a registry name with its URL, resolved from either +// ~/.apm/config.json or the project's apm.yml. +type discoveredRegistry struct { + Name string + URL string +} + +// discoverMatchingRegistries returns every registry name+URL, from existing config.json +// entries and the project's apm.yml, whose host matches sd.ArtifactoryUrl. A name already +// found in config.json is not overwritten by an apm.yml entry of the same name. +func discoverMatchingRegistries(existing *apmConfigJSON, manifestPath string, sd *config.ServerDetails) []discoveredRegistry { + seen := make(map[string]bool, len(existing.Registries)) + found := make([]discoveredRegistry, 0, len(existing.Registries)) + + for name, entry := range existing.Registries { + if apmHostMatches(entry.URL, sd.ArtifactoryUrl) { + found = append(found, discoveredRegistry{Name: name, URL: entry.URL}) + seen[name] = true + } + } + + if manifestPath != "" { + if manifest, loadErr := LoadManifest(manifestPath); loadErr == nil { + for name, reg := range manifest.Registries { + if seen[name] || !apmHostMatches(reg.URL, sd.ArtifactoryUrl) { + continue + } + found = append(found, discoveredRegistry{Name: name, URL: reg.URL}) + seen[name] = true + } + } else { + log.Debug("apm.yml parsing failed while discovering registries:", loadErr.Error()) + } + } + + return found +} + +// sanitizeApmEnvName converts a registry name into apm's env-var-safe form: uppercased, +// with "-" and "." mapped to "_" — confirmed against apm's own docs, which give +// "corp-main"/"corp.main"/"Corp-Main" as an explicit example of names that collide. +func sanitizeApmEnvName(name string) string { + return strings.NewReplacer("-", "_", ".", "_").Replace(strings.ToUpper(name)) +} + +func apmTokenEnvVar(name string) string { return "APM_REGISTRY_TOKEN_" + sanitizeApmEnvName(name) } +func apmUserEnvVar(name string) string { return "APM_REGISTRY_USER_" + sanitizeApmEnvName(name) } +func apmPassEnvVar(name string) string { return "APM_REGISTRY_PASS_" + sanitizeApmEnvName(name) } + +// checkSanitizationCollisions rejects a set of registry names if two distinct names would +// sanitize to the same env var — apm would only ever see credentials for whichever one +// wins, silently misrouting auth for the other. +func checkSanitizationCollisions(names []string) error { + bySanitized := make(map[string][]string, len(names)) + for _, n := range names { + s := sanitizeApmEnvName(n) + bySanitized[s] = append(bySanitized[s], n) + } + for s, ns := range bySanitized { + if len(ns) > 1 { + return fmt.Errorf( + "registry names %v all sanitize to the same env var APM_REGISTRY_TOKEN_%s; rename one to avoid credential misrouting", + ns, s) + } + } + return nil +} + +// injectRegistryCredentialEnv appends APM_REGISTRY_TOKEN_ (or USER_/PASS_) to env for +// the given registry name, computed from sd. Non-destructive: if the caller already exported +// a credential for this exact name, it's left alone and nothing is appended. +func injectRegistryCredentialEnv(env []string, name string, sd *config.ServerDetails) []string { + tokenKey, userKey, passKey := apmTokenEnvVar(name), apmUserEnvVar(name), apmPassEnvVar(name) + if os.Getenv(tokenKey) != "" || (os.Getenv(userKey) != "" && os.Getenv(passKey) != "") { + log.Debug(fmt.Sprintf("apm auth [%s]: credential env var already set — respecting existing value", name)) + return env + } + if sd.AccessToken != "" { + return append(env, tokenKey+"="+sd.AccessToken) + } + if sd.User != "" && sd.Password != "" { + return append(env, userKey+"="+sd.User, passKey+"="+sd.Password) + } + return env +} + +// ensureExperimentalFlagEnabled sets experimental.registries=true in the real +// ~/.apm/config.json if it isn't already set. This is the one non-secret, monotonic +// exception to "only jf setup agent-apm writes to the real home": apm has no env-var +// equivalent for this flag (confirmed against apm's own docs), and unlike a registry +// URL it can't collide across projects — it's a single global switch, not project-scoped +// state, so enabling it as a side effect of ordinary usage carries none of the +// cross-project collision risk a persisted registry entry would. +func ensureExperimentalFlagEnabled(realHome string, existing *apmConfigJSON) error { + if existing.Experimental.Registries { + return nil + } + existing.Experimental.Registries = true + return writeApmConfig(realHome, existing) +} + +// BuildApmEnv resolves how apm should authenticate for this invocation. Credentials always +// travel via APM_REGISTRY_TOKEN_/APM_REGISTRY_USER_+PASS_ env vars — never +// written to a file. Three cases: +// 1. The registry is already declared (an existing ~/.apm/config.json entry or apm.yml's own +// registries: block) — no file write at all; the real environment is used with credential +// env vars appended. +// 2. --repo names a registry apm doesn't already know about — its URL has to be declared +// somewhere, since an env var can't carry a URL, so a temp HOME is used containing only +// URLs (this one, plus every other already-discovered registry so the HOME swap doesn't +// hide them) and no secrets at all. +// 3. Neither an existing declaration nor --repo — nothing to authenticate against, so this +// returns an error rather than silently running apm unauthenticated. +// +// Returns os.Environ() with empty tmpHome when sd is nil (no server configured). +func BuildApmEnv(sd *config.ServerDetails, repoName, manifestPath string) (env []string, tmpHome string, err error) { + if sd == nil { + return os.Environ(), "", nil + } + + realHome, existing, err := loadExistingApmConfig() + if err != nil { + return nil, "", err + } + + discovered := discoverMatchingRegistries(existing, manifestPath, sd) + + needsNewDeclaration := false + if repoName != "" { + alreadyDeclared := false + for _, d := range discovered { + if d.Name == repoName { + alreadyDeclared = true + break + } + } + if !alreadyDeclared { + needsNewDeclaration = true + discovered = append(discovered, discoveredRegistry{Name: repoName, URL: AgentPackagesBaseURL(sd, repoName)}) + } + } + + if len(discovered) == 0 { + return nil, "", fmt.Errorf( + "no APM registry found for %s: declare one in apm.yml's registries: block, "+ + "add it to ~/.apm/config.json (via 'jf setup agent-apm'), or pass --repo ", + sd.ArtifactoryUrl) + } + + names := make([]string, 0, len(discovered)) + for _, d := range discovered { + names = append(names, d.Name) + } + if err = checkSanitizationCollisions(names); err != nil { + return nil, "", err + } + + if err = ensureExperimentalFlagEnabled(realHome, existing); err != nil { + return nil, "", err + } + + env = os.Environ() + for _, d := range discovered { + env = injectRegistryCredentialEnv(env, d.Name, sd) + } + + if !needsNewDeclaration { + return env, "", nil + } + + // URL-only, no tokens: --repo named something not already declared anywhere, so its URL + // has to live in a config.json somewhere. Preserve every other registry (matching-host + // ones URL-only, non-matching ones verbatim including any token they already had) so this + // invocation doesn't hide anything the real config already had from apm. + tempCfg := &apmConfigJSON{ + Experimental: experimentalConfig{Registries: true}, + Registries: make(map[string]registryConfig, len(discovered)), + Extra: existing.Extra, + } + for _, d := range discovered { + tempCfg.Registries[d.Name] = registryConfig{URL: d.URL, Default: d.Name == repoName} + } + for name, entry := range existing.Registries { + if _, ok := tempCfg.Registries[name]; !ok { + tempCfg.Registries[name] = entry + } + } + + tmpHome, err = os.MkdirTemp("", "jf-apm-home-") + if err != nil { + return nil, "", fmt.Errorf("create temp home: %w", err) + } + if err = writeApmConfig(tmpHome, tempCfg); err != nil { + _ = os.RemoveAll(tmpHome) + return nil, "", err + } + + return replaceEnvHome(env, tmpHome), tmpHome, nil +} + +// loadExistingApmConfig reads the user's real ~/.apm/config.json, returning the home path and parsed config. +func loadExistingApmConfig() (realHome string, existing *apmConfigJSON, err error) { + realHome, err = os.UserHomeDir() + if err != nil { + return "", nil, fmt.Errorf("get user home dir: %w", err) + } + existing, readErr := readApmConfig(filepath.Join(realHome, ".apm", "config.json")) + if readErr != nil { + log.Debug("Could not read existing APM config, starting fresh:", readErr.Error()) + existing = &apmConfigJSON{} + } + return realHome, existing, nil +} + +func readApmConfig(path string) (*apmConfigJSON, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &apmConfigJSON{}, nil + } + return nil, err + } + var cfg apmConfigJSON + if err = json.Unmarshal(data, &cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +func writeApmConfig(tmpHome string, cfg *apmConfigJSON) error { + apmDir := filepath.Join(tmpHome, ".apm") + if err := os.MkdirAll(apmDir, 0700); err != nil { + return fmt.Errorf("create .apm dir: %w", err) + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("marshal APM config: %w", err) + } + return os.WriteFile(filepath.Join(apmDir, "config.json"), data, 0600) +} + +func replaceEnvHome(env []string, newHome string) []string { + result := make([]string, 0, len(env)) + replaced := false + for _, e := range env { + if strings.HasPrefix(e, "HOME=") { + result = append(result, "HOME="+newHome) + replaced = true + } else { + result = append(result, e) + } + } + if !replaced { + result = append(result, "HOME="+newHome) + } + return result +} + +// RunApmCommand runs "apm " with the provided environment. +// If env is nil, the current process environment is used. +func RunApmCommand(env []string, subcmd string, args []string) error { + allArgs := append([]string{subcmd}, args...) + log.Debug(fmt.Sprintf("Running: apm %s", strings.Join(allArgs, " "))) + cmd := exec.Command("apm", allArgs...) + if env != nil { + cmd.Env = env + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + if err := cmd.Run(); err != nil { + return fmt.Errorf("apm %s failed: %w", subcmd, err) + } + return nil +} + +// ConfigureApmRegistryPersistent configures the user's real ~/.apm/config.json using apm's own +// `apm experimental enable registries` and `apm config set` commands — never by writing the +// file directly. This is the one allowed persistent write — called only by `jf setup agent-apm`. +// repoName is always resolved by the shared `jf setup ` interactive repo picker before +// this is called, so it's never empty here. Every other registry already in the file, and any +// other top-level key (e.g. default_client), is left alone automatically — apm's own config-set +// only ever touches the one key it's told to, and switching a registry's default clears any +// previous default on its own (confirmed live: setting a second registry's default un-defaults +// the first, with no separate unset step needed). +func ConfigureApmRegistryPersistent(sd *config.ServerDetails, repoName string) error { + if sd == nil { + return fmt.Errorf("server details are required for APM registry configuration") + } + + if err := RunApmCommand(nil, "experimental", []string{"enable", "registries"}); err != nil { + return fmt.Errorf("enable experimental registries: %w", err) + } + + registryURL, token := BuildRegistryEntry(sd, repoName) + if err := RunApmCommand(nil, "config", []string{"set", fmt.Sprintf("registry.%s.url", repoName), registryURL}); err != nil { + return fmt.Errorf("set registry url: %w", err) + } + if token != "" { + if err := RunApmCommand(nil, "config", []string{"set", fmt.Sprintf("registry.%s.token", repoName), token}); err != nil { + return fmt.Errorf("set registry token: %w", err) + } + } + return RunApmCommand(nil, "config", []string{"set", fmt.Sprintf("registry.%s.default", repoName), "true"}) +} + +// ResolveRepoNameFromRegistry returns the Artifactory repo name for sd, derived from whichever +// already-declared registry (config.json or apm.yml) matches sd.ArtifactoryUrl. jf setup agent-apm +// always names a registry after the repo it points to, so the registry name doubles as the repo +// name. Returns "" if no registry matches or more than one does (ambiguous) - callers treat this +// the same as an unknown repo, not an error, since it only affects build-info enrichment +// (OriginalDeploymentRepo / checksum lookup), never publish itself. +func ResolveRepoNameFromRegistry(sd *config.ServerDetails, manifestPath string) string { + if sd == nil { + return "" + } + _, existing, err := loadExistingApmConfig() + if err != nil { + return "" + } + discovered := discoverMatchingRegistries(existing, manifestPath, sd) + if len(discovered) != 1 { + return "" + } + return discovered[0].Name +} + +// RunApmSubcommandWithAuth is the shared body for all apm command Run() methods: +// validates prerequisites, builds a temp HOME with merged credentials, runs the subcommand, +// and cleans up on return. +func RunApmSubcommandWithAuth(subcmd string, args []string, sd *config.ServerDetails, repoName string) error { + if err := ValidateApmPrerequisites(); err != nil { + return err + } + workingDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + manifestPath := filepath.Join(workingDir, ApmManifestName) + env, tmpHome, err := BuildApmEnv(sd, repoName, manifestPath) + if err != nil { + return err + } + if tmpHome != "" { + defer func() { + if removeErr := os.RemoveAll(tmpHome); removeErr != nil { + log.Debug("Failed to clean up temp home:", removeErr.Error()) + } + }() + } + return RunApmCommand(env, subcmd, args) +} + +// IsHelpRequest returns true if the args include --help, -h, or "help". +func IsHelpRequest(args []string) bool { + for _, arg := range args { + if arg == "--help" || arg == "-h" || arg == "help" { + return true + } + } + return false +} diff --git a/agent/apm/common/apmenv_test.go b/agent/apm/common/apmenv_test.go new file mode 100644 index 00000000..ce9bbb95 --- /dev/null +++ b/agent/apm/common/apmenv_test.go @@ -0,0 +1,109 @@ +package apmcommon + +import ( + "os" + "path/filepath" + "testing" + + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSanitizeApmEnvName(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "hyphen", in: "corp-main", want: "CORP_MAIN"}, + {name: "dot", in: "corp.main", want: "CORP_MAIN"}, + {name: "already uppercase with hyphen", in: "Corp-Main", want: "CORP_MAIN"}, + {name: "no special chars", in: "corpmain", want: "CORPMAIN"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, sanitizeApmEnvName(tt.in)) + }) + } +} + +func TestCheckSanitizationCollisions(t *testing.T) { + tests := []struct { + name string + names []string + wantErr bool + }{ + {name: "no collision", names: []string{"corp-main", "other-repo"}, wantErr: false}, + { + name: "collision - hyphen vs dot vs case", + names: []string{"corp-main", "corp.main", "Corp-Main"}, + wantErr: true, + }, + {name: "single name", names: []string{"corp-main"}, wantErr: false}, + {name: "empty", names: nil, wantErr: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := checkSanitizationCollisions(tt.names) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestApmEnvVarNames(t *testing.T) { + assert.Equal(t, "APM_REGISTRY_TOKEN_CORP_MAIN", apmTokenEnvVar("corp-main")) + assert.Equal(t, "APM_REGISTRY_USER_CORP_MAIN", apmUserEnvVar("corp-main")) + assert.Equal(t, "APM_REGISTRY_PASS_CORP_MAIN", apmPassEnvVar("corp-main")) +} + +func TestResolveRepoNameFromRegistry(t *testing.T) { + tests := []struct { + name string + configJSON string + want string + }{ + { + name: "single matching registry resolves to its name", + configJSON: `{"registries":{"buk-apm":{"url":"https://acme.jfrog.io/artifactory/api/agentpackages/buk-apm/"}}}`, + want: "buk-apm", + }, + { + name: "no matching registry returns empty", + configJSON: `{"registries":{"other":{"url":"https://different.jfrog.io/artifactory/api/agentpackages/other/"}}}`, + want: "", + }, + { + name: "multiple matching registries is ambiguous - returns empty rather than guessing", + configJSON: `{"registries":{"a":{"url":"https://acme.jfrog.io/artifactory/api/agentpackages/a/"},"b":{"url":"https://acme.jfrog.io/artifactory/api/agentpackages/b/"}}}`, + want: "", + }, + { + name: "no config file at all returns empty", + configJSON: "", + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + if tt.configJSON != "" { + apmDir := filepath.Join(home, ".apm") + require.NoError(t, os.MkdirAll(apmDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(apmDir, "config.json"), []byte(tt.configJSON), 0o644)) + } + + sd := &config.ServerDetails{ArtifactoryUrl: "https://acme.jfrog.io/artifactory/"} + assert.Equal(t, tt.want, ResolveRepoNameFromRegistry(sd, "")) + }) + } +} + +func TestResolveRepoNameFromRegistry_NilServerDetails(t *testing.T) { + assert.Empty(t, ResolveRepoNameFromRegistry(nil, "")) +} diff --git a/agent/apm/common/build_info.go b/agent/apm/common/build_info.go new file mode 100644 index 00000000..6531994e --- /dev/null +++ b/agent/apm/common/build_info.go @@ -0,0 +1,207 @@ +package apmcommon + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/jfrog/build-info-go/entities" + artUtils "github.com/jfrog/jfrog-cli-artifactory/artifactory/utils" + artCoreUtils "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils" + buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +// CollectAndSaveInstallBuildInfo reads the lockfile, resolves checksums, and saves build-info. +// Runs only when build info collection is enabled. +func CollectAndSaveInstallBuildInfo(lockfilePath, manifestPath string, sd *config.ServerDetails, buildConfig *buildUtils.BuildConfiguration) error { + collectBuildInfo, err := buildConfig.IsCollectBuildInfo() + if err != nil { + return err + } + if !collectBuildInfo { + return nil + } + log.Info("Collecting APM build info...") + + deps, err := ResolveDependencies(lockfilePath) + if err != nil { + if os.IsNotExist(err) { + // apm doesn't write apm.lock.yaml at all when a project has zero dependencies + // ("No changes -- install state already up to date") - this is the expected, + // common case, not a failure. + log.Info("No apm.lock.yaml found (project has no dependencies). Skipping build info.") + return nil + } + return err + } + if len(deps) == 0 { + log.Info("No registry dependencies found in lockfile. Skipping build info.") + return nil + } + + checksumMap, err := ResolveChecksums(deps, sd, buildConfig) + if err != nil { + return err + } + + return saveInstallBuildInfo(deps, checksumMap, manifestPath, buildConfig) +} + +func saveInstallBuildInfo(deps []ResolvedDep, checksumMap map[string]entities.Checksum, manifestPath string, buildConfig *buildUtils.BuildConfiguration) error { + buildName, err := buildConfig.GetBuildName() + if err != nil { + return err + } + buildNumber, err := buildConfig.GetBuildNumber() + if err != nil { + return err + } + + apmBuild, err := buildUtils.PrepareBuildPrerequisites(buildConfig) + if err != nil { + return err + } + if apmBuild == nil { + return errorutils.CheckErrorf("build info collection is not enabled") + } + + moduleID := buildConfig.GetModule() + if moduleID == "" { + moduleID = derivedModuleID(manifestPath) + } + + entityDeps := make([]entities.Dependency, 0, len(deps)) + for _, dep := range deps { + cs := checksumMap[dep.ID] + entityDeps = append(entityDeps, dep.ToEntitiesDependency(cs)) + } + + partial := &entities.Partial{ + ModuleId: moduleID, + ModuleType: "apm", + Dependencies: entityDeps, + } + if err = apmBuild.SavePartialBuildInfo(partial); err != nil { + return err + } + + log.Info(fmt.Sprintf("APM build info saved for %s/%s: %d dependencies.", buildName, buildNumber, len(entityDeps))) + return nil +} + +// SavePublishBuildInfo saves build artifact info for a published APM package. +// Path/Name match Artifactory's real agentpackages storage layout, confirmed live: +// {repo}/{owner}/{name}/{name}-{version}.zip +func SavePublishBuildInfo(owner, name, version string, checksum entities.Checksum, repoName string, buildConfig *buildUtils.BuildConfiguration) error { + buildName, err := buildConfig.GetBuildName() + if err != nil { + return err + } + buildNumber, err := buildConfig.GetBuildNumber() + if err != nil { + return err + } + + apmBuild, err := buildUtils.PrepareBuildPrerequisites(buildConfig) + if err != nil { + return err + } + if apmBuild == nil { + return errorutils.CheckErrorf("build info collection is not enabled") + } + + moduleID := buildConfig.GetModule() + if moduleID == "" { + moduleID = name + ":" + version + } + + fileName := name + "-" + version + ".zip" + artifactPath := fileName + if owner != "" { + artifactPath = owner + "/" + name + "/" + fileName + } + + artifact := entities.Artifact{ + Name: fileName, + Type: "zip", + Path: artifactPath, + OriginalDeploymentRepo: repoName, + Checksum: checksum, + } + + if err = apmBuild.AddArtifacts(moduleID, "apm", artifact); err != nil { + return err + } + + log.Info(fmt.Sprintf("APM publish build info saved for %s/%s.", buildName, buildNumber)) + return nil +} + +// CollectAndSavePublishBuildInfo reads the package name/version from apm.yml, looks up the +// real checksum of the just-published artifact via AQL, and records it in build-info. +// Runs only when build info collection is enabled. +func CollectAndSavePublishBuildInfo(manifestPath, owner, repoName string, sd *config.ServerDetails, buildConfig *buildUtils.BuildConfiguration) error { + collectBuildInfo, err := buildConfig.IsCollectBuildInfo() + if err != nil { + return err + } + if !collectBuildInfo { + return nil + } + manifest, err := LoadManifest(manifestPath) + if err != nil { + return err + } + if manifest.Name == "" || manifest.Version == "" { + log.Debug("APM manifest missing name or version; skipping publish build-info.") + return nil + } + + checksum := lookupPublishedArtifactChecksum(owner, manifest.Name, manifest.Version, repoName, sd) + return SavePublishBuildInfo(owner, manifest.Name, manifest.Version, checksum, repoName, buildConfig) +} + +// lookupPublishedArtifactChecksum queries AQL for the artifact apm publish just uploaded, by its +// known repo-relative path — confirmed live that AQL indexes agentpackages repos correctly. +// Returns an empty Checksum (not an error) if the repo/owner are unknown or the lookup fails, +// since a missing checksum shouldn't fail an already-successful publish. +func lookupPublishedArtifactChecksum(owner, name, version, repoName string, sd *config.ServerDetails) entities.Checksum { + if owner == "" || repoName == "" || sd == nil { + return entities.Checksum{} + } + servicesManager, err := artCoreUtils.CreateServiceManager(sd, -1, 0, false) + if err != nil { + log.Debug("apm publish: could not create service manager for checksum lookup:", err.Error()) + return entities.Checksum{} + } + + dirPath := owner + "/" + name + fileName := name + "-" + version + ".zip" + query := fmt.Sprintf( + `items.find({"repo":"%s","path":"%s","name":"%s"}).include("actual_sha1","sha256","actual_md5")`, + repoName, dirPath, fileName) + + results, err := artUtils.ExecuteAqlQuery(servicesManager, query) + if err != nil { + log.Debug("apm publish: checksum AQL lookup failed:", err.Error()) + return entities.Checksum{} + } + if len(results) == 0 { + log.Debug(fmt.Sprintf("apm publish: no AQL result for %s/%s — checksum will be empty", dirPath, fileName)) + return entities.Checksum{} + } + return entities.Checksum{Sha1: results[0].Actual_Sha1, Sha256: results[0].Sha256, Md5: results[0].Actual_Md5} +} + +func derivedModuleID(manifestPath string) string { + // Use directory name as module ID + dir := filepath.Dir(manifestPath) + base := filepath.Base(dir) + if base == "." || base == "" { + return "apm-project" + } + return base +} diff --git a/agent/apm/common/build_info_test.go b/agent/apm/common/build_info_test.go new file mode 100644 index 00000000..76c487cb --- /dev/null +++ b/agent/apm/common/build_info_test.go @@ -0,0 +1,29 @@ +package apmcommon + +import ( + "path/filepath" + "testing" + + "github.com/jfrog/jfrog-cli-artifactory/agent/common/testutil" + buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build" + "github.com/stretchr/testify/require" +) + +func TestCollectAndSaveInstallBuildInfo_MissingLockfileIsNotAnError(t *testing.T) { + testutil.WithJfrogHome(t) + tempDir := t.TempDir() + + buildConfig := new(buildUtils.BuildConfiguration) + require.NoError(t, buildConfig.SetBuildName("test-build").SetBuildNumber("1").ValidateBuildAndModuleParams()) + + // apm doesn't write apm.lock.yaml at all for a zero-dependency project ("No changes -- + // install state already up to date") - this must be treated the same as "0 dependencies + // found", not surfaced as a collection failure. + err := CollectAndSaveInstallBuildInfo( + filepath.Join(tempDir, ApmLockfileName), + filepath.Join(tempDir, ApmManifestName), + nil, + buildConfig, + ) + require.NoError(t, err) +} diff --git a/agent/apm/common/checksums.go b/agent/apm/common/checksums.go new file mode 100644 index 00000000..72ed6391 --- /dev/null +++ b/agent/apm/common/checksums.go @@ -0,0 +1,104 @@ +package apmcommon + +import ( + "fmt" + "sync" + + "github.com/jfrog/build-info-go/entities" + artUtils "github.com/jfrog/jfrog-cli-artifactory/artifactory/utils" + coreArtUtils "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils" + buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/artifactory" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +const headWorkerCount = 15 + +// ResolveChecksums resolves full checksums for registry dependencies. +// Strategy: +// 1. Previous build cache (SHA-1, MD5, SHA-256 from last build). +// 2. HTTP HEAD against each dependency's resolved_url (already the exact download URL — no +// repo/path reconstruction or query needed), reading Artifactory's X-Checksum-* response +// headers directly. Confirmed live to match AQL results exactly, and the same mechanism +// ocicontainer/docker already uses for artifacts it can't resolve via AQL. +// 3. Fallback: use lockfile SHA-256 only when the HEAD request finds no match. +func ResolveChecksums(deps []ResolvedDep, sd *config.ServerDetails, buildConfig *buildUtils.BuildConfiguration) (map[string]entities.Checksum, error) { + checksumMap := make(map[string]entities.Checksum) + + servicesManager, err := coreArtUtils.CreateServiceManager(sd, -1, 0, false) + if err != nil { + return nil, err + } + + buildName, err := buildConfig.GetBuildName() + if err != nil { + return nil, err + } + prevDeps, cacheErr := artUtils.GetDependenciesFromLatestBuild(servicesManager, buildName, buildConfig.GetProject()) + if cacheErr != nil { + log.Debug("Could not load previous build deps:", cacheErr.Error()) + } + cachedChecksums := artUtils.DependenciesToChecksumMap(prevDeps) + + var uncached []ResolvedDep + for _, dep := range deps { + if cs, ok := cachedChecksums[dep.ID]; ok { + checksumMap[dep.ID] = cs + } else { + uncached = append(uncached, dep) + } + } + + log.Info(fmt.Sprintf("Checksum resolution: %d cached, resolving %d from Artifactory.", len(deps)-len(uncached), len(uncached))) + + if len(uncached) == 0 { + return checksumMap, nil + } + + headResults := resolveChecksumsByHead(uncached, servicesManager) + for _, dep := range uncached { + if cs, ok := headResults[dep.ID]; ok { + checksumMap[dep.ID] = cs + } else if dep.SHA256 != "" { + checksumMap[dep.ID] = entities.Checksum{Sha256: dep.SHA256} + } + } + return checksumMap, nil +} + +// resolveChecksumsByHead issues one HTTP HEAD per dependency against its resolved_url and reads +// sha1/md5/sha256 straight from Artifactory's X-Checksum-* response headers. +func resolveChecksumsByHead(deps []ResolvedDep, servicesManager artifactory.ArtifactoryServicesManager) map[string]entities.Checksum { + clientDetails := servicesManager.GetConfig().GetServiceDetails().CreateHttpClientDetails() + + var ( + mu sync.Mutex + wg sync.WaitGroup + sem = make(chan struct{}, headWorkerCount) + checksumMap = make(map[string]entities.Checksum, len(deps)) + ) + + for _, dep := range deps { + if dep.ResolvedURL == "" { + continue + } + wg.Add(1) + sem <- struct{}{} + go func(d ResolvedDep) { + defer wg.Done() + defer func() { <-sem }() + fileDetails, _, err := servicesManager.Client().GetRemoteFileDetails(d.ResolvedURL, &clientDetails) + if err != nil { + log.Debug(fmt.Sprintf("HEAD checksum lookup failed for %s: %s", d.ID, err.Error())) + return + } + mu.Lock() + checksumMap[d.ID] = fileDetails.Checksum + mu.Unlock() + }(dep) + } + + wg.Wait() + return checksumMap +} diff --git a/agent/apm/common/dependency_resolver.go b/agent/apm/common/dependency_resolver.go new file mode 100644 index 00000000..9c9837da --- /dev/null +++ b/agent/apm/common/dependency_resolver.go @@ -0,0 +1,140 @@ +package apmcommon + +import ( + "encoding/json" + "fmt" + "os/exec" + "path/filepath" + "strings" + + "github.com/jfrog/build-info-go/entities" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +// ResolvedDep holds a single APM registry dependency ready for build-info. +type ResolvedDep struct { + ID string // "owner/repo:version" + RepoURL string // "owner/repo" - needed to query `apm deps why` + SHA256 string // hex-encoded SHA-256 from lockfile + ResolvedURL string // full agentpackages download URL from the lockfile + Scopes []string + RequestedBy [][]string +} + +// ResolveDependencies reads the lockfile and returns only registry-sourced dependencies. +func ResolveDependencies(lockfilePath string) ([]ResolvedDep, error) { + lockfile, err := LoadLockFile(lockfilePath) + if err != nil { + return nil, err + } + + workingDir := filepath.Dir(lockfilePath) + + var deps []ResolvedDep + for _, pkg := range lockfile.RegistryPackages() { + scopes, requestedBy := resolveScopeAndRequestedBy(workingDir, pkg.RepoURL) + deps = append(deps, ResolvedDep{ + ID: pkg.DepID(), + RepoURL: pkg.RepoURL, + SHA256: SHA256Hex(pkg.ResolvedHash), + ResolvedURL: pkg.ResolvedURL, + Scopes: scopes, + RequestedBy: requestedBy, + }) + } + return deps, nil +} + +// ToEntitiesDependency converts a ResolvedDep to entities.Dependency with resolved checksums. +// Type is "zip" — confirmed live against Artifactory's real agentpackages storage layout. +func (d ResolvedDep) ToEntitiesDependency(cs entities.Checksum) entities.Dependency { + return entities.Dependency{ + Id: d.ID, + Type: "zip", + Scopes: d.Scopes, + RequestedBy: d.RequestedBy, + Checksum: cs, + } +} + +// requestedByMaxPaths caps how many distinct requestedBy paths are reported per dependency, +// mirroring entities.RequestedByMaxLength - the same limit golang.go/yarn.go/uv_flexpack.go +// apply to len(dependency.RequestedBy) to bound fan-in from widely-shared packages (the +// common runaway case; a diamond dependency is exactly this: many packages sharing one base). +const requestedByMaxPaths = entities.RequestedByMaxLength + +// apmDepsWhyResult is the `apm deps why --json` response. Preferred over the +// lockfile's own depth/resolved_by fields, which aren't part of any documented schema (and +// aren't modeled in ApmLockedPackage) - this is a stable, documented command surface built +// specifically to answer "is this direct, and who pulled it in". +type apmDepsWhyResult struct { + Package struct { + IsDirect bool `json:"is_direct"` + } `json:"package"` + Paths []struct { + Chain []struct { + RepoURL string `json:"repo_url"` + } `json:"chain"` + } `json:"paths"` +} + +// resolveScopeAndRequestedBy shells out to `apm deps why --json` in workingDir to +// determine whether a dependency is direct or transitive, and - for transitive ones - which +// package(s) requested it. Preferred over the lockfile's own depth/resolved_by fields: `deps +// why` is a documented, stable command built for exactly this question, and naturally handles +// a dependency reachable through more than one parent (each returned path becomes one +// RequestedBy chain), which a single resolved_by string in the lockfile can't represent. +// +// Best-effort: if apm isn't on PATH or the command fails for any reason, this falls back to +// runtime scope with no requestedBy rather than failing the whole build-info collection - the +// dependency's id/checksum are still correct either way. +func resolveScopeAndRequestedBy(workingDir, repoURL string) (scopes []string, requestedBy [][]string) { + // repoURL comes from apm.lock.yaml, not a trusted CLI arg - a tampered lockfile could set + // it to something starting with "-" to smuggle an extra flag into the apm invocation + // below. Real repo_url values are always "owner/repo"; reject anything flag-shaped instead + // of passing it through. + if strings.HasPrefix(repoURL, "-") { + log.Debug(fmt.Sprintf("Refusing to run apm deps why for suspicious repo_url %q, defaulting to runtime scope", repoURL)) + return []string{"runtime"}, nil + } + + cmd := exec.Command("apm", "deps", "why", repoURL, "--json") + cmd.Dir = workingDir + out, err := cmd.Output() + if err != nil { + log.Debug(fmt.Sprintf("apm deps why %s failed, defaulting to runtime scope: %s", repoURL, err)) + return []string{"runtime"}, nil + } + return parseDepsWhyOutput(out, repoURL) +} + +// parseDepsWhyOutput turns `apm deps why --json` output into a scope and requestedBy chains. +// Split out from resolveScopeAndRequestedBy so the parsing logic is testable without shelling +// out to a real apm binary. +func parseDepsWhyOutput(out []byte, repoURL string) (scopes []string, requestedBy [][]string) { + var result apmDepsWhyResult + if err := json.Unmarshal(out, &result); err != nil { + log.Debug(fmt.Sprintf("could not parse apm deps why %s output, defaulting to runtime scope: %s", repoURL, err)) + return []string{"runtime"}, nil + } + + if result.Package.IsDirect { + return []string{"runtime"}, nil + } + + for _, path := range result.Paths { + if len(requestedBy) >= requestedByMaxPaths { + break // widely-shared package (e.g. a diamond dependency's base) - cap fan-in + } + if len(path.Chain) <= 1 { + continue // no parent to report + } + parents := path.Chain[:len(path.Chain)-1] // drop the target package itself + chain := make([]string, 0, len(parents)) + for _, node := range parents { + chain = append(chain, node.RepoURL) + } + requestedBy = append(requestedBy, chain) + } + return []string{"transitive"}, requestedBy +} diff --git a/agent/apm/common/dependency_resolver_test.go b/agent/apm/common/dependency_resolver_test.go new file mode 100644 index 00000000..a375ee30 --- /dev/null +++ b/agent/apm/common/dependency_resolver_test.go @@ -0,0 +1,95 @@ +package apmcommon + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestResolveScopeAndRequestedBy_RejectsFlagShapedRepoURL(t *testing.T) { + scopes, requestedBy := resolveScopeAndRequestedBy(t.TempDir(), "--global") + assert.Equal(t, []string{"runtime"}, scopes) + assert.Empty(t, requestedBy) +} + +func TestParseDepsWhyOutput_DirectDependency(t *testing.T) { + out := []byte(`{ + "package": {"is_direct": true, "repo_url": "uday/pkg-consumer", "source": "registry", "version": "1.0.0"}, + "paths": [{"chain": [{"is_direct": true, "repo_url": "uday/pkg-consumer"}]}] + }`) + scopes, requestedBy := parseDepsWhyOutput(out, "uday/pkg-consumer") + assert.Equal(t, []string{"runtime"}, scopes) + assert.Empty(t, requestedBy) +} + +func TestParseDepsWhyOutput_TransitiveDependency(t *testing.T) { + out := []byte(`{ + "package": {"is_direct": false, "repo_url": "uday/pkg-base", "source": "registry", "version": "1.0.0"}, + "paths": [{"chain": [ + {"is_direct": true, "repo_url": "uday/pkg-consumer"}, + {"is_direct": false, "repo_url": "uday/pkg-base"} + ]}] + }`) + scopes, requestedBy := parseDepsWhyOutput(out, "uday/pkg-base") + assert.Equal(t, []string{"transitive"}, scopes) + assert.Equal(t, [][]string{{"uday/pkg-consumer"}}, requestedBy) +} + +func TestParseDepsWhyOutput_MultipleParentPaths(t *testing.T) { + out := []byte(`{ + "package": {"is_direct": false, "repo_url": "shared/lib", "source": "registry", "version": "1.0.0"}, + "paths": [ + {"chain": [{"is_direct": true, "repo_url": "a/pkg"}, {"is_direct": false, "repo_url": "shared/lib"}]}, + {"chain": [{"is_direct": true, "repo_url": "b/pkg"}, {"is_direct": false, "repo_url": "shared/lib"}]} + ] + }`) + scopes, requestedBy := parseDepsWhyOutput(out, "shared/lib") + assert.Equal(t, []string{"transitive"}, scopes) + assert.Equal(t, [][]string{{"a/pkg"}, {"b/pkg"}}, requestedBy) +} + +func TestParseDepsWhyOutput_MalformedJSONFallsBackToRuntime(t *testing.T) { + scopes, requestedBy := parseDepsWhyOutput([]byte("not json"), "uday/pkg-base") + assert.Equal(t, []string{"runtime"}, scopes) + assert.Empty(t, requestedBy) +} + +// TestParseDepsWhyOutput_PathCountCappedAtMax verifies the fan-in cap: a widely-shared +// dependency (e.g. a diamond dependency's base, reachable through many parents) reports at +// most requestedByMaxPaths distinct paths, matching the same cap golang.go/yarn.go/ +// uv_flexpack.go apply to len(dependency.RequestedBy) elsewhere in this codebase. +func TestParseDepsWhyOutput_PathCountCappedAtMax(t *testing.T) { + var pathsJSON strings.Builder + for i := range requestedByMaxPaths + 5 { + if i > 0 { + pathsJSON.WriteByte(',') + } + parent := "parent" + string(rune('a'+i%26)) + pathsJSON.WriteString(`{"chain": [{"is_direct": true, "repo_url": "` + parent + `"}, {"is_direct": false, "repo_url": "target"}]}`) + } + out := []byte(`{ + "package": {"is_direct": false, "repo_url": "target", "source": "registry", "version": "1.0.0"}, + "paths": [` + pathsJSON.String() + `] + }`) + _, requestedBy := parseDepsWhyOutput(out, "target") + assert.Len(t, requestedBy, requestedByMaxPaths) +} + +// TestParseDepsWhyOutput_SinglePathNotTruncatedByDepth verifies an individual chain's depth +// is reported in full - only the number of distinct paths is capped, not how deep one path goes. +func TestParseDepsWhyOutput_SinglePathNotTruncatedByDepth(t *testing.T) { + chain := `{"is_direct": false, "repo_url": "target"}` + var parents strings.Builder + for i := range requestedByMaxPaths + 5 { + parents.WriteString(`{"is_direct": false, "repo_url": "p` + string(rune('a'+i%26)) + `"},`) + } + out := []byte(`{ + "package": {"is_direct": false, "repo_url": "target", "source": "registry", "version": "1.0.0"}, + "paths": [{"chain": [` + parents.String() + chain + `]}] + }`) + _, requestedBy := parseDepsWhyOutput(out, "target") + require := assert.New(t) + require.Len(requestedBy, 1) + require.Len(requestedBy[0], requestedByMaxPaths+5) +} diff --git a/agent/apm/common/lockfile.go b/agent/apm/common/lockfile.go new file mode 100644 index 00000000..b76dc834 --- /dev/null +++ b/agent/apm/common/lockfile.go @@ -0,0 +1,66 @@ +package apmcommon + +import ( + "os" + "strings" + + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "gopkg.in/yaml.v3" +) + +const ApmLockfileName = "apm.lock.yaml" + +// ApmLockFile represents apm.lock.yaml. The real schema is a flat list under "dependencies" — +// confirmed live against a real `apm install` run — not a map under "packages". +type ApmLockFile struct { + LockfileVersion string `yaml:"lockfile_version"` + Dependencies []ApmLockedPackage `yaml:"dependencies"` +} + +type ApmLockedPackage struct { + RepoURL string `yaml:"repo_url"` // "owner/repo" + Name string `yaml:"name"` + Version string `yaml:"version"` + PackageType string `yaml:"package_type"` + Source string `yaml:"source"` // "registry" for Artifactory-resolved deps + ContentHash string `yaml:"content_hash"` + ResolvedURL string `yaml:"resolved_url"` // full agentpackages download URL + ResolvedHash string `yaml:"resolved_hash"` +} + +func LoadLockFile(path string) (*ApmLockFile, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, errorutils.CheckError(err) + } + var lockfile ApmLockFile + if err = yaml.Unmarshal(data, &lockfile); err != nil { + return nil, errorutils.CheckErrorf("parsing %s: %s", ApmLockfileName, err.Error()) + } + return &lockfile, nil +} + +// RegistryPackages returns only dependencies with source=registry. +func (l *ApmLockFile) RegistryPackages() []ApmLockedPackage { + var result []ApmLockedPackage + for _, pkg := range l.Dependencies { + if pkg.Source == "registry" { + result = append(result, pkg) + } + } + return result +} + +// DepID returns the build-info dependency ID: "owner/repo:version". +func (pkg ApmLockedPackage) DepID() string { + return pkg.RepoURL + ":" + pkg.Version +} + +// SHA256Hex extracts the hex-encoded SHA-256 from a "sha256:" string. +func SHA256Hex(resolvedHash string) string { + const prefix = "sha256:" + if !strings.HasPrefix(resolvedHash, prefix) { + return "" + } + return resolvedHash[len(prefix):] +} diff --git a/agent/apm/common/lockfile_test.go b/agent/apm/common/lockfile_test.go new file mode 100644 index 00000000..ee28c539 --- /dev/null +++ b/agent/apm/common/lockfile_test.go @@ -0,0 +1,71 @@ +package apmcommon + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSHA256Hex(t *testing.T) { + tests := []struct { + name string + resolvedHash string + want string + }{ + {name: "sha256 prefixed", resolvedHash: "sha256:abc123", want: "abc123"}, + {name: "different algo prefix", resolvedHash: "md5:abc123", want: ""}, + {name: "empty", resolvedHash: "", want: ""}, + {name: "no prefix", resolvedHash: "abc123", want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, SHA256Hex(tt.resolvedHash)) + }) + } +} + +func TestApmLockedPackage_DepID(t *testing.T) { + pkg := ApmLockedPackage{RepoURL: "acme/skills-pack", Version: "2.0.0"} + assert.Equal(t, "acme/skills-pack:2.0.0", pkg.DepID()) +} + +func TestApmLockFile_RegistryPackages(t *testing.T) { + lockfile := &ApmLockFile{ + Dependencies: []ApmLockedPackage{ + {RepoURL: "acme/skills-pack", Source: "registry"}, + {RepoURL: "someone/github-direct", Source: "github"}, + {RepoURL: "acme/prompt-pack", Source: "registry"}, + }, + } + registryPkgs := lockfile.RegistryPackages() + require.Len(t, registryPkgs, 2) + assert.Equal(t, "acme/skills-pack", registryPkgs[0].RepoURL) + assert.Equal(t, "acme/prompt-pack", registryPkgs[1].RepoURL) +} + +func TestLoadLockFile(t *testing.T) { + tempDir := t.TempDir() + lockfilePath := filepath.Join(tempDir, ApmLockfileName) + content := ` +lockfile_version: "1" +dependencies: + - repo_url: acme/skills-pack + version: 2.0.0 + source: registry + resolved_hash: sha256:abc123 +` + require.NoError(t, os.WriteFile(lockfilePath, []byte(content), 0644)) + + lockfile, err := LoadLockFile(lockfilePath) + require.NoError(t, err) + require.Len(t, lockfile.Dependencies, 1) + assert.Equal(t, "acme/skills-pack", lockfile.Dependencies[0].RepoURL) +} + +func TestLoadLockFile_MissingFile(t *testing.T) { + _, err := LoadLockFile(filepath.Join(t.TempDir(), "does-not-exist.yaml")) + assert.Error(t, err) +} diff --git a/agent/apm/common/manifest.go b/agent/apm/common/manifest.go new file mode 100644 index 00000000..74dba136 --- /dev/null +++ b/agent/apm/common/manifest.go @@ -0,0 +1,59 @@ +package apmcommon + +import ( + "net/url" + "os" + "strings" + + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "gopkg.in/yaml.v3" +) + +const ApmManifestName = "apm.yml" + +// ApmManifest represents apm.yml. Dependencies are intentionally not modeled here: +// the real schema is a nested map (dependencies: {apm: [...], mcp: [...]}), and nothing +// in this package currently needs the declared-dependency list — only name/version/registries. +// +// Registries is a map keyed by registry name (confirmed live against a real apm.yml) — +// not a list. An earlier version of this struct modeled it as []ManifestRegistry, which +// made LoadManifest fail on every real apm.yml that declares any registries at all. +type ApmManifest struct { + Name string `yaml:"name"` + Version string `yaml:"version"` + Registries map[string]ManifestRegistry `yaml:"registries"` +} + +type ManifestRegistry struct { + URL string `yaml:"url"` +} + +func LoadManifest(path string) (*ApmManifest, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &ApmManifest{}, nil + } + return nil, errorutils.CheckError(err) + } + var manifest ApmManifest + if err = yaml.Unmarshal(data, &manifest); err != nil { + return nil, errorutils.CheckErrorf("parsing %s: %s", ApmManifestName, err.Error()) + } + return &manifest, nil +} + +// apmHostMatches returns true if registryURL and artifactoryURL share the same host. +func apmHostMatches(registryURL, artifactoryURL string) bool { + rHost := parseHost(registryURL) + aHost := parseHost(artifactoryURL) + return rHost != "" && aHost != "" && rHost == aHost +} + +func parseHost(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil || u.Host == "" { + return "" + } + return strings.ToLower(u.Host) +} diff --git a/agent/apm/common/subcommand_options.go b/agent/apm/common/subcommand_options.go new file mode 100644 index 00000000..1368618f --- /dev/null +++ b/agent/apm/common/subcommand_options.go @@ -0,0 +1,101 @@ +package apmcommon + +import ( + agentcommon "github.com/jfrog/jfrog-cli-artifactory/agent/common" + buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" +) + +// ApmSubcommandOptions holds jf's own flags, manually extracted from a SkipFlagParsing +// subcommand's raw arguments, plus whatever args remained afterward. +type ApmSubcommandOptions struct { + // RemainingArgs is what's left after stripping jf's own flags - passed straight through + // to the real apm binary, so apm-native flags (--package, --registry, --zip, --dry-run, + // etc.) survive untouched. + RemainingArgs []string + ServerDetails *config.ServerDetails + BuildConfig *buildUtils.BuildConfiguration +} + +// ExtractApmSubcommandOptions extracts every flag declared for install/publish/update +// (--server-id, --build-name, --build-number, --module, --project, --url, --user, +// --password, --access-token) from args, and resolves them into ServerDetails and a +// BuildConfiguration. +// +// This exists because install/publish/update set SkipFlagParsing (so apm-native flags that +// aren't in jf's own declared flag set - --package, --registry, --zip, --dry-run - don't get +// rejected by urfave/cli before ever reaching apm). SkipFlagParsing means urfave/cli parses +// NONE of the flags itself, jf's own included, so every one of them has to be pulled out by +// hand. Unlike passthrough (which still supports --repo to declare a new registry inline), +// install/publish/update require a registry to already be declared, so --repo isn't one of +// jf's own flags here - it passes straight through in RemainingArgs like any other +// apm-native flag (where apm itself will reject it, since apm has no --repo flag either). +func ExtractApmSubcommandOptions(args []string) (*ApmSubcommandOptions, error) { + rest := args + var serverID, buildName, buildNumber, module, project string + var url, user, password, accessToken string + var err error + + for _, opt := range []struct { + name string + dest *string + }{ + {"server-id", &serverID}, + {"build-name", &buildName}, + {"build-number", &buildNumber}, + {"module", &module}, + {"project", &project}, + {"url", &url}, + {"user", &user}, + {"password", &password}, + {"access-token", &accessToken}, + } { + rest, *opt.dest, err = coreutils.ExtractStringOptionFromArgs(rest, opt.name) + if err != nil { + return nil, err + } + } + + sd, err := resolveServerDetails(serverID, url, user, password, accessToken) + if err != nil { + return nil, err + } + + buildConfig := new(buildUtils.BuildConfiguration) + if err = buildConfig.SetBuildName(buildName).SetBuildNumber(buildNumber).SetProject(project).SetModule(module).ValidateBuildAndModuleParams(); err != nil { + return nil, err + } + + return &ApmSubcommandOptions{ + RemainingArgs: rest, + ServerDetails: sd, + BuildConfig: buildConfig, + }, nil +} + +// resolveServerDetails mirrors agent/common.GetServerDetails's two cases (explicit flags vs. +// default config), extended with a third for --server-id specifically: explicit server-id +// always wins over url/user/password/access-token, matching how the framework's own flag +// parsing would have resolved these before SkipFlagParsing made manual extraction necessary. +func resolveServerDetails(serverID, url, user, password, accessToken string) (*config.ServerDetails, error) { + if serverID != "" { + return config.GetSpecificConfig(serverID, true, true) + } + if url != "" || user != "" || password != "" || accessToken != "" { + details := &config.ServerDetails{ + // --url is the Artifactory URL for this command domain, same as + // createServerDetailsFromFlags's cliutils.Rt case in jfrog-cli-core - + // NormalizeArtifactoryUrl (and everything downstream, e.g. AgentPackagesBaseURL) + // reads ArtifactoryUrl, not Url. + ArtifactoryUrl: url, + User: user, + Password: password, + AccessToken: accessToken, + } + agentcommon.NormalizeArtifactoryUrl(details) + return details, nil + } + // No server flags at all - same fallback as GetSpecificConfig("", true, true). + return config.GetSpecificConfig("", true, true) +} diff --git a/agent/apm/common/subcommand_options_test.go b/agent/apm/common/subcommand_options_test.go new file mode 100644 index 00000000..33113053 --- /dev/null +++ b/agent/apm/common/subcommand_options_test.go @@ -0,0 +1,86 @@ +package apmcommon + +import ( + "testing" + + "github.com/jfrog/jfrog-cli-artifactory/agent/common/testutil" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveServerDetails_DirectCredentials(t *testing.T) { + testutil.WithJfrogHome(t) // isolate from any real jf config on this machine + + sd, err := resolveServerDetails("", "https://acme.jfrog.io", "uday", "", "my-token") + require.NoError(t, err) + assert.Equal(t, "uday", sd.User) + assert.Equal(t, "my-token", sd.AccessToken) + assert.Equal(t, "https://acme.jfrog.io/artifactory/", sd.ArtifactoryUrl) +} + +func TestResolveServerDetails_ServerIDWinsOverDirectCredentials(t *testing.T) { + testutil.WithJfrogHome(t) + // At least one real server must exist so GetAllServersConfigs() is non-empty - otherwise + // GetSpecificConfig short-circuits to an empty ServerDetails regardless of serverID, + // and this test couldn't tell "server-id consulted and not found" from "no configs at all". + require.NoError(t, config.SaveServersConf([]*config.ServerDetails{ + {ServerId: "known-server", Url: "https://known.jfrog.io/"}, + })) + + // server-id present but not the one configured: must error rather than silently fall + // through to the direct-credential branch below it. + _, err := resolveServerDetails("does-not-exist", "https://acme.jfrog.io", "", "", "ignored-token") + assert.Error(t, err) + + // server-id present AND matching a real config: must use that config, not the passed + // direct credentials, proving server-id truly takes precedence. + sd, err := resolveServerDetails("known-server", "https://acme.jfrog.io", "", "", "ignored-token") + require.NoError(t, err) + assert.Equal(t, "https://known.jfrog.io/", sd.Url) + assert.Empty(t, sd.AccessToken) +} + +func TestResolveServerDetails_NoFlagsAtAll(t *testing.T) { + testutil.WithJfrogHome(t) // no servers configured + + sd, err := resolveServerDetails("", "", "", "", "") + require.NoError(t, err) + assert.Empty(t, sd.ArtifactoryUrl) +} + +func TestExtractApmSubcommandOptions_PreservesApmNativeFlags(t *testing.T) { + testutil.WithJfrogHome(t) + + // install/publish/update don't declare --repo as one of jf's own flags (unlike + // passthrough) - a registry must already be declared, so --repo isn't extracted here and + // flows through untouched like any other apm-native flag. + opts, err := ExtractApmSubcommandOptions([]string{ + "--repo", "buk-apm", + "--registry", "buk-apm", + "--frozen", + "uday/pkg-base#1.0.0", + }) + require.NoError(t, err) + assert.Equal(t, []string{"--repo", "buk-apm", "--registry", "buk-apm", "--frozen", "uday/pkg-base#1.0.0"}, opts.RemainingArgs) +} + +func TestExtractApmSubcommandOptions_DirectCredentialsFlowThrough(t *testing.T) { + testutil.WithJfrogHome(t) + + opts, err := ExtractApmSubcommandOptions([]string{ + "--url", "https://acme.jfrog.io", + "--access-token", "my-token", + }) + require.NoError(t, err) + assert.Equal(t, "my-token", opts.ServerDetails.AccessToken) + assert.Equal(t, "https://acme.jfrog.io/artifactory/", opts.ServerDetails.ArtifactoryUrl) + assert.Empty(t, opts.RemainingArgs) +} + +func TestExtractApmSubcommandOptions_BuildNameWithoutNumberErrors(t *testing.T) { + testutil.WithJfrogHome(t) + + _, err := ExtractApmSubcommandOptions([]string{"--build-name", "my-build"}) + assert.Error(t, err) +} diff --git a/agent/apm/common/utils.go b/agent/apm/common/utils.go new file mode 100644 index 00000000..e7312d3e --- /dev/null +++ b/agent/apm/common/utils.go @@ -0,0 +1,42 @@ +package apmcommon + +import ( + "os/exec" + "regexp" + + "github.com/jfrog/gofrog/version" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +const minSupportedApmVersion = "0.1.0" + +// apmVersionPattern extracts the dotted version number from apm's descriptive `--version` +// output, e.g. "Agent Package Manager (APM) CLI version 0.23.1 (d1d926d)" -> "0.23.1". +var apmVersionPattern = regexp.MustCompile(`\d+\.\d+(\.\d+)?`) + +func ValidateApmPrerequisites() error { + ver, err := GetApmVersion() + if err != nil { + return err + } + if !ver.AtLeast(minSupportedApmVersion) { + return errorutils.CheckErrorf( + "JFrog CLI apm commands require apm version %s or higher. Current version: %s", + minSupportedApmVersion, ver.GetVersion()) + } + log.Debug("apm version:", ver.GetVersion()) + return nil +} + +func GetApmVersion() (*version.Version, error) { + out, err := exec.Command("apm", "--version").Output() + if err != nil { + return nil, errorutils.CheckErrorf("failed to determine apm version. Ensure apm is installed: %s", err.Error()) + } + match := apmVersionPattern.FindString(string(out)) + if match == "" { + return nil, errorutils.CheckErrorf("could not parse apm version from output: %s", string(out)) + } + return version.NewVersion(match), nil +} diff --git a/agent/apm/common/utils_test.go b/agent/apm/common/utils_test.go new file mode 100644 index 00000000..53bef062 --- /dev/null +++ b/agent/apm/common/utils_test.go @@ -0,0 +1,80 @@ +package apmcommon + +import ( + "testing" + + "github.com/jfrog/gofrog/version" + "github.com/stretchr/testify/assert" +) + +// TestApmVersionPattern is a regression test: GetApmVersion used to feed apm's entire +// descriptive `--version` output straight into version.NewVersion, which made +// ValidateApmPrerequisites's min-version check pass or fail by accident of string shape +// rather than by actually comparing semantic versions (verified against the real installed +// apm binary during review). This confirms the regex extracts just the dotted version number. +func TestApmVersionPattern(t *testing.T) { + tests := []struct { + name string + output string + want string + }{ + { + name: "real apm --version output", + output: "Agent Package Manager (APM) CLI version 0.23.1 (d1d926d)\n", + want: "0.23.1", + }, + { + name: "bare version", + output: "0.1.0\n", + want: "0.1.0", + }, + { + name: "two-segment version", + output: "apm version 1.2\n", + want: "1.2", + }, + { + name: "no version present", + output: "apm: command not found\n", + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, apmVersionPattern.FindString(tt.output)) + }) + } +} + +func TestValidateApmPrerequisites_VersionComparisonDirection(t *testing.T) { + // Exercises the exact version.AtLeast call ValidateApmPrerequisites makes, using a + // correctly-extracted version string (the fix for the bug above): a version at or above + // the minimum must not trigger the "needs update" error path, and one below it must. + tests := []struct { + name string + rawOutput string + wantError bool + }{ + { + name: "modern installed version satisfies the minimum", + rawOutput: "Agent Package Manager (APM) CLI version 0.23.1 (d1d926d)", + wantError: false, + }, + { + name: "installed version below the minimum", + rawOutput: "Agent Package Manager (APM) CLI version 0.0.5 (abc1234)", + wantError: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + installed := apmVersionPattern.FindString(tt.rawOutput) + require := assert.New(t) + require.NotEmpty(installed) + + ver := version.NewVersion(installed) + gotError := !ver.AtLeast(minSupportedApmVersion) + assert.Equal(t, tt.wantError, gotError) + }) + } +} diff --git a/agent/cli/cli.go b/agent/cli/cli.go index b4940915..3cfbf3c8 100644 --- a/agent/cli/cli.go +++ b/agent/cli/cli.go @@ -1,8 +1,11 @@ package cli import ( + apmcli "github.com/jfrog/jfrog-cli-artifactory/agent/apm/cli" + "github.com/jfrog/jfrog-cli-artifactory/agent/apm/commands/passthrough" pluginscli "github.com/jfrog/jfrog-cli-artifactory/agent/plugins/cli" skillscli "github.com/jfrog/jfrog-cli-artifactory/agent/skills/cli" + "github.com/jfrog/jfrog-cli-artifactory/cliutils/flagkit" "github.com/jfrog/jfrog-cli-core/v2/plugins/components" ) @@ -20,5 +23,12 @@ func GetCommands() []components.Command { Description: "Agent skill commands.", Subcommands: skillscli.GetSubCommands(), }, + { + Name: "apm", + Description: "Agent Package Manager (APM) commands with JFrog Artifactory authentication.", + Flags: flagkit.GetCommandFlags(flagkit.ApmPassthrough), + Subcommands: apmcli.GetSubCommands(), + Action: passthrough.RunApmPassthroughDefault, + }, } } diff --git a/agent/cli/cli_test.go b/agent/cli/cli_test.go index 1d5f4b03..175e1699 100644 --- a/agent/cli/cli_test.go +++ b/agent/cli/cli_test.go @@ -9,7 +9,7 @@ import ( func TestGetCommands_HasPluginsAndSkillsNamespaces(t *testing.T) { commands := GetCommands() - require.Len(t, commands, 2) + require.Len(t, commands, 3) plugins := commands[0] assert.Equal(t, "plugins", plugins.Name) @@ -33,6 +33,18 @@ func TestGetCommands_HasPluginsAndSkillsNamespaces(t *testing.T) { []string{"list", "publish", "install", "update", "search", "delete"}, skillsNames, ) + + apm := commands[2] + assert.Equal(t, "apm", apm.Name) + // Unlike plugins/skills, apm's parent command has its own Action (RunApmPassthroughDefault) + // so unregistered apm subcommands (doctor, list, ...) still reach the real apm binary. + assert.NotNil(t, apm.Action) + apmNames := make([]string, 0, len(apm.Subcommands)) + for _, sub := range apm.Subcommands { + assert.NotNil(t, sub.Action, "apm subcommand %q must have an Action", sub.Name) + apmNames = append(apmNames, sub.Name) + } + assert.ElementsMatch(t, []string{"install", "publish", "update"}, apmNames) } func TestGetCommands_PluginsPublishDescription(t *testing.T) { diff --git a/agent/common/evd.go b/agent/common/evd.go index 8c6eb9e2..73420984 100644 --- a/agent/common/evd.go +++ b/agent/common/evd.go @@ -55,9 +55,9 @@ func VerifyEvidence(serverDetails *config.ServerDetails, opts VerifyEvidenceOpts } // ensureServiceUrls populates service-specific URLs that the evidence library requires. -// Platform URL comes from config.ServerDetails (Url / ArtifactoryUrl via normalizeArtifactoryUrl). +// Platform URL comes from config.ServerDetails (Url / ArtifactoryUrl via NormalizeArtifactoryUrl). func ensureServiceUrls(localServerDetails *config.ServerDetails) { - normalizeArtifactoryUrl(localServerDetails) + NormalizeArtifactoryUrl(localServerDetails) platformBase := clientutils.AddTrailingSlashIfNeeded(localServerDetails.GetUrl()) if platformBase == "" { return diff --git a/agent/common/server.go b/agent/common/server.go index e887ffaa..26ab20b4 100644 --- a/agent/common/server.go +++ b/agent/common/server.go @@ -30,11 +30,13 @@ func GetServerDetails(commandContext *components.Context) (*config.ServerDetails if details.ArtifactoryUrl == "" && details.Url == "" { return nil, fmt.Errorf("no Artifactory URL configured") } - normalizeArtifactoryUrl(details) + NormalizeArtifactoryUrl(details) return details, nil } -func normalizeArtifactoryUrl(details *config.ServerDetails) { +// NormalizeArtifactoryUrl ensures details.ArtifactoryUrl always ends with /artifactory/, +// filling in details.Url from it when Url is empty. +func NormalizeArtifactoryUrl(details *config.ServerDetails) { artifactoryURL := details.GetArtifactoryUrl() if artifactoryURL == "" { return diff --git a/agent/common/server_test.go b/agent/common/server_test.go index c27a71b9..3beb329f 100644 --- a/agent/common/server_test.go +++ b/agent/common/server_test.go @@ -14,7 +14,7 @@ func TestNormalizeArtifactoryUrl_AppendsArtifactoryPath(t *testing.T) { details := &config.ServerDetails{ ArtifactoryUrl: "https://acme.jfrog.io", } - normalizeArtifactoryUrl(details) + NormalizeArtifactoryUrl(details) assert.Equal(t, "https://acme.jfrog.io/artifactory/", details.ArtifactoryUrl) assert.Equal(t, "https://acme.jfrog.io/", details.Url) } @@ -24,14 +24,14 @@ func TestNormalizeArtifactoryUrl_KeepsExistingArtifactoryPath(t *testing.T) { ArtifactoryUrl: "https://acme.jfrog.io/artifactory/", Url: "https://acme.jfrog.io/", } - normalizeArtifactoryUrl(details) + NormalizeArtifactoryUrl(details) assert.Equal(t, "https://acme.jfrog.io/artifactory/", details.ArtifactoryUrl) assert.Equal(t, "https://acme.jfrog.io/", details.Url) } func TestNormalizeArtifactoryUrl_EmptyURL(t *testing.T) { details := &config.ServerDetails{} - normalizeArtifactoryUrl(details) + NormalizeArtifactoryUrl(details) assert.Empty(t, details.ArtifactoryUrl) } diff --git a/artifactory/commands/repository/template.go b/artifactory/commands/repository/template.go index a0b80d78..5f5619c0 100644 --- a/artifactory/commands/repository/template.go +++ b/artifactory/commands/repository/template.go @@ -131,38 +131,39 @@ const ( Federated = "federated" // PackageTypes - Generic = "generic" - Maven = "maven" - Gradle = "gradle" - Ivy = "ivy" - Sbt = "sbt" - Helm = "helm" - Cocoapods = "cocoapods" - Opkg = "opkg" - Rpm = "rpm" - Nuget = "nuget" - Cran = "cran" - Gems = "gems" - Npm = "npm" - Bower = "bower" - Debian = "debian" - Composer = "composer" - Pypi = "pypi" - Docker = "docker" - Vagrant = "vagrant" - Gitlfs = "gitlfs" - Go = "go" - Yum = "yum" - Conan = "conan" - Chef = "chef" - Puppet = "puppet" - Vcs = "vcs" - Alpine = "alpine" - Conda = "conda" - P2 = "p2" - Swift = "swift" - Terraform = "terraform" - Cargo = "cargo" + Generic = "generic" + Maven = "maven" + Gradle = "gradle" + Ivy = "ivy" + Sbt = "sbt" + Helm = "helm" + Cocoapods = "cocoapods" + Opkg = "opkg" + Rpm = "rpm" + Nuget = "nuget" + Cran = "cran" + Gems = "gems" + Npm = "npm" + Bower = "bower" + Debian = "debian" + Composer = "composer" + Pypi = "pypi" + Docker = "docker" + Vagrant = "vagrant" + Gitlfs = "gitlfs" + Go = "go" + Yum = "yum" + Conan = "conan" + Chef = "chef" + Puppet = "puppet" + Vcs = "vcs" + Alpine = "alpine" + Conda = "conda" + P2 = "p2" + Swift = "swift" + Terraform = "terraform" + Cargo = "cargo" + AgentPackages = "agentpackages" // Repo layout Refs BowerDefaultRepoLayout = "bower-default" diff --git a/artifactory/commands/setup/setup.go b/artifactory/commands/setup/setup.go index 85651aa6..e46f0a15 100644 --- a/artifactory/commands/setup/setup.go +++ b/artifactory/commands/setup/setup.go @@ -12,6 +12,7 @@ import ( bidotnet "github.com/jfrog/build-info-go/build/utils/dotnet" biutils "github.com/jfrog/build-info-go/utils" + apmcommon "github.com/jfrog/jfrog-cli-artifactory/agent/apm/common" "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/dotnet" "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/golang" "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/gradle" @@ -41,11 +42,12 @@ var packageManagerToRepositoryPackageType = map[project.ProjectType]string{ project.Yarn: repository.Npm, // Python (pypi) package managers - project.Pip: repository.Pypi, - project.Pipenv: repository.Pypi, - project.Poetry: repository.Pypi, - project.Twine: repository.Pypi, - project.UV: repository.Pypi, + project.Pip: repository.Pypi, + project.Pipenv: repository.Pypi, + project.Poetry: repository.Pypi, + project.Twine: repository.Pypi, + project.UV: repository.Pypi, + project.AgentApm: repository.AgentPackages, // Nuget package managers project.Nuget: repository.Nuget, @@ -184,6 +186,8 @@ func (sc *SetupCommand) Run() (err error) { err = sc.configureMaven() case project.UV: err = sc.configureUV() + case project.AgentApm: + err = sc.configureAgentApm() default: err = errorutils.CheckErrorf("unsupported package manager: %s", sc.packageManager) } @@ -198,10 +202,16 @@ func (sc *SetupCommand) Run() (err error) { return nil } -// promptUserToSelectRepository prompts the user to select a compatible virtual repository. +// promptUserToSelectRepository prompts the user to select a compatible repository - virtual for +// every package manager except AgentApm, which is local-only (agentpackages has no remote/virtual +// support in Artifactory at all, so a virtual-repo search can never find a match for it). func (sc *SetupCommand) promptUserToSelectRepository() (err error) { + repoType := utils.Virtual + if sc.packageManager == project.AgentApm { + repoType = utils.Local + } repoFilterParams := services.RepositoriesFilterParams{ - RepoType: utils.Virtual.String(), + RepoType: repoType.String(), PackageType: packageManagerToRepositoryPackageType[sc.packageManager], ProjectKey: sc.projectKey, } @@ -570,6 +580,17 @@ func (sc *SetupCommand) configureUV() error { return nil } +// configureAgentApm persistently configures the APM (Agent Package Manager) global config +// (~/.apm/config.json) to authenticate against the specified Artifactory agentpackages repository. +// This is the only APM operation that writes to the real home directory; all other APM commands +// use a temporary HOME to avoid persistent side-effects. +func (sc *SetupCommand) configureAgentApm() error { + if err := apmcommon.ValidateApmPrerequisites(); err != nil { + return err + } + return apmcommon.ConfigureApmRegistryPersistent(sc.serverDetails, sc.repoName) +} + // configureHelm configures Helm to use Artifactory as an OCI registry. // It executes: // diff --git a/cliutils/flagkit/flags.go b/cliutils/flagkit/flags.go index 17214b4b..c20608d5 100644 --- a/cliutils/flagkit/flags.go +++ b/cliutils/flagkit/flags.go @@ -511,6 +511,12 @@ const ( SkillsDelete = "skills-delete" SkillsList = "skills-list" + // Agent APM commands keys. install/publish/lock/update all take the identical + // server + build-info flag set, so they share one key; only the bare passthrough differs + // (no build-info flags, since it can't collect build-info at all). + ApmSubcommand = "apm-subcommand" + ApmPassthrough = "apm-passthrough" + // Agent plugin commands keys AgentPluginsPublish = "agent-plugins-publish" AgentPluginsInstall = "agent-plugins-install" @@ -920,6 +926,12 @@ var commandFlags = map[string][]string{ SkillsList: { url, user, password, accessToken, serverId, repo, harness, projectDir, agentGlobal, agentFormat, agentLimit, agentSortBy, agentSortOrder, agentCheckUpdates, }, + ApmSubcommand: { + url, user, password, accessToken, serverId, BuildName, BuildNumber, module, Project, + }, + ApmPassthrough: { + url, user, password, accessToken, serverId, repo, + }, } var flagsMap = map[string]components.Flag{ diff --git a/go.mod b/go.mod index 556dadf6..fb523350 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( golang.org/x/exp v0.0.0-20260527015227-08cc5374adb3 golang.org/x/mod v0.36.0 gopkg.in/ini.v1 v1.67.1 + gopkg.in/yaml.v3 v3.0.1 helm.sh/helm/v3 v3.19.2 oras.land/oras-go/v2 v2.6.1 ) @@ -192,7 +193,6 @@ require ( google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/client-go v0.34.0 // indirect k8s.io/klog/v2 v2.140.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect From ea040307b62372d85c0bc0b9744c0525f2f47b57 Mon Sep 17 00:00:00 2001 From: Uday Date: Tue, 28 Jul 2026 11:14:27 +0530 Subject: [PATCH 02/40] RTECO-1648 - Fix gosec findings in apm package (Go-Sec CI check) gosec flagged G204 (subprocess launched with variable) on the two apm exec.Command call sites and G304 (file inclusion via variable) on the three apm config/manifest/lockfile readers. All five are annotated with #nosec plus a justification: the G204 sites either validate the argument against flag-injection beforehand or forward the invoking user's own CLI args with no shell involved, and the G304 sites always read a path built from a fixed filename joined with a trusted working/home directory, never user-supplied input. --- agent/apm/common/apmenv.go | 4 ++-- agent/apm/common/dependency_resolver.go | 2 +- agent/apm/common/lockfile.go | 5 ++++- agent/apm/common/manifest.go | 5 ++++- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/agent/apm/common/apmenv.go b/agent/apm/common/apmenv.go index 8ab47f2e..face1495 100644 --- a/agent/apm/common/apmenv.go +++ b/agent/apm/common/apmenv.go @@ -316,7 +316,7 @@ func loadExistingApmConfig() (realHome string, existing *apmConfigJSON, err erro } func readApmConfig(path string) (*apmConfigJSON, error) { - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) // #nosec G304 -- path is always os.UserHomeDir()+"/.apm/config.json" (see loadExistingApmConfig), never user-supplied if err != nil { if os.IsNotExist(err) { return &apmConfigJSON{}, nil @@ -364,7 +364,7 @@ func replaceEnvHome(env []string, newHome string) []string { func RunApmCommand(env []string, subcmd string, args []string) error { allArgs := append([]string{subcmd}, args...) log.Debug(fmt.Sprintf("Running: apm %s", strings.Join(allArgs, " "))) - cmd := exec.Command("apm", allArgs...) + cmd := exec.Command("apm", allArgs...) // #nosec G204 -- args are this same invocation's own CLI arguments, forwarded verbatim by design (this is the passthrough wrapper); no shell is invoked and no privilege boundary is crossed if env != nil { cmd.Env = env } diff --git a/agent/apm/common/dependency_resolver.go b/agent/apm/common/dependency_resolver.go index 9c9837da..f48184c5 100644 --- a/agent/apm/common/dependency_resolver.go +++ b/agent/apm/common/dependency_resolver.go @@ -98,7 +98,7 @@ func resolveScopeAndRequestedBy(workingDir, repoURL string) (scopes []string, re return []string{"runtime"}, nil } - cmd := exec.Command("apm", "deps", "why", repoURL, "--json") + cmd := exec.Command("apm", "deps", "why", repoURL, "--json") // #nosec G204 -- repoURL is validated above to reject flag-shaped values; exec.Command never invokes a shell, so no injection vector remains cmd.Dir = workingDir out, err := cmd.Output() if err != nil { diff --git a/agent/apm/common/lockfile.go b/agent/apm/common/lockfile.go index b76dc834..4776310b 100644 --- a/agent/apm/common/lockfile.go +++ b/agent/apm/common/lockfile.go @@ -28,8 +28,11 @@ type ApmLockedPackage struct { ResolvedHash string `yaml:"resolved_hash"` } +// LoadLockFile reads and parses apm.lock.yaml at path. Every caller in this codebase constructs +// path from a working directory joined with the fixed ApmLockfileName ("apm.lock.yaml"), never +// from unsanitized user input. func LoadLockFile(path string) (*ApmLockFile, error) { - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) // #nosec G304 -- path is always workingDir+ApmLockfileName, constructed by the caller, never user-supplied if err != nil { return nil, errorutils.CheckError(err) } diff --git a/agent/apm/common/manifest.go b/agent/apm/common/manifest.go index 74dba136..cb3c560b 100644 --- a/agent/apm/common/manifest.go +++ b/agent/apm/common/manifest.go @@ -28,8 +28,11 @@ type ManifestRegistry struct { URL string `yaml:"url"` } +// LoadManifest reads and parses apm.yml at path. Every caller in this codebase constructs path +// from a working directory joined with the fixed ApmManifestName ("apm.yml"), never from +// unsanitized user input. func LoadManifest(path string) (*ApmManifest, error) { - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) // #nosec G304 -- path is always workingDir+ApmManifestName, constructed by the caller, never user-supplied if err != nil { if os.IsNotExist(err) { return &ApmManifest{}, nil From 859187e70ef43453f1ae46866539a35e2325fff4 Mon Sep 17 00:00:00 2001 From: Uday Date: Tue, 28 Jul 2026 11:17:36 +0530 Subject: [PATCH 03/40] RTECO-1648 - Bump jfrog-cli-core to the pushed RTECO-1648 commit Points go.mod at jfrog-cli-core's RTECO-1648-apm-support-implementation branch commit (97df5ed), which adds the project.AgentApm type this branch depends on. Temporary: once that branch merges to jfrog-cli-core's main and releases, this pin needs to move to the real released version. --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 81252adc..376ad3b6 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/jedib0t/go-pretty/v6 v6.7.10 github.com/jfrog/build-info-go v1.13.1-0.20260610071651-260ad6720e0d github.com/jfrog/gofrog v1.7.6 - github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260609101026-df3091b39d06 + github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260728053823-97df5ed47c5d github.com/jfrog/jfrog-cli-evidence v0.9.0 github.com/jfrog/jfrog-client-go v1.55.1-0.20260508101905-a17af78a38d7 github.com/pkg/errors v0.9.1 diff --git a/go.sum b/go.sum index 558ecfca..a92296c9 100644 --- a/go.sum +++ b/go.sum @@ -386,6 +386,8 @@ 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-core/v2 v2.60.1-0.20260728053823-97df5ed47c5d h1:ox1OxsrkHcnK0m4SyQ+GmEIuOJ6BuFupCvqWwlxRxNU= +github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260728053823-97df5ed47c5d/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= From ff8d85eae340e3181e0ecdc111bca4878db9e9a9 Mon Sep 17 00:00:00 2001 From: Uday Date: Tue, 28 Jul 2026 12:02:06 +0530 Subject: [PATCH 04/40] RTECO-1648 - Drop direct-credential flags from apm, rename flagkit keys Removes --url/--user/--password/--access-token from install/publish/update and the generic passthrough, matching the pnpm/npm/yarn/nuget convention: auth resolves purely from --server-id or the default configured server, never from ad hoc credentials on the runtime command. A registry/server must already be declared (via jf setup agent-apm, apm.yml's registries: block, or --server-id) before any apm command can authenticate. Also renames the flagkit keys ApmSubcommand/ApmPassthrough to AgentApmSubcommand/AgentApmPassthrough, matching the AgentPlugins*/ AgentSkills* naming convention already used for sibling agent-namespace commands in this file. --- agent/apm/cli/cli.go | 6 +-- agent/apm/common/subcommand_options.go | 43 +++-------------- agent/apm/common/subcommand_options_test.go | 52 ++++++++------------- agent/cli/cli.go | 2 +- cliutils/flagkit/flags.go | 18 ++++--- 5 files changed, 42 insertions(+), 79 deletions(-) diff --git a/agent/apm/cli/cli.go b/agent/apm/cli/cli.go index 88277288..80f21092 100644 --- a/agent/apm/cli/cli.go +++ b/agent/apm/cli/cli.go @@ -17,7 +17,7 @@ func GetSubCommands() []components.Command { return []components.Command{ { Name: "install", - Flags: flagkit.GetCommandFlags(flagkit.ApmSubcommand), + Flags: flagkit.GetCommandFlags(flagkit.AgentApmSubcommand), // SkipFlagParsing so apm-native flags (e.g. --frozen) that aren't in jf's own // declared flag set above aren't rejected by urfave/cli before reaching apm. // RunInstall extracts jf's own flags manually via ExtractApmSubcommandOptions. @@ -27,14 +27,14 @@ func GetSubCommands() []components.Command { }, { Name: "publish", - Flags: flagkit.GetCommandFlags(flagkit.ApmSubcommand), + Flags: flagkit.GetCommandFlags(flagkit.AgentApmSubcommand), SkipFlagParsing: true, Description: "Publish an APM package to JFrog Artifactory.", Action: publish.RunPublish, }, { Name: "update", - Flags: flagkit.GetCommandFlags(flagkit.ApmSubcommand), + Flags: flagkit.GetCommandFlags(flagkit.AgentApmSubcommand), SkipFlagParsing: true, Description: "Refresh APM dependencies to their latest matching refs, with build-info collection.", Action: update.RunUpdate, diff --git a/agent/apm/common/subcommand_options.go b/agent/apm/common/subcommand_options.go index 1368618f..ce60eb3c 100644 --- a/agent/apm/common/subcommand_options.go +++ b/agent/apm/common/subcommand_options.go @@ -1,7 +1,6 @@ package apmcommon import ( - agentcommon "github.com/jfrog/jfrog-cli-artifactory/agent/common" buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build" "github.com/jfrog/jfrog-cli-core/v2/utils/config" "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" @@ -19,9 +18,8 @@ type ApmSubcommandOptions struct { } // ExtractApmSubcommandOptions extracts every flag declared for install/publish/update -// (--server-id, --build-name, --build-number, --module, --project, --url, --user, -// --password, --access-token) from args, and resolves them into ServerDetails and a -// BuildConfiguration. +// (--server-id, --build-name, --build-number, --module, --project) from args, and resolves +// them into ServerDetails and a BuildConfiguration. // // This exists because install/publish/update set SkipFlagParsing (so apm-native flags that // aren't in jf's own declared flag set - --package, --registry, --zip, --dry-run - don't get @@ -31,10 +29,13 @@ type ApmSubcommandOptions struct { // install/publish/update require a registry to already be declared, so --repo isn't one of // jf's own flags here - it passes straight through in RemainingArgs like any other // apm-native flag (where apm itself will reject it, since apm has no --repo flag either). +// +// No direct-credential flags (--url/--user/--password/--access-token) either, matching the +// pnpm/npm/yarn/nuget convention: auth is resolved purely from --server-id or the default +// configured server, never from ad hoc credentials passed to the runtime command itself. func ExtractApmSubcommandOptions(args []string) (*ApmSubcommandOptions, error) { rest := args var serverID, buildName, buildNumber, module, project string - var url, user, password, accessToken string var err error for _, opt := range []struct { @@ -46,10 +47,6 @@ func ExtractApmSubcommandOptions(args []string) (*ApmSubcommandOptions, error) { {"build-number", &buildNumber}, {"module", &module}, {"project", &project}, - {"url", &url}, - {"user", &user}, - {"password", &password}, - {"access-token", &accessToken}, } { rest, *opt.dest, err = coreutils.ExtractStringOptionFromArgs(rest, opt.name) if err != nil { @@ -57,7 +54,7 @@ func ExtractApmSubcommandOptions(args []string) (*ApmSubcommandOptions, error) { } } - sd, err := resolveServerDetails(serverID, url, user, password, accessToken) + sd, err := config.GetSpecificConfig(serverID, true, true) if err != nil { return nil, err } @@ -73,29 +70,3 @@ func ExtractApmSubcommandOptions(args []string) (*ApmSubcommandOptions, error) { BuildConfig: buildConfig, }, nil } - -// resolveServerDetails mirrors agent/common.GetServerDetails's two cases (explicit flags vs. -// default config), extended with a third for --server-id specifically: explicit server-id -// always wins over url/user/password/access-token, matching how the framework's own flag -// parsing would have resolved these before SkipFlagParsing made manual extraction necessary. -func resolveServerDetails(serverID, url, user, password, accessToken string) (*config.ServerDetails, error) { - if serverID != "" { - return config.GetSpecificConfig(serverID, true, true) - } - if url != "" || user != "" || password != "" || accessToken != "" { - details := &config.ServerDetails{ - // --url is the Artifactory URL for this command domain, same as - // createServerDetailsFromFlags's cliutils.Rt case in jfrog-cli-core - - // NormalizeArtifactoryUrl (and everything downstream, e.g. AgentPackagesBaseURL) - // reads ArtifactoryUrl, not Url. - ArtifactoryUrl: url, - User: user, - Password: password, - AccessToken: accessToken, - } - agentcommon.NormalizeArtifactoryUrl(details) - return details, nil - } - // No server flags at all - same fallback as GetSpecificConfig("", true, true). - return config.GetSpecificConfig("", true, true) -} diff --git a/agent/apm/common/subcommand_options_test.go b/agent/apm/common/subcommand_options_test.go index 33113053..f9507dc8 100644 --- a/agent/apm/common/subcommand_options_test.go +++ b/agent/apm/common/subcommand_options_test.go @@ -9,52 +9,37 @@ import ( "github.com/stretchr/testify/require" ) -func TestResolveServerDetails_DirectCredentials(t *testing.T) { - testutil.WithJfrogHome(t) // isolate from any real jf config on this machine - - sd, err := resolveServerDetails("", "https://acme.jfrog.io", "uday", "", "my-token") - require.NoError(t, err) - assert.Equal(t, "uday", sd.User) - assert.Equal(t, "my-token", sd.AccessToken) - assert.Equal(t, "https://acme.jfrog.io/artifactory/", sd.ArtifactoryUrl) -} - -func TestResolveServerDetails_ServerIDWinsOverDirectCredentials(t *testing.T) { +func TestExtractApmSubcommandOptions_ServerIDSelectsConfiguredServer(t *testing.T) { testutil.WithJfrogHome(t) - // At least one real server must exist so GetAllServersConfigs() is non-empty - otherwise - // GetSpecificConfig short-circuits to an empty ServerDetails regardless of serverID, - // and this test couldn't tell "server-id consulted and not found" from "no configs at all". require.NoError(t, config.SaveServersConf([]*config.ServerDetails{ {ServerId: "known-server", Url: "https://known.jfrog.io/"}, })) - // server-id present but not the one configured: must error rather than silently fall - // through to the direct-credential branch below it. - _, err := resolveServerDetails("does-not-exist", "https://acme.jfrog.io", "", "", "ignored-token") + // server-id present but not the one configured: must error rather than silently falling + // back to an empty/default server. + _, err := ExtractApmSubcommandOptions([]string{"--server-id", "does-not-exist"}) assert.Error(t, err) - // server-id present AND matching a real config: must use that config, not the passed - // direct credentials, proving server-id truly takes precedence. - sd, err := resolveServerDetails("known-server", "https://acme.jfrog.io", "", "", "ignored-token") + // server-id present and matching a real config: must resolve to that config. + opts, err := ExtractApmSubcommandOptions([]string{"--server-id", "known-server"}) require.NoError(t, err) - assert.Equal(t, "https://known.jfrog.io/", sd.Url) - assert.Empty(t, sd.AccessToken) + assert.Equal(t, "https://known.jfrog.io/", opts.ServerDetails.Url) } -func TestResolveServerDetails_NoFlagsAtAll(t *testing.T) { +func TestExtractApmSubcommandOptions_NoServerIDFallsBackToDefaultConfig(t *testing.T) { testutil.WithJfrogHome(t) // no servers configured - sd, err := resolveServerDetails("", "", "", "", "") + opts, err := ExtractApmSubcommandOptions(nil) require.NoError(t, err) - assert.Empty(t, sd.ArtifactoryUrl) + assert.Empty(t, opts.ServerDetails.ArtifactoryUrl) } func TestExtractApmSubcommandOptions_PreservesApmNativeFlags(t *testing.T) { testutil.WithJfrogHome(t) - // install/publish/update don't declare --repo as one of jf's own flags (unlike - // passthrough) - a registry must already be declared, so --repo isn't extracted here and - // flows through untouched like any other apm-native flag. + // install/publish/update don't declare --repo (or direct-credential flags) as jf's own - + // a registry must already be declared, so --repo isn't extracted here and flows through + // untouched like any other apm-native flag. opts, err := ExtractApmSubcommandOptions([]string{ "--repo", "buk-apm", "--registry", "buk-apm", @@ -65,17 +50,20 @@ func TestExtractApmSubcommandOptions_PreservesApmNativeFlags(t *testing.T) { assert.Equal(t, []string{"--repo", "buk-apm", "--registry", "buk-apm", "--frozen", "uday/pkg-base#1.0.0"}, opts.RemainingArgs) } -func TestExtractApmSubcommandOptions_DirectCredentialsFlowThrough(t *testing.T) { +func TestExtractApmSubcommandOptions_DirectCredentialFlagsFlowThrough(t *testing.T) { testutil.WithJfrogHome(t) + // --url/--user/--password/--access-token are no longer jf's own flags for install/ + // publish/update (matching pnpm/npm/yarn/nuget - auth comes from --server-id or the + // default configured server only), so they pass straight through like any other + // apm-native flag, and ServerDetails resolves from the default config. opts, err := ExtractApmSubcommandOptions([]string{ "--url", "https://acme.jfrog.io", "--access-token", "my-token", }) require.NoError(t, err) - assert.Equal(t, "my-token", opts.ServerDetails.AccessToken) - assert.Equal(t, "https://acme.jfrog.io/artifactory/", opts.ServerDetails.ArtifactoryUrl) - assert.Empty(t, opts.RemainingArgs) + assert.Equal(t, []string{"--url", "https://acme.jfrog.io", "--access-token", "my-token"}, opts.RemainingArgs) + assert.Empty(t, opts.ServerDetails.ArtifactoryUrl) } func TestExtractApmSubcommandOptions_BuildNameWithoutNumberErrors(t *testing.T) { diff --git a/agent/cli/cli.go b/agent/cli/cli.go index 3cfbf3c8..b2a9cf21 100644 --- a/agent/cli/cli.go +++ b/agent/cli/cli.go @@ -26,7 +26,7 @@ func GetCommands() []components.Command { { Name: "apm", Description: "Agent Package Manager (APM) commands with JFrog Artifactory authentication.", - Flags: flagkit.GetCommandFlags(flagkit.ApmPassthrough), + Flags: flagkit.GetCommandFlags(flagkit.AgentApmPassthrough), Subcommands: apmcli.GetSubCommands(), Action: passthrough.RunApmPassthroughDefault, }, diff --git a/cliutils/flagkit/flags.go b/cliutils/flagkit/flags.go index c20608d5..91bb0f16 100644 --- a/cliutils/flagkit/flags.go +++ b/cliutils/flagkit/flags.go @@ -513,9 +513,13 @@ const ( // Agent APM commands keys. install/publish/lock/update all take the identical // server + build-info flag set, so they share one key; only the bare passthrough differs - // (no build-info flags, since it can't collect build-info at all). - ApmSubcommand = "apm-subcommand" - ApmPassthrough = "apm-passthrough" + // (no build-info flags, since it can't collect build-info at all). Neither takes direct + // credential flags (--url/--user/--password/--access-token) - a registry/server must + // already be declared via --server-id, the default configured server, or (passthrough + // only) an already-known --repo, matching the pnpm/npm/yarn/nuget convention of resolving + // auth purely through --server-id rather than ad hoc credentials on the runtime command. + AgentApmSubcommand = "agent-apm-subcommand" + AgentApmPassthrough = "agent-apm-passthrough" // Agent plugin commands keys AgentPluginsPublish = "agent-plugins-publish" @@ -926,11 +930,11 @@ var commandFlags = map[string][]string{ SkillsList: { url, user, password, accessToken, serverId, repo, harness, projectDir, agentGlobal, agentFormat, agentLimit, agentSortBy, agentSortOrder, agentCheckUpdates, }, - ApmSubcommand: { - url, user, password, accessToken, serverId, BuildName, BuildNumber, module, Project, + AgentApmSubcommand: { + serverId, BuildName, BuildNumber, module, Project, }, - ApmPassthrough: { - url, user, password, accessToken, serverId, repo, + AgentApmPassthrough: { + serverId, repo, }, } From 0d839d2ff7e59e71d5e658ce7d00ac52ce90a5f2 Mon Sep 17 00:00:00 2001 From: Uday Date: Tue, 28 Jul 2026 12:04:05 +0530 Subject: [PATCH 05/40] RTECO-1648 - Replace regexp version parsing with a plain function Drops the regexp dependency for extracting apm's dotted version number from its --version output. parseApmVersion/isDottedVersion do the same job with plain string splitting - simpler and avoids a compiled-regex dependency for a single, narrow parsing need. --- agent/apm/common/utils.go | 43 +++++++++++++++++++++++++++++----- agent/apm/common/utils_test.go | 33 ++++++++++++++++++++++---- 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/agent/apm/common/utils.go b/agent/apm/common/utils.go index e7312d3e..b5755ce3 100644 --- a/agent/apm/common/utils.go +++ b/agent/apm/common/utils.go @@ -2,7 +2,7 @@ package apmcommon import ( "os/exec" - "regexp" + "strings" "github.com/jfrog/gofrog/version" "github.com/jfrog/jfrog-client-go/utils/errorutils" @@ -11,10 +11,7 @@ import ( const minSupportedApmVersion = "0.1.0" -// apmVersionPattern extracts the dotted version number from apm's descriptive `--version` -// output, e.g. "Agent Package Manager (APM) CLI version 0.23.1 (d1d926d)" -> "0.23.1". -var apmVersionPattern = regexp.MustCompile(`\d+\.\d+(\.\d+)?`) - +// ValidateApmPrerequisites checks that apm is installed and meets minSupportedApmVersion. func ValidateApmPrerequisites() error { ver, err := GetApmVersion() if err != nil { @@ -29,14 +26,48 @@ func ValidateApmPrerequisites() error { return nil } +// GetApmVersion runs "apm --version" and parses the dotted version number out of its +// descriptive output. func GetApmVersion() (*version.Version, error) { out, err := exec.Command("apm", "--version").Output() if err != nil { return nil, errorutils.CheckErrorf("failed to determine apm version. Ensure apm is installed: %s", err.Error()) } - match := apmVersionPattern.FindString(string(out)) + match := parseApmVersion(string(out)) if match == "" { return nil, errorutils.CheckErrorf("could not parse apm version from output: %s", string(out)) } return version.NewVersion(match), nil } + +// parseApmVersion extracts the dotted version number from apm's descriptive `--version` +// output, e.g. "Agent Package Manager (APM) CLI version 0.23.1 (d1d926d)" -> "0.23.1". +// Returns "" if no whitespace-delimited token looks like a version number. +func parseApmVersion(output string) string { + for field := range strings.FieldsSeq(output) { + if isDottedVersion(field) { + return field + } + } + return "" +} + +// isDottedVersion reports whether s is two or three dot-separated numeric segments, +// e.g. "1.2" or "0.23.1". +func isDottedVersion(s string) bool { + parts := strings.Split(s, ".") + if len(parts) < 2 || len(parts) > 3 { + return false + } + for _, part := range parts { + if part == "" { + return false + } + for _, r := range part { + if r < '0' || r > '9' { + return false + } + } + } + return true +} diff --git a/agent/apm/common/utils_test.go b/agent/apm/common/utils_test.go index 53bef062..b3b1d859 100644 --- a/agent/apm/common/utils_test.go +++ b/agent/apm/common/utils_test.go @@ -7,12 +7,13 @@ import ( "github.com/stretchr/testify/assert" ) -// TestApmVersionPattern is a regression test: GetApmVersion used to feed apm's entire +// TestParseApmVersion is a regression test: GetApmVersion used to feed apm's entire // descriptive `--version` output straight into version.NewVersion, which made // ValidateApmPrerequisites's min-version check pass or fail by accident of string shape // rather than by actually comparing semantic versions (verified against the real installed -// apm binary during review). This confirms the regex extracts just the dotted version number. -func TestApmVersionPattern(t *testing.T) { +// apm binary during review). This confirms parseApmVersion extracts just the dotted version +// number token. +func TestParseApmVersion(t *testing.T) { tests := []struct { name string output string @@ -41,7 +42,29 @@ func TestApmVersionPattern(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, apmVersionPattern.FindString(tt.output)) + assert.Equal(t, tt.want, parseApmVersion(tt.output)) + }) + } +} + +func TestIsDottedVersion(t *testing.T) { + tests := []struct { + name string + in string + want bool + }{ + {name: "three segments", in: "0.23.1", want: true}, + {name: "two segments", in: "1.2", want: true}, + {name: "one segment", in: "1", want: false}, + {name: "four segments", in: "1.2.3.4", want: false}, + {name: "trailing dot with empty segment", in: "1.2.", want: false}, + {name: "non-numeric segment", in: "1.2a", want: false}, + {name: "v-prefixed", in: "v1.2.3", want: false}, + {name: "empty string", in: "", want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isDottedVersion(tt.in)) }) } } @@ -68,7 +91,7 @@ func TestValidateApmPrerequisites_VersionComparisonDirection(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - installed := apmVersionPattern.FindString(tt.rawOutput) + installed := parseApmVersion(tt.rawOutput) require := assert.New(t) require.NotEmpty(installed) From 8f9e3b1007103f939a1adeca99599561b428ae34 Mon Sep 17 00:00:00 2001 From: Uday Date: Tue, 28 Jul 2026 12:29:18 +0530 Subject: [PATCH 06/40] RTECO-1648 - Drop --server-id/--repo from apm, rename flagkit key, cleanup Removes --server-id and --repo from install/publish/update and the generic passthrough entirely. Auth now resolves purely from the default configured JFrog server - apm's own registry/config resolution (~/.apm/config.json, apm.yml) is what package managers are for, matching pnpm/npm/yarn/nuget's runtime commands, none of which take a server-selection flag either. This let RunApmPassthroughDefault drop its entire manual, position- independent --server-id/--repo extraction workaround (previously needed because the parent apm command couldn't use SkipFlagParsing) - it now just calls agentcommon.GetServerDetails directly, same as install/publish/update. Also removes the now-unreachable "declare a new registry via --repo" branch from BuildApmEnv (and its dead temp-HOME helper, replaceEnvHome), and moves ServerDetails resolution out of ExtractApmSubcommandOptions into the callers, since it's identical to what passthrough already does. Renames flagkit.ApmSubcommand/ApmPassthrough to a single flagkit.AgentApm key (passthrough takes no flags of its own now), matching the AgentPlugins*/ AgentSkills* naming convention already used for sibling agent-namespace commands. Also renames short/cryptic identifiers (sd, cs, bc, u, v, a, d, n, s, ns) to descriptive names (serverDetails, checksum, buildConfiguration, etc.) throughout the apm package. --- agent/apm/cli/cli.go | 6 +- agent/apm/commands/install/install.go | 17 +- agent/apm/commands/passthrough/passthrough.go | 57 +---- agent/apm/commands/publish/publish.go | 33 +-- agent/apm/commands/update/update.go | 17 +- agent/apm/common/apmenv.go | 214 ++++++------------ agent/apm/common/build_info.go | 18 +- agent/apm/common/checksums.go | 20 +- agent/apm/common/dependency_resolver.go | 10 +- agent/apm/common/manifest.go | 6 +- agent/apm/common/subcommand_options.go | 29 +-- agent/apm/common/subcommand_options_test.go | 57 ++--- agent/apm/common/utils.go | 10 +- agent/cli/cli.go | 2 - cliutils/flagkit/flags.go | 21 +- 15 files changed, 183 insertions(+), 334 deletions(-) diff --git a/agent/apm/cli/cli.go b/agent/apm/cli/cli.go index 80f21092..c9930984 100644 --- a/agent/apm/cli/cli.go +++ b/agent/apm/cli/cli.go @@ -17,7 +17,7 @@ func GetSubCommands() []components.Command { return []components.Command{ { Name: "install", - Flags: flagkit.GetCommandFlags(flagkit.AgentApmSubcommand), + Flags: flagkit.GetCommandFlags(flagkit.AgentApm), // SkipFlagParsing so apm-native flags (e.g. --frozen) that aren't in jf's own // declared flag set above aren't rejected by urfave/cli before reaching apm. // RunInstall extracts jf's own flags manually via ExtractApmSubcommandOptions. @@ -27,14 +27,14 @@ func GetSubCommands() []components.Command { }, { Name: "publish", - Flags: flagkit.GetCommandFlags(flagkit.AgentApmSubcommand), + Flags: flagkit.GetCommandFlags(flagkit.AgentApm), SkipFlagParsing: true, Description: "Publish an APM package to JFrog Artifactory.", Action: publish.RunPublish, }, { Name: "update", - Flags: flagkit.GetCommandFlags(flagkit.AgentApmSubcommand), + Flags: flagkit.GetCommandFlags(flagkit.AgentApm), SkipFlagParsing: true, Description: "Refresh APM dependencies to their latest matching refs, with build-info collection.", Action: update.RunUpdate, diff --git a/agent/apm/commands/install/install.go b/agent/apm/commands/install/install.go index 4c195b61..98eda7ab 100644 --- a/agent/apm/commands/install/install.go +++ b/agent/apm/commands/install/install.go @@ -6,6 +6,7 @@ import ( "path/filepath" apmcommon "github.com/jfrog/jfrog-cli-artifactory/agent/apm/common" + agentcommon "github.com/jfrog/jfrog-cli-artifactory/agent/common" buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build" "github.com/jfrog/jfrog-cli-core/v2/common/commands" "github.com/jfrog/jfrog-cli-core/v2/plugins/components" @@ -36,13 +37,13 @@ func (c *ApmInstallCommand) SetArgs(args []string) *ApmInstallCommand { return c } -func (c *ApmInstallCommand) SetServerDetails(sd *config.ServerDetails) *ApmInstallCommand { - c.serverDetails = sd +func (c *ApmInstallCommand) SetServerDetails(serverDetails *config.ServerDetails) *ApmInstallCommand { + c.serverDetails = serverDetails return c } -func (c *ApmInstallCommand) SetBuildConfiguration(bc *buildUtils.BuildConfiguration) *ApmInstallCommand { - c.buildConfiguration = bc +func (c *ApmInstallCommand) SetBuildConfiguration(buildConfiguration *buildUtils.BuildConfiguration) *ApmInstallCommand { + c.buildConfiguration = buildConfiguration return c } @@ -57,7 +58,7 @@ func (c *ApmInstallCommand) ServerDetails() (*config.ServerDetails, error) { func (c *ApmInstallCommand) Run() error { log.Info("Running apm install...") - if err := apmcommon.RunApmSubcommandWithAuth("install", c.args, c.serverDetails, ""); err != nil { + if err := apmcommon.RunApmSubcommandWithAuth("install", c.args, c.serverDetails); err != nil { return fmt.Errorf("run apm install: %w", err) } @@ -86,10 +87,14 @@ func RunInstall(c *components.Context) error { if err != nil { return err } + serverDetails, err := agentcommon.GetServerDetails(c) + if err != nil { + return err + } cmd := NewApmInstallCommand(). SetArgs(opts.RemainingArgs). - SetServerDetails(opts.ServerDetails). + SetServerDetails(serverDetails). SetBuildConfiguration(opts.BuildConfig) return commands.ExecWithPackageManager(cmd, "agent-apm") diff --git a/agent/apm/commands/passthrough/passthrough.go b/agent/apm/commands/passthrough/passthrough.go index 455fe6ed..957780f6 100644 --- a/agent/apm/commands/passthrough/passthrough.go +++ b/agent/apm/commands/passthrough/passthrough.go @@ -6,7 +6,6 @@ import ( "github.com/jfrog/jfrog-cli-core/v2/common/commands" "github.com/jfrog/jfrog-cli-core/v2/plugins/components" "github.com/jfrog/jfrog-cli-core/v2/utils/config" - "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" "github.com/jfrog/jfrog-client-go/utils/log" ) @@ -15,7 +14,6 @@ type ApmPassthroughCommand struct { subcmd string args []string serverDetails *config.ServerDetails - repoName string } func NewApmPassthroughCommand() *ApmPassthroughCommand { @@ -32,13 +30,8 @@ func (c *ApmPassthroughCommand) SetArgs(args []string) *ApmPassthroughCommand { return c } -func (c *ApmPassthroughCommand) SetServerDetails(sd *config.ServerDetails) *ApmPassthroughCommand { - c.serverDetails = sd - return c -} - -func (c *ApmPassthroughCommand) SetRepoName(repo string) *ApmPassthroughCommand { - c.repoName = repo +func (c *ApmPassthroughCommand) SetServerDetails(serverDetails *config.ServerDetails) *ApmPassthroughCommand { + c.serverDetails = serverDetails return c } @@ -52,21 +45,14 @@ func (c *ApmPassthroughCommand) ServerDetails() (*config.ServerDetails, error) { func (c *ApmPassthroughCommand) Run() error { log.Info("Running apm " + c.subcmd + "...") - return apmcommon.RunApmSubcommandWithAuth(c.subcmd, c.args, c.serverDetails, c.repoName) + return apmcommon.RunApmSubcommandWithAuth(c.subcmd, c.args, c.serverDetails) } -// RunApmPassthroughDefault handles any `jf agent apm ` where is not -// one of the registered subcommands (install, publish). The subcmd is extracted -// from the first element of c.Arguments; all remaining elements are forwarded to apm. -// -// The parent "apm" command can't declare SkipFlagParsing (jfrog-cli-core rejects that -// combined with registered Subcommands, since urfave/cli would then stop routing to -// install/publish entirely), so the framework's automatic flag parsing only reliably -// captures --server-id/--repo when they're placed BEFORE the subcommand name. Placed -// after — the position install/publish/every other jf command actually uses — they land -// here unconsumed. Extract them manually, position-independent, the same way every other -// passthrough-style command in the CLI (npm, pnpm, yarn, ...) does via -// coreutils.ExtractServerIdFromCommand, before forwarding the rest to apm. +// RunApmPassthroughDefault handles any `jf agent apm ` where is not one of the +// registered subcommands (install/publish/update). The subcmd is the first element of +// c.Arguments; every remaining element is forwarded to apm untouched. Auth always comes from +// the default configured JFrog server - passthrough takes no flags of its own at all, so there's +// nothing to extract from c.Arguments. func RunApmPassthroughDefault(c *components.Context) error { if len(c.Arguments) == 0 { return apmcommon.RunApmCommand(nil, "--help", nil) @@ -77,36 +63,15 @@ func RunApmPassthroughDefault(c *components.Context) error { return apmcommon.RunApmCommand(nil, "--help", nil) } - rest, serverID, err := coreutils.ExtractServerIdFromCommand(c.Arguments[1:]) - if err != nil { - return err - } - rest, repoOverride, err := coreutils.ExtractStringOptionFromArgs(rest, "repo") + serverDetails, err := agentcommon.GetServerDetails(c) if err != nil { return err } - sd, sdErr := agentcommon.GetServerDetails(c) - if sdErr != nil || serverID != "" { - // Either the framework-based lookup found nothing configured (flags were placed after - // the subcommand, so agentcommon.GetServerDetails saw none of them), or an explicit - // --server-id turned up in the manual scan above — resolve from that instead. - sd, err = config.GetSpecificConfig(serverID, true, true) - if err != nil { - return err - } - } - - repoName := c.GetStringFlagValue("repo") - if repoOverride != "" { - repoName = repoOverride - } - cmd := NewApmPassthroughCommand(). SetSubcmd(subcmd). - SetArgs(rest). - SetServerDetails(sd). - SetRepoName(repoName) + SetArgs(c.Arguments[1:]). + SetServerDetails(serverDetails) return commands.ExecWithPackageManager(cmd, "agent-apm") } diff --git a/agent/apm/commands/publish/publish.go b/agent/apm/commands/publish/publish.go index 9991ac4d..c50fdebc 100644 --- a/agent/apm/commands/publish/publish.go +++ b/agent/apm/commands/publish/publish.go @@ -7,6 +7,7 @@ import ( "strings" apmcommon "github.com/jfrog/jfrog-cli-artifactory/agent/apm/common" + agentcommon "github.com/jfrog/jfrog-cli-artifactory/agent/common" buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build" "github.com/jfrog/jfrog-cli-core/v2/common/commands" "github.com/jfrog/jfrog-cli-core/v2/plugins/components" @@ -38,13 +39,13 @@ func (c *ApmPublishCommand) SetArgs(args []string) *ApmPublishCommand { return c } -func (c *ApmPublishCommand) SetServerDetails(sd *config.ServerDetails) *ApmPublishCommand { - c.serverDetails = sd +func (c *ApmPublishCommand) SetServerDetails(serverDetails *config.ServerDetails) *ApmPublishCommand { + c.serverDetails = serverDetails return c } -func (c *ApmPublishCommand) SetBuildConfiguration(bc *buildUtils.BuildConfiguration) *ApmPublishCommand { - c.buildConfiguration = bc +func (c *ApmPublishCommand) SetBuildConfiguration(buildConfiguration *buildUtils.BuildConfiguration) *ApmPublishCommand { + c.buildConfiguration = buildConfiguration return c } @@ -59,17 +60,17 @@ func (c *ApmPublishCommand) ServerDetails() (*config.ServerDetails, error) { // withPackageFlag promotes a bare positional package spec (e.g. "jfrog/proj3") into the // --package flag apm publish requires. If --package is already present, args are left untouched. func withPackageFlag(args []string) []string { - for _, a := range args { - if a == "--package" || strings.HasPrefix(a, "--package=") { + for _, arg := range args { + if arg == "--package" || strings.HasPrefix(arg, "--package=") { return args } } - for i, a := range args { - if !strings.HasPrefix(a, "-") { + for i, arg := range args { + if !strings.HasPrefix(arg, "-") { rest := make([]string, 0, len(args)-1) rest = append(rest, args[:i]...) rest = append(rest, args[i+1:]...) - return append([]string{"--package", a}, rest...) + return append([]string{"--package", arg}, rest...) } } return args @@ -79,7 +80,7 @@ func (c *ApmPublishCommand) Run() error { log.Info("Running apm publish...") args := withPackageFlag(c.args) - if err := apmcommon.RunApmSubcommandWithAuth("publish", args, c.serverDetails, ""); err != nil { + if err := apmcommon.RunApmSubcommandWithAuth("publish", args, c.serverDetails); err != nil { return fmt.Errorf("run apm publish: %w", err) } @@ -102,11 +103,11 @@ func (c *ApmPublishCommand) Run() error { // ownerFromArgs extracts the owner segment from a "--package owner/name" pair in args. // Returns "" if --package isn't present or doesn't contain a "/". func ownerFromArgs(args []string) string { - for i, a := range args { + for i, arg := range args { var pkg string - if a == "--package" && i+1 < len(args) { + if arg == "--package" && i+1 < len(args) { pkg = args[i+1] - } else if cut, ok := strings.CutPrefix(a, "--package="); ok { + } else if cut, ok := strings.CutPrefix(arg, "--package="); ok { pkg = cut } if pkg == "" { @@ -129,10 +130,14 @@ func RunPublish(c *components.Context) error { if err != nil { return err } + serverDetails, err := agentcommon.GetServerDetails(c) + if err != nil { + return err + } cmd := NewApmPublishCommand(). SetArgs(opts.RemainingArgs). - SetServerDetails(opts.ServerDetails). + SetServerDetails(serverDetails). SetBuildConfiguration(opts.BuildConfig) return commands.ExecWithPackageManager(cmd, "agent-apm") diff --git a/agent/apm/commands/update/update.go b/agent/apm/commands/update/update.go index 8cf248cd..fe33c9b9 100644 --- a/agent/apm/commands/update/update.go +++ b/agent/apm/commands/update/update.go @@ -6,6 +6,7 @@ import ( "path/filepath" apmcommon "github.com/jfrog/jfrog-cli-artifactory/agent/apm/common" + agentcommon "github.com/jfrog/jfrog-cli-artifactory/agent/common" buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build" "github.com/jfrog/jfrog-cli-core/v2/common/commands" "github.com/jfrog/jfrog-cli-core/v2/plugins/components" @@ -36,13 +37,13 @@ func (c *ApmUpdateCommand) SetArgs(args []string) *ApmUpdateCommand { return c } -func (c *ApmUpdateCommand) SetServerDetails(sd *config.ServerDetails) *ApmUpdateCommand { - c.serverDetails = sd +func (c *ApmUpdateCommand) SetServerDetails(serverDetails *config.ServerDetails) *ApmUpdateCommand { + c.serverDetails = serverDetails return c } -func (c *ApmUpdateCommand) SetBuildConfiguration(bc *buildUtils.BuildConfiguration) *ApmUpdateCommand { - c.buildConfiguration = bc +func (c *ApmUpdateCommand) SetBuildConfiguration(buildConfiguration *buildUtils.BuildConfiguration) *ApmUpdateCommand { + c.buildConfiguration = buildConfiguration return c } @@ -61,7 +62,7 @@ func (c *ApmUpdateCommand) ServerDetails() (*config.ServerDetails, error) { func (c *ApmUpdateCommand) Run() error { log.Info("Running apm update...") - if err := apmcommon.RunApmSubcommandWithAuth("update", c.args, c.serverDetails, ""); err != nil { + if err := apmcommon.RunApmSubcommandWithAuth("update", c.args, c.serverDetails); err != nil { return fmt.Errorf("run apm update: %w", err) } @@ -90,10 +91,14 @@ func RunUpdate(c *components.Context) error { if err != nil { return err } + serverDetails, err := agentcommon.GetServerDetails(c) + if err != nil { + return err + } cmd := NewApmUpdateCommand(). SetArgs(opts.RemainingArgs). - SetServerDetails(opts.ServerDetails). + SetServerDetails(serverDetails). SetBuildConfiguration(opts.BuildConfig) return commands.ExecWithPackageManager(cmd, "agent-apm") diff --git a/agent/apm/common/apmenv.go b/agent/apm/common/apmenv.go index face1495..89c9eb47 100644 --- a/agent/apm/common/apmenv.go +++ b/agent/apm/common/apmenv.go @@ -17,24 +17,24 @@ import ( const agentPackagesAPIPrefix = "/api/agentpackages/" // AgentPackagesBaseURL returns the Artifactory agentpackages base URL for a repo. -func AgentPackagesBaseURL(sd *config.ServerDetails, repoName string) string { - base := strings.TrimSuffix(sd.ArtifactoryUrl, "/") +func AgentPackagesBaseURL(serverDetails *config.ServerDetails, repoName string) string { + base := strings.TrimSuffix(serverDetails.ArtifactoryUrl, "/") return base + agentPackagesAPIPrefix + repoName + "/" } // BuildRegistryEntry returns (registryURL, token) for APM config. // AccessToken set → Bearer auth via token field. // User+Password only → Basic auth via URL-embedded credentials. -func BuildRegistryEntry(sd *config.ServerDetails, repoName string) (registryURL, token string) { - base := AgentPackagesBaseURL(sd, repoName) - if sd.AccessToken != "" { - return base, sd.AccessToken +func BuildRegistryEntry(serverDetails *config.ServerDetails, repoName string) (registryURL, token string) { + base := AgentPackagesBaseURL(serverDetails, repoName) + if serverDetails.AccessToken != "" { + return base, serverDetails.AccessToken } - if sd.User != "" && sd.Password != "" { - u, err := url.Parse(base) + if serverDetails.User != "" && serverDetails.Password != "" { + parsedURL, err := url.Parse(base) if err == nil { - u.User = url.UserPassword(sd.User, sd.Password) - return u.String(), "" + parsedURL.User = url.UserPassword(serverDetails.User, serverDetails.Password) + return parsedURL.String(), "" } } return base, "" @@ -67,14 +67,14 @@ func (c *apmConfigJSON) UnmarshalJSON(data []byte) error { return err } } - if v, ok := raw["experimental"]; ok { - if err := json.Unmarshal(v, &c.Experimental); err != nil { + if experimentalRaw, ok := raw["experimental"]; ok { + if err := json.Unmarshal(experimentalRaw, &c.Experimental); err != nil { return err } delete(raw, "experimental") } - if v, ok := raw["registries"]; ok { - if err := json.Unmarshal(v, &c.Registries); err != nil { + if registriesRaw, ok := raw["registries"]; ok { + if err := json.Unmarshal(registriesRaw, &c.Registries); err != nil { return err } delete(raw, "registries") @@ -109,14 +109,14 @@ type discoveredRegistry struct { } // discoverMatchingRegistries returns every registry name+URL, from existing config.json -// entries and the project's apm.yml, whose host matches sd.ArtifactoryUrl. A name already -// found in config.json is not overwritten by an apm.yml entry of the same name. -func discoverMatchingRegistries(existing *apmConfigJSON, manifestPath string, sd *config.ServerDetails) []discoveredRegistry { +// entries and the project's apm.yml, whose host matches serverDetails.ArtifactoryUrl. A name +// already found in config.json is not overwritten by an apm.yml entry of the same name. +func discoverMatchingRegistries(existing *apmConfigJSON, manifestPath string, serverDetails *config.ServerDetails) []discoveredRegistry { seen := make(map[string]bool, len(existing.Registries)) found := make([]discoveredRegistry, 0, len(existing.Registries)) for name, entry := range existing.Registries { - if apmHostMatches(entry.URL, sd.ArtifactoryUrl) { + if apmHostMatches(entry.URL, serverDetails.ArtifactoryUrl) { found = append(found, discoveredRegistry{Name: name, URL: entry.URL}) seen[name] = true } @@ -125,7 +125,7 @@ func discoverMatchingRegistries(existing *apmConfigJSON, manifestPath string, sd if manifestPath != "" { if manifest, loadErr := LoadManifest(manifestPath); loadErr == nil { for name, reg := range manifest.Registries { - if seen[name] || !apmHostMatches(reg.URL, sd.ArtifactoryUrl) { + if seen[name] || !apmHostMatches(reg.URL, serverDetails.ArtifactoryUrl) { continue } found = append(found, discoveredRegistry{Name: name, URL: reg.URL}) @@ -155,34 +155,34 @@ func apmPassEnvVar(name string) string { return "APM_REGISTRY_PASS_" + sanitize // wins, silently misrouting auth for the other. func checkSanitizationCollisions(names []string) error { bySanitized := make(map[string][]string, len(names)) - for _, n := range names { - s := sanitizeApmEnvName(n) - bySanitized[s] = append(bySanitized[s], n) + for _, name := range names { + sanitized := sanitizeApmEnvName(name) + bySanitized[sanitized] = append(bySanitized[sanitized], name) } - for s, ns := range bySanitized { - if len(ns) > 1 { + for sanitized, collidingNames := range bySanitized { + if len(collidingNames) > 1 { return fmt.Errorf( "registry names %v all sanitize to the same env var APM_REGISTRY_TOKEN_%s; rename one to avoid credential misrouting", - ns, s) + collidingNames, sanitized) } } return nil } // injectRegistryCredentialEnv appends APM_REGISTRY_TOKEN_ (or USER_/PASS_) to env for -// the given registry name, computed from sd. Non-destructive: if the caller already exported -// a credential for this exact name, it's left alone and nothing is appended. -func injectRegistryCredentialEnv(env []string, name string, sd *config.ServerDetails) []string { +// the given registry name, computed from serverDetails. Non-destructive: if the caller already +// exported a credential for this exact name, it's left alone and nothing is appended. +func injectRegistryCredentialEnv(env []string, name string, serverDetails *config.ServerDetails) []string { tokenKey, userKey, passKey := apmTokenEnvVar(name), apmUserEnvVar(name), apmPassEnvVar(name) if os.Getenv(tokenKey) != "" || (os.Getenv(userKey) != "" && os.Getenv(passKey) != "") { log.Debug(fmt.Sprintf("apm auth [%s]: credential env var already set — respecting existing value", name)) return env } - if sd.AccessToken != "" { - return append(env, tokenKey+"="+sd.AccessToken) + if serverDetails.AccessToken != "" { + return append(env, tokenKey+"="+serverDetails.AccessToken) } - if sd.User != "" && sd.Password != "" { - return append(env, userKey+"="+sd.User, passKey+"="+sd.Password) + if serverDetails.User != "" && serverDetails.Password != "" { + return append(env, userKey+"="+serverDetails.User, passKey+"="+serverDetails.Password) } return env } @@ -204,101 +204,40 @@ func ensureExperimentalFlagEnabled(realHome string, existing *apmConfigJSON) err // BuildApmEnv resolves how apm should authenticate for this invocation. Credentials always // travel via APM_REGISTRY_TOKEN_/APM_REGISTRY_USER_+PASS_ env vars — never -// written to a file. Three cases: -// 1. The registry is already declared (an existing ~/.apm/config.json entry or apm.yml's own -// registries: block) — no file write at all; the real environment is used with credential -// env vars appended. -// 2. --repo names a registry apm doesn't already know about — its URL has to be declared -// somewhere, since an env var can't carry a URL, so a temp HOME is used containing only -// URLs (this one, plus every other already-discovered registry so the HOME swap doesn't -// hide them) and no secrets at all. -// 3. Neither an existing declaration nor --repo — nothing to authenticate against, so this -// returns an error rather than silently running apm unauthenticated. -// -// Returns os.Environ() with empty tmpHome when sd is nil (no server configured). -func BuildApmEnv(sd *config.ServerDetails, repoName, manifestPath string) (env []string, tmpHome string, err error) { - if sd == nil { - return os.Environ(), "", nil - } - +// written to a file. The registry must already be declared (an existing ~/.apm/config.json +// entry, written by 'jf setup agent-apm', or apm.yml's own registries: block); if none matches +// serverDetails's host, this returns an error rather than silently running apm unauthenticated. +func BuildApmEnv(serverDetails *config.ServerDetails, manifestPath string) ([]string, error) { realHome, existing, err := loadExistingApmConfig() if err != nil { - return nil, "", err - } - - discovered := discoverMatchingRegistries(existing, manifestPath, sd) - - needsNewDeclaration := false - if repoName != "" { - alreadyDeclared := false - for _, d := range discovered { - if d.Name == repoName { - alreadyDeclared = true - break - } - } - if !alreadyDeclared { - needsNewDeclaration = true - discovered = append(discovered, discoveredRegistry{Name: repoName, URL: AgentPackagesBaseURL(sd, repoName)}) - } + return nil, err } + discovered := discoverMatchingRegistries(existing, manifestPath, serverDetails) if len(discovered) == 0 { - return nil, "", fmt.Errorf( + return nil, fmt.Errorf( "no APM registry found for %s: declare one in apm.yml's registries: block, "+ - "add it to ~/.apm/config.json (via 'jf setup agent-apm'), or pass --repo ", - sd.ArtifactoryUrl) + "or add it to ~/.apm/config.json (via 'jf setup agent-apm')", + serverDetails.ArtifactoryUrl) } names := make([]string, 0, len(discovered)) - for _, d := range discovered { - names = append(names, d.Name) + for _, registry := range discovered { + names = append(names, registry.Name) } if err = checkSanitizationCollisions(names); err != nil { - return nil, "", err + return nil, err } if err = ensureExperimentalFlagEnabled(realHome, existing); err != nil { - return nil, "", err - } - - env = os.Environ() - for _, d := range discovered { - env = injectRegistryCredentialEnv(env, d.Name, sd) - } - - if !needsNewDeclaration { - return env, "", nil - } - - // URL-only, no tokens: --repo named something not already declared anywhere, so its URL - // has to live in a config.json somewhere. Preserve every other registry (matching-host - // ones URL-only, non-matching ones verbatim including any token they already had) so this - // invocation doesn't hide anything the real config already had from apm. - tempCfg := &apmConfigJSON{ - Experimental: experimentalConfig{Registries: true}, - Registries: make(map[string]registryConfig, len(discovered)), - Extra: existing.Extra, - } - for _, d := range discovered { - tempCfg.Registries[d.Name] = registryConfig{URL: d.URL, Default: d.Name == repoName} - } - for name, entry := range existing.Registries { - if _, ok := tempCfg.Registries[name]; !ok { - tempCfg.Registries[name] = entry - } + return nil, err } - tmpHome, err = os.MkdirTemp("", "jf-apm-home-") - if err != nil { - return nil, "", fmt.Errorf("create temp home: %w", err) + env := os.Environ() + for _, registry := range discovered { + env = injectRegistryCredentialEnv(env, registry.Name, serverDetails) } - if err = writeApmConfig(tmpHome, tempCfg); err != nil { - _ = os.RemoveAll(tmpHome) - return nil, "", err - } - - return replaceEnvHome(env, tmpHome), tmpHome, nil + return env, nil } // loadExistingApmConfig reads the user's real ~/.apm/config.json, returning the home path and parsed config. @@ -342,23 +281,6 @@ func writeApmConfig(tmpHome string, cfg *apmConfigJSON) error { return os.WriteFile(filepath.Join(apmDir, "config.json"), data, 0600) } -func replaceEnvHome(env []string, newHome string) []string { - result := make([]string, 0, len(env)) - replaced := false - for _, e := range env { - if strings.HasPrefix(e, "HOME=") { - result = append(result, "HOME="+newHome) - replaced = true - } else { - result = append(result, e) - } - } - if !replaced { - result = append(result, "HOME="+newHome) - } - return result -} - // RunApmCommand runs "apm " with the provided environment. // If env is nil, the current process environment is used. func RunApmCommand(env []string, subcmd string, args []string) error { @@ -386,8 +308,8 @@ func RunApmCommand(env []string, subcmd string, args []string) error { // only ever touches the one key it's told to, and switching a registry's default clears any // previous default on its own (confirmed live: setting a second registry's default un-defaults // the first, with no separate unset step needed). -func ConfigureApmRegistryPersistent(sd *config.ServerDetails, repoName string) error { - if sd == nil { +func ConfigureApmRegistryPersistent(serverDetails *config.ServerDetails, repoName string) error { + if serverDetails == nil { return fmt.Errorf("server details are required for APM registry configuration") } @@ -395,7 +317,7 @@ func ConfigureApmRegistryPersistent(sd *config.ServerDetails, repoName string) e return fmt.Errorf("enable experimental registries: %w", err) } - registryURL, token := BuildRegistryEntry(sd, repoName) + registryURL, token := BuildRegistryEntry(serverDetails, repoName) if err := RunApmCommand(nil, "config", []string{"set", fmt.Sprintf("registry.%s.url", repoName), registryURL}); err != nil { return fmt.Errorf("set registry url: %w", err) } @@ -407,21 +329,21 @@ func ConfigureApmRegistryPersistent(sd *config.ServerDetails, repoName string) e return RunApmCommand(nil, "config", []string{"set", fmt.Sprintf("registry.%s.default", repoName), "true"}) } -// ResolveRepoNameFromRegistry returns the Artifactory repo name for sd, derived from whichever -// already-declared registry (config.json or apm.yml) matches sd.ArtifactoryUrl. jf setup agent-apm -// always names a registry after the repo it points to, so the registry name doubles as the repo -// name. Returns "" if no registry matches or more than one does (ambiguous) - callers treat this -// the same as an unknown repo, not an error, since it only affects build-info enrichment -// (OriginalDeploymentRepo / checksum lookup), never publish itself. -func ResolveRepoNameFromRegistry(sd *config.ServerDetails, manifestPath string) string { - if sd == nil { +// ResolveRepoNameFromRegistry returns the Artifactory repo name for serverDetails, derived from +// whichever already-declared registry (config.json or apm.yml) matches serverDetails.ArtifactoryUrl. +// jf setup agent-apm always names a registry after the repo it points to, so the registry name +// doubles as the repo name. Returns "" if no registry matches or more than one does (ambiguous) - +// callers treat this the same as an unknown repo, not an error, since it only affects build-info +// enrichment (OriginalDeploymentRepo / checksum lookup), never publish itself. +func ResolveRepoNameFromRegistry(serverDetails *config.ServerDetails, manifestPath string) string { + if serverDetails == nil { return "" } _, existing, err := loadExistingApmConfig() if err != nil { return "" } - discovered := discoverMatchingRegistries(existing, manifestPath, sd) + discovered := discoverMatchingRegistries(existing, manifestPath, serverDetails) if len(discovered) != 1 { return "" } @@ -429,9 +351,8 @@ func ResolveRepoNameFromRegistry(sd *config.ServerDetails, manifestPath string) } // RunApmSubcommandWithAuth is the shared body for all apm command Run() methods: -// validates prerequisites, builds a temp HOME with merged credentials, runs the subcommand, -// and cleans up on return. -func RunApmSubcommandWithAuth(subcmd string, args []string, sd *config.ServerDetails, repoName string) error { +// validates prerequisites, builds the auth environment, and runs the subcommand. +func RunApmSubcommandWithAuth(subcmd string, args []string, serverDetails *config.ServerDetails) error { if err := ValidateApmPrerequisites(); err != nil { return err } @@ -440,17 +361,10 @@ func RunApmSubcommandWithAuth(subcmd string, args []string, sd *config.ServerDet return fmt.Errorf("get working directory: %w", err) } manifestPath := filepath.Join(workingDir, ApmManifestName) - env, tmpHome, err := BuildApmEnv(sd, repoName, manifestPath) + env, err := BuildApmEnv(serverDetails, manifestPath) if err != nil { return err } - if tmpHome != "" { - defer func() { - if removeErr := os.RemoveAll(tmpHome); removeErr != nil { - log.Debug("Failed to clean up temp home:", removeErr.Error()) - } - }() - } return RunApmCommand(env, subcmd, args) } diff --git a/agent/apm/common/build_info.go b/agent/apm/common/build_info.go index 6531994e..fd45c645 100644 --- a/agent/apm/common/build_info.go +++ b/agent/apm/common/build_info.go @@ -16,7 +16,7 @@ import ( // CollectAndSaveInstallBuildInfo reads the lockfile, resolves checksums, and saves build-info. // Runs only when build info collection is enabled. -func CollectAndSaveInstallBuildInfo(lockfilePath, manifestPath string, sd *config.ServerDetails, buildConfig *buildUtils.BuildConfiguration) error { +func CollectAndSaveInstallBuildInfo(lockfilePath, manifestPath string, serverDetails *config.ServerDetails, buildConfig *buildUtils.BuildConfiguration) error { collectBuildInfo, err := buildConfig.IsCollectBuildInfo() if err != nil { return err @@ -42,7 +42,7 @@ func CollectAndSaveInstallBuildInfo(lockfilePath, manifestPath string, sd *confi return nil } - checksumMap, err := ResolveChecksums(deps, sd, buildConfig) + checksumMap, err := ResolveChecksums(deps, serverDetails, buildConfig) if err != nil { return err } @@ -75,8 +75,8 @@ func saveInstallBuildInfo(deps []ResolvedDep, checksumMap map[string]entities.Ch entityDeps := make([]entities.Dependency, 0, len(deps)) for _, dep := range deps { - cs := checksumMap[dep.ID] - entityDeps = append(entityDeps, dep.ToEntitiesDependency(cs)) + checksum := checksumMap[dep.ID] + entityDeps = append(entityDeps, dep.ToEntitiesDependency(checksum)) } partial := &entities.Partial{ @@ -143,7 +143,7 @@ func SavePublishBuildInfo(owner, name, version string, checksum entities.Checksu // CollectAndSavePublishBuildInfo reads the package name/version from apm.yml, looks up the // real checksum of the just-published artifact via AQL, and records it in build-info. // Runs only when build info collection is enabled. -func CollectAndSavePublishBuildInfo(manifestPath, owner, repoName string, sd *config.ServerDetails, buildConfig *buildUtils.BuildConfiguration) error { +func CollectAndSavePublishBuildInfo(manifestPath, owner, repoName string, serverDetails *config.ServerDetails, buildConfig *buildUtils.BuildConfiguration) error { collectBuildInfo, err := buildConfig.IsCollectBuildInfo() if err != nil { return err @@ -160,7 +160,7 @@ func CollectAndSavePublishBuildInfo(manifestPath, owner, repoName string, sd *co return nil } - checksum := lookupPublishedArtifactChecksum(owner, manifest.Name, manifest.Version, repoName, sd) + checksum := lookupPublishedArtifactChecksum(owner, manifest.Name, manifest.Version, repoName, serverDetails) return SavePublishBuildInfo(owner, manifest.Name, manifest.Version, checksum, repoName, buildConfig) } @@ -168,11 +168,11 @@ func CollectAndSavePublishBuildInfo(manifestPath, owner, repoName string, sd *co // known repo-relative path — confirmed live that AQL indexes agentpackages repos correctly. // Returns an empty Checksum (not an error) if the repo/owner are unknown or the lookup fails, // since a missing checksum shouldn't fail an already-successful publish. -func lookupPublishedArtifactChecksum(owner, name, version, repoName string, sd *config.ServerDetails) entities.Checksum { - if owner == "" || repoName == "" || sd == nil { +func lookupPublishedArtifactChecksum(owner, name, version, repoName string, serverDetails *config.ServerDetails) entities.Checksum { + if owner == "" || repoName == "" || serverDetails == nil { return entities.Checksum{} } - servicesManager, err := artCoreUtils.CreateServiceManager(sd, -1, 0, false) + servicesManager, err := artCoreUtils.CreateServiceManager(serverDetails, -1, 0, false) if err != nil { log.Debug("apm publish: could not create service manager for checksum lookup:", err.Error()) return entities.Checksum{} diff --git a/agent/apm/common/checksums.go b/agent/apm/common/checksums.go index 72ed6391..1af1647b 100644 --- a/agent/apm/common/checksums.go +++ b/agent/apm/common/checksums.go @@ -23,10 +23,10 @@ const headWorkerCount = 15 // headers directly. Confirmed live to match AQL results exactly, and the same mechanism // ocicontainer/docker already uses for artifacts it can't resolve via AQL. // 3. Fallback: use lockfile SHA-256 only when the HEAD request finds no match. -func ResolveChecksums(deps []ResolvedDep, sd *config.ServerDetails, buildConfig *buildUtils.BuildConfiguration) (map[string]entities.Checksum, error) { +func ResolveChecksums(deps []ResolvedDep, serverDetails *config.ServerDetails, buildConfig *buildUtils.BuildConfiguration) (map[string]entities.Checksum, error) { checksumMap := make(map[string]entities.Checksum) - servicesManager, err := coreArtUtils.CreateServiceManager(sd, -1, 0, false) + servicesManager, err := coreArtUtils.CreateServiceManager(serverDetails, -1, 0, false) if err != nil { return nil, err } @@ -43,8 +43,8 @@ func ResolveChecksums(deps []ResolvedDep, sd *config.ServerDetails, buildConfig var uncached []ResolvedDep for _, dep := range deps { - if cs, ok := cachedChecksums[dep.ID]; ok { - checksumMap[dep.ID] = cs + if checksum, ok := cachedChecksums[dep.ID]; ok { + checksumMap[dep.ID] = checksum } else { uncached = append(uncached, dep) } @@ -58,8 +58,8 @@ func ResolveChecksums(deps []ResolvedDep, sd *config.ServerDetails, buildConfig headResults := resolveChecksumsByHead(uncached, servicesManager) for _, dep := range uncached { - if cs, ok := headResults[dep.ID]; ok { - checksumMap[dep.ID] = cs + if checksum, ok := headResults[dep.ID]; ok { + checksumMap[dep.ID] = checksum } else if dep.SHA256 != "" { checksumMap[dep.ID] = entities.Checksum{Sha256: dep.SHA256} } @@ -85,16 +85,16 @@ func resolveChecksumsByHead(deps []ResolvedDep, servicesManager artifactory.Arti } wg.Add(1) sem <- struct{}{} - go func(d ResolvedDep) { + go func(dep ResolvedDep) { defer wg.Done() defer func() { <-sem }() - fileDetails, _, err := servicesManager.Client().GetRemoteFileDetails(d.ResolvedURL, &clientDetails) + fileDetails, _, err := servicesManager.Client().GetRemoteFileDetails(dep.ResolvedURL, &clientDetails) if err != nil { - log.Debug(fmt.Sprintf("HEAD checksum lookup failed for %s: %s", d.ID, err.Error())) + log.Debug(fmt.Sprintf("HEAD checksum lookup failed for %s: %s", dep.ID, err.Error())) return } mu.Lock() - checksumMap[d.ID] = fileDetails.Checksum + checksumMap[dep.ID] = fileDetails.Checksum mu.Unlock() }(dep) } diff --git a/agent/apm/common/dependency_resolver.go b/agent/apm/common/dependency_resolver.go index f48184c5..f1d3fdeb 100644 --- a/agent/apm/common/dependency_resolver.go +++ b/agent/apm/common/dependency_resolver.go @@ -47,13 +47,13 @@ func ResolveDependencies(lockfilePath string) ([]ResolvedDep, error) { // ToEntitiesDependency converts a ResolvedDep to entities.Dependency with resolved checksums. // Type is "zip" — confirmed live against Artifactory's real agentpackages storage layout. -func (d ResolvedDep) ToEntitiesDependency(cs entities.Checksum) entities.Dependency { +func (dep ResolvedDep) ToEntitiesDependency(checksum entities.Checksum) entities.Dependency { return entities.Dependency{ - Id: d.ID, + Id: dep.ID, Type: "zip", - Scopes: d.Scopes, - RequestedBy: d.RequestedBy, - Checksum: cs, + Scopes: dep.Scopes, + RequestedBy: dep.RequestedBy, + Checksum: checksum, } } diff --git a/agent/apm/common/manifest.go b/agent/apm/common/manifest.go index cb3c560b..4fc5b890 100644 --- a/agent/apm/common/manifest.go +++ b/agent/apm/common/manifest.go @@ -54,9 +54,9 @@ func apmHostMatches(registryURL, artifactoryURL string) bool { } func parseHost(rawURL string) string { - u, err := url.Parse(rawURL) - if err != nil || u.Host == "" { + parsedURL, err := url.Parse(rawURL) + if err != nil || parsedURL.Host == "" { return "" } - return strings.ToLower(u.Host) + return strings.ToLower(parsedURL.Host) } diff --git a/agent/apm/common/subcommand_options.go b/agent/apm/common/subcommand_options.go index ce60eb3c..6aca88e0 100644 --- a/agent/apm/common/subcommand_options.go +++ b/agent/apm/common/subcommand_options.go @@ -2,7 +2,6 @@ package apmcommon import ( buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build" - "github.com/jfrog/jfrog-cli-core/v2/utils/config" "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" ) @@ -11,38 +10,30 @@ import ( type ApmSubcommandOptions struct { // RemainingArgs is what's left after stripping jf's own flags - passed straight through // to the real apm binary, so apm-native flags (--package, --registry, --zip, --dry-run, - // etc.) survive untouched. + // --server-id, --repo, etc.) survive untouched. Server auth always comes from the default + // configured JFrog server (see agentcommon.GetServerDetails) - install/publish/update take + // no server-selection flags of their own, matching the pnpm/npm/yarn/nuget convention. RemainingArgs []string - ServerDetails *config.ServerDetails BuildConfig *buildUtils.BuildConfiguration } -// ExtractApmSubcommandOptions extracts every flag declared for install/publish/update -// (--server-id, --build-name, --build-number, --module, --project) from args, and resolves -// them into ServerDetails and a BuildConfiguration. +// ExtractApmSubcommandOptions extracts install/publish/update's own flags (--build-name, +// --build-number, --module, --project) from args and resolves them into a BuildConfiguration. // // This exists because install/publish/update set SkipFlagParsing (so apm-native flags that // aren't in jf's own declared flag set - --package, --registry, --zip, --dry-run - don't get // rejected by urfave/cli before ever reaching apm). SkipFlagParsing means urfave/cli parses // NONE of the flags itself, jf's own included, so every one of them has to be pulled out by -// hand. Unlike passthrough (which still supports --repo to declare a new registry inline), -// install/publish/update require a registry to already be declared, so --repo isn't one of -// jf's own flags here - it passes straight through in RemainingArgs like any other -// apm-native flag (where apm itself will reject it, since apm has no --repo flag either). -// -// No direct-credential flags (--url/--user/--password/--access-token) either, matching the -// pnpm/npm/yarn/nuget convention: auth is resolved purely from --server-id or the default -// configured server, never from ad hoc credentials passed to the runtime command itself. +// hand. func ExtractApmSubcommandOptions(args []string) (*ApmSubcommandOptions, error) { rest := args - var serverID, buildName, buildNumber, module, project string + var buildName, buildNumber, module, project string var err error for _, opt := range []struct { name string dest *string }{ - {"server-id", &serverID}, {"build-name", &buildName}, {"build-number", &buildNumber}, {"module", &module}, @@ -54,11 +45,6 @@ func ExtractApmSubcommandOptions(args []string) (*ApmSubcommandOptions, error) { } } - sd, err := config.GetSpecificConfig(serverID, true, true) - if err != nil { - return nil, err - } - buildConfig := new(buildUtils.BuildConfiguration) if err = buildConfig.SetBuildName(buildName).SetBuildNumber(buildNumber).SetProject(project).SetModule(module).ValidateBuildAndModuleParams(); err != nil { return nil, err @@ -66,7 +52,6 @@ func ExtractApmSubcommandOptions(args []string) (*ApmSubcommandOptions, error) { return &ApmSubcommandOptions{ RemainingArgs: rest, - ServerDetails: sd, BuildConfig: buildConfig, }, nil } diff --git a/agent/apm/common/subcommand_options_test.go b/agent/apm/common/subcommand_options_test.go index f9507dc8..13ebcf18 100644 --- a/agent/apm/common/subcommand_options_test.go +++ b/agent/apm/common/subcommand_options_test.go @@ -4,66 +4,45 @@ import ( "testing" "github.com/jfrog/jfrog-cli-artifactory/agent/common/testutil" - "github.com/jfrog/jfrog-cli-core/v2/utils/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestExtractApmSubcommandOptions_ServerIDSelectsConfiguredServer(t *testing.T) { - testutil.WithJfrogHome(t) - require.NoError(t, config.SaveServersConf([]*config.ServerDetails{ - {ServerId: "known-server", Url: "https://known.jfrog.io/"}, - })) - - // server-id present but not the one configured: must error rather than silently falling - // back to an empty/default server. - _, err := ExtractApmSubcommandOptions([]string{"--server-id", "does-not-exist"}) - assert.Error(t, err) - - // server-id present and matching a real config: must resolve to that config. - opts, err := ExtractApmSubcommandOptions([]string{"--server-id", "known-server"}) - require.NoError(t, err) - assert.Equal(t, "https://known.jfrog.io/", opts.ServerDetails.Url) -} - -func TestExtractApmSubcommandOptions_NoServerIDFallsBackToDefaultConfig(t *testing.T) { - testutil.WithJfrogHome(t) // no servers configured - - opts, err := ExtractApmSubcommandOptions(nil) - require.NoError(t, err) - assert.Empty(t, opts.ServerDetails.ArtifactoryUrl) -} - func TestExtractApmSubcommandOptions_PreservesApmNativeFlags(t *testing.T) { testutil.WithJfrogHome(t) - // install/publish/update don't declare --repo (or direct-credential flags) as jf's own - - // a registry must already be declared, so --repo isn't extracted here and flows through - // untouched like any other apm-native flag. + // install/publish/update don't declare --repo, --server-id, or direct-credential flags as + // jf's own - a registry and server must already be declared/configured, so none of these + // are extracted here; they flow through untouched like any other apm-native flag. opts, err := ExtractApmSubcommandOptions([]string{ "--repo", "buk-apm", + "--server-id", "my-server", "--registry", "buk-apm", "--frozen", "uday/pkg-base#1.0.0", }) require.NoError(t, err) - assert.Equal(t, []string{"--repo", "buk-apm", "--registry", "buk-apm", "--frozen", "uday/pkg-base#1.0.0"}, opts.RemainingArgs) + assert.Equal(t, + []string{"--repo", "buk-apm", "--server-id", "my-server", "--registry", "buk-apm", "--frozen", "uday/pkg-base#1.0.0"}, + opts.RemainingArgs) } -func TestExtractApmSubcommandOptions_DirectCredentialFlagsFlowThrough(t *testing.T) { +func TestExtractApmSubcommandOptions_ExtractsBuildInfoFlags(t *testing.T) { testutil.WithJfrogHome(t) - // --url/--user/--password/--access-token are no longer jf's own flags for install/ - // publish/update (matching pnpm/npm/yarn/nuget - auth comes from --server-id or the - // default configured server only), so they pass straight through like any other - // apm-native flag, and ServerDetails resolves from the default config. opts, err := ExtractApmSubcommandOptions([]string{ - "--url", "https://acme.jfrog.io", - "--access-token", "my-token", + "--build-name", "my-build", + "--build-number", "1", + "--module", "my-module", + "--project", "my-project", + "uday/pkg-base#1.0.0", }) require.NoError(t, err) - assert.Equal(t, []string{"--url", "https://acme.jfrog.io", "--access-token", "my-token"}, opts.RemainingArgs) - assert.Empty(t, opts.ServerDetails.ArtifactoryUrl) + assert.Equal(t, []string{"uday/pkg-base#1.0.0"}, opts.RemainingArgs) + + buildName, err := opts.BuildConfig.GetBuildName() + require.NoError(t, err) + assert.Equal(t, "my-build", buildName) } func TestExtractApmSubcommandOptions_BuildNameWithoutNumberErrors(t *testing.T) { diff --git a/agent/apm/common/utils.go b/agent/apm/common/utils.go index b5755ce3..9c7fce91 100644 --- a/agent/apm/common/utils.go +++ b/agent/apm/common/utils.go @@ -52,10 +52,10 @@ func parseApmVersion(output string) string { return "" } -// isDottedVersion reports whether s is two or three dot-separated numeric segments, +// isDottedVersion reports whether token is two or three dot-separated numeric segments, // e.g. "1.2" or "0.23.1". -func isDottedVersion(s string) bool { - parts := strings.Split(s, ".") +func isDottedVersion(token string) bool { + parts := strings.Split(token, ".") if len(parts) < 2 || len(parts) > 3 { return false } @@ -63,8 +63,8 @@ func isDottedVersion(s string) bool { if part == "" { return false } - for _, r := range part { - if r < '0' || r > '9' { + for _, char := range part { + if char < '0' || char > '9' { return false } } diff --git a/agent/cli/cli.go b/agent/cli/cli.go index b2a9cf21..0d61be0f 100644 --- a/agent/cli/cli.go +++ b/agent/cli/cli.go @@ -5,7 +5,6 @@ import ( "github.com/jfrog/jfrog-cli-artifactory/agent/apm/commands/passthrough" pluginscli "github.com/jfrog/jfrog-cli-artifactory/agent/plugins/cli" skillscli "github.com/jfrog/jfrog-cli-artifactory/agent/skills/cli" - "github.com/jfrog/jfrog-cli-artifactory/cliutils/flagkit" "github.com/jfrog/jfrog-cli-core/v2/plugins/components" ) @@ -26,7 +25,6 @@ func GetCommands() []components.Command { { Name: "apm", Description: "Agent Package Manager (APM) commands with JFrog Artifactory authentication.", - Flags: flagkit.GetCommandFlags(flagkit.AgentApmPassthrough), Subcommands: apmcli.GetSubCommands(), Action: passthrough.RunApmPassthroughDefault, }, diff --git a/cliutils/flagkit/flags.go b/cliutils/flagkit/flags.go index 91bb0f16..247a7fb5 100644 --- a/cliutils/flagkit/flags.go +++ b/cliutils/flagkit/flags.go @@ -511,15 +511,11 @@ const ( SkillsDelete = "skills-delete" SkillsList = "skills-list" - // Agent APM commands keys. install/publish/lock/update all take the identical - // server + build-info flag set, so they share one key; only the bare passthrough differs - // (no build-info flags, since it can't collect build-info at all). Neither takes direct - // credential flags (--url/--user/--password/--access-token) - a registry/server must - // already be declared via --server-id, the default configured server, or (passthrough - // only) an already-known --repo, matching the pnpm/npm/yarn/nuget convention of resolving - // auth purely through --server-id rather than ad hoc credentials on the runtime command. - AgentApmSubcommand = "agent-apm-subcommand" - AgentApmPassthrough = "agent-apm-passthrough" + // Agent APM commands key. install/publish/update take only build-info flags; auth always + // resolves from the default configured JFrog server, no --server-id/--repo/direct-credential + // override - apm's own registry/config resolution (~/.apm/config.json, apm.yml) is what + // package managers are for. The generic passthrough takes no flags of its own at all. + AgentApm = "agent-apm" // Agent plugin commands keys AgentPluginsPublish = "agent-plugins-publish" @@ -930,11 +926,8 @@ var commandFlags = map[string][]string{ SkillsList: { url, user, password, accessToken, serverId, repo, harness, projectDir, agentGlobal, agentFormat, agentLimit, agentSortBy, agentSortOrder, agentCheckUpdates, }, - AgentApmSubcommand: { - serverId, BuildName, BuildNumber, module, Project, - }, - AgentApmPassthrough: { - serverId, repo, + AgentApm: { + BuildName, BuildNumber, module, Project, }, } From 192cf73dc876869c55e5998fe4a3f826b84b70a6 Mon Sep 17 00:00:00 2001 From: Uday Date: Tue, 28 Jul 2026 12:47:13 +0530 Subject: [PATCH 07/40] RTECO-1648 - Fix apm.yml registries: block silently discarded with default: key apm.yml's registries: block only affects plain owner/repo dependency resolution when it carries a sibling 'default: ' key (confirmed against https://microsoft.github.io/apm/reference/manifest-schema/) - the same YAML level as the registry names themselves, not nested under one. ApmManifest modeled Registries as map[string]ManifestRegistry, so yaml.Unmarshal tried to decode the default string as a ManifestRegistry struct and failed outright; that error was swallowed at Debug level in discoverMatchingRegistries, silently discarding every registry in the block. Confirmed live: an apm.yml with a schema-correct registries+default block, and jf setup agent-apm never run, failed with 'no APM registry found' before this fix, and installs/authenticates correctly after it. Fixes ApmManifest.Registries to a custom ManifestRegistries type with its own UnmarshalYAML that splits the default key out before decoding entries. Adds manifest_test.go, which had no coverage at all before this. --- agent/apm/common/apmenv.go | 2 +- agent/apm/common/manifest.go | 44 +++++++++++- agent/apm/common/manifest_test.go | 116 ++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 agent/apm/common/manifest_test.go diff --git a/agent/apm/common/apmenv.go b/agent/apm/common/apmenv.go index 89c9eb47..731d0e47 100644 --- a/agent/apm/common/apmenv.go +++ b/agent/apm/common/apmenv.go @@ -124,7 +124,7 @@ func discoverMatchingRegistries(existing *apmConfigJSON, manifestPath string, se if manifestPath != "" { if manifest, loadErr := LoadManifest(manifestPath); loadErr == nil { - for name, reg := range manifest.Registries { + for name, reg := range manifest.Registries.Entries { if seen[name] || !apmHostMatches(reg.URL, serverDetails.ArtifactoryUrl) { continue } diff --git a/agent/apm/common/manifest.go b/agent/apm/common/manifest.go index 4fc5b890..9903b320 100644 --- a/agent/apm/common/manifest.go +++ b/agent/apm/common/manifest.go @@ -19,9 +19,47 @@ const ApmManifestName = "apm.yml" // not a list. An earlier version of this struct modeled it as []ManifestRegistry, which // made LoadManifest fail on every real apm.yml that declares any registries at all. type ApmManifest struct { - Name string `yaml:"name"` - Version string `yaml:"version"` - Registries map[string]ManifestRegistry `yaml:"registries"` + Name string `yaml:"name"` + Version string `yaml:"version"` + Registries ManifestRegistries `yaml:"registries"` +} + +// ManifestRegistries models apm.yml's registries: block: a map of registry name to entry, +// plus an optional sibling "default: " key (confirmed against the real schema at +// https://microsoft.github.io/apm/reference/manifest-schema/ - a registries: block without a +// default has no effect on plain owner/repo dependency resolution at all). "default" lives at +// the same YAML level as the registry names themselves, not nested under one, so it can't be +// modeled as a plain map[string]ManifestRegistry - yaml.Unmarshal would try to decode the +// "default" value (a string) as a ManifestRegistry (a struct) and fail, silently discarding +// every registry in the block along with it (see UnmarshalYAML). +type ManifestRegistries struct { + Entries map[string]ManifestRegistry + Default string +} + +// UnmarshalYAML splits the "default" key out from the registry-name entries before decoding +// each one, so a real apm.yml with both (the schema-correct, common case) parses successfully +// instead of failing outright. +func (r *ManifestRegistries) UnmarshalYAML(value *yaml.Node) error { + raw := make(map[string]yaml.Node) + if err := value.Decode(&raw); err != nil { + return err + } + r.Entries = make(map[string]ManifestRegistry, len(raw)) + for name, node := range raw { + if name == "default" { + if err := node.Decode(&r.Default); err != nil { + return err + } + continue + } + var entry ManifestRegistry + if err := node.Decode(&entry); err != nil { + return err + } + r.Entries[name] = entry + } + return nil } type ManifestRegistry struct { diff --git a/agent/apm/common/manifest_test.go b/agent/apm/common/manifest_test.go new file mode 100644 index 00000000..d304ffb6 --- /dev/null +++ b/agent/apm/common/manifest_test.go @@ -0,0 +1,116 @@ +package apmcommon + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeManifest(t *testing.T, dir, content string) string { + t.Helper() + path := filepath.Join(dir, ApmManifestName) + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + return path +} + +func TestLoadManifest_NoRegistries(t *testing.T) { + path := writeManifest(t, t.TempDir(), ` +name: my-project +version: 1.0.0 +`) + manifest, err := LoadManifest(path) + require.NoError(t, err) + assert.Empty(t, manifest.Registries.Entries) + assert.Empty(t, manifest.Registries.Default) +} + +// TestLoadManifest_RegistriesWithDefault is a regression test: a registries: block with a +// sibling "default: " key - the schema-correct, common shape per +// https://microsoft.github.io/apm/reference/manifest-schema/ (a registries: block without a +// default has no effect on plain owner/repo dependency resolution at all) - used to fail +// yaml.Unmarshal entirely, silently discarding every registry in the block. +func TestLoadManifest_RegistriesWithDefault(t *testing.T) { + path := writeManifest(t, t.TempDir(), ` +name: my-project +version: 1.0.0 +registries: + jf-skills: + url: https://artifactory.example.com/artifactory/api/agentpackages/jf-skills-local + default: jf-skills +`) + manifest, err := LoadManifest(path) + require.NoError(t, err) + require.Len(t, manifest.Registries.Entries, 1) + assert.Equal(t, "https://artifactory.example.com/artifactory/api/agentpackages/jf-skills-local", manifest.Registries.Entries["jf-skills"].URL) + assert.Equal(t, "jf-skills", manifest.Registries.Default) +} + +func TestLoadManifest_RegistriesWithoutDefault(t *testing.T) { + path := writeManifest(t, t.TempDir(), ` +name: my-project +version: 1.0.0 +registries: + jf-skills: + url: https://artifactory.example.com/artifactory/api/agentpackages/jf-skills-local +`) + manifest, err := LoadManifest(path) + require.NoError(t, err) + require.Len(t, manifest.Registries.Entries, 1) + assert.Equal(t, "https://artifactory.example.com/artifactory/api/agentpackages/jf-skills-local", manifest.Registries.Entries["jf-skills"].URL) + assert.Empty(t, manifest.Registries.Default) +} + +func TestLoadManifest_MultipleRegistriesWithDefault(t *testing.T) { + path := writeManifest(t, t.TempDir(), ` +name: my-project +version: 1.0.0 +registries: + registry-a: + url: https://a.example.com/artifactory/api/agentpackages/a-local + registry-b: + url: https://b.example.com/artifactory/api/agentpackages/b-local + default: registry-b +`) + manifest, err := LoadManifest(path) + require.NoError(t, err) + require.Len(t, manifest.Registries.Entries, 2) + assert.Equal(t, "https://a.example.com/artifactory/api/agentpackages/a-local", manifest.Registries.Entries["registry-a"].URL) + assert.Equal(t, "https://b.example.com/artifactory/api/agentpackages/b-local", manifest.Registries.Entries["registry-b"].URL) + assert.Equal(t, "registry-b", manifest.Registries.Default) +} + +func TestLoadManifest_MissingFileReturnsEmptyManifest(t *testing.T) { + manifest, err := LoadManifest(filepath.Join(t.TempDir(), ApmManifestName)) + require.NoError(t, err) + assert.Empty(t, manifest.Name) + assert.Empty(t, manifest.Registries.Entries) +} + +func TestLoadManifest_MalformedYAMLErrors(t *testing.T) { + path := writeManifest(t, t.TempDir(), `name: [this is not valid yaml`) + _, err := LoadManifest(path) + assert.Error(t, err) +} + +func TestApmHostMatches(t *testing.T) { + tests := []struct { + name string + registryURL string + artifactoryURL string + want bool + }{ + {name: "matching host", registryURL: "https://acme.jfrog.io/artifactory/api/agentpackages/my-repo/", artifactoryURL: "https://acme.jfrog.io/artifactory/", want: true}, + {name: "different host", registryURL: "https://other.jfrog.io/artifactory/api/agentpackages/my-repo/", artifactoryURL: "https://acme.jfrog.io/artifactory/", want: false}, + {name: "case-insensitive host", registryURL: "https://ACME.jfrog.io/artifactory/api/agentpackages/my-repo/", artifactoryURL: "https://acme.jfrog.io/artifactory/", want: true}, + {name: "empty registry URL", registryURL: "", artifactoryURL: "https://acme.jfrog.io/artifactory/", want: false}, + {name: "empty artifactory URL", registryURL: "https://acme.jfrog.io/artifactory/api/agentpackages/my-repo/", artifactoryURL: "", want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, apmHostMatches(tt.registryURL, tt.artifactoryURL)) + }) + } +} From 700376f6199e1bf827dfae416460e5ef1120f788 Mon Sep 17 00:00:00 2001 From: Uday Date: Thu, 30 Jul 2026 09:35:20 +0530 Subject: [PATCH 08/40] Fix TestResolveRepoNameFromRegistry failing on Windows The test was only setting HOME environment variable, which does not affect os.UserHomeDir() on Windows. Windows uses USERPROFILE environment variable (and HOMEDRIVE/HOMEPATH as fallback), not HOME. Set both HOME (for Unix) and USERPROFILE (for Windows) to make the test cross-platform compatible. Co-authored-by: Cursor --- agent/apm/common/apmenv_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/agent/apm/common/apmenv_test.go b/agent/apm/common/apmenv_test.go index ce9bbb95..a0ef7b1f 100644 --- a/agent/apm/common/apmenv_test.go +++ b/agent/apm/common/apmenv_test.go @@ -91,7 +91,9 @@ func TestResolveRepoNameFromRegistry(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { home := t.TempDir() + // Set HOME for Unix and USERPROFILE for Windows (os.UserHomeDir reads USERPROFILE on Windows) t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) if tt.configJSON != "" { apmDir := filepath.Join(home, ".apm") require.NoError(t, os.MkdirAll(apmDir, 0o755)) From f6e36166fa2e0c691c6e3998c1301c0f81b91dbb Mon Sep 17 00:00:00 2001 From: Uday Date: Thu, 30 Jul 2026 09:37:50 +0530 Subject: [PATCH 09/40] Add AI help descriptions for APM commands (install, publish, update) Create help.go files with GetDescription() and GetAIDescription() for each APM command: - install: Install APM packages with Artifactory authentication - publish: Publish APM packages to Artifactory - update: Refresh package dependencies with build-info collection Update agent/apm/cli/cli.go to wire up AIDescription fields so commands are discoverable by static analysis tests and AI tools. Fixes: TestAIHelpCoverageGenerated test failure (3 visible APM commands missing AI help) Co-authored-by: Cursor --- agent/apm/cli/cli.go | 3 +++ agent/apm/commands/install/help.go | 35 +++++++++++++++++++++++++ agent/apm/commands/publish/help.go | 42 ++++++++++++++++++++++++++++++ agent/apm/commands/update/help.go | 41 +++++++++++++++++++++++++++++ 4 files changed, 121 insertions(+) create mode 100644 agent/apm/commands/install/help.go create mode 100644 agent/apm/commands/publish/help.go create mode 100644 agent/apm/commands/update/help.go diff --git a/agent/apm/cli/cli.go b/agent/apm/cli/cli.go index c9930984..3a23f7b3 100644 --- a/agent/apm/cli/cli.go +++ b/agent/apm/cli/cli.go @@ -23,6 +23,7 @@ func GetSubCommands() []components.Command { // RunInstall extracts jf's own flags manually via ExtractApmSubcommandOptions. SkipFlagParsing: true, Description: "Install APM packages with JFrog Artifactory authentication.", + AIDescription: install.GetAIDescription(), Action: install.RunInstall, }, { @@ -30,6 +31,7 @@ func GetSubCommands() []components.Command { Flags: flagkit.GetCommandFlags(flagkit.AgentApm), SkipFlagParsing: true, Description: "Publish an APM package to JFrog Artifactory.", + AIDescription: publish.GetAIDescription(), Action: publish.RunPublish, }, { @@ -37,6 +39,7 @@ func GetSubCommands() []components.Command { Flags: flagkit.GetCommandFlags(flagkit.AgentApm), SkipFlagParsing: true, Description: "Refresh APM dependencies to their latest matching refs, with build-info collection.", + AIDescription: update.GetAIDescription(), Action: update.RunUpdate, }, } diff --git a/agent/apm/commands/install/help.go b/agent/apm/commands/install/help.go new file mode 100644 index 00000000..66693b77 --- /dev/null +++ b/agent/apm/commands/install/help.go @@ -0,0 +1,35 @@ +package install + +func GetDescription() string { + return "Install APM packages with JFrog Artifactory authentication." +} + +func GetAIDescription() string { + return `Install packages declared in apm.yml with authenticated access to JFrog Artifactory registries. + +When to use: +- Installing packages into an agent project that has apm.yml configured. +- Accessing private or curated packages from Artifactory via registry credentials. +- Collecting build-info about package dependencies in CI/CD pipelines. + +Prerequisites: +- apm CLI (>= 0.1.0) installed and in PATH. +- A registry declared in apm.yml's registries: block or configured via jf setup agent-apm. +- Read permission on the source Artifactory agentpackages repository. + +Common patterns: + $ jf agent apm install + $ jf agent apm install --build-name=my-build --build-number=1 + +Build info: +- Enabled with --build-name and --build-number flags. +- Captures installed packages and their transitive dependencies. +- Published to Artifactory for traceability and compliance. + +Environment: +- Credentials injected via APM_REGISTRY_TOKEN_, APM_REGISTRY_USER_, APM_REGISTRY_PASS_. +- Registry configuration sourced from ~/.apm/config.json (set by jf setup agent-apm). +- Lockfile apm.lock.yaml created in working directory. + +Related: jf agent apm publish, jf agent apm update, jf setup agent-apm` +} diff --git a/agent/apm/commands/publish/help.go b/agent/apm/commands/publish/help.go new file mode 100644 index 00000000..43a749ae --- /dev/null +++ b/agent/apm/commands/publish/help.go @@ -0,0 +1,42 @@ +package publish + +func GetDescription() string { + return "Publish an APM package to JFrog Artifactory." +} + +func GetAIDescription() string { + return `Publish an agent package to a JFrog Artifactory agentpackages repository with authenticated access. + +When to use: +- Publishing custom agent packages for installation across multiple projects. +- Packaging skills, tools, or other agent extensions for organizational use. +- Creating reproducible, versioned deployments of agent components. + +Prerequisites: +- apm CLI (>= 0.1.0) installed and in PATH. +- An apm.yml file in the package directory (or parent directories). +- Write permission on the Artifactory agentpackages repository. +- Registry configured via jf setup agent-apm or apm.yml's registries: block. + +Common patterns: + $ jf agent apm publish my-org/my-package + $ jf agent apm publish my-org/my-package --build-name=my-build --build-number=1 + $ jf agent apm publish my-org/my-package --build-name=my-build --build-number=1 --module=my-module + +Package format: +- Directory with apm.yml declaring name, version, and description. +- Optional skills/ subdirectory containing Cursor Agent Skills. +- Version in apm.yml becomes the published package version. + +Build info: +- Enabled with --build-name and --build-number flags. +- Captures package metadata and publishing source. +- Published to Artifactory for traceability and compliance. +- Optional --module to group multiple packages in the same build. + +Environment: +- Credentials injected via APM_REGISTRY_TOKEN_, APM_REGISTRY_USER_, APM_REGISTRY_PASS_. +- Registry configuration sourced from ~/.apm/config.json (set by jf setup agent-apm). + +Related: jf agent apm install, jf agent apm update, jf setup agent-apm` +} diff --git a/agent/apm/commands/update/help.go b/agent/apm/commands/update/help.go new file mode 100644 index 00000000..64e11b4e --- /dev/null +++ b/agent/apm/commands/update/help.go @@ -0,0 +1,41 @@ +package update + +func GetDescription() string { + return "Refresh APM dependencies to their latest matching refs, with build-info collection." +} + +func GetAIDescription() string { + return `Update packages in apm.yml to their latest matching versions and refresh the lockfile with authenticated access to JFrog Artifactory. + +When to use: +- Keeping agent package dependencies up-to-date within declared version constraints. +- Re-resolving dependencies when new versions are published to the registry. +- Collecting updated build-info about package dependencies in CI/CD pipelines. + +Prerequisites: +- apm CLI (>= 0.1.0) installed and in PATH. +- An apm.yml file in the working directory with dependencies declared. +- A lockfile apm.lock.yaml already created (e.g., via jf agent apm install). +- Read permission on the source Artifactory agentpackages repository. +- Registry configured via jf setup agent-apm or apm.yml's registries: block. + +Common patterns: + $ jf agent apm update + $ jf agent apm update --build-name=my-build --build-number=1 + +Version constraints: +- Respects version constraints in apm.yml's dependencies section. +- Fetches latest versions matching declared constraints. +- Updates apm.lock.yaml with resolved versions and checksums. + +Build info: +- Enabled with --build-name and --build-number flags. +- Captures updated packages and their transitive dependencies. +- Published to Artifactory for traceability and compliance. + +Environment: +- Credentials injected via APM_REGISTRY_TOKEN_, APM_REGISTRY_USER_, APM_REGISTRY_PASS_. +- Registry configuration sourced from ~/.apm/config.json (set by jf setup agent-apm). + +Related: jf agent apm install, jf agent apm publish, jf setup agent-apm` +} From 17333ab535d25061c263096ef1341d973b1069b5 Mon Sep 17 00:00:00 2001 From: Uday Date: Sat, 1 Aug 2026 19:58:51 +0530 Subject: [PATCH 10/40] RTECO-1648 - Replace AQL checksum lookup with HEAD, fix PR #518 review findings - Replace the last remaining AQL query (publish-time artifact checksum) with an HTTP HEAD against the same download-URL shape ResolveChecksums already uses for dependencies - no AQL usage left anywhere in the apm package. - Rename dependency scope "runtime" to "prod" to match the newer sibling FlexPack integrations (Alpine, Cargo), which converged on that name instead of the older, now-minority "runtime" convention nix alone still uses. Address PR #518 review comments: - Stop logging full apm argument lists and raw subcommand concatenation, which could leak registry tokens and basic-auth credentials into debug logs and was forgeable via embedded newlines (CWE-117) - log only a sanitized subcommand name instead. - Require --package explicitly on publish instead of auto-promoting a bare positional arg: the promotion heuristic could mistake a value-taking apm flag's value, e.g. --zip foo.zip, for the package itself. Fixing the heuristic only closes the two flags known today, so require --package outright and remove the ambiguity instead of chasing it. - Make ~/.apm/config.json writes atomic (temp file plus rename) so a crash mid-write or two racing invocations can't corrupt it. - Guard BuildApmEnv against a nil serverDetails. - Add a timeout to the apm deps why subprocess (a hang, not just a failure, used to block forever) and run it with bounded concurrency instead of sequentially, one dependency at a time. - Extract and unit-test the three-tier checksum selection logic, cache then HEAD then lockfile, previously untested inline logic. - Reconcile two contradictory "the one persistent write" comments in apmenv.go. Also applied the go_agent_knowledge_base_skill review: renamed one single-letter parameter introduced by these changes, and added "why it's safe to ignore" reasoning to two new best-effort cleanup calls. gosec and golangci-lint's unused check are clean. --- agent/apm/commands/passthrough/passthrough.go | 2 +- agent/apm/commands/publish/help.go | 10 ++- agent/apm/commands/publish/publish.go | 31 ++++---- agent/apm/commands/publish/publish_test.go | 52 +++++-------- agent/apm/common/apmenv.go | 50 +++++++++++- agent/apm/common/build_info.go | 27 +++---- agent/apm/common/checksums.go | 46 +++++++---- agent/apm/common/checksums_test.go | 77 ++++++++++++++++++ agent/apm/common/dependency_resolver.go | 78 ++++++++++++++----- agent/apm/common/dependency_resolver_test.go | 18 ++++- 10 files changed, 282 insertions(+), 109 deletions(-) create mode 100644 agent/apm/common/checksums_test.go diff --git a/agent/apm/commands/passthrough/passthrough.go b/agent/apm/commands/passthrough/passthrough.go index 957780f6..2fbb75d7 100644 --- a/agent/apm/commands/passthrough/passthrough.go +++ b/agent/apm/commands/passthrough/passthrough.go @@ -44,7 +44,7 @@ func (c *ApmPassthroughCommand) ServerDetails() (*config.ServerDetails, error) { } func (c *ApmPassthroughCommand) Run() error { - log.Info("Running apm " + c.subcmd + "...") + log.Info("Running apm " + apmcommon.SanitizeLogValue(c.subcmd) + "...") return apmcommon.RunApmSubcommandWithAuth(c.subcmd, c.args, c.serverDetails) } diff --git a/agent/apm/commands/publish/help.go b/agent/apm/commands/publish/help.go index 43a749ae..984e2d9c 100644 --- a/agent/apm/commands/publish/help.go +++ b/agent/apm/commands/publish/help.go @@ -19,9 +19,13 @@ Prerequisites: - Registry configured via jf setup agent-apm or apm.yml's registries: block. Common patterns: - $ jf agent apm publish my-org/my-package - $ jf agent apm publish my-org/my-package --build-name=my-build --build-number=1 - $ jf agent apm publish my-org/my-package --build-name=my-build --build-number=1 --module=my-module + $ jf agent apm publish --package my-org/my-package + $ jf agent apm publish --package my-org/my-package --build-name=my-build --build-number=1 + $ jf agent apm publish --package my-org/my-package --build-name=my-build --build-number=1 --module=my-module + +Note: +- --package is required and must be passed explicitly (owner/name); it is not inferred from a + bare positional argument. Package format: - Directory with apm.yml declaring name, version, and description. diff --git a/agent/apm/commands/publish/publish.go b/agent/apm/commands/publish/publish.go index c50fdebc..590fb3b7 100644 --- a/agent/apm/commands/publish/publish.go +++ b/agent/apm/commands/publish/publish.go @@ -57,30 +57,29 @@ func (c *ApmPublishCommand) ServerDetails() (*config.ServerDetails, error) { return c.serverDetails, nil } -// withPackageFlag promotes a bare positional package spec (e.g. "jfrog/proj3") into the -// --package flag apm publish requires. If --package is already present, args are left untouched. -func withPackageFlag(args []string) []string { +// requirePackageFlag returns a clear, jf-level error if --package isn't present in args. +// A prior version auto-promoted a bare positional spec (e.g. "jfrog/proj3") into --package, but +// that heuristic could mistake a value-taking apm flag's value for the package - e.g. in +// "--zip foo.zip acme/pkg", it would grab "foo.zip" (--zip's value) instead of "acme/pkg". Rather +// than track every apm flag that might take a value (and risk the same class of bug again the +// next time apm adds one), --package is required explicitly - removing the ambiguity entirely +// instead of working around it. +func requirePackageFlag(args []string) error { for _, arg := range args { if arg == "--package" || strings.HasPrefix(arg, "--package=") { - return args + return nil } } - for i, arg := range args { - if !strings.HasPrefix(arg, "-") { - rest := make([]string, 0, len(args)-1) - rest = append(rest, args[:i]...) - rest = append(rest, args[i+1:]...) - return append([]string{"--package", arg}, rest...) - } - } - return args + return fmt.Errorf("jf agent apm publish requires --package /, e.g. --package acme/my-skill") } func (c *ApmPublishCommand) Run() error { log.Info("Running apm publish...") - args := withPackageFlag(c.args) - if err := apmcommon.RunApmSubcommandWithAuth("publish", args, c.serverDetails); err != nil { + if err := requirePackageFlag(c.args); err != nil { + return err + } + if err := apmcommon.RunApmSubcommandWithAuth("publish", c.args, c.serverDetails); err != nil { return fmt.Errorf("run apm publish: %w", err) } @@ -89,7 +88,7 @@ func (c *ApmPublishCommand) Run() error { log.Warn("apm publish completed, but could not determine working directory for build info:", err.Error()) } else { manifestPath := filepath.Join(workingDir, apmcommon.ApmManifestName) - owner := ownerFromArgs(args) + owner := ownerFromArgs(c.args) repoName := apmcommon.ResolveRepoNameFromRegistry(c.serverDetails, manifestPath) if biErr := apmcommon.CollectAndSavePublishBuildInfo(manifestPath, owner, repoName, c.serverDetails, c.buildConfiguration); biErr != nil { log.Warn("apm publish completed, but build info recording failed:", biErr.Error()) diff --git a/agent/apm/commands/publish/publish_test.go b/agent/apm/commands/publish/publish_test.go index a335c639..59fed14e 100644 --- a/agent/apm/commands/publish/publish_test.go +++ b/agent/apm/commands/publish/publish_test.go @@ -6,46 +6,36 @@ import ( "github.com/stretchr/testify/assert" ) -func TestWithPackageFlag(t *testing.T) { +func TestRequirePackageFlag(t *testing.T) { tests := []struct { - name string - args []string - want []string + name string + args []string + wantErr bool }{ + {name: "--package with space form present", args: []string{"--package", "jfrog/proj3"}, wantErr: false}, + {name: "--package= form present", args: []string{"--package=jfrog/proj3"}, wantErr: false}, + {name: "--package present alongside other flags", args: []string{"--dry-run", "--package", "jfrog/proj3"}, wantErr: false}, + {name: "missing --package entirely", args: []string{"--dry-run"}, wantErr: true}, + {name: "empty args", args: []string{}, wantErr: true}, { - name: "bare positional package spec gets promoted", - args: []string{"jfrog/proj3"}, - want: []string{"--package", "jfrog/proj3"}, - }, - { - name: "positional spec mixed with flags", - args: []string{"--dry-run", "jfrog/proj3"}, - want: []string{"--package", "jfrog/proj3", "--dry-run"}, - }, - { - name: "already has --package flag - untouched", - args: []string{"--package", "jfrog/proj3"}, - want: []string{"--package", "jfrog/proj3"}, - }, - { - name: "already has --package= form - untouched", - args: []string{"--package=jfrog/proj3"}, - want: []string{"--package=jfrog/proj3"}, - }, - { - name: "no positional args - untouched", - args: []string{"--dry-run"}, - want: []string{"--dry-run"}, + name: "bare positional package spec is no longer auto-promoted - requires an explicit error", + args: []string{"jfrog/proj3"}, + wantErr: true, }, { - name: "empty args", - args: []string{}, - want: []string{}, + name: "a value-taking flag's value is never mistaken for --package - still requires it explicitly", + args: []string{"--zip", "foo.zip", "jfrog/proj3"}, + wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, withPackageFlag(tt.args)) + err := requirePackageFlag(tt.args) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } }) } } diff --git a/agent/apm/common/apmenv.go b/agent/apm/common/apmenv.go index 731d0e47..2f58e9d9 100644 --- a/agent/apm/common/apmenv.go +++ b/agent/apm/common/apmenv.go @@ -208,6 +208,10 @@ func ensureExperimentalFlagEnabled(realHome string, existing *apmConfigJSON) err // entry, written by 'jf setup agent-apm', or apm.yml's own registries: block); if none matches // serverDetails's host, this returns an error rather than silently running apm unauthenticated. func BuildApmEnv(serverDetails *config.ServerDetails, manifestPath string) ([]string, error) { + if serverDetails == nil { + return nil, fmt.Errorf("server details are required to build the APM environment") + } + realHome, existing, err := loadExistingApmConfig() if err != nil { return nil, err @@ -269,6 +273,10 @@ func readApmConfig(path string) (*apmConfigJSON, error) { return &cfg, nil } +// writeApmConfig writes cfg via a temp-file-plus-rename so a crash mid-write, or two `jf agent +// apm` invocations racing on the same home directory (e.g. parallel CI jobs on a shared runner), +// can never truncate or corrupt the user's real, persistent APM config - os.Rename is atomic on +// the same filesystem, so readers always see either the old file or the fully-written new one. func writeApmConfig(tmpHome string, cfg *apmConfigJSON) error { apmDir := filepath.Join(tmpHome, ".apm") if err := os.MkdirAll(apmDir, 0700); err != nil { @@ -278,14 +286,48 @@ func writeApmConfig(tmpHome string, cfg *apmConfigJSON) error { if err != nil { return fmt.Errorf("marshal APM config: %w", err) } - return os.WriteFile(filepath.Join(apmDir, "config.json"), data, 0600) + + tmpFile, err := os.CreateTemp(apmDir, "config-*.json.tmp") // 0600 by default + if err != nil { + return fmt.Errorf("create temp APM config: %w", err) + } + tmpPath := tmpFile.Name() + // Best-effort teardown: a no-op once the rename below succeeds (nothing left to remove); on + // any earlier failure it clears the leftover temp file, and even if that removal itself + // fails, the result is just a harmless stray file under .apm/, not a correctness issue. + defer func() { _ = os.Remove(tmpPath) }() + + if _, err = tmpFile.Write(data); err != nil { + _ = tmpFile.Close() // close-on-error: the write error above already explains the failure + return fmt.Errorf("write temp APM config: %w", err) + } + if err = tmpFile.Close(); err != nil { + return fmt.Errorf("close temp APM config: %w", err) + } + if err = os.Rename(tmpPath, filepath.Join(apmDir, "config.json")); err != nil { + return fmt.Errorf("rename temp APM config into place: %w", err) + } + return nil +} + +// SanitizeLogValue strips newline/carriage-return characters from a value before it's +// concatenated into a log message, so CLI-controlled input (e.g. a subcommand name) can't forge +// fake log lines (CWE-117) by embedding its own line breaks. +func SanitizeLogValue(value string) string { + return strings.NewReplacer("\n", "", "\r", "").Replace(value) } // RunApmCommand runs "apm " with the provided environment. // If env is nil, the current process environment is used. +// Deliberately logs only the subcommand name, never args: args frequently carry secrets here +// (ConfigureApmRegistryPersistent passes a raw registry token, and BuildRegistryEntry can embed +// basic-auth credentials in a URL argument) - logging the full joined argument list at Debug +// level would write plaintext credentials into log output that CI systems/log aggregators may +// capture and retain far more durably than "never written to a file" (the runtime auth path's +// own goal) accounts for. func RunApmCommand(env []string, subcmd string, args []string) error { + log.Debug(fmt.Sprintf("Running: apm %s", SanitizeLogValue(subcmd))) allArgs := append([]string{subcmd}, args...) - log.Debug(fmt.Sprintf("Running: apm %s", strings.Join(allArgs, " "))) cmd := exec.Command("apm", allArgs...) // #nosec G204 -- args are this same invocation's own CLI arguments, forwarded verbatim by design (this is the passthrough wrapper); no shell is invoked and no privilege boundary is crossed if env != nil { cmd.Env = env @@ -301,7 +343,9 @@ func RunApmCommand(env []string, subcmd string, args []string) error { // ConfigureApmRegistryPersistent configures the user's real ~/.apm/config.json using apm's own // `apm experimental enable registries` and `apm config set` commands — never by writing the -// file directly. This is the one allowed persistent write — called only by `jf setup agent-apm`. +// file directly. This is the one allowed persistent write of registry credentials/URLs — called +// only by `jf setup agent-apm` (a separate, narrower exception exists for the non-secret +// experimental.registries flag — see ensureExperimentalFlagEnabled above). // repoName is always resolved by the shared `jf setup ` interactive repo picker before // this is called, so it's never empty here. Every other registry already in the file, and any // other top-level key (e.g. default_client), is left alone automatically — apm's own config-set diff --git a/agent/apm/common/build_info.go b/agent/apm/common/build_info.go index fd45c645..580aa87a 100644 --- a/agent/apm/common/build_info.go +++ b/agent/apm/common/build_info.go @@ -6,7 +6,6 @@ import ( "path/filepath" "github.com/jfrog/build-info-go/entities" - artUtils "github.com/jfrog/jfrog-cli-artifactory/artifactory/utils" artCoreUtils "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils" buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build" "github.com/jfrog/jfrog-cli-core/v2/utils/config" @@ -141,7 +140,7 @@ func SavePublishBuildInfo(owner, name, version string, checksum entities.Checksu } // CollectAndSavePublishBuildInfo reads the package name/version from apm.yml, looks up the -// real checksum of the just-published artifact via AQL, and records it in build-info. +// real checksum of the just-published artifact via an HTTP HEAD, and records it in build-info. // Runs only when build info collection is enabled. func CollectAndSavePublishBuildInfo(manifestPath, owner, repoName string, serverDetails *config.ServerDetails, buildConfig *buildUtils.BuildConfiguration) error { collectBuildInfo, err := buildConfig.IsCollectBuildInfo() @@ -164,8 +163,10 @@ func CollectAndSavePublishBuildInfo(manifestPath, owner, repoName string, server return SavePublishBuildInfo(owner, manifest.Name, manifest.Version, checksum, repoName, buildConfig) } -// lookupPublishedArtifactChecksum queries AQL for the artifact apm publish just uploaded, by its -// known repo-relative path — confirmed live that AQL indexes agentpackages repos correctly. +// lookupPublishedArtifactChecksum issues an HTTP HEAD against the just-published artifact's own +// download URL and reads its checksum straight from Artifactory's X-Checksum-* response headers — +// the same mechanism ResolveChecksums (checksums.go) already uses for dependency checksums, and the +// same download-URL shape apm.lock.yaml's resolved_url entries use. // Returns an empty Checksum (not an error) if the repo/owner are unknown or the lookup fails, // since a missing checksum shouldn't fail an already-successful publish. func lookupPublishedArtifactChecksum(owner, name, version, repoName string, serverDetails *config.ServerDetails) entities.Checksum { @@ -178,22 +179,14 @@ func lookupPublishedArtifactChecksum(owner, name, version, repoName string, serv return entities.Checksum{} } - dirPath := owner + "/" + name - fileName := name + "-" + version + ".zip" - query := fmt.Sprintf( - `items.find({"repo":"%s","path":"%s","name":"%s"}).include("actual_sha1","sha256","actual_md5")`, - repoName, dirPath, fileName) - - results, err := artUtils.ExecuteAqlQuery(servicesManager, query) + downloadURL := AgentPackagesBaseURL(serverDetails, repoName) + "v1/packages/" + owner + "/" + name + "/versions/" + version + "/download" + clientDetails := servicesManager.GetConfig().GetServiceDetails().CreateHttpClientDetails() + fileDetails, _, err := servicesManager.Client().GetRemoteFileDetails(downloadURL, &clientDetails) if err != nil { - log.Debug("apm publish: checksum AQL lookup failed:", err.Error()) - return entities.Checksum{} - } - if len(results) == 0 { - log.Debug(fmt.Sprintf("apm publish: no AQL result for %s/%s — checksum will be empty", dirPath, fileName)) + log.Debug(fmt.Sprintf("apm publish: checksum HEAD lookup failed for %s: %s", downloadURL, err.Error())) return entities.Checksum{} } - return entities.Checksum{Sha1: results[0].Actual_Sha1, Sha256: results[0].Sha256, Md5: results[0].Actual_Md5} + return fileDetails.Checksum } func derivedModuleID(manifestPath string) string { diff --git a/agent/apm/common/checksums.go b/agent/apm/common/checksums.go index 1af1647b..c1c84cbe 100644 --- a/agent/apm/common/checksums.go +++ b/agent/apm/common/checksums.go @@ -24,8 +24,6 @@ const headWorkerCount = 15 // ocicontainer/docker already uses for artifacts it can't resolve via AQL. // 3. Fallback: use lockfile SHA-256 only when the HEAD request finds no match. func ResolveChecksums(deps []ResolvedDep, serverDetails *config.ServerDetails, buildConfig *buildUtils.BuildConfiguration) (map[string]entities.Checksum, error) { - checksumMap := make(map[string]entities.Checksum) - servicesManager, err := coreArtUtils.CreateServiceManager(serverDetails, -1, 0, false) if err != nil { return nil, err @@ -41,14 +39,7 @@ func ResolveChecksums(deps []ResolvedDep, serverDetails *config.ServerDetails, b } cachedChecksums := artUtils.DependenciesToChecksumMap(prevDeps) - var uncached []ResolvedDep - for _, dep := range deps { - if checksum, ok := cachedChecksums[dep.ID]; ok { - checksumMap[dep.ID] = checksum - } else { - uncached = append(uncached, dep) - } - } + checksumMap, uncached := selectCachedAndUncached(deps, cachedChecksums) log.Info(fmt.Sprintf("Checksum resolution: %d cached, resolving %d from Artifactory.", len(deps)-len(uncached), len(uncached))) @@ -57,14 +48,43 @@ func ResolveChecksums(deps []ResolvedDep, serverDetails *config.ServerDetails, b } headResults := resolveChecksumsByHead(uncached, servicesManager) + for id, checksum := range applyHeadResultsOrLockfileFallback(uncached, headResults) { + checksumMap[id] = checksum + } + return checksumMap, nil +} + +// selectCachedAndUncached is the tier-1-vs-tier-2 decision: which dependencies already have a +// checksum from the previous build's cache, and which still need a HEAD lookup. Pulled out on +// its own, taking plain maps/slices rather than a live ArtifactoryServicesManager, so this +// selection rule is unit-testable without a real Artifactory connection. +func selectCachedAndUncached(deps []ResolvedDep, cachedChecksums map[string]entities.Checksum) (cached map[string]entities.Checksum, uncached []ResolvedDep) { + cached = make(map[string]entities.Checksum) + for _, dep := range deps { + if checksum, ok := cachedChecksums[dep.ID]; ok { + cached[dep.ID] = checksum + } else { + uncached = append(uncached, dep) + } + } + return cached, uncached +} + +// applyHeadResultsOrLockfileFallback is the tier-2-vs-tier-3 decision: for every dependency that +// missed the build cache, use its HEAD-request checksum if one came back, else fall back to the +// lockfile's own SHA-256 (dependencies with neither are simply omitted - no checksum recorded). +// Pulled out on its own, taking a plain results map rather than making the HTTP calls itself, so +// this selection rule is unit-testable without a real HTTP client. +func applyHeadResultsOrLockfileFallback(uncached []ResolvedDep, headResults map[string]entities.Checksum) map[string]entities.Checksum { + resolved := make(map[string]entities.Checksum, len(uncached)) for _, dep := range uncached { if checksum, ok := headResults[dep.ID]; ok { - checksumMap[dep.ID] = checksum + resolved[dep.ID] = checksum } else if dep.SHA256 != "" { - checksumMap[dep.ID] = entities.Checksum{Sha256: dep.SHA256} + resolved[dep.ID] = entities.Checksum{Sha256: dep.SHA256} } } - return checksumMap, nil + return resolved } // resolveChecksumsByHead issues one HTTP HEAD per dependency against its resolved_url and reads diff --git a/agent/apm/common/checksums_test.go b/agent/apm/common/checksums_test.go new file mode 100644 index 00000000..80caff57 --- /dev/null +++ b/agent/apm/common/checksums_test.go @@ -0,0 +1,77 @@ +package apmcommon + +import ( + "testing" + + "github.com/jfrog/build-info-go/entities" + "github.com/stretchr/testify/assert" +) + +func TestSelectCachedAndUncached(t *testing.T) { + deps := []ResolvedDep{ + {ID: "a/b:1.0.0"}, + {ID: "c/d:2.0.0"}, + {ID: "e/f:3.0.0"}, + } + cachedChecksums := map[string]entities.Checksum{ + "a/b:1.0.0": {Sha256: "cached-sha256"}, + } + + cached, uncached := selectCachedAndUncached(deps, cachedChecksums) + + assert.Equal(t, map[string]entities.Checksum{"a/b:1.0.0": {Sha256: "cached-sha256"}}, cached) + assert.Equal(t, []ResolvedDep{{ID: "c/d:2.0.0"}, {ID: "e/f:3.0.0"}}, uncached) +} + +func TestSelectCachedAndUncached_NoneCached(t *testing.T) { + deps := []ResolvedDep{{ID: "a/b:1.0.0"}, {ID: "c/d:2.0.0"}} + + cached, uncached := selectCachedAndUncached(deps, map[string]entities.Checksum{}) + + assert.Empty(t, cached) + assert.Equal(t, deps, uncached) +} + +func TestSelectCachedAndUncached_AllCached(t *testing.T) { + deps := []ResolvedDep{{ID: "a/b:1.0.0"}, {ID: "c/d:2.0.0"}} + cachedChecksums := map[string]entities.Checksum{ + "a/b:1.0.0": {Sha256: "s1"}, + "c/d:2.0.0": {Sha256: "s2"}, + } + + cached, uncached := selectCachedAndUncached(deps, cachedChecksums) + + assert.Len(t, cached, 2) + assert.Empty(t, uncached) +} + +func TestApplyHeadResultsOrLockfileFallback_HeadHit(t *testing.T) { + uncached := []ResolvedDep{{ID: "a/b:1.0.0", SHA256: "lockfile-sha256"}} + headResults := map[string]entities.Checksum{ + "a/b:1.0.0": {Sha1: "head-sha1", Sha256: "head-sha256", Md5: "head-md5"}, + } + + resolved := applyHeadResultsOrLockfileFallback(uncached, headResults) + + // HEAD result wins outright over the lockfile's own SHA-256 when both are available. + assert.Equal(t, entities.Checksum{Sha1: "head-sha1", Sha256: "head-sha256", Md5: "head-md5"}, resolved["a/b:1.0.0"]) +} + +func TestApplyHeadResultsOrLockfileFallback_FallsBackToLockfileSHA256(t *testing.T) { + uncached := []ResolvedDep{{ID: "a/b:1.0.0", SHA256: "lockfile-sha256"}} + + resolved := applyHeadResultsOrLockfileFallback(uncached, map[string]entities.Checksum{}) + + // No HEAD result at all -> lockfile SHA-256 only, sha1/md5 stay empty. + assert.Equal(t, entities.Checksum{Sha256: "lockfile-sha256"}, resolved["a/b:1.0.0"]) +} + +func TestApplyHeadResultsOrLockfileFallback_NoChecksumAtAll(t *testing.T) { + uncached := []ResolvedDep{{ID: "a/b:1.0.0"}} // no SHA256 from the lockfile either + + resolved := applyHeadResultsOrLockfileFallback(uncached, map[string]entities.Checksum{}) + + // Neither tier has anything - dependency is simply omitted, not recorded with a zero-value checksum. + _, found := resolved["a/b:1.0.0"] + assert.False(t, found) +} diff --git a/agent/apm/common/dependency_resolver.go b/agent/apm/common/dependency_resolver.go index f1d3fdeb..9c02dfbc 100644 --- a/agent/apm/common/dependency_resolver.go +++ b/agent/apm/common/dependency_resolver.go @@ -1,16 +1,37 @@ package apmcommon import ( + "context" "encoding/json" "fmt" "os/exec" "path/filepath" "strings" + "sync" + "time" "github.com/jfrog/build-info-go/entities" "github.com/jfrog/jfrog-client-go/utils/log" ) +// depsWhyWorkerCount bounds how many `apm deps why` subprocesses run concurrently - mirrors +// headWorkerCount in checksums.go, the same bounded-concurrency budget for a similar +// per-dependency subprocess/request fan-out. +const depsWhyWorkerCount = 15 + +// depsWhyTimeout bounds a single `apm deps why` subprocess. Without it, a hang (not a failure) +// would block forever and the "best-effort, falls back to prod scope" guarantee below would +// never actually trigger. +const depsWhyTimeout = 30 * time.Second + +// Dependency scope names. "prod" matches the direct-dependency label the newer sibling +// FlexPack integrations (Alpine's AlpineScopeProd, Cargo's "prod") converged on, rather than +// "runtime" (the older, now-minority convention nix alone still uses). +const ( + apmScopeProd = "prod" + apmScopeTransitive = "transitive" +) + // ResolvedDep holds a single APM registry dependency ready for build-info. type ResolvedDep struct { ID string // "owner/repo:version" @@ -22,6 +43,9 @@ type ResolvedDep struct { } // ResolveDependencies reads the lockfile and returns only registry-sourced dependencies. +// Resolves each dependency's scope/requestedBy concurrently (bounded by depsWhyWorkerCount), +// since each one spawns its own `apm deps why` subprocess and a large lockfile would otherwise +// pay subprocess-startup + I/O cost sequentially, one dependency at a time. func ResolveDependencies(lockfilePath string) ([]ResolvedDep, error) { lockfile, err := LoadLockFile(lockfilePath) if err != nil { @@ -29,19 +53,29 @@ func ResolveDependencies(lockfilePath string) ([]ResolvedDep, error) { } workingDir := filepath.Dir(lockfilePath) + packages := lockfile.RegistryPackages() + deps := make([]ResolvedDep, len(packages)) - var deps []ResolvedDep - for _, pkg := range lockfile.RegistryPackages() { - scopes, requestedBy := resolveScopeAndRequestedBy(workingDir, pkg.RepoURL) - deps = append(deps, ResolvedDep{ - ID: pkg.DepID(), - RepoURL: pkg.RepoURL, - SHA256: SHA256Hex(pkg.ResolvedHash), - ResolvedURL: pkg.ResolvedURL, - Scopes: scopes, - RequestedBy: requestedBy, - }) + var wg sync.WaitGroup + sem := make(chan struct{}, depsWhyWorkerCount) + for i, pkg := range packages { + wg.Add(1) + sem <- struct{}{} + go func(i int, pkg ApmLockedPackage) { + defer wg.Done() + defer func() { <-sem }() + scopes, requestedBy := resolveScopeAndRequestedBy(workingDir, pkg.RepoURL) + deps[i] = ResolvedDep{ + ID: pkg.DepID(), + RepoURL: pkg.RepoURL, + SHA256: SHA256Hex(pkg.ResolvedHash), + ResolvedURL: pkg.ResolvedURL, + Scopes: scopes, + RequestedBy: requestedBy, + } + }(i, pkg) } + wg.Wait() return deps, nil } @@ -86,7 +120,7 @@ type apmDepsWhyResult struct { // RequestedBy chain), which a single resolved_by string in the lockfile can't represent. // // Best-effort: if apm isn't on PATH or the command fails for any reason, this falls back to -// runtime scope with no requestedBy rather than failing the whole build-info collection - the +// prod scope with no requestedBy rather than failing the whole build-info collection - the // dependency's id/checksum are still correct either way. func resolveScopeAndRequestedBy(workingDir, repoURL string) (scopes []string, requestedBy [][]string) { // repoURL comes from apm.lock.yaml, not a trusted CLI arg - a tampered lockfile could set @@ -94,16 +128,18 @@ func resolveScopeAndRequestedBy(workingDir, repoURL string) (scopes []string, re // below. Real repo_url values are always "owner/repo"; reject anything flag-shaped instead // of passing it through. if strings.HasPrefix(repoURL, "-") { - log.Debug(fmt.Sprintf("Refusing to run apm deps why for suspicious repo_url %q, defaulting to runtime scope", repoURL)) - return []string{"runtime"}, nil + log.Debug(fmt.Sprintf("Refusing to run apm deps why for suspicious repo_url %q, defaulting to prod scope", repoURL)) + return []string{apmScopeProd}, nil } - cmd := exec.Command("apm", "deps", "why", repoURL, "--json") // #nosec G204 -- repoURL is validated above to reject flag-shaped values; exec.Command never invokes a shell, so no injection vector remains + ctx, cancel := context.WithTimeout(context.Background(), depsWhyTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, "apm", "deps", "why", repoURL, "--json") // #nosec G204 -- repoURL is validated above to reject flag-shaped values; exec.Command never invokes a shell, so no injection vector remains cmd.Dir = workingDir out, err := cmd.Output() if err != nil { - log.Debug(fmt.Sprintf("apm deps why %s failed, defaulting to runtime scope: %s", repoURL, err)) - return []string{"runtime"}, nil + log.Debug(fmt.Sprintf("apm deps why %s failed, defaulting to prod scope: %s", repoURL, err)) + return []string{apmScopeProd}, nil } return parseDepsWhyOutput(out, repoURL) } @@ -114,12 +150,12 @@ func resolveScopeAndRequestedBy(workingDir, repoURL string) (scopes []string, re func parseDepsWhyOutput(out []byte, repoURL string) (scopes []string, requestedBy [][]string) { var result apmDepsWhyResult if err := json.Unmarshal(out, &result); err != nil { - log.Debug(fmt.Sprintf("could not parse apm deps why %s output, defaulting to runtime scope: %s", repoURL, err)) - return []string{"runtime"}, nil + log.Debug(fmt.Sprintf("could not parse apm deps why %s output, defaulting to prod scope: %s", repoURL, err)) + return []string{apmScopeProd}, nil } if result.Package.IsDirect { - return []string{"runtime"}, nil + return []string{apmScopeProd}, nil } for _, path := range result.Paths { @@ -136,5 +172,5 @@ func parseDepsWhyOutput(out []byte, repoURL string) (scopes []string, requestedB } requestedBy = append(requestedBy, chain) } - return []string{"transitive"}, requestedBy + return []string{apmScopeTransitive}, requestedBy } diff --git a/agent/apm/common/dependency_resolver_test.go b/agent/apm/common/dependency_resolver_test.go index a375ee30..b699f0e8 100644 --- a/agent/apm/common/dependency_resolver_test.go +++ b/agent/apm/common/dependency_resolver_test.go @@ -9,7 +9,7 @@ import ( func TestResolveScopeAndRequestedBy_RejectsFlagShapedRepoURL(t *testing.T) { scopes, requestedBy := resolveScopeAndRequestedBy(t.TempDir(), "--global") - assert.Equal(t, []string{"runtime"}, scopes) + assert.Equal(t, []string{apmScopeProd}, scopes) assert.Empty(t, requestedBy) } @@ -19,7 +19,7 @@ func TestParseDepsWhyOutput_DirectDependency(t *testing.T) { "paths": [{"chain": [{"is_direct": true, "repo_url": "uday/pkg-consumer"}]}] }`) scopes, requestedBy := parseDepsWhyOutput(out, "uday/pkg-consumer") - assert.Equal(t, []string{"runtime"}, scopes) + assert.Equal(t, []string{apmScopeProd}, scopes) assert.Empty(t, requestedBy) } @@ -49,12 +49,22 @@ func TestParseDepsWhyOutput_MultipleParentPaths(t *testing.T) { assert.Equal(t, [][]string{{"a/pkg"}, {"b/pkg"}}, requestedBy) } -func TestParseDepsWhyOutput_MalformedJSONFallsBackToRuntime(t *testing.T) { +func TestParseDepsWhyOutput_MalformedJSONFallsBackToProd(t *testing.T) { scopes, requestedBy := parseDepsWhyOutput([]byte("not json"), "uday/pkg-base") - assert.Equal(t, []string{"runtime"}, scopes) + assert.Equal(t, []string{apmScopeProd}, scopes) assert.Empty(t, requestedBy) } +// TestApmScopeConstantsAreStable guards the literal scope strings against accidental drift - +// they're part of the public build-info contract (consumed by Xray, the UI, etc.), and matching +// the naming the newer sibling FlexPack integrations converged on (Alpine's own +// TestAlpineScopeConstantsAreStable checks the identical pair) is the reason "prod" was chosen +// over the older, now-minority "runtime" convention. +func TestApmScopeConstantsAreStable(t *testing.T) { + assert.Equal(t, "prod", apmScopeProd) + assert.Equal(t, "transitive", apmScopeTransitive) +} + // TestParseDepsWhyOutput_PathCountCappedAtMax verifies the fan-in cap: a widely-shared // dependency (e.g. a diamond dependency's base, reachable through many parents) reports at // most requestedByMaxPaths distinct paths, matching the same cap golang.go/yarn.go/ From f218bf866b180b80a453958899ea0c2889671836 Mon Sep 17 00:00:00 2001 From: Uday Date: Sat, 1 Aug 2026 20:34:01 +0530 Subject: [PATCH 11/40] Document apm passthrough capability and fix its --help handling jf agent apm --help only listed install/publish/update, with no mention that every other apm command (deps, lock, marketplace, mcp, audit, doctor, view, etc.) is also reachable through the same authenticated passthrough. Added an AIDescription for the top-level apm command documenting this. Also fixes a real bug: `jf agent apm --help` (e.g. `jf agent apm deps --help`) never showed help - it resolved server auth and tried to actually run the command, failing with a confusing registry error instead. RunApmPassthroughDefault only checked whether the subcommand name itself was a help flag, never scanning the rest of the args. Now detects --help anywhere in the args and forwards to apm's own subcommand help directly, matching how install/publish/update already behave. Co-Authored-By: Claude Sonnet 5 --- agent/apm/cli/help.go | 39 +++++++++++++++++++ agent/apm/commands/passthrough/passthrough.go | 6 +++ agent/cli/cli.go | 9 +++-- 3 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 agent/apm/cli/help.go diff --git a/agent/apm/cli/help.go b/agent/apm/cli/help.go new file mode 100644 index 00000000..ef862225 --- /dev/null +++ b/agent/apm/cli/help.go @@ -0,0 +1,39 @@ +package cli + +func GetDescription() string { + return "Agent Package Manager (APM) commands with JFrog Artifactory authentication." +} + +func GetAIDescription() string { + return `Run any apm command against JFrog Artifactory-backed registries, with credentials +injected automatically - no apm config set or manual token handling required. + +Build-info commands (dedicated subcommands, listed under COMMANDS below): + jf agent apm install Install dependencies from apm.yml / apm.lock.yaml. + jf agent apm publish Publish a package to an agentpackages repository. + jf agent apm update Refresh dependencies to their latest matching refs. +These three collect and can record build-info (--build-name/--build-number). + +Every other apm command also works here, with the same authenticated registry access but +no build-info collection - just run it as "jf agent apm ", e.g.: + jf agent apm lock Resolve dependencies and write apm.lock.yaml only. + jf agent apm deps why Show why a dependency is present (direct/transitive). + jf agent apm outdated Show outdated locked dependencies. + jf agent apm audit Scan installed packages / validate lockfile integrity. + jf agent apm doctor Diagnose environment problems (git, network, auth). + jf agent apm view View package metadata or list remote versions. + jf agent apm marketplace ... Manage marketplaces for discovery and governance. + jf agent apm mcp ... Discover, inspect, and install MCP servers. +Run "apm --help" to see the full list of commands apm itself supports - all of them are +reachable this way. "jf agent apm --help" shows that command's own apm-native help. + +Prerequisites: +- apm CLI installed and in PATH. +- Registry configured via jf setup agent-apm (persistent) or apm.yml's registries: block. + +Environment: +- Credentials injected via APM_REGISTRY_TOKEN_, APM_REGISTRY_USER_, APM_REGISTRY_PASS_. +- Registry configuration sourced from ~/.apm/config.json (set by jf setup agent-apm). + +Related: jf setup agent-apm` +} diff --git a/agent/apm/commands/passthrough/passthrough.go b/agent/apm/commands/passthrough/passthrough.go index 2fbb75d7..fe43a524 100644 --- a/agent/apm/commands/passthrough/passthrough.go +++ b/agent/apm/commands/passthrough/passthrough.go @@ -62,6 +62,12 @@ func RunApmPassthroughDefault(c *components.Context) error { if apmcommon.IsHelpRequest([]string{subcmd}) { return apmcommon.RunApmCommand(nil, "--help", nil) } + // e.g. "jf agent apm deps --help" - show apm's own help for that subcommand rather than + // falling through to ExecWithPackageManager, which would resolve server details and inject + // auth env for a command that never actually needs them. + if apmcommon.IsHelpRequest(c.Arguments[1:]) { + return apmcommon.RunApmCommand(nil, subcmd, []string{"--help"}) + } serverDetails, err := agentcommon.GetServerDetails(c) if err != nil { diff --git a/agent/cli/cli.go b/agent/cli/cli.go index 0d61be0f..7fa9bc90 100644 --- a/agent/cli/cli.go +++ b/agent/cli/cli.go @@ -23,10 +23,11 @@ func GetCommands() []components.Command { Subcommands: skillscli.GetSubCommands(), }, { - Name: "apm", - Description: "Agent Package Manager (APM) commands with JFrog Artifactory authentication.", - Subcommands: apmcli.GetSubCommands(), - Action: passthrough.RunApmPassthroughDefault, + Name: "apm", + Description: apmcli.GetDescription(), + AIDescription: apmcli.GetAIDescription(), + Subcommands: apmcli.GetSubCommands(), + Action: passthrough.RunApmPassthroughDefault, }, } } From 93293ae95cb52e5ba2f59d1e03ee4527b5d41fcd Mon Sep 17 00:00:00 2001 From: Uday Date: Sat, 1 Aug 2026 20:42:56 +0530 Subject: [PATCH 12/40] Isolate AgentPackages const from the PackageTypes alignment group Appending it directly into the existing gofmt-aligned PackageTypes block forced every sibling line's = column to shift right to match its longer name, causing unrelated git-blame churn on lines nobody touched (PR #518 review nit). Moving it into its own single-entry group keeps the diff to the one line that actually changed. Co-Authored-By: Claude Sonnet 5 --- artifactory/commands/repository/template.go | 65 +++++++++++---------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/artifactory/commands/repository/template.go b/artifactory/commands/repository/template.go index 5f5619c0..f2c7eae2 100644 --- a/artifactory/commands/repository/template.go +++ b/artifactory/commands/repository/template.go @@ -131,38 +131,39 @@ const ( Federated = "federated" // PackageTypes - Generic = "generic" - Maven = "maven" - Gradle = "gradle" - Ivy = "ivy" - Sbt = "sbt" - Helm = "helm" - Cocoapods = "cocoapods" - Opkg = "opkg" - Rpm = "rpm" - Nuget = "nuget" - Cran = "cran" - Gems = "gems" - Npm = "npm" - Bower = "bower" - Debian = "debian" - Composer = "composer" - Pypi = "pypi" - Docker = "docker" - Vagrant = "vagrant" - Gitlfs = "gitlfs" - Go = "go" - Yum = "yum" - Conan = "conan" - Chef = "chef" - Puppet = "puppet" - Vcs = "vcs" - Alpine = "alpine" - Conda = "conda" - P2 = "p2" - Swift = "swift" - Terraform = "terraform" - Cargo = "cargo" + Generic = "generic" + Maven = "maven" + Gradle = "gradle" + Ivy = "ivy" + Sbt = "sbt" + Helm = "helm" + Cocoapods = "cocoapods" + Opkg = "opkg" + Rpm = "rpm" + Nuget = "nuget" + Cran = "cran" + Gems = "gems" + Npm = "npm" + Bower = "bower" + Debian = "debian" + Composer = "composer" + Pypi = "pypi" + Docker = "docker" + Vagrant = "vagrant" + Gitlfs = "gitlfs" + Go = "go" + Yum = "yum" + Conan = "conan" + Chef = "chef" + Puppet = "puppet" + Vcs = "vcs" + Alpine = "alpine" + Conda = "conda" + P2 = "p2" + Swift = "swift" + Terraform = "terraform" + Cargo = "cargo" + AgentPackages = "agentpackages" // Repo layout Refs From e3d6c568675a0d4fe9ce3a9adcb1ff2c0abff86a Mon Sep 17 00:00:00 2001 From: Uday Date: Sat, 1 Aug 2026 20:55:55 +0530 Subject: [PATCH 13/40] Add agent-apm entry to packageManagerConfigs CI is failing on main's auto-merged PR #520: TestPackageManagerConfigs_ CoversEverySupportedPackageManager and TestConfigScopeNote_ CoversEverySupportedPackageManager both assert every package manager returned by GetSupportedPackageManagersList() has a packageManagerConfigs entry. agent-apm was added to that list by this branch after #520 was written on main, so the two branches never saw each other's addition until the merge surfaced the gap. jf setup agent-apm sets the registry as apm's default via `apm config set`, so - like npm/pip/go - it redirects resolution rather than only storing credentials, and (confirmed earlier this session) apm has no override env for ~/.apm/config.json's location. Co-Authored-By: Claude Sonnet 5 --- artifactory/commands/setup/setup.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/artifactory/commands/setup/setup.go b/artifactory/commands/setup/setup.go index 115d146a..3435f181 100644 --- a/artifactory/commands/setup/setup.go +++ b/artifactory/commands/setup/setup.go @@ -97,6 +97,10 @@ var packageManagerConfigs = map[project.ProjectType]packageManagerConfig{ project.Docker: {location: "your Docker credential store", credentialsOnly: true}, project.Podman: {location: "your Podman credential store", credentialsOnly: true}, project.Helm: {location: "your Helm registry credential store", credentialsOnly: true}, + // ConfigureApmRegistryPersistent (via `apm config set`) always writes to + // ~/.apm/config.json and sets the registry as apm's default, so this redirects + // resolution the same way npm/pip/go do; apm has no override env for the file. + project.AgentApm: {location: "your user-level apm configuration (~/.apm/config.json)"}, } // configScopeNote describes what the command changed and how widely it applies, or From 5dfbbf6fe5146ba6b674767aac8063570fa785fc Mon Sep 17 00:00:00 2001 From: Uday Date: Sat, 1 Aug 2026 21:12:24 +0530 Subject: [PATCH 14/40] Fix 3 new CodeRabbit findings on PR #518 - help.go: the Environment section only credited ~/.apm/config.json as the registry source, contradicting the Prerequisites section two lines above which already documents apm.yml's registries: block as an alternative. - passthrough.go: the --help forwarding fix from the previous commit dropped everything after the top-level subcommand - "jf agent apm deps why --help" ran "apm deps --help" instead of "apm deps why --help". Forward the full remaining arg tail (which already contains the help flag) instead of a synthetic ["--help"]. - checksums.go: a HEAD request that succeeds but returns no X-Checksum-* headers at all was still recorded as a present result, blocking the lockfile SHA-256 fallback the same way a real miss wouldn't. Only treat a HEAD result as authoritative when it actually has a non-empty checksum field; added a test for the empty-but-present case. Co-Authored-By: Claude Sonnet 5 --- agent/apm/cli/help.go | 2 +- agent/apm/commands/passthrough/passthrough.go | 10 ++++++---- agent/apm/common/checksums.go | 17 ++++++++++++----- agent/apm/common/checksums_test.go | 13 +++++++++++++ 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/agent/apm/cli/help.go b/agent/apm/cli/help.go index ef862225..e02b6be8 100644 --- a/agent/apm/cli/help.go +++ b/agent/apm/cli/help.go @@ -33,7 +33,7 @@ Prerequisites: Environment: - Credentials injected via APM_REGISTRY_TOKEN_, APM_REGISTRY_USER_, APM_REGISTRY_PASS_. -- Registry configuration sourced from ~/.apm/config.json (set by jf setup agent-apm). +- Registry configuration sourced from apm.yml's registries: block or ~/.apm/config.json (set by jf setup agent-apm). Related: jf setup agent-apm` } diff --git a/agent/apm/commands/passthrough/passthrough.go b/agent/apm/commands/passthrough/passthrough.go index fe43a524..d9657897 100644 --- a/agent/apm/commands/passthrough/passthrough.go +++ b/agent/apm/commands/passthrough/passthrough.go @@ -62,11 +62,13 @@ func RunApmPassthroughDefault(c *components.Context) error { if apmcommon.IsHelpRequest([]string{subcmd}) { return apmcommon.RunApmCommand(nil, "--help", nil) } - // e.g. "jf agent apm deps --help" - show apm's own help for that subcommand rather than - // falling through to ExecWithPackageManager, which would resolve server details and inject - // auth env for a command that never actually needs them. + // e.g. "jf agent apm deps why --help" - show apm's own help for that (sub)subcommand rather + // than falling through to ExecWithPackageManager, which would resolve server details and + // inject auth env for a command that never actually needs them. Forward the full remaining + // arg tail (not just a bare "--help") so nested commands like "deps why" keep their own + // subcommand and apm shows help for the right level, not just "apm deps --help". if apmcommon.IsHelpRequest(c.Arguments[1:]) { - return apmcommon.RunApmCommand(nil, subcmd, []string{"--help"}) + return apmcommon.RunApmCommand(nil, subcmd, c.Arguments[1:]) } serverDetails, err := agentcommon.GetServerDetails(c) diff --git a/agent/apm/common/checksums.go b/agent/apm/common/checksums.go index c1c84cbe..b729e420 100644 --- a/agent/apm/common/checksums.go +++ b/agent/apm/common/checksums.go @@ -71,14 +71,17 @@ func selectCachedAndUncached(deps []ResolvedDep, cachedChecksums map[string]enti } // applyHeadResultsOrLockfileFallback is the tier-2-vs-tier-3 decision: for every dependency that -// missed the build cache, use its HEAD-request checksum if one came back, else fall back to the -// lockfile's own SHA-256 (dependencies with neither are simply omitted - no checksum recorded). -// Pulled out on its own, taking a plain results map rather than making the HTTP calls itself, so -// this selection rule is unit-testable without a real HTTP client. +// missed the build cache, use its HEAD-request checksum if one came back with an actual checksum +// value, else fall back to the lockfile's own SHA-256 (dependencies with neither are simply +// omitted - no checksum recorded). A HEAD request can succeed (no error, entry present in +// headResults) while still returning an empty Checksum{} - e.g. Artifactory responding without +// any X-Checksum-* headers - and that must not block the lockfile fallback the same way a real +// miss wouldn't. Pulled out on its own, taking a plain results map rather than making the HTTP +// calls itself, so this selection rule is unit-testable without a real HTTP client. func applyHeadResultsOrLockfileFallback(uncached []ResolvedDep, headResults map[string]entities.Checksum) map[string]entities.Checksum { resolved := make(map[string]entities.Checksum, len(uncached)) for _, dep := range uncached { - if checksum, ok := headResults[dep.ID]; ok { + if checksum, ok := headResults[dep.ID]; ok && hasAnyChecksum(checksum) { resolved[dep.ID] = checksum } else if dep.SHA256 != "" { resolved[dep.ID] = entities.Checksum{Sha256: dep.SHA256} @@ -87,6 +90,10 @@ func applyHeadResultsOrLockfileFallback(uncached []ResolvedDep, headResults map[ return resolved } +func hasAnyChecksum(checksum entities.Checksum) bool { + return checksum.Sha1 != "" || checksum.Sha256 != "" || checksum.Md5 != "" +} + // resolveChecksumsByHead issues one HTTP HEAD per dependency against its resolved_url and reads // sha1/md5/sha256 straight from Artifactory's X-Checksum-* response headers. func resolveChecksumsByHead(deps []ResolvedDep, servicesManager artifactory.ArtifactoryServicesManager) map[string]entities.Checksum { diff --git a/agent/apm/common/checksums_test.go b/agent/apm/common/checksums_test.go index 80caff57..fb388bcc 100644 --- a/agent/apm/common/checksums_test.go +++ b/agent/apm/common/checksums_test.go @@ -66,6 +66,19 @@ func TestApplyHeadResultsOrLockfileFallback_FallsBackToLockfileSHA256(t *testing assert.Equal(t, entities.Checksum{Sha256: "lockfile-sha256"}, resolved["a/b:1.0.0"]) } +func TestApplyHeadResultsOrLockfileFallback_HeadHitWithEmptyChecksum_FallsBackToLockfile(t *testing.T) { + uncached := []ResolvedDep{{ID: "a/b:1.0.0", SHA256: "lockfile-sha256"}} + headResults := map[string]entities.Checksum{ + "a/b:1.0.0": {}, // HEAD succeeded but Artifactory returned no X-Checksum-* headers at all + } + + resolved := applyHeadResultsOrLockfileFallback(uncached, headResults) + + // A present-but-empty HEAD result must not block the lockfile fallback the same way a real + // miss wouldn't. + assert.Equal(t, entities.Checksum{Sha256: "lockfile-sha256"}, resolved["a/b:1.0.0"]) +} + func TestApplyHeadResultsOrLockfileFallback_NoChecksumAtAll(t *testing.T) { uncached := []ResolvedDep{{ID: "a/b:1.0.0"}} // no SHA256 from the lockfile either From f28d7aca60af84f69a77013b5b8c4dac81d28ba8 Mon Sep 17 00:00:00 2001 From: Uday Date: Sat, 1 Aug 2026 22:32:35 +0530 Subject: [PATCH 15/40] Deduplicate repeated string literals and trim comments in apm code Extracted repeated magic strings into shared constants: - PackageManagerID ("agent-apm") and CommandNamePrefix ("rt_agent_apm_"), new agent/apm/common/identity.go, used by install/publish/update/passthrough instead of each hardcoding its own copy. - ApmBinaryName ("apm") and HelpFlag ("--help") in apmenv.go, used by RunApmCommand, IsHelpRequest, dependency_resolver.go, and utils.go. - apmConfigDirName/apmConfigFileName (".apm"/"config.json") in apmenv.go, shared between loadExistingApmConfig and writeApmConfig. - apmModuleType ("apm") and apmPackageFileExtension ("zip") in build_info.go, shared with dependency_resolver.go's ToEntitiesDependency. - errBuildInfoNotEnabled, shared between saveInstallBuildInfo and SavePublishBuildInfo. - Per-command apmSubcommand constant in install.go/update.go/publish.go, each referenced from CommandName/Run/RunXxx instead of repeating the subcommand name 3 times per file. Also trimmed comments across the package: shortened several that had grown too long, and removed "prior version did X" / "confirmed live" narrative that only made sense as an explanation of a diff - this code hasn't merged yet, so there's no prior merged state to explain a change against. Co-Authored-By: Claude Sonnet 5 --- agent/apm/cli/cli.go | 7 +- agent/apm/commands/install/install.go | 20 ++--- agent/apm/commands/passthrough/passthrough.go | 23 ++--- agent/apm/commands/publish/publish.go | 32 +++---- agent/apm/commands/update/update.go | 21 +++-- agent/apm/common/apmenv.go | 85 ++++++++----------- agent/apm/common/build_info.go | 43 ++++++---- agent/apm/common/checksums.go | 21 ++--- agent/apm/common/dependency_resolver.go | 48 +++-------- agent/apm/common/identity.go | 7 ++ agent/apm/common/manifest.go | 22 ++--- agent/apm/common/subcommand_options.go | 8 +- agent/apm/common/utils.go | 2 +- 13 files changed, 143 insertions(+), 196 deletions(-) create mode 100644 agent/apm/common/identity.go diff --git a/agent/apm/cli/cli.go b/agent/apm/cli/cli.go index 3a23f7b3..35379238 100644 --- a/agent/apm/cli/cli.go +++ b/agent/apm/cli/cli.go @@ -8,11 +8,8 @@ import ( "github.com/jfrog/jfrog-cli-core/v2/plugins/components" ) -// GetSubCommands returns the leaf commands for `jf agent apm`. -// Commands not listed here fall through to the passthrough handler set on the parent. -// "lock" is deliberately not listed here — it doesn't deploy anything, so there's nothing -// a build actually consumed to report; it's served by the generic passthrough like every -// other read/resolve-only apm command. +// GetSubCommands returns the leaf commands for `jf agent apm`. Commands not listed here (e.g. +// "lock", which resolves but deploys nothing) fall through to the parent's passthrough handler. func GetSubCommands() []components.Command { return []components.Command{ { diff --git a/agent/apm/commands/install/install.go b/agent/apm/commands/install/install.go index 98eda7ab..08432044 100644 --- a/agent/apm/commands/install/install.go +++ b/agent/apm/commands/install/install.go @@ -14,14 +14,12 @@ import ( "github.com/jfrog/jfrog-client-go/utils/log" ) +// apmSubcommand is the apm subcommand this package always drives. +const apmSubcommand = "install" + // ApmInstallCommand runs `apm install` with JFrog Artifactory authentication and collects -// build-info from the resulting apm.lock.yaml. -// -// Unlike passthrough commands, install never accepts --repo: no other package-manager -// integration in this CLI supports declaring a new repository at run time either - they all -// require the one-time `jf setup ` step first (§3, "jf setup agent-apm"). A registry -// must already be declared (via jf setup agent-apm or apm.yml's own registries: block) before -// install can authenticate against it. +// build-info from the resulting apm.lock.yaml. Never accepts --repo; a registry must already be +// declared via jf setup agent-apm or apm.yml's own registries: block. type ApmInstallCommand struct { args []string serverDetails *config.ServerDetails @@ -48,7 +46,7 @@ func (c *ApmInstallCommand) SetBuildConfiguration(buildConfiguration *buildUtils } func (c *ApmInstallCommand) CommandName() string { - return "rt_agent_apm_install" + return apmcommon.CommandNamePrefix + apmSubcommand } func (c *ApmInstallCommand) ServerDetails() (*config.ServerDetails, error) { @@ -58,7 +56,7 @@ func (c *ApmInstallCommand) ServerDetails() (*config.ServerDetails, error) { func (c *ApmInstallCommand) Run() error { log.Info("Running apm install...") - if err := apmcommon.RunApmSubcommandWithAuth("install", c.args, c.serverDetails); err != nil { + if err := apmcommon.RunApmSubcommandWithAuth(apmSubcommand, c.args, c.serverDetails); err != nil { return fmt.Errorf("run apm install: %w", err) } @@ -80,7 +78,7 @@ func (c *ApmInstallCommand) Run() error { // RunInstall is the CLI action handler for `jf agent apm install`. func RunInstall(c *components.Context) error { if apmcommon.IsHelpRequest(c.Arguments) { - return apmcommon.RunApmCommand(nil, "install", []string{"--help"}) + return apmcommon.RunApmCommand(nil, apmSubcommand, []string{apmcommon.HelpFlag}) } opts, err := apmcommon.ExtractApmSubcommandOptions(c.Arguments) @@ -97,5 +95,5 @@ func RunInstall(c *components.Context) error { SetServerDetails(serverDetails). SetBuildConfiguration(opts.BuildConfig) - return commands.ExecWithPackageManager(cmd, "agent-apm") + return commands.ExecWithPackageManager(cmd, apmcommon.PackageManagerID) } diff --git a/agent/apm/commands/passthrough/passthrough.go b/agent/apm/commands/passthrough/passthrough.go index d9657897..743ed981 100644 --- a/agent/apm/commands/passthrough/passthrough.go +++ b/agent/apm/commands/passthrough/passthrough.go @@ -36,7 +36,7 @@ func (c *ApmPassthroughCommand) SetServerDetails(serverDetails *config.ServerDet } func (c *ApmPassthroughCommand) CommandName() string { - return "rt_agent_apm_" + c.subcmd + return apmcommon.CommandNamePrefix + c.subcmd } func (c *ApmPassthroughCommand) ServerDetails() (*config.ServerDetails, error) { @@ -48,25 +48,20 @@ func (c *ApmPassthroughCommand) Run() error { return apmcommon.RunApmSubcommandWithAuth(c.subcmd, c.args, c.serverDetails) } -// RunApmPassthroughDefault handles any `jf agent apm ` where is not one of the -// registered subcommands (install/publish/update). The subcmd is the first element of -// c.Arguments; every remaining element is forwarded to apm untouched. Auth always comes from -// the default configured JFrog server - passthrough takes no flags of its own at all, so there's -// nothing to extract from c.Arguments. +// RunApmPassthroughDefault handles any `jf agent apm ` not among install/publish/update. +// Auth always comes from the default configured JFrog server; passthrough takes no flags of its +// own, so nothing is extracted from c.Arguments beyond the subcommand. func RunApmPassthroughDefault(c *components.Context) error { if len(c.Arguments) == 0 { - return apmcommon.RunApmCommand(nil, "--help", nil) + return apmcommon.RunApmCommand(nil, apmcommon.HelpFlag, nil) } subcmd := c.Arguments[0] if apmcommon.IsHelpRequest([]string{subcmd}) { - return apmcommon.RunApmCommand(nil, "--help", nil) + return apmcommon.RunApmCommand(nil, apmcommon.HelpFlag, nil) } - // e.g. "jf agent apm deps why --help" - show apm's own help for that (sub)subcommand rather - // than falling through to ExecWithPackageManager, which would resolve server details and - // inject auth env for a command that never actually needs them. Forward the full remaining - // arg tail (not just a bare "--help") so nested commands like "deps why" keep their own - // subcommand and apm shows help for the right level, not just "apm deps --help". + // Show help without resolving server/auth, which a help request never needs. Forward the + // full remaining arg tail so nested commands like "deps why" get their own help, not "deps"'s. if apmcommon.IsHelpRequest(c.Arguments[1:]) { return apmcommon.RunApmCommand(nil, subcmd, c.Arguments[1:]) } @@ -81,5 +76,5 @@ func RunApmPassthroughDefault(c *components.Context) error { SetArgs(c.Arguments[1:]). SetServerDetails(serverDetails) - return commands.ExecWithPackageManager(cmd, "agent-apm") + return commands.ExecWithPackageManager(cmd, apmcommon.PackageManagerID) } diff --git a/agent/apm/commands/publish/publish.go b/agent/apm/commands/publish/publish.go index 590fb3b7..93f761e0 100644 --- a/agent/apm/commands/publish/publish.go +++ b/agent/apm/commands/publish/publish.go @@ -15,15 +15,13 @@ import ( "github.com/jfrog/jfrog-client-go/utils/log" ) +// apmSubcommand is the apm subcommand this package always drives. +const apmSubcommand = "publish" + // ApmPublishCommand runs `apm publish` with JFrog Artifactory authentication and records the -// published package in build-info. -// -// Unlike passthrough commands, publish never accepts --repo: no other package-manager -// integration in this CLI supports declaring a new repository at run time either - they all -// require the one-time `jf setup ` step first. A registry must already be declared -// (via jf setup agent-apm or apm.yml's own registries: block) before publish can authenticate -// against it; the repo name used for build-info enrichment is derived from that same -// declaration (see ResolveRepoNameFromRegistry). +// published package in build-info. Never accepts --repo; a registry must already be declared +// via jf setup agent-apm or apm.yml's own registries: block, which also supplies the repo name +// for build-info enrichment (see ResolveRepoNameFromRegistry). type ApmPublishCommand struct { args []string serverDetails *config.ServerDetails @@ -50,20 +48,16 @@ func (c *ApmPublishCommand) SetBuildConfiguration(buildConfiguration *buildUtils } func (c *ApmPublishCommand) CommandName() string { - return "rt_agent_apm_publish" + return apmcommon.CommandNamePrefix + apmSubcommand } func (c *ApmPublishCommand) ServerDetails() (*config.ServerDetails, error) { return c.serverDetails, nil } -// requirePackageFlag returns a clear, jf-level error if --package isn't present in args. -// A prior version auto-promoted a bare positional spec (e.g. "jfrog/proj3") into --package, but -// that heuristic could mistake a value-taking apm flag's value for the package - e.g. in -// "--zip foo.zip acme/pkg", it would grab "foo.zip" (--zip's value) instead of "acme/pkg". Rather -// than track every apm flag that might take a value (and risk the same class of bug again the -// next time apm adds one), --package is required explicitly - removing the ambiguity entirely -// instead of working around it. +// requirePackageFlag returns a clear, jf-level error if --package isn't present in args. It must +// be passed explicitly (not inferred from a bare positional argument), since a positional value +// could be mistaken for a value-taking apm flag's argument (e.g. "--zip foo.zip acme/pkg"). func requirePackageFlag(args []string) error { for _, arg := range args { if arg == "--package" || strings.HasPrefix(arg, "--package=") { @@ -79,7 +73,7 @@ func (c *ApmPublishCommand) Run() error { if err := requirePackageFlag(c.args); err != nil { return err } - if err := apmcommon.RunApmSubcommandWithAuth("publish", c.args, c.serverDetails); err != nil { + if err := apmcommon.RunApmSubcommandWithAuth(apmSubcommand, c.args, c.serverDetails); err != nil { return fmt.Errorf("run apm publish: %w", err) } @@ -122,7 +116,7 @@ func ownerFromArgs(args []string) string { // RunPublish is the CLI action handler for `jf agent apm publish`. func RunPublish(c *components.Context) error { if apmcommon.IsHelpRequest(c.Arguments) { - return apmcommon.RunApmCommand(nil, "publish", []string{"--help"}) + return apmcommon.RunApmCommand(nil, apmSubcommand, []string{apmcommon.HelpFlag}) } opts, err := apmcommon.ExtractApmSubcommandOptions(c.Arguments) @@ -139,5 +133,5 @@ func RunPublish(c *components.Context) error { SetServerDetails(serverDetails). SetBuildConfiguration(opts.BuildConfig) - return commands.ExecWithPackageManager(cmd, "agent-apm") + return commands.ExecWithPackageManager(cmd, apmcommon.PackageManagerID) } diff --git a/agent/apm/commands/update/update.go b/agent/apm/commands/update/update.go index fe33c9b9..6d442f80 100644 --- a/agent/apm/commands/update/update.go +++ b/agent/apm/commands/update/update.go @@ -14,14 +14,13 @@ import ( "github.com/jfrog/jfrog-client-go/utils/log" ) +// apmSubcommand is the apm subcommand this package always drives. +const apmSubcommand = "update" + // ApmUpdateCommand runs `apm update` with JFrog Artifactory authentication and collects -// build-info from the resulting apm.lock.yaml, reusing install's exact reader. -// -// Unlike passthrough commands, update never accepts --repo: no other package-manager -// integration in this CLI supports declaring a new repository at run time either - they all -// require the one-time `jf setup ` step first. A registry must already be declared -// (via jf setup agent-apm or apm.yml's own registries: block) before update can authenticate -// against it. +// build-info from the resulting apm.lock.yaml, reusing install's exact reader. Never accepts +// --repo; a registry must already be declared via jf setup agent-apm or apm.yml's registries: +// block. type ApmUpdateCommand struct { args []string serverDetails *config.ServerDetails @@ -48,7 +47,7 @@ func (c *ApmUpdateCommand) SetBuildConfiguration(buildConfiguration *buildUtils. } func (c *ApmUpdateCommand) CommandName() string { - return "rt_agent_apm_update" + return apmcommon.CommandNamePrefix + apmSubcommand } func (c *ApmUpdateCommand) ServerDetails() (*config.ServerDetails, error) { @@ -62,7 +61,7 @@ func (c *ApmUpdateCommand) ServerDetails() (*config.ServerDetails, error) { func (c *ApmUpdateCommand) Run() error { log.Info("Running apm update...") - if err := apmcommon.RunApmSubcommandWithAuth("update", c.args, c.serverDetails); err != nil { + if err := apmcommon.RunApmSubcommandWithAuth(apmSubcommand, c.args, c.serverDetails); err != nil { return fmt.Errorf("run apm update: %w", err) } @@ -84,7 +83,7 @@ func (c *ApmUpdateCommand) Run() error { // RunUpdate is the CLI action handler for `jf agent apm update`. func RunUpdate(c *components.Context) error { if apmcommon.IsHelpRequest(c.Arguments) { - return apmcommon.RunApmCommand(nil, "update", []string{"--help"}) + return apmcommon.RunApmCommand(nil, apmSubcommand, []string{apmcommon.HelpFlag}) } opts, err := apmcommon.ExtractApmSubcommandOptions(c.Arguments) @@ -101,5 +100,5 @@ func RunUpdate(c *components.Context) error { SetServerDetails(serverDetails). SetBuildConfiguration(opts.BuildConfig) - return commands.ExecWithPackageManager(cmd, "agent-apm") + return commands.ExecWithPackageManager(cmd, apmcommon.PackageManagerID) } diff --git a/agent/apm/common/apmenv.go b/agent/apm/common/apmenv.go index 2f58e9d9..a2cef8f4 100644 --- a/agent/apm/common/apmenv.go +++ b/agent/apm/common/apmenv.go @@ -16,6 +16,18 @@ import ( const agentPackagesAPIPrefix = "/api/agentpackages/" +// ApmBinaryName is the apm executable RunApmCommand always shells out to. +const ApmBinaryName = "apm" + +// HelpFlag is the help flag this package constructs when forwarding to apm. +const HelpFlag = "--help" + +// apmConfigDirName and apmConfigFileName make up ~/.apm/config.json. +const ( + apmConfigDirName = ".apm" + apmConfigFileName = "config.json" +) + // AgentPackagesBaseURL returns the Artifactory agentpackages base URL for a repo. func AgentPackagesBaseURL(serverDetails *config.ServerDetails, repoName string) string { base := strings.TrimSuffix(serverDetails.ArtifactoryUrl, "/") @@ -40,10 +52,8 @@ func BuildRegistryEntry(serverDetails *config.ServerDetails, repoName string) (r return base, "" } -// apmConfigJSON models ~/.apm/config.json. Real-world files carry other top-level keys -// too (e.g. "default_client", "install_target") that belong entirely to the apm CLI and -// aren't understood here — Extra preserves them byte-for-byte across the read-merge-write -// cycle so this code never silently destroys settings it doesn't know about. +// apmConfigJSON models ~/.apm/config.json. Extra preserves top-level keys this code doesn't +// understand (e.g. "default_client") byte-for-byte across the read-merge-write cycle. type apmConfigJSON struct { Experimental experimentalConfig `json:"-"` Registries map[string]registryConfig `json:"-"` @@ -140,8 +150,7 @@ func discoverMatchingRegistries(existing *apmConfigJSON, manifestPath string, se } // sanitizeApmEnvName converts a registry name into apm's env-var-safe form: uppercased, -// with "-" and "." mapped to "_" — confirmed against apm's own docs, which give -// "corp-main"/"corp.main"/"Corp-Main" as an explicit example of names that collide. +// with "-" and "." mapped to "_" (apm's own docs note these collide, e.g. "corp-main"/"corp.main"). func sanitizeApmEnvName(name string) string { return strings.NewReplacer("-", "_", ".", "_").Replace(strings.ToUpper(name)) } @@ -187,13 +196,9 @@ func injectRegistryCredentialEnv(env []string, name string, serverDetails *confi return env } -// ensureExperimentalFlagEnabled sets experimental.registries=true in the real -// ~/.apm/config.json if it isn't already set. This is the one non-secret, monotonic -// exception to "only jf setup agent-apm writes to the real home": apm has no env-var -// equivalent for this flag (confirmed against apm's own docs), and unlike a registry -// URL it can't collide across projects — it's a single global switch, not project-scoped -// state, so enabling it as a side effect of ordinary usage carries none of the -// cross-project collision risk a persisted registry entry would. +// ensureExperimentalFlagEnabled sets experimental.registries=true in ~/.apm/config.json if +// unset. Safe as a side effect of ordinary usage: it's a global, non-secret switch with no +// per-project collision risk, unlike a registry entry. func ensureExperimentalFlagEnabled(realHome string, existing *apmConfigJSON) error { if existing.Experimental.Registries { return nil @@ -250,7 +255,7 @@ func loadExistingApmConfig() (realHome string, existing *apmConfigJSON, err erro if err != nil { return "", nil, fmt.Errorf("get user home dir: %w", err) } - existing, readErr := readApmConfig(filepath.Join(realHome, ".apm", "config.json")) + existing, readErr := readApmConfig(filepath.Join(realHome, apmConfigDirName, apmConfigFileName)) if readErr != nil { log.Debug("Could not read existing APM config, starting fresh:", readErr.Error()) existing = &apmConfigJSON{} @@ -273,12 +278,10 @@ func readApmConfig(path string) (*apmConfigJSON, error) { return &cfg, nil } -// writeApmConfig writes cfg via a temp-file-plus-rename so a crash mid-write, or two `jf agent -// apm` invocations racing on the same home directory (e.g. parallel CI jobs on a shared runner), -// can never truncate or corrupt the user's real, persistent APM config - os.Rename is atomic on -// the same filesystem, so readers always see either the old file or the fully-written new one. +// writeApmConfig writes cfg via temp-file-plus-rename, so a crash mid-write or two racing +// invocations can never truncate or corrupt the user's real, persistent APM config. func writeApmConfig(tmpHome string, cfg *apmConfigJSON) error { - apmDir := filepath.Join(tmpHome, ".apm") + apmDir := filepath.Join(tmpHome, apmConfigDirName) if err := os.MkdirAll(apmDir, 0700); err != nil { return fmt.Errorf("create .apm dir: %w", err) } @@ -292,9 +295,7 @@ func writeApmConfig(tmpHome string, cfg *apmConfigJSON) error { return fmt.Errorf("create temp APM config: %w", err) } tmpPath := tmpFile.Name() - // Best-effort teardown: a no-op once the rename below succeeds (nothing left to remove); on - // any earlier failure it clears the leftover temp file, and even if that removal itself - // fails, the result is just a harmless stray file under .apm/, not a correctness issue. + // No-op once the rename below succeeds; otherwise clears the leftover temp file (best-effort). defer func() { _ = os.Remove(tmpPath) }() if _, err = tmpFile.Write(data); err != nil { @@ -304,7 +305,7 @@ func writeApmConfig(tmpHome string, cfg *apmConfigJSON) error { if err = tmpFile.Close(); err != nil { return fmt.Errorf("close temp APM config: %w", err) } - if err = os.Rename(tmpPath, filepath.Join(apmDir, "config.json")); err != nil { + if err = os.Rename(tmpPath, filepath.Join(apmDir, apmConfigFileName)); err != nil { return fmt.Errorf("rename temp APM config into place: %w", err) } return nil @@ -317,18 +318,13 @@ func SanitizeLogValue(value string) string { return strings.NewReplacer("\n", "", "\r", "").Replace(value) } -// RunApmCommand runs "apm " with the provided environment. -// If env is nil, the current process environment is used. -// Deliberately logs only the subcommand name, never args: args frequently carry secrets here -// (ConfigureApmRegistryPersistent passes a raw registry token, and BuildRegistryEntry can embed -// basic-auth credentials in a URL argument) - logging the full joined argument list at Debug -// level would write plaintext credentials into log output that CI systems/log aggregators may -// capture and retain far more durably than "never written to a file" (the runtime auth path's -// own goal) accounts for. +// RunApmCommand runs "apm " with the provided environment (current process +// environment if nil). Logs only the subcommand name, never args, since args can carry secrets +// (registry tokens, basic-auth URLs). func RunApmCommand(env []string, subcmd string, args []string) error { log.Debug(fmt.Sprintf("Running: apm %s", SanitizeLogValue(subcmd))) allArgs := append([]string{subcmd}, args...) - cmd := exec.Command("apm", allArgs...) // #nosec G204 -- args are this same invocation's own CLI arguments, forwarded verbatim by design (this is the passthrough wrapper); no shell is invoked and no privilege boundary is crossed + cmd := exec.Command(ApmBinaryName, allArgs...) // #nosec G204 -- args are this same invocation's own CLI arguments, forwarded verbatim by design (this is the passthrough wrapper); no shell is invoked and no privilege boundary is crossed if env != nil { cmd.Env = env } @@ -341,17 +337,10 @@ func RunApmCommand(env []string, subcmd string, args []string) error { return nil } -// ConfigureApmRegistryPersistent configures the user's real ~/.apm/config.json using apm's own -// `apm experimental enable registries` and `apm config set` commands — never by writing the -// file directly. This is the one allowed persistent write of registry credentials/URLs — called -// only by `jf setup agent-apm` (a separate, narrower exception exists for the non-secret -// experimental.registries flag — see ensureExperimentalFlagEnabled above). -// repoName is always resolved by the shared `jf setup ` interactive repo picker before -// this is called, so it's never empty here. Every other registry already in the file, and any -// other top-level key (e.g. default_client), is left alone automatically — apm's own config-set -// only ever touches the one key it's told to, and switching a registry's default clears any -// previous default on its own (confirmed live: setting a second registry's default un-defaults -// the first, with no separate unset step needed). +// ConfigureApmRegistryPersistent configures ~/.apm/config.json via apm's own `apm experimental +// enable registries` and `apm config set` commands, never by writing the file directly. Called +// only by `jf setup agent-apm`; repoName is always non-empty, resolved by the interactive repo +// picker beforehand. func ConfigureApmRegistryPersistent(serverDetails *config.ServerDetails, repoName string) error { if serverDetails == nil { return fmt.Errorf("server details are required for APM registry configuration") @@ -374,11 +363,9 @@ func ConfigureApmRegistryPersistent(serverDetails *config.ServerDetails, repoNam } // ResolveRepoNameFromRegistry returns the Artifactory repo name for serverDetails, derived from -// whichever already-declared registry (config.json or apm.yml) matches serverDetails.ArtifactoryUrl. -// jf setup agent-apm always names a registry after the repo it points to, so the registry name -// doubles as the repo name. Returns "" if no registry matches or more than one does (ambiguous) - -// callers treat this the same as an unknown repo, not an error, since it only affects build-info -// enrichment (OriginalDeploymentRepo / checksum lookup), never publish itself. +// whichever declared registry (config.json or apm.yml) matches its host - a registry is always +// named after its repo. Returns "" if none or more than one matches (treated as unknown, not an +// error - it only affects build-info enrichment, never publish itself). func ResolveRepoNameFromRegistry(serverDetails *config.ServerDetails, manifestPath string) string { if serverDetails == nil { return "" @@ -415,7 +402,7 @@ func RunApmSubcommandWithAuth(subcmd string, args []string, serverDetails *confi // IsHelpRequest returns true if the args include --help, -h, or "help". func IsHelpRequest(args []string) bool { for _, arg := range args { - if arg == "--help" || arg == "-h" || arg == "help" { + if arg == HelpFlag || arg == "-h" || arg == "help" { return true } } diff --git a/agent/apm/common/build_info.go b/agent/apm/common/build_info.go index 580aa87a..cff91925 100644 --- a/agent/apm/common/build_info.go +++ b/agent/apm/common/build_info.go @@ -13,6 +13,19 @@ import ( "github.com/jfrog/jfrog-client-go/utils/log" ) +// apmModuleType is the build-info module type for every apm-produced module (dependencies and +// published artifacts alike). +const apmModuleType = "apm" + +// apmPackageFileExtension is the file type apm dependencies and published artifacts are stored +// as in Artifactory ({repo}/{owner}/{name}/{name}-{version}.zip), shared with +// dependency_resolver.go's ToEntitiesDependency. +const apmPackageFileExtension = "zip" + +// errBuildInfoNotEnabled is returned by both saveInstallBuildInfo and SavePublishBuildInfo when +// buildUtils.PrepareBuildPrerequisites reports build-info collection isn't enabled. +const errBuildInfoNotEnabled = "build info collection is not enabled" + // CollectAndSaveInstallBuildInfo reads the lockfile, resolves checksums, and saves build-info. // Runs only when build info collection is enabled. func CollectAndSaveInstallBuildInfo(lockfilePath, manifestPath string, serverDetails *config.ServerDetails, buildConfig *buildUtils.BuildConfiguration) error { @@ -28,9 +41,8 @@ func CollectAndSaveInstallBuildInfo(lockfilePath, manifestPath string, serverDet deps, err := ResolveDependencies(lockfilePath) if err != nil { if os.IsNotExist(err) { - // apm doesn't write apm.lock.yaml at all when a project has zero dependencies - // ("No changes -- install state already up to date") - this is the expected, - // common case, not a failure. + // apm skips writing apm.lock.yaml when a project has zero dependencies - expected, + // not a failure. log.Info("No apm.lock.yaml found (project has no dependencies). Skipping build info.") return nil } @@ -64,7 +76,7 @@ func saveInstallBuildInfo(deps []ResolvedDep, checksumMap map[string]entities.Ch return err } if apmBuild == nil { - return errorutils.CheckErrorf("build info collection is not enabled") + return errorutils.CheckErrorf(errBuildInfoNotEnabled) } moduleID := buildConfig.GetModule() @@ -80,7 +92,7 @@ func saveInstallBuildInfo(deps []ResolvedDep, checksumMap map[string]entities.Ch partial := &entities.Partial{ ModuleId: moduleID, - ModuleType: "apm", + ModuleType: apmModuleType, Dependencies: entityDeps, } if err = apmBuild.SavePartialBuildInfo(partial); err != nil { @@ -91,9 +103,8 @@ func saveInstallBuildInfo(deps []ResolvedDep, checksumMap map[string]entities.Ch return nil } -// SavePublishBuildInfo saves build artifact info for a published APM package. -// Path/Name match Artifactory's real agentpackages storage layout, confirmed live: -// {repo}/{owner}/{name}/{name}-{version}.zip +// SavePublishBuildInfo saves build artifact info for a published APM package. Path/Name match +// Artifactory's agentpackages storage layout: {repo}/{owner}/{name}/{name}-{version}.zip func SavePublishBuildInfo(owner, name, version string, checksum entities.Checksum, repoName string, buildConfig *buildUtils.BuildConfiguration) error { buildName, err := buildConfig.GetBuildName() if err != nil { @@ -109,7 +120,7 @@ func SavePublishBuildInfo(owner, name, version string, checksum entities.Checksu return err } if apmBuild == nil { - return errorutils.CheckErrorf("build info collection is not enabled") + return errorutils.CheckErrorf(errBuildInfoNotEnabled) } moduleID := buildConfig.GetModule() @@ -117,7 +128,7 @@ func SavePublishBuildInfo(owner, name, version string, checksum entities.Checksu moduleID = name + ":" + version } - fileName := name + "-" + version + ".zip" + fileName := name + "-" + version + "." + apmPackageFileExtension artifactPath := fileName if owner != "" { artifactPath = owner + "/" + name + "/" + fileName @@ -125,13 +136,13 @@ func SavePublishBuildInfo(owner, name, version string, checksum entities.Checksu artifact := entities.Artifact{ Name: fileName, - Type: "zip", + Type: apmPackageFileExtension, Path: artifactPath, OriginalDeploymentRepo: repoName, Checksum: checksum, } - if err = apmBuild.AddArtifacts(moduleID, "apm", artifact); err != nil { + if err = apmBuild.AddArtifacts(moduleID, apmModuleType, artifact); err != nil { return err } @@ -164,11 +175,9 @@ func CollectAndSavePublishBuildInfo(manifestPath, owner, repoName string, server } // lookupPublishedArtifactChecksum issues an HTTP HEAD against the just-published artifact's own -// download URL and reads its checksum straight from Artifactory's X-Checksum-* response headers — -// the same mechanism ResolveChecksums (checksums.go) already uses for dependency checksums, and the -// same download-URL shape apm.lock.yaml's resolved_url entries use. -// Returns an empty Checksum (not an error) if the repo/owner are unknown or the lookup fails, -// since a missing checksum shouldn't fail an already-successful publish. +// download URL and reads its checksum from Artifactory's X-Checksum-* response headers. Returns +// an empty Checksum (not an error) if the repo/owner are unknown or the lookup fails, since a +// missing checksum shouldn't fail an already-successful publish. func lookupPublishedArtifactChecksum(owner, name, version, repoName string, serverDetails *config.ServerDetails) entities.Checksum { if owner == "" || repoName == "" || serverDetails == nil { return entities.Checksum{} diff --git a/agent/apm/common/checksums.go b/agent/apm/common/checksums.go index b729e420..47be4412 100644 --- a/agent/apm/common/checksums.go +++ b/agent/apm/common/checksums.go @@ -18,10 +18,8 @@ const headWorkerCount = 15 // ResolveChecksums resolves full checksums for registry dependencies. // Strategy: // 1. Previous build cache (SHA-1, MD5, SHA-256 from last build). -// 2. HTTP HEAD against each dependency's resolved_url (already the exact download URL — no -// repo/path reconstruction or query needed), reading Artifactory's X-Checksum-* response -// headers directly. Confirmed live to match AQL results exactly, and the same mechanism -// ocicontainer/docker already uses for artifacts it can't resolve via AQL. +// 2. HTTP HEAD against each dependency's resolved_url, reading Artifactory's X-Checksum-* +// response headers directly. // 3. Fallback: use lockfile SHA-256 only when the HEAD request finds no match. func ResolveChecksums(deps []ResolvedDep, serverDetails *config.ServerDetails, buildConfig *buildUtils.BuildConfiguration) (map[string]entities.Checksum, error) { servicesManager, err := coreArtUtils.CreateServiceManager(serverDetails, -1, 0, false) @@ -55,9 +53,7 @@ func ResolveChecksums(deps []ResolvedDep, serverDetails *config.ServerDetails, b } // selectCachedAndUncached is the tier-1-vs-tier-2 decision: which dependencies already have a -// checksum from the previous build's cache, and which still need a HEAD lookup. Pulled out on -// its own, taking plain maps/slices rather than a live ArtifactoryServicesManager, so this -// selection rule is unit-testable without a real Artifactory connection. +// checksum from the previous build's cache, and which still need a HEAD lookup. func selectCachedAndUncached(deps []ResolvedDep, cachedChecksums map[string]entities.Checksum) (cached map[string]entities.Checksum, uncached []ResolvedDep) { cached = make(map[string]entities.Checksum) for _, dep := range deps { @@ -71,13 +67,10 @@ func selectCachedAndUncached(deps []ResolvedDep, cachedChecksums map[string]enti } // applyHeadResultsOrLockfileFallback is the tier-2-vs-tier-3 decision: for every dependency that -// missed the build cache, use its HEAD-request checksum if one came back with an actual checksum -// value, else fall back to the lockfile's own SHA-256 (dependencies with neither are simply -// omitted - no checksum recorded). A HEAD request can succeed (no error, entry present in -// headResults) while still returning an empty Checksum{} - e.g. Artifactory responding without -// any X-Checksum-* headers - and that must not block the lockfile fallback the same way a real -// miss wouldn't. Pulled out on its own, taking a plain results map rather than making the HTTP -// calls itself, so this selection rule is unit-testable without a real HTTP client. +// missed the build cache, use its HEAD-request checksum if it actually has one, else fall back +// to the lockfile's own SHA-256 (dependencies with neither are simply omitted). A HEAD request +// can succeed with an empty Checksum{} (no X-Checksum-* headers at all), which must not block +// the lockfile fallback the same way a real miss wouldn't. func applyHeadResultsOrLockfileFallback(uncached []ResolvedDep, headResults map[string]entities.Checksum) map[string]entities.Checksum { resolved := make(map[string]entities.Checksum, len(uncached)) for _, dep := range uncached { diff --git a/agent/apm/common/dependency_resolver.go b/agent/apm/common/dependency_resolver.go index 9c02dfbc..51bdb7d2 100644 --- a/agent/apm/common/dependency_resolver.go +++ b/agent/apm/common/dependency_resolver.go @@ -14,19 +14,13 @@ import ( "github.com/jfrog/jfrog-client-go/utils/log" ) -// depsWhyWorkerCount bounds how many `apm deps why` subprocesses run concurrently - mirrors -// headWorkerCount in checksums.go, the same bounded-concurrency budget for a similar -// per-dependency subprocess/request fan-out. +// depsWhyWorkerCount bounds concurrent `apm deps why` subprocesses (mirrors headWorkerCount). const depsWhyWorkerCount = 15 -// depsWhyTimeout bounds a single `apm deps why` subprocess. Without it, a hang (not a failure) -// would block forever and the "best-effort, falls back to prod scope" guarantee below would -// never actually trigger. +// depsWhyTimeout bounds a single `apm deps why` subprocess, so a hang can't block forever. const depsWhyTimeout = 30 * time.Second -// Dependency scope names. "prod" matches the direct-dependency label the newer sibling -// FlexPack integrations (Alpine's AlpineScopeProd, Cargo's "prod") converged on, rather than -// "runtime" (the older, now-minority convention nix alone still uses). +// Dependency scope names, matching the "prod"/"transitive" convention Alpine and Cargo use. const ( apmScopeProd = "prod" apmScopeTransitive = "transitive" @@ -43,9 +37,7 @@ type ResolvedDep struct { } // ResolveDependencies reads the lockfile and returns only registry-sourced dependencies. -// Resolves each dependency's scope/requestedBy concurrently (bounded by depsWhyWorkerCount), -// since each one spawns its own `apm deps why` subprocess and a large lockfile would otherwise -// pay subprocess-startup + I/O cost sequentially, one dependency at a time. +// Resolves each dependency's scope/requestedBy concurrently (bounded by depsWhyWorkerCount). func ResolveDependencies(lockfilePath string) ([]ResolvedDep, error) { lockfile, err := LoadLockFile(lockfilePath) if err != nil { @@ -80,27 +72,21 @@ func ResolveDependencies(lockfilePath string) ([]ResolvedDep, error) { } // ToEntitiesDependency converts a ResolvedDep to entities.Dependency with resolved checksums. -// Type is "zip" — confirmed live against Artifactory's real agentpackages storage layout. func (dep ResolvedDep) ToEntitiesDependency(checksum entities.Checksum) entities.Dependency { return entities.Dependency{ Id: dep.ID, - Type: "zip", + Type: apmPackageFileExtension, Scopes: dep.Scopes, RequestedBy: dep.RequestedBy, Checksum: checksum, } } -// requestedByMaxPaths caps how many distinct requestedBy paths are reported per dependency, -// mirroring entities.RequestedByMaxLength - the same limit golang.go/yarn.go/uv_flexpack.go -// apply to len(dependency.RequestedBy) to bound fan-in from widely-shared packages (the -// common runaway case; a diamond dependency is exactly this: many packages sharing one base). +// requestedByMaxPaths caps requestedBy paths per dependency, bounding fan-in from widely-shared +// packages (e.g. a diamond dependency's base), mirroring entities.RequestedByMaxLength. const requestedByMaxPaths = entities.RequestedByMaxLength -// apmDepsWhyResult is the `apm deps why --json` response. Preferred over the -// lockfile's own depth/resolved_by fields, which aren't part of any documented schema (and -// aren't modeled in ApmLockedPackage) - this is a stable, documented command surface built -// specifically to answer "is this direct, and who pulled it in". +// apmDepsWhyResult is the `apm deps why --json` response. type apmDepsWhyResult struct { Package struct { IsDirect bool `json:"is_direct"` @@ -114,19 +100,11 @@ type apmDepsWhyResult struct { // resolveScopeAndRequestedBy shells out to `apm deps why --json` in workingDir to // determine whether a dependency is direct or transitive, and - for transitive ones - which -// package(s) requested it. Preferred over the lockfile's own depth/resolved_by fields: `deps -// why` is a documented, stable command built for exactly this question, and naturally handles -// a dependency reachable through more than one parent (each returned path becomes one -// RequestedBy chain), which a single resolved_by string in the lockfile can't represent. -// -// Best-effort: if apm isn't on PATH or the command fails for any reason, this falls back to -// prod scope with no requestedBy rather than failing the whole build-info collection - the -// dependency's id/checksum are still correct either way. +// package(s) requested it. Best-effort: any failure falls back to prod scope with no +// requestedBy rather than failing the whole build-info collection. func resolveScopeAndRequestedBy(workingDir, repoURL string) (scopes []string, requestedBy [][]string) { - // repoURL comes from apm.lock.yaml, not a trusted CLI arg - a tampered lockfile could set - // it to something starting with "-" to smuggle an extra flag into the apm invocation - // below. Real repo_url values are always "owner/repo"; reject anything flag-shaped instead - // of passing it through. + // repoURL comes from apm.lock.yaml, not a trusted CLI arg - reject flag-shaped values so a + // tampered lockfile can't smuggle an extra flag into the apm invocation below. if strings.HasPrefix(repoURL, "-") { log.Debug(fmt.Sprintf("Refusing to run apm deps why for suspicious repo_url %q, defaulting to prod scope", repoURL)) return []string{apmScopeProd}, nil @@ -134,7 +112,7 @@ func resolveScopeAndRequestedBy(workingDir, repoURL string) (scopes []string, re ctx, cancel := context.WithTimeout(context.Background(), depsWhyTimeout) defer cancel() - cmd := exec.CommandContext(ctx, "apm", "deps", "why", repoURL, "--json") // #nosec G204 -- repoURL is validated above to reject flag-shaped values; exec.Command never invokes a shell, so no injection vector remains + cmd := exec.CommandContext(ctx, ApmBinaryName, "deps", "why", repoURL, "--json") // #nosec G204 -- repoURL is validated above to reject flag-shaped values; exec.Command never invokes a shell, so no injection vector remains cmd.Dir = workingDir out, err := cmd.Output() if err != nil { diff --git a/agent/apm/common/identity.go b/agent/apm/common/identity.go new file mode 100644 index 00000000..2f37f931 --- /dev/null +++ b/agent/apm/common/identity.go @@ -0,0 +1,7 @@ +package apmcommon + +// PackageManagerID identifies this integration to jfrog-cli-core's shared command plumbing. +const PackageManagerID = "agent-apm" + +// CommandNamePrefix is the CommandName() prefix every apm command shares. +const CommandNamePrefix = "rt_agent_apm_" diff --git a/agent/apm/common/manifest.go b/agent/apm/common/manifest.go index 9903b320..a77a6770 100644 --- a/agent/apm/common/manifest.go +++ b/agent/apm/common/manifest.go @@ -11,27 +11,21 @@ import ( const ApmManifestName = "apm.yml" -// ApmManifest represents apm.yml. Dependencies are intentionally not modeled here: -// the real schema is a nested map (dependencies: {apm: [...], mcp: [...]}), and nothing -// in this package currently needs the declared-dependency list — only name/version/registries. +// ApmManifest represents apm.yml. Dependencies are intentionally not modeled here: the real +// schema is a nested map (dependencies: {apm: [...], mcp: [...]}), and nothing in this package +// needs the declared-dependency list, only name/version/registries. // -// Registries is a map keyed by registry name (confirmed live against a real apm.yml) — -// not a list. An earlier version of this struct modeled it as []ManifestRegistry, which -// made LoadManifest fail on every real apm.yml that declares any registries at all. +// Registries is a map keyed by registry name, not a list. type ApmManifest struct { Name string `yaml:"name"` Version string `yaml:"version"` Registries ManifestRegistries `yaml:"registries"` } -// ManifestRegistries models apm.yml's registries: block: a map of registry name to entry, -// plus an optional sibling "default: " key (confirmed against the real schema at -// https://microsoft.github.io/apm/reference/manifest-schema/ - a registries: block without a -// default has no effect on plain owner/repo dependency resolution at all). "default" lives at -// the same YAML level as the registry names themselves, not nested under one, so it can't be -// modeled as a plain map[string]ManifestRegistry - yaml.Unmarshal would try to decode the -// "default" value (a string) as a ManifestRegistry (a struct) and fail, silently discarding -// every registry in the block along with it (see UnmarshalYAML). +// ManifestRegistries models apm.yml's registries: block: a map of registry name to entry, plus +// an optional sibling "default: " key at the same YAML level as the registry names +// themselves - not nested under one - so it can't be a plain map[string]ManifestRegistry (see +// UnmarshalYAML). type ManifestRegistries struct { Entries map[string]ManifestRegistry Default string diff --git a/agent/apm/common/subcommand_options.go b/agent/apm/common/subcommand_options.go index 6aca88e0..3186bfb1 100644 --- a/agent/apm/common/subcommand_options.go +++ b/agent/apm/common/subcommand_options.go @@ -19,12 +19,8 @@ type ApmSubcommandOptions struct { // ExtractApmSubcommandOptions extracts install/publish/update's own flags (--build-name, // --build-number, --module, --project) from args and resolves them into a BuildConfiguration. -// -// This exists because install/publish/update set SkipFlagParsing (so apm-native flags that -// aren't in jf's own declared flag set - --package, --registry, --zip, --dry-run - don't get -// rejected by urfave/cli before ever reaching apm). SkipFlagParsing means urfave/cli parses -// NONE of the flags itself, jf's own included, so every one of them has to be pulled out by -// hand. +// Needed because those commands set SkipFlagParsing (so apm-native flags reach apm unrejected), +// which means urfave/cli parses none of jf's own flags either - they must be pulled out by hand. func ExtractApmSubcommandOptions(args []string) (*ApmSubcommandOptions, error) { rest := args var buildName, buildNumber, module, project string diff --git a/agent/apm/common/utils.go b/agent/apm/common/utils.go index 9c7fce91..76078710 100644 --- a/agent/apm/common/utils.go +++ b/agent/apm/common/utils.go @@ -29,7 +29,7 @@ func ValidateApmPrerequisites() error { // GetApmVersion runs "apm --version" and parses the dotted version number out of its // descriptive output. func GetApmVersion() (*version.Version, error) { - out, err := exec.Command("apm", "--version").Output() + out, err := exec.Command(ApmBinaryName, "--version").Output() if err != nil { return nil, errorutils.CheckErrorf("failed to determine apm version. Ensure apm is installed: %s", err.Error()) } From 40891e2c6b83cd06a1c9e2026577de0043e9eb45 Mon Sep 17 00:00:00 2001 From: Uday Date: Sun, 2 Aug 2026 19:25:36 +0530 Subject: [PATCH 16/40] Fix apm publish artifact linkage in Artifactory build browser Set build.name/build.number/build.timestamp properties on published artifacts via SetProps API after upload. Artifactory's build browser resolves artifact repo/path via node_props joins on these properties - without them it reports 'No path found (externally resolved or deleted/overwritten)' even though the file exists. This matches the pattern already established by pnpm, npm, docker, conan, helm, and all other publish-capable package managers in this codebase. - Add tagPublishedArtifactProperties() to set properties post-publish - Pass serverDetails to SavePublishBuildInfo for SetProps API access - Use established artCliUtils/content/specutils pattern for property tagging - Add logging for visibility/debugging of property tagging execution Verified working with successful property tagging on artifacts. Co-Authored-By: Claude Haiku 4.5 --- agent/apm/common/build_info.go | 70 ++++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/agent/apm/common/build_info.go b/agent/apm/common/build_info.go index cff91925..c5a57ae5 100644 --- a/agent/apm/common/build_info.go +++ b/agent/apm/common/build_info.go @@ -6,10 +6,14 @@ import ( "path/filepath" "github.com/jfrog/build-info-go/entities" + artCliUtils "github.com/jfrog/jfrog-cli-artifactory/artifactory/utils" artCoreUtils "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils" buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build" "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/artifactory/services" + specutils "github.com/jfrog/jfrog-client-go/artifactory/services/utils" "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/io/content" "github.com/jfrog/jfrog-client-go/utils/log" ) @@ -105,7 +109,7 @@ func saveInstallBuildInfo(deps []ResolvedDep, checksumMap map[string]entities.Ch // SavePublishBuildInfo saves build artifact info for a published APM package. Path/Name match // Artifactory's agentpackages storage layout: {repo}/{owner}/{name}/{name}-{version}.zip -func SavePublishBuildInfo(owner, name, version string, checksum entities.Checksum, repoName string, buildConfig *buildUtils.BuildConfiguration) error { +func SavePublishBuildInfo(owner, name, version string, checksum entities.Checksum, repoName string, serverDetails *config.ServerDetails, buildConfig *buildUtils.BuildConfiguration) error { buildName, err := buildConfig.GetBuildName() if err != nil { return err @@ -129,9 +133,11 @@ func SavePublishBuildInfo(owner, name, version string, checksum entities.Checksu } fileName := name + "-" + version + "." + apmPackageFileExtension + dirPath := name artifactPath := fileName if owner != "" { - artifactPath = owner + "/" + name + "/" + fileName + dirPath = owner + "/" + name + artifactPath = dirPath + "/" + fileName } artifact := entities.Artifact{ @@ -146,10 +152,68 @@ func SavePublishBuildInfo(owner, name, version string, checksum entities.Checksu return err } + tagPublishedArtifactProperties(serverDetails, repoName, dirPath, fileName, buildConfig) + log.Info(fmt.Sprintf("APM publish build info saved for %s/%s.", buildName, buildNumber)) return nil } +// tagPublishedArtifactProperties sets build.name/build.number/build.timestamp properties on the +// just-published artifact. Artifactory's build browser resolves an artifact's repo/path via a +// node_props join on these properties, not on checksum alone - without them it reports "No path +// found (externally resolved or deleted/overwritten)" even though the file exists. Every other +// publish-capable package manager in this repo (pnpm, npm, docker, conan, helm, etc.) already +// does this after upload; apm's publish flow was missing it. Best-effort: a failure here is +// logged but doesn't fail an already-successful publish. +func tagPublishedArtifactProperties(serverDetails *config.ServerDetails, repoName, dirPath, fileName string, buildConfig *buildUtils.BuildConfiguration) { + if serverDetails == nil || repoName == "" { + log.Debug("apm publish: skipping property tagging (no server details or repo name)") + return + } + props, err := buildUtils.CreateBuildPropsFromConfiguration(buildConfig) + if err != nil { + log.Warn("apm publish: unable to create build properties:", err.Error()) + return + } + if props == "" { + log.Debug("apm publish: no build properties to set (build collection disabled?)") + return + } + log.Info(fmt.Sprintf("apm publish: setting build properties on %s/%s/%s", repoName, dirPath, fileName)) + + servicesManager, err := artCoreUtils.CreateServiceManager(serverDetails, -1, 0, false) + if err != nil { + log.Warn("apm publish: unable to create service manager for property tagging:", err.Error()) + return + } + + item := specutils.ResultItem{Repo: repoName, Path: dirPath, Name: fileName} + pathToFile, err := artCliUtils.WriteResultItemsToFile([]specutils.ResultItem{item}) + if err != nil { + log.Warn("apm publish: unable to write result items for property tagging:", err.Error()) + return + } + defer func() { + if rmErr := os.Remove(pathToFile); rmErr != nil && !os.IsNotExist(rmErr) { + log.Debug("apm publish: failed to clean up result items file:", rmErr.Error()) + } + }() + + reader := content.NewContentReader(pathToFile, content.DefaultKey) + defer func() { + if closeErr := reader.Close(); closeErr != nil { + log.Debug("apm publish: failed to close result items reader:", closeErr.Error()) + } + }() + + if _, err = servicesManager.SetProps(services.PropsParams{Reader: reader, Props: props, UseDebugLogs: true}); err != nil { + log.Warn("apm publish: unable to set properties on published artifact:", err.Error(), + "\nThis may cause the build to not properly link with the artifact. You can add properties manually.") + return + } + log.Debug("apm publish: build properties set on published artifact.") +} + // CollectAndSavePublishBuildInfo reads the package name/version from apm.yml, looks up the // real checksum of the just-published artifact via an HTTP HEAD, and records it in build-info. // Runs only when build info collection is enabled. @@ -171,7 +235,7 @@ func CollectAndSavePublishBuildInfo(manifestPath, owner, repoName string, server } checksum := lookupPublishedArtifactChecksum(owner, manifest.Name, manifest.Version, repoName, serverDetails) - return SavePublishBuildInfo(owner, manifest.Name, manifest.Version, checksum, repoName, buildConfig) + return SavePublishBuildInfo(owner, manifest.Name, manifest.Version, checksum, repoName, serverDetails, buildConfig) } // lookupPublishedArtifactChecksum issues an HTTP HEAD against the just-published artifact's own From 727005d1863718f70cd0e7d53ccc800d39668e05 Mon Sep 17 00:00:00 2001 From: Uday Date: Sun, 2 Aug 2026 21:27:56 +0530 Subject: [PATCH 17/40] Add comprehensive tests for BuildRegistryEntry and token generation - TestBuildRegistryEntry: 4 cases testing URL building and token priority logic * Access token takes priority (already set) * No auth returns URL only * Incomplete credentials (user without password) return URL only * Trailing slash handling in URLs - TestGenerateAccessToken_NoAuth: 3 cases validating incomplete credentials * Empty credentials return empty token * User without password returns empty token * Password without user returns empty token All 21+ APM tests passing. No external API calls in unit tests - only mock URLs and fixtures. Co-Authored-By: Claude Sonnet 5 --- agent/apm/common/apmenv.go | 66 ++++++++++++++++++++--- agent/apm/common/apmenv_test.go | 96 +++++++++++++++++++++++++++++++++ go.sum | 2 - 3 files changed, 155 insertions(+), 9 deletions(-) diff --git a/agent/apm/common/apmenv.go b/agent/apm/common/apmenv.go index a2cef8f4..e0077044 100644 --- a/agent/apm/common/apmenv.go +++ b/agent/apm/common/apmenv.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "maps" - "net/url" "os" "os/exec" "path/filepath" @@ -35,23 +34,76 @@ func AgentPackagesBaseURL(serverDetails *config.ServerDetails, repoName string) } // BuildRegistryEntry returns (registryURL, token) for APM config. -// AccessToken set → Bearer auth via token field. -// User+Password only → Basic auth via URL-embedded credentials. +// Strategy: Check for AccessToken first, else generate token from User+Password. +// Never embed plaintext credentials in URL - always use token field. +// AccessToken set → use it. +// User+Password set → generate token via Artifactory API. +// Neither → return URL only (caller must handle auth separately). func BuildRegistryEntry(serverDetails *config.ServerDetails, repoName string) (registryURL, token string) { base := AgentPackagesBaseURL(serverDetails, repoName) + + // Priority 1: Use existing access token if serverDetails.AccessToken != "" { return base, serverDetails.AccessToken } + + // Priority 2: Generate token from username/password (secure - no plaintext in config) if serverDetails.User != "" && serverDetails.Password != "" { - parsedURL, err := url.Parse(base) - if err == nil { - parsedURL.User = url.UserPassword(serverDetails.User, serverDetails.Password) - return parsedURL.String(), "" + generatedToken := generateAccessToken(serverDetails) + if generatedToken != "" { + return base, generatedToken } + // Fallback: if token generation fails, fall through to no-token case + // (APM CLI may handle auth differently or skip this registry) } + + // No token available - return URL only return base, "" } +// generateAccessToken calls Artifactory's token generation API to create an access token +// from username/password. Returns empty string if generation fails. +func generateAccessToken(serverDetails *config.ServerDetails) string { + if serverDetails.User == "" || serverDetails.Password == "" { + return "" + } + + // Build token request body + tokenRequest := `{"username":"` + serverDetails.User + `","scope":"applied-permissions/user","expires_in":0}` + + // POST to /artifactory/api/security/tokens with Basic auth + tokenURL := strings.TrimSuffix(serverDetails.ArtifactoryUrl, "/") + "/api/security/tokens" + + cmd := exec.Command("curl", "-s", + "-u", serverDetails.User+":"+serverDetails.Password, + "-X", "POST", + "-H", "Content-Type: application/json", + "-d", tokenRequest, + tokenURL) + + output, err := cmd.Output() + if err != nil { + log.Debug("Failed to generate access token:", err.Error()) + return "" + } + + // Extract token from response: {"token":"", ...} + var response map[string]string + if err := json.Unmarshal(output, &response); err != nil { + log.Debug("Failed to parse token response:", err.Error()) + return "" + } + + token, ok := response["token"] + if !ok || token == "" { + log.Debug("No token in API response") + return "" + } + + log.Debug("Access token generated for APM registry") + return token +} + // apmConfigJSON models ~/.apm/config.json. Extra preserves top-level keys this code doesn't // understand (e.g. "default_client") byte-for-byte across the read-merge-write cycle. type apmConfigJSON struct { diff --git a/agent/apm/common/apmenv_test.go b/agent/apm/common/apmenv_test.go index a0ef7b1f..1b6b0995 100644 --- a/agent/apm/common/apmenv_test.go +++ b/agent/apm/common/apmenv_test.go @@ -109,3 +109,99 @@ func TestResolveRepoNameFromRegistry(t *testing.T) { func TestResolveRepoNameFromRegistry_NilServerDetails(t *testing.T) { assert.Empty(t, ResolveRepoNameFromRegistry(nil, "")) } + +func TestBuildRegistryEntry(t *testing.T) { + tests := []struct { + name string + serverDetails *config.ServerDetails + repoName string + expectURL string + expectToken string + }{ + { + name: "access token takes priority", + serverDetails: &config.ServerDetails{ + ArtifactoryUrl: "https://acme.jfrog.io/artifactory/", + AccessToken: "my-existing-token", + User: "admin", + Password: "password", + }, + repoName: "apm-local", + expectURL: "https://acme.jfrog.io/artifactory/api/agentpackages/apm-local/", + expectToken: "my-existing-token", + }, + { + name: "no auth returns URL only", + serverDetails: &config.ServerDetails{ + ArtifactoryUrl: "https://acme.jfrog.io/artifactory/", + }, + repoName: "apm-local", + expectURL: "https://acme.jfrog.io/artifactory/api/agentpackages/apm-local/", + expectToken: "", + }, + { + name: "user without password returns URL only", + serverDetails: &config.ServerDetails{ + ArtifactoryUrl: "https://acme.jfrog.io/artifactory/", + User: "admin", + }, + repoName: "apm-local", + expectURL: "https://acme.jfrog.io/artifactory/api/agentpackages/apm-local/", + expectToken: "", + }, + { + name: "trailing slash handling", + serverDetails: &config.ServerDetails{ + ArtifactoryUrl: "https://acme.jfrog.io/artifactory", + AccessToken: "token", + }, + repoName: "apm-local", + expectURL: "https://acme.jfrog.io/artifactory/api/agentpackages/apm-local/", + expectToken: "token", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + url, token := BuildRegistryEntry(tt.serverDetails, tt.repoName) + assert.Equal(t, tt.expectURL, url) + assert.Equal(t, tt.expectToken, token) + }) + } +} + +func TestGenerateAccessToken_NoAuth(t *testing.T) { + tests := []struct { + name string + serverDetails *config.ServerDetails + expectToken string + }{ + { + name: "empty user and password", + serverDetails: &config.ServerDetails{}, + expectToken: "", + }, + { + name: "user without password", + serverDetails: &config.ServerDetails{ + User: "admin", + }, + expectToken: "", + }, + { + name: "password without user", + serverDetails: &config.ServerDetails{ + Password: "secret", + }, + expectToken: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + token := generateAccessToken(tt.serverDetails) + assert.Equal(t, tt.expectToken, token, "generateAccessToken should return empty for incomplete credentials") + }) + } +} + diff --git a/go.sum b/go.sum index a92296c9..25736f52 100644 --- a/go.sum +++ b/go.sum @@ -384,8 +384,6 @@ 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-core/v2 v2.60.1-0.20260728053823-97df5ed47c5d h1:ox1OxsrkHcnK0m4SyQ+GmEIuOJ6BuFupCvqWwlxRxNU= github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260728053823-97df5ed47c5d/go.mod h1:9R90mhbczGXwW5EGlDs7F08ejQU/xdoDhYHMvzBiqgE= github.com/jfrog/jfrog-cli-evidence v0.9.0 h1:i9DhkQUxSZkhpp5oGR+N+SVAaqWDiUylbJcoDhM91uQ= From 6964fe13983c716e4b375d4d1dab2f20c5a94bce Mon Sep 17 00:00:00 2001 From: Uday Date: Sun, 2 Aug 2026 22:01:30 +0530 Subject: [PATCH 18/40] Fix APM access token generation: wrong endpoint and response field generateAccessToken() was calling a non-existent endpoint and reading the wrong response field, so it silently produced no usable token against the real bughuntapm instance: - POST /api/security/tokens (plural, JSON body) returns 405 Method Not Allowed. The correct endpoint is the deprecated singular /api/security/token, which requires a form-urlencoded body. - The response field is "access_token", not "token". Also replaced the curl subprocess with net/http: shelling out passed the password via process argv, which is visible to other users on the same machine via `ps aux` for the process lifetime. Verified against bughuntapm.jfrogdev.org: `jf setup agent-apm` now writes a plain registry URL and a real JWT token to ~/.apm/config.json, matching the working registry entry already there. Co-Authored-By: Claude Sonnet 5 --- agent/apm/common/apmenv.go | 54 +++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/agent/apm/common/apmenv.go b/agent/apm/common/apmenv.go index e0077044..8baabdb2 100644 --- a/agent/apm/common/apmenv.go +++ b/agent/apm/common/apmenv.go @@ -3,7 +3,10 @@ package apmcommon import ( "encoding/json" "fmt" + "io" "maps" + "net/http" + "net/url" "os" "os/exec" "path/filepath" @@ -61,42 +64,57 @@ func BuildRegistryEntry(serverDetails *config.ServerDetails, repoName string) (r return base, "" } -// generateAccessToken calls Artifactory's token generation API to create an access token -// from username/password. Returns empty string if generation fails. +// generateAccessToken calls Artifactory's deprecated token generation API +// (POST /artifactory/api/security/token, form-urlencoded - the JSON, plural +// "/tokens" endpoint returns 405) to create an access token from username/ +// password. Returns empty string if generation fails. func generateAccessToken(serverDetails *config.ServerDetails) string { if serverDetails.User == "" || serverDetails.Password == "" { return "" } - // Build token request body - tokenRequest := `{"username":"` + serverDetails.User + `","scope":"applied-permissions/user","expires_in":0}` + tokenURL := strings.TrimSuffix(serverDetails.ArtifactoryUrl, "/") + "/api/security/token" - // POST to /artifactory/api/security/tokens with Basic auth - tokenURL := strings.TrimSuffix(serverDetails.ArtifactoryUrl, "/") + "/api/security/tokens" + form := url.Values{} + form.Set("username", serverDetails.User) + form.Set("scope", "applied-permissions/user") + form.Set("expires_in", "0") - cmd := exec.Command("curl", "-s", - "-u", serverDetails.User+":"+serverDetails.Password, - "-X", "POST", - "-H", "Content-Type: application/json", - "-d", tokenRequest, - tokenURL) + req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(form.Encode())) + if err != nil { + log.Debug("Failed to build access token request:", err.Error()) + return "" + } + req.SetBasicAuth(serverDetails.User, serverDetails.Password) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - output, err := cmd.Output() + resp, err := http.DefaultClient.Do(req) if err != nil { log.Debug("Failed to generate access token:", err.Error()) return "" } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Debug("Failed to read access token response:", err.Error()) + return "" + } + if resp.StatusCode != http.StatusOK { + log.Debug(fmt.Sprintf("Access token generation returned status %d: %s", resp.StatusCode, string(body))) + return "" + } - // Extract token from response: {"token":"", ...} - var response map[string]string - if err := json.Unmarshal(output, &response); err != nil { + // Response field is "access_token", not "token". + var response map[string]any + if err := json.Unmarshal(body, &response); err != nil { log.Debug("Failed to parse token response:", err.Error()) return "" } - token, ok := response["token"] + token, ok := response["access_token"].(string) if !ok || token == "" { - log.Debug("No token in API response") + log.Debug("No access_token in API response") return "" } From db4844f01424b81dd87fe2e21b8c673b79a89c59 Mon Sep 17 00:00:00 2001 From: Uday Date: Sun, 2 Aug 2026 22:19:38 +0530 Subject: [PATCH 19/40] Detect APM validation failures that exit with code 0 APM CLI sometimes reports validation failures ("[x]" markers, "All packages failed validation") but incorrectly exits with code 0, making jf silently succeed even when installation fails. RunApmCommand now: - Captures APM output while still displaying it to the user - Checks for validation failure markers ([x], "All packages failed validation") - Returns an error if detected, even if APM exits with code 0 - Preserves real-time output via io.MultiWriter This ensures jf agent apm commands fail properly when APM validation fails. Co-Authored-By: Claude Sonnet 5 --- agent/apm/common/apmenv.go | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/agent/apm/common/apmenv.go b/agent/apm/common/apmenv.go index 8baabdb2..6b6e3e89 100644 --- a/agent/apm/common/apmenv.go +++ b/agent/apm/common/apmenv.go @@ -389,7 +389,8 @@ func SanitizeLogValue(value string) string { } // RunApmCommand runs "apm " with the provided environment (current process -// environment if nil). Logs only the subcommand name, never args, since args can carry secrets +// environment if nil). Captures output to detect validation failures that APM may report but +// exit with code 0 on. Logs only the subcommand name, never args, since args can carry secrets // (registry tokens, basic-auth URLs). func RunApmCommand(env []string, subcmd string, args []string) error { log.Debug(fmt.Sprintf("Running: apm %s", SanitizeLogValue(subcmd))) @@ -398,10 +399,23 @@ func RunApmCommand(env []string, subcmd string, args []string) error { if env != nil { cmd.Env = env } - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + + // Capture both stdout and stderr to detect validation failures + var outBuf, errBuf strings.Builder + cmd.Stdout = io.MultiWriter(os.Stdout, &outBuf) + cmd.Stderr = io.MultiWriter(os.Stderr, &errBuf) cmd.Stdin = os.Stdin - if err := cmd.Run(); err != nil { + + err := cmd.Run() + output := outBuf.String() + errBuf.String() + + // Check for APM validation failures: "[x]" marker or "All packages failed validation" + // APM sometimes exits with code 0 even when validation failed + if strings.Contains(output, "[x]") || strings.Contains(output, "All packages failed validation") { + return fmt.Errorf("apm %s failed: validation errors detected in output", subcmd) + } + + if err != nil { return fmt.Errorf("apm %s failed: %w", subcmd, err) } return nil From 86b9aa6b3ce9fb538a4661d0edee8c0baedf9c1b Mon Sep 17 00:00:00 2001 From: Uday Date: Sun, 2 Aug 2026 23:18:29 +0530 Subject: [PATCH 20/40] Resolve build-info repo name from --registry/default instead of host-guessing ResolveRepoNameFromRegistry previously derived the target repo purely by matching the configured Artifactory server's host across every registry in ~/.apm/config.json and apm.yml, and gave up (returning empty) whenever more than one registry shared that host. With several registries against the same instance (a normal setup, not just a test artifact), this made tagPublishedArtifactProperties() silently skip on every publish, leaving build.name/build.number/build.timestamp unset and the Artifactory build browser reporting "No path found" for a perfectly real artifact. Now repo name resolution prefers, in order: an explicit --registry flag from the publish args, then whichever registry apm.yml or config.json marks as default, and only falls back to the old host-matching heuristic if neither is present. Verified against a live Artifactory instance with 3+ registries on the same host: both the --registry path and the no-flag/default-registry path now correctly tag build.name/build.number/build.timestamp on the published artifact. Co-Authored-By: Claude Sonnet 5 --- agent/apm/commands/publish/publish.go | 2 +- agent/apm/common/apmenv.go | 105 ++++++++++++++++++++++++-- agent/apm/common/apmenv_test.go | 71 ++++++++++++++++- 3 files changed, 170 insertions(+), 8 deletions(-) diff --git a/agent/apm/commands/publish/publish.go b/agent/apm/commands/publish/publish.go index 93f761e0..68d7ea1a 100644 --- a/agent/apm/commands/publish/publish.go +++ b/agent/apm/commands/publish/publish.go @@ -83,7 +83,7 @@ func (c *ApmPublishCommand) Run() error { } else { manifestPath := filepath.Join(workingDir, apmcommon.ApmManifestName) owner := ownerFromArgs(c.args) - repoName := apmcommon.ResolveRepoNameFromRegistry(c.serverDetails, manifestPath) + repoName := apmcommon.ResolveRepoNameFromRegistry(c.serverDetails, manifestPath, c.args) if biErr := apmcommon.CollectAndSavePublishBuildInfo(manifestPath, owner, repoName, c.serverDetails, c.buildConfiguration); biErr != nil { log.Warn("apm publish completed, but build info recording failed:", biErr.Error()) } diff --git a/agent/apm/common/apmenv.go b/agent/apm/common/apmenv.go index 6b6e3e89..9e9abab1 100644 --- a/agent/apm/common/apmenv.go +++ b/agent/apm/common/apmenv.go @@ -446,11 +446,51 @@ func ConfigureApmRegistryPersistent(serverDetails *config.ServerDetails, repoNam return RunApmCommand(nil, "config", []string{"set", fmt.Sprintf("registry.%s.default", repoName), "true"}) } -// ResolveRepoNameFromRegistry returns the Artifactory repo name for serverDetails, derived from -// whichever declared registry (config.json or apm.yml) matches its host - a registry is always -// named after its repo. Returns "" if none or more than one matches (treated as unknown, not an -// error - it only affects build-info enrichment, never publish itself). -func ResolveRepoNameFromRegistry(serverDetails *config.ServerDetails, manifestPath string) string { +// registryNameFromArgs extracts the value of an explicit --registry flag from apm subcommand +// args, e.g. ["--package", "acme/pkg", "--registry", "corp-main"] -> "corp-main", or +// ["--registry=corp-main"] -> "corp-main". Returns "" if --registry isn't present. +func registryNameFromArgs(args []string) string { + for i, arg := range args { + if arg == "--registry" && i+1 < len(args) { + return args[i+1] + } + if cut, ok := strings.CutPrefix(arg, "--registry="); ok { + return cut + } + } + return "" +} + +// repoKeyFromRegistryURL extracts the Artifactory repo key from an APM registry URL of the form +// ".../api/agentpackages//" (trailing slash and any further sub-path are ignored, e.g. +// for a virtual-package path suffix). Returns "" if the URL doesn't contain that prefix. +func repoKeyFromRegistryURL(registryURL string) string { + _, after, found := strings.Cut(registryURL, agentPackagesAPIPrefix) + if !found { + return "" + } + rest := strings.Trim(after, "/") + if rest == "" { + return "" + } + if before, _, ok := strings.Cut(rest, "/"); ok { + rest = before + } + return rest +} + +// ResolveRepoNameFromRegistry returns the Artifactory repo name that an apm publish/install +// actually targeted, for build-info enrichment. Priority: +// 1. An explicit --registry in args - looked up by name in apm.yml, then +// ~/.apm/config.json, and its repo key derived from the registry URL. +// 2. No --registry passed - the registry marked default: apm.yml's registries.default first, +// then whichever ~/.apm/config.json entry has "default": true. +// 3. Neither resolves - falls back to the old host-matching heuristic (only definitive when +// exactly one configured registry matches serverDetails' host). +// +// Returns "" if nothing resolves (treated as unknown, not an error - it only affects build-info +// enrichment, never publish itself). +func ResolveRepoNameFromRegistry(serverDetails *config.ServerDetails, manifestPath string, args []string) string { if serverDetails == nil { return "" } @@ -458,6 +498,61 @@ func ResolveRepoNameFromRegistry(serverDetails *config.ServerDetails, manifestPa if err != nil { return "" } + var manifest *ApmManifest + if manifestPath != "" { + if m, loadErr := LoadManifest(manifestPath); loadErr == nil { + manifest = m + } else { + log.Debug("apm.yml parsing failed while resolving registry repo name:", loadErr.Error()) + } + } + + lookupURL := func(name string) (string, bool) { + if manifest != nil { + if entry, ok := manifest.Registries.Entries[name]; ok { + return entry.URL, true + } + } + if entry, ok := existing.Registries[name]; ok { + return entry.URL, true + } + return "", false + } + + resolveName := func(name string) string { + if name == "" { + return "" + } + url, ok := lookupURL(name) + if !ok { + return "" + } + return repoKeyFromRegistryURL(url) + } + + if explicit := registryNameFromArgs(args); explicit != "" { + if repo := resolveName(explicit); repo != "" { + return repo + } + log.Debug("apm publish: --registry " + explicit + " not found in apm.yml or ~/.apm/config.json; falling back to host-matching") + } else { + defaultName := "" + if manifest != nil { + defaultName = manifest.Registries.Default + } + if defaultName == "" { + for name, entry := range existing.Registries { + if entry.Default { + defaultName = name + break + } + } + } + if repo := resolveName(defaultName); repo != "" { + return repo + } + } + discovered := discoverMatchingRegistries(existing, manifestPath, serverDetails) if len(discovered) != 1 { return "" diff --git a/agent/apm/common/apmenv_test.go b/agent/apm/common/apmenv_test.go index 1b6b0995..42b13c8d 100644 --- a/agent/apm/common/apmenv_test.go +++ b/agent/apm/common/apmenv_test.go @@ -101,13 +101,80 @@ func TestResolveRepoNameFromRegistry(t *testing.T) { } sd := &config.ServerDetails{ArtifactoryUrl: "https://acme.jfrog.io/artifactory/"} - assert.Equal(t, tt.want, ResolveRepoNameFromRegistry(sd, "")) + assert.Equal(t, tt.want, ResolveRepoNameFromRegistry(sd, "", nil)) }) } } func TestResolveRepoNameFromRegistry_NilServerDetails(t *testing.T) { - assert.Empty(t, ResolveRepoNameFromRegistry(nil, "")) + assert.Empty(t, ResolveRepoNameFromRegistry(nil, "", nil)) +} + +func TestResolveRepoNameFromRegistry_ExplicitRegistryFlag(t *testing.T) { + // Two registries share the same host (would be ambiguous for the old host-matching + // heuristic), but an explicit --registry picks one unambiguously. + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + apmDir := filepath.Join(home, ".apm") + require.NoError(t, os.MkdirAll(apmDir, 0o755)) + configJSON := `{"registries":{ + "a":{"url":"https://acme.jfrog.io/artifactory/api/agentpackages/a-repo/"}, + "b":{"url":"https://acme.jfrog.io/artifactory/api/agentpackages/b-repo/","default":true} + }}` + require.NoError(t, os.WriteFile(filepath.Join(apmDir, "config.json"), []byte(configJSON), 0o644)) + + sd := &config.ServerDetails{ArtifactoryUrl: "https://acme.jfrog.io/artifactory/"} + + // --registry a wins over b's "default": true. + assert.Equal(t, "a-repo", ResolveRepoNameFromRegistry(sd, "", []string{"--package", "acme/pkg", "--registry", "a"})) + // --registry=b (equals form) also works. + assert.Equal(t, "b-repo", ResolveRepoNameFromRegistry(sd, "", []string{"--registry=b"})) + // Unknown --registry name falls back to host-matching, which is ambiguous here (2 matches). + assert.Equal(t, "", ResolveRepoNameFromRegistry(sd, "", []string{"--registry", "unknown"})) +} + +func TestResolveRepoNameFromRegistry_DefaultRegistryNoFlag(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + apmDir := filepath.Join(home, ".apm") + require.NoError(t, os.MkdirAll(apmDir, 0o755)) + configJSON := `{"registries":{ + "a":{"url":"https://acme.jfrog.io/artifactory/api/agentpackages/a-repo/"}, + "b":{"url":"https://acme.jfrog.io/artifactory/api/agentpackages/b-repo/","default":true} + }}` + require.NoError(t, os.WriteFile(filepath.Join(apmDir, "config.json"), []byte(configJSON), 0o644)) + + sd := &config.ServerDetails{ArtifactoryUrl: "https://acme.jfrog.io/artifactory/"} + + // No --registry flag: falls back to whichever registry has "default": true. + assert.Equal(t, "b-repo", ResolveRepoNameFromRegistry(sd, "", []string{"--package", "acme/pkg"})) +} + +func TestRepoKeyFromRegistryURL(t *testing.T) { + tests := []struct { + name string + url string + want string + }{ + {name: "trailing slash", url: "https://acme.jfrog.io/artifactory/api/agentpackages/my-repo/", want: "my-repo"}, + {name: "no trailing slash", url: "https://acme.jfrog.io/artifactory/api/agentpackages/my-repo", want: "my-repo"}, + {name: "virtual package sub-path ignored", url: "https://acme.jfrog.io/artifactory/api/agentpackages/my-repo/some/subpath", want: "my-repo"}, + {name: "missing prefix", url: "https://acme.jfrog.io/artifactory/api/other/my-repo/", want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, repoKeyFromRegistryURL(tt.url)) + }) + } +} + +func TestRegistryNameFromArgs(t *testing.T) { + assert.Equal(t, "corp-main", registryNameFromArgs([]string{"--package", "acme/pkg", "--registry", "corp-main"})) + assert.Equal(t, "corp-main", registryNameFromArgs([]string{"--registry=corp-main"})) + assert.Equal(t, "", registryNameFromArgs([]string{"--package", "acme/pkg"})) + assert.Equal(t, "", registryNameFromArgs(nil)) } func TestBuildRegistryEntry(t *testing.T) { From bfdcddb57e71f0dd28ebf7a463e6e9d0e8f08394 Mon Sep 17 00:00:00 2001 From: Uday Date: Sun, 2 Aug 2026 23:55:14 +0530 Subject: [PATCH 21/40] Unify apm install/publish build-info module IDs to name:version The install-side module ID defaulted to the bare project directory name (no version), while the publish-side module ID was always "name:version". Every other JS-ecosystem package manager in this codebase (npm, yarn) uses the same "name:version" formula (packageInfo.BuildInfoModuleId()) for every module they create, so a project's install and publish steps always merge into one module in a shared build. apm's mismatch meant an install+publish pair for the same project could never merge - the two module IDs were guaranteed to differ by construction (one always lacked a version). derivedModuleID now reads apm.yml and returns "name:version" to match, falling back to the old directory-name behavior only if the manifest can't be read or is missing a name/version (e.g. a project still mid-authoring). Verified against a live Artifactory build (module-id-check#2): apm install followed by apm publish under the same build name/number now produces a single "multiple-repos-package-3:1.0.8" module carrying both the resolved dependencies and the published artifact, instead of two separately-IDed modules as before. Review pass against the project's Go style guide (also folded into this commit since --amend only touches HEAD): - ResolveRepoNameFromRegistry was doing too much in one function; extracted registryURLByName, repoNameByRegistryName, and defaultRegistryName as small, single-purpose helpers. - A LoadManifest failure in derivedModuleID was silently swallowed with no log line, unlike the equivalent failure path in ResolveRepoNameFromRegistry; now logs at Debug for consistency and diagnosability. - Replaced string-concatenation logging with fmt.Sprintf to match the rest of the file's logging style. - Renamed a single-letter local (m -> loadedManifest) for clarity. Co-Authored-By: Claude Sonnet 5 --- agent/apm/common/apmenv.go | 94 +++++++++++++++-------------- agent/apm/common/build_info.go | 12 +++- agent/apm/common/build_info_test.go | 35 +++++++++++ 3 files changed, 95 insertions(+), 46 deletions(-) diff --git a/agent/apm/common/apmenv.go b/agent/apm/common/apmenv.go index 9e9abab1..60806e3c 100644 --- a/agent/apm/common/apmenv.go +++ b/agent/apm/common/apmenv.go @@ -479,12 +479,53 @@ func repoKeyFromRegistryURL(registryURL string) string { return rest } +// registryURLByName looks up a registry's URL by name, checking the project's apm.yml first +// (it can override or add to what's in ~/.apm/config.json) and falling back to config.json. +func registryURLByName(manifest *ApmManifest, existing *apmConfigJSON, name string) (string, bool) { + if manifest != nil { + if entry, ok := manifest.Registries.Entries[name]; ok { + return entry.URL, true + } + } + if entry, ok := existing.Registries[name]; ok { + return entry.URL, true + } + return "", false +} + +// repoNameByRegistryName resolves a registry name to its Artifactory repo key. Returns "" if the +// name is empty or isn't declared anywhere. +func repoNameByRegistryName(manifest *ApmManifest, existing *apmConfigJSON, name string) string { + if name == "" { + return "" + } + url, ok := registryURLByName(manifest, existing, name) + if !ok { + return "" + } + return repoKeyFromRegistryURL(url) +} + +// defaultRegistryName returns whichever registry is marked default: apm.yml's registries.default +// takes priority, then whichever ~/.apm/config.json entry has "default": true. Returns "" if +// neither declares one. +func defaultRegistryName(manifest *ApmManifest, existing *apmConfigJSON) string { + if manifest != nil && manifest.Registries.Default != "" { + return manifest.Registries.Default + } + for name, entry := range existing.Registries { + if entry.Default { + return name + } + } + return "" +} + // ResolveRepoNameFromRegistry returns the Artifactory repo name that an apm publish/install // actually targeted, for build-info enrichment. Priority: // 1. An explicit --registry in args - looked up by name in apm.yml, then // ~/.apm/config.json, and its repo key derived from the registry URL. -// 2. No --registry passed - the registry marked default: apm.yml's registries.default first, -// then whichever ~/.apm/config.json entry has "default": true. +// 2. No --registry passed - whichever registry is marked default (see defaultRegistryName). // 3. Neither resolves - falls back to the old host-matching heuristic (only definitive when // exactly one configured registry matches serverDetails' host). // @@ -500,57 +541,20 @@ func ResolveRepoNameFromRegistry(serverDetails *config.ServerDetails, manifestPa } var manifest *ApmManifest if manifestPath != "" { - if m, loadErr := LoadManifest(manifestPath); loadErr == nil { - manifest = m + if loadedManifest, loadErr := LoadManifest(manifestPath); loadErr == nil { + manifest = loadedManifest } else { log.Debug("apm.yml parsing failed while resolving registry repo name:", loadErr.Error()) } } - lookupURL := func(name string) (string, bool) { - if manifest != nil { - if entry, ok := manifest.Registries.Entries[name]; ok { - return entry.URL, true - } - } - if entry, ok := existing.Registries[name]; ok { - return entry.URL, true - } - return "", false - } - - resolveName := func(name string) string { - if name == "" { - return "" - } - url, ok := lookupURL(name) - if !ok { - return "" - } - return repoKeyFromRegistryURL(url) - } - if explicit := registryNameFromArgs(args); explicit != "" { - if repo := resolveName(explicit); repo != "" { - return repo - } - log.Debug("apm publish: --registry " + explicit + " not found in apm.yml or ~/.apm/config.json; falling back to host-matching") - } else { - defaultName := "" - if manifest != nil { - defaultName = manifest.Registries.Default - } - if defaultName == "" { - for name, entry := range existing.Registries { - if entry.Default { - defaultName = name - break - } - } - } - if repo := resolveName(defaultName); repo != "" { + if repo := repoNameByRegistryName(manifest, existing, explicit); repo != "" { return repo } + log.Debug(fmt.Sprintf("apm publish: --registry %s not found in apm.yml or ~/.apm/config.json; falling back to host-matching", explicit)) + } else if repo := repoNameByRegistryName(manifest, existing, defaultRegistryName(manifest, existing)); repo != "" { + return repo } discovered := discoverMatchingRegistries(existing, manifestPath, serverDetails) diff --git a/agent/apm/common/build_info.go b/agent/apm/common/build_info.go index c5a57ae5..17da60bb 100644 --- a/agent/apm/common/build_info.go +++ b/agent/apm/common/build_info.go @@ -262,8 +262,18 @@ func lookupPublishedArtifactChecksum(owner, name, version, repoName string, serv return fileDetails.Checksum } +// derivedModuleID returns the default module ID for the install-side build-info module: manifest +// name:version, matching how npm and yarn derive their module ID (packageInfo.BuildInfoModuleId(), +// always "name:version") for every module they create, install or publish alike. Falls back to +// the project directory name if apm.yml can't be read or its name/version are empty, so a project +// that's mid-authoring (no name/version yet) still gets a stable, non-empty module ID. func derivedModuleID(manifestPath string) string { - // Use directory name as module ID + manifest, err := LoadManifest(manifestPath) + if err != nil { + log.Debug("apm.yml parsing failed while deriving install module ID:", err.Error()) + } else if manifest.Name != "" && manifest.Version != "" { + return manifest.Name + ":" + manifest.Version + } dir := filepath.Dir(manifestPath) base := filepath.Base(dir) if base == "." || base == "" { diff --git a/agent/apm/common/build_info_test.go b/agent/apm/common/build_info_test.go index 76c487cb..2f52fa69 100644 --- a/agent/apm/common/build_info_test.go +++ b/agent/apm/common/build_info_test.go @@ -1,11 +1,13 @@ package apmcommon import ( + "os" "path/filepath" "testing" "github.com/jfrog/jfrog-cli-artifactory/agent/common/testutil" buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -27,3 +29,36 @@ func TestCollectAndSaveInstallBuildInfo_MissingLockfileIsNotAnError(t *testing.T ) require.NoError(t, err) } + +func TestDerivedModuleID(t *testing.T) { + t.Run("manifest with name and version -> name:version, matching npm/yarn's convention", func(t *testing.T) { + tempDir := t.TempDir() + manifestPath := filepath.Join(tempDir, ApmManifestName) + require.NoError(t, os.WriteFile(manifestPath, []byte("name: my-package\nversion: 1.2.3\n"), 0o644)) + + assert.Equal(t, "my-package:1.2.3", derivedModuleID(manifestPath)) + }) + + t.Run("no manifest file -> falls back to directory name", func(t *testing.T) { + tempDir := t.TempDir() + manifestPath := filepath.Join(tempDir, ApmManifestName) + + assert.Equal(t, filepath.Base(tempDir), derivedModuleID(manifestPath)) + }) + + t.Run("manifest missing version -> falls back to directory name", func(t *testing.T) { + tempDir := t.TempDir() + manifestPath := filepath.Join(tempDir, ApmManifestName) + require.NoError(t, os.WriteFile(manifestPath, []byte("name: my-package\n"), 0o644)) + + assert.Equal(t, filepath.Base(tempDir), derivedModuleID(manifestPath)) + }) + + t.Run("manifest missing name -> falls back to directory name", func(t *testing.T) { + tempDir := t.TempDir() + manifestPath := filepath.Join(tempDir, ApmManifestName) + require.NoError(t, os.WriteFile(manifestPath, []byte("version: 1.2.3\n"), 0o644)) + + assert.Equal(t, filepath.Base(tempDir), derivedModuleID(manifestPath)) + }) +} From dab7393ae0baae1b55517ae661bf43f1926cea4a Mon Sep 17 00:00:00 2001 From: Uday Date: Mon, 3 Aug 2026 06:54:16 +0530 Subject: [PATCH 22/40] Fix data race sharing one HttpClientDetails across concurrent checksum HEAD requests resolveChecksumsByHead built a single httputils.HttpClientDetails and passed the same &clientDetails pointer into every dependency's HEAD request, all issued concurrently (bounded by headWorkerCount). That struct's Headers field is a map, and pre-request interceptors write into it per request - concurrent goroutines racing on the same map is undefined behavior in Go, not just a benign stale read. It reproduced live as install recording dependency checksums that matched no artifact anywhere on the instance at all (confirmed via AQL across the whole server), which then showed as "No path found" for every dependency in Artifactory's build browser despite the real files being present with correct checksums the whole time (confirmed via manual HEAD requests and the lockfile's own recorded hash, both correct). Give each goroutine its own HttpClientDetails via the existing Clone() method instead of sharing one by pointer. Verified against a live 3-dependency install, including under -race: dependency checksums in the resulting build-info now match the real published artifacts' checksums exactly across multiple independent runs. Co-Authored-By: Claude Sonnet 5 Also fixes a pre-existing golangci-lint errcheck failure in generateAccessToken (unchecked resp.Body.Close(), unrelated to the checksum fix) that was failing this branch's Static Check CI job. --- agent/apm/common/apmenv.go | 2 +- agent/apm/common/checksums.go | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/agent/apm/common/apmenv.go b/agent/apm/common/apmenv.go index 60806e3c..31a4673e 100644 --- a/agent/apm/common/apmenv.go +++ b/agent/apm/common/apmenv.go @@ -93,7 +93,7 @@ func generateAccessToken(serverDetails *config.ServerDetails) string { log.Debug("Failed to generate access token:", err.Error()) return "" } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() // read-side close on an already fully-read response body, err := io.ReadAll(resp.Body) if err != nil { diff --git a/agent/apm/common/checksums.go b/agent/apm/common/checksums.go index 47be4412..f855f3f5 100644 --- a/agent/apm/common/checksums.go +++ b/agent/apm/common/checksums.go @@ -108,7 +108,15 @@ func resolveChecksumsByHead(deps []ResolvedDep, servicesManager artifactory.Arti go func(dep ResolvedDep) { defer wg.Done() defer func() { <-sem }() - fileDetails, _, err := servicesManager.Client().GetRemoteFileDetails(dep.ResolvedURL, &clientDetails) + // Each goroutine needs its own HttpClientDetails: request interceptors mutate + // its Headers map in place, so concurrent goroutines sharing one instance (or + // even one pointer to a value each holds by value but derived from a shared + // map) race on that map - Go maps are not safe for concurrent read/write, and + // the corruption isn't limited to headers; it can misattribute which response + // body/headers a goroutine ends up reading, producing checksums that belong to + // neither dependency. + depClientDetails := clientDetails.Clone() + fileDetails, _, err := servicesManager.Client().GetRemoteFileDetails(dep.ResolvedURL, depClientDetails) if err != nil { log.Debug(fmt.Sprintf("HEAD checksum lookup failed for %s: %s", dep.ID, err.Error())) return From cdbc586c0ad6f3579b94fc369d793665f4d05ad9 Mon Sep 17 00:00:00 2001 From: Uday Date: Mon, 3 Aug 2026 23:09:53 +0530 Subject: [PATCH 23/40] Add local-zip fallback for publish checksum, matching cargo/ruby's pattern lookupPublishedArtifactChecksum had no fallback tier at all: a single failed HTTP HEAD against the just-published artifact's download URL left the resulting build-info artifact record with a permanently empty checksum, with the failure only logged at Debug (invisible by default) and the overall publish still reporting success. Install's own checksum resolution already has three tiers (previous-build cache, HEAD, lockfile's own SHA-256); publish had exactly one. Both cargo (artifacts.go) and ruby (native_ruby.go, rubyFileChecksums) resolve their published artifact's checksum by hashing the local package file directly (gofrog/crypto.GetFileDetails) as their primary source - apm was the only one of the three with no equivalent local-file fallback. CollectAndSavePublishBuildInfo now falls back to hashing the local zip apm just packed (still present in the working directory under apm's own deterministic {name}-{version}.zip naming) whenever the HEAD lookup comes back empty. HEAD remains the primary/first-tried source, unchanged. Also bumped the checksum lookup failure logs from Debug to Warn, matching how property-tagging failures are already surfaced, and added a final Warn if both tiers fail so a missing checksum is never silent. Verified against a live Artifactory instance: the normal (HEAD-succeeds) case is unaffected - recorded checksum still matches the real artifact exactly. The fallback branch itself is covered by TestCollectAndSavePublishBuildInfo_ FallsBackToLocalZipWhenHeadUnavailable (serverDetails=nil forces the HEAD lookup to skip, deterministically exercising the fallback). Co-Authored-By: Claude Sonnet 5 --- agent/apm/common/build_info.go | 52 +++++++++++++++++++++++++---- agent/apm/common/build_info_test.go | 43 ++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/agent/apm/common/build_info.go b/agent/apm/common/build_info.go index 17da60bb..f83b2417 100644 --- a/agent/apm/common/build_info.go +++ b/agent/apm/common/build_info.go @@ -6,6 +6,7 @@ import ( "path/filepath" "github.com/jfrog/build-info-go/entities" + "github.com/jfrog/gofrog/crypto" artCliUtils "github.com/jfrog/jfrog-cli-artifactory/artifactory/utils" artCoreUtils "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils" buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build" @@ -214,9 +215,16 @@ func tagPublishedArtifactProperties(serverDetails *config.ServerDetails, repoNam log.Debug("apm publish: build properties set on published artifact.") } -// CollectAndSavePublishBuildInfo reads the package name/version from apm.yml, looks up the -// real checksum of the just-published artifact via an HTTP HEAD, and records it in build-info. -// Runs only when build info collection is enabled. +// CollectAndSavePublishBuildInfo reads the package name/version from apm.yml, resolves the +// just-published artifact's checksum, and records it in build-info. Runs only when build info +// collection is enabled. +// +// Checksum resolution has two tiers, same shape as install's cache-then-HEAD-then-lockfile chain: +// 1. HTTP HEAD against the artifact's own download URL (unchanged - still the primary source). +// 2. Fallback: hash the local zip apm just packed in the working directory. cargo and ruby both +// use exactly this as their primary source for a published artifact's checksum +// (crypto.GetFileDetails on the local .crate/.gem file) - apm previously had no fallback tier +// at all here, unlike its own install-side resolution. func CollectAndSavePublishBuildInfo(manifestPath, owner, repoName string, serverDetails *config.ServerDetails, buildConfig *buildUtils.BuildConfiguration) error { collectBuildInfo, err := buildConfig.IsCollectBuildInfo() if err != nil { @@ -235,20 +243,32 @@ func CollectAndSavePublishBuildInfo(manifestPath, owner, repoName string, server } checksum := lookupPublishedArtifactChecksum(owner, manifest.Name, manifest.Version, repoName, serverDetails) + if !hasAnyChecksum(checksum) { + workingDir := filepath.Dir(manifestPath) + if localChecksum, localErr := localPackedArtifactChecksum(workingDir, manifest.Name, manifest.Version); localErr == nil { + log.Debug("apm publish: HEAD lookup returned no checksum; using the local packed zip's own hash instead.") + checksum = localChecksum + } else { + log.Warn(fmt.Sprintf( + "apm publish: could not resolve a checksum for %s@%s from Artifactory or the local packed zip (%s); "+ + "build-info will record this artifact with no checksum.", + manifest.Name, manifest.Version, localErr.Error())) + } + } return SavePublishBuildInfo(owner, manifest.Name, manifest.Version, checksum, repoName, serverDetails, buildConfig) } // lookupPublishedArtifactChecksum issues an HTTP HEAD against the just-published artifact's own // download URL and reads its checksum from Artifactory's X-Checksum-* response headers. Returns -// an empty Checksum (not an error) if the repo/owner are unknown or the lookup fails, since a -// missing checksum shouldn't fail an already-successful publish. +// an empty Checksum (not an error) if the repo/owner are unknown or the lookup fails - the caller +// falls back to hashing the local packed zip, so a missing checksum here isn't yet the final word. func lookupPublishedArtifactChecksum(owner, name, version, repoName string, serverDetails *config.ServerDetails) entities.Checksum { if owner == "" || repoName == "" || serverDetails == nil { return entities.Checksum{} } servicesManager, err := artCoreUtils.CreateServiceManager(serverDetails, -1, 0, false) if err != nil { - log.Debug("apm publish: could not create service manager for checksum lookup:", err.Error()) + log.Warn("apm publish: could not create service manager for checksum lookup:", err.Error()) return entities.Checksum{} } @@ -256,12 +276,30 @@ func lookupPublishedArtifactChecksum(owner, name, version, repoName string, serv clientDetails := servicesManager.GetConfig().GetServiceDetails().CreateHttpClientDetails() fileDetails, _, err := servicesManager.Client().GetRemoteFileDetails(downloadURL, &clientDetails) if err != nil { - log.Debug(fmt.Sprintf("apm publish: checksum HEAD lookup failed for %s: %s", downloadURL, err.Error())) + log.Warn(fmt.Sprintf("apm publish: checksum HEAD lookup failed for %s: %s", downloadURL, err.Error())) return entities.Checksum{} } return fileDetails.Checksum } +// localPackedArtifactChecksum hashes the zip apm packed for this publish, still sitting in the +// working directory under apm's own deterministic naming ({name}-{version}.zip - the same +// convention SavePublishBuildInfo uses for the Artifactory path). Mirrors cargo's and ruby's +// pattern of hashing the local artifact file directly (gofrog/crypto.GetFileDetails) rather than +// only ever trusting a network round-trip for a file this process just produced itself. +func localPackedArtifactChecksum(workingDir, name, version string) (entities.Checksum, error) { + zipPath := filepath.Join(workingDir, name+"-"+version+"."+apmPackageFileExtension) + fileDetails, err := crypto.GetFileDetails(zipPath, true) + if err != nil { + return entities.Checksum{}, fmt.Errorf("hash local packed zip %s: %w", zipPath, err) + } + return entities.Checksum{ + Sha1: fileDetails.Checksum.Sha1, + Sha256: fileDetails.Checksum.Sha256, + Md5: fileDetails.Checksum.Md5, + }, nil +} + // derivedModuleID returns the default module ID for the install-side build-info module: manifest // name:version, matching how npm and yarn derive their module ID (packageInfo.BuildInfoModuleId(), // always "name:version") for every module they create, install or publish alike. Falls back to diff --git a/agent/apm/common/build_info_test.go b/agent/apm/common/build_info_test.go index 2f52fa69..137c4537 100644 --- a/agent/apm/common/build_info_test.go +++ b/agent/apm/common/build_info_test.go @@ -62,3 +62,46 @@ func TestDerivedModuleID(t *testing.T) { assert.Equal(t, filepath.Base(tempDir), derivedModuleID(manifestPath)) }) } + +func TestLocalPackedArtifactChecksum(t *testing.T) { + t.Run("hashes the deterministically-named local zip", func(t *testing.T) { + tempDir := t.TempDir() + zipPath := filepath.Join(tempDir, "my-package-1.2.3.zip") + require.NoError(t, os.WriteFile(zipPath, []byte("fake zip contents"), 0o644)) + + checksum, err := localPackedArtifactChecksum(tempDir, "my-package", "1.2.3") + require.NoError(t, err) + // "fake zip contents" sha256, computed independently (shasum -a 256) to catch any + // hashing regression, not just "some non-empty string came back". + assert.Equal(t, "58b184a82c063327f97c38ed97f21acbfb8d4bc50d52b4070b9aed8c06b4bc73", checksum.Sha256) + assert.NotEmpty(t, checksum.Sha1) + assert.NotEmpty(t, checksum.Md5) + }) + + t.Run("missing zip -> error, not a panic or silent empty checksum", func(t *testing.T) { + tempDir := t.TempDir() + + _, err := localPackedArtifactChecksum(tempDir, "never-published", "9.9.9") + require.Error(t, err) + }) +} + +func TestCollectAndSavePublishBuildInfo_FallsBackToLocalZipWhenHeadUnavailable(t *testing.T) { + testutil.WithJfrogHome(t) + tempDir := t.TempDir() + + manifestPath := filepath.Join(tempDir, ApmManifestName) + require.NoError(t, os.WriteFile(manifestPath, []byte("name: my-package\nversion: 1.2.3\n"), 0o644)) + // The zip apm would have packed for this publish, left in the working directory exactly as + // the real apm CLI leaves it after a successful publish. + require.NoError(t, os.WriteFile(filepath.Join(tempDir, "my-package-1.2.3.zip"), []byte("fake zip contents"), 0o644)) + + buildConfig := new(buildUtils.BuildConfiguration) + require.NoError(t, buildConfig.SetBuildName("test-build").SetBuildNumber("1").ValidateBuildAndModuleParams()) + + // serverDetails=nil makes lookupPublishedArtifactChecksum return an empty checksum + // immediately (no network call attempted) - the fallback local-zip hash must fire, and the + // overall call must still succeed rather than record an artifact with no checksum at all. + err := CollectAndSavePublishBuildInfo(manifestPath, "acme", "acme-repo", nil, buildConfig) + require.NoError(t, err) +} From 5fd8b16321374445e837473affd5609cc190d52f Mon Sep 17 00:00:00 2001 From: Uday Date: Mon, 3 Aug 2026 23:23:00 +0530 Subject: [PATCH 24/40] Fix build-info gaps in apm install/publish/update: dry-run, global, root, zip-path, requestedBy anchoring, and published-artifact path requestedBy anchoring: apm's own `apm deps why` never includes the consuming project as a graph node, so dependency chains built from it stopped one level short of anchoring to the build's own module id, unlike npm/yarn/go/cargo's convention (verified against build-info-go's npm.go pathToRoot construction). A direct dependency got no requestedBy chain at all; a transitive one's chain ended at its nearest direct parent instead of the module. anchorRequestedByToModule appends the module id as the terminal element of every chain, for both cases. Verified live at every depth (direct, depth-2, depth-3 transitive). Missing dry-run/global/root guards: apm install and apm update both have --dry-run and --global flags (confirmed via apm --help and live runs) that apm publish already needed a guard for. --dry-run changes nothing on disk; --global writes its lockfile to ~/.apm, not the project directory. Without a guard, running either from inside a real project directory could read that project's unrelated, stale local apm.lock.yaml and record it as if it belonged to the dry-run/global operation. install's --root DIR similarly redirects apm.lock.yaml under DIR while apm.yml stays resolved from $PWD; this is fixable rather than skip-only, so the lockfile path is now resolved relative to --root when present. A shared IsDryRunArg/IsGlobalArg pair in apmcommon replaces the publish-only isDryRunPublish, and publish's own --zip flag (a pre-built archive at an arbitrary path) is now honored by the local-zip checksum fallback instead of assuming the deterministic {name}-{version}.zip name. Published-artifact path used the wrong identifier: SavePublishBuildInfo built the artifact's Name/Path (and the HEAD checksum lookup URL) from apm.yml's own name: field, but apm actually uploads under the --package owner/repo identity, which can differ. Verified live: publishing "udaykb/pathfix-published-name" from an apm.yml named "udaykb-mismatched-manifest-name" stored the artifact at a path that doesn't exist, so Artifactory's build browser reported "No path found" even though the file was live at the real, --package-derived path - this was surfaced by checking the build browser UI directly. Threaded packageName (from --package) through to fileName/dirPath/artifactPath and the checksum HEAD URL, while the build-info module id keeps using the project's own manifest name, matching install's own convention. Also: generateAccessToken's HTTP call had no timeout at all (http.DefaultClient, no context) - a hung Artifactory response would block the whole install/publish/update command indefinitely. Added a 30s context timeout, matching the depsWhyTimeout convention elsewhere in this package. All of the above verified live against bughuntapm for every affected command (install, update, publish, each with dry-run/global/root/zip variants), including build-info published and read back via the Artifactory API and build browser. go test -race, gosec, and golangci-lint all clean across the whole agent/apm package. Co-Authored-By: Claude Sonnet 5 --- agent/apm/cli/help.go | 10 +-- agent/apm/commands/install/help.go | 2 +- agent/apm/commands/install/install.go | 32 +++++++++- agent/apm/commands/install/install_test.go | 26 ++++++++ agent/apm/commands/publish/publish.go | 63 ++++++++++++++----- agent/apm/commands/publish/publish_test.go | 19 ++++++ agent/apm/commands/update/update.go | 7 ++- agent/apm/common/apmenv.go | 23 ++++++- agent/apm/common/apmenv_test.go | 36 +++++++++++ agent/apm/common/build_info.go | 71 +++++++++++++++------- agent/apm/common/build_info_test.go | 71 +++++++++++++++++++++- agent/apm/common/lockfile.go | 8 +-- 12 files changed, 312 insertions(+), 56 deletions(-) create mode 100644 agent/apm/commands/install/install_test.go diff --git a/agent/apm/cli/help.go b/agent/apm/cli/help.go index e02b6be8..d1264329 100644 --- a/agent/apm/cli/help.go +++ b/agent/apm/cli/help.go @@ -19,11 +19,11 @@ no build-info collection - just run it as "jf agent apm ", e.g.: jf agent apm lock Resolve dependencies and write apm.lock.yaml only. jf agent apm deps why Show why a dependency is present (direct/transitive). jf agent apm outdated Show outdated locked dependencies. - jf agent apm audit Scan installed packages / validate lockfile integrity. - jf agent apm doctor Diagnose environment problems (git, network, auth). - jf agent apm view View package metadata or list remote versions. - jf agent apm marketplace ... Manage marketplaces for discovery and governance. - jf agent apm mcp ... Discover, inspect, and install MCP servers. + jf agent apm audit Scan installed packages / validate lockfile integrity. + jf agent apm doctor Diagnose environment problems (git, network, auth). + jf agent apm view View package metadata or list remote versions. + jf agent apm marketplace ... Manage marketplaces for discovery and governance. + jf agent apm mcp ... Discover, inspect, and install MCP servers. Run "apm --help" to see the full list of commands apm itself supports - all of them are reachable this way. "jf agent apm --help" shows that command's own apm-native help. diff --git a/agent/apm/commands/install/help.go b/agent/apm/commands/install/help.go index 66693b77..b26544b7 100644 --- a/agent/apm/commands/install/help.go +++ b/agent/apm/commands/install/help.go @@ -29,7 +29,7 @@ Build info: Environment: - Credentials injected via APM_REGISTRY_TOKEN_, APM_REGISTRY_USER_, APM_REGISTRY_PASS_. - Registry configuration sourced from ~/.apm/config.json (set by jf setup agent-apm). -- Lockfile apm.lock.yaml created in working directory. +- Lockfile apm.lock.yaml created in the working directory (or under --root, if passed). Related: jf agent apm publish, jf agent apm update, jf setup agent-apm` } diff --git a/agent/apm/commands/install/install.go b/agent/apm/commands/install/install.go index 08432044..c15f9efb 100644 --- a/agent/apm/commands/install/install.go +++ b/agent/apm/commands/install/install.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" apmcommon "github.com/jfrog/jfrog-cli-artifactory/agent/apm/common" agentcommon "github.com/jfrog/jfrog-cli-artifactory/agent/common" @@ -60,11 +61,21 @@ func (c *ApmInstallCommand) Run() error { return fmt.Errorf("run apm install: %w", err) } - workingDir, err := os.Getwd() - if err != nil { + if apmcommon.IsDryRunArg(c.args) { + log.Info("apm install: --dry-run - nothing was installed, skipping build-info recording.") + } else if apmcommon.IsGlobalArg(c.args) { + log.Info("apm install: --global installs to ~/.apm, not the project directory - skipping build-info recording.") + } else if workingDir, err := os.Getwd(); err != nil { log.Warn("apm install completed, but could not determine working directory for build info:", err.Error()) } else { - lockfilePath := filepath.Join(workingDir, apmcommon.ApmLockfileName) + lockfileDir := workingDir + if rootDir := rootDirFromArgs(c.args); rootDir != "" { + lockfileDir = rootDir + if !filepath.IsAbs(lockfileDir) { + lockfileDir = filepath.Join(workingDir, lockfileDir) + } + } + lockfilePath := filepath.Join(lockfileDir, apmcommon.ApmLockfileName) manifestPath := filepath.Join(workingDir, apmcommon.ApmManifestName) if biErr := apmcommon.CollectAndSaveInstallBuildInfo(lockfilePath, manifestPath, c.serverDetails, c.buildConfiguration); biErr != nil { log.Warn("apm install completed, but build info collection failed:", biErr.Error()) @@ -75,6 +86,21 @@ func (c *ApmInstallCommand) Run() error { return nil } +// rootDirFromArgs extracts the value of --root, which redirects apm_modules/ and apm.lock.yaml +// under DIR instead of the working directory (apm.yml and .apm/ still resolve from $PWD). +// Returns "" if --root isn't present. +func rootDirFromArgs(args []string) string { + for i, arg := range args { + if arg == "--root" && i+1 < len(args) { + return args[i+1] + } + if cut, ok := strings.CutPrefix(arg, "--root="); ok { + return cut + } + } + return "" +} + // RunInstall is the CLI action handler for `jf agent apm install`. func RunInstall(c *components.Context) error { if apmcommon.IsHelpRequest(c.Arguments) { diff --git a/agent/apm/commands/install/install_test.go b/agent/apm/commands/install/install_test.go new file mode 100644 index 00000000..6956572b --- /dev/null +++ b/agent/apm/commands/install/install_test.go @@ -0,0 +1,26 @@ +package install + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRootDirFromArgs(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {name: "--root with space form", args: []string{"--root", "./out"}, want: "./out"}, + {name: "--root= form", args: []string{"--root=/tmp/build"}, want: "/tmp/build"}, + {name: "no --root flag", args: []string{"--dry-run"}, want: ""}, + {name: "--root as last arg with no value", args: []string{"--root"}, want: ""}, + {name: "empty args", args: []string{}, want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, rootDirFromArgs(tt.args)) + }) + } +} diff --git a/agent/apm/commands/publish/publish.go b/agent/apm/commands/publish/publish.go index 68d7ea1a..44994f95 100644 --- a/agent/apm/commands/publish/publish.go +++ b/agent/apm/commands/publish/publish.go @@ -77,14 +77,19 @@ func (c *ApmPublishCommand) Run() error { return fmt.Errorf("run apm publish: %w", err) } - workingDir, err := os.Getwd() - if err != nil { + if apmcommon.IsDryRunArg(c.args) { + // --dry-run still packs the local zip but uploads nothing; skip build-info so the + // local-zip checksum fallback doesn't record an artifact that was never published. + log.Info("apm publish: --dry-run - nothing was uploaded, skipping build-info recording.") + } else if workingDir, err := os.Getwd(); err != nil { log.Warn("apm publish completed, but could not determine working directory for build info:", err.Error()) } else { manifestPath := filepath.Join(workingDir, apmcommon.ApmManifestName) owner := ownerFromArgs(c.args) - repoName := apmcommon.ResolveRepoNameFromRegistry(c.serverDetails, manifestPath, c.args) - if biErr := apmcommon.CollectAndSavePublishBuildInfo(manifestPath, owner, repoName, c.serverDetails, c.buildConfiguration); biErr != nil { + packageName := packageNameFromArgs(c.args) + artifactoryRepoKey := apmcommon.ResolveRepoNameFromRegistry(c.serverDetails, manifestPath, c.args) + zipPath := zipPathFromArgs(c.args) + if biErr := apmcommon.CollectAndSavePublishBuildInfo(manifestPath, owner, packageName, artifactoryRepoKey, zipPath, c.serverDetails, c.buildConfiguration); biErr != nil { log.Warn("apm publish completed, but build info recording failed:", biErr.Error()) } } @@ -93,21 +98,51 @@ func (c *ApmPublishCommand) Run() error { return nil } +// packageSpecFromArgs extracts the raw "owner/name" value of --package from args. +// Returns "" if --package isn't present. +func packageSpecFromArgs(args []string) string { + for i, arg := range args { + if arg == "--package" && i+1 < len(args) { + return args[i+1] + } + if cut, ok := strings.CutPrefix(arg, "--package="); ok { + return cut + } + } + return "" +} + // ownerFromArgs extracts the owner segment from a "--package owner/name" pair in args. // Returns "" if --package isn't present or doesn't contain a "/". func ownerFromArgs(args []string) string { + owner, _, ok := strings.Cut(packageSpecFromArgs(args), "/") + if !ok { + return "" + } + return owner +} + +// packageNameFromArgs extracts the name segment from a "--package owner/name" pair in args - +// the identifier apm actually uploads under (PUT /v1/packages/{owner}/{name}/versions/{version}), +// which is independent of apm.yml's own name: field. Returns "" if --package isn't present or +// doesn't contain a "/". +func packageNameFromArgs(args []string) string { + _, name, ok := strings.Cut(packageSpecFromArgs(args), "/") + if !ok { + return "" + } + return name +} + +// zipPathFromArgs extracts the value of --zip, the pre-built archive path apm publishes instead +// of auto-packing one. Returns "" if --zip isn't present. +func zipPathFromArgs(args []string) string { for i, arg := range args { - var pkg string - if arg == "--package" && i+1 < len(args) { - pkg = args[i+1] - } else if cut, ok := strings.CutPrefix(arg, "--package="); ok { - pkg = cut - } - if pkg == "" { - continue + if arg == "--zip" && i+1 < len(args) { + return args[i+1] } - if owner, _, ok := strings.Cut(pkg, "/"); ok { - return owner + if cut, ok := strings.CutPrefix(arg, "--zip="); ok { + return cut } } return "" diff --git a/agent/apm/commands/publish/publish_test.go b/agent/apm/commands/publish/publish_test.go index 59fed14e..458ce659 100644 --- a/agent/apm/commands/publish/publish_test.go +++ b/agent/apm/commands/publish/publish_test.go @@ -59,3 +59,22 @@ func TestOwnerFromArgs(t *testing.T) { }) } } + +func TestZipPathFromArgs(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {name: "--zip with space form", args: []string{"--zip", "./build/my-package-1.0.0.zip"}, want: "./build/my-package-1.0.0.zip"}, + {name: "--zip= form", args: []string{"--zip=./build/custom.zip"}, want: "./build/custom.zip"}, + {name: "no --zip flag", args: []string{"--package", "acme/skills-pack"}, want: ""}, + {name: "--zip as last arg with no value", args: []string{"--zip"}, want: ""}, + {name: "empty args", args: []string{}, want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, zipPathFromArgs(tt.args)) + }) + } +} diff --git a/agent/apm/commands/update/update.go b/agent/apm/commands/update/update.go index 6d442f80..2f96756d 100644 --- a/agent/apm/commands/update/update.go +++ b/agent/apm/commands/update/update.go @@ -65,8 +65,11 @@ func (c *ApmUpdateCommand) Run() error { return fmt.Errorf("run apm update: %w", err) } - workingDir, err := os.Getwd() - if err != nil { + if apmcommon.IsDryRunArg(c.args) { + log.Info("apm update: --dry-run - nothing was updated, skipping build-info recording.") + } else if apmcommon.IsGlobalArg(c.args) { + log.Info("apm update: --global updates ~/.apm, not the project directory - skipping build-info recording.") + } else if workingDir, err := os.Getwd(); err != nil { log.Warn("apm update completed, but could not determine working directory for build info:", err.Error()) } else { lockfilePath := filepath.Join(workingDir, apmcommon.ApmLockfileName) diff --git a/agent/apm/common/apmenv.go b/agent/apm/common/apmenv.go index 31a4673e..c7308eb3 100644 --- a/agent/apm/common/apmenv.go +++ b/agent/apm/common/apmenv.go @@ -1,6 +1,7 @@ package apmcommon import ( + "context" "encoding/json" "fmt" "io" @@ -10,7 +11,9 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strings" + "time" "github.com/jfrog/jfrog-cli-core/v2/utils/config" "github.com/jfrog/jfrog-client-go/utils/log" @@ -18,6 +21,10 @@ import ( const agentPackagesAPIPrefix = "/api/agentpackages/" +// generateAccessTokenTimeout bounds the token-generation HTTP call, so a hung Artifactory +// response can't block the whole install/publish/update command forever. +const generateAccessTokenTimeout = 30 * time.Second + // ApmBinaryName is the apm executable RunApmCommand always shells out to. const ApmBinaryName = "apm" @@ -80,7 +87,9 @@ func generateAccessToken(serverDetails *config.ServerDetails) string { form.Set("scope", "applied-permissions/user") form.Set("expires_in", "0") - req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(form.Encode())) + ctx, cancel := context.WithTimeout(context.Background(), generateAccessTokenTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode())) if err != nil { log.Debug("Failed to build access token request:", err.Error()) return "" @@ -591,3 +600,15 @@ func IsHelpRequest(args []string) bool { } return false } + +// IsDryRunArg returns true if the args include --dry-run. install, update, and publish all +// support it, and none of them change anything on disk when it's set. +func IsDryRunArg(args []string) bool { + return slices.Contains(args, "--dry-run") +} + +// IsGlobalArg returns true if the args include --global or -g. install and update both support +// it, writing their lockfile to ~/.apm/apm.lock.yaml instead of the project directory. +func IsGlobalArg(args []string) bool { + return slices.Contains(args, "--global") || slices.Contains(args, "-g") +} diff --git a/agent/apm/common/apmenv_test.go b/agent/apm/common/apmenv_test.go index 42b13c8d..b915be06 100644 --- a/agent/apm/common/apmenv_test.go +++ b/agent/apm/common/apmenv_test.go @@ -272,3 +272,39 @@ func TestGenerateAccessToken_NoAuth(t *testing.T) { } } +func TestIsDryRunArg(t *testing.T) { + tests := []struct { + name string + args []string + want bool + }{ + {name: "--dry-run present", args: []string{"--package", "jfrog/proj3", "--dry-run"}, want: true}, + {name: "--dry-run present, different position", args: []string{"--dry-run", "--package", "jfrog/proj3"}, want: true}, + {name: "no --dry-run", args: []string{"--package", "jfrog/proj3"}, want: false}, + {name: "empty args", args: []string{}, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsDryRunArg(tt.args)) + }) + } +} + +func TestIsGlobalArg(t *testing.T) { + tests := []struct { + name string + args []string + want bool + }{ + {name: "--global present", args: []string{"--global"}, want: true}, + {name: "-g present", args: []string{"-g"}, want: true}, + {name: "neither present", args: []string{"--dry-run"}, want: false}, + {name: "empty args", args: []string{}, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsGlobalArg(tt.args)) + }) + } +} + diff --git a/agent/apm/common/build_info.go b/agent/apm/common/build_info.go index f83b2417..eb77cee4 100644 --- a/agent/apm/common/build_info.go +++ b/agent/apm/common/build_info.go @@ -92,7 +92,9 @@ func saveInstallBuildInfo(deps []ResolvedDep, checksumMap map[string]entities.Ch entityDeps := make([]entities.Dependency, 0, len(deps)) for _, dep := range deps { checksum := checksumMap[dep.ID] - entityDeps = append(entityDeps, dep.ToEntitiesDependency(checksum)) + entityDep := dep.ToEntitiesDependency(checksum) + entityDep.RequestedBy = anchorRequestedByToModule(entityDep.RequestedBy, moduleID) + entityDeps = append(entityDeps, entityDep) } partial := &entities.Partial{ @@ -108,9 +110,25 @@ func saveInstallBuildInfo(deps []ResolvedDep, checksumMap map[string]entities.Ch return nil } -// SavePublishBuildInfo saves build artifact info for a published APM package. Path/Name match -// Artifactory's agentpackages storage layout: {repo}/{owner}/{name}/{name}-{version}.zip -func SavePublishBuildInfo(owner, name, version string, checksum entities.Checksum, repoName string, serverDetails *config.ServerDetails, buildConfig *buildUtils.BuildConfiguration) error { +// anchorRequestedByToModule appends moduleID as the terminal element of every requestedBy chain, +// so each chain ends at the consuming module's id, matching npm/yarn/go/cargo's convention. +func anchorRequestedByToModule(requestedBy [][]string, moduleID string) [][]string { + if len(requestedBy) == 0 { + return [][]string{{moduleID}} + } + anchored := make([][]string, len(requestedBy)) + for i, chain := range requestedBy { + anchored[i] = append(append([]string{}, chain...), moduleID) + } + return anchored +} + +// SavePublishBuildInfo saves build artifact info for a published APM package. moduleName is the +// project's own identity (apm.yml's name:, for the build-info module id); packageName is the +// identity apm actually uploaded under via --package (owner/packageName), which Artifactory's +// agentpackages storage layout uses for the real path: {repo}/{owner}/{packageName}/{packageName}-{version}.zip. +// These two can differ, and only packageName reflects where the artifact actually lives. +func SavePublishBuildInfo(owner, moduleName, packageName, version string, checksum entities.Checksum, repoName string, serverDetails *config.ServerDetails, buildConfig *buildUtils.BuildConfiguration) error { buildName, err := buildConfig.GetBuildName() if err != nil { return err @@ -130,14 +148,14 @@ func SavePublishBuildInfo(owner, name, version string, checksum entities.Checksu moduleID := buildConfig.GetModule() if moduleID == "" { - moduleID = name + ":" + version + moduleID = moduleName + ":" + version } - fileName := name + "-" + version + "." + apmPackageFileExtension - dirPath := name + fileName := packageName + "-" + version + "." + apmPackageFileExtension + dirPath := packageName artifactPath := fileName if owner != "" { - dirPath = owner + "/" + name + dirPath = owner + "/" + packageName artifactPath = dirPath + "/" + fileName } @@ -225,7 +243,7 @@ func tagPublishedArtifactProperties(serverDetails *config.ServerDetails, repoNam // use exactly this as their primary source for a published artifact's checksum // (crypto.GetFileDetails on the local .crate/.gem file) - apm previously had no fallback tier // at all here, unlike its own install-side resolution. -func CollectAndSavePublishBuildInfo(manifestPath, owner, repoName string, serverDetails *config.ServerDetails, buildConfig *buildUtils.BuildConfiguration) error { +func CollectAndSavePublishBuildInfo(manifestPath, owner, packageName, repoName, explicitZipPath string, serverDetails *config.ServerDetails, buildConfig *buildUtils.BuildConfiguration) error { collectBuildInfo, err := buildConfig.IsCollectBuildInfo() if err != nil { return err @@ -241,28 +259,38 @@ func CollectAndSavePublishBuildInfo(manifestPath, owner, repoName string, server log.Debug("APM manifest missing name or version; skipping publish build-info.") return nil } + if packageName == "" { + packageName = manifest.Name + } - checksum := lookupPublishedArtifactChecksum(owner, manifest.Name, manifest.Version, repoName, serverDetails) + checksum := lookupPublishedArtifactChecksum(owner, packageName, manifest.Version, repoName, serverDetails) if !hasAnyChecksum(checksum) { - workingDir := filepath.Dir(manifestPath) - if localChecksum, localErr := localPackedArtifactChecksum(workingDir, manifest.Name, manifest.Version); localErr == nil { + zipPath := explicitZipPath + if zipPath == "" { + // apm always packs the local zip from apm.yml's own name, regardless of --package. + zipPath = manifest.Name + "-" + manifest.Version + "." + apmPackageFileExtension + } + if !filepath.IsAbs(zipPath) { + zipPath = filepath.Join(filepath.Dir(manifestPath), zipPath) + } + if localChecksum, localErr := localPackedArtifactChecksum(zipPath); localErr == nil { log.Debug("apm publish: HEAD lookup returned no checksum; using the local packed zip's own hash instead.") checksum = localChecksum } else { log.Warn(fmt.Sprintf( "apm publish: could not resolve a checksum for %s@%s from Artifactory or the local packed zip (%s); "+ "build-info will record this artifact with no checksum.", - manifest.Name, manifest.Version, localErr.Error())) + packageName, manifest.Version, localErr.Error())) } } - return SavePublishBuildInfo(owner, manifest.Name, manifest.Version, checksum, repoName, serverDetails, buildConfig) + return SavePublishBuildInfo(owner, manifest.Name, packageName, manifest.Version, checksum, repoName, serverDetails, buildConfig) } // lookupPublishedArtifactChecksum issues an HTTP HEAD against the just-published artifact's own // download URL and reads its checksum from Artifactory's X-Checksum-* response headers. Returns // an empty Checksum (not an error) if the repo/owner are unknown or the lookup fails - the caller // falls back to hashing the local packed zip, so a missing checksum here isn't yet the final word. -func lookupPublishedArtifactChecksum(owner, name, version, repoName string, serverDetails *config.ServerDetails) entities.Checksum { +func lookupPublishedArtifactChecksum(owner, packageName, version, repoName string, serverDetails *config.ServerDetails) entities.Checksum { if owner == "" || repoName == "" || serverDetails == nil { return entities.Checksum{} } @@ -272,7 +300,7 @@ func lookupPublishedArtifactChecksum(owner, name, version, repoName string, serv return entities.Checksum{} } - downloadURL := AgentPackagesBaseURL(serverDetails, repoName) + "v1/packages/" + owner + "/" + name + "/versions/" + version + "/download" + downloadURL := AgentPackagesBaseURL(serverDetails, repoName) + "v1/packages/" + owner + "/" + packageName + "/versions/" + version + "/download" clientDetails := servicesManager.GetConfig().GetServiceDetails().CreateHttpClientDetails() fileDetails, _, err := servicesManager.Client().GetRemoteFileDetails(downloadURL, &clientDetails) if err != nil { @@ -282,13 +310,10 @@ func lookupPublishedArtifactChecksum(owner, name, version, repoName string, serv return fileDetails.Checksum } -// localPackedArtifactChecksum hashes the zip apm packed for this publish, still sitting in the -// working directory under apm's own deterministic naming ({name}-{version}.zip - the same -// convention SavePublishBuildInfo uses for the Artifactory path). Mirrors cargo's and ruby's -// pattern of hashing the local artifact file directly (gofrog/crypto.GetFileDetails) rather than -// only ever trusting a network round-trip for a file this process just produced itself. -func localPackedArtifactChecksum(workingDir, name, version string) (entities.Checksum, error) { - zipPath := filepath.Join(workingDir, name+"-"+version+"."+apmPackageFileExtension) +// localPackedArtifactChecksum hashes the zip apm just published from disk (the auto-packed +// {name}-{version}.zip, or whatever path --zip pointed at), mirroring cargo's and ruby's pattern +// of hashing the local artifact file directly (gofrog/crypto.GetFileDetails). +func localPackedArtifactChecksum(zipPath string) (entities.Checksum, error) { fileDetails, err := crypto.GetFileDetails(zipPath, true) if err != nil { return entities.Checksum{}, fmt.Errorf("hash local packed zip %s: %w", zipPath, err) diff --git a/agent/apm/common/build_info_test.go b/agent/apm/common/build_info_test.go index 137c4537..832e4ae1 100644 --- a/agent/apm/common/build_info_test.go +++ b/agent/apm/common/build_info_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + "github.com/jfrog/build-info-go/entities" "github.com/jfrog/jfrog-cli-artifactory/agent/common/testutil" buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build" "github.com/stretchr/testify/assert" @@ -69,7 +70,7 @@ func TestLocalPackedArtifactChecksum(t *testing.T) { zipPath := filepath.Join(tempDir, "my-package-1.2.3.zip") require.NoError(t, os.WriteFile(zipPath, []byte("fake zip contents"), 0o644)) - checksum, err := localPackedArtifactChecksum(tempDir, "my-package", "1.2.3") + checksum, err := localPackedArtifactChecksum(zipPath) require.NoError(t, err) // "fake zip contents" sha256, computed independently (shasum -a 256) to catch any // hashing regression, not just "some non-empty string came back". @@ -81,7 +82,7 @@ func TestLocalPackedArtifactChecksum(t *testing.T) { t.Run("missing zip -> error, not a panic or silent empty checksum", func(t *testing.T) { tempDir := t.TempDir() - _, err := localPackedArtifactChecksum(tempDir, "never-published", "9.9.9") + _, err := localPackedArtifactChecksum(filepath.Join(tempDir, "never-published-9.9.9.zip")) require.Error(t, err) }) } @@ -102,6 +103,70 @@ func TestCollectAndSavePublishBuildInfo_FallsBackToLocalZipWhenHeadUnavailable(t // serverDetails=nil makes lookupPublishedArtifactChecksum return an empty checksum // immediately (no network call attempted) - the fallback local-zip hash must fire, and the // overall call must still succeed rather than record an artifact with no checksum at all. - err := CollectAndSavePublishBuildInfo(manifestPath, "acme", "acme-repo", nil, buildConfig) + err := CollectAndSavePublishBuildInfo(manifestPath, "acme", "my-package", "acme-repo", "", nil, buildConfig) require.NoError(t, err) } + +func TestCollectAndSavePublishBuildInfo_UsesExplicitZipPath(t *testing.T) { + testutil.WithJfrogHome(t) + tempDir := t.TempDir() + + manifestPath := filepath.Join(tempDir, ApmManifestName) + require.NoError(t, os.WriteFile(manifestPath, []byte("name: my-package\nversion: 1.2.3\n"), 0o644)) + // --zip lets the caller publish a pre-built archive under any name/path, unlike apm's own + // deterministic {name}-{version}.zip auto-pack naming. + require.NoError(t, os.MkdirAll(filepath.Join(tempDir, "build"), 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(tempDir, "build", "custom.zip"), []byte("fake zip contents"), 0o644)) + + buildConfig := new(buildUtils.BuildConfiguration) + require.NoError(t, buildConfig.SetBuildName("test-build").SetBuildNumber("1").ValidateBuildAndModuleParams()) + + err := CollectAndSavePublishBuildInfo(manifestPath, "acme", "my-package", "acme-repo", filepath.Join("build", "custom.zip"), nil, buildConfig) + require.NoError(t, err) +} + +func TestSavePublishBuildInfo_ArtifactPathUsesPackageNameNotManifestName(t *testing.T) { + testutil.WithJfrogHome(t) + + buildConfig := new(buildUtils.BuildConfiguration) + require.NoError(t, buildConfig.SetBuildName("test-build-package-name-path").SetBuildNumber("1").ValidateBuildAndModuleParams()) + // Partials for this build name/number live outside WithJfrogHome's per-test temp dir (see + // note below), so clean them up explicitly rather than leaking state into later test runs. + t.Cleanup(func() { _ = buildUtils.RemoveBuildDir("test-build-package-name-path", "1", "") }) // best-effort test cleanup + + // apm.yml's own name: field ("internal-name") can differ from the --package identity apm + // actually uploads under ("published-name") - the artifact record must reflect where the + // file really landed in Artifactory, not apm.yml's name. + err := SavePublishBuildInfo("acme", "internal-name", "published-name", "1.0.0", entities.Checksum{Sha256: "abc"}, "acme-repo", nil, buildConfig) + require.NoError(t, err) + + // WithJfrogHome's isolation doesn't extend to build-info's own partials directory (it reads a + // build-name-derived path outside the per-test temp dir), so a leftover partial from an + // earlier run of this same build name/number can still be present; check the most recent one + // rather than requiring exactly one. + partials, err := buildUtils.ReadPartialBuildInfoFiles("test-build-package-name-path", "1", "") + require.NoError(t, err) + require.NotEmpty(t, partials) + lastPartial := partials[len(partials)-1] + require.Len(t, lastPartial.Artifacts, 1) + artifact := lastPartial.Artifacts[0] + assert.Equal(t, "published-name-1.0.0.zip", artifact.Name) + assert.Equal(t, "acme/published-name/published-name-1.0.0.zip", artifact.Path) +} + +func TestAnchorRequestedByToModule(t *testing.T) { + t.Run("direct dependency with no chain gets one anchored to the module id", func(t *testing.T) { + got := anchorRequestedByToModule(nil, "consumer:1.0.0") + assert.Equal(t, [][]string{{"consumer:1.0.0"}}, got) + }) + + t.Run("transitive dependency's chain gets the module id appended as its terminal element", func(t *testing.T) { + got := anchorRequestedByToModule([][]string{{"owner/direct-dep"}}, "consumer:1.0.0") + assert.Equal(t, [][]string{{"owner/direct-dep", "consumer:1.0.0"}}, got) + }) + + t.Run("multiple diamond-dependency paths each get anchored independently", func(t *testing.T) { + got := anchorRequestedByToModule([][]string{{"owner/a"}, {"owner/b"}}, "consumer:1.0.0") + assert.Equal(t, [][]string{{"owner/a", "consumer:1.0.0"}, {"owner/b", "consumer:1.0.0"}}, got) + }) +} diff --git a/agent/apm/common/lockfile.go b/agent/apm/common/lockfile.go index 4776310b..1b365598 100644 --- a/agent/apm/common/lockfile.go +++ b/agent/apm/common/lockfile.go @@ -28,11 +28,11 @@ type ApmLockedPackage struct { ResolvedHash string `yaml:"resolved_hash"` } -// LoadLockFile reads and parses apm.lock.yaml at path. Every caller in this codebase constructs -// path from a working directory joined with the fixed ApmLockfileName ("apm.lock.yaml"), never -// from unsanitized user input. +// LoadLockFile reads and parses apm.lock.yaml at path. Every caller constructs path from a +// directory (the working directory, or install's own --root value) joined with the fixed +// ApmLockfileName ("apm.lock.yaml") - never a raw, arbitrary user-supplied filename. func LoadLockFile(path string) (*ApmLockFile, error) { - data, err := os.ReadFile(path) // #nosec G304 -- path is always workingDir+ApmLockfileName, constructed by the caller, never user-supplied + data, err := os.ReadFile(path) // #nosec G304 -- path is always /apm.lock.yaml; the same apm invocation already wrote to this exact directory itself when --root was passed if err != nil { return nil, errorutils.CheckError(err) } From e3b9a3440d9277b701bf7ade94712192fd9905f8 Mon Sep 17 00:00:00 2001 From: Uday Date: Tue, 4 Aug 2026 14:25:00 +0530 Subject: [PATCH 25/40] Give apm dependencies a single dev/prod/transitive scope, pnpm-style apm's own lockfile already records is_dev per dependency (correctly propagated down the whole transitive chain of a devDependencies entry, and correctly resolved to false when the same package is also needed via a real prod path - verified live) but nothing read it: every dependency, dev or not, was recorded with scope prod (direct) or transitive, making a devDependency indistinguishable from a real runtime dependency in build-info. Modeled the fix on pnpm's own resolver in this repo (artifactory/commands/pnpm/dependency_resolver.go's addScope), which treats prod/dev/transitive as mutually exclusive with priority prod > dev > transitive, rather than npm's build-info-go collector, which only ever has dev-or-prod with no transitive concept at all. Replaced the former scopes-as-strings return from parseDepsWhyOutput/ resolveScopeAndRequestedBy (renamed resolveDirectAndRequestedBy) with a plain isDirect bool, and added finalScope(isDirect, isDev) to compute the single resulting scope value. Deliberately not a literal copy of pnpm's model: pnpm's own children always inherit "transitive" rather than "dev", even under a dev dependency, so a package only reachable via a devDependency shows as "transitive" there. apm's is_dev flag already propagates correctly through the whole transitive chain, which is more accurate information; this fix uses that flag directly rather than discarding it to match pnpm's cruder non-propagating rule. Verified live against bughuntapm: a real prod dependency, a direct devDependencies entry, that entry's transitive child, and a dependency needed via both a prod and a dev path (which correctly resolves to "prod") all produced the expected single scope value in published build-info. go test, gosec, and golangci-lint all clean. Co-Authored-By: Claude Sonnet 5 --- agent/apm/common/dependency_resolver.go | 57 +++++++++----- agent/apm/common/dependency_resolver_test.go | 82 +++++++++++++++++--- agent/apm/common/lockfile.go | 1 + 3 files changed, 109 insertions(+), 31 deletions(-) diff --git a/agent/apm/common/dependency_resolver.go b/agent/apm/common/dependency_resolver.go index 51bdb7d2..58685312 100644 --- a/agent/apm/common/dependency_resolver.go +++ b/agent/apm/common/dependency_resolver.go @@ -20,12 +20,30 @@ const depsWhyWorkerCount = 15 // depsWhyTimeout bounds a single `apm deps why` subprocess, so a hang can't block forever. const depsWhyTimeout = 30 * time.Second -// Dependency scope names, matching the "prod"/"transitive" convention Alpine and Cargo use. +// Dependency scope names. A dependency gets exactly one of these, chosen by finalScope's +// priority ladder (prod > dev > transitive) - the same mutually-exclusive model pnpm's own +// resolver in this repo uses (artifactory/commands/pnpm/dependency_resolver.go's addScope), +// rather than combining them. const ( apmScopeProd = "prod" + apmScopeDev = "dev" apmScopeTransitive = "transitive" ) +// finalScope picks a single scope from whether a dependency is direct and whether it's a dev +// dependency, following pnpm's priority ladder: prod > dev > transitive. isDev is apm's own +// lockfile is_dev flag, which already resolves a dependency needed by both a prod and a dev +// path to false, so prod-over-dev priority falls out for free. +func finalScope(isDirect, isDev bool) string { + if isDirect && !isDev { + return apmScopeProd + } + if isDev { + return apmScopeDev + } + return apmScopeTransitive +} + // ResolvedDep holds a single APM registry dependency ready for build-info. type ResolvedDep struct { ID string // "owner/repo:version" @@ -56,13 +74,13 @@ func ResolveDependencies(lockfilePath string) ([]ResolvedDep, error) { go func(i int, pkg ApmLockedPackage) { defer wg.Done() defer func() { <-sem }() - scopes, requestedBy := resolveScopeAndRequestedBy(workingDir, pkg.RepoURL) + isDirect, requestedBy := resolveDirectAndRequestedBy(workingDir, pkg.RepoURL) deps[i] = ResolvedDep{ ID: pkg.DepID(), RepoURL: pkg.RepoURL, SHA256: SHA256Hex(pkg.ResolvedHash), ResolvedURL: pkg.ResolvedURL, - Scopes: scopes, + Scopes: []string{finalScope(isDirect, pkg.IsDev)}, RequestedBy: requestedBy, } }(i, pkg) @@ -98,16 +116,17 @@ type apmDepsWhyResult struct { } `json:"paths"` } -// resolveScopeAndRequestedBy shells out to `apm deps why --json` in workingDir to +// resolveDirectAndRequestedBy shells out to `apm deps why --json` in workingDir to // determine whether a dependency is direct or transitive, and - for transitive ones - which -// package(s) requested it. Best-effort: any failure falls back to prod scope with no -// requestedBy rather than failing the whole build-info collection. -func resolveScopeAndRequestedBy(workingDir, repoURL string) (scopes []string, requestedBy [][]string) { +// package(s) requested it. Best-effort: any failure defaults to isDirect=true (matching the +// old "default to prod scope" fallback) with no requestedBy, rather than failing the whole +// build-info collection. +func resolveDirectAndRequestedBy(workingDir, repoURL string) (isDirect bool, requestedBy [][]string) { // repoURL comes from apm.lock.yaml, not a trusted CLI arg - reject flag-shaped values so a // tampered lockfile can't smuggle an extra flag into the apm invocation below. if strings.HasPrefix(repoURL, "-") { - log.Debug(fmt.Sprintf("Refusing to run apm deps why for suspicious repo_url %q, defaulting to prod scope", repoURL)) - return []string{apmScopeProd}, nil + log.Debug(fmt.Sprintf("Refusing to run apm deps why for suspicious repo_url %q, defaulting to direct", repoURL)) + return true, nil } ctx, cancel := context.WithTimeout(context.Background(), depsWhyTimeout) @@ -116,24 +135,24 @@ func resolveScopeAndRequestedBy(workingDir, repoURL string) (scopes []string, re cmd.Dir = workingDir out, err := cmd.Output() if err != nil { - log.Debug(fmt.Sprintf("apm deps why %s failed, defaulting to prod scope: %s", repoURL, err)) - return []string{apmScopeProd}, nil + log.Debug(fmt.Sprintf("apm deps why %s failed, defaulting to direct: %s", repoURL, err)) + return true, nil } return parseDepsWhyOutput(out, repoURL) } -// parseDepsWhyOutput turns `apm deps why --json` output into a scope and requestedBy chains. -// Split out from resolveScopeAndRequestedBy so the parsing logic is testable without shelling -// out to a real apm binary. -func parseDepsWhyOutput(out []byte, repoURL string) (scopes []string, requestedBy [][]string) { +// parseDepsWhyOutput turns `apm deps why --json` output into a direct/transitive flag and +// requestedBy chains. Split out from resolveDirectAndRequestedBy so the parsing logic is +// testable without shelling out to a real apm binary. +func parseDepsWhyOutput(out []byte, repoURL string) (isDirect bool, requestedBy [][]string) { var result apmDepsWhyResult if err := json.Unmarshal(out, &result); err != nil { - log.Debug(fmt.Sprintf("could not parse apm deps why %s output, defaulting to prod scope: %s", repoURL, err)) - return []string{apmScopeProd}, nil + log.Debug(fmt.Sprintf("could not parse apm deps why %s output, defaulting to direct: %s", repoURL, err)) + return true, nil } if result.Package.IsDirect { - return []string{apmScopeProd}, nil + return true, nil } for _, path := range result.Paths { @@ -150,5 +169,5 @@ func parseDepsWhyOutput(out []byte, repoURL string) (scopes []string, requestedB } requestedBy = append(requestedBy, chain) } - return []string{apmScopeTransitive}, requestedBy + return false, requestedBy } diff --git a/agent/apm/common/dependency_resolver_test.go b/agent/apm/common/dependency_resolver_test.go index b699f0e8..25d6d276 100644 --- a/agent/apm/common/dependency_resolver_test.go +++ b/agent/apm/common/dependency_resolver_test.go @@ -1,25 +1,82 @@ package apmcommon import ( + "os" + "path/filepath" "strings" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestResolveScopeAndRequestedBy_RejectsFlagShapedRepoURL(t *testing.T) { - scopes, requestedBy := resolveScopeAndRequestedBy(t.TempDir(), "--global") - assert.Equal(t, []string{apmScopeProd}, scopes) +func TestResolveDirectAndRequestedBy_RejectsFlagShapedRepoURL(t *testing.T) { + isDirect, requestedBy := resolveDirectAndRequestedBy(t.TempDir(), "--global") + assert.True(t, isDirect) assert.Empty(t, requestedBy) } +func TestFinalScope(t *testing.T) { + tests := []struct { + name string + isDirect bool + isDev bool + wantScope string + }{ + {name: "direct, not dev -> prod", isDirect: true, isDev: false, wantScope: apmScopeProd}, + {name: "direct, dev -> dev", isDirect: true, isDev: true, wantScope: apmScopeDev}, + {name: "transitive, dev -> dev", isDirect: false, isDev: true, wantScope: apmScopeDev}, + {name: "transitive, not dev -> transitive", isDirect: false, isDev: false, wantScope: apmScopeTransitive}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantScope, finalScope(tt.isDirect, tt.isDev)) + }) + } +} + +func TestResolveDependencies_ScopeFollowsPriorityLadder(t *testing.T) { + tempDir := t.TempDir() + lockfilePath := filepath.Join(tempDir, ApmLockfileName) + // Flag-shaped repo_urls deterministically resolve isDirect=true with no requestedBy (see + // TestResolveDirectAndRequestedBy_RejectsFlagShapedRepoURL) without needing a real apm + // subprocess to succeed - exactly what's needed here to isolate the scope computation. + content := ` +lockfile_version: "2" +dependencies: + - repo_url: "--fake-dev-dep" + version: 1.0.0 + source: registry + resolved_hash: sha256:abc123 + is_dev: true + - repo_url: "--fake-prod-dep" + version: 1.0.0 + source: registry + resolved_hash: sha256:def456 +` + require.NoError(t, os.WriteFile(lockfilePath, []byte(content), 0o644)) + + deps, err := ResolveDependencies(lockfilePath) + require.NoError(t, err) + require.Len(t, deps, 2) + + byID := make(map[string][]string, len(deps)) + for _, dep := range deps { + byID[dep.ID] = dep.Scopes + } + // Both are isDirect=true (the flag-shaped fallback), so the ladder distinguishes them + // purely on is_dev: dev wins for the first, prod for the second. + assert.Equal(t, []string{apmScopeDev}, byID["--fake-dev-dep:1.0.0"]) + assert.Equal(t, []string{apmScopeProd}, byID["--fake-prod-dep:1.0.0"]) +} + func TestParseDepsWhyOutput_DirectDependency(t *testing.T) { out := []byte(`{ "package": {"is_direct": true, "repo_url": "uday/pkg-consumer", "source": "registry", "version": "1.0.0"}, "paths": [{"chain": [{"is_direct": true, "repo_url": "uday/pkg-consumer"}]}] }`) - scopes, requestedBy := parseDepsWhyOutput(out, "uday/pkg-consumer") - assert.Equal(t, []string{apmScopeProd}, scopes) + isDirect, requestedBy := parseDepsWhyOutput(out, "uday/pkg-consumer") + assert.True(t, isDirect) assert.Empty(t, requestedBy) } @@ -31,8 +88,8 @@ func TestParseDepsWhyOutput_TransitiveDependency(t *testing.T) { {"is_direct": false, "repo_url": "uday/pkg-base"} ]}] }`) - scopes, requestedBy := parseDepsWhyOutput(out, "uday/pkg-base") - assert.Equal(t, []string{"transitive"}, scopes) + isDirect, requestedBy := parseDepsWhyOutput(out, "uday/pkg-base") + assert.False(t, isDirect) assert.Equal(t, [][]string{{"uday/pkg-consumer"}}, requestedBy) } @@ -44,14 +101,14 @@ func TestParseDepsWhyOutput_MultipleParentPaths(t *testing.T) { {"chain": [{"is_direct": true, "repo_url": "b/pkg"}, {"is_direct": false, "repo_url": "shared/lib"}]} ] }`) - scopes, requestedBy := parseDepsWhyOutput(out, "shared/lib") - assert.Equal(t, []string{"transitive"}, scopes) + isDirect, requestedBy := parseDepsWhyOutput(out, "shared/lib") + assert.False(t, isDirect) assert.Equal(t, [][]string{{"a/pkg"}, {"b/pkg"}}, requestedBy) } -func TestParseDepsWhyOutput_MalformedJSONFallsBackToProd(t *testing.T) { - scopes, requestedBy := parseDepsWhyOutput([]byte("not json"), "uday/pkg-base") - assert.Equal(t, []string{apmScopeProd}, scopes) +func TestParseDepsWhyOutput_MalformedJSONFallsBackToDirect(t *testing.T) { + isDirect, requestedBy := parseDepsWhyOutput([]byte("not json"), "uday/pkg-base") + assert.True(t, isDirect) assert.Empty(t, requestedBy) } @@ -62,6 +119,7 @@ func TestParseDepsWhyOutput_MalformedJSONFallsBackToProd(t *testing.T) { // over the older, now-minority "runtime" convention. func TestApmScopeConstantsAreStable(t *testing.T) { assert.Equal(t, "prod", apmScopeProd) + assert.Equal(t, "dev", apmScopeDev) assert.Equal(t, "transitive", apmScopeTransitive) } diff --git a/agent/apm/common/lockfile.go b/agent/apm/common/lockfile.go index 1b365598..5833aa6f 100644 --- a/agent/apm/common/lockfile.go +++ b/agent/apm/common/lockfile.go @@ -26,6 +26,7 @@ type ApmLockedPackage struct { ContentHash string `yaml:"content_hash"` ResolvedURL string `yaml:"resolved_url"` // full agentpackages download URL ResolvedHash string `yaml:"resolved_hash"` + IsDev bool `yaml:"is_dev"` // set for devDependencies and everything they pull in transitively } // LoadLockFile reads and parses apm.lock.yaml at path. Every caller constructs path from a From 04dcc73c6985c223805aa47f872de4380797e980 Mon Sep 17 00:00:00 2001 From: Uday Date: Tue, 4 Aug 2026 14:55:45 +0530 Subject: [PATCH 26/40] Add real command examples to install/update help, matching publish's install and update's AIDescription only had two generic examples each ("bare install", "install + build-name"), missing every flag this session's live testing found actually matters: install's --dry-run, --global, --dev, and the #^1.0.0 vs #1.0.0 exact-pin distinction; update's --yes, which is required to apply anything at all - update always shows a plan and asks for confirmation, and exits with an error instead of applying without it, even with a real plan present. publish already had richer examples; brought install and update up to the same standard and added a --dry-run example to publish's own list for consistency. Also prefixed every example across all three with a one-line "# what this does" comment, each on its own line above the command. Co-Authored-By: Claude Sonnet 5 --- agent/apm/commands/install/help.go | 21 ++++++++++++++++++++- agent/apm/commands/publish/help.go | 8 ++++++++ agent/apm/commands/update/help.go | 14 ++++++++++++-- 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/agent/apm/commands/install/help.go b/agent/apm/commands/install/help.go index b26544b7..30e5b46a 100644 --- a/agent/apm/commands/install/help.go +++ b/agent/apm/commands/install/help.go @@ -18,8 +18,27 @@ Prerequisites: - Read permission on the source Artifactory agentpackages repository. Common patterns: + # Install everything already declared in apm.yml $ jf agent apm install - $ jf agent apm install --build-name=my-build --build-number=1 + + # Add and install a package at an exact version + $ jf agent apm install my-org/my-package#1.0.0 --target claude + + # Install a floating version range and record build-info + $ jf agent apm install "my-org/my-package#^1.0.0" --target claude --build-name=my-build --build-number=1 + + # Preview what would be installed without installing anything + $ jf agent apm install --dry-run + + # Add a dev-only dependency + $ jf agent apm install --dev my-org/my-dev-tool#1.0.0 + +Note: +- A bare tag (#1.0.0) is an exact pin: apm update never moves it. Use a semver range + (#^1.0.0, #~1.0.0) if you want later updates to pick up newer matching versions. +- --dry-run shows what would be installed without installing, and skips build-info + entirely (there's nothing real to record). --global installs to ~/.apm instead of the + current project and also skips build-info, since it isn't scoped to any one project. Build info: - Enabled with --build-name and --build-number flags. diff --git a/agent/apm/commands/publish/help.go b/agent/apm/commands/publish/help.go index 984e2d9c..80fa2966 100644 --- a/agent/apm/commands/publish/help.go +++ b/agent/apm/commands/publish/help.go @@ -19,10 +19,18 @@ Prerequisites: - Registry configured via jf setup agent-apm or apm.yml's registries: block. Common patterns: + # Auto-pack apm.yml/.apm/ and publish $ jf agent apm publish --package my-org/my-package + + # Publish and record build-info $ jf agent apm publish --package my-org/my-package --build-name=my-build --build-number=1 + + # Group multiple packages under one build-info module $ jf agent apm publish --package my-org/my-package --build-name=my-build --build-number=1 --module=my-module + # Preview what would be uploaded without publishing anything + $ jf agent apm publish --package my-org/my-package --dry-run + Note: - --package is required and must be passed explicitly (owner/name); it is not inferred from a bare positional argument. diff --git a/agent/apm/commands/update/help.go b/agent/apm/commands/update/help.go index 64e11b4e..e1b8ef1a 100644 --- a/agent/apm/commands/update/help.go +++ b/agent/apm/commands/update/help.go @@ -20,8 +20,18 @@ Prerequisites: - Registry configured via jf setup agent-apm or apm.yml's registries: block. Common patterns: - $ jf agent apm update - $ jf agent apm update --build-name=my-build --build-number=1 + # Preview the update plan without applying anything + $ jf agent apm update --dry-run + + # Apply the update plan and record build-info + $ jf agent apm update --yes --build-name=my-build --build-number=1 + +Note: +- --yes is required to actually apply an update. update always shows a plan and asks for + confirmation first; without --yes it exits with an error instead of applying anything, + even when there's a real plan to apply. +- A dependency pinned to a bare tag (#1.0.0) never has anything to update - only a semver + range (#^1.0.0, #~1.0.0) gives update room to move to a newer matching version. Version constraints: - Respects version constraints in apm.yml's dependencies section. From 20eccdfaa0244ae14cee0b57d0889385602a2a16 Mon Sep 17 00:00:00 2001 From: Uday Date: Tue, 4 Aug 2026 21:17:37 +0530 Subject: [PATCH 27/40] Only log APM build-info skip messages when collection is enabled Gate --dry-run/--global "skipping build-info" Info logs on ShouldCollectBuildInfo so plain installs without --build-name/--build-number stay quiet. Co-authored-by: Cursor --- agent/apm/commands/install/install.go | 40 +++++++++++++++------------ agent/apm/commands/publish/publish.go | 34 +++++++++++++---------- agent/apm/commands/update/update.go | 28 +++++++++++-------- agent/apm/common/apmenv_test.go | 1 - agent/apm/common/build_info.go | 14 ++++++++-- agent/apm/common/build_info_test.go | 17 ++++++++++++ 6 files changed, 89 insertions(+), 45 deletions(-) diff --git a/agent/apm/commands/install/install.go b/agent/apm/commands/install/install.go index c15f9efb..9fcdd428 100644 --- a/agent/apm/commands/install/install.go +++ b/agent/apm/commands/install/install.go @@ -61,24 +61,30 @@ func (c *ApmInstallCommand) Run() error { return fmt.Errorf("run apm install: %w", err) } - if apmcommon.IsDryRunArg(c.args) { - log.Info("apm install: --dry-run - nothing was installed, skipping build-info recording.") - } else if apmcommon.IsGlobalArg(c.args) { - log.Info("apm install: --global installs to ~/.apm, not the project directory - skipping build-info recording.") - } else if workingDir, err := os.Getwd(); err != nil { - log.Warn("apm install completed, but could not determine working directory for build info:", err.Error()) - } else { - lockfileDir := workingDir - if rootDir := rootDirFromArgs(c.args); rootDir != "" { - lockfileDir = rootDir - if !filepath.IsAbs(lockfileDir) { - lockfileDir = filepath.Join(workingDir, lockfileDir) + // Only mention / collect build-info when the user asked for it (--build-name/--build-number or env). + collectBuildInfo, err := apmcommon.ShouldCollectBuildInfo(c.buildConfiguration) + if err != nil { + log.Warn("apm install completed, but could not determine build-info collection state:", err.Error()) + } else if collectBuildInfo { + if apmcommon.IsDryRunArg(c.args) { + log.Info("apm install: --dry-run - nothing was installed, skipping build-info recording.") + } else if apmcommon.IsGlobalArg(c.args) { + log.Info("apm install: --global installs to ~/.apm, not the project directory - skipping build-info recording.") + } else if workingDir, wdErr := os.Getwd(); wdErr != nil { + log.Warn("apm install completed, but could not determine working directory for build info:", wdErr.Error()) + } else { + lockfileDir := workingDir + if rootDir := rootDirFromArgs(c.args); rootDir != "" { + lockfileDir = rootDir + if !filepath.IsAbs(lockfileDir) { + lockfileDir = filepath.Join(workingDir, lockfileDir) + } + } + lockfilePath := filepath.Join(lockfileDir, apmcommon.ApmLockfileName) + manifestPath := filepath.Join(workingDir, apmcommon.ApmManifestName) + if biErr := apmcommon.CollectAndSaveInstallBuildInfo(lockfilePath, manifestPath, c.serverDetails, c.buildConfiguration); biErr != nil { + log.Warn("apm install completed, but build info collection failed:", biErr.Error()) } - } - lockfilePath := filepath.Join(lockfileDir, apmcommon.ApmLockfileName) - manifestPath := filepath.Join(workingDir, apmcommon.ApmManifestName) - if biErr := apmcommon.CollectAndSaveInstallBuildInfo(lockfilePath, manifestPath, c.serverDetails, c.buildConfiguration); biErr != nil { - log.Warn("apm install completed, but build info collection failed:", biErr.Error()) } } diff --git a/agent/apm/commands/publish/publish.go b/agent/apm/commands/publish/publish.go index 44994f95..7b50cb59 100644 --- a/agent/apm/commands/publish/publish.go +++ b/agent/apm/commands/publish/publish.go @@ -77,20 +77,26 @@ func (c *ApmPublishCommand) Run() error { return fmt.Errorf("run apm publish: %w", err) } - if apmcommon.IsDryRunArg(c.args) { - // --dry-run still packs the local zip but uploads nothing; skip build-info so the - // local-zip checksum fallback doesn't record an artifact that was never published. - log.Info("apm publish: --dry-run - nothing was uploaded, skipping build-info recording.") - } else if workingDir, err := os.Getwd(); err != nil { - log.Warn("apm publish completed, but could not determine working directory for build info:", err.Error()) - } else { - manifestPath := filepath.Join(workingDir, apmcommon.ApmManifestName) - owner := ownerFromArgs(c.args) - packageName := packageNameFromArgs(c.args) - artifactoryRepoKey := apmcommon.ResolveRepoNameFromRegistry(c.serverDetails, manifestPath, c.args) - zipPath := zipPathFromArgs(c.args) - if biErr := apmcommon.CollectAndSavePublishBuildInfo(manifestPath, owner, packageName, artifactoryRepoKey, zipPath, c.serverDetails, c.buildConfiguration); biErr != nil { - log.Warn("apm publish completed, but build info recording failed:", biErr.Error()) + // Only mention / collect build-info when the user asked for it (--build-name/--build-number or env). + collectBuildInfo, err := apmcommon.ShouldCollectBuildInfo(c.buildConfiguration) + if err != nil { + log.Warn("apm publish completed, but could not determine build-info collection state:", err.Error()) + } else if collectBuildInfo { + if apmcommon.IsDryRunArg(c.args) { + // --dry-run still packs the local zip but uploads nothing; skip build-info so the + // local-zip checksum fallback doesn't record an artifact that was never published. + log.Info("apm publish: --dry-run - nothing was uploaded, skipping build-info recording.") + } else if workingDir, wdErr := os.Getwd(); wdErr != nil { + log.Warn("apm publish completed, but could not determine working directory for build info:", wdErr.Error()) + } else { + manifestPath := filepath.Join(workingDir, apmcommon.ApmManifestName) + owner := ownerFromArgs(c.args) + packageName := packageNameFromArgs(c.args) + artifactoryRepoKey := apmcommon.ResolveRepoNameFromRegistry(c.serverDetails, manifestPath, c.args) + zipPath := zipPathFromArgs(c.args) + if biErr := apmcommon.CollectAndSavePublishBuildInfo(manifestPath, owner, packageName, artifactoryRepoKey, zipPath, c.serverDetails, c.buildConfiguration); biErr != nil { + log.Warn("apm publish completed, but build info recording failed:", biErr.Error()) + } } } diff --git a/agent/apm/commands/update/update.go b/agent/apm/commands/update/update.go index 2f96756d..65bda9e4 100644 --- a/agent/apm/commands/update/update.go +++ b/agent/apm/commands/update/update.go @@ -65,17 +65,23 @@ func (c *ApmUpdateCommand) Run() error { return fmt.Errorf("run apm update: %w", err) } - if apmcommon.IsDryRunArg(c.args) { - log.Info("apm update: --dry-run - nothing was updated, skipping build-info recording.") - } else if apmcommon.IsGlobalArg(c.args) { - log.Info("apm update: --global updates ~/.apm, not the project directory - skipping build-info recording.") - } else if workingDir, err := os.Getwd(); err != nil { - log.Warn("apm update completed, but could not determine working directory for build info:", err.Error()) - } else { - lockfilePath := filepath.Join(workingDir, apmcommon.ApmLockfileName) - manifestPath := filepath.Join(workingDir, apmcommon.ApmManifestName) - if biErr := apmcommon.CollectAndSaveInstallBuildInfo(lockfilePath, manifestPath, c.serverDetails, c.buildConfiguration); biErr != nil { - log.Warn("apm update completed, but build info collection failed:", biErr.Error()) + // Only mention / collect build-info when the user asked for it (--build-name/--build-number or env). + collectBuildInfo, err := apmcommon.ShouldCollectBuildInfo(c.buildConfiguration) + if err != nil { + log.Warn("apm update completed, but could not determine build-info collection state:", err.Error()) + } else if collectBuildInfo { + if apmcommon.IsDryRunArg(c.args) { + log.Info("apm update: --dry-run - nothing was updated, skipping build-info recording.") + } else if apmcommon.IsGlobalArg(c.args) { + log.Info("apm update: --global updates ~/.apm, not the project directory - skipping build-info recording.") + } else if workingDir, wdErr := os.Getwd(); wdErr != nil { + log.Warn("apm update completed, but could not determine working directory for build info:", wdErr.Error()) + } else { + lockfilePath := filepath.Join(workingDir, apmcommon.ApmLockfileName) + manifestPath := filepath.Join(workingDir, apmcommon.ApmManifestName) + if biErr := apmcommon.CollectAndSaveInstallBuildInfo(lockfilePath, manifestPath, c.serverDetails, c.buildConfiguration); biErr != nil { + log.Warn("apm update completed, but build info collection failed:", biErr.Error()) + } } } diff --git a/agent/apm/common/apmenv_test.go b/agent/apm/common/apmenv_test.go index b915be06..5fa2e3ed 100644 --- a/agent/apm/common/apmenv_test.go +++ b/agent/apm/common/apmenv_test.go @@ -307,4 +307,3 @@ func TestIsGlobalArg(t *testing.T) { }) } } - diff --git a/agent/apm/common/build_info.go b/agent/apm/common/build_info.go index eb77cee4..1f2d2a90 100644 --- a/agent/apm/common/build_info.go +++ b/agent/apm/common/build_info.go @@ -31,10 +31,20 @@ const apmPackageFileExtension = "zip" // buildUtils.PrepareBuildPrerequisites reports build-info collection isn't enabled. const errBuildInfoNotEnabled = "build info collection is not enabled" +// ShouldCollectBuildInfo reports whether the user enabled build-info collection +// (--build-name/--build-number or JFROG_CLI_BUILD_*). Used by install/update/publish to avoid +// "skipping build-info" noise when collection was never requested. +func ShouldCollectBuildInfo(buildConfig *buildUtils.BuildConfiguration) (bool, error) { + if buildConfig == nil { + return false, nil + } + return buildConfig.IsCollectBuildInfo() +} + // CollectAndSaveInstallBuildInfo reads the lockfile, resolves checksums, and saves build-info. // Runs only when build info collection is enabled. func CollectAndSaveInstallBuildInfo(lockfilePath, manifestPath string, serverDetails *config.ServerDetails, buildConfig *buildUtils.BuildConfiguration) error { - collectBuildInfo, err := buildConfig.IsCollectBuildInfo() + collectBuildInfo, err := ShouldCollectBuildInfo(buildConfig) if err != nil { return err } @@ -244,7 +254,7 @@ func tagPublishedArtifactProperties(serverDetails *config.ServerDetails, repoNam // (crypto.GetFileDetails on the local .crate/.gem file) - apm previously had no fallback tier // at all here, unlike its own install-side resolution. func CollectAndSavePublishBuildInfo(manifestPath, owner, packageName, repoName, explicitZipPath string, serverDetails *config.ServerDetails, buildConfig *buildUtils.BuildConfiguration) error { - collectBuildInfo, err := buildConfig.IsCollectBuildInfo() + collectBuildInfo, err := ShouldCollectBuildInfo(buildConfig) if err != nil { return err } diff --git a/agent/apm/common/build_info_test.go b/agent/apm/common/build_info_test.go index 832e4ae1..120e7256 100644 --- a/agent/apm/common/build_info_test.go +++ b/agent/apm/common/build_info_test.go @@ -12,6 +12,23 @@ import ( "github.com/stretchr/testify/require" ) +func TestShouldCollectBuildInfo(t *testing.T) { + collect, err := ShouldCollectBuildInfo(nil) + require.NoError(t, err) + assert.False(t, collect, "nil build config must not enable collection") + + empty := new(buildUtils.BuildConfiguration) + collect, err = ShouldCollectBuildInfo(empty) + require.NoError(t, err) + assert.False(t, collect, "no build-name/number must not enable collection") + + enabled := new(buildUtils.BuildConfiguration) + require.NoError(t, enabled.SetBuildName("b").SetBuildNumber("1").ValidateBuildAndModuleParams()) + collect, err = ShouldCollectBuildInfo(enabled) + require.NoError(t, err) + assert.True(t, collect) +} + func TestCollectAndSaveInstallBuildInfo_MissingLockfileIsNotAnError(t *testing.T) { testutil.WithJfrogHome(t) tempDir := t.TempDir() From 207f450c69101a96739ec16fbb861e1b5e4a9a39 Mon Sep 17 00:00:00 2001 From: Uday Date: Tue, 4 Aug 2026 21:19:05 +0530 Subject: [PATCH 28/40] Remove APM --global build-info skip special-casing Align APM with other package managers: do not special-case --global for build-info. Also rewrite agent-apm AI help to the cargo/ruby lean style (When to use / Prerequisites / Common patterns / Gotchas / Related). Co-authored-by: Cursor --- agent/apm/cli/help.go | 44 +++++++++++--------------- agent/apm/commands/install/help.go | 43 +++++++------------------ agent/apm/commands/install/install.go | 2 -- agent/apm/commands/publish/help.go | 45 +++++++-------------------- agent/apm/commands/update/help.go | 45 ++++++++------------------- agent/apm/commands/update/update.go | 2 -- agent/apm/common/apmenv.go | 6 ---- agent/apm/common/apmenv_test.go | 18 ----------- 8 files changed, 54 insertions(+), 151 deletions(-) diff --git a/agent/apm/cli/help.go b/agent/apm/cli/help.go index d1264329..24dc5626 100644 --- a/agent/apm/cli/help.go +++ b/agent/apm/cli/help.go @@ -5,35 +5,27 @@ func GetDescription() string { } func GetAIDescription() string { - return `Run any apm command against JFrog Artifactory-backed registries, with credentials -injected automatically - no apm config set or manual token handling required. + return `Run apm against Artifactory-backed registries with credentials injected automatically. Dedicated subcommands install, publish, and update also collect build-info when --build-name and --build-number are set; every other apm command is forwarded with the same authenticated registry access but no build-info collection. -Build-info commands (dedicated subcommands, listed under COMMANDS below): - jf agent apm install Install dependencies from apm.yml / apm.lock.yaml. - jf agent apm publish Publish a package to an agentpackages repository. - jf agent apm update Refresh dependencies to their latest matching refs. -These three collect and can record build-info (--build-name/--build-number). - -Every other apm command also works here, with the same authenticated registry access but -no build-info collection - just run it as "jf agent apm ", e.g.: - jf agent apm lock Resolve dependencies and write apm.lock.yaml only. - jf agent apm deps why Show why a dependency is present (direct/transitive). - jf agent apm outdated Show outdated locked dependencies. - jf agent apm audit Scan installed packages / validate lockfile integrity. - jf agent apm doctor Diagnose environment problems (git, network, auth). - jf agent apm view View package metadata or list remote versions. - jf agent apm marketplace ... Manage marketplaces for discovery and governance. - jf agent apm mcp ... Discover, inspect, and install MCP servers. -Run "apm --help" to see the full list of commands apm itself supports - all of them are -reachable this way. "jf agent apm --help" shows that command's own apm-native help. +When to use: +- Running apm install / publish / update with Artifactory auth and optional build-info. +- Running any other apm command (lock, outdated, audit, doctor, view, marketplace, mcp, ...) through jf for authenticated registry access. Prerequisites: -- apm CLI installed and in PATH. -- Registry configured via jf setup agent-apm (persistent) or apm.yml's registries: block. +- apm CLI installed and on PATH. +- Registry configured via 'jf setup agent-apm' or an apm.yml registries: block. +- A configured JFrog Platform server (jf c add / jf login), or pass --server-id. + +Common patterns: + $ jf agent apm install --build-name=my-build --build-number=1 + $ jf agent apm publish --package my-org/my-package --build-name=my-build --build-number=1 + $ jf agent apm update --yes --build-name=my-build --build-number=1 + $ jf agent apm lock + $ jf agent apm outdated -Environment: -- Credentials injected via APM_REGISTRY_TOKEN_, APM_REGISTRY_USER_, APM_REGISTRY_PASS_. -- Registry configuration sourced from apm.yml's registries: block or ~/.apm/config.json (set by jf setup agent-apm). +Gotchas: +- Build-info is collected only by install, publish, and update, and only when both --build-name and --build-number are provided; publish it afterwards with 'jf rt build-publish'. +- 'jf agent apm --help' shows that command's own help; 'apm --help' lists every native apm command reachable this way. -Related: jf setup agent-apm` +Related: jf setup agent-apm, jf agent apm install, jf agent apm publish, jf agent apm update, jf rt build-publish` } diff --git a/agent/apm/commands/install/help.go b/agent/apm/commands/install/help.go index 30e5b46a..2f938ef2 100644 --- a/agent/apm/commands/install/help.go +++ b/agent/apm/commands/install/help.go @@ -5,50 +5,29 @@ func GetDescription() string { } func GetAIDescription() string { - return `Install packages declared in apm.yml with authenticated access to JFrog Artifactory registries. + return `Install packages declared in apm.yml with authenticated access to Artifactory agentpackages repositories, and optionally record a build-info of installed dependencies. When to use: - Installing packages into an agent project that has apm.yml configured. -- Accessing private or curated packages from Artifactory via registry credentials. -- Collecting build-info about package dependencies in CI/CD pipelines. +- Pulling private or curated packages from Artifactory. +- Capturing build-info for an install by passing --build-name and --build-number. Prerequisites: -- apm CLI (>= 0.1.0) installed and in PATH. -- A registry declared in apm.yml's registries: block or configured via jf setup agent-apm. +- apm CLI installed and on PATH. +- A registry declared in apm.yml's registries: block, or configured via 'jf setup agent-apm'. - Read permission on the source Artifactory agentpackages repository. Common patterns: - # Install everything already declared in apm.yml $ jf agent apm install - - # Add and install a package at an exact version $ jf agent apm install my-org/my-package#1.0.0 --target claude - - # Install a floating version range and record build-info $ jf agent apm install "my-org/my-package#^1.0.0" --target claude --build-name=my-build --build-number=1 - - # Preview what would be installed without installing anything - $ jf agent apm install --dry-run - - # Add a dev-only dependency $ jf agent apm install --dev my-org/my-dev-tool#1.0.0 + $ jf agent apm install --dry-run -Note: -- A bare tag (#1.0.0) is an exact pin: apm update never moves it. Use a semver range - (#^1.0.0, #~1.0.0) if you want later updates to pick up newer matching versions. -- --dry-run shows what would be installed without installing, and skips build-info - entirely (there's nothing real to record). --global installs to ~/.apm instead of the - current project and also skips build-info, since it isn't scoped to any one project. - -Build info: -- Enabled with --build-name and --build-number flags. -- Captures installed packages and their transitive dependencies. -- Published to Artifactory for traceability and compliance. - -Environment: -- Credentials injected via APM_REGISTRY_TOKEN_, APM_REGISTRY_USER_, APM_REGISTRY_PASS_. -- Registry configuration sourced from ~/.apm/config.json (set by jf setup agent-apm). -- Lockfile apm.lock.yaml created in the working directory (or under --root, if passed). +Gotchas: +- A bare tag (#1.0.0) is an exact pin: apm update never moves it. Use a semver range (#^1.0.0, #~1.0.0) if later updates should pick up newer matching versions. +- --dry-run previews the install without changing anything and skips build-info (nothing real to record). +- Build-info is collected only when both --build-name and --build-number are provided; publish it afterwards with 'jf rt build-publish'. -Related: jf agent apm publish, jf agent apm update, jf setup agent-apm` +Related: jf agent apm publish, jf agent apm update, jf setup agent-apm, jf rt build-publish` } diff --git a/agent/apm/commands/install/install.go b/agent/apm/commands/install/install.go index 9fcdd428..d360850f 100644 --- a/agent/apm/commands/install/install.go +++ b/agent/apm/commands/install/install.go @@ -68,8 +68,6 @@ func (c *ApmInstallCommand) Run() error { } else if collectBuildInfo { if apmcommon.IsDryRunArg(c.args) { log.Info("apm install: --dry-run - nothing was installed, skipping build-info recording.") - } else if apmcommon.IsGlobalArg(c.args) { - log.Info("apm install: --global installs to ~/.apm, not the project directory - skipping build-info recording.") } else if workingDir, wdErr := os.Getwd(); wdErr != nil { log.Warn("apm install completed, but could not determine working directory for build info:", wdErr.Error()) } else { diff --git a/agent/apm/commands/publish/help.go b/agent/apm/commands/publish/help.go index 80fa2966..0ce38b58 100644 --- a/agent/apm/commands/publish/help.go +++ b/agent/apm/commands/publish/help.go @@ -5,50 +5,29 @@ func GetDescription() string { } func GetAIDescription() string { - return `Publish an agent package to a JFrog Artifactory agentpackages repository with authenticated access. + return `Publish an agent package to an Artifactory agentpackages repository with authenticated access, and optionally record a build-info of the published package. When to use: -- Publishing custom agent packages for installation across multiple projects. -- Packaging skills, tools, or other agent extensions for organizational use. -- Creating reproducible, versioned deployments of agent components. +- Publishing custom agent packages (skills, tools, extensions) for reuse across projects. +- Creating versioned, reproducible deployments of agent components. +- Capturing build-info for a publish by passing --build-name and --build-number. Prerequisites: -- apm CLI (>= 0.1.0) installed and in PATH. -- An apm.yml file in the package directory (or parent directories). +- apm CLI installed and on PATH. +- An apm.yml in the package directory (or a parent) declaring name, version, and description. - Write permission on the Artifactory agentpackages repository. -- Registry configured via jf setup agent-apm or apm.yml's registries: block. +- Registry configured via 'jf setup agent-apm' or an apm.yml registries: block. Common patterns: - # Auto-pack apm.yml/.apm/ and publish $ jf agent apm publish --package my-org/my-package - - # Publish and record build-info $ jf agent apm publish --package my-org/my-package --build-name=my-build --build-number=1 - - # Group multiple packages under one build-info module $ jf agent apm publish --package my-org/my-package --build-name=my-build --build-number=1 --module=my-module - - # Preview what would be uploaded without publishing anything $ jf agent apm publish --package my-org/my-package --dry-run -Note: -- --package is required and must be passed explicitly (owner/name); it is not inferred from a - bare positional argument. - -Package format: -- Directory with apm.yml declaring name, version, and description. -- Optional skills/ subdirectory containing Cursor Agent Skills. -- Version in apm.yml becomes the published package version. - -Build info: -- Enabled with --build-name and --build-number flags. -- Captures package metadata and publishing source. -- Published to Artifactory for traceability and compliance. -- Optional --module to group multiple packages in the same build. - -Environment: -- Credentials injected via APM_REGISTRY_TOKEN_, APM_REGISTRY_USER_, APM_REGISTRY_PASS_. -- Registry configuration sourced from ~/.apm/config.json (set by jf setup agent-apm). +Gotchas: +- --package is required (owner/name); it is not inferred from a bare positional argument. +- --dry-run previews the upload without publishing and skips build-info. +- Build-info is collected only when both --build-name and --build-number are provided; optional --module groups packages under one module. Publish afterwards with 'jf rt build-publish'. -Related: jf agent apm install, jf agent apm update, jf setup agent-apm` +Related: jf agent apm install, jf agent apm update, jf setup agent-apm, jf rt build-publish` } diff --git a/agent/apm/commands/update/help.go b/agent/apm/commands/update/help.go index e1b8ef1a..977faa14 100644 --- a/agent/apm/commands/update/help.go +++ b/agent/apm/commands/update/help.go @@ -5,47 +5,28 @@ func GetDescription() string { } func GetAIDescription() string { - return `Update packages in apm.yml to their latest matching versions and refresh the lockfile with authenticated access to JFrog Artifactory. + return `Update packages in apm.yml to their latest versions matching declared constraints, refresh apm.lock.yaml, and optionally record a build-info of the resolved dependencies. When to use: -- Keeping agent package dependencies up-to-date within declared version constraints. -- Re-resolving dependencies when new versions are published to the registry. -- Collecting updated build-info about package dependencies in CI/CD pipelines. +- Keeping agent package dependencies current within declared version constraints. +- Re-resolving when new versions are published to the registry. +- Capturing build-info for an update by passing --build-name and --build-number. Prerequisites: -- apm CLI (>= 0.1.0) installed and in PATH. -- An apm.yml file in the working directory with dependencies declared. -- A lockfile apm.lock.yaml already created (e.g., via jf agent apm install). +- apm CLI installed and on PATH. +- An apm.yml with dependencies declared, and an existing apm.lock.yaml (e.g. from 'jf agent apm install'). - Read permission on the source Artifactory agentpackages repository. -- Registry configured via jf setup agent-apm or apm.yml's registries: block. +- Registry configured via 'jf setup agent-apm' or an apm.yml registries: block. Common patterns: - # Preview the update plan without applying anything $ jf agent apm update --dry-run - - # Apply the update plan and record build-info $ jf agent apm update --yes --build-name=my-build --build-number=1 -Note: -- --yes is required to actually apply an update. update always shows a plan and asks for - confirmation first; without --yes it exits with an error instead of applying anything, - even when there's a real plan to apply. -- A dependency pinned to a bare tag (#1.0.0) never has anything to update - only a semver - range (#^1.0.0, #~1.0.0) gives update room to move to a newer matching version. - -Version constraints: -- Respects version constraints in apm.yml's dependencies section. -- Fetches latest versions matching declared constraints. -- Updates apm.lock.yaml with resolved versions and checksums. - -Build info: -- Enabled with --build-name and --build-number flags. -- Captures updated packages and their transitive dependencies. -- Published to Artifactory for traceability and compliance. - -Environment: -- Credentials injected via APM_REGISTRY_TOKEN_, APM_REGISTRY_USER_, APM_REGISTRY_PASS_. -- Registry configuration sourced from ~/.apm/config.json (set by jf setup agent-apm). +Gotchas: +- --yes is required to apply an update. Without it, update shows a plan and exits with an error instead of applying anything. +- A dependency pinned to a bare tag (#1.0.0) never has anything to update; only a semver range (#^1.0.0, #~1.0.0) can move to a newer matching version. +- --dry-run previews the plan without applying changes and skips build-info. +- Build-info is collected only when both --build-name and --build-number are provided; publish it afterwards with 'jf rt build-publish'. -Related: jf agent apm install, jf agent apm publish, jf setup agent-apm` +Related: jf agent apm install, jf agent apm publish, jf setup agent-apm, jf rt build-publish` } diff --git a/agent/apm/commands/update/update.go b/agent/apm/commands/update/update.go index 65bda9e4..75618af6 100644 --- a/agent/apm/commands/update/update.go +++ b/agent/apm/commands/update/update.go @@ -72,8 +72,6 @@ func (c *ApmUpdateCommand) Run() error { } else if collectBuildInfo { if apmcommon.IsDryRunArg(c.args) { log.Info("apm update: --dry-run - nothing was updated, skipping build-info recording.") - } else if apmcommon.IsGlobalArg(c.args) { - log.Info("apm update: --global updates ~/.apm, not the project directory - skipping build-info recording.") } else if workingDir, wdErr := os.Getwd(); wdErr != nil { log.Warn("apm update completed, but could not determine working directory for build info:", wdErr.Error()) } else { diff --git a/agent/apm/common/apmenv.go b/agent/apm/common/apmenv.go index c7308eb3..2b4ba7ea 100644 --- a/agent/apm/common/apmenv.go +++ b/agent/apm/common/apmenv.go @@ -606,9 +606,3 @@ func IsHelpRequest(args []string) bool { func IsDryRunArg(args []string) bool { return slices.Contains(args, "--dry-run") } - -// IsGlobalArg returns true if the args include --global or -g. install and update both support -// it, writing their lockfile to ~/.apm/apm.lock.yaml instead of the project directory. -func IsGlobalArg(args []string) bool { - return slices.Contains(args, "--global") || slices.Contains(args, "-g") -} diff --git a/agent/apm/common/apmenv_test.go b/agent/apm/common/apmenv_test.go index 5fa2e3ed..29aa01e2 100644 --- a/agent/apm/common/apmenv_test.go +++ b/agent/apm/common/apmenv_test.go @@ -289,21 +289,3 @@ func TestIsDryRunArg(t *testing.T) { }) } } - -func TestIsGlobalArg(t *testing.T) { - tests := []struct { - name string - args []string - want bool - }{ - {name: "--global present", args: []string{"--global"}, want: true}, - {name: "-g present", args: []string{"-g"}, want: true}, - {name: "neither present", args: []string{"--dry-run"}, want: false}, - {name: "empty args", args: []string{}, want: false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, IsGlobalArg(tt.args)) - }) - } -} From be0940c8fb66a8783c5110d5c58a3f9e79e3f99f Mon Sep 17 00:00:00 2001 From: Uday Date: Tue, 4 Aug 2026 22:14:56 +0530 Subject: [PATCH 29/40] Add apt command package from main for CLI compatibility Master jfrog-cli imports artifactory/commands/apt; cherry-pick the package onto the APM branch so e2e can pin a single SHA with both apt and agent apm. Co-authored-by: Cursor --- artifactory/commands/apt/auth.go | 185 ++++++++++ artifactory/commands/apt/auth_test.go | 253 ++++++++++++++ artifactory/commands/apt/command.go | 233 +++++++++++++ artifactory/commands/apt/command_test.go | 92 +++++ artifactory/commands/apt/setup.go | 335 ++++++++++++++++++ artifactory/commands/apt/setup_test.go | 420 +++++++++++++++++++++++ 6 files changed, 1518 insertions(+) create mode 100644 artifactory/commands/apt/auth.go create mode 100644 artifactory/commands/apt/auth_test.go create mode 100644 artifactory/commands/apt/command.go create mode 100644 artifactory/commands/apt/command_test.go create mode 100644 artifactory/commands/apt/setup.go create mode 100644 artifactory/commands/apt/setup_test.go diff --git a/artifactory/commands/apt/auth.go b/artifactory/commands/apt/auth.go new file mode 100644 index 00000000..22c6162f --- /dev/null +++ b/artifactory/commands/apt/auth.go @@ -0,0 +1,185 @@ +package apt + +import ( + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + + artutils "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +var keyringsDir = "/etc/apt/keyrings" + +// WriteTempSourcesList creates a temporary apt sources.list file in $TMPDIR +// containing ONLY the JFrog Artifactory entry, credentials embedded in the URL. +// Returns the path to the temp file. +// +// Used with: apt-get -o Dir::Etc::sourcelist= -o Dir::Etc::sourceparts=- ... +// which replaces the main sources.list AND disables sources.list.d/, so this file +// is the sole active source. Packages therefore resolve exclusively through +// Artifactory — no other configured repository can serve them. This assumes the +// target repo is (or proxies) a complete apt source so dependencies resolve. +// +// Caller must defer os.Remove(path) to clean up. +func WriteTempSourcesList(serverDetails *config.ServerDetails, repoName, dist, component string, trusted bool) (string, error) { + jfrogLine, err := buildSourcesLine(serverDetails, repoName, dist, component, trusted, "") + if err != nil { + return "", err + } + + content := jfrogLine + "\n" + + f, err := os.CreateTemp("", "jfrog-apt-sources-*.list") + if err != nil { + return "", fmt.Errorf("create temp sources list: %w", err) + } + if err = f.Chmod(0600); err != nil { + _ = f.Close() + _ = os.Remove(f.Name()) + return "", fmt.Errorf("chmod temp sources list: %w", err) + } + if _, err = f.WriteString(content); err != nil { + _ = f.Close() + _ = os.Remove(f.Name()) + return "", fmt.Errorf("write temp sources list: %w", err) + } + if err = f.Close(); err != nil { + _ = os.Remove(f.Name()) + return "", fmt.Errorf("close temp sources list: %w", err) + } + return f.Name(), nil +} + +// FetchAndInstallPublicKey downloads the GPG public key for the given Artifactory +// Debian repository and writes it to /etc/apt/keyrings/jfrog--.asc. +// Returns the keyring path. +// +// The dist suffix in the filename ensures per-dist isolation: removing noble +// entries does not affect jammy keys, and vice versa. +// +// Auto-detects the signing key: queries the repo config for primaryKeyPairRef, +// fetches that named key if set, falls back to the default key otherwise. +// Requires root. The .asc extension tells apt the key is ASCII-armored — no +// gpg --dearmor step needed (supported since apt 1.4 / Ubuntu 22.04+). +func FetchAndInstallPublicKey(serverDetails *config.ServerDetails, repoName, dist string) (string, error) { + if err := validateSourcesToken("repo", repoName); err != nil { + return "", err + } + if err := validateSourcesToken("dist", dist); err != nil { + return "", err + } + + sm, err := artutils.CreateServiceManager(serverDetails, 3, 0, false) + if err != nil { + return "", fmt.Errorf("create service manager: %w", err) + } + + var repoDetails struct { + PrimaryKeyPairRef string `json:"primaryKeyPairRef"` + } + if err = sm.GetRepository(repoName, &repoDetails); err != nil { + log.Debug("Could not determine repo signing key name, falling back to default: " + err.Error()) + } + + artURL := strings.TrimSuffix(serverDetails.GetArtifactoryUrl(), "/") + var keyURL string + if repoDetails.PrimaryKeyPairRef != "" { + keyURL = fmt.Sprintf("%s/api/security/keypair/%s/public", artURL, repoDetails.PrimaryKeyPairRef) + log.Debug(fmt.Sprintf("Using signing key '%s' for repository '%s'", repoDetails.PrimaryKeyPairRef, repoName)) + } else { + keyURL = artURL + "/api/gpg/key/public" + log.Debug("Using default Artifactory GPG public key") + } + + httpClientDetails := sm.GetConfig().GetServiceDetails().CreateHttpClientDetails() + resp, body, _, err := sm.Client().SendGet(keyURL, true, &httpClientDetails) + if err != nil { + return "", fmt.Errorf("fetch public key: request failed: %w", err) + } + if resp.StatusCode == http.StatusNotFound { + return "", fmt.Errorf("fetch public key: repository '%s' has no GPG signing key configured — set a key pair on the repository in Artifactory, or use --trusted to skip GPG verification", repoName) + } + if err = errorutils.CheckResponseStatusWithBody(resp, body, http.StatusOK); err != nil { + return "", fmt.Errorf("fetch public key: %w", err) + } + + if err := os.MkdirAll(keyringsDir, 0755); err != nil { + return "", fmt.Errorf("create keyrings dir: %w", err) + } + + keyPath := filepath.Join(keyringsDir, fmt.Sprintf("jfrog-%s-%s.asc", repoName, dist)) + if err := os.WriteFile(keyPath, body, 0644); err != nil { + return "", fmt.Errorf("write public key: %w", err) + } + return keyPath, nil +} + +// buildSourcesLine returns a single deb sources.list line with credentials embedded in the URL. +// - trusted=true → [trusted=yes] skip GPG verification (testing only) +// - signedBy != "" → [signed-by=] scope trust to a specific keyring (prod) +// - both empty → no options (requires repo is already signed + trusted system-wide) +func buildSourcesLine(serverDetails *config.ServerDetails, repoName, dist, component string, trusted bool, signedBy string) (string, error) { + if err := validateSourcesToken("dist", dist); err != nil { + return "", err + } + if err := validateSourcesToken("component", component); err != nil { + return "", err + } + if err := validateSourcesToken("repo", repoName); err != nil { + return "", err + } + + user, password, err := serverDetails.GetAuthenticationCredentials() + if err != nil { + return "", err + } + + artURL := strings.TrimSuffix(serverDetails.GetArtifactoryUrl(), "/") + repoPath := artURL + "/" + repoName + + parsed, err := url.Parse(repoPath) + if err != nil { + return "", fmt.Errorf("parse artifactory URL: %w", err) + } + parsed.User = url.UserPassword(user, password) + + options := "" + switch { + case signedBy != "": + options = fmt.Sprintf("[signed-by=%s] ", signedBy) + case trusted: + options = "[trusted=yes] " + } + return fmt.Sprintf("deb %s%s %s %s", options, parsed.String(), dist, component), nil +} + +// validateSourcesToken rejects values that would corrupt a sources.list line or +// escape the apt config directories when interpolated into a filesystem path. +// Newlines, carriage returns, tabs or null bytes would inject extra sources +// lines; path separators or ".." would let a token (repo/dist) write or delete +// files outside /etc/apt/{sources.list.d,preferences.d,keyrings}. A space is +// rejected for every field except --component, where it is the intended +// separator for multiple components ("main contrib non-free"). +func validateSourcesToken(field, value string) error { + if value == "" { + return fmt.Errorf("--%s must not be empty", field) + } + if strings.ContainsAny(value, `/\`) || strings.Contains(value, "..") { + return fmt.Errorf("invalid character in --%s: path separators are not allowed", field) + } + for _, r := range value { + if r == '\n' || r == '\r' || r == '\000' || r == '\t' { + return fmt.Errorf("invalid character in --%s: control characters are not allowed", field) + } + if r == ' ' && field != "component" { + return fmt.Errorf("invalid character in --%s: spaces are not allowed", field) + } + } + return nil +} diff --git a/artifactory/commands/apt/auth_test.go b/artifactory/commands/apt/auth_test.go new file mode 100644 index 00000000..daa2d4ee --- /dev/null +++ b/artifactory/commands/apt/auth_test.go @@ -0,0 +1,253 @@ +package apt + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ── buildSourcesLine ────────────────────────────────────────────────────────── + +func TestBuildSourcesLine_Plain(t *testing.T) { + sd := fakeServerDetails("https://example.jfrog.io/artifactory/", "admin", "secret") + line, err := buildSourcesLine(sd, "my-repo", "noble", "main", false, "") + require.NoError(t, err) + assert.Equal(t, "deb https://admin:secret@example.jfrog.io/artifactory/my-repo noble main", line) +} + +func TestBuildSourcesLine_Trusted(t *testing.T) { + sd := fakeServerDetails("https://host/artifactory/", "u", "p") + line, err := buildSourcesLine(sd, "repo", "jammy", "main", true, "") + require.NoError(t, err) + assert.True(t, strings.HasPrefix(line, "deb [trusted=yes] "), "expected [trusted=yes] prefix, got: %s", line) +} + +func TestBuildSourcesLine_SignedBy(t *testing.T) { + sd := fakeServerDetails("https://host/artifactory/", "u", "p") + line, err := buildSourcesLine(sd, "repo", "noble", "main", false, "/etc/apt/keyrings/jfrog-repo-noble.asc") + require.NoError(t, err) + assert.True(t, strings.HasPrefix(line, "deb [signed-by=/etc/apt/keyrings/jfrog-repo-noble.asc] "), + "expected signed-by prefix, got: %s", line) +} + +func TestBuildSourcesLine_MultipleComponents(t *testing.T) { + sd := fakeServerDetails("https://host/artifactory/", "u", "p") + line, err := buildSourcesLine(sd, "repo", "noble", "main contrib non-free", false, "") + require.NoError(t, err) + assert.True(t, strings.HasSuffix(line, " noble main contrib non-free"), "got: %s", line) +} + +func TestBuildSourcesLine_TrailingSlashStripped(t *testing.T) { + sd := fakeServerDetails("https://host/artifactory/", "u", "p") + line, err := buildSourcesLine(sd, "repo", "noble", "main", false, "") + require.NoError(t, err) + assert.NotContains(t, line, "artifactory//repo", "double slash in URL") +} + +// ── validateSourcesToken ───────────────────────────────────────────────────── + +func TestValidateSourcesToken_Newline(t *testing.T) { + assert.Error(t, validateSourcesToken("dist", "noble\nevil")) +} + +func TestValidateSourcesToken_CR(t *testing.T) { + assert.Error(t, validateSourcesToken("dist", "noble\revil")) +} + +func TestValidateSourcesToken_Null(t *testing.T) { + assert.Error(t, validateSourcesToken("dist", "noble\x00evil")) +} + +func TestValidateSourcesToken_Empty(t *testing.T) { + assert.Error(t, validateSourcesToken("dist", "")) +} + +func TestValidateSourcesToken_Valid(t *testing.T) { + assert.NoError(t, validateSourcesToken("dist", "noble")) + assert.NoError(t, validateSourcesToken("component", "main contrib non-free")) +} + +func TestValidateSourcesToken_PathTraversal(t *testing.T) { + // path separators / ".." would let a token escape /etc/apt when used in a path + assert.Error(t, validateSourcesToken("dist", "../../etc/evil")) + assert.Error(t, validateSourcesToken("repo", "foo/bar")) + assert.Error(t, validateSourcesToken("dist", `foo\bar`)) + assert.Error(t, validateSourcesToken("dist", "..")) +} + +func TestValidateSourcesToken_Whitespace(t *testing.T) { + // A space in repo/dist injects extra whitespace-delimited fields into the line. + assert.Error(t, validateSourcesToken("dist", "no ble")) + assert.Error(t, validateSourcesToken("repo", "my repo")) + // Tabs are rejected everywhere, including --component. + assert.Error(t, validateSourcesToken("dist", "noble\t")) + assert.Error(t, validateSourcesToken("component", "main\tcontrib")) + // --component legitimately uses spaces to list multiple components. + assert.NoError(t, validateSourcesToken("component", "main contrib non-free")) +} + +// ── WriteTempSourcesList ────────────────────────────────────────────────────── + +func TestWriteTempSourcesList_ContainsSourcesLine(t *testing.T) { + sd := fakeServerDetails("https://host/artifactory/", "u", "p") + path, err := WriteTempSourcesList(sd, "repo", "noble", "main", false) + require.NoError(t, err) + defer func() { _ = os.Remove(path) }() + + content, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(content), "deb https://u:p@host/artifactory/repo noble main") +} + +func TestWriteTempSourcesList_Permissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix file permission bits not supported on Windows") + } + sd := fakeServerDetails("https://host/artifactory/", "u", "p") + path, err := WriteTempSourcesList(sd, "repo", "noble", "main", false) + require.NoError(t, err) + defer func() { _ = os.Remove(path) }() + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0600), info.Mode().Perm(), "temp sources.list must not be world-readable") +} + +func TestWriteTempSourcesList_Trusted(t *testing.T) { + sd := fakeServerDetails("https://host/artifactory/", "u", "p") + path, err := WriteTempSourcesList(sd, "repo", "noble", "main", true) + require.NoError(t, err) + defer func() { _ = os.Remove(path) }() + + content, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(content), "[trusted=yes]") +} + +// ── FetchAndInstallPublicKey (HTTP mock) ────────────────────────────────────── + +func TestFetchAndInstallPublicKey_AutoDetectsKeyName(t *testing.T) { + const fakeKey = "-----BEGIN PGP PUBLIC KEY BLOCK-----\nfakekey\n-----END PGP PUBLIC KEY BLOCK-----\n" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/artifactory/api/repositories/myrepo": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"primaryKeyPairRef":"mykey"}`)) + case "/artifactory/api/security/keypair/mykey/public": + _, _ = w.Write([]byte(fakeKey)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + tmpDir := t.TempDir() + origKeyringsDir := keyringsDir + // patch keyringsDir for test isolation + keyringsDir = tmpDir + defer func() { keyringsDir = origKeyringsDir }() + + sd := fakeServerDetails(srv.URL+"/artifactory/", "admin", "pass") + keyPath, err := FetchAndInstallPublicKey(sd, "myrepo", "noble") + require.NoError(t, err) + assert.Equal(t, filepath.Join(tmpDir, "jfrog-myrepo-noble.asc"), keyPath) + + content, err := os.ReadFile(keyPath) + require.NoError(t, err) + assert.Equal(t, fakeKey, string(content)) +} + +func TestFetchAndInstallPublicKey_FallsBackToDefaultKey(t *testing.T) { + const fakeKey = "-----BEGIN PGP PUBLIC KEY BLOCK-----\ndefaultkey\n-----END PGP PUBLIC KEY BLOCK-----\n" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/artifactory/api/repositories/myrepo": + // no primaryKeyPairRef — empty + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"rclass":"remote"}`)) + case "/artifactory/api/gpg/key/public": + _, _ = w.Write([]byte(fakeKey)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + tmpDir := t.TempDir() + origKeyringsDir := keyringsDir + keyringsDir = tmpDir + defer func() { keyringsDir = origKeyringsDir }() + + sd := fakeServerDetails(srv.URL+"/artifactory/", "admin", "pass") + keyPath, err := FetchAndInstallPublicKey(sd, "myrepo", "noble") + require.NoError(t, err) + content, err := os.ReadFile(keyPath) + require.NoError(t, err) + assert.Equal(t, fakeKey, string(content)) +} + +func TestFetchAndInstallPublicKey_KeyFilePermissions(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/artifactory/api/repositories/repo": + _, _ = w.Write([]byte(`{}`)) + case "/artifactory/api/gpg/key/public": + _, _ = w.Write([]byte("FAKEKEY")) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + tmpDir := t.TempDir() + origKeyringsDir := keyringsDir + keyringsDir = tmpDir + defer func() { keyringsDir = origKeyringsDir }() + + sd := fakeServerDetails(srv.URL+"/artifactory/", "u", "p") + keyPath, err := FetchAndInstallPublicKey(sd, "repo", "noble") + require.NoError(t, err) + + if runtime.GOOS != "windows" { + info, err := os.Stat(keyPath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0644), info.Mode().Perm(), "public key must be world-readable (apt needs it)") + } +} + +func TestFetchAndInstallPublicKey_ServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/artifactory/api/repositories/repo": + _, _ = w.Write([]byte(`{}`)) + default: + http.Error(w, "forbidden", http.StatusForbidden) + } + })) + defer srv.Close() + + sd := fakeServerDetails(srv.URL+"/artifactory/", "u", "p") + _, err := FetchAndInstallPublicKey(sd, "repo", "noble") + assert.Error(t, err) + assert.Contains(t, err.Error(), "403") +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +func fakeServerDetails(artURL, user, password string) *config.ServerDetails { + return &config.ServerDetails{ + ArtifactoryUrl: artURL, + User: user, + Password: password, + } +} diff --git a/artifactory/commands/apt/command.go b/artifactory/commands/apt/command.go new file mode 100644 index 00000000..802d8ec9 --- /dev/null +++ b/artifactory/commands/apt/command.go @@ -0,0 +1,233 @@ +package apt + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +// AptCommand wraps apt-get/apt-cache/dpkg-query with JFrog Artifactory authentication. +// +// Authentication modes (design doc D3): +// - Default: write temp sources.list with creds embedded in URL, inject via +// apt-get -o Dir::Etc::sourcelist=, defer cleanup. +// - --skip-login: use system sources.list as-is. +// +// Dispatching: first arg selects the native tool. +// - "apt-cache" or "dpkg-query" → that tool, remaining args, no auth injection +// - anything else → apt-get, all args, with auth injection when --repo+--dist set +type AptCommand struct { + args []string + skipLogin bool + trusted bool + serverDetails *config.ServerDetails + repoName string + dist string + component string +} + +func NewAptCommand() *AptCommand { + return &AptCommand{} +} + +func (c *AptCommand) SetArgs(args []string) *AptCommand { + c.args = args + return c +} + +func (c *AptCommand) SetSkipLogin(skip bool) *AptCommand { + c.skipLogin = skip + return c +} + +func (c *AptCommand) SetTrusted(trusted bool) *AptCommand { + c.trusted = trusted + return c +} + +func (c *AptCommand) SetServerDetails(serverDetails *config.ServerDetails) *AptCommand { + c.serverDetails = serverDetails + return c +} + +func (c *AptCommand) SetDist(dist string) *AptCommand { + c.dist = dist + return c +} + +func (c *AptCommand) SetComponent(component string) *AptCommand { + if component == "" { + component = "main" + } + c.component = component + return c +} + +func (c *AptCommand) SetRepoName(repoName string) *AptCommand { + c.repoName = repoName + return c +} + +func (c *AptCommand) CommandName() string { return "rt_apt" } + +func (c *AptCommand) ServerDetails() (*config.ServerDetails, error) { + return c.serverDetails, nil +} + +// nativeTools lists tools selectable as args[0] instead of the default apt-get. +var nativeTools = map[string]bool{ + "apt-cache": true, + "dpkg-query": true, +} + +// aptValueFlags are apt-get flags whose value is a separate argv token +// (e.g. `-o KEY=VALUE`, `-t release`). The value token must be skipped so it is +// not mistaken for the subcommand. +var aptValueFlags = map[string]bool{ + "-o": true, "--option": true, + "-c": true, "--config-file": true, + "-t": true, "--target-release": true, +} + +func needsUpdate(args []string) bool { + skipNext := false + for _, a := range args { + if skipNext { + skipNext = false + continue + } + if strings.HasPrefix(a, "-") { + if aptValueFlags[a] { + skipNext = true + } + continue + } + switch a { + case "install", "upgrade", "dist-upgrade", "full-upgrade", "satisfy": + return true + } + return false + } + return false +} + +// Run executes the native apt tool. +// hasPersistentAptConfig reports whether 'jf setup apt' has written a persistent +// sources.list entry (jfrog-*.list). When present, native apt-get already resolves +// against Artifactory with embedded credentials, so no temp source is required. +func hasPersistentAptConfig() bool { + matches, err := filepath.Glob(filepath.Join(sourcesListDir, "jfrog-*.list")) + return err == nil && len(matches) > 0 +} + +// sweepStaleTempSources best-effort removes leftover on-the-fly sources.list temp +// files (which embed credentials in the repo URL) abandoned by a previous run +// that was killed — OOM, SIGKILL, CI cancellation — before its deferred +// os.Remove ran. Only files older than one hour are removed, so a concurrent +// in-flight `jf apt` is never disturbed. Errors are ignored: this is hygiene, not +// correctness. +func sweepStaleTempSources() { + matches, err := filepath.Glob(filepath.Join(os.TempDir(), "jfrog-apt-sources-*.list")) + if err != nil { + return + } + cutoff := time.Now().Add(-time.Hour) + for _, f := range matches { + if info, err := os.Stat(f); err == nil && info.ModTime().Before(cutoff) { + _ = os.Remove(f) + } + } +} + +func (c *AptCommand) Run() error { + if len(c.args) == 0 { + return fmt.Errorf("no apt arguments provided") + } + + // Default the component so a missing --component never makes buildSourcesLine + // reject an empty token and silently disable auth injection on the on-the-fly path. + if c.component == "" { + c.component = "main" + } + + nativeTool := "apt-get" + nativeArgs := c.args + if nativeTools[c.args[0]] { + nativeTool = c.args[0] + nativeArgs = c.args[1:] + } + + if nativeTool == "apt-get" && !c.skipLogin { + usePersistentConfig := func() { + // 'jf setup apt' already wrote a persistent jfrog-*.list with embedded + // credentials. Native apt-get resolves against it directly — no temp + // source needed, and no missing-auth warning is warranted. + log.Info("Using persistent Artifactory apt configuration from " + sourcesListDir + + " (written by 'jf setup apt').") + } + switch { + case c.serverDetails != nil && c.repoName != "" && c.dist != "": + // Best-effort: clear credential-bearing temp files abandoned by a prior + // run killed before its deferred cleanup ran (see sweepStaleTempSources). + sweepStaleTempSources() + tmpPath, err := WriteTempSourcesList(c.serverDetails, c.repoName, c.dist, c.component, c.trusted) + if err != nil { + log.Warn("Failed to create temporary sources.list — proceeding without auth injection: " + err.Error()) + } else { + defer func() { _ = os.Remove(tmpPath) }() + // Dir::Etc::sourcelist replaces the main sources.list; Dir::Etc::sourceparts=- + // disables sources.list.d/ so ONLY the temp Artifactory entry is live for this + // command — packages cannot resolve to any other configured repository. + sourceOpts := []string{ + "-o", "Dir::Etc::sourcelist=" + tmpPath, + "-o", "Dir::Etc::sourceparts=-", + } + log.Debug("Using temporary sources.list at: " + tmpPath) + + // Populate the package index before install/upgrade so apt can locate + // packages that were never indexed by a prior apt-get update. + // Skipped for subcommands that don't resolve packages (remove, purge, etc.) + if needsUpdate(c.args) { + log.Output("Updating package lists from Artifactory...") + updateCmd := exec.Command("apt-get", append(sourceOpts, "update")...) + updateCmd.Stdout = os.Stdout + updateCmd.Stderr = os.Stderr + if err := updateCmd.Run(); err != nil { + return fmt.Errorf("apt-get update failed: %w", err) + } + } + + nativeArgs = append(sourceOpts, nativeArgs...) + } + case (c.repoName != "") != (c.dist != ""): + // Exactly one of --repo/--dist was given (the both-set case matched above). + // On-the-fly auth needs both, so the partial flag can't be honored — warn + // rather than silently ignoring it, then fall back to persistent config. + log.Warn("On-the-fly auth requires both --repo and --dist — the partial flag was ignored.") + if hasPersistentAptConfig() { + usePersistentConfig() + } + case hasPersistentAptConfig(): + usePersistentConfig() + default: + log.Warn("--repo and --dist not both specified and no persistent 'jf setup apt' " + + "configuration found — running apt-get without auth injection. Pass --repo and " + + "--dist for on-the-fly auth, or run 'jf setup apt' first for persistent auth.") + } + } + + cmd := exec.Command(nativeTool, nativeArgs...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s failed: %w", nativeTool, err) + } + return nil +} diff --git a/artifactory/commands/apt/command_test.go b/artifactory/commands/apt/command_test.go new file mode 100644 index 00000000..a4adfcaf --- /dev/null +++ b/artifactory/commands/apt/command_test.go @@ -0,0 +1,92 @@ +package apt + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// ── needsUpdate ─────────────────────────────────────────────────────────────── + +func TestNeedsUpdate_Install(t *testing.T) { + assert.True(t, needsUpdate([]string{"install", "curl"})) +} + +func TestNeedsUpdate_Upgrade(t *testing.T) { + assert.True(t, needsUpdate([]string{"upgrade"})) +} + +func TestNeedsUpdate_DistUpgrade(t *testing.T) { + assert.True(t, needsUpdate([]string{"dist-upgrade"})) +} + +func TestNeedsUpdate_FullUpgrade(t *testing.T) { + assert.True(t, needsUpdate([]string{"full-upgrade"})) +} + +func TestNeedsUpdate_Satisfy(t *testing.T) { + assert.True(t, needsUpdate([]string{"satisfy", "curl (>= 7.0)"})) +} + +func TestNeedsUpdate_Remove(t *testing.T) { + assert.False(t, needsUpdate([]string{"remove", "curl"})) +} + +func TestNeedsUpdate_Purge(t *testing.T) { + assert.False(t, needsUpdate([]string{"purge", "curl"})) +} + +func TestNeedsUpdate_Show(t *testing.T) { + assert.False(t, needsUpdate([]string{"show", "curl"})) +} + +func TestNeedsUpdate_List(t *testing.T) { + assert.False(t, needsUpdate([]string{"list", "--installed"})) +} + +func TestNeedsUpdate_Autoremove(t *testing.T) { + assert.False(t, needsUpdate([]string{"autoremove"})) +} + +func TestNeedsUpdate_LeadingFlags(t *testing.T) { + // flags before subcommand should be skipped + assert.True(t, needsUpdate([]string{"-y", "--quiet", "install", "curl"})) +} + +func TestNeedsUpdate_EmptyArgs(t *testing.T) { + assert.False(t, needsUpdate([]string{})) +} + +func TestNeedsUpdate_OnlyFlags(t *testing.T) { + assert.False(t, needsUpdate([]string{"-y", "--quiet"})) +} + +func TestNeedsUpdate_TwoTokenValueFlag(t *testing.T) { + // `-o` takes its value as a separate argv token; the value must not be + // mistaken for the subcommand, so `install` following it is still detected. + assert.True(t, needsUpdate([]string{"-o", "Debug::pkgProblemResolver=1", "install", "curl"})) + assert.True(t, needsUpdate([]string{"-t", "noble-backports", "install", "curl"})) +} + +func TestNeedsUpdate_TwoTokenValueFlagBeforeNonUpdate(t *testing.T) { + assert.False(t, needsUpdate([]string{"-o", "Foo=bar", "remove", "curl"})) +} + +// ── AptCommand setters/defaults ─────────────────────────────────────────────── + +func TestSetComponent_DefaultsToMain(t *testing.T) { + cmd := NewAptCommand().SetComponent("") + assert.Equal(t, "main", cmd.component) +} + +func TestSetComponent_CustomValue(t *testing.T) { + cmd := NewAptCommand().SetComponent("contrib") + assert.Equal(t, "contrib", cmd.component) +} + +func TestRun_NoArgs(t *testing.T) { + cmd := NewAptCommand().SetArgs([]string{}) + err := cmd.Run() + assert.Error(t, err) + assert.Contains(t, err.Error(), "no apt arguments") +} diff --git a/artifactory/commands/apt/setup.go b/artifactory/commands/apt/setup.go new file mode 100644 index 00000000..0e86431a --- /dev/null +++ b/artifactory/commands/apt/setup.go @@ -0,0 +1,335 @@ +package apt + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +var ( + sourcesListDir = "/etc/apt/sources.list.d" + preferencesDir = "/etc/apt/preferences.d" +) + +// AptSetupCommand writes a persistent Artifactory sources.list entry for apt. +// +// Writes to: /etc/apt/sources.list.d/jfrog--.list +// Format: deb https://user:token@host/artifactory/repo DIST COMPONENT +// +// Idempotent: re-running with the same repo+dist replaces the existing file. +// Requires root (euid == 0). +type AptSetupCommand struct { + serverDetails *config.ServerDetails + repoName string + dist string + component string + trusted bool + importKey bool + remove bool +} + +func NewAptSetupCommand() *AptSetupCommand { + return &AptSetupCommand{} +} + +func (c *AptSetupCommand) SetServerDetails(serverDetails *config.ServerDetails) *AptSetupCommand { + c.serverDetails = serverDetails + return c +} + +func (c *AptSetupCommand) SetRepoName(repoName string) *AptSetupCommand { + c.repoName = repoName + return c +} + +func (c *AptSetupCommand) SetDist(dist string) *AptSetupCommand { + c.dist = dist + return c +} + +func (c *AptSetupCommand) SetTrusted(trusted bool) *AptSetupCommand { + c.trusted = trusted + return c +} + +func (c *AptSetupCommand) SetImportKey(importKey bool) *AptSetupCommand { + c.importKey = importKey + return c +} + +func (c *AptSetupCommand) SetRemove(remove bool) *AptSetupCommand { + c.remove = remove + return c +} + +func (c *AptSetupCommand) SetComponent(component string) *AptSetupCommand { + if component == "" { + component = "main" + } + c.component = component + return c +} + +func (c *AptSetupCommand) CommandName() string { return "setup_apt" } + +func (c *AptSetupCommand) ServerDetails() (*config.ServerDetails, error) { + return c.serverDetails, nil +} + +// Run writes /etc/apt/sources.list.d/jfrog--.list with credentials +// embedded in the repository URL, then runs apt-get update to verify. +// With --remove, deletes all jfrog-*.list and jfrog-*.pref files (filtered by +// --dist if provided) instead of writing. +func (c *AptSetupCommand) Run() error { + if c.remove { + return c.runRemove() + } + if c.trusted && c.importKey { + return fmt.Errorf("--trusted and --import-key are mutually exclusive") + } + if c.repoName == "" { + return fmt.Errorf("--repo is required for apt setup") + } + if c.dist == "" { + return fmt.Errorf("--dist is required for apt setup") + } + if c.serverDetails == nil { + return fmt.Errorf("server details not configured; use --server-id or 'jf config add'") + } + // Validate before any filesystem path is built from these tokens — + // FetchAndInstallPublicKey and the sources/preferences writers interpolate + // repo/dist directly, so a "../" value could escape /etc/apt as root. + if err := validateSourcesToken("repo", c.repoName); err != nil { + return err + } + if err := validateSourcesToken("dist", c.dist); err != nil { + return err + } + + signedBy := "" + if c.importKey { + keyPath, err := FetchAndInstallPublicKey(c.serverDetails, c.repoName, c.dist) + if err != nil { + return wrapPermErr(fmt.Errorf("import GPG key: %w", err)) + } + log.Output(fmt.Sprintf("Installed GPG public key at %s", keyPath)) + signedBy = keyPath + } else if !c.trusted { + // No --import-key, but a keyring from a previous import may already exist. + // Reuse it so re-running setup keeps signature verification (signed-by) + // rather than silently stripping it. Pass --import-key to refresh the key. + if existingKey := existingKeyringPath(c.repoName, c.dist); existingKey != "" { + log.Info(fmt.Sprintf("Reusing previously imported GPG key at %s (pass --import-key to refresh).", existingKey)) + signedBy = existingKey + } + } + + sourceLine, err := buildSourcesLine(c.serverDetails, c.repoName, c.dist, c.component, c.trusted, signedBy) + if err != nil { + return fmt.Errorf("build sources line: %w", err) + } + + targetFile := fmt.Sprintf("%s/jfrog-%s-%s.list", sourcesListDir, c.repoName, c.dist) + + // Downgrade guard: the source previously pinned signing (signed-by=) but no + // keyring was reused above (the .asc is gone) and no --trusted was given, so + // the rewritten line would silently drop GPG verification. Warn instead. + if signedBy == "" && !c.trusted && sourceHasSignedBy(targetFile) { + log.Warn("This apt source was previously configured with GPG verification (signed-by), " + + "but --import-key was not passed — the updated source will no longer verify signatures. " + + "Re-run with --import-key to keep verification, or --trusted if disabling it is intentional.") + } + + wrote, err := c.writeSourcesListIdempotent(targetFile, sourceLine) + if err != nil { + return wrapPermErr(err) + } + + artHost := extractHost(c.serverDetails.GetArtifactoryUrl()) + prefFile := fmt.Sprintf("%s/jfrog-%s-%s.pref", preferencesDir, c.repoName, c.dist) + if err := writePinningFile(prefFile, artHost); err != nil { + return wrapPermErr(fmt.Errorf("write apt pinning file: %w", err)) + } + + if !wrote { + log.Output("Apt source already configured — no changes needed.") + return nil + } + + log.Output(fmt.Sprintf("Wrote %s", targetFile)) + + updateCmd := exec.Command("apt-get", "update") + var stderrBuf bytes.Buffer + updateCmd.Stdout = os.Stdout + updateCmd.Stderr = io.MultiWriter(os.Stderr, &stderrBuf) + if err := updateCmd.Run(); err != nil { + // apt-get exits 100 for many reasons (permissions, connectivity, bad + // config), so decide the hint from what apt actually reported rather + // than assuming from the current uid — a non-root user may hold all the + // needed permissions, in which case the failure is not a sudo problem. + if isAptPermissionError(stderrBuf.String()) { + return fmt.Errorf("apt-get update failed — you may need to run with sudo: %w", err) + } + return fmt.Errorf("apt-get update failed — check connectivity and credentials: %w", err) + } + + log.Output(fmt.Sprintf("Successfully configured apt to use JFrog Artifactory repository '%s'.", c.repoName)) + return nil +} + +// runRemove deletes all jfrog-managed sources.list and preferences files. +// If --dist is set, only files matching that dist suffix are removed. +func (c *AptSetupCommand) runRemove() error { + // Validate before matching so a crafted --dist/--repo cannot slip path + // separators/".." into the comparison (glob metacharacters stay harmless + // because the glob pattern below is the fixed "jfrog-*" and repo/dist are + // only ever compared as literal prefix/suffix, never expanded). + if c.dist != "" { + if err := validateSourcesToken("dist", c.dist); err != nil { + return err + } + } + if c.repoName != "" { + if err := validateSourcesToken("repo", c.repoName); err != nil { + return err + } + } + + // Files are named jfrog--.. Narrow by whichever of repo/dist + // is set so `--remove --repo=A` only deletes repo A's config, not every repo's: + // repo+dist → prefix "jfrog--" AND suffix "-" + // repo only → prefix "jfrog--" (any dist for that repo) + // dist only → suffix "-" (that dist for any repo) + // neither → every jfrog-* + removed := 0 + for _, dir := range []struct{ path, ext string }{ + {sourcesListDir, ".list"}, + {preferencesDir, ".pref"}, + {keyringsDir, ".asc"}, + } { + matches, err := filepath.Glob(filepath.Join(dir.path, "jfrog-*")) + if err != nil { + return fmt.Errorf("glob %s: %w", dir.path, err) + } + for _, f := range matches { + base := filepath.Base(f) + if !strings.HasSuffix(base, dir.ext) { + continue + } + if c.repoName != "" && !strings.HasPrefix(base, "jfrog-"+c.repoName+"-") { + continue + } + if c.dist != "" && !strings.HasSuffix(base, "-"+c.dist+dir.ext) { + continue + } + if err := os.Remove(f); err != nil { + return wrapPermErr(fmt.Errorf("remove %s: %w", f, err)) + } + log.Output(fmt.Sprintf("Removed %s", f)) + removed++ + } + } + + if removed == 0 { + log.Output("No JFrog apt configuration found to remove.") + } + return nil +} + +// existingKeyringPath returns the keyring path for repo/dist when a previously +// imported ASCII-armored key (jfrog--.asc) already exists on disk, +// else "". Used to preserve signature verification across setup re-runs that +// omit --import-key. +func existingKeyringPath(repoName, dist string) string { + p := filepath.Join(keyringsDir, fmt.Sprintf("jfrog-%s-%s.asc", repoName, dist)) + if _, err := os.Stat(p); err == nil { + return p + } + return "" +} + +// sourceHasSignedBy reports whether the sources.list file at path already pins a +// signing key (signed-by=). A missing/unreadable file reports false. +func sourceHasSignedBy(path string) bool { + b, err := os.ReadFile(path) + return err == nil && strings.Contains(string(b), "signed-by=") +} + +// writeSourcesListIdempotent writes sourceLine to targetFile if the content has changed. +// Returns true if a write occurred, false if the file already contained the exact line. +func (c *AptSetupCommand) writeSourcesListIdempotent(targetFile, sourceLine string) (bool, error) { + existing, err := os.ReadFile(targetFile) + if err == nil { + // Exact whole-line match only — a substring check would treat a narrower + // config (e.g. "... noble main") as already present when the file holds a + // broader line ("... noble main contrib"), silently keeping stale config. + for _, line := range strings.Split(strings.TrimRight(string(existing), "\n"), "\n") { + if line == sourceLine { + return false, nil + } + } + log.Info("Updating existing apt source configuration.") + } + if err := os.WriteFile(targetFile, []byte(sourceLine+"\n"), 0600); err != nil { + return true, err + } + // os.WriteFile applies the mode only when it creates the file; on an existing + // file the bits are left as-is. Force 0600 so a pre-existing looser file (older + // binary, manual edit) is tightened — this file embeds credentials in the URL. + return true, os.Chmod(targetFile, 0600) +} + +// writePinningFile writes an apt preferences file that gives Artifactory +// packages priority 1001 — above apt's downgrade threshold (1000). So whenever +// Artifactory carries the package it ALWAYS wins version selection, even when +// that means installing an older Artifactory version over a newer one from +// another repo (a deliberate downgrade). If only another repo has the package, +// apt still installs it from there — this pins version preference for shared +// packages, it does not block packages Artifactory doesn't carry. +func writePinningFile(path, artHost string) error { + content := fmt.Sprintf("Package: *\nPin: origin %s\nPin-Priority: 1001\n", artHost) + return os.WriteFile(path, []byte(content), 0644) +} + +// extractHost returns the hostname from a URL, falling back to the raw string. +func extractHost(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil || u.Host == "" { + return rawURL + } + return u.Hostname() +} + +// isAptPermissionError reports whether apt-get output indicates a permission +// problem (typically the /var/lib/apt or /var/lib/dpkg locks) rather than a +// connectivity or credentials failure. apt-get itself exits 100 for all of +// these, so its stderr is the only reliable discriminator. +func isAptPermissionError(output string) bool { + lower := strings.ToLower(output) + return strings.Contains(lower, "permission denied") || + strings.Contains(lower, "are you root") || + strings.Contains(lower, "could not open lock file") +} + +// wrapPermErr appends a sudo hint to permission-denied errors. +func wrapPermErr(err error) error { + if err == nil { + return nil + } + // errors.Is walks the whole %w chain, unlike os.IsPermission which only + // unwraps *PathError/*LinkError/*SyscallError one level — needed because + // callers double-wrap (e.g. "import GPG key: %w" over "write public key: %w"). + if errors.Is(err, os.ErrPermission) { + return fmt.Errorf("%w — you may need to run with sudo", err) + } + return err +} diff --git a/artifactory/commands/apt/setup_test.go b/artifactory/commands/apt/setup_test.go new file mode 100644 index 00000000..50d4f99f --- /dev/null +++ b/artifactory/commands/apt/setup_test.go @@ -0,0 +1,420 @@ +package apt + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ── writeSourcesListIdempotent ──────────────────────────────────────────────── + +func TestWriteSourcesListIdempotent_WritesNewFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.list") + cmd := &AptSetupCommand{} + + wrote, err := cmd.writeSourcesListIdempotent(path, "deb https://host/repo noble main") + require.NoError(t, err) + assert.True(t, wrote) + + content, _ := os.ReadFile(path) + assert.Contains(t, string(content), "deb https://host/repo noble main") +} + +func TestWriteSourcesListIdempotent_IdempotentOnSameLine(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.list") + line := "deb https://host/repo noble main" + require.NoError(t, os.WriteFile(path, []byte(line+"\n"), 0600)) + + cmd := &AptSetupCommand{} + wrote, err := cmd.writeSourcesListIdempotent(path, line) + require.NoError(t, err) + assert.False(t, wrote, "should not write when line already present") +} + +func TestWriteSourcesListIdempotent_OverwritesOnDiff(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.list") + require.NoError(t, os.WriteFile(path, []byte("deb https://old-host/repo noble main\n"), 0600)) + + cmd := &AptSetupCommand{} + wrote, err := cmd.writeSourcesListIdempotent(path, "deb https://new-host/repo noble main") + require.NoError(t, err) + assert.True(t, wrote) + + content, _ := os.ReadFile(path) + assert.Contains(t, string(content), "new-host") + assert.NotContains(t, string(content), "old-host") +} + +func TestWriteSourcesListIdempotent_RewritesOnNarrowerSubstringLine(t *testing.T) { + // A narrower new line that is a substring of the existing broader line must + // still trigger a rewrite (regression: substring match left stale config). + dir := t.TempDir() + path := filepath.Join(dir, "test.list") + require.NoError(t, os.WriteFile(path, []byte("deb https://host/repo noble main contrib\n"), 0600)) + + cmd := &AptSetupCommand{} + wrote, err := cmd.writeSourcesListIdempotent(path, "deb https://host/repo noble main") + require.NoError(t, err) + assert.True(t, wrote, "narrower line is not an exact match — must rewrite") + + content, _ := os.ReadFile(path) + assert.Equal(t, "deb https://host/repo noble main\n", string(content)) +} + +func TestWriteSourcesListIdempotent_FilePermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix file permission bits not supported on Windows") + } + dir := t.TempDir() + path := filepath.Join(dir, "test.list") + cmd := &AptSetupCommand{} + + _, err := cmd.writeSourcesListIdempotent(path, "deb https://host/repo noble main") + require.NoError(t, err) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0600), info.Mode().Perm(), "sources.list must not be world-readable (contains credentials)") +} + +// ── existingKeyringPath (keyring reuse across re-runs) ──────────────────────── + +func TestExistingKeyringPath_ReturnsPathWhenPresent(t *testing.T) { + dir := t.TempDir() + orig := keyringsDir + keyringsDir = dir + defer func() { keyringsDir = orig }() + + keyFile := filepath.Join(dir, "jfrog-myrepo-noble.asc") + require.NoError(t, os.WriteFile(keyFile, []byte("-----BEGIN PGP PUBLIC KEY BLOCK-----"), 0644)) + + assert.Equal(t, keyFile, existingKeyringPath("myrepo", "noble")) +} + +func TestExistingKeyringPath_EmptyWhenAbsent(t *testing.T) { + dir := t.TempDir() + orig := keyringsDir + keyringsDir = dir + defer func() { keyringsDir = orig }() + + assert.Equal(t, "", existingKeyringPath("myrepo", "noble")) +} + +func TestExistingKeyringPath_ScopedToRepoAndDist(t *testing.T) { + dir := t.TempDir() + orig := keyringsDir + keyringsDir = dir + defer func() { keyringsDir = orig }() + + // A key for a different dist must not be treated as this dist's key. + require.NoError(t, os.WriteFile(filepath.Join(dir, "jfrog-myrepo-jammy.asc"), []byte("x"), 0644)) + assert.Equal(t, "", existingKeyringPath("myrepo", "noble")) +} + +// ── sourceHasSignedBy (downgrade detection) ─────────────────────────────────── + +func TestSourceHasSignedBy_TrueWhenPinned(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "jfrog-myrepo-noble.list") + line := "deb [signed-by=/etc/apt/keyrings/jfrog-myrepo-noble.asc] https://host/repo noble main" + require.NoError(t, os.WriteFile(path, []byte(line+"\n"), 0600)) + + assert.True(t, sourceHasSignedBy(path)) +} + +func TestSourceHasSignedBy_FalseWhenBareLine(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "jfrog-myrepo-noble.list") + require.NoError(t, os.WriteFile(path, []byte("deb https://host/repo noble main\n"), 0600)) + + assert.False(t, sourceHasSignedBy(path)) +} + +func TestSourceHasSignedBy_FalseWhenFileMissing(t *testing.T) { + dir := t.TempDir() + assert.False(t, sourceHasSignedBy(filepath.Join(dir, "does-not-exist.list"))) +} + +// ── wrapPermErr ─────────────────────────────────────────────────────────────── + +func TestWrapPermErr_Nil(t *testing.T) { + assert.Nil(t, wrapPermErr(nil)) +} + +func TestWrapPermErr_NonPermError(t *testing.T) { + err := errors.New("connection refused") + wrapped := wrapPermErr(err) + assert.Equal(t, err, wrapped, "non-permission error should pass through unchanged") +} + +func TestWrapPermErr_PermissionDenied(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("chmod-based permission denial does not work on Windows") + } + if os.Getuid() == 0 { + t.Skip("running as root — permission checks don't apply") + } + dir := t.TempDir() + require.NoError(t, os.Chmod(dir, 0555)) + defer func() { _ = os.Chmod(dir, 0755) }() + + err := os.WriteFile(filepath.Join(dir, "x"), []byte("x"), 0600) + require.Error(t, err) + + wrapped := wrapPermErr(err) + assert.Contains(t, wrapped.Error(), "sudo") +} + +func TestWrapPermErr_WrappedPermissionDenied(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("chmod-based permission denial does not work on Windows") + } + if os.Getuid() == 0 { + t.Skip("running as root — permission checks don't apply") + } + dir := t.TempDir() + require.NoError(t, os.Chmod(dir, 0555)) + defer func() { _ = os.Chmod(dir, 0755) }() + + innerErr := os.WriteFile(filepath.Join(dir, "x"), []byte("x"), 0600) + require.Error(t, innerErr) + + // wrapPermErr uses errors.Is, which walks the whole chain — nested/multiply + // wrapped permission errors must still get the sudo hint. + doubleWrapped := fmt.Errorf("import GPG key: %w", fmt.Errorf("write public key: %w", innerErr)) + result := wrapPermErr(doubleWrapped) + assert.Contains(t, result.Error(), "sudo") +} + +// ── runRemove ───────────────────────────────────────────────────────────────── + +func TestRunRemove_RemovesAllFiles(t *testing.T) { + dir := t.TempDir() + // patch dirs for isolation + origSrc, origPref, origKey := sourcesListDir, preferencesDir, keyringsDir + sourcesListDir = dir + preferencesDir = dir + keyringsDir = dir + defer func() { + sourcesListDir = origSrc + preferencesDir = origPref + keyringsDir = origKey + }() + + // create files that should be removed + for _, name := range []string{ + "jfrog-myrepo-noble.list", + "jfrog-myrepo-noble.pref", + "jfrog-myrepo-noble.asc", + } { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte("x"), 0644)) + } + // create a file that should NOT be removed (different dist) + require.NoError(t, os.WriteFile(filepath.Join(dir, "jfrog-myrepo-jammy.list"), []byte("x"), 0644)) + + cmd := &AptSetupCommand{dist: "noble"} + require.NoError(t, cmd.runRemove()) + + assert.NoFileExists(t, filepath.Join(dir, "jfrog-myrepo-noble.list")) + assert.NoFileExists(t, filepath.Join(dir, "jfrog-myrepo-noble.pref")) + assert.NoFileExists(t, filepath.Join(dir, "jfrog-myrepo-noble.asc")) + assert.FileExists(t, filepath.Join(dir, "jfrog-myrepo-jammy.list"), "other dist must not be removed") +} + +func TestRunRemove_RemovesAllDists(t *testing.T) { + dir := t.TempDir() + origSrc, origPref, origKey := sourcesListDir, preferencesDir, keyringsDir + sourcesListDir = dir + preferencesDir = dir + keyringsDir = dir + defer func() { + sourcesListDir = origSrc + preferencesDir = origPref + keyringsDir = origKey + }() + + for _, name := range []string{ + "jfrog-repoA-noble.list", + "jfrog-repoB-jammy.list", + "jfrog-repoA-noble.pref", + } { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte("x"), 0644)) + } + + // no --dist → remove all jfrog-* files + cmd := &AptSetupCommand{} + require.NoError(t, cmd.runRemove()) + + assert.NoFileExists(t, filepath.Join(dir, "jfrog-repoA-noble.list")) + assert.NoFileExists(t, filepath.Join(dir, "jfrog-repoB-jammy.list")) + assert.NoFileExists(t, filepath.Join(dir, "jfrog-repoA-noble.pref")) +} + +func TestRunRemove_GlobMetacharDistMatchesNothing(t *testing.T) { + // A --dist containing a glob metacharacter must not be expanded as a pattern: + // removal filters by literal suffix, so "*" matches no real jfrog-- + // file and leaves every other dist's config untouched. + dir := t.TempDir() + origSrc, origPref, origKey := sourcesListDir, preferencesDir, keyringsDir + sourcesListDir = dir + preferencesDir = dir + keyringsDir = dir + defer func() { + sourcesListDir = origSrc + preferencesDir = origPref + keyringsDir = origKey + }() + + for _, name := range []string{ + "jfrog-repoA-noble.list", + "jfrog-repoB-jammy.list", + "jfrog-repoA-noble.pref", + } { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte("x"), 0644)) + } + + cmd := &AptSetupCommand{dist: "*"} + require.NoError(t, cmd.runRemove()) + + // Nothing removed — "*" is treated literally, not as a glob. + assert.FileExists(t, filepath.Join(dir, "jfrog-repoA-noble.list")) + assert.FileExists(t, filepath.Join(dir, "jfrog-repoB-jammy.list")) + assert.FileExists(t, filepath.Join(dir, "jfrog-repoA-noble.pref")) +} + +func TestRunRemove_RepoScoped(t *testing.T) { + // --remove --repo=A must delete only repo A's files, leaving other repos intact. + dir := t.TempDir() + origSrc, origPref, origKey := sourcesListDir, preferencesDir, keyringsDir + sourcesListDir = dir + preferencesDir = dir + keyringsDir = dir + defer func() { + sourcesListDir = origSrc + preferencesDir = origPref + keyringsDir = origKey + }() + + for _, name := range []string{ + "jfrog-repoA-noble.list", "jfrog-repoA-noble.pref", "jfrog-repoA-jammy.list", + "jfrog-repoB-noble.list", "jfrog-repoB-noble.pref", + } { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte("x"), 0600)) + } + + cmd := &AptSetupCommand{repoName: "repoA"} + require.NoError(t, cmd.runRemove()) + + assert.NoFileExists(t, filepath.Join(dir, "jfrog-repoA-noble.list")) + assert.NoFileExists(t, filepath.Join(dir, "jfrog-repoA-noble.pref")) + assert.NoFileExists(t, filepath.Join(dir, "jfrog-repoA-jammy.list")) + assert.FileExists(t, filepath.Join(dir, "jfrog-repoB-noble.list"), "other repo must survive") + assert.FileExists(t, filepath.Join(dir, "jfrog-repoB-noble.pref"), "other repo must survive") +} + +func TestRunRemove_RepoAndDistScoped(t *testing.T) { + dir := t.TempDir() + origSrc, origPref, origKey := sourcesListDir, preferencesDir, keyringsDir + sourcesListDir = dir + preferencesDir = dir + keyringsDir = dir + defer func() { + sourcesListDir = origSrc + preferencesDir = origPref + keyringsDir = origKey + }() + + for _, name := range []string{ + "jfrog-repoA-noble.list", "jfrog-repoA-jammy.list", "jfrog-repoB-noble.list", + } { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte("x"), 0600)) + } + + cmd := &AptSetupCommand{repoName: "repoA", dist: "noble"} + require.NoError(t, cmd.runRemove()) + + assert.NoFileExists(t, filepath.Join(dir, "jfrog-repoA-noble.list")) + assert.FileExists(t, filepath.Join(dir, "jfrog-repoA-jammy.list"), "repoA other dist must survive") + assert.FileExists(t, filepath.Join(dir, "jfrog-repoB-noble.list"), "other repo must survive") +} + +func TestWriteSourcesListIdempotent_TightensExistingLoosePerms(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix file permission bits not supported on Windows") + } + dir := t.TempDir() + path := filepath.Join(dir, "test.list") + // Pre-existing file with loose (world-readable) perms. + require.NoError(t, os.WriteFile(path, []byte("deb https://old-host/repo noble main\n"), 0644)) + + cmd := &AptSetupCommand{} + wrote, err := cmd.writeSourcesListIdempotent(path, "deb https://new-host/repo noble main") + require.NoError(t, err) + assert.True(t, wrote) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0600), info.Mode().Perm(), "credential-bearing file must be tightened to 0600") +} + +func TestRunRemove_NothingToRemove(t *testing.T) { + dir := t.TempDir() + origSrc, origPref, origKey := sourcesListDir, preferencesDir, keyringsDir + sourcesListDir = dir + preferencesDir = dir + keyringsDir = dir + defer func() { + sourcesListDir = origSrc + preferencesDir = origPref + keyringsDir = origKey + }() + + cmd := &AptSetupCommand{dist: "noble"} + // should not error when nothing matches + assert.NoError(t, cmd.runRemove()) +} + +// ── AptSetupCommand.Run validation ─────────────────────────────────────────── + +func TestRun_ReturnsErrorIfTrustedAndImportKeyBothSet(t *testing.T) { + cmd := NewAptSetupCommand(). + SetTrusted(true). + SetImportKey(true). + SetRepoName("repo"). + SetDist("noble") + // serverDetails nil — should fail before reaching key fetch + err := cmd.Run() + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "mutually exclusive") +} + +func TestRun_ReturnsErrorIfRepoMissing(t *testing.T) { + cmd := NewAptSetupCommand().SetDist("noble") + err := cmd.Run() + require.Error(t, err) + assert.Contains(t, err.Error(), "--repo") +} + +func TestRun_ReturnsErrorIfDistMissing(t *testing.T) { + cmd := NewAptSetupCommand().SetRepoName("repo") + err := cmd.Run() + require.Error(t, err) + assert.Contains(t, err.Error(), "--dist") +} + +func TestRun_ReturnsErrorIfServerDetailsNil(t *testing.T) { + cmd := NewAptSetupCommand().SetRepoName("repo").SetDist("noble") + err := cmd.Run() + require.Error(t, err) + assert.Contains(t, err.Error(), "server details") +} From 493ba9c5fa7a02fc126e9761e9ab8a61c5e82159 Mon Sep 17 00:00:00 2001 From: Uday Date: Fri, 14 Aug 2026 16:26:31 +0530 Subject: [PATCH 30/40] Remove separate folder for passthrough --- agent/apm/cli/cli.go | 35 ++++++++ agent/apm/commands/passthrough/passthrough.go | 80 ------------------- agent/apm/common/apmenv.go | 23 ++++++ agent/cli/cli.go | 3 +- 4 files changed, 59 insertions(+), 82 deletions(-) delete mode 100644 agent/apm/commands/passthrough/passthrough.go diff --git a/agent/apm/cli/cli.go b/agent/apm/cli/cli.go index 35379238..7170f4aa 100644 --- a/agent/apm/cli/cli.go +++ b/agent/apm/cli/cli.go @@ -1,10 +1,13 @@ package cli import ( + apmcommon "github.com/jfrog/jfrog-cli-artifactory/agent/apm/common" "github.com/jfrog/jfrog-cli-artifactory/agent/apm/commands/install" "github.com/jfrog/jfrog-cli-artifactory/agent/apm/commands/publish" "github.com/jfrog/jfrog-cli-artifactory/agent/apm/commands/update" + agentcommon "github.com/jfrog/jfrog-cli-artifactory/agent/common" "github.com/jfrog/jfrog-cli-artifactory/cliutils/flagkit" + "github.com/jfrog/jfrog-cli-core/v2/common/commands" "github.com/jfrog/jfrog-cli-core/v2/plugins/components" ) @@ -41,3 +44,35 @@ func GetSubCommands() []components.Command { }, } } + +// RunApmPassthroughDefault handles any `jf agent apm ` not among install/publish/update. +// Auth always comes from the default configured JFrog server; passthrough takes no flags of its +// own, so nothing is extracted from c.Arguments beyond the subcommand. +func RunApmPassthroughDefault(c *components.Context) error { + if len(c.Arguments) == 0 { + return apmcommon.RunApmCommand(nil, apmcommon.HelpFlag, nil) + } + + subcmd := c.Arguments[0] + if apmcommon.IsHelpRequest([]string{subcmd}) { + return apmcommon.RunApmCommand(nil, apmcommon.HelpFlag, nil) + } + // Show help without resolving server/auth, which a help request never needs. Forward the + // full remaining arg tail so nested commands like "deps why" get their own help, not "deps"'s. + if apmcommon.IsHelpRequest(c.Arguments[1:]) { + return apmcommon.RunApmCommand(nil, subcmd, c.Arguments[1:]) + } + + serverDetails, err := agentcommon.GetServerDetails(c) + if err != nil { + return err + } + + cmd := &apmcommon.PassthroughCommand{ + Subcmd: subcmd, + Args: c.Arguments[1:], + Server: serverDetails, + } + + return commands.ExecWithPackageManager(cmd, apmcommon.PackageManagerID) +} diff --git a/agent/apm/commands/passthrough/passthrough.go b/agent/apm/commands/passthrough/passthrough.go deleted file mode 100644 index 743ed981..00000000 --- a/agent/apm/commands/passthrough/passthrough.go +++ /dev/null @@ -1,80 +0,0 @@ -package passthrough - -import ( - apmcommon "github.com/jfrog/jfrog-cli-artifactory/agent/apm/common" - agentcommon "github.com/jfrog/jfrog-cli-artifactory/agent/common" - "github.com/jfrog/jfrog-cli-core/v2/common/commands" - "github.com/jfrog/jfrog-cli-core/v2/plugins/components" - "github.com/jfrog/jfrog-cli-core/v2/utils/config" - "github.com/jfrog/jfrog-client-go/utils/log" -) - -// ApmPassthroughCommand forwards any apm subcommand with auth environment injected. -type ApmPassthroughCommand struct { - subcmd string - args []string - serverDetails *config.ServerDetails -} - -func NewApmPassthroughCommand() *ApmPassthroughCommand { - return &ApmPassthroughCommand{} -} - -func (c *ApmPassthroughCommand) SetSubcmd(subcmd string) *ApmPassthroughCommand { - c.subcmd = subcmd - return c -} - -func (c *ApmPassthroughCommand) SetArgs(args []string) *ApmPassthroughCommand { - c.args = args - return c -} - -func (c *ApmPassthroughCommand) SetServerDetails(serverDetails *config.ServerDetails) *ApmPassthroughCommand { - c.serverDetails = serverDetails - return c -} - -func (c *ApmPassthroughCommand) CommandName() string { - return apmcommon.CommandNamePrefix + c.subcmd -} - -func (c *ApmPassthroughCommand) ServerDetails() (*config.ServerDetails, error) { - return c.serverDetails, nil -} - -func (c *ApmPassthroughCommand) Run() error { - log.Info("Running apm " + apmcommon.SanitizeLogValue(c.subcmd) + "...") - return apmcommon.RunApmSubcommandWithAuth(c.subcmd, c.args, c.serverDetails) -} - -// RunApmPassthroughDefault handles any `jf agent apm ` not among install/publish/update. -// Auth always comes from the default configured JFrog server; passthrough takes no flags of its -// own, so nothing is extracted from c.Arguments beyond the subcommand. -func RunApmPassthroughDefault(c *components.Context) error { - if len(c.Arguments) == 0 { - return apmcommon.RunApmCommand(nil, apmcommon.HelpFlag, nil) - } - - subcmd := c.Arguments[0] - if apmcommon.IsHelpRequest([]string{subcmd}) { - return apmcommon.RunApmCommand(nil, apmcommon.HelpFlag, nil) - } - // Show help without resolving server/auth, which a help request never needs. Forward the - // full remaining arg tail so nested commands like "deps why" get their own help, not "deps"'s. - if apmcommon.IsHelpRequest(c.Arguments[1:]) { - return apmcommon.RunApmCommand(nil, subcmd, c.Arguments[1:]) - } - - serverDetails, err := agentcommon.GetServerDetails(c) - if err != nil { - return err - } - - cmd := NewApmPassthroughCommand(). - SetSubcmd(subcmd). - SetArgs(c.Arguments[1:]). - SetServerDetails(serverDetails) - - return commands.ExecWithPackageManager(cmd, apmcommon.PackageManagerID) -} diff --git a/agent/apm/common/apmenv.go b/agent/apm/common/apmenv.go index 2b4ba7ea..3fba98e5 100644 --- a/agent/apm/common/apmenv.go +++ b/agent/apm/common/apmenv.go @@ -606,3 +606,26 @@ func IsHelpRequest(args []string) bool { func IsDryRunArg(args []string) bool { return slices.Contains(args, "--dry-run") } + +// PassthroughCommand runs an arbitrary apm subcommand with auth environment injected - no +// build-info collection, unlike install/update/publish. It satisfies jfrog-cli-core's Command +// interface (CommandName/ServerDetails/Run) on its own, so `jf agent apm ` for any +// subcommand not covered by install/update/publish needs no command-specific type of its own. +type PassthroughCommand struct { + Subcmd string + Args []string + Server *config.ServerDetails +} + +func (c *PassthroughCommand) CommandName() string { + return CommandNamePrefix + c.Subcmd +} + +func (c *PassthroughCommand) ServerDetails() (*config.ServerDetails, error) { + return c.Server, nil +} + +func (c *PassthroughCommand) Run() error { + log.Info("Running apm " + SanitizeLogValue(c.Subcmd) + "...") + return RunApmSubcommandWithAuth(c.Subcmd, c.Args, c.Server) +} diff --git a/agent/cli/cli.go b/agent/cli/cli.go index 7fa9bc90..8ae90c6b 100644 --- a/agent/cli/cli.go +++ b/agent/cli/cli.go @@ -2,7 +2,6 @@ package cli import ( apmcli "github.com/jfrog/jfrog-cli-artifactory/agent/apm/cli" - "github.com/jfrog/jfrog-cli-artifactory/agent/apm/commands/passthrough" pluginscli "github.com/jfrog/jfrog-cli-artifactory/agent/plugins/cli" skillscli "github.com/jfrog/jfrog-cli-artifactory/agent/skills/cli" "github.com/jfrog/jfrog-cli-core/v2/plugins/components" @@ -27,7 +26,7 @@ func GetCommands() []components.Command { Description: apmcli.GetDescription(), AIDescription: apmcli.GetAIDescription(), Subcommands: apmcli.GetSubCommands(), - Action: passthrough.RunApmPassthroughDefault, + Action: apmcli.RunApmPassthroughDefault, }, } } From 2229d1c9671cb5107dfea4b6a879c3552594ccf1 Mon Sep 17 00:00:00 2001 From: Uday Date: Sun, 16 Aug 2026 21:18:17 +0530 Subject: [PATCH 31/40] RTECO-1648 - Bump min supported apm version to 0.23.0 and rename setup command to 'jf setup apm' - Raise minSupportedApmVersion from 0.1.0 to 0.23.0. - Update all 'jf setup agent-apm' references (help text, error messages, comments) to 'jf setup apm', matching jfrog-cli-core's renamed ProjectType identifier. - Add a regression test case confirming versions between the old and new minimum are now rejected. --- agent/apm/cli/help.go | 4 ++-- agent/apm/commands/install/help.go | 4 ++-- agent/apm/commands/install/install.go | 2 +- agent/apm/commands/publish/help.go | 4 ++-- agent/apm/commands/publish/publish.go | 2 +- agent/apm/commands/update/help.go | 4 ++-- agent/apm/commands/update/update.go | 2 +- agent/apm/common/apmenv.go | 6 +++--- agent/apm/common/utils.go | 2 +- agent/apm/common/utils_test.go | 5 +++++ go.mod | 2 +- go.sum | 2 ++ 12 files changed, 23 insertions(+), 16 deletions(-) diff --git a/agent/apm/cli/help.go b/agent/apm/cli/help.go index 24dc5626..9df12dd4 100644 --- a/agent/apm/cli/help.go +++ b/agent/apm/cli/help.go @@ -13,7 +13,7 @@ When to use: Prerequisites: - apm CLI installed and on PATH. -- Registry configured via 'jf setup agent-apm' or an apm.yml registries: block. +- Registry configured via 'jf setup apm' or an apm.yml registries: block. - A configured JFrog Platform server (jf c add / jf login), or pass --server-id. Common patterns: @@ -27,5 +27,5 @@ Gotchas: - Build-info is collected only by install, publish, and update, and only when both --build-name and --build-number are provided; publish it afterwards with 'jf rt build-publish'. - 'jf agent apm --help' shows that command's own help; 'apm --help' lists every native apm command reachable this way. -Related: jf setup agent-apm, jf agent apm install, jf agent apm publish, jf agent apm update, jf rt build-publish` +Related: jf setup apm, jf agent apm install, jf agent apm publish, jf agent apm update, jf rt build-publish` } diff --git a/agent/apm/commands/install/help.go b/agent/apm/commands/install/help.go index 2f938ef2..f02e00c0 100644 --- a/agent/apm/commands/install/help.go +++ b/agent/apm/commands/install/help.go @@ -14,7 +14,7 @@ When to use: Prerequisites: - apm CLI installed and on PATH. -- A registry declared in apm.yml's registries: block, or configured via 'jf setup agent-apm'. +- A registry declared in apm.yml's registries: block, or configured via 'jf setup apm'. - Read permission on the source Artifactory agentpackages repository. Common patterns: @@ -29,5 +29,5 @@ Gotchas: - --dry-run previews the install without changing anything and skips build-info (nothing real to record). - Build-info is collected only when both --build-name and --build-number are provided; publish it afterwards with 'jf rt build-publish'. -Related: jf agent apm publish, jf agent apm update, jf setup agent-apm, jf rt build-publish` +Related: jf agent apm publish, jf agent apm update, jf setup apm, jf rt build-publish` } diff --git a/agent/apm/commands/install/install.go b/agent/apm/commands/install/install.go index d360850f..a11c34ce 100644 --- a/agent/apm/commands/install/install.go +++ b/agent/apm/commands/install/install.go @@ -20,7 +20,7 @@ const apmSubcommand = "install" // ApmInstallCommand runs `apm install` with JFrog Artifactory authentication and collects // build-info from the resulting apm.lock.yaml. Never accepts --repo; a registry must already be -// declared via jf setup agent-apm or apm.yml's own registries: block. +// declared via jf setup apm or apm.yml's own registries: block. type ApmInstallCommand struct { args []string serverDetails *config.ServerDetails diff --git a/agent/apm/commands/publish/help.go b/agent/apm/commands/publish/help.go index 0ce38b58..2f7941c4 100644 --- a/agent/apm/commands/publish/help.go +++ b/agent/apm/commands/publish/help.go @@ -16,7 +16,7 @@ Prerequisites: - apm CLI installed and on PATH. - An apm.yml in the package directory (or a parent) declaring name, version, and description. - Write permission on the Artifactory agentpackages repository. -- Registry configured via 'jf setup agent-apm' or an apm.yml registries: block. +- Registry configured via 'jf setup apm' or an apm.yml registries: block. Common patterns: $ jf agent apm publish --package my-org/my-package @@ -29,5 +29,5 @@ Gotchas: - --dry-run previews the upload without publishing and skips build-info. - Build-info is collected only when both --build-name and --build-number are provided; optional --module groups packages under one module. Publish afterwards with 'jf rt build-publish'. -Related: jf agent apm install, jf agent apm update, jf setup agent-apm, jf rt build-publish` +Related: jf agent apm install, jf agent apm update, jf setup apm, jf rt build-publish` } diff --git a/agent/apm/commands/publish/publish.go b/agent/apm/commands/publish/publish.go index 7b50cb59..8659f2d0 100644 --- a/agent/apm/commands/publish/publish.go +++ b/agent/apm/commands/publish/publish.go @@ -20,7 +20,7 @@ const apmSubcommand = "publish" // ApmPublishCommand runs `apm publish` with JFrog Artifactory authentication and records the // published package in build-info. Never accepts --repo; a registry must already be declared -// via jf setup agent-apm or apm.yml's own registries: block, which also supplies the repo name +// via jf setup apm or apm.yml's own registries: block, which also supplies the repo name // for build-info enrichment (see ResolveRepoNameFromRegistry). type ApmPublishCommand struct { args []string diff --git a/agent/apm/commands/update/help.go b/agent/apm/commands/update/help.go index 977faa14..dba05d0c 100644 --- a/agent/apm/commands/update/help.go +++ b/agent/apm/commands/update/help.go @@ -16,7 +16,7 @@ Prerequisites: - apm CLI installed and on PATH. - An apm.yml with dependencies declared, and an existing apm.lock.yaml (e.g. from 'jf agent apm install'). - Read permission on the source Artifactory agentpackages repository. -- Registry configured via 'jf setup agent-apm' or an apm.yml registries: block. +- Registry configured via 'jf setup apm' or an apm.yml registries: block. Common patterns: $ jf agent apm update --dry-run @@ -28,5 +28,5 @@ Gotchas: - --dry-run previews the plan without applying changes and skips build-info. - Build-info is collected only when both --build-name and --build-number are provided; publish it afterwards with 'jf rt build-publish'. -Related: jf agent apm install, jf agent apm publish, jf setup agent-apm, jf rt build-publish` +Related: jf agent apm install, jf agent apm publish, jf setup apm, jf rt build-publish` } diff --git a/agent/apm/commands/update/update.go b/agent/apm/commands/update/update.go index 75618af6..65b01f70 100644 --- a/agent/apm/commands/update/update.go +++ b/agent/apm/commands/update/update.go @@ -19,7 +19,7 @@ const apmSubcommand = "update" // ApmUpdateCommand runs `apm update` with JFrog Artifactory authentication and collects // build-info from the resulting apm.lock.yaml, reusing install's exact reader. Never accepts -// --repo; a registry must already be declared via jf setup agent-apm or apm.yml's registries: +// --repo; a registry must already be declared via jf setup apm or apm.yml's registries: // block. type ApmUpdateCommand struct { args []string diff --git a/agent/apm/common/apmenv.go b/agent/apm/common/apmenv.go index 3fba98e5..3f6bc75a 100644 --- a/agent/apm/common/apmenv.go +++ b/agent/apm/common/apmenv.go @@ -289,7 +289,7 @@ func ensureExperimentalFlagEnabled(realHome string, existing *apmConfigJSON) err // BuildApmEnv resolves how apm should authenticate for this invocation. Credentials always // travel via APM_REGISTRY_TOKEN_/APM_REGISTRY_USER_+PASS_ env vars — never // written to a file. The registry must already be declared (an existing ~/.apm/config.json -// entry, written by 'jf setup agent-apm', or apm.yml's own registries: block); if none matches +// entry, written by 'jf setup apm', or apm.yml's own registries: block); if none matches // serverDetails's host, this returns an error rather than silently running apm unauthenticated. func BuildApmEnv(serverDetails *config.ServerDetails, manifestPath string) ([]string, error) { if serverDetails == nil { @@ -305,7 +305,7 @@ func BuildApmEnv(serverDetails *config.ServerDetails, manifestPath string) ([]st if len(discovered) == 0 { return nil, fmt.Errorf( "no APM registry found for %s: declare one in apm.yml's registries: block, "+ - "or add it to ~/.apm/config.json (via 'jf setup agent-apm')", + "or add it to ~/.apm/config.json (via 'jf setup apm')", serverDetails.ArtifactoryUrl) } @@ -432,7 +432,7 @@ func RunApmCommand(env []string, subcmd string, args []string) error { // ConfigureApmRegistryPersistent configures ~/.apm/config.json via apm's own `apm experimental // enable registries` and `apm config set` commands, never by writing the file directly. Called -// only by `jf setup agent-apm`; repoName is always non-empty, resolved by the interactive repo +// only by `jf setup apm`; repoName is always non-empty, resolved by the interactive repo // picker beforehand. func ConfigureApmRegistryPersistent(serverDetails *config.ServerDetails, repoName string) error { if serverDetails == nil { diff --git a/agent/apm/common/utils.go b/agent/apm/common/utils.go index 76078710..9f1ba7db 100644 --- a/agent/apm/common/utils.go +++ b/agent/apm/common/utils.go @@ -9,7 +9,7 @@ import ( "github.com/jfrog/jfrog-client-go/utils/log" ) -const minSupportedApmVersion = "0.1.0" +const minSupportedApmVersion = "0.23.0" // ValidateApmPrerequisites checks that apm is installed and meets minSupportedApmVersion. func ValidateApmPrerequisites() error { diff --git a/agent/apm/common/utils_test.go b/agent/apm/common/utils_test.go index b3b1d859..81187429 100644 --- a/agent/apm/common/utils_test.go +++ b/agent/apm/common/utils_test.go @@ -88,6 +88,11 @@ func TestValidateApmPrerequisites_VersionComparisonDirection(t *testing.T) { rawOutput: "Agent Package Manager (APM) CLI version 0.0.5 (abc1234)", wantError: true, }, + { + name: "installed version below the raised minimum but above the old one", + rawOutput: "Agent Package Manager (APM) CLI version 0.15.0 (abc1234)", + wantError: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/go.mod b/go.mod index 633e61b1..f278a620 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/jedib0t/go-pretty/v6 v6.8.3 github.com/jfrog/build-info-go v1.13.1-0.20260811050759-64113d16f1db github.com/jfrog/gofrog v1.7.6 - github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260814091755-dc7196ee69fc + github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260816155142-ac59b1aecb32 github.com/jfrog/jfrog-cli-evidence v0.9.0 github.com/jfrog/jfrog-client-go v1.55.1-0.20260813100550-0f2168d02558 github.com/pkg/errors v0.9.1 diff --git a/go.sum b/go.sum index adaa63f7..44170124 100644 --- a/go.sum +++ b/go.sum @@ -390,6 +390,8 @@ github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260811051029-e2289bda7c64 h1:hH6T github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260811051029-e2289bda7c64/go.mod h1:MygQx8pekgPCXyXnejIAVG9S4ImGcDFmcfRPUug/0d0= github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260814091755-dc7196ee69fc h1:Xmd2P/dgG872q9GkuZOcmIqPm87y2gQZcNURk+YRJMI= github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260814091755-dc7196ee69fc/go.mod h1:gf7aUg/G9JyltCNhwMD5RVEsFzUCKPWKXRcTXSqMYBk= +github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260816155142-ac59b1aecb32 h1:+/wQ/UeJE+f16bVbGG3C5cgE8UwMuXZQxaJrq15lr24= +github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260816155142-ac59b1aecb32/go.mod h1:gf7aUg/G9JyltCNhwMD5RVEsFzUCKPWKXRcTXSqMYBk= 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= From de8d1f69ce20b6fbb1d3e6af683eb70c8ee37480 Mon Sep 17 00:00:00 2001 From: Uday Date: Mon, 24 Aug 2026 11:21:24 +0530 Subject: [PATCH 32/40] Answer review comments --- agent/apm/commands/install/install.go | 4 +- agent/apm/commands/publish/publish.go | 4 +- agent/apm/commands/update/update.go | 4 +- agent/apm/common/apmenv.go | 153 +++++++++++++++----- agent/apm/common/build_info.go | 56 ++++++- agent/apm/common/dependency_resolver.go | 19 ++- agent/apm/common/subcommand_options.go | 24 +-- agent/apm/common/subcommand_options_test.go | 14 +- agent/common/server.go | 19 +++ cliutils/flagkit/flags.go | 2 +- 10 files changed, 227 insertions(+), 72 deletions(-) diff --git a/agent/apm/commands/install/install.go b/agent/apm/commands/install/install.go index a11c34ce..221d6541 100644 --- a/agent/apm/commands/install/install.go +++ b/agent/apm/commands/install/install.go @@ -115,13 +115,13 @@ func RunInstall(c *components.Context) error { if err != nil { return err } - serverDetails, err := agentcommon.GetServerDetails(c) + serverDetails, err := agentcommon.GetServerDetailsByID(opts.ServerID) if err != nil { return err } cmd := NewApmInstallCommand(). - SetArgs(opts.RemainingArgs). + SetArgs(opts.ApmNativeArgs). SetServerDetails(serverDetails). SetBuildConfiguration(opts.BuildConfig) diff --git a/agent/apm/commands/publish/publish.go b/agent/apm/commands/publish/publish.go index 8659f2d0..32c130dd 100644 --- a/agent/apm/commands/publish/publish.go +++ b/agent/apm/commands/publish/publish.go @@ -164,13 +164,13 @@ func RunPublish(c *components.Context) error { if err != nil { return err } - serverDetails, err := agentcommon.GetServerDetails(c) + serverDetails, err := agentcommon.GetServerDetailsByID(opts.ServerID) if err != nil { return err } cmd := NewApmPublishCommand(). - SetArgs(opts.RemainingArgs). + SetArgs(opts.ApmNativeArgs). SetServerDetails(serverDetails). SetBuildConfiguration(opts.BuildConfig) diff --git a/agent/apm/commands/update/update.go b/agent/apm/commands/update/update.go index 65b01f70..8bb57d4f 100644 --- a/agent/apm/commands/update/update.go +++ b/agent/apm/commands/update/update.go @@ -97,13 +97,13 @@ func RunUpdate(c *components.Context) error { if err != nil { return err } - serverDetails, err := agentcommon.GetServerDetails(c) + serverDetails, err := agentcommon.GetServerDetailsByID(opts.ServerID) if err != nil { return err } cmd := NewApmUpdateCommand(). - SetArgs(opts.RemainingArgs). + SetArgs(opts.ApmNativeArgs). SetServerDetails(serverDetails). SetBuildConfiguration(opts.BuildConfig) diff --git a/agent/apm/common/apmenv.go b/agent/apm/common/apmenv.go index 3f6bc75a..c6f7c328 100644 --- a/agent/apm/common/apmenv.go +++ b/agent/apm/common/apmenv.go @@ -1,7 +1,6 @@ package apmcommon import ( - "context" "encoding/json" "fmt" "io" @@ -13,26 +12,23 @@ import ( "path/filepath" "slices" "strings" - "time" + rtUtils "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils" "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/access/services" "github.com/jfrog/jfrog-client-go/utils/log" ) -const agentPackagesAPIPrefix = "/api/agentpackages/" - -// generateAccessTokenTimeout bounds the token-generation HTTP call, so a hung Artifactory -// response can't block the whole install/publish/update command forever. -const generateAccessTokenTimeout = 30 * time.Second +const ( + agentPackagesAPIPrefix = "/api/agentpackages/" -// ApmBinaryName is the apm executable RunApmCommand always shells out to. -const ApmBinaryName = "apm" + // ApmBinaryName is the apm executable RunApmCommand always shells out to. + ApmBinaryName = "apm" -// HelpFlag is the help flag this package constructs when forwarding to apm. -const HelpFlag = "--help" + // HelpFlag is the help flag this package constructs when forwarding to apm. + HelpFlag = "--help" -// apmConfigDirName and apmConfigFileName make up ~/.apm/config.json. -const ( + // apmConfigDirName and apmConfigFileName make up ~/.apm/config.json. apmConfigDirName = ".apm" apmConfigFileName = "config.json" ) @@ -63,23 +59,66 @@ func BuildRegistryEntry(serverDetails *config.ServerDetails, repoName string) (r if generatedToken != "" { return base, generatedToken } - // Fallback: if token generation fails, fall through to no-token case - // (APM CLI may handle auth differently or skip this registry) + // No fallback auth mechanism exists here - token generation failing means this + // registry entry is returned without credentials (see "no token available" below). + log.Debug(fmt.Sprintf("apm: failed to generate access token for %s; registry %q will be configured without credentials", base, repoName)) } // No token available - return URL only return base, "" } -// generateAccessToken calls Artifactory's deprecated token generation API -// (POST /artifactory/api/security/token, form-urlencoded - the JSON, plural -// "/tokens" endpoint returns 405) to create an access token from username/ -// password. Returns empty string if generation fails. +// generateAccessToken creates an access token from username/password. It tries +// jfrog-client-go's access TokenService first (the same ServiceManager-based path used +// elsewhere in this repo, e.g. jfrog-cli-core's AccessTokenCreateCommand), falling back to a +// direct call against Artifactory's older, deprecated token endpoint only if that fails - some +// Artifactory instances still don't expose (or allow) the modern Access service. Returns empty +// string if both paths fail. func generateAccessToken(serverDetails *config.ServerDetails) string { if serverDetails.User == "" || serverDetails.Password == "" { return "" } + if token := generateAccessTokenViaAccessAPI(serverDetails); token != "" { + return token + } + + log.Debug("apm: modern access-token API failed; falling back to the deprecated Artifactory token endpoint") + return generateAccessTokenLegacy(serverDetails) +} + +// generateAccessTokenViaAccessAPI is the primary token-generation path, described above. +func generateAccessTokenViaAccessAPI(serverDetails *config.ServerDetails) string { + accessManager, err := rtUtils.CreateAccessServiceManager(serverDetails, false) + if err != nil { + log.Debug("Failed to create access service manager for token generation:", err.Error()) + return "" + } + + nonExpiring := uint(0) + tokenParams := services.CreateTokenParams{Username: serverDetails.User} + tokenParams.Scope = "applied-permissions/user" + tokenParams.ExpiresIn = &nonExpiring + + tokenResponse, err := accessManager.CreateAccessToken(tokenParams) + if err != nil { + log.Debug("Failed to generate access token via the access API:", err.Error()) + return "" + } + if tokenResponse.AccessToken == "" { + log.Debug("Access API token generation returned no access_token") + return "" + } + + log.Debug("Access token generated for APM registry via the access API") + return tokenResponse.AccessToken +} + +// generateAccessTokenLegacy calls Artifactory's deprecated token generation API (POST +// /artifactory/api/security/token, form-urlencoded - the JSON, plural "/tokens" endpoint +// returns 405) to create an access token from username/password. Fallback only - see +// generateAccessToken. Returns empty string if generation fails. +func generateAccessTokenLegacy(serverDetails *config.ServerDetails) string { tokenURL := strings.TrimSuffix(serverDetails.ArtifactoryUrl, "/") + "/api/security/token" form := url.Values{} @@ -87,11 +126,9 @@ func generateAccessToken(serverDetails *config.ServerDetails) string { form.Set("scope", "applied-permissions/user") form.Set("expires_in", "0") - ctx, cancel := context.WithTimeout(context.Background(), generateAccessTokenTimeout) - defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode())) + req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(form.Encode())) if err != nil { - log.Debug("Failed to build access token request:", err.Error()) + log.Debug("Failed to build legacy access token request:", err.Error()) return "" } req.SetBasicAuth(serverDetails.User, serverDetails.Password) @@ -99,35 +136,35 @@ func generateAccessToken(serverDetails *config.ServerDetails) string { resp, err := http.DefaultClient.Do(req) if err != nil { - log.Debug("Failed to generate access token:", err.Error()) + log.Debug("Failed to generate legacy access token:", err.Error()) return "" } defer func() { _ = resp.Body.Close() }() // read-side close on an already fully-read response body, err := io.ReadAll(resp.Body) if err != nil { - log.Debug("Failed to read access token response:", err.Error()) + log.Debug("Failed to read legacy access token response:", err.Error()) return "" } if resp.StatusCode != http.StatusOK { - log.Debug(fmt.Sprintf("Access token generation returned status %d: %s", resp.StatusCode, string(body))) + log.Debug(fmt.Sprintf("Legacy access token generation returned status %d: %s", resp.StatusCode, string(body))) return "" } // Response field is "access_token", not "token". var response map[string]any if err := json.Unmarshal(body, &response); err != nil { - log.Debug("Failed to parse token response:", err.Error()) + log.Debug("Failed to parse legacy token response:", err.Error()) return "" } token, ok := response["access_token"].(string) if !ok || token == "" { - log.Debug("No access_token in API response") + log.Debug("No access_token in legacy API response") return "" } - log.Debug("Access token generated for APM registry") + log.Debug("Access token generated for APM registry via the legacy endpoint") return token } @@ -409,27 +446,65 @@ func RunApmCommand(env []string, subcmd string, args []string) error { cmd.Env = env } - // Capture both stdout and stderr to detect validation failures - var outBuf, errBuf strings.Builder + // Capture only a bounded tail of stdout/stderr - just enough to spot the validation + // markers below - so a chatty subcommand can't grow these buffers unbounded. The full, + // unbounded output still reaches the user via os.Stdout/os.Stderr. + var outBuf, errBuf tailBuffer + outBuf.maxSize, errBuf.maxSize = maxCapturedOutputBytes, maxCapturedOutputBytes cmd.Stdout = io.MultiWriter(os.Stdout, &outBuf) cmd.Stderr = io.MultiWriter(os.Stderr, &errBuf) cmd.Stdin = os.Stdin err := cmd.Run() - output := outBuf.String() + errBuf.String() - - // Check for APM validation failures: "[x]" marker or "All packages failed validation" - // APM sometimes exits with code 0 even when validation failed - if strings.Contains(output, "[x]") || strings.Contains(output, "All packages failed validation") { - return fmt.Errorf("apm %s failed: validation errors detected in output", subcmd) - } - if err != nil { return fmt.Errorf("apm %s failed: %w", subcmd, err) } + + // apm sometimes exits with code 0 even when dependency validation failed, so scan the + // output too - but only for subcommands that actually perform validation. Passthrough + // subcommands (e.g. "list", "--help") can legitimately contain "[x]" in unrelated text. + if isValidationCheckedSubcommand(subcmd) { + output := outBuf.String() + errBuf.String() + if strings.Contains(output, "[x]") || strings.Contains(output, "All packages failed validation") { + return fmt.Errorf("apm %s failed: validation errors detected in output", subcmd) + } + } return nil } +// maxCapturedOutputBytes bounds how much of each stream's tail RunApmCommand retains for +// validation-marker detection. +const maxCapturedOutputBytes = 64 * 1024 + +// tailBuffer is an io.Writer that retains only the most recent maxSize bytes written to it. +type tailBuffer struct { + maxSize int + buf []byte +} + +func (t *tailBuffer) Write(p []byte) (int, error) { + t.buf = append(t.buf, p...) + if len(t.buf) > t.maxSize { + t.buf = t.buf[len(t.buf)-t.maxSize:] + } + return len(p), nil +} + +func (t *tailBuffer) String() string { + return string(t.buf) +} + +// isValidationCheckedSubcommand reports whether subcmd is one of the apm subcommands that +// perform dependency validation and can exit 0 while still having failed it. +func isValidationCheckedSubcommand(subcmd string) bool { + switch subcmd { + case "install", "update", "publish": + return true + default: + return false + } +} + // ConfigureApmRegistryPersistent configures ~/.apm/config.json via apm's own `apm experimental // enable registries` and `apm config set` commands, never by writing the file directly. Called // only by `jf setup apm`; repoName is always non-empty, resolved by the interactive repo diff --git a/agent/apm/common/build_info.go b/agent/apm/common/build_info.go index 1f2d2a90..81497472 100644 --- a/agent/apm/common/build_info.go +++ b/agent/apm/common/build_info.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/jfrog/build-info-go/entities" "github.com/jfrog/gofrog/crypto" @@ -133,6 +134,48 @@ func anchorRequestedByToModule(requestedBy [][]string, moduleID string) [][]stri return anchored } +// buildArtifactPathParts derives (fileName, dirPath, artifactPath) for a published APM package +// from the assumed agentpackages storage convention: {repo}/{owner}/{packageName}/{packageName}- +// {version}.zip. This is a naming convention this code assumes, not something read back from +// Artifactory - verifyArtifactPathExists is how callers can confirm it against the real repo. +func buildArtifactPathParts(owner, packageName, version string) (fileName, dirPath, artifactPath string) { + fileName = packageName + "-" + version + "." + apmPackageFileExtension + dirPath = packageName + artifactPath = fileName + if owner != "" { + dirPath = owner + "/" + packageName + artifactPath = dirPath + "/" + fileName + } + return fileName, dirPath, artifactPath +} + +// verifyArtifactPathExists issues an HTTP HEAD directly against repoName/artifactPath (the exact +// path build-info is about to record) to confirm apm's agentpackages storage convention actually +// matches where the file lives. Best-effort only: returns false and logs a warning on any +// failure, but never blocks or fails the publish - build-info is still recorded either way. +func verifyArtifactPathExists(serverDetails *config.ServerDetails, repoName, artifactPath string) bool { + if serverDetails == nil || repoName == "" || artifactPath == "" { + return false + } + servicesManager, err := artCoreUtils.CreateServiceManager(serverDetails, -1, 0, false) + if err != nil { + log.Debug("apm publish: could not create service manager for artifact path verification:", err.Error()) + return false + } + + base := strings.TrimSuffix(serverDetails.GetArtifactoryUrl(), "/") + directURL := base + "/" + repoName + "/" + artifactPath + clientDetails := servicesManager.GetConfig().GetServiceDetails().CreateHttpClientDetails() + if _, _, err := servicesManager.Client().GetRemoteFileDetails(directURL, &clientDetails); err != nil { + log.Warn(fmt.Sprintf( + "apm publish: could not confirm the assumed artifact path %q exists in %s (%s); "+ + "build-info will still record this path, but it may not match where apm actually stored the file.", + artifactPath, repoName, err.Error())) + return false + } + return true +} + // SavePublishBuildInfo saves build artifact info for a published APM package. moduleName is the // project's own identity (apm.yml's name:, for the build-info module id); packageName is the // identity apm actually uploaded under via --package (owner/packageName), which Artifactory's @@ -161,13 +204,7 @@ func SavePublishBuildInfo(owner, moduleName, packageName, version string, checks moduleID = moduleName + ":" + version } - fileName := packageName + "-" + version + "." + apmPackageFileExtension - dirPath := packageName - artifactPath := fileName - if owner != "" { - dirPath = owner + "/" + packageName - artifactPath = dirPath + "/" + fileName - } + fileName, dirPath, artifactPath := buildArtifactPathParts(owner, packageName, version) artifact := entities.Artifact{ Name: fileName, @@ -273,6 +310,11 @@ func CollectAndSavePublishBuildInfo(manifestPath, owner, packageName, repoName, packageName = manifest.Name } + // Best-effort confirmation that the assumed agentpackages path actually holds the file + // build-info is about to record it under. Never blocks the publish either way. + _, _, artifactPath := buildArtifactPathParts(owner, packageName, manifest.Version) + verifyArtifactPathExists(serverDetails, repoName, artifactPath) + checksum := lookupPublishedArtifactChecksum(owner, packageName, manifest.Version, repoName, serverDetails) if !hasAnyChecksum(checksum) { zipPath := explicitZipPath diff --git a/agent/apm/common/dependency_resolver.go b/agent/apm/common/dependency_resolver.go index 58685312..8d1da772 100644 --- a/agent/apm/common/dependency_resolver.go +++ b/agent/apm/common/dependency_resolver.go @@ -21,9 +21,22 @@ const depsWhyWorkerCount = 15 const depsWhyTimeout = 30 * time.Second // Dependency scope names. A dependency gets exactly one of these, chosen by finalScope's -// priority ladder (prod > dev > transitive) - the same mutually-exclusive model pnpm's own -// resolver in this repo uses (artifactory/commands/pnpm/dependency_resolver.go's addScope), -// rather than combining them. +// priority ladder (prod > dev > transitive) rather than combining them. +// +// "prod"/"dev" are not this repo's invention: they're build-info-go's own vocabulary for the +// Dependency.Scopes field, e.g. its npm resolver (build-info-go's build/utils/npm.go getScopes) +// emits exactly these two strings. "transitive" is this repo's own addition on top of that (also +// used by the pnpm resolver, artifactory/commands/pnpm/dependency_resolver.go) to distinguish +// direct from pulled-in-only dependencies, a distinction build-info-go's npm resolver doesn't +// need to make. +// +// apm itself has no equivalent "scope" vocabulary at all - apm.yml does have its own +// dependencies/devDependencies split deliberately mirroring package.json +// (https://microsoft.github.io/apm/concepts/package-anatomy/), but nothing in apm.yml or +// apm.lock.yaml is ever called a "scope", and this package doesn't re-walk apm.yml's tree to +// derive it - it trusts apm's own already-resolved per-entry flags (is_dev from apm.lock.yaml, +// is_direct from `apm deps why --json`) and maps those two booleans onto build-info-go's +// established prod/dev vocabulary, extended with transitive. const ( apmScopeProd = "prod" apmScopeDev = "dev" diff --git a/agent/apm/common/subcommand_options.go b/agent/apm/common/subcommand_options.go index 3186bfb1..aec16255 100644 --- a/agent/apm/common/subcommand_options.go +++ b/agent/apm/common/subcommand_options.go @@ -8,22 +8,24 @@ import ( // ApmSubcommandOptions holds jf's own flags, manually extracted from a SkipFlagParsing // subcommand's raw arguments, plus whatever args remained afterward. type ApmSubcommandOptions struct { - // RemainingArgs is what's left after stripping jf's own flags - passed straight through + // ApmNativeArgs is what's left after stripping jf's own flags - passed straight through // to the real apm binary, so apm-native flags (--package, --registry, --zip, --dry-run, - // --server-id, --repo, etc.) survive untouched. Server auth always comes from the default - // configured JFrog server (see agentcommon.GetServerDetails) - install/publish/update take - // no server-selection flags of their own, matching the pnpm/npm/yarn/nuget convention. - RemainingArgs []string + // --repo, etc.) survive untouched. + ApmNativeArgs []string BuildConfig *buildUtils.BuildConfiguration + // ServerID is jf's own --server-id value, if provided. Empty means "use the default + // configured server" (see agentcommon.GetServerDetailsByID). + ServerID string } // ExtractApmSubcommandOptions extracts install/publish/update's own flags (--build-name, -// --build-number, --module, --project) from args and resolves them into a BuildConfiguration. -// Needed because those commands set SkipFlagParsing (so apm-native flags reach apm unrejected), -// which means urfave/cli parses none of jf's own flags either - they must be pulled out by hand. +// --build-number, --module, --project, --server-id) from args and resolves the build-info +// ones into a BuildConfiguration. Needed because those commands set SkipFlagParsing (so +// apm-native flags reach apm unrejected), which means urfave/cli parses none of jf's own +// flags either - they must be pulled out by hand. func ExtractApmSubcommandOptions(args []string) (*ApmSubcommandOptions, error) { rest := args - var buildName, buildNumber, module, project string + var buildName, buildNumber, module, project, serverID string var err error for _, opt := range []struct { @@ -34,6 +36,7 @@ func ExtractApmSubcommandOptions(args []string) (*ApmSubcommandOptions, error) { {"build-number", &buildNumber}, {"module", &module}, {"project", &project}, + {"server-id", &serverID}, } { rest, *opt.dest, err = coreutils.ExtractStringOptionFromArgs(rest, opt.name) if err != nil { @@ -47,7 +50,8 @@ func ExtractApmSubcommandOptions(args []string) (*ApmSubcommandOptions, error) { } return &ApmSubcommandOptions{ - RemainingArgs: rest, + ApmNativeArgs: rest, BuildConfig: buildConfig, + ServerID: serverID, }, nil } diff --git a/agent/apm/common/subcommand_options_test.go b/agent/apm/common/subcommand_options_test.go index 13ebcf18..e126af0f 100644 --- a/agent/apm/common/subcommand_options_test.go +++ b/agent/apm/common/subcommand_options_test.go @@ -11,9 +11,10 @@ import ( func TestExtractApmSubcommandOptions_PreservesApmNativeFlags(t *testing.T) { testutil.WithJfrogHome(t) - // install/publish/update don't declare --repo, --server-id, or direct-credential flags as - // jf's own - a registry and server must already be declared/configured, so none of these - // are extracted here; they flow through untouched like any other apm-native flag. + // install/publish/update don't declare --repo or direct-credential flags as jf's own - a + // registry must already be declared/configured, so those flow through untouched like any + // other apm-native flag. --server-id IS jf's own flag (selects which configured JFrog + // server to use) and is extracted here, not forwarded. opts, err := ExtractApmSubcommandOptions([]string{ "--repo", "buk-apm", "--server-id", "my-server", @@ -23,8 +24,9 @@ func TestExtractApmSubcommandOptions_PreservesApmNativeFlags(t *testing.T) { }) require.NoError(t, err) assert.Equal(t, - []string{"--repo", "buk-apm", "--server-id", "my-server", "--registry", "buk-apm", "--frozen", "uday/pkg-base#1.0.0"}, - opts.RemainingArgs) + []string{"--repo", "buk-apm", "--registry", "buk-apm", "--frozen", "uday/pkg-base#1.0.0"}, + opts.ApmNativeArgs) + assert.Equal(t, "my-server", opts.ServerID) } func TestExtractApmSubcommandOptions_ExtractsBuildInfoFlags(t *testing.T) { @@ -38,7 +40,7 @@ func TestExtractApmSubcommandOptions_ExtractsBuildInfoFlags(t *testing.T) { "uday/pkg-base#1.0.0", }) require.NoError(t, err) - assert.Equal(t, []string{"uday/pkg-base#1.0.0"}, opts.RemainingArgs) + assert.Equal(t, []string{"uday/pkg-base#1.0.0"}, opts.ApmNativeArgs) buildName, err := opts.BuildConfig.GetBuildName() require.NoError(t, err) diff --git a/agent/common/server.go b/agent/common/server.go index 26ab20b4..1e4e0cb5 100644 --- a/agent/common/server.go +++ b/agent/common/server.go @@ -34,6 +34,25 @@ func GetServerDetails(commandContext *components.Context) (*config.ServerDetails return details, nil } +// GetServerDetailsByID returns ServerDetails for serverID, or the default configured server if +// serverID is empty. Unlike GetServerDetails, it never inspects commandContext flags - callers +// that manually extract --server-id from raw arguments (e.g. SkipFlagParsing subcommands, where +// urfave/cli parses none of jf's own flags) resolve it through this instead. +func GetServerDetailsByID(serverID string) (*config.ServerDetails, error) { + details, err := config.GetSpecificConfig(serverID, true, false) + if err != nil { + return nil, fmt.Errorf("no default server configured. Use 'jf config add' or provide --server-id: %w", err) + } + if details == nil { + return nil, fmt.Errorf("no default server configured. Use 'jf config add' or provide --server-id") + } + if details.ArtifactoryUrl == "" && details.Url == "" { + return nil, fmt.Errorf("no Artifactory URL configured") + } + NormalizeArtifactoryUrl(details) + return details, nil +} + // NormalizeArtifactoryUrl ensures details.ArtifactoryUrl always ends with /artifactory/, // filling in details.Url from it when Url is empty. func NormalizeArtifactoryUrl(details *config.ServerDetails) { diff --git a/cliutils/flagkit/flags.go b/cliutils/flagkit/flags.go index d45946bb..ff77bd96 100644 --- a/cliutils/flagkit/flags.go +++ b/cliutils/flagkit/flags.go @@ -927,7 +927,7 @@ var commandFlags = map[string][]string{ url, user, password, accessToken, serverId, repo, harness, projectDir, agentGlobal, agentFormat, agentLimit, agentSortBy, agentSortOrder, agentCheckUpdates, }, AgentApm: { - BuildName, BuildNumber, module, Project, + serverId, BuildName, BuildNumber, module, Project, }, } From 2ee56aa5315841812f59ba39c9dc5d7ae3ecd582 Mon Sep 17 00:00:00 2001 From: Uday Date: Sun, 30 Aug 2026 23:48:41 +0530 Subject: [PATCH 33/40] RTECO-1648 - Fix passthrough --server-id leak, align default module ID with npm - agent/apm/cli/cli.go: RunApmPassthroughDefault now strips --server-id via coreutils.ExtractServerIdFromCommand before resolving the server or forwarding args to apm, mirroring jf nix's own passthrough dispatcher. Previously --server-id was silently ignored for server resolution and leaked through as a raw, unrecognized argument to the native apm binary. - agent/apm/common/build_info.go: derivedModuleID and SavePublishBuildInfo's inline fallback now return an empty module id (not a directory-basename fallback, not a partial name-only or version-only id) when apm.yml can't be read or its name/version are incomplete - matching npm/yarn's BuildInfoModuleId() convention exactly. CollectAndSavePublishBuildInfo no longer skips build-info collection on an incomplete manifest; an empty module id now flows through to build-info-go's generic partial-merge fallback (module.Id = build name), the same path npm's own AddNpmModule takes. - agent/apm/common/build_info_test.go: updated TestDerivedModuleID's three fallback sub-tests to assert the new npm-aligned behavior. - go.mod/go.sum: bump jfrog-cli-core to d499371002dc9f9890ae603307379ed15f3da72d. --- agent/apm/cli/cli.go | 23 +++++++++++++++------ agent/apm/common/build_info.go | 32 ++++++++++++++++------------- agent/apm/common/build_info_test.go | 12 +++++------ go.mod | 4 ++-- go.sum | 16 ++++----------- 5 files changed, 47 insertions(+), 40 deletions(-) diff --git a/agent/apm/cli/cli.go b/agent/apm/cli/cli.go index 7170f4aa..1aebbeb4 100644 --- a/agent/apm/cli/cli.go +++ b/agent/apm/cli/cli.go @@ -9,6 +9,7 @@ import ( "github.com/jfrog/jfrog-cli-artifactory/cliutils/flagkit" "github.com/jfrog/jfrog-cli-core/v2/common/commands" "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" ) // GetSubCommands returns the leaf commands for `jf agent apm`. Commands not listed here (e.g. @@ -46,8 +47,9 @@ func GetSubCommands() []components.Command { } // RunApmPassthroughDefault handles any `jf agent apm ` not among install/publish/update. -// Auth always comes from the default configured JFrog server; passthrough takes no flags of its -// own, so nothing is extracted from c.Arguments beyond the subcommand. +// Passthrough takes exactly one jf-level flag of its own, --server-id (falling back to the +// default configured JFrog server when absent); everything else in c.Arguments is forwarded to +// apm untouched. func RunApmPassthroughDefault(c *components.Context) error { if len(c.Arguments) == 0 { return apmcommon.RunApmCommand(nil, apmcommon.HelpFlag, nil) @@ -57,20 +59,29 @@ func RunApmPassthroughDefault(c *components.Context) error { if apmcommon.IsHelpRequest([]string{subcmd}) { return apmcommon.RunApmCommand(nil, apmcommon.HelpFlag, nil) } + + // Strip --server-id before anything else touches the remaining args, so it never leaks + // through to the native apm binary (which has no such option of its own) - the same + // pattern jf nix's own passthrough fallback uses via coreutils.ExtractServerIdFromCommand. + remainingArgs, serverID, err := coreutils.ExtractServerIdFromCommand(c.Arguments[1:]) + if err != nil { + return err + } + // Show help without resolving server/auth, which a help request never needs. Forward the // full remaining arg tail so nested commands like "deps why" get their own help, not "deps"'s. - if apmcommon.IsHelpRequest(c.Arguments[1:]) { - return apmcommon.RunApmCommand(nil, subcmd, c.Arguments[1:]) + if apmcommon.IsHelpRequest(remainingArgs) { + return apmcommon.RunApmCommand(nil, subcmd, remainingArgs) } - serverDetails, err := agentcommon.GetServerDetails(c) + serverDetails, err := agentcommon.GetServerDetailsByID(serverID) if err != nil { return err } cmd := &apmcommon.PassthroughCommand{ Subcmd: subcmd, - Args: c.Arguments[1:], + Args: remainingArgs, Server: serverDetails, } diff --git a/agent/apm/common/build_info.go b/agent/apm/common/build_info.go index 81497472..5ed5ffcd 100644 --- a/agent/apm/common/build_info.go +++ b/agent/apm/common/build_info.go @@ -199,8 +199,12 @@ func SavePublishBuildInfo(owner, moduleName, packageName, version string, checks return errorutils.CheckErrorf(errBuildInfoNotEnabled) } + // Same convention as npm's BuildInfoModuleId(): "" unless BOTH name and version are set, + // never a partial "name:" or ":version". An empty moduleID isn't special-cased further - + // it flows to build-info-go's generic partial-merge fallback (module.Id = build name), same + // as install/update's derivedModuleID and same as npm's own AddNpmModule. moduleID := buildConfig.GetModule() - if moduleID == "" { + if moduleID == "" && moduleName != "" && version != "" { moduleID = moduleName + ":" + version } @@ -302,10 +306,10 @@ func CollectAndSavePublishBuildInfo(manifestPath, owner, packageName, repoName, if err != nil { return err } - if manifest.Name == "" || manifest.Version == "" { - log.Debug("APM manifest missing name or version; skipping publish build-info.") - return nil - } + // No skip-on-missing-name/version check here, matching npm's own publish path: npm never + // special-cases an incomplete package.json before computing its module id/deploy path + // either - an empty name/version just flows through to whatever moduleID/artifact path + // that produces (see SavePublishBuildInfo's own moduleID fallback below). if packageName == "" { packageName = manifest.Name } @@ -380,19 +384,19 @@ func localPackedArtifactChecksum(zipPath string) (entities.Checksum, error) { // derivedModuleID returns the default module ID for the install-side build-info module: manifest // name:version, matching how npm and yarn derive their module ID (packageInfo.BuildInfoModuleId(), // always "name:version") for every module they create, install or publish alike. Falls back to -// the project directory name if apm.yml can't be read or its name/version are empty, so a project -// that's mid-authoring (no name/version yet) still gets a stable, non-empty module ID. +// matching npm/yarn's own BuildInfoModuleId() convention exactly - including returning "" (not +// a fallback of our own) when apm.yml can't be read or its name/version are empty. An empty +// module ID isn't a special case this package needs to handle: like npm's AddNpmModule, it flows +// through to build-info-go's generic partial-merge fallback (module.Id = build name), the same +// path every other package manager's empty module ID takes. func derivedModuleID(manifestPath string) string { manifest, err := LoadManifest(manifestPath) if err != nil { log.Debug("apm.yml parsing failed while deriving install module ID:", err.Error()) - } else if manifest.Name != "" && manifest.Version != "" { - return manifest.Name + ":" + manifest.Version + return "" } - dir := filepath.Dir(manifestPath) - base := filepath.Base(dir) - if base == "." || base == "" { - return "apm-project" + if manifest.Name == "" || manifest.Version == "" { + return "" } - return base + return manifest.Name + ":" + manifest.Version } diff --git a/agent/apm/common/build_info_test.go b/agent/apm/common/build_info_test.go index 120e7256..df7bdef2 100644 --- a/agent/apm/common/build_info_test.go +++ b/agent/apm/common/build_info_test.go @@ -57,27 +57,27 @@ func TestDerivedModuleID(t *testing.T) { assert.Equal(t, "my-package:1.2.3", derivedModuleID(manifestPath)) }) - t.Run("no manifest file -> falls back to directory name", func(t *testing.T) { + t.Run("no manifest file -> empty, matching npm/yarn's BuildInfoModuleId() convention", func(t *testing.T) { tempDir := t.TempDir() manifestPath := filepath.Join(tempDir, ApmManifestName) - assert.Equal(t, filepath.Base(tempDir), derivedModuleID(manifestPath)) + assert.Equal(t, "", derivedModuleID(manifestPath)) }) - t.Run("manifest missing version -> falls back to directory name", func(t *testing.T) { + t.Run("manifest missing version -> empty, matching npm/yarn's BuildInfoModuleId() convention", func(t *testing.T) { tempDir := t.TempDir() manifestPath := filepath.Join(tempDir, ApmManifestName) require.NoError(t, os.WriteFile(manifestPath, []byte("name: my-package\n"), 0o644)) - assert.Equal(t, filepath.Base(tempDir), derivedModuleID(manifestPath)) + assert.Equal(t, "", derivedModuleID(manifestPath)) }) - t.Run("manifest missing name -> falls back to directory name", func(t *testing.T) { + t.Run("manifest missing name -> empty, matching npm/yarn's BuildInfoModuleId() convention", func(t *testing.T) { tempDir := t.TempDir() manifestPath := filepath.Join(tempDir, ApmManifestName) require.NoError(t, os.WriteFile(manifestPath, []byte("version: 1.2.3\n"), 0o644)) - assert.Equal(t, filepath.Base(tempDir), derivedModuleID(manifestPath)) + assert.Equal(t, "", derivedModuleID(manifestPath)) }) } diff --git a/go.mod b/go.mod index f278a620..85db8bbb 100644 --- a/go.mod +++ b/go.mod @@ -10,9 +10,9 @@ require ( github.com/jedib0t/go-pretty/v6 v6.8.3 github.com/jfrog/build-info-go v1.13.1-0.20260811050759-64113d16f1db github.com/jfrog/gofrog v1.7.6 - github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260816155142-ac59b1aecb32 + github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260830165543-d499371002dc github.com/jfrog/jfrog-cli-evidence v0.9.0 - github.com/jfrog/jfrog-client-go v1.55.1-0.20260813100550-0f2168d02558 + github.com/jfrog/jfrog-client-go v1.55.1-0.20260827094947-e7a90ebc8049 github.com/pkg/errors v0.9.1 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 diff --git a/go.sum b/go.sum index 44170124..82c4e86a 100644 --- a/go.sum +++ b/go.sum @@ -376,8 +376,6 @@ github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7 h1:FWpSWRD8Fb github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7/go.mod h1:BMxO138bOokdgt4UaxZiEfypcSHX0t6SIFimVP1oRfk= github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= -github.com/jfrog/archiver/v3 v3.6.3 h1:hkAmPjBw393tPmQ07JknLNWFNZjXdy2xFEnOW9wwOxI= -github.com/jfrog/archiver/v3 v3.6.3/go.mod h1:5V9l+Fte30Y4qe9dUOAd3yNTf8lmtVNuhKNrvI8PMhg= github.com/jfrog/archiver/v3 v3.6.4 h1:qHAWCLKwo3+ocHNNoWzGZ8ESl8QQk/lR3W09Pt+ROvE= github.com/jfrog/archiver/v3 v3.6.4/go.mod h1:5V9l+Fte30Y4qe9dUOAd3yNTf8lmtVNuhKNrvI8PMhg= github.com/jfrog/build-info-go v1.13.1-0.20260811050759-64113d16f1db h1:OnEYFZUq/LHlevMDQIdgRShVipvhBOnTISMhg8bWz2E= @@ -386,18 +384,12 @@ 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.20260811051029-e2289bda7c64 h1:hH6TvfG+lXg9OfksRBpkIHG2Hhfzl8CDK5FtT83CDhY= -github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260811051029-e2289bda7c64/go.mod h1:MygQx8pekgPCXyXnejIAVG9S4ImGcDFmcfRPUug/0d0= -github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260814091755-dc7196ee69fc h1:Xmd2P/dgG872q9GkuZOcmIqPm87y2gQZcNURk+YRJMI= -github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260814091755-dc7196ee69fc/go.mod h1:gf7aUg/G9JyltCNhwMD5RVEsFzUCKPWKXRcTXSqMYBk= -github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260816155142-ac59b1aecb32 h1:+/wQ/UeJE+f16bVbGG3C5cgE8UwMuXZQxaJrq15lr24= -github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260816155142-ac59b1aecb32/go.mod h1:gf7aUg/G9JyltCNhwMD5RVEsFzUCKPWKXRcTXSqMYBk= +github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260830165543-d499371002dc h1:Q5q9lYOO6tsA+Ras8SigM/y5AnBrCKAcdpVRzUNKuh4= +github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260830165543-d499371002dc/go.mod h1:vuARjRZopsCqVcZmWzCgw5Pr9QD1FWvwFxijV4bvJJI= 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/jfrog/jfrog-client-go v1.55.1-0.20260813100550-0f2168d02558 h1:/4ayHXxzgyZ9f66EqImCyZr2SKUpygU1P36sDVAlskM= -github.com/jfrog/jfrog-client-go v1.55.1-0.20260813100550-0f2168d02558/go.mod h1:7B7eMRKuMhZ0rOdMItbJVpWjRUe1L//J3Jq+PgjiNxI= +github.com/jfrog/jfrog-client-go v1.55.1-0.20260827094947-e7a90ebc8049 h1:eogwWAzZFir1suYEgZ4ZrYrch8fhWs7ma2dxv06p/z8= +github.com/jfrog/jfrog-client-go v1.55.1-0.20260827094947-e7a90ebc8049/go.mod h1:7B7eMRKuMhZ0rOdMItbJVpWjRUe1L//J3Jq+PgjiNxI= 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= From d2b1963c56789812e0613cae8d8500075ff55ac4 Mon Sep 17 00:00:00 2001 From: Uday Date: Sun, 30 Aug 2026 23:59:19 +0530 Subject: [PATCH 34/40] RTECO-1648 - Rename project.AgentApm to project.Apm, bump jfrog-cli-core pin jfrog-cli-core's ProjectType.AgentApm is renamed to Apm, matching the Npm/Pnpm/Nuget initialism style already used elsewhere in that enum (UV is the one outlier, not the convention to follow). String representation ("apm") is unchanged. - artifactory/commands/setup/setup.go: updated every project.AgentApm reference (and the one doc comment mentioning it) to project.Apm. - go.mod/go.sum: bump jfrog-cli-core to 43e0b312da78, which contains the rename (pushed to origin/RTECO-1648-apm-support-implementation in jfrog-cli-core). --- artifactory/commands/setup/setup.go | 10 +++++----- go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/artifactory/commands/setup/setup.go b/artifactory/commands/setup/setup.go index f11d2d3d..83939328 100644 --- a/artifactory/commands/setup/setup.go +++ b/artifactory/commands/setup/setup.go @@ -107,7 +107,7 @@ var packageManagerConfigs = map[project.ProjectType]packageManagerConfig{ project.Docker: {location: "your Docker credential store", credentialsOnly: true}, project.Podman: {location: "your Podman credential store", credentialsOnly: true}, project.Helm: {location: "your Helm registry credential store", credentialsOnly: true}, - project.AgentApm: {location: "your user-level apm configuration (~/.apm/config.json)"}, + project.Apm: {location: "your user-level apm configuration (~/.apm/config.json)"}, project.Apt: {location: "your apt configuration"}, project.Apk: {location: "your apk configuration"}, // configureRuby writes ~/.gemrc and ~/.bundle/config directly, always under the user's @@ -154,7 +154,7 @@ var packageManagerToRepositoryPackageType = map[project.ProjectType]string{ project.Poetry: repository.Pypi, project.Twine: repository.Pypi, project.UV: repository.Pypi, - project.AgentApm: repository.AgentPackages, + project.Apm: repository.AgentPackages, // Nuget package managers project.Nuget: repository.Nuget, @@ -312,7 +312,7 @@ func (sc *SetupCommand) Run() (err error) { err = sc.configureMaven() case project.UV: err = sc.configureUV() - case project.AgentApm: + case project.Apm: err = sc.configureAgentApm() case project.Cargo: err = sc.configureCargo() @@ -346,13 +346,13 @@ func (sc *SetupCommand) Run() (err error) { const noMatchingRepositoriesErrSubstring = "no repositories were found that match" // promptUserToSelectRepository prompts the user to select a compatible repository - virtual for -// every package manager except AgentApm, which is local-only (agentpackages has no remote/virtual +// every package manager except Apm, which is local-only (agentpackages has no remote/virtual // support in Artifactory at all, so a virtual-repo search can never find a match for it). If none // is found (e.g. for Cargo, which also has no virtual package type in Artifactory), falls back to // asking the user to type an existing repository name directly. func (sc *SetupCommand) promptUserToSelectRepository() (err error) { repoType := utils.Virtual.String() - if sc.packageManager == project.AgentApm { + if sc.packageManager == project.Apm { repoType = utils.Local.String() } return sc.promptUserToSelectRepositoryFiltered(repoType) diff --git a/go.mod b/go.mod index 569c70ed..a459d76d 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/jedib0t/go-pretty/v6 v6.8.3 github.com/jfrog/build-info-go v1.13.1-0.20260828071122-bb92ab7ba69b github.com/jfrog/gofrog v1.7.6 - github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260830165543-d499371002dc + github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260830182749-43e0b312da78 github.com/jfrog/jfrog-cli-evidence v0.9.0 github.com/jfrog/jfrog-client-go v1.55.1-0.20260827094947-e7a90ebc8049 github.com/pkg/errors v0.9.1 diff --git a/go.sum b/go.sum index 9d1a5077..53428d3c 100644 --- a/go.sum +++ b/go.sum @@ -384,8 +384,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.20260830165543-d499371002dc h1:Q5q9lYOO6tsA+Ras8SigM/y5AnBrCKAcdpVRzUNKuh4= -github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260830165543-d499371002dc/go.mod h1:vuARjRZopsCqVcZmWzCgw5Pr9QD1FWvwFxijV4bvJJI= +github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260830182749-43e0b312da78 h1:4FA8vwx0mSFmF3tnqjpDb+pJaazsRCL1o6HOql2ijCs= +github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260830182749-43e0b312da78/go.mod h1:vuARjRZopsCqVcZmWzCgw5Pr9QD1FWvwFxijV4bvJJI= 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.20260827094947-e7a90ebc8049 h1:eogwWAzZFir1suYEgZ4ZrYrch8fhWs7ma2dxv06p/z8= From dd2ec816101b3e65c26b6561a70a6e8ee34a3430 Mon Sep 17 00:00:00 2001 From: Uday Date: Mon, 31 Aug 2026 09:43:30 +0530 Subject: [PATCH 35/40] RTECO-1648 - Remove the dedicated APM update command `jf agent apm update` is no longer a dedicated jf-owned command with its own build-info collection and --module/--project support. `agent/apm/commands/update` is deleted entirely; `update` now falls through to the same generic passthrough path as every other native apm subcommand (lock, outdated, deps why, ...) - still fully functional (auth injection, --server-id), just without build-info collection, matching how apm itself has always behaved for everything except install/publish. - agent/apm/commands/update/: deleted (update.go, help.go). - agent/apm/cli/cli.go: removed the "update" entry from GetSubCommands and the now-unused import; updated doc comments accordingly. - agent/apm/cli/help.go, agent/apm/commands/{install,publish}/help.go: updated descriptions to reflect update as passthrough, not a dedicated subcommand. - agent/apm/common/{apmenv,build_info,subcommand_options}.go and subcommand_options_test.go: trimmed stale "install/update/publish" comment references down to "install/publish" wherever the update command's removal actually changed what's true (left isValidationCheckedSubcommand's "update" case alone - that's about apm's own native validation behavior, unrelated to whether jf has a dedicated wrapper). - agent/cli/cli_test.go: updated TestGetCommands_HasPluginsAndSkillsNamespaces's expected apm subcommand list to {install, publish}. --- agent/apm/cli/cli.go | 15 +-- agent/apm/cli/help.go | 13 +-- agent/apm/commands/install/help.go | 4 +- agent/apm/commands/publish/help.go | 2 +- agent/apm/commands/update/help.go | 32 ------ agent/apm/commands/update/update.go | 111 -------------------- agent/apm/common/apmenv.go | 6 +- agent/apm/common/build_info.go | 4 +- agent/apm/common/subcommand_options.go | 2 +- agent/apm/common/subcommand_options_test.go | 2 +- agent/cli/cli_test.go | 2 +- 11 files changed, 22 insertions(+), 171 deletions(-) delete mode 100644 agent/apm/commands/update/help.go delete mode 100644 agent/apm/commands/update/update.go diff --git a/agent/apm/cli/cli.go b/agent/apm/cli/cli.go index 1aebbeb4..58f2f161 100644 --- a/agent/apm/cli/cli.go +++ b/agent/apm/cli/cli.go @@ -4,7 +4,6 @@ import ( apmcommon "github.com/jfrog/jfrog-cli-artifactory/agent/apm/common" "github.com/jfrog/jfrog-cli-artifactory/agent/apm/commands/install" "github.com/jfrog/jfrog-cli-artifactory/agent/apm/commands/publish" - "github.com/jfrog/jfrog-cli-artifactory/agent/apm/commands/update" agentcommon "github.com/jfrog/jfrog-cli-artifactory/agent/common" "github.com/jfrog/jfrog-cli-artifactory/cliutils/flagkit" "github.com/jfrog/jfrog-cli-core/v2/common/commands" @@ -13,7 +12,8 @@ import ( ) // GetSubCommands returns the leaf commands for `jf agent apm`. Commands not listed here (e.g. -// "lock", which resolves but deploys nothing) fall through to the parent's passthrough handler. +// "update" and "lock", which resolve but don't collect build-info) fall through to the parent's +// passthrough handler. func GetSubCommands() []components.Command { return []components.Command{ { @@ -35,18 +35,11 @@ func GetSubCommands() []components.Command { AIDescription: publish.GetAIDescription(), Action: publish.RunPublish, }, - { - Name: "update", - Flags: flagkit.GetCommandFlags(flagkit.AgentApm), - SkipFlagParsing: true, - Description: "Refresh APM dependencies to their latest matching refs, with build-info collection.", - AIDescription: update.GetAIDescription(), - Action: update.RunUpdate, - }, } } -// RunApmPassthroughDefault handles any `jf agent apm ` not among install/publish/update. +// RunApmPassthroughDefault handles any `jf agent apm ` not among install/publish - +// including "update", which is now a plain native passthrough (no build-info collection). // Passthrough takes exactly one jf-level flag of its own, --server-id (falling back to the // default configured JFrog server when absent); everything else in c.Arguments is forwarded to // apm untouched. diff --git a/agent/apm/cli/help.go b/agent/apm/cli/help.go index 9df12dd4..b1807846 100644 --- a/agent/apm/cli/help.go +++ b/agent/apm/cli/help.go @@ -5,11 +5,11 @@ func GetDescription() string { } func GetAIDescription() string { - return `Run apm against Artifactory-backed registries with credentials injected automatically. Dedicated subcommands install, publish, and update also collect build-info when --build-name and --build-number are set; every other apm command is forwarded with the same authenticated registry access but no build-info collection. + return `Run apm against Artifactory-backed registries with credentials injected automatically. Dedicated subcommands install and publish also collect build-info when --build-name and --build-number are set; every other apm command (including update) is forwarded with the same authenticated registry access but no build-info collection. When to use: -- Running apm install / publish / update with Artifactory auth and optional build-info. -- Running any other apm command (lock, outdated, audit, doctor, view, marketplace, mcp, ...) through jf for authenticated registry access. +- Running apm install / publish with Artifactory auth and optional build-info. +- Running any other apm command (update, lock, outdated, audit, doctor, view, marketplace, mcp, ...) through jf for authenticated registry access. Prerequisites: - apm CLI installed and on PATH. @@ -19,13 +19,14 @@ Prerequisites: Common patterns: $ jf agent apm install --build-name=my-build --build-number=1 $ jf agent apm publish --package my-org/my-package --build-name=my-build --build-number=1 - $ jf agent apm update --yes --build-name=my-build --build-number=1 + $ jf agent apm update --yes $ jf agent apm lock $ jf agent apm outdated Gotchas: -- Build-info is collected only by install, publish, and update, and only when both --build-name and --build-number are provided; publish it afterwards with 'jf rt build-publish'. +- Build-info is collected only by install and publish, and only when both --build-name and --build-number are provided; publish it afterwards with 'jf rt build-publish'. +- 'jf agent apm update' is a plain passthrough - it refreshes apm.yml/apm.lock.yaml like the native CLI always did, but does not collect build-info. - 'jf agent apm --help' shows that command's own help; 'apm --help' lists every native apm command reachable this way. -Related: jf setup apm, jf agent apm install, jf agent apm publish, jf agent apm update, jf rt build-publish` +Related: jf setup apm, jf agent apm install, jf agent apm publish, jf rt build-publish` } diff --git a/agent/apm/commands/install/help.go b/agent/apm/commands/install/help.go index f02e00c0..8fd31acf 100644 --- a/agent/apm/commands/install/help.go +++ b/agent/apm/commands/install/help.go @@ -25,9 +25,9 @@ Common patterns: $ jf agent apm install --dry-run Gotchas: -- A bare tag (#1.0.0) is an exact pin: apm update never moves it. Use a semver range (#^1.0.0, #~1.0.0) if later updates should pick up newer matching versions. +- A bare tag (#1.0.0) is an exact pin: 'jf agent apm update' never moves it. Use a semver range (#^1.0.0, #~1.0.0) if later updates should pick up newer matching versions. - --dry-run previews the install without changing anything and skips build-info (nothing real to record). - Build-info is collected only when both --build-name and --build-number are provided; publish it afterwards with 'jf rt build-publish'. -Related: jf agent apm publish, jf agent apm update, jf setup apm, jf rt build-publish` +Related: jf agent apm publish, jf setup apm, jf rt build-publish` } diff --git a/agent/apm/commands/publish/help.go b/agent/apm/commands/publish/help.go index 2f7941c4..29e9b520 100644 --- a/agent/apm/commands/publish/help.go +++ b/agent/apm/commands/publish/help.go @@ -29,5 +29,5 @@ Gotchas: - --dry-run previews the upload without publishing and skips build-info. - Build-info is collected only when both --build-name and --build-number are provided; optional --module groups packages under one module. Publish afterwards with 'jf rt build-publish'. -Related: jf agent apm install, jf agent apm update, jf setup apm, jf rt build-publish` +Related: jf agent apm install, jf setup apm, jf rt build-publish` } diff --git a/agent/apm/commands/update/help.go b/agent/apm/commands/update/help.go deleted file mode 100644 index dba05d0c..00000000 --- a/agent/apm/commands/update/help.go +++ /dev/null @@ -1,32 +0,0 @@ -package update - -func GetDescription() string { - return "Refresh APM dependencies to their latest matching refs, with build-info collection." -} - -func GetAIDescription() string { - return `Update packages in apm.yml to their latest versions matching declared constraints, refresh apm.lock.yaml, and optionally record a build-info of the resolved dependencies. - -When to use: -- Keeping agent package dependencies current within declared version constraints. -- Re-resolving when new versions are published to the registry. -- Capturing build-info for an update by passing --build-name and --build-number. - -Prerequisites: -- apm CLI installed and on PATH. -- An apm.yml with dependencies declared, and an existing apm.lock.yaml (e.g. from 'jf agent apm install'). -- Read permission on the source Artifactory agentpackages repository. -- Registry configured via 'jf setup apm' or an apm.yml registries: block. - -Common patterns: - $ jf agent apm update --dry-run - $ jf agent apm update --yes --build-name=my-build --build-number=1 - -Gotchas: -- --yes is required to apply an update. Without it, update shows a plan and exits with an error instead of applying anything. -- A dependency pinned to a bare tag (#1.0.0) never has anything to update; only a semver range (#^1.0.0, #~1.0.0) can move to a newer matching version. -- --dry-run previews the plan without applying changes and skips build-info. -- Build-info is collected only when both --build-name and --build-number are provided; publish it afterwards with 'jf rt build-publish'. - -Related: jf agent apm install, jf agent apm publish, jf setup apm, jf rt build-publish` -} diff --git a/agent/apm/commands/update/update.go b/agent/apm/commands/update/update.go deleted file mode 100644 index 8bb57d4f..00000000 --- a/agent/apm/commands/update/update.go +++ /dev/null @@ -1,111 +0,0 @@ -package update - -import ( - "fmt" - "os" - "path/filepath" - - apmcommon "github.com/jfrog/jfrog-cli-artifactory/agent/apm/common" - agentcommon "github.com/jfrog/jfrog-cli-artifactory/agent/common" - buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build" - "github.com/jfrog/jfrog-cli-core/v2/common/commands" - "github.com/jfrog/jfrog-cli-core/v2/plugins/components" - "github.com/jfrog/jfrog-cli-core/v2/utils/config" - "github.com/jfrog/jfrog-client-go/utils/log" -) - -// apmSubcommand is the apm subcommand this package always drives. -const apmSubcommand = "update" - -// ApmUpdateCommand runs `apm update` with JFrog Artifactory authentication and collects -// build-info from the resulting apm.lock.yaml, reusing install's exact reader. Never accepts -// --repo; a registry must already be declared via jf setup apm or apm.yml's registries: -// block. -type ApmUpdateCommand struct { - args []string - serverDetails *config.ServerDetails - buildConfiguration *buildUtils.BuildConfiguration -} - -func NewApmUpdateCommand() *ApmUpdateCommand { - return &ApmUpdateCommand{} -} - -func (c *ApmUpdateCommand) SetArgs(args []string) *ApmUpdateCommand { - c.args = args - return c -} - -func (c *ApmUpdateCommand) SetServerDetails(serverDetails *config.ServerDetails) *ApmUpdateCommand { - c.serverDetails = serverDetails - return c -} - -func (c *ApmUpdateCommand) SetBuildConfiguration(buildConfiguration *buildUtils.BuildConfiguration) *ApmUpdateCommand { - c.buildConfiguration = buildConfiguration - return c -} - -func (c *ApmUpdateCommand) CommandName() string { - return apmcommon.CommandNamePrefix + apmSubcommand -} - -func (c *ApmUpdateCommand) ServerDetails() (*config.ServerDetails, error) { - return c.serverDetails, nil -} - -// Run wraps "apm update", which re-resolves dependencies to their latest matching refs and, on -// acceptance (interactive confirmation, or --yes for CI), rewrites both apm.yml and -// apm.lock.yaml. Build-info collection reuses install's exact reader — same resolved-dependency -// shape, whether the lockfile just changed or update reported nothing new. -func (c *ApmUpdateCommand) Run() error { - log.Info("Running apm update...") - - if err := apmcommon.RunApmSubcommandWithAuth(apmSubcommand, c.args, c.serverDetails); err != nil { - return fmt.Errorf("run apm update: %w", err) - } - - // Only mention / collect build-info when the user asked for it (--build-name/--build-number or env). - collectBuildInfo, err := apmcommon.ShouldCollectBuildInfo(c.buildConfiguration) - if err != nil { - log.Warn("apm update completed, but could not determine build-info collection state:", err.Error()) - } else if collectBuildInfo { - if apmcommon.IsDryRunArg(c.args) { - log.Info("apm update: --dry-run - nothing was updated, skipping build-info recording.") - } else if workingDir, wdErr := os.Getwd(); wdErr != nil { - log.Warn("apm update completed, but could not determine working directory for build info:", wdErr.Error()) - } else { - lockfilePath := filepath.Join(workingDir, apmcommon.ApmLockfileName) - manifestPath := filepath.Join(workingDir, apmcommon.ApmManifestName) - if biErr := apmcommon.CollectAndSaveInstallBuildInfo(lockfilePath, manifestPath, c.serverDetails, c.buildConfiguration); biErr != nil { - log.Warn("apm update completed, but build info collection failed:", biErr.Error()) - } - } - } - - log.Info("apm update finished successfully.") - return nil -} - -// RunUpdate is the CLI action handler for `jf agent apm update`. -func RunUpdate(c *components.Context) error { - if apmcommon.IsHelpRequest(c.Arguments) { - return apmcommon.RunApmCommand(nil, apmSubcommand, []string{apmcommon.HelpFlag}) - } - - opts, err := apmcommon.ExtractApmSubcommandOptions(c.Arguments) - if err != nil { - return err - } - serverDetails, err := agentcommon.GetServerDetailsByID(opts.ServerID) - if err != nil { - return err - } - - cmd := NewApmUpdateCommand(). - SetArgs(opts.ApmNativeArgs). - SetServerDetails(serverDetails). - SetBuildConfiguration(opts.BuildConfig) - - return commands.ExecWithPackageManager(cmd, apmcommon.PackageManagerID) -} diff --git a/agent/apm/common/apmenv.go b/agent/apm/common/apmenv.go index c6f7c328..74d7325b 100644 --- a/agent/apm/common/apmenv.go +++ b/agent/apm/common/apmenv.go @@ -683,9 +683,9 @@ func IsDryRunArg(args []string) bool { } // PassthroughCommand runs an arbitrary apm subcommand with auth environment injected - no -// build-info collection, unlike install/update/publish. It satisfies jfrog-cli-core's Command -// interface (CommandName/ServerDetails/Run) on its own, so `jf agent apm ` for any -// subcommand not covered by install/update/publish needs no command-specific type of its own. +// build-info collection, unlike install/publish. It satisfies jfrog-cli-core's Command interface +// (CommandName/ServerDetails/Run) on its own, so `jf agent apm ` for any subcommand not +// covered by install/publish (including update) needs no command-specific type of its own. type PassthroughCommand struct { Subcmd string Args []string diff --git a/agent/apm/common/build_info.go b/agent/apm/common/build_info.go index 5ed5ffcd..b0424129 100644 --- a/agent/apm/common/build_info.go +++ b/agent/apm/common/build_info.go @@ -33,7 +33,7 @@ const apmPackageFileExtension = "zip" const errBuildInfoNotEnabled = "build info collection is not enabled" // ShouldCollectBuildInfo reports whether the user enabled build-info collection -// (--build-name/--build-number or JFROG_CLI_BUILD_*). Used by install/update/publish to avoid +// (--build-name/--build-number or JFROG_CLI_BUILD_*). Used by install/publish to avoid // "skipping build-info" noise when collection was never requested. func ShouldCollectBuildInfo(buildConfig *buildUtils.BuildConfiguration) (bool, error) { if buildConfig == nil { @@ -202,7 +202,7 @@ func SavePublishBuildInfo(owner, moduleName, packageName, version string, checks // Same convention as npm's BuildInfoModuleId(): "" unless BOTH name and version are set, // never a partial "name:" or ":version". An empty moduleID isn't special-cased further - // it flows to build-info-go's generic partial-merge fallback (module.Id = build name), same - // as install/update's derivedModuleID and same as npm's own AddNpmModule. + // as install's derivedModuleID and same as npm's own AddNpmModule. moduleID := buildConfig.GetModule() if moduleID == "" && moduleName != "" && version != "" { moduleID = moduleName + ":" + version diff --git a/agent/apm/common/subcommand_options.go b/agent/apm/common/subcommand_options.go index aec16255..d784befb 100644 --- a/agent/apm/common/subcommand_options.go +++ b/agent/apm/common/subcommand_options.go @@ -18,7 +18,7 @@ type ApmSubcommandOptions struct { ServerID string } -// ExtractApmSubcommandOptions extracts install/publish/update's own flags (--build-name, +// ExtractApmSubcommandOptions extracts install/publish's own flags (--build-name, // --build-number, --module, --project, --server-id) from args and resolves the build-info // ones into a BuildConfiguration. Needed because those commands set SkipFlagParsing (so // apm-native flags reach apm unrejected), which means urfave/cli parses none of jf's own diff --git a/agent/apm/common/subcommand_options_test.go b/agent/apm/common/subcommand_options_test.go index e126af0f..5f548a95 100644 --- a/agent/apm/common/subcommand_options_test.go +++ b/agent/apm/common/subcommand_options_test.go @@ -11,7 +11,7 @@ import ( func TestExtractApmSubcommandOptions_PreservesApmNativeFlags(t *testing.T) { testutil.WithJfrogHome(t) - // install/publish/update don't declare --repo or direct-credential flags as jf's own - a + // install/publish don't declare --repo or direct-credential flags as jf's own - a // registry must already be declared/configured, so those flow through untouched like any // other apm-native flag. --server-id IS jf's own flag (selects which configured JFrog // server to use) and is extracted here, not forwarded. diff --git a/agent/cli/cli_test.go b/agent/cli/cli_test.go index 175e1699..f7d64861 100644 --- a/agent/cli/cli_test.go +++ b/agent/cli/cli_test.go @@ -44,7 +44,7 @@ func TestGetCommands_HasPluginsAndSkillsNamespaces(t *testing.T) { assert.NotNil(t, sub.Action, "apm subcommand %q must have an Action", sub.Name) apmNames = append(apmNames, sub.Name) } - assert.ElementsMatch(t, []string{"install", "publish", "update"}, apmNames) + assert.ElementsMatch(t, []string{"install", "publish"}, apmNames) } func TestGetCommands_PluginsPublishDescription(t *testing.T) { From 451857f2bb8ff52fb39c8b99a1b323370f7849f8 Mon Sep 17 00:00:00 2001 From: Uday Date: Mon, 31 Aug 2026 09:51:44 +0530 Subject: [PATCH 36/40] RTECO-1648 - Fix coderabbit review findings: malformed artifact path, gofmt - agent/apm/common/build_info.go: reinstated a guard in CollectAndSavePublishBuildInfo - skip artifact build-info recording (with a warning) when apm.yml has no version, instead of constructing a malformed "-.zip" artifact path and tagging properties on a path that can't exist. The npm-parity argument from the earlier change doesn't hold here: npm derives its deploy path from an already-validated package.json, while apm's artifact path is ours to construct and has no such guarantee. Module-id computation is untouched - an empty name/version there still safely falls back to the build name via build-info-go's generic mechanism, same as npm's own equivalent case. - artifactory/commands/setup/setup.go: fixed the two gofmt alignment issues in the project.Apm map entries (introduced by the earlier AgentApm -> Apm rename shortening the key) and a stray double blank line left over from an earlier merge conflict resolution. Scoped to exactly these lines, not a blanket gofmt pass over the file. --- agent/apm/common/build_info.go | 16 ++++++++++++---- artifactory/commands/setup/setup.go | 15 +++++++-------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/agent/apm/common/build_info.go b/agent/apm/common/build_info.go index b0424129..162b569c 100644 --- a/agent/apm/common/build_info.go +++ b/agent/apm/common/build_info.go @@ -306,13 +306,21 @@ func CollectAndSavePublishBuildInfo(manifestPath, owner, packageName, repoName, if err != nil { return err } - // No skip-on-missing-name/version check here, matching npm's own publish path: npm never - // special-cases an incomplete package.json before computing its module id/deploy path - // either - an empty name/version just flows through to whatever moduleID/artifact path - // that produces (see SavePublishBuildInfo's own moduleID fallback below). + // No skip-on-missing-name check here, matching npm's own publish path for module id + // purposes: an empty name just flows through to whatever moduleID that produces (see + // SavePublishBuildInfo's own moduleID fallback below). Version is different, though - unlike + // npm (which derives its deploy path from a package.json already validated to have one), + // apm's artifact path is only ever ours to construct, so a missing version would produce a + // malformed "-.zip" that can't exist in the repository; skip artifact recording + // entirely rather than record a path guaranteed to be wrong. if packageName == "" { packageName = manifest.Name } + if manifest.Version == "" { + log.Warn("apm publish: apm.yml has no version; skipping artifact build-info recording " + + "because the published artifact path cannot be derived.") + return nil + } // Best-effort confirmation that the assumed agentpackages path actually holds the file // build-info is about to record it under. Never blocks the publish either way. diff --git a/artifactory/commands/setup/setup.go b/artifactory/commands/setup/setup.go index 83939328..c77490db 100644 --- a/artifactory/commands/setup/setup.go +++ b/artifactory/commands/setup/setup.go @@ -107,7 +107,7 @@ var packageManagerConfigs = map[project.ProjectType]packageManagerConfig{ project.Docker: {location: "your Docker credential store", credentialsOnly: true}, project.Podman: {location: "your Podman credential store", credentialsOnly: true}, project.Helm: {location: "your Helm registry credential store", credentialsOnly: true}, - project.Apm: {location: "your user-level apm configuration (~/.apm/config.json)"}, + project.Apm: {location: "your user-level apm configuration (~/.apm/config.json)"}, project.Apt: {location: "your apt configuration"}, project.Apk: {location: "your apk configuration"}, // configureRuby writes ~/.gemrc and ~/.bundle/config directly, always under the user's @@ -149,12 +149,12 @@ var packageManagerToRepositoryPackageType = map[project.ProjectType]string{ project.Yarn: repository.Npm, // Python (pypi) package managers - project.Pip: repository.Pypi, - project.Pipenv: repository.Pypi, - project.Poetry: repository.Pypi, - project.Twine: repository.Pypi, - project.UV: repository.Pypi, - project.Apm: repository.AgentPackages, + project.Pip: repository.Pypi, + project.Pipenv: repository.Pypi, + project.Poetry: repository.Pypi, + project.Twine: repository.Pypi, + project.UV: repository.Pypi, + project.Apm: repository.AgentPackages, // Nuget package managers project.Nuget: repository.Nuget, @@ -402,7 +402,6 @@ func (sc *SetupCommand) promptUserToSelectRepositoryFiltered(repoType string) (e return nil } - // promptUserToSelectCargoRepositories selects the repositories Cargo needs when --repo is not // given. Cargo has two orthogonal roles that map to two different Artifactory repo types: // From 22d3a9dd3964dee335778ef02f7f0f591fc5155d Mon Sep 17 00:00:00 2001 From: Uday Date: Mon, 31 Aug 2026 10:24:22 +0530 Subject: [PATCH 37/40] RTECO-1648 - Remove all remaining traces of the apm update command Follow-up to dd2ec816 (removed the dedicated command itself): cleaned every remaining doc/comment mention of update as a supported apm operation in help.go/cli.go, and removed "update" from isValidationCheckedSubcommand's case list - jf agent apm update is now treated identically to every other passthrough subcommand (lock, outdated, ...), with no special-cased validation-failure detection carried over from when it was dedicated. --- agent/apm/cli/cli.go | 5 ++--- agent/apm/cli/help.go | 6 ++---- agent/apm/commands/install/help.go | 2 +- agent/apm/common/apmenv.go | 8 ++++---- 4 files changed, 9 insertions(+), 12 deletions(-) diff --git a/agent/apm/cli/cli.go b/agent/apm/cli/cli.go index 58f2f161..1b9a0b71 100644 --- a/agent/apm/cli/cli.go +++ b/agent/apm/cli/cli.go @@ -12,7 +12,7 @@ import ( ) // GetSubCommands returns the leaf commands for `jf agent apm`. Commands not listed here (e.g. -// "update" and "lock", which resolve but don't collect build-info) fall through to the parent's +// "lock", which resolves but doesn't collect build-info) fall through to the parent's // passthrough handler. func GetSubCommands() []components.Command { return []components.Command{ @@ -38,8 +38,7 @@ func GetSubCommands() []components.Command { } } -// RunApmPassthroughDefault handles any `jf agent apm ` not among install/publish - -// including "update", which is now a plain native passthrough (no build-info collection). +// RunApmPassthroughDefault handles any `jf agent apm ` not among install/publish. // Passthrough takes exactly one jf-level flag of its own, --server-id (falling back to the // default configured JFrog server when absent); everything else in c.Arguments is forwarded to // apm untouched. diff --git a/agent/apm/cli/help.go b/agent/apm/cli/help.go index b1807846..889b3b74 100644 --- a/agent/apm/cli/help.go +++ b/agent/apm/cli/help.go @@ -5,11 +5,11 @@ func GetDescription() string { } func GetAIDescription() string { - return `Run apm against Artifactory-backed registries with credentials injected automatically. Dedicated subcommands install and publish also collect build-info when --build-name and --build-number are set; every other apm command (including update) is forwarded with the same authenticated registry access but no build-info collection. + return `Run apm against Artifactory-backed registries with credentials injected automatically. Dedicated subcommands install and publish also collect build-info when --build-name and --build-number are set; every other apm command is forwarded with the same authenticated registry access but no build-info collection. When to use: - Running apm install / publish with Artifactory auth and optional build-info. -- Running any other apm command (update, lock, outdated, audit, doctor, view, marketplace, mcp, ...) through jf for authenticated registry access. +- Running any other apm command (lock, outdated, audit, doctor, view, marketplace, mcp, ...) through jf for authenticated registry access. Prerequisites: - apm CLI installed and on PATH. @@ -19,13 +19,11 @@ Prerequisites: Common patterns: $ jf agent apm install --build-name=my-build --build-number=1 $ jf agent apm publish --package my-org/my-package --build-name=my-build --build-number=1 - $ jf agent apm update --yes $ jf agent apm lock $ jf agent apm outdated Gotchas: - Build-info is collected only by install and publish, and only when both --build-name and --build-number are provided; publish it afterwards with 'jf rt build-publish'. -- 'jf agent apm update' is a plain passthrough - it refreshes apm.yml/apm.lock.yaml like the native CLI always did, but does not collect build-info. - 'jf agent apm --help' shows that command's own help; 'apm --help' lists every native apm command reachable this way. Related: jf setup apm, jf agent apm install, jf agent apm publish, jf rt build-publish` diff --git a/agent/apm/commands/install/help.go b/agent/apm/commands/install/help.go index 8fd31acf..2dbcefad 100644 --- a/agent/apm/commands/install/help.go +++ b/agent/apm/commands/install/help.go @@ -25,7 +25,7 @@ Common patterns: $ jf agent apm install --dry-run Gotchas: -- A bare tag (#1.0.0) is an exact pin: 'jf agent apm update' never moves it. Use a semver range (#^1.0.0, #~1.0.0) if later updates should pick up newer matching versions. +- A bare tag (#1.0.0) is an exact pin and never moves on re-resolution. Use a semver range (#^1.0.0, #~1.0.0) if later runs should pick up newer matching versions. - --dry-run previews the install without changing anything and skips build-info (nothing real to record). - Build-info is collected only when both --build-name and --build-number are provided; publish it afterwards with 'jf rt build-publish'. diff --git a/agent/apm/common/apmenv.go b/agent/apm/common/apmenv.go index 74d7325b..e1486fca 100644 --- a/agent/apm/common/apmenv.go +++ b/agent/apm/common/apmenv.go @@ -498,7 +498,7 @@ func (t *tailBuffer) String() string { // perform dependency validation and can exit 0 while still having failed it. func isValidationCheckedSubcommand(subcmd string) bool { switch subcmd { - case "install", "update", "publish": + case "install", "publish": return true default: return false @@ -676,8 +676,8 @@ func IsHelpRequest(args []string) bool { return false } -// IsDryRunArg returns true if the args include --dry-run. install, update, and publish all -// support it, and none of them change anything on disk when it's set. +// IsDryRunArg returns true if the args include --dry-run. install and publish both support it, +// and neither changes anything on disk when it's set. func IsDryRunArg(args []string) bool { return slices.Contains(args, "--dry-run") } @@ -685,7 +685,7 @@ func IsDryRunArg(args []string) bool { // PassthroughCommand runs an arbitrary apm subcommand with auth environment injected - no // build-info collection, unlike install/publish. It satisfies jfrog-cli-core's Command interface // (CommandName/ServerDetails/Run) on its own, so `jf agent apm ` for any subcommand not -// covered by install/publish (including update) needs no command-specific type of its own. +// covered by install/publish needs no command-specific type of its own. type PassthroughCommand struct { Subcmd string Args []string From a5c73d50d4aaef303d96b518d1dc6fe2a8c7d321 Mon Sep 17 00:00:00 2001 From: Uday Date: Mon, 31 Aug 2026 11:27:33 +0530 Subject: [PATCH 38/40] RTECO-1648 - Finite APM registry token lifetime, surface generation failures Fixes the two remaining parts of coderabbit's ServiceManager-client review finding (agent/apm/common/apmenv.go) that weren't addressed by the earlier client-usage fix: - Token lifetime: both generateAccessTokenViaAccessAPI (ExpiresIn) and generateAccessTokenLegacy (expires_in form field) now request apmAccessTokenExpirySeconds (3600s) instead of a non-expiring token. Matches cliutils/flagkit.ArtifactoryTokenExpiry, the default `jf access token-create` already uses - the only other place in this repo that mints an access token itself. Checked go/nix/ruby's own `jf setup` auth: none of them mint a token at all, they just re-embed serverDetails' existing AccessToken/User+Password as-is, so there's no other precedent to match besides token-create's. - Failure surfacing: BuildRegistryEntry, generateAccessToken, generateAccessTokenViaAccessAPI, and generateAccessTokenLegacy now all return (..., error) instead of silently returning "" on failure. ConfigureApmRegistryPersistent (`jf setup apm`) now fails loudly if User+Password are set but token generation fails, instead of reporting success while persisting a registry entry apm can never authenticate against. The "no credentials configured at all" case is unchanged and still returns URL-only with no error - that's a legitimate anonymous/public registry, not a failure. - apmenv_test.go: updated TestBuildRegistryEntry and TestGenerateAccessToken_NoAuth for the new (..., error) signatures. --- agent/apm/common/apmenv.go | 102 +++++++++++++++++--------------- agent/apm/common/apmenv_test.go | 12 ++-- 2 files changed, 60 insertions(+), 54 deletions(-) diff --git a/agent/apm/common/apmenv.go b/agent/apm/common/apmenv.go index e1486fca..0ba1435e 100644 --- a/agent/apm/common/apmenv.go +++ b/agent/apm/common/apmenv.go @@ -11,6 +11,7 @@ import ( "os/exec" "path/filepath" "slices" + "strconv" "strings" rtUtils "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils" @@ -31,6 +32,13 @@ const ( // apmConfigDirName and apmConfigFileName make up ~/.apm/config.json. apmConfigDirName = ".apm" apmConfigFileName = "config.json" + + // apmAccessTokenExpirySeconds bounds the lifetime of tokens this package mints for APM + // registry auth. Matches cliutils/flagkit.ArtifactoryTokenExpiry, the same default `jf + // access token-create` already uses - the only other place in this repo that mints an + // access token itself. A non-expiring (expires_in=0) token left no way to bound how long a + // credential written into ~/.apm/config.json stays valid. + apmAccessTokenExpirySeconds = 3600 ) // AgentPackagesBaseURL returns the Artifactory agentpackages base URL for a repo. @@ -43,129 +51,126 @@ func AgentPackagesBaseURL(serverDetails *config.ServerDetails, repoName string) // Strategy: Check for AccessToken first, else generate token from User+Password. // Never embed plaintext credentials in URL - always use token field. // AccessToken set → use it. -// User+Password set → generate token via Artifactory API. -// Neither → return URL only (caller must handle auth separately). -func BuildRegistryEntry(serverDetails *config.ServerDetails, repoName string) (registryURL, token string) { +// User+Password set → generate token via Artifactory API; a generation failure is returned as +// an error rather than silently falling back to an unauthenticated URL-only entry. +// Neither → return URL only (caller must handle auth separately) - a legitimate +// anonymous/public-registry case, not a failure. +func BuildRegistryEntry(serverDetails *config.ServerDetails, repoName string) (registryURL, token string, err error) { base := AgentPackagesBaseURL(serverDetails, repoName) // Priority 1: Use existing access token if serverDetails.AccessToken != "" { - return base, serverDetails.AccessToken + return base, serverDetails.AccessToken, nil } // Priority 2: Generate token from username/password (secure - no plaintext in config) if serverDetails.User != "" && serverDetails.Password != "" { - generatedToken := generateAccessToken(serverDetails) - if generatedToken != "" { - return base, generatedToken + generatedToken, genErr := generateAccessToken(serverDetails) + if genErr != nil { + return "", "", fmt.Errorf("apm: failed to generate access token for registry %q: %w", repoName, genErr) } - // No fallback auth mechanism exists here - token generation failing means this - // registry entry is returned without credentials (see "no token available" below). - log.Debug(fmt.Sprintf("apm: failed to generate access token for %s; registry %q will be configured without credentials", base, repoName)) + return base, generatedToken, nil } - // No token available - return URL only - return base, "" + // No credentials configured at all - return URL only. Not an error: a legitimate + // anonymous/public-registry case. + return base, "", nil } // generateAccessToken creates an access token from username/password. It tries // jfrog-client-go's access TokenService first (the same ServiceManager-based path used // elsewhere in this repo, e.g. jfrog-cli-core's AccessTokenCreateCommand), falling back to a // direct call against Artifactory's older, deprecated token endpoint only if that fails - some -// Artifactory instances still don't expose (or allow) the modern Access service. Returns empty -// string if both paths fail. -func generateAccessToken(serverDetails *config.ServerDetails) string { +// Artifactory instances still don't expose (or allow) the modern Access service. Returns an +// error, combining both attempts' failures, only if both paths fail. +func generateAccessToken(serverDetails *config.ServerDetails) (string, error) { if serverDetails.User == "" || serverDetails.Password == "" { - return "" + return "", fmt.Errorf("username and password are required to generate an access token") } - if token := generateAccessTokenViaAccessAPI(serverDetails); token != "" { - return token + token, accessAPIErr := generateAccessTokenViaAccessAPI(serverDetails) + if accessAPIErr == nil { + return token, nil } + log.Debug(fmt.Sprintf("apm: modern access-token API failed (%s); falling back to the deprecated Artifactory token endpoint", accessAPIErr.Error())) - log.Debug("apm: modern access-token API failed; falling back to the deprecated Artifactory token endpoint") - return generateAccessTokenLegacy(serverDetails) + token, legacyErr := generateAccessTokenLegacy(serverDetails) + if legacyErr == nil { + return token, nil + } + return "", fmt.Errorf("access API: %w; legacy endpoint: %w", accessAPIErr, legacyErr) } // generateAccessTokenViaAccessAPI is the primary token-generation path, described above. -func generateAccessTokenViaAccessAPI(serverDetails *config.ServerDetails) string { +func generateAccessTokenViaAccessAPI(serverDetails *config.ServerDetails) (string, error) { accessManager, err := rtUtils.CreateAccessServiceManager(serverDetails, false) if err != nil { - log.Debug("Failed to create access service manager for token generation:", err.Error()) - return "" + return "", fmt.Errorf("failed to create access service manager for token generation: %w", err) } - nonExpiring := uint(0) + expiresIn := uint(apmAccessTokenExpirySeconds) tokenParams := services.CreateTokenParams{Username: serverDetails.User} tokenParams.Scope = "applied-permissions/user" - tokenParams.ExpiresIn = &nonExpiring + tokenParams.ExpiresIn = &expiresIn tokenResponse, err := accessManager.CreateAccessToken(tokenParams) if err != nil { - log.Debug("Failed to generate access token via the access API:", err.Error()) - return "" + return "", fmt.Errorf("failed to generate access token via the access API: %w", err) } if tokenResponse.AccessToken == "" { - log.Debug("Access API token generation returned no access_token") - return "" + return "", fmt.Errorf("access API token generation returned no access_token") } log.Debug("Access token generated for APM registry via the access API") - return tokenResponse.AccessToken + return tokenResponse.AccessToken, nil } // generateAccessTokenLegacy calls Artifactory's deprecated token generation API (POST // /artifactory/api/security/token, form-urlencoded - the JSON, plural "/tokens" endpoint // returns 405) to create an access token from username/password. Fallback only - see -// generateAccessToken. Returns empty string if generation fails. -func generateAccessTokenLegacy(serverDetails *config.ServerDetails) string { +// generateAccessToken. Returns an error if generation fails. +func generateAccessTokenLegacy(serverDetails *config.ServerDetails) (string, error) { tokenURL := strings.TrimSuffix(serverDetails.ArtifactoryUrl, "/") + "/api/security/token" form := url.Values{} form.Set("username", serverDetails.User) form.Set("scope", "applied-permissions/user") - form.Set("expires_in", "0") + form.Set("expires_in", strconv.Itoa(apmAccessTokenExpirySeconds)) req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(form.Encode())) if err != nil { - log.Debug("Failed to build legacy access token request:", err.Error()) - return "" + return "", fmt.Errorf("failed to build legacy access token request: %w", err) } req.SetBasicAuth(serverDetails.User, serverDetails.Password) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := http.DefaultClient.Do(req) if err != nil { - log.Debug("Failed to generate legacy access token:", err.Error()) - return "" + return "", fmt.Errorf("failed to generate legacy access token: %w", err) } defer func() { _ = resp.Body.Close() }() // read-side close on an already fully-read response body, err := io.ReadAll(resp.Body) if err != nil { - log.Debug("Failed to read legacy access token response:", err.Error()) - return "" + return "", fmt.Errorf("failed to read legacy access token response: %w", err) } if resp.StatusCode != http.StatusOK { - log.Debug(fmt.Sprintf("Legacy access token generation returned status %d: %s", resp.StatusCode, string(body))) - return "" + return "", fmt.Errorf("legacy access token generation returned status %d: %s", resp.StatusCode, string(body)) } // Response field is "access_token", not "token". var response map[string]any if err := json.Unmarshal(body, &response); err != nil { - log.Debug("Failed to parse legacy token response:", err.Error()) - return "" + return "", fmt.Errorf("failed to parse legacy token response: %w", err) } token, ok := response["access_token"].(string) if !ok || token == "" { - log.Debug("No access_token in legacy API response") - return "" + return "", fmt.Errorf("no access_token in legacy API response") } log.Debug("Access token generated for APM registry via the legacy endpoint") - return token + return token, nil } // apmConfigJSON models ~/.apm/config.json. Extra preserves top-level keys this code doesn't @@ -518,7 +523,10 @@ func ConfigureApmRegistryPersistent(serverDetails *config.ServerDetails, repoNam return fmt.Errorf("enable experimental registries: %w", err) } - registryURL, token := BuildRegistryEntry(serverDetails, repoName) + registryURL, token, err := BuildRegistryEntry(serverDetails, repoName) + if err != nil { + return fmt.Errorf("build registry entry: %w", err) + } if err := RunApmCommand(nil, "config", []string{"set", fmt.Sprintf("registry.%s.url", repoName), registryURL}); err != nil { return fmt.Errorf("set registry url: %w", err) } diff --git a/agent/apm/common/apmenv_test.go b/agent/apm/common/apmenv_test.go index 29aa01e2..ba831910 100644 --- a/agent/apm/common/apmenv_test.go +++ b/agent/apm/common/apmenv_test.go @@ -230,7 +230,8 @@ func TestBuildRegistryEntry(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - url, token := BuildRegistryEntry(tt.serverDetails, tt.repoName) + url, token, err := BuildRegistryEntry(tt.serverDetails, tt.repoName) + require.NoError(t, err) assert.Equal(t, tt.expectURL, url) assert.Equal(t, tt.expectToken, token) }) @@ -241,33 +242,30 @@ func TestGenerateAccessToken_NoAuth(t *testing.T) { tests := []struct { name string serverDetails *config.ServerDetails - expectToken string }{ { name: "empty user and password", serverDetails: &config.ServerDetails{}, - expectToken: "", }, { name: "user without password", serverDetails: &config.ServerDetails{ User: "admin", }, - expectToken: "", }, { name: "password without user", serverDetails: &config.ServerDetails{ Password: "secret", }, - expectToken: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - token := generateAccessToken(tt.serverDetails) - assert.Equal(t, tt.expectToken, token, "generateAccessToken should return empty for incomplete credentials") + token, err := generateAccessToken(tt.serverDetails) + assert.Error(t, err, "generateAccessToken should error for incomplete credentials") + assert.Empty(t, token, "generateAccessToken should return empty token for incomplete credentials") }) } } From d84beaca15757e048ea428973c67c7ffe5a8cb0c Mon Sep 17 00:00:00 2001 From: Uday Date: Tue, 1 Sep 2026 22:59:31 +0530 Subject: [PATCH 39/40] RTECO-1648: Replace per-dep HEAD requests with batched AQL for checksum resolution - Remove Tier 3 (lockfile SHA256-only fallback) - Implement Tier 2: Batched AQL queries (30 deps per batch, 15 workers in parallel) - Extract repository from resolved_url and group dependencies by repo - Follow pnpm/Cargo pattern: build cache + batched AQL (complete checksums) - For 70 uncached deps: reduce from 70 HEAD requests to ~3 AQL queries - Parse AQL response with SHA1, MD5, SHA256 (all three, not SHA256-only) --- agent/apm/commands/install/install.go | 2 +- agent/apm/commands/publish/publish.go | 2 +- agent/apm/common/checksums.go | 242 ++++++++++++++++++++------ agent/apm/common/checksums_test.go | 54 ++---- 4 files changed, 210 insertions(+), 90 deletions(-) diff --git a/agent/apm/commands/install/install.go b/agent/apm/commands/install/install.go index 221d6541..ea257c83 100644 --- a/agent/apm/commands/install/install.go +++ b/agent/apm/commands/install/install.go @@ -81,7 +81,7 @@ func (c *ApmInstallCommand) Run() error { lockfilePath := filepath.Join(lockfileDir, apmcommon.ApmLockfileName) manifestPath := filepath.Join(workingDir, apmcommon.ApmManifestName) if biErr := apmcommon.CollectAndSaveInstallBuildInfo(lockfilePath, manifestPath, c.serverDetails, c.buildConfiguration); biErr != nil { - log.Warn("apm install completed, but build info collection failed:", biErr.Error()) + return biErr } } } diff --git a/agent/apm/commands/publish/publish.go b/agent/apm/commands/publish/publish.go index 32c130dd..b735704e 100644 --- a/agent/apm/commands/publish/publish.go +++ b/agent/apm/commands/publish/publish.go @@ -95,7 +95,7 @@ func (c *ApmPublishCommand) Run() error { artifactoryRepoKey := apmcommon.ResolveRepoNameFromRegistry(c.serverDetails, manifestPath, c.args) zipPath := zipPathFromArgs(c.args) if biErr := apmcommon.CollectAndSavePublishBuildInfo(manifestPath, owner, packageName, artifactoryRepoKey, zipPath, c.serverDetails, c.buildConfiguration); biErr != nil { - log.Warn("apm publish completed, but build info recording failed:", biErr.Error()) + return biErr } } } diff --git a/agent/apm/common/checksums.go b/agent/apm/common/checksums.go index f855f3f5..18f1dc30 100644 --- a/agent/apm/common/checksums.go +++ b/agent/apm/common/checksums.go @@ -1,7 +1,10 @@ package apmcommon import ( + "encoding/json" "fmt" + "io" + "strings" "sync" "github.com/jfrog/build-info-go/entities" @@ -10,17 +13,27 @@ import ( buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build" "github.com/jfrog/jfrog-cli-core/v2/utils/config" "github.com/jfrog/jfrog-client-go/artifactory" + servicesUtils "github.com/jfrog/jfrog-client-go/artifactory/services/utils" "github.com/jfrog/jfrog-client-go/utils/log" ) -const headWorkerCount = 15 +const aqlBatchSize = 30 +const aqlWorkerCount = 15 + +// aqlResult is the subset of the AQL response we consume. +type aqlResult struct { + Results []struct { + Name string `json:"name"` + ActualSha1 string `json:"actual_sha1"` + Sha256 string `json:"sha256"` + ActualMd5 string `json:"actual_md5"` + } `json:"results"` +} // ResolveChecksums resolves full checksums for registry dependencies. // Strategy: // 1. Previous build cache (SHA-1, MD5, SHA-256 from last build). -// 2. HTTP HEAD against each dependency's resolved_url, reading Artifactory's X-Checksum-* -// response headers directly. -// 3. Fallback: use lockfile SHA-256 only when the HEAD request finds no match. +// 2. Batched AQL queries to Artifactory (up to 30 deps per query, 15 workers in parallel). func ResolveChecksums(deps []ResolvedDep, serverDetails *config.ServerDetails, buildConfig *buildUtils.BuildConfiguration) (map[string]entities.Checksum, error) { servicesManager, err := coreArtUtils.CreateServiceManager(serverDetails, -1, 0, false) if err != nil { @@ -45,15 +58,21 @@ func ResolveChecksums(deps []ResolvedDep, serverDetails *config.ServerDetails, b return checksumMap, nil } - headResults := resolveChecksumsByHead(uncached, servicesManager) - for id, checksum := range applyHeadResultsOrLockfileFallback(uncached, headResults) { - checksumMap[id] = checksum + aqlResults := batchedAQLFetch(uncached, servicesManager) + aqlResolved := 0 + for k, v := range aqlResults { + checksumMap[k] = v + if !v.IsEmpty() { + aqlResolved++ + } } + log.Debug(fmt.Sprintf("AQL resolved %d/%d uncached dependencies.", aqlResolved, len(uncached))) + return checksumMap, nil } // selectCachedAndUncached is the tier-1-vs-tier-2 decision: which dependencies already have a -// checksum from the previous build's cache, and which still need a HEAD lookup. +// checksum from the previous build's cache, and which still need an AQL lookup. func selectCachedAndUncached(deps []ResolvedDep, cachedChecksums map[string]entities.Checksum) (cached map[string]entities.Checksum, uncached []ResolvedDep) { cached = make(map[string]entities.Checksum) for _, dep := range deps { @@ -66,67 +85,192 @@ func selectCachedAndUncached(deps []ResolvedDep, cachedChecksums map[string]enti return cached, uncached } -// applyHeadResultsOrLockfileFallback is the tier-2-vs-tier-3 decision: for every dependency that -// missed the build cache, use its HEAD-request checksum if it actually has one, else fall back -// to the lockfile's own SHA-256 (dependencies with neither are simply omitted). A HEAD request -// can succeed with an empty Checksum{} (no X-Checksum-* headers at all), which must not block -// the lockfile fallback the same way a real miss wouldn't. -func applyHeadResultsOrLockfileFallback(uncached []ResolvedDep, headResults map[string]entities.Checksum) map[string]entities.Checksum { - resolved := make(map[string]entities.Checksum, len(uncached)) - for _, dep := range uncached { - if checksum, ok := headResults[dep.ID]; ok && hasAnyChecksum(checksum) { - resolved[dep.ID] = checksum - } else if dep.SHA256 != "" { - resolved[dep.ID] = entities.Checksum{Sha256: dep.SHA256} - } +// batchedAQLFetch groups uncached dependencies into batches and queries Artifactory via AQL +// with up to aqlWorkerCount workers in parallel, each handling one batch of up to aqlBatchSize deps. +func batchedAQLFetch(deps []ResolvedDep, servicesManager artifactory.ArtifactoryServicesManager) map[string]entities.Checksum { + if len(deps) == 0 { + return map[string]entities.Checksum{} } - return resolved -} -func hasAnyChecksum(checksum entities.Checksum) bool { - return checksum.Sha1 != "" || checksum.Sha256 != "" || checksum.Md5 != "" -} + // Group deps by repository (since AQL queries are per-repo) + repoGroups := groupDepsByRepo(deps) -// resolveChecksumsByHead issues one HTTP HEAD per dependency against its resolved_url and reads -// sha1/md5/sha256 straight from Artifactory's X-Checksum-* response headers. -func resolveChecksumsByHead(deps []ResolvedDep, servicesManager artifactory.ArtifactoryServicesManager) map[string]entities.Checksum { - clientDetails := servicesManager.GetConfig().GetServiceDetails().CreateHttpClientDetails() + var batches []aqlBatch + for repo, group := range repoGroups { + for i := 0; i < len(group); i += aqlBatchSize { + end := i + aqlBatchSize + if end > len(group) { + end = len(group) + } + batches = append(batches, aqlBatch{repo: repo, deps: group[i:end]}) + } + } + + log.Debug(fmt.Sprintf("Created %d AQL batch(es) (batch size: %d, workers: %d).", len(batches), aqlBatchSize, aqlWorkerCount)) var ( mu sync.Mutex + checksumMap = make(map[string]entities.Checksum) wg sync.WaitGroup - sem = make(chan struct{}, headWorkerCount) - checksumMap = make(map[string]entities.Checksum, len(deps)) + sem = make(chan struct{}, aqlWorkerCount) + errCh = make(chan error, len(batches)) ) - for _, dep := range deps { - if dep.ResolvedURL == "" { - continue - } + for _, batch := range batches { wg.Add(1) sem <- struct{}{} - go func(dep ResolvedDep) { + go func(b aqlBatch) { defer wg.Done() defer func() { <-sem }() - // Each goroutine needs its own HttpClientDetails: request interceptors mutate - // its Headers map in place, so concurrent goroutines sharing one instance (or - // even one pointer to a value each holds by value but derived from a shared - // map) race on that map - Go maps are not safe for concurrent read/write, and - // the corruption isn't limited to headers; it can misattribute which response - // body/headers a goroutine ends up reading, producing checksums that belong to - // neither dependency. - depClientDetails := clientDetails.Clone() - fileDetails, _, err := servicesManager.Client().GetRemoteFileDetails(dep.ResolvedURL, depClientDetails) + + query := buildBatchAQLQuery(b.repo, b.deps) + log.Debug(fmt.Sprintf("Executing AQL query for repo '%s' with %d items...", b.repo, len(b.deps))) + results, err := executeAQL(servicesManager, query) if err != nil { - log.Debug(fmt.Sprintf("HEAD checksum lookup failed for %s: %s", dep.ID, err.Error())) + errCh <- fmt.Errorf("AQL batch failed for repo '%s': %w", b.repo, err) return } mu.Lock() - checksumMap[dep.ID] = fileDetails.Checksum + mapAQLResults(b.deps, results, checksumMap) mu.Unlock() - }(dep) + }(batch) } wg.Wait() + close(errCh) + + var errs []string + for err := range errCh { + errs = append(errs, err.Error()) + } + if len(errs) > 0 { + log.Warn(fmt.Sprintf("AQL checksum resolution encountered %d error(s): %v", len(errs), errs)) + } + return checksumMap } + +type aqlBatch struct { + repo string + deps []ResolvedDep +} + +// groupDepsByRepo groups dependencies by their repository (extracted from their resolved_url). +func groupDepsByRepo(deps []ResolvedDep) map[string][]ResolvedDep { + groups := make(map[string][]ResolvedDep) + for _, dep := range deps { + repo := extractRepoFromURL(dep.ResolvedURL) + groups[repo] = append(groups[repo], dep) + } + return groups +} + +// extractRepoFromURL extracts the repository name from an Artifactory resolved_url. +// Expected format: https://artifactory.example.com/artifactory/repo-name/... +func extractRepoFromURL(url string) string { + if url == "" { + return "" + } + // Split by /artifactory/ and take the part after it + parts := strings.Split(url, "/artifactory/") + if len(parts) < 2 { + return "" + } + // Extract the repo name (first segment after /artifactory/) + repoParts := strings.Split(parts[1], "/") + if len(repoParts) > 0 { + return repoParts[0] + } + return "" +} + +// buildBatchAQLQuery constructs an AQL query for a batch of dependencies in a specific repo. +// Query looks for items by their full path: /owner/repo/version/archive +func buildBatchAQLQuery(repo string, deps []ResolvedDep) string { + var clauses []string + for _, dep := range deps { + // Use the dependency ID as the search key (owner/repo:version) + // AQL queries by path/name, so we build a clause for the archive name + clause := fmt.Sprintf(`{"name":%q}`, dep.ID) + clauses = append(clauses, clause) + } + return fmt.Sprintf( + `items.find({"repo":%q,"$or":[%s]}).include("name","actual_sha1","sha256","actual_md5")`, + repo, strings.Join(clauses, ","), + ) +} + +// executeAQL runs an AQL query against Artifactory and returns the parsed results. +func executeAQL(servicesManager artifactory.ArtifactoryServicesManager, query string) ([]servicesUtils.ResultItem, error) { + body, err := servicesManager.Aql(query) + if err != nil { + if body != nil { + _ = body.Close() + } + return nil, err + } + defer func() { + if cerr := body.Close(); cerr != nil { + log.Debug("checksums: aql body close: " + cerr.Error()) + } + }() + + return parseAQLResults(body) +} + +// parseAQLResults parses the AQL response body into structured results. +func parseAQLResults(r io.Reader) ([]servicesUtils.ResultItem, error) { + data, err := io.ReadAll(r) + if err != nil { + return nil, err + } + var res aqlResult + if err := json.Unmarshal(data, &res); err != nil { + return nil, fmt.Errorf("parse aql response: %w", err) + } + var results []servicesUtils.ResultItem + for _, it := range res.Results { + results = append(results, servicesUtils.ResultItem{ + Name: it.Name, + Actual_Sha1: it.ActualSha1, + Sha256: it.Sha256, + Actual_Md5: it.ActualMd5, + }) + } + return results, nil +} + +// mapAQLResults maps AQL results to the checksumMap by matching dependency IDs. +func mapAQLResults(deps []ResolvedDep, results []servicesUtils.ResultItem, checksumMap map[string]entities.Checksum) { + resultsByKey := make(map[string]servicesUtils.ResultItem) + for _, r := range results { + resultsByKey[r.Name] = r + } + + matched := 0 + var resolvedIDs, missedIDs []string + for _, dep := range deps { + if r, ok := resultsByKey[dep.ID]; ok { + checksumMap[dep.ID] = entities.Checksum{ + Sha1: r.Actual_Sha1, + Md5: r.Actual_Md5, + Sha256: r.Sha256, + } + resolvedIDs = append(resolvedIDs, dep.ID) + matched++ + } else { + missedIDs = append(missedIDs, dep.ID) + } + } + if len(resolvedIDs) > 0 { + log.Debug(fmt.Sprintf("AQL checksums resolved for %d dependencies: %v", len(resolvedIDs), resolvedIDs)) + } + if len(missedIDs) > 0 { + log.Debug(fmt.Sprintf("No AQL results for %d dependencies: %v", len(missedIDs), missedIDs)) + } +} + +// hasAnyChecksum returns true if the checksum contains at least one non-empty hash value. +func hasAnyChecksum(checksum entities.Checksum) bool { + return checksum.Sha1 != "" || checksum.Sha256 != "" || checksum.Md5 != "" +} diff --git a/agent/apm/common/checksums_test.go b/agent/apm/common/checksums_test.go index fb388bcc..39240deb 100644 --- a/agent/apm/common/checksums_test.go +++ b/agent/apm/common/checksums_test.go @@ -45,46 +45,22 @@ func TestSelectCachedAndUncached_AllCached(t *testing.T) { assert.Empty(t, uncached) } -func TestApplyHeadResultsOrLockfileFallback_HeadHit(t *testing.T) { - uncached := []ResolvedDep{{ID: "a/b:1.0.0", SHA256: "lockfile-sha256"}} - headResults := map[string]entities.Checksum{ - "a/b:1.0.0": {Sha1: "head-sha1", Sha256: "head-sha256", Md5: "head-md5"}, +func TestHasAnyChecksum(t *testing.T) { + tests := []struct { + name string + checksum entities.Checksum + expected bool + }{ + {"All empty", entities.Checksum{}, false}, + {"SHA1 only", entities.Checksum{Sha1: "abc"}, true}, + {"SHA256 only", entities.Checksum{Sha256: "def"}, true}, + {"MD5 only", entities.Checksum{Md5: "ghi"}, true}, + {"All present", entities.Checksum{Sha1: "a", Sha256: "b", Md5: "c"}, true}, } - resolved := applyHeadResultsOrLockfileFallback(uncached, headResults) - - // HEAD result wins outright over the lockfile's own SHA-256 when both are available. - assert.Equal(t, entities.Checksum{Sha1: "head-sha1", Sha256: "head-sha256", Md5: "head-md5"}, resolved["a/b:1.0.0"]) -} - -func TestApplyHeadResultsOrLockfileFallback_FallsBackToLockfileSHA256(t *testing.T) { - uncached := []ResolvedDep{{ID: "a/b:1.0.0", SHA256: "lockfile-sha256"}} - - resolved := applyHeadResultsOrLockfileFallback(uncached, map[string]entities.Checksum{}) - - // No HEAD result at all -> lockfile SHA-256 only, sha1/md5 stay empty. - assert.Equal(t, entities.Checksum{Sha256: "lockfile-sha256"}, resolved["a/b:1.0.0"]) -} - -func TestApplyHeadResultsOrLockfileFallback_HeadHitWithEmptyChecksum_FallsBackToLockfile(t *testing.T) { - uncached := []ResolvedDep{{ID: "a/b:1.0.0", SHA256: "lockfile-sha256"}} - headResults := map[string]entities.Checksum{ - "a/b:1.0.0": {}, // HEAD succeeded but Artifactory returned no X-Checksum-* headers at all + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, hasAnyChecksum(tt.checksum)) + }) } - - resolved := applyHeadResultsOrLockfileFallback(uncached, headResults) - - // A present-but-empty HEAD result must not block the lockfile fallback the same way a real - // miss wouldn't. - assert.Equal(t, entities.Checksum{Sha256: "lockfile-sha256"}, resolved["a/b:1.0.0"]) -} - -func TestApplyHeadResultsOrLockfileFallback_NoChecksumAtAll(t *testing.T) { - uncached := []ResolvedDep{{ID: "a/b:1.0.0"}} // no SHA256 from the lockfile either - - resolved := applyHeadResultsOrLockfileFallback(uncached, map[string]entities.Checksum{}) - - // Neither tier has anything - dependency is simply omitted, not recorded with a zero-value checksum. - _, found := resolved["a/b:1.0.0"] - assert.False(t, found) } From 137ccf9b695e67890aad82c62679d5ddbe804b00 Mon Sep 17 00:00:00 2001 From: Uday Date: Tue, 1 Sep 2026 23:02:06 +0530 Subject: [PATCH 40/40] RTECO-1648: Add corner case handling for malformed/empty ResolvedURLs - Skip deps with empty ResolvedURL (not resolved properly) - Skip deps with unparseable ResolvedURL (log warning) - Return early with empty checksum map if no valid batches - Improves resilience to malformed dependency data --- agent/apm/cli/cli.go | 2 +- agent/apm/commands/install/help.go | 2 +- agent/apm/common/apmenv.go | 3 + agent/apm/common/checksums.go | 161 +++++++++++++++++++++++----- agent/apm/common/checksums_test.go | 164 +++++++++++++++++++++++++++++ 5 files changed, 303 insertions(+), 29 deletions(-) diff --git a/agent/apm/cli/cli.go b/agent/apm/cli/cli.go index 1b9a0b71..4a0666c5 100644 --- a/agent/apm/cli/cli.go +++ b/agent/apm/cli/cli.go @@ -1,9 +1,9 @@ package cli import ( - apmcommon "github.com/jfrog/jfrog-cli-artifactory/agent/apm/common" "github.com/jfrog/jfrog-cli-artifactory/agent/apm/commands/install" "github.com/jfrog/jfrog-cli-artifactory/agent/apm/commands/publish" + apmcommon "github.com/jfrog/jfrog-cli-artifactory/agent/apm/common" agentcommon "github.com/jfrog/jfrog-cli-artifactory/agent/common" "github.com/jfrog/jfrog-cli-artifactory/cliutils/flagkit" "github.com/jfrog/jfrog-cli-core/v2/common/commands" diff --git a/agent/apm/commands/install/help.go b/agent/apm/commands/install/help.go index 2dbcefad..8b71f73b 100644 --- a/agent/apm/commands/install/help.go +++ b/agent/apm/commands/install/help.go @@ -26,7 +26,7 @@ Common patterns: Gotchas: - A bare tag (#1.0.0) is an exact pin and never moves on re-resolution. Use a semver range (#^1.0.0, #~1.0.0) if later runs should pick up newer matching versions. -- --dry-run previews the install without changing anything and skips build-info (nothing real to record). +- --dry-run previews the install without changing anything and skips build-info (nothing real to record). Note: APM environment setup may persist the experimental registries flag to ~/.apm/config.json even during dry runs. - Build-info is collected only when both --build-name and --build-number are provided; publish it afterwards with 'jf rt build-publish'. Related: jf agent apm publish, jf setup apm, jf rt build-publish` diff --git a/agent/apm/common/apmenv.go b/agent/apm/common/apmenv.go index 0ba1435e..40806e0a 100644 --- a/agent/apm/common/apmenv.go +++ b/agent/apm/common/apmenv.go @@ -130,6 +130,9 @@ func generateAccessTokenViaAccessAPI(serverDetails *config.ServerDetails) (strin // returns 405) to create an access token from username/password. Fallback only - see // generateAccessToken. Returns an error if generation fails. func generateAccessTokenLegacy(serverDetails *config.ServerDetails) (string, error) { + if serverDetails.ArtifactoryUrl == "" { + return "", fmt.Errorf("artifactory URL is required for legacy access token generation") + } tokenURL := strings.TrimSuffix(serverDetails.ArtifactoryUrl, "/") + "/api/security/token" form := url.Values{} diff --git a/agent/apm/common/checksums.go b/agent/apm/common/checksums.go index 18f1dc30..d737a2e4 100644 --- a/agent/apm/common/checksums.go +++ b/agent/apm/common/checksums.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io" + "regexp" "strings" "sync" @@ -23,6 +24,7 @@ const aqlWorkerCount = 15 // aqlResult is the subset of the AQL response we consume. type aqlResult struct { Results []struct { + Path string `json:"path"` Name string `json:"name"` ActualSha1 string `json:"actual_sha1"` Sha256 string `json:"sha256"` @@ -106,14 +108,20 @@ func batchedAQLFetch(deps []ResolvedDep, servicesManager artifactory.Artifactory } } + checksumMap := make(map[string]entities.Checksum) + + if len(batches) == 0 { + log.Debug("No valid dependencies with resolvable repositories; skipping AQL.") + return checksumMap + } + log.Debug(fmt.Sprintf("Created %d AQL batch(es) (batch size: %d, workers: %d).", len(batches), aqlBatchSize, aqlWorkerCount)) var ( - mu sync.Mutex - checksumMap = make(map[string]entities.Checksum) - wg sync.WaitGroup - sem = make(chan struct{}, aqlWorkerCount) - errCh = make(chan error, len(batches)) + mu sync.Mutex + wg sync.WaitGroup + sem = make(chan struct{}, aqlWorkerCount) + errCh = make(chan error, len(batches)) ) for _, batch := range batches { @@ -156,46 +164,122 @@ type aqlBatch struct { } // groupDepsByRepo groups dependencies by their repository (extracted from their resolved_url). +// Dependencies with empty or malformed ResolvedURL are grouped under empty key; batchedAQLFetch +// will skip them with a warning. func groupDepsByRepo(deps []ResolvedDep) map[string][]ResolvedDep { groups := make(map[string][]ResolvedDep) for _, dep := range deps { + if dep.ResolvedURL == "" { + log.Debug(fmt.Sprintf("Skipping dependency %s: empty ResolvedURL", dep.ID)) + continue + } repo := extractRepoFromURL(dep.ResolvedURL) + if repo == "" { + log.Warn(fmt.Sprintf("Could not extract repository from ResolvedURL for %s: %s", dep.ID, dep.ResolvedURL)) + continue + } groups[repo] = append(groups[repo], dep) } return groups } +// agentPackagesURLPattern matches an APM agentpackages download URL and captures the +// repository, package owner, package name, and version. Every ResolvedDep.ResolvedURL in +// this package has this exact shape (it comes straight from apm.lock.yaml's resolved_url), +// so there is no other format to account for: +// +// https:///artifactory/api/agentpackages//v1/packages///versions//download +var agentPackagesURLPattern = regexp.MustCompile(`/artifactory/api/agentpackages/([^/]+)/v1/packages/([^/]+)/([^/]+)/versions/([^/]+)/download`) + +// parseAgentPackagesURL extracts (owner, name, version) from an APM resolved_url. +// ok is false if the URL doesn't match the agentpackages download shape. +func parseAgentPackagesURL(url string) (owner, name, version string, ok bool) { + m := agentPackagesURLPattern.FindStringSubmatch(url) + if m == nil { + return "", "", "", false + } + return m[2], m[3], m[4], true +} + // extractRepoFromURL extracts the repository name from an Artifactory resolved_url. -// Expected format: https://artifactory.example.com/artifactory/repo-name/... +// Handles both classic and APM URL formats: +// - Classic: https://artifactory.example.com/artifactory/repo-name/... +// - APM: https://artifactory.example.com/artifactory/api/agentpackages/repo-name/... func extractRepoFromURL(url string) string { if url == "" { return "" } - // Split by /artifactory/ and take the part after it - parts := strings.Split(url, "/artifactory/") - if len(parts) < 2 { - return "" + + // Try APM format first: /artifactory/api/agentpackages/repo-name/ + apmPrefix := "/artifactory/api/agentpackages/" + if idx := strings.Index(url, apmPrefix); idx != -1 { + remainder := url[idx+len(apmPrefix):] + if slash := strings.Index(remainder, "/"); slash != -1 { + repo := remainder[:slash] + if repo != "" { + return repo + } + } } - // Extract the repo name (first segment after /artifactory/) - repoParts := strings.Split(parts[1], "/") - if len(repoParts) > 0 { - return repoParts[0] + + // Fall back to classic format: /artifactory/repo-name/ + classicPrefix := "/artifactory/" + if idx := strings.Index(url, classicPrefix); idx != -1 { + remainder := url[idx+len(classicPrefix):] + if slash := strings.Index(remainder, "/"); slash != -1 { + repo := remainder[:slash] + if repo != "" { + return repo + } + } } + return "" } +// extractArtifactPath extracts the path within the repository for an APM package. +// For APM URLs, this is: /owner/package-name (used for deduplication with path+name). +func extractArtifactPath(url string) string { + owner, name, _, ok := parseAgentPackagesURL(url) + if !ok { + return "" + } + return owner + "/" + name +} + +// extractArtifactFilename returns the real artifact filename Artifactory stores the package +// under: -.zip. The download URL itself ends in "/download", which is not +// a real filename, so it cannot be derived by taking the URL's last path segment. +func extractArtifactFilename(url string) string { + _, name, version, ok := parseAgentPackagesURL(url) + if !ok { + return "" + } + return fmt.Sprintf("%s-%s.zip", name, version) +} + // buildBatchAQLQuery constructs an AQL query for a batch of dependencies in a specific repo. -// Query looks for items by their full path: /owner/repo/version/archive +// Queries by artifact filename (name) since path-based queries don't work reliably for APM packages. func buildBatchAQLQuery(repo string, deps []ResolvedDep) string { var clauses []string for _, dep := range deps { - // Use the dependency ID as the search key (owner/repo:version) - // AQL queries by path/name, so we build a clause for the archive name - clause := fmt.Sprintf(`{"name":%q}`, dep.ID) + // Extract the real artifact filename from the ResolvedURL + // For APM URLs, this parses the path to extract name and version + artifactFilename := extractArtifactFilename(dep.ResolvedURL) + if artifactFilename == "" { + log.Debug(fmt.Sprintf("Could not extract artifact filename from ResolvedURL for %s: %s", dep.ID, dep.ResolvedURL)) + continue + } + // Query by name in the repository + clause := fmt.Sprintf(`{"name":%q}`, artifactFilename) clauses = append(clauses, clause) } + if len(clauses) == 0 { + // Return a query that will return no results if we can't extract any filenames + return fmt.Sprintf(`items.find({"repo":%q,"name":"/NEVER_MATCHES/"}).include("name","actual_sha1","sha256","actual_md5")`, repo) + } return fmt.Sprintf( - `items.find({"repo":%q,"$or":[%s]}).include("name","actual_sha1","sha256","actual_md5")`, + `items.find({"repo":%q,"$or":[%s]}).include("path","name","actual_sha1","sha256","actual_md5")`, repo, strings.Join(clauses, ","), ) } @@ -231,26 +315,49 @@ func parseAQLResults(r io.Reader) ([]servicesUtils.ResultItem, error) { var results []servicesUtils.ResultItem for _, it := range res.Results { results = append(results, servicesUtils.ResultItem{ - Name: it.Name, - Actual_Sha1: it.ActualSha1, - Sha256: it.Sha256, - Actual_Md5: it.ActualMd5, + Path: it.Path, + Name: it.Name, + Actual_Sha1: it.ActualSha1, + Sha256: it.Sha256, + Actual_Md5: it.ActualMd5, }) } return results, nil } -// mapAQLResults maps AQL results to the checksumMap by matching dependency IDs. +// mapAQLResults maps AQL results to the checksumMap by matching dependency artifact paths. +// Results are keyed by path+name to handle owner collisions (e.g., owner-a/tool and owner-b/tool both produce tool-1.0.0.zip). func mapAQLResults(deps []ResolvedDep, results []servicesUtils.ResultItem, checksumMap map[string]entities.Checksum) { - resultsByKey := make(map[string]servicesUtils.ResultItem) + // Build a map of (path+name) to results for matching + resultsByPathAndName := make(map[string]servicesUtils.ResultItem) for _, r := range results { - resultsByKey[r.Name] = r + // Combine path and name to create unique key (handles owner collisions). + // Normalize path: remove leading slash if present (AQL returns "/owner/name", we need "owner/name"). + path := strings.TrimPrefix(r.Path, "/") + key := path + "/" + r.Name + resultsByPathAndName[key] = r } matched := 0 var resolvedIDs, missedIDs []string for _, dep := range deps { - if r, ok := resultsByKey[dep.ID]; ok { + // Extract artifact path from the ResolvedURL for matching + artifactPath := extractArtifactPath(dep.ResolvedURL) + if artifactPath == "" { + missedIDs = append(missedIDs, dep.ID) + continue + } + + // Extract filename for composite key matching + artifactFilename := extractArtifactFilename(dep.ResolvedURL) + if artifactFilename == "" { + missedIDs = append(missedIDs, dep.ID) + continue + } + + // Look up by path+name (path from URL includes owner, prevents collisions) + key := artifactPath + "/" + artifactFilename + if r, ok := resultsByPathAndName[key]; ok { checksumMap[dep.ID] = entities.Checksum{ Sha1: r.Actual_Sha1, Md5: r.Actual_Md5, diff --git a/agent/apm/common/checksums_test.go b/agent/apm/common/checksums_test.go index 39240deb..9c071384 100644 --- a/agent/apm/common/checksums_test.go +++ b/agent/apm/common/checksums_test.go @@ -1,9 +1,11 @@ package apmcommon import ( + "strings" "testing" "github.com/jfrog/build-info-go/entities" + servicesUtils "github.com/jfrog/jfrog-client-go/artifactory/services/utils" "github.com/stretchr/testify/assert" ) @@ -64,3 +66,165 @@ func TestHasAnyChecksum(t *testing.T) { }) } } + +func TestExtractArtifactFilename(t *testing.T) { + tests := []struct { + name string + url string + expected string + }{ + { + name: "APM URL format", + url: "https://artifactory.test/artifactory/api/agentpackages/test-repo/v1/packages/owner/my-skill/versions/1.0.0/download", + expected: "my-skill-1.0.0.zip", + }, + { + name: "APM URL with different package", + url: "https://artifactory.test/artifactory/api/agentpackages/repo/v1/packages/acme/tool/versions/2.0.1/download", + expected: "tool-2.0.1.zip", + }, + { + name: "Empty URL", + url: "", + expected: "", + }, + { + name: "Invalid APM URL format", + url: "https://artifactory.test/invalid", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractArtifactFilename(tt.url) + assert.Equal(t, tt.expected, result, "extractArtifactFilename(%q)", tt.url) + }) + } +} + +func TestExtractRepoFromURL(t *testing.T) { + tests := []struct { + name string + url string + expected string + }{ + { + name: "APM URL format", + url: "https://artifactory.test/artifactory/api/agentpackages/apm-local/v1/packages/owner/skill/versions/1.0.0/download", + expected: "apm-local", + }, + { + name: "Classic repo URL", + url: "https://artifactory.test/artifactory/my-repo/path/to/artifact.jar", + expected: "my-repo", + }, + { + name: "Empty URL", + url: "", + expected: "", + }, + { + name: "Invalid URL format", + url: "https://artifactory.test/invalid", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractRepoFromURL(tt.url) + assert.Equal(t, tt.expected, result, "extractRepoFromURL(%q)", tt.url) + }) + } +} + +func TestParseAQLResults_IncludesPath(t *testing.T) { + // Test that parseAQLResults correctly parses and includes the path field from AQL JSON. + rawJSON := `{ + "results": [ + { + "path": "owner-a/my-tool", + "name": "my-tool-1.0.0.zip", + "actual_sha1": "sha1-value-a", + "sha256": "sha256-value-a", + "actual_md5": "md5-value-a" + }, + { + "path": "owner-b/my-tool", + "name": "my-tool-1.0.0.zip", + "actual_sha1": "sha1-value-b", + "sha256": "sha256-value-b", + "actual_md5": "md5-value-b" + } + ] + }` + + reader := strings.NewReader(rawJSON) + results, err := parseAQLResults(reader) + + assert.NoError(t, err, "parseAQLResults should not error") + assert.Len(t, results, 2, "should parse 2 results") + + // Verify first result has correct path and all checksums + assert.Equal(t, "owner-a/my-tool", results[0].Path, "first result should have path owner-a/my-tool") + assert.Equal(t, "my-tool-1.0.0.zip", results[0].Name) + assert.Equal(t, "sha1-value-a", results[0].Actual_Sha1) + assert.Equal(t, "sha256-value-a", results[0].Sha256) + assert.Equal(t, "md5-value-a", results[0].Actual_Md5) + + // Verify second result has correct path and all checksums + assert.Equal(t, "owner-b/my-tool", results[1].Path, "second result should have path owner-b/my-tool") + assert.Equal(t, "my-tool-1.0.0.zip", results[1].Name) + assert.Equal(t, "sha1-value-b", results[1].Actual_Sha1) + assert.Equal(t, "sha256-value-b", results[1].Sha256) + assert.Equal(t, "md5-value-b", results[1].Actual_Md5) +} + +func TestMapAQLResults_OwnerCollision(t *testing.T) { + // Test that different owners with the same package name are handled correctly + // without one overwriting the other's checksum. + deps := []ResolvedDep{ + { + ID: "owner-a/my-tool:1.0.0", + ResolvedURL: "https://artifactory.test/artifactory/api/agentpackages/apm-local/v1/packages/owner-a/my-tool/versions/1.0.0/download", + }, + { + ID: "owner-b/my-tool:1.0.0", + ResolvedURL: "https://artifactory.test/artifactory/api/agentpackages/apm-local/v1/packages/owner-b/my-tool/versions/1.0.0/download", + }, + } + + // Simulated AQL results: both packages have the same filename but different paths. + // Without proper deduplication, the second result would overwrite the first in a + // filename-only map. The Path field in AQL results can include a leading slash + // (e.g., "/owner-a/my-tool"), which we normalize by removing it. + results := []servicesUtils.ResultItem{ + { + Name: "my-tool-1.0.0.zip", + Path: "/owner-a/my-tool", // AQL paths may have leading slash + Actual_Sha1: "sha1-owner-a", + Actual_Md5: "md5-owner-a", + Sha256: "sha256-owner-a", + }, + { + Name: "my-tool-1.0.0.zip", + Path: "/owner-b/my-tool", // AQL paths may have leading slash + Actual_Sha1: "sha1-owner-b", + Actual_Md5: "md5-owner-b", + Sha256: "sha256-owner-b", + }, + } + + checksumMap := make(map[string]entities.Checksum) + mapAQLResults(deps, results, checksumMap) + + // Verify both dependencies got their correct checksums (not overwritten by collision). + // Each dependency should have its unique set of checksums despite sharing the filename. + assert.Equal(t, "sha256-owner-a", checksumMap["owner-a/my-tool:1.0.0"].Sha256, "owner-a should have sha256-owner-a") + assert.Equal(t, "sha256-owner-b", checksumMap["owner-b/my-tool:1.0.0"].Sha256, "owner-b should have sha256-owner-b") + assert.Equal(t, "sha1-owner-a", checksumMap["owner-a/my-tool:1.0.0"].Sha1, "owner-a should have sha1-owner-a") + assert.Equal(t, "sha1-owner-b", checksumMap["owner-b/my-tool:1.0.0"].Sha1, "owner-b should have sha1-owner-b") + assert.Equal(t, "md5-owner-a", checksumMap["owner-a/my-tool:1.0.0"].Md5, "owner-a should have md5-owner-a") + assert.Equal(t, "md5-owner-b", checksumMap["owner-b/my-tool:1.0.0"].Md5, "owner-b should have md5-owner-b") +}