diff --git a/agent/apm/cli/cli.go b/agent/apm/cli/cli.go new file mode 100644 index 00000000..4a0666c5 --- /dev/null +++ b/agent/apm/cli/cli.go @@ -0,0 +1,81 @@ +package cli + +import ( + "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" + "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. +// "lock", which resolves but doesn't collect build-info) fall through to the parent's +// passthrough handler. +func GetSubCommands() []components.Command { + return []components.Command{ + { + Name: "install", + 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. + SkipFlagParsing: true, + Description: "Install APM packages with JFrog Artifactory authentication.", + AIDescription: install.GetAIDescription(), + Action: install.RunInstall, + }, + { + Name: "publish", + Flags: flagkit.GetCommandFlags(flagkit.AgentApm), + SkipFlagParsing: true, + Description: "Publish an APM package to JFrog Artifactory.", + AIDescription: publish.GetAIDescription(), + Action: publish.RunPublish, + }, + } +} + +// 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. +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) + } + + // 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(remainingArgs) { + return apmcommon.RunApmCommand(nil, subcmd, remainingArgs) + } + + serverDetails, err := agentcommon.GetServerDetailsByID(serverID) + if err != nil { + return err + } + + cmd := &apmcommon.PassthroughCommand{ + Subcmd: subcmd, + Args: remainingArgs, + Server: serverDetails, + } + + return commands.ExecWithPackageManager(cmd, apmcommon.PackageManagerID) +} diff --git a/agent/apm/cli/help.go b/agent/apm/cli/help.go new file mode 100644 index 00000000..889b3b74 --- /dev/null +++ b/agent/apm/cli/help.go @@ -0,0 +1,30 @@ +package cli + +func GetDescription() string { + return "Agent Package Manager (APM) commands with JFrog Artifactory authentication." +} + +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 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 (lock, outdated, audit, doctor, view, marketplace, mcp, ...) through jf for authenticated registry access. + +Prerequisites: +- apm CLI installed and on PATH. +- 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: + $ 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 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 --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 new file mode 100644 index 00000000..8b71f73b --- /dev/null +++ b/agent/apm/commands/install/help.go @@ -0,0 +1,33 @@ +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 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. +- Pulling private or curated packages from Artifactory. +- Capturing build-info for an install by passing --build-name and --build-number. + +Prerequisites: +- apm CLI installed and on PATH. +- 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: + $ jf agent apm install + $ jf agent apm install my-org/my-package#1.0.0 --target claude + $ jf agent apm install "my-org/my-package#^1.0.0" --target claude --build-name=my-build --build-number=1 + $ jf agent apm install --dev my-org/my-dev-tool#1.0.0 + $ jf agent apm install --dry-run + +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). 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/commands/install/install.go b/agent/apm/commands/install/install.go new file mode 100644 index 00000000..ea257c83 --- /dev/null +++ b/agent/apm/commands/install/install.go @@ -0,0 +1,129 @@ +package install + +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" + 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 = "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 apm or apm.yml's own registries: block. +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(serverDetails *config.ServerDetails) *ApmInstallCommand { + c.serverDetails = serverDetails + return c +} + +func (c *ApmInstallCommand) SetBuildConfiguration(buildConfiguration *buildUtils.BuildConfiguration) *ApmInstallCommand { + c.buildConfiguration = buildConfiguration + return c +} + +func (c *ApmInstallCommand) CommandName() string { + return apmcommon.CommandNamePrefix + apmSubcommand +} + +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(apmSubcommand, c.args, c.serverDetails); err != nil { + return fmt.Errorf("run apm install: %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 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 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 { + return biErr + } + } + } + + log.Info("apm install finished successfully.") + 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) { + 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 := NewApmInstallCommand(). + SetArgs(opts.ApmNativeArgs). + SetServerDetails(serverDetails). + SetBuildConfiguration(opts.BuildConfig) + + return commands.ExecWithPackageManager(cmd, apmcommon.PackageManagerID) +} 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/help.go b/agent/apm/commands/publish/help.go new file mode 100644 index 00000000..29e9b520 --- /dev/null +++ b/agent/apm/commands/publish/help.go @@ -0,0 +1,33 @@ +package publish + +func GetDescription() string { + return "Publish an APM package to JFrog Artifactory." +} + +func GetAIDescription() string { + 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 (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 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 apm' or an apm.yml registries: block. + +Common patterns: + $ 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 + $ jf agent apm publish --package my-org/my-package --dry-run + +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 setup apm, jf rt build-publish` +} diff --git a/agent/apm/commands/publish/publish.go b/agent/apm/commands/publish/publish.go new file mode 100644 index 00000000..b735704e --- /dev/null +++ b/agent/apm/commands/publish/publish.go @@ -0,0 +1,178 @@ +package publish + +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" + 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 = "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 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 + buildConfiguration *buildUtils.BuildConfiguration +} + +func NewApmPublishCommand() *ApmPublishCommand { + return &ApmPublishCommand{} +} + +func (c *ApmPublishCommand) SetArgs(args []string) *ApmPublishCommand { + c.args = args + return c +} + +func (c *ApmPublishCommand) SetServerDetails(serverDetails *config.ServerDetails) *ApmPublishCommand { + c.serverDetails = serverDetails + return c +} + +func (c *ApmPublishCommand) SetBuildConfiguration(buildConfiguration *buildUtils.BuildConfiguration) *ApmPublishCommand { + c.buildConfiguration = buildConfiguration + return c +} + +func (c *ApmPublishCommand) CommandName() string { + 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. 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=") { + return nil + } + } + 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...") + + if err := requirePackageFlag(c.args); err != nil { + return err + } + if err := apmcommon.RunApmSubcommandWithAuth(apmSubcommand, c.args, c.serverDetails); err != nil { + return fmt.Errorf("run apm publish: %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 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 { + return biErr + } + } + } + + log.Info("apm publish finished successfully.") + 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 { + if arg == "--zip" && i+1 < len(args) { + return args[i+1] + } + if cut, ok := strings.CutPrefix(arg, "--zip="); ok { + return cut + } + } + 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, 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 := NewApmPublishCommand(). + SetArgs(opts.ApmNativeArgs). + SetServerDetails(serverDetails). + SetBuildConfiguration(opts.BuildConfig) + + return commands.ExecWithPackageManager(cmd, apmcommon.PackageManagerID) +} diff --git a/agent/apm/commands/publish/publish_test.go b/agent/apm/commands/publish/publish_test.go new file mode 100644 index 00000000..458ce659 --- /dev/null +++ b/agent/apm/commands/publish/publish_test.go @@ -0,0 +1,80 @@ +package publish + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRequirePackageFlag(t *testing.T) { + tests := []struct { + 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 is no longer auto-promoted - requires an explicit error", + args: []string{"jfrog/proj3"}, + wantErr: true, + }, + { + 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) { + err := requirePackageFlag(tt.args) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +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)) + }) + } +} + +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/common/apmenv.go b/agent/apm/common/apmenv.go new file mode 100644 index 00000000..40806e0a --- /dev/null +++ b/agent/apm/common/apmenv.go @@ -0,0 +1,717 @@ +package apmcommon + +import ( + "encoding/json" + "fmt" + "io" + "maps" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "slices" + "strconv" + "strings" + + 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/" + + // ApmBinaryName is the apm executable RunApmCommand always shells out to. + ApmBinaryName = "apm" + + // HelpFlag is the help flag this package constructs when forwarding to apm. + HelpFlag = "--help" + + // 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. +func AgentPackagesBaseURL(serverDetails *config.ServerDetails, repoName string) string { + base := strings.TrimSuffix(serverDetails.ArtifactoryUrl, "/") + return base + agentPackagesAPIPrefix + repoName + "/" +} + +// BuildRegistryEntry returns (registryURL, token) for APM config. +// 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; 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, nil + } + + // Priority 2: Generate token from username/password (secure - no plaintext in config) + if serverDetails.User != "" && serverDetails.Password != "" { + generatedToken, genErr := generateAccessToken(serverDetails) + if genErr != nil { + return "", "", fmt.Errorf("apm: failed to generate access token for registry %q: %w", repoName, genErr) + } + return base, generatedToken, nil + } + + // 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 an +// error, combining both attempts' failures, only if both paths fail. +func generateAccessToken(serverDetails *config.ServerDetails) (string, error) { + if serverDetails.User == "" || serverDetails.Password == "" { + return "", fmt.Errorf("username and password are required to generate an access 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())) + + 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, error) { + accessManager, err := rtUtils.CreateAccessServiceManager(serverDetails, false) + if err != nil { + return "", fmt.Errorf("failed to create access service manager for token generation: %w", err) + } + + expiresIn := uint(apmAccessTokenExpirySeconds) + tokenParams := services.CreateTokenParams{Username: serverDetails.User} + tokenParams.Scope = "applied-permissions/user" + tokenParams.ExpiresIn = &expiresIn + + tokenResponse, err := accessManager.CreateAccessToken(tokenParams) + if err != nil { + return "", fmt.Errorf("failed to generate access token via the access API: %w", err) + } + if tokenResponse.AccessToken == "" { + 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, 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 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{} + form.Set("username", serverDetails.User) + form.Set("scope", "applied-permissions/user") + form.Set("expires_in", strconv.Itoa(apmAccessTokenExpirySeconds)) + + req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(form.Encode())) + if err != nil { + 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 { + 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 { + return "", fmt.Errorf("failed to read legacy access token response: %w", err) + } + if resp.StatusCode != http.StatusOK { + 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 { + return "", fmt.Errorf("failed to parse legacy token response: %w", err) + } + + token, ok := response["access_token"].(string) + if !ok || token == "" { + return "", fmt.Errorf("no access_token in legacy API response") + } + + log.Debug("Access token generated for APM registry via the legacy endpoint") + return token, nil +} + +// 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:"-"` + 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 experimentalRaw, ok := raw["experimental"]; ok { + if err := json.Unmarshal(experimentalRaw, &c.Experimental); err != nil { + return err + } + delete(raw, "experimental") + } + if registriesRaw, ok := raw["registries"]; ok { + if err := json.Unmarshal(registriesRaw, &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 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, serverDetails.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.Entries { + if seen[name] || !apmHostMatches(reg.URL, serverDetails.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 "_" (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)) +} + +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 _, name := range names { + sanitized := sanitizeApmEnvName(name) + bySanitized[sanitized] = append(bySanitized[sanitized], name) + } + 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", + collidingNames, sanitized) + } + } + return nil +} + +// injectRegistryCredentialEnv appends APM_REGISTRY_TOKEN_ (or USER_/PASS_) to env for +// 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 serverDetails.AccessToken != "" { + return append(env, tokenKey+"="+serverDetails.AccessToken) + } + if serverDetails.User != "" && serverDetails.Password != "" { + return append(env, userKey+"="+serverDetails.User, passKey+"="+serverDetails.Password) + } + return env +} + +// 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 + } + 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. The registry must already be declared (an existing ~/.apm/config.json +// 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 { + return nil, fmt.Errorf("server details are required to build the APM environment") + } + + realHome, existing, err := loadExistingApmConfig() + if err != nil { + return nil, err + } + + discovered := discoverMatchingRegistries(existing, manifestPath, serverDetails) + 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 apm')", + serverDetails.ArtifactoryUrl) + } + + names := make([]string, 0, len(discovered)) + for _, registry := range discovered { + names = append(names, registry.Name) + } + if err = checkSanitizationCollisions(names); err != nil { + return nil, err + } + + if err = ensureExperimentalFlagEnabled(realHome, existing); err != nil { + return nil, err + } + + env := os.Environ() + for _, registry := range discovered { + env = injectRegistryCredentialEnv(env, registry.Name, serverDetails) + } + return env, 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, apmConfigDirName, apmConfigFileName)) + 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) // #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 + } + return nil, err + } + var cfg apmConfigJSON + if err = json.Unmarshal(data, &cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +// 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, apmConfigDirName) + 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) + } + + 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() + // 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 { + _ = 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, apmConfigFileName)); 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 (current process +// 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))) + allArgs := append([]string{subcmd}, args...) + 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 + } + + // 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() + 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", "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 +// picker beforehand. +func ConfigureApmRegistryPersistent(serverDetails *config.ServerDetails, repoName string) error { + if serverDetails == 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, 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) + } + 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"}) +} + +// 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 +} + +// 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 - 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). +// +// 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 "" + } + _, existing, err := loadExistingApmConfig() + if err != nil { + return "" + } + var manifest *ApmManifest + if manifestPath != "" { + if loadedManifest, loadErr := LoadManifest(manifestPath); loadErr == nil { + manifest = loadedManifest + } else { + log.Debug("apm.yml parsing failed while resolving registry repo name:", loadErr.Error()) + } + } + + if explicit := registryNameFromArgs(args); explicit != "" { + 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) + if len(discovered) != 1 { + return "" + } + return discovered[0].Name +} + +// RunApmSubcommandWithAuth is the shared body for all apm command Run() methods: +// 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 + } + workingDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + manifestPath := filepath.Join(workingDir, ApmManifestName) + env, err := BuildApmEnv(serverDetails, manifestPath) + if err != nil { + return err + } + 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 == HelpFlag || arg == "-h" || arg == "help" { + return true + } + } + return false +} + +// 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") +} + +// 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 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/apm/common/apmenv_test.go b/agent/apm/common/apmenv_test.go new file mode 100644 index 00000000..ba831910 --- /dev/null +++ b/agent/apm/common/apmenv_test.go @@ -0,0 +1,289 @@ +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() + // 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)) + 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, "", nil)) + }) + } +} + +func TestResolveRepoNameFromRegistry_NilServerDetails(t *testing.T) { + 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) { + 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, err := BuildRegistryEntry(tt.serverDetails, tt.repoName) + require.NoError(t, err) + 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 + }{ + { + name: "empty user and password", + serverDetails: &config.ServerDetails{}, + }, + { + name: "user without password", + serverDetails: &config.ServerDetails{ + User: "admin", + }, + }, + { + name: "password without user", + serverDetails: &config.ServerDetails{ + Password: "secret", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + 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") + }) + } +} + +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)) + }) + } +} diff --git a/agent/apm/common/build_info.go b/agent/apm/common/build_info.go new file mode 100644 index 00000000..162b569c --- /dev/null +++ b/agent/apm/common/build_info.go @@ -0,0 +1,410 @@ +package apmcommon + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "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" + "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" +) + +// 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" + +// ShouldCollectBuildInfo reports whether the user enabled build-info collection +// (--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 { + 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 := ShouldCollectBuildInfo(buildConfig) + 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 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 + } + return err + } + if len(deps) == 0 { + log.Info("No registry dependencies found in lockfile. Skipping build info.") + return nil + } + + checksumMap, err := ResolveChecksums(deps, serverDetails, 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(errBuildInfoNotEnabled) + } + + moduleID := buildConfig.GetModule() + if moduleID == "" { + moduleID = derivedModuleID(manifestPath) + } + + entityDeps := make([]entities.Dependency, 0, len(deps)) + for _, dep := range deps { + checksum := checksumMap[dep.ID] + entityDep := dep.ToEntitiesDependency(checksum) + entityDep.RequestedBy = anchorRequestedByToModule(entityDep.RequestedBy, moduleID) + entityDeps = append(entityDeps, entityDep) + } + + partial := &entities.Partial{ + ModuleId: moduleID, + ModuleType: apmModuleType, + 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 +} + +// 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 +} + +// 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 +// 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 + } + 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(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's derivedModuleID and same as npm's own AddNpmModule. + moduleID := buildConfig.GetModule() + if moduleID == "" && moduleName != "" && version != "" { + moduleID = moduleName + ":" + version + } + + fileName, dirPath, artifactPath := buildArtifactPathParts(owner, packageName, version) + + artifact := entities.Artifact{ + Name: fileName, + Type: apmPackageFileExtension, + Path: artifactPath, + OriginalDeploymentRepo: repoName, + Checksum: checksum, + } + + if err = apmBuild.AddArtifacts(moduleID, apmModuleType, artifact); err != nil { + 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, 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, packageName, repoName, explicitZipPath string, serverDetails *config.ServerDetails, buildConfig *buildUtils.BuildConfiguration) error { + collectBuildInfo, err := ShouldCollectBuildInfo(buildConfig) + if err != nil { + return err + } + if !collectBuildInfo { + return nil + } + manifest, err := LoadManifest(manifestPath) + if err != nil { + return err + } + // 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. + _, _, artifactPath := buildArtifactPathParts(owner, packageName, manifest.Version) + verifyArtifactPathExists(serverDetails, repoName, artifactPath) + + checksum := lookupPublishedArtifactChecksum(owner, packageName, manifest.Version, repoName, serverDetails) + if !hasAnyChecksum(checksum) { + 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.", + packageName, manifest.Version, localErr.Error())) + } + } + 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, packageName, 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.Warn("apm publish: could not create service manager for checksum lookup:", err.Error()) + return entities.Checksum{} + } + + 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 { + 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 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) + } + 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 +// 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()) + return "" + } + if manifest.Name == "" || manifest.Version == "" { + return "" + } + return manifest.Name + ":" + manifest.Version +} diff --git a/agent/apm/common/build_info_test.go b/agent/apm/common/build_info_test.go new file mode 100644 index 00000000..df7bdef2 --- /dev/null +++ b/agent/apm/common/build_info_test.go @@ -0,0 +1,189 @@ +package apmcommon + +import ( + "os" + "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" + "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() + + 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) +} + +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 -> empty, matching npm/yarn's BuildInfoModuleId() convention", func(t *testing.T) { + tempDir := t.TempDir() + manifestPath := filepath.Join(tempDir, ApmManifestName) + + assert.Equal(t, "", derivedModuleID(manifestPath)) + }) + + 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, "", derivedModuleID(manifestPath)) + }) + + 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, "", 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(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". + 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(filepath.Join(tempDir, "never-published-9.9.9.zip")) + 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", "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/checksums.go b/agent/apm/common/checksums.go new file mode 100644 index 00000000..d737a2e4 --- /dev/null +++ b/agent/apm/common/checksums.go @@ -0,0 +1,383 @@ +package apmcommon + +import ( + "encoding/json" + "fmt" + "io" + "regexp" + "strings" + "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" + servicesUtils "github.com/jfrog/jfrog-client-go/artifactory/services/utils" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +const aqlBatchSize = 30 +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"` + 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. 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 { + 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) + + checksumMap, uncached := selectCachedAndUncached(deps, cachedChecksums) + + 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 + } + + 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 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 { + if checksum, ok := cachedChecksums[dep.ID]; ok { + cached[dep.ID] = checksum + } else { + uncached = append(uncached, dep) + } + } + return cached, uncached +} + +// 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{} + } + + // Group deps by repository (since AQL queries are per-repo) + repoGroups := groupDepsByRepo(deps) + + 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]}) + } + } + + 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 + wg sync.WaitGroup + sem = make(chan struct{}, aqlWorkerCount) + errCh = make(chan error, len(batches)) + ) + + for _, batch := range batches { + wg.Add(1) + sem <- struct{}{} + go func(b aqlBatch) { + defer wg.Done() + defer func() { <-sem }() + + 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 { + errCh <- fmt.Errorf("AQL batch failed for repo '%s': %w", b.repo, err) + return + } + mu.Lock() + mapAQLResults(b.deps, results, checksumMap) + mu.Unlock() + }(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). +// 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. +// 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 "" + } + + // 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 + } + } + } + + // 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. +// 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 { + // 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("path","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{ + 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 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) { + // Build a map of (path+name) to results for matching + resultsByPathAndName := make(map[string]servicesUtils.ResultItem) + for _, r := range results { + // 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 { + // 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, + 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 new file mode 100644 index 00000000..9c071384 --- /dev/null +++ b/agent/apm/common/checksums_test.go @@ -0,0 +1,230 @@ +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" +) + +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 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}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, hasAnyChecksum(tt.checksum)) + }) + } +} + +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") +} diff --git a/agent/apm/common/dependency_resolver.go b/agent/apm/common/dependency_resolver.go new file mode 100644 index 00000000..8d1da772 --- /dev/null +++ b/agent/apm/common/dependency_resolver.go @@ -0,0 +1,186 @@ +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 concurrent `apm deps why` subprocesses (mirrors headWorkerCount). +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. A dependency gets exactly one of these, chosen by finalScope's +// 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" + 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" + 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. +// Resolves each dependency's scope/requestedBy concurrently (bounded by depsWhyWorkerCount). +func ResolveDependencies(lockfilePath string) ([]ResolvedDep, error) { + lockfile, err := LoadLockFile(lockfilePath) + if err != nil { + return nil, err + } + + workingDir := filepath.Dir(lockfilePath) + packages := lockfile.RegistryPackages() + deps := make([]ResolvedDep, len(packages)) + + 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 }() + isDirect, requestedBy := resolveDirectAndRequestedBy(workingDir, pkg.RepoURL) + deps[i] = ResolvedDep{ + ID: pkg.DepID(), + RepoURL: pkg.RepoURL, + SHA256: SHA256Hex(pkg.ResolvedHash), + ResolvedURL: pkg.ResolvedURL, + Scopes: []string{finalScope(isDirect, pkg.IsDev)}, + RequestedBy: requestedBy, + } + }(i, pkg) + } + wg.Wait() + return deps, nil +} + +// ToEntitiesDependency converts a ResolvedDep to entities.Dependency with resolved checksums. +func (dep ResolvedDep) ToEntitiesDependency(checksum entities.Checksum) entities.Dependency { + return entities.Dependency{ + Id: dep.ID, + Type: apmPackageFileExtension, + Scopes: dep.Scopes, + RequestedBy: dep.RequestedBy, + Checksum: checksum, + } +} + +// 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. +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"` +} + +// 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 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 direct", repoURL)) + return true, nil + } + + ctx, cancel := context.WithTimeout(context.Background(), depsWhyTimeout) + defer cancel() + 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 { + 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 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 direct: %s", repoURL, err)) + return true, nil + } + + if result.Package.IsDirect { + return true, 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 false, 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..25d6d276 --- /dev/null +++ b/agent/apm/common/dependency_resolver_test.go @@ -0,0 +1,163 @@ +package apmcommon + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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"}]}] + }`) + isDirect, requestedBy := parseDepsWhyOutput(out, "uday/pkg-consumer") + assert.True(t, isDirect) + 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"} + ]}] + }`) + isDirect, requestedBy := parseDepsWhyOutput(out, "uday/pkg-base") + assert.False(t, isDirect) + 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"}]} + ] + }`) + isDirect, requestedBy := parseDepsWhyOutput(out, "shared/lib") + assert.False(t, isDirect) + assert.Equal(t, [][]string{{"a/pkg"}, {"b/pkg"}}, requestedBy) +} + +func TestParseDepsWhyOutput_MalformedJSONFallsBackToDirect(t *testing.T) { + isDirect, requestedBy := parseDepsWhyOutput([]byte("not json"), "uday/pkg-base") + assert.True(t, isDirect) + 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, "dev", apmScopeDev) + 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/ +// 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/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/lockfile.go b/agent/apm/common/lockfile.go new file mode 100644 index 00000000..5833aa6f --- /dev/null +++ b/agent/apm/common/lockfile.go @@ -0,0 +1,70 @@ +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"` + 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 +// 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 /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) + } + 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..a77a6770 --- /dev/null +++ b/agent/apm/common/manifest.go @@ -0,0 +1,94 @@ +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 +// needs the declared-dependency list, only name/version/registries. +// +// 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 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 +} + +// 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 { + 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) // #nosec G304 -- path is always workingDir+ApmManifestName, constructed by the caller, never user-supplied + 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 { + parsedURL, err := url.Parse(rawURL) + if err != nil || parsedURL.Host == "" { + return "" + } + return strings.ToLower(parsedURL.Host) +} 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)) + }) + } +} diff --git a/agent/apm/common/subcommand_options.go b/agent/apm/common/subcommand_options.go new file mode 100644 index 00000000..d784befb --- /dev/null +++ b/agent/apm/common/subcommand_options.go @@ -0,0 +1,57 @@ +package apmcommon + +import ( + buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build" + "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 { + // 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, + // --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'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 +// flags either - they must be pulled out by hand. +func ExtractApmSubcommandOptions(args []string) (*ApmSubcommandOptions, error) { + rest := args + var buildName, buildNumber, module, project, serverID string + var err error + + for _, opt := range []struct { + name string + dest *string + }{ + {"build-name", &buildName}, + {"build-number", &buildNumber}, + {"module", &module}, + {"project", &project}, + {"server-id", &serverID}, + } { + rest, *opt.dest, err = coreutils.ExtractStringOptionFromArgs(rest, opt.name) + 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{ + 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 new file mode 100644 index 00000000..5f548a95 --- /dev/null +++ b/agent/apm/common/subcommand_options_test.go @@ -0,0 +1,55 @@ +package apmcommon + +import ( + "testing" + + "github.com/jfrog/jfrog-cli-artifactory/agent/common/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExtractApmSubcommandOptions_PreservesApmNativeFlags(t *testing.T) { + testutil.WithJfrogHome(t) + + // 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. + 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.ApmNativeArgs) + assert.Equal(t, "my-server", opts.ServerID) +} + +func TestExtractApmSubcommandOptions_ExtractsBuildInfoFlags(t *testing.T) { + testutil.WithJfrogHome(t) + + opts, err := ExtractApmSubcommandOptions([]string{ + "--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{"uday/pkg-base#1.0.0"}, opts.ApmNativeArgs) + + buildName, err := opts.BuildConfig.GetBuildName() + require.NoError(t, err) + assert.Equal(t, "my-build", buildName) +} + +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..9f1ba7db --- /dev/null +++ b/agent/apm/common/utils.go @@ -0,0 +1,73 @@ +package apmcommon + +import ( + "os/exec" + "strings" + + "github.com/jfrog/gofrog/version" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +const minSupportedApmVersion = "0.23.0" + +// ValidateApmPrerequisites checks that apm is installed and meets minSupportedApmVersion. +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 +} + +// GetApmVersion runs "apm --version" and parses the dotted version number out of its +// descriptive output. +func GetApmVersion() (*version.Version, error) { + 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()) + } + 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 token is two or three dot-separated numeric segments, +// e.g. "1.2" or "0.23.1". +func isDottedVersion(token string) bool { + parts := strings.Split(token, ".") + if len(parts) < 2 || len(parts) > 3 { + return false + } + for _, part := range parts { + if part == "" { + return false + } + for _, char := range part { + if char < '0' || char > '9' { + return false + } + } + } + return true +} diff --git a/agent/apm/common/utils_test.go b/agent/apm/common/utils_test.go new file mode 100644 index 00000000..81187429 --- /dev/null +++ b/agent/apm/common/utils_test.go @@ -0,0 +1,108 @@ +package apmcommon + +import ( + "testing" + + "github.com/jfrog/gofrog/version" + "github.com/stretchr/testify/assert" +) + +// 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 parseApmVersion extracts just the dotted version +// number token. +func TestParseApmVersion(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, 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)) + }) + } +} + +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, + }, + { + 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) { + installed := parseApmVersion(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..8ae90c6b 100644 --- a/agent/cli/cli.go +++ b/agent/cli/cli.go @@ -1,6 +1,7 @@ package cli import ( + apmcli "github.com/jfrog/jfrog-cli-artifactory/agent/apm/cli" 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" @@ -20,5 +21,12 @@ func GetCommands() []components.Command { Description: "Agent skill commands.", Subcommands: skillscli.GetSubCommands(), }, + { + Name: "apm", + Description: apmcli.GetDescription(), + AIDescription: apmcli.GetAIDescription(), + Subcommands: apmcli.GetSubCommands(), + Action: apmcli.RunApmPassthroughDefault, + }, } } diff --git a/agent/cli/cli_test.go b/agent/cli/cli_test.go index 1d5f4b03..f7d64861 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"}, 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..1e4e0cb5 100644 --- a/agent/common/server.go +++ b/agent/common/server.go @@ -30,11 +30,32 @@ 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) { +// 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) { 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..b32c4613 100644 --- a/artifactory/commands/repository/template.go +++ b/artifactory/commands/repository/template.go @@ -163,6 +163,7 @@ const ( 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 a3b1262f..c77490db 100644 --- a/artifactory/commands/setup/setup.go +++ b/artifactory/commands/setup/setup.go @@ -16,8 +16,9 @@ import ( bidotnet "github.com/jfrog/build-info-go/build/utils/dotnet" biutils "github.com/jfrog/build-info-go/utils" - "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/cargo" + apmcommon "github.com/jfrog/jfrog-cli-artifactory/agent/apm/common" aptcommand "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/apt" + "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/cargo" "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" @@ -106,6 +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.Apt: {location: "your apt configuration"}, project.Apk: {location: "your apk configuration"}, // configureRuby writes ~/.gemrc and ~/.bundle/config directly, always under the user's @@ -152,6 +154,7 @@ var packageManagerToRepositoryPackageType = map[project.ProjectType]string{ project.Poetry: repository.Pypi, project.Twine: repository.Pypi, project.UV: repository.Pypi, + project.Apm: repository.AgentPackages, // Nuget package managers project.Nuget: repository.Nuget, @@ -309,6 +312,8 @@ func (sc *SetupCommand) Run() (err error) { err = sc.configureMaven() case project.UV: err = sc.configureUV() + case project.Apm: + err = sc.configureAgentApm() case project.Cargo: err = sc.configureCargo() case project.Ruby: @@ -340,10 +345,17 @@ func (sc *SetupCommand) Run() (err error) { // Artifactory doesn't support as a virtual package type - a virtual-repo filter always returns zero results. const noMatchingRepositoriesErrSubstring = "no repositories were found that match" -// promptUserToSelectRepository prompts the user to select a compatible virtual repository. -// If none is found, falls back to asking the user to type an existing repository name directly. +// promptUserToSelectRepository prompts the user to select a compatible repository - virtual for +// 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) { - return sc.promptUserToSelectRepositoryFiltered(utils.Virtual.String()) + repoType := utils.Virtual.String() + if sc.packageManager == project.Apm { + repoType = utils.Local.String() + } + return sc.promptUserToSelectRepositoryFiltered(repoType) } // promptUserToSelectRepositoryFiltered prompts for a repository of the given type @@ -390,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: // @@ -965,6 +976,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) +} + // rubygemsDefaultSource is the public source that RubyGems and Bundler use by default. // It stays first in ~/.gemrc's :sources: list, and is the source mirrored to Artifactory // so that unmodified Gemfiles resolve through Artifactory. diff --git a/cliutils/flagkit/flags.go b/cliutils/flagkit/flags.go index 493a601a..53fbf66d 100644 --- a/cliutils/flagkit/flags.go +++ b/cliutils/flagkit/flags.go @@ -512,6 +512,12 @@ const ( SkillsDelete = "skills-delete" SkillsList = "skills-list" + // 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" AgentPluginsInstall = "agent-plugins-install" @@ -921,6 +927,9 @@ var commandFlags = map[string][]string{ SkillsList: { url, user, password, accessToken, serverId, repo, harness, projectDir, agentGlobal, agentFormat, agentLimit, agentSortBy, agentSortOrder, agentCheckUpdates, }, + AgentApm: { + serverId, BuildName, BuildNumber, module, Project, + }, } var flagsMap = map[string]components.Flag{ diff --git a/go.mod b/go.mod index 2a1f95a8..4c4ecdbc 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.20260827111619-bee4d60fbdc7 + github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260831061529-c6dd293bccca github.com/jfrog/jfrog-cli-evidence v0.9.0 github.com/jfrog/jfrog-client-go v1.55.1-0.20260901090904-78d68f83abec github.com/pkg/errors v0.9.1 diff --git a/go.sum b/go.sum index 5ce6d173..551d68d3 100644 --- a/go.sum +++ b/go.sum @@ -384,8 +384,10 @@ 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.20260827111619-bee4d60fbdc7 h1:4ytBkQB+iBS/KbG+a974hiZbmTith6KuWa5g0Zvw+z4= -github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260827111619-bee4d60fbdc7/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-core/v2 v2.60.1-0.20260831061529-c6dd293bccca h1:/Ox4k56Pbiow4qbkNrBOmgcnAHwIBjZOsJmS7dURJng= +github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260831061529-c6dd293bccca/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.20260901090904-78d68f83abec h1:fotFisxAbONFCpvjMniD30XbK+h92TpspLcqb258Z04=