diff --git a/apk_test.go b/apk_test.go new file mode 100644 index 000000000..971a3cb7c --- /dev/null +++ b/apk_test.go @@ -0,0 +1,1234 @@ +package main + +import ( + "crypto/sha256" + "fmt" + "io" + "os" + "os/exec" + "strings" + "testing" + + buildinfo "github.com/jfrog/build-info-go/entities" + "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/generic" + artUtils "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils" + "github.com/jfrog/jfrog-cli-core/v2/common/spec" + coreutils "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" + coretests "github.com/jfrog/jfrog-cli-core/v2/utils/tests" + clientTestUtils "github.com/jfrog/jfrog-client-go/utils/tests" + + "github.com/jfrog/jfrog-cli/inttestutils" + "github.com/jfrog/jfrog-cli/utils/tests" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ==================== Initialization ==================== + +func initApkTest(t *testing.T) { + if !*tests.TestAlpine { + t.Skip("Skipping Alpine APK test. To run Alpine APK tests add the '-test.alpine=true' option.") + } + require.True(t, isRepoExist(tests.AlpineLocalRepo), "APK test local repository doesn't exist.") + require.True(t, isRepoExist(tests.AlpineVirtualRepo), "APK test virtual repository doesn't exist.") +} + +// apkAvailable returns true when the `apk` binary is present on this machine. +// Tests that actually invoke `apk` must call t.Skip when this returns false. +func apkAvailable() bool { + _, err := exec.LookPath("apk") + return err == nil +} + +// computeFileSHA256 returns the hex-encoded SHA256 digest of the file at path. +func computeFileSHA256(t *testing.T, path string) string { + t.Helper() + f, err := os.Open(path) + require.NoError(t, err, "open file for SHA256: %s", path) + defer f.Close() + h := sha256.New() + _, err = io.Copy(h, f) + require.NoError(t, err, "compute SHA256 for: %s", path) + return fmt.Sprintf("%x", h.Sum(nil)) +} + +// ==================== jf apk add (build info collection) ==================== + +// TestApkAdd_BasicBuildInfo verifies that `jf apk add` records build-info +// dependencies for a single explicitly requested package. +func TestApkAdd_BasicBuildInfo(t *testing.T) { + initApkTest(t) + if !apkAvailable() { + t.Skip("apk binary not found — test requires Alpine Linux.") + } + + oldHomeDir, newHomeDir := prepareHomeDir(t) + defer func() { + clientTestUtils.SetEnvAndAssert(t, coreutils.HomeDir, oldHomeDir) + clientTestUtils.RemoveAllAndAssert(t, newHomeDir) + }() + + buildName := tests.AlpineBuildName + "-add-basic" + buildNumber := "1" + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + err := jfrogCli.Exec("apk", "add", "curl", + "--build-name="+buildName, "--build-number="+buildNumber, + "--repo="+tests.AlpineVirtualRepo) + if err != nil { + t.Skipf("jf apk add failed (apk system command unavailable or repo not configured): %v", err) + } + + assert.NoError(t, artifactoryCli.Exec("bp", buildName, buildNumber)) + + publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, buildName, buildNumber) + assert.NoError(t, err) + assert.True(t, found, "build-info should be published after jf apk add") + + if found { + bi := publishedBuildInfo.BuildInfo + require.Len(t, bi.Modules, 1) + assert.Equal(t, buildinfo.Alpine, bi.Modules[0].Type) + assert.GreaterOrEqual(t, len(bi.Modules[0].Dependencies), 1, + "curl should produce at least 1 dependency (curl itself + transitive)") + } + + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, buildName, artHttpDetails) +} + +// TestApkAdd_MultiplePackages verifies build-info when installing more than one +// package in a single invocation. +func TestApkAdd_MultiplePackages(t *testing.T) { + initApkTest(t) + if !apkAvailable() { + t.Skip("apk binary not found — test requires Alpine Linux.") + } + + oldHomeDir, newHomeDir := prepareHomeDir(t) + defer func() { + clientTestUtils.SetEnvAndAssert(t, coreutils.HomeDir, oldHomeDir) + clientTestUtils.RemoveAllAndAssert(t, newHomeDir) + }() + + buildName := tests.AlpineBuildName + "-add-multi" + buildNumber := "1" + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + err := jfrogCli.Exec("apk", "add", "wget", "jq", + "--build-name="+buildName, "--build-number="+buildNumber, + "--repo="+tests.AlpineVirtualRepo) + if err != nil { + t.Skipf("jf apk add failed: %v", err) + } + + assert.NoError(t, artifactoryCli.Exec("bp", buildName, buildNumber)) + + publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, buildName, buildNumber) + assert.NoError(t, err) + assert.True(t, found) + + if found && len(publishedBuildInfo.BuildInfo.Modules) > 0 { + deps := publishedBuildInfo.BuildInfo.Modules[0].Dependencies + // wget + jq each pull in multiple transitive deps; at minimum 2 direct ones + assert.GreaterOrEqual(t, len(deps), 2, + "wget + jq should produce at least 2 direct dependencies") + } + + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, buildName, artHttpDetails) +} + +// TestApkAdd_NoBuildFlags verifies that `jf apk add` without --build-name / --build-number +// still runs natively without a panic or crash. +func TestApkAdd_NoBuildFlags(t *testing.T) { + initApkTest(t) + if !apkAvailable() { + t.Skip("apk binary not found — test requires Alpine Linux.") + } + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + err := jfrogCli.Exec("apk", "add", "busybox") + if err != nil { + // busybox may already be installed; that's fine. + t.Logf("jf apk add busybox (no build flags): %v", err) + } +} + +// TestApkAdd_BuildNameOnly verifies that supplying only --build-name (no --build-number) +// does not cause a panic; build-info collection is simply skipped. +func TestApkAdd_BuildNameOnly(t *testing.T) { + initApkTest(t) + if !apkAvailable() { + t.Skip("apk binary not found — test requires Alpine Linux.") + } + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + err := jfrogCli.Exec("apk", "add", "busybox", + "--build-name=apk-name-only-test") + _ = err // command may succeed or fail — just ensure no panic +} + +// ==================== jf apk upgrade (build info collection) ==================== + +// TestApkUpgrade_BuildInfo verifies that `jf apk upgrade` records build-info. +func TestApkUpgrade_BuildInfo(t *testing.T) { + initApkTest(t) + if !apkAvailable() { + t.Skip("apk binary not found — test requires Alpine Linux.") + } + + oldHomeDir, newHomeDir := prepareHomeDir(t) + defer func() { + clientTestUtils.SetEnvAndAssert(t, coreutils.HomeDir, oldHomeDir) + clientTestUtils.RemoveAllAndAssert(t, newHomeDir) + }() + + buildName := tests.AlpineBuildName + "-upgrade" + buildNumber := "1" + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + err := jfrogCli.Exec("apk", "upgrade", + "--build-name="+buildName, "--build-number="+buildNumber, + "--repo="+tests.AlpineVirtualRepo) + if err != nil { + t.Skipf("jf apk upgrade failed (no packages to upgrade or system unavailable): %v", err) + } + + assert.NoError(t, artifactoryCli.Exec("bp", buildName, buildNumber)) + + publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, buildName, buildNumber) + assert.NoError(t, err) + assert.True(t, found, "build-info should exist after jf apk upgrade") + + if found { + require.Len(t, publishedBuildInfo.BuildInfo.Modules, 1) + assert.Equal(t, buildinfo.Alpine, publishedBuildInfo.BuildInfo.Modules[0].Type) + } + + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, buildName, artHttpDetails) +} + +// ==================== Module override ==================== + +// TestApkAdd_ModuleOverride verifies that --module overrides the module ID in build-info. +func TestApkAdd_ModuleOverride(t *testing.T) { + initApkTest(t) + if !apkAvailable() { + t.Skip("apk binary not found — test requires Alpine Linux.") + } + + oldHomeDir, newHomeDir := prepareHomeDir(t) + defer func() { + clientTestUtils.SetEnvAndAssert(t, coreutils.HomeDir, oldHomeDir) + clientTestUtils.RemoveAllAndAssert(t, newHomeDir) + }() + + buildName := tests.AlpineBuildName + "-module-override" + buildNumber := "1" + customModule := "my-custom-alpine-module" + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + err := jfrogCli.Exec("apk", "add", "curl", + "--build-name="+buildName, "--build-number="+buildNumber, + "--module="+customModule, + "--repo="+tests.AlpineVirtualRepo) + if err != nil { + t.Skipf("jf apk add failed: %v", err) + } + + assert.NoError(t, artifactoryCli.Exec("bp", buildName, buildNumber)) + + publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, buildName, buildNumber) + assert.NoError(t, err) + assert.True(t, found) + + if found && len(publishedBuildInfo.BuildInfo.Modules) > 0 { + assert.Equal(t, customModule, publishedBuildInfo.BuildInfo.Modules[0].Id, + "module ID should be overridden to the custom value") + } + + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, buildName, artHttpDetails) +} + +// ==================== Build-info JSON structure ==================== + +// TestApkAdd_BuildInfoJSON verifies the basic structure of the published build-info: +// correct name, number, started timestamp, module type, and non-empty dep IDs. +func TestApkAdd_BuildInfoJSON(t *testing.T) { + initApkTest(t) + if !apkAvailable() { + t.Skip("apk binary not found — test requires Alpine Linux.") + } + + oldHomeDir, newHomeDir := prepareHomeDir(t) + defer func() { + clientTestUtils.SetEnvAndAssert(t, coreutils.HomeDir, oldHomeDir) + clientTestUtils.RemoveAllAndAssert(t, newHomeDir) + }() + + buildName := tests.AlpineBuildName + "-bi-json" + buildNumber := "1" + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + err := jfrogCli.Exec("apk", "add", "jq", + "--build-name="+buildName, "--build-number="+buildNumber, + "--repo="+tests.AlpineVirtualRepo) + if err != nil { + t.Skipf("jf apk add failed: %v", err) + } + + assert.NoError(t, artifactoryCli.Exec("bp", buildName, buildNumber)) + + publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, buildName, buildNumber) + assert.NoError(t, err) + assert.True(t, found) + + if found { + bi := publishedBuildInfo.BuildInfo + assert.Equal(t, buildName, bi.Name) + assert.Equal(t, buildNumber, bi.Number) + assert.NotEmpty(t, bi.Started, "build-info should have a Started timestamp") + require.Len(t, bi.Modules, 1) + assert.Equal(t, buildinfo.Alpine, bi.Modules[0].Type) + for _, dep := range bi.Modules[0].Dependencies { + assert.NotEmpty(t, dep.Id, "dependency ID must not be empty") + } + } + + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, buildName, artHttpDetails) +} + +// ==================== Dependency ID format ==================== + +// TestApkAdd_DepIDFormat verifies that every dependency ID is in the "name:version" format. +func TestApkAdd_DepIDFormat(t *testing.T) { + initApkTest(t) + if !apkAvailable() { + t.Skip("apk binary not found — test requires Alpine Linux.") + } + + oldHomeDir, newHomeDir := prepareHomeDir(t) + defer func() { + clientTestUtils.SetEnvAndAssert(t, coreutils.HomeDir, oldHomeDir) + clientTestUtils.RemoveAllAndAssert(t, newHomeDir) + }() + + buildName := tests.AlpineBuildName + "-dep-id-format" + buildNumber := "1" + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + err := jfrogCli.Exec("apk", "add", "curl", + "--build-name="+buildName, "--build-number="+buildNumber, + "--repo="+tests.AlpineVirtualRepo) + if err != nil { + t.Skipf("jf apk add failed: %v", err) + } + + assert.NoError(t, artifactoryCli.Exec("bp", buildName, buildNumber)) + + publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, buildName, buildNumber) + assert.NoError(t, err) + assert.True(t, found) + + if found && len(publishedBuildInfo.BuildInfo.Modules) > 0 { + for _, dep := range publishedBuildInfo.BuildInfo.Modules[0].Dependencies { + assert.Contains(t, dep.Id, ":", + "dep ID should be 'name:version' format, got: %s", dep.Id) + } + } + + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, buildName, artHttpDetails) +} + +// ==================== Dependency scopes ==================== + +// TestApkAdd_DepScopes verifies that directly-requested packages get scope "prod" +// and transitive packages get scope "transitive". +func TestApkAdd_DepScopes(t *testing.T) { + initApkTest(t) + if !apkAvailable() { + t.Skip("apk binary not found — test requires Alpine Linux.") + } + + oldHomeDir, newHomeDir := prepareHomeDir(t) + defer func() { + clientTestUtils.SetEnvAndAssert(t, coreutils.HomeDir, oldHomeDir) + clientTestUtils.RemoveAllAndAssert(t, newHomeDir) + }() + + buildName := tests.AlpineBuildName + "-dep-scopes" + buildNumber := "1" + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + // curl brings in transitive deps (libcurl, musl, etc.) + err := jfrogCli.Exec("apk", "add", "curl", + "--build-name="+buildName, "--build-number="+buildNumber, + "--repo="+tests.AlpineVirtualRepo) + if err != nil { + t.Skipf("jf apk add failed: %v", err) + } + + assert.NoError(t, artifactoryCli.Exec("bp", buildName, buildNumber)) + + publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, buildName, buildNumber) + assert.NoError(t, err) + assert.True(t, found) + + if found && len(publishedBuildInfo.BuildInfo.Modules) > 0 { + hasProd := false + hasTransitive := false + for _, dep := range publishedBuildInfo.BuildInfo.Modules[0].Dependencies { + for _, scope := range dep.Scopes { + if scope == "prod" { + hasProd = true + } + if scope == "transitive" { + hasTransitive = true + } + } + } + assert.True(t, hasProd, "at least one dependency should have scope 'prod'") + // Transitive deps are present when curl pulls in libcurl, musl, etc. + t.Logf("hasProd=%v hasTransitive=%v (transitive depends on whether new deps were installed)", + hasProd, hasTransitive) + } + + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, buildName, artHttpDetails) +} + +// ==================== Dependency checksums ==================== + +// TestApkAdd_DepChecksums verifies that at least some dependencies have SHA256 checksums set. +// SHA1 and MD5 come from the local cache; SHA256 from the APK database. +func TestApkAdd_DepChecksums(t *testing.T) { + initApkTest(t) + if !apkAvailable() { + t.Skip("apk binary not found — test requires Alpine Linux.") + } + + oldHomeDir, newHomeDir := prepareHomeDir(t) + defer func() { + clientTestUtils.SetEnvAndAssert(t, coreutils.HomeDir, oldHomeDir) + clientTestUtils.RemoveAllAndAssert(t, newHomeDir) + }() + + buildName := tests.AlpineBuildName + "-dep-checksums" + buildNumber := "1" + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + err := jfrogCli.Exec("apk", "add", "curl", + "--build-name="+buildName, "--build-number="+buildNumber, + "--repo="+tests.AlpineVirtualRepo) + if err != nil { + t.Skipf("jf apk add failed: %v", err) + } + + assert.NoError(t, artifactoryCli.Exec("bp", buildName, buildNumber)) + + publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, buildName, buildNumber) + assert.NoError(t, err) + assert.True(t, found) + + if found && len(publishedBuildInfo.BuildInfo.Modules) > 0 { + depsWithChecksums := 0 + for _, dep := range publishedBuildInfo.BuildInfo.Modules[0].Dependencies { + if dep.Sha1 != "" || dep.Sha256 != "" { + depsWithChecksums++ + } + } + t.Logf("Dependencies with at least one checksum: %d/%d", + depsWithChecksums, len(publishedBuildInfo.BuildInfo.Modules[0].Dependencies)) + // On a machine with a populated APK cache, checksums should be present. + // We assert at least the package itself has one. + assert.Greater(t, depsWithChecksums, 0, + "at least one dep should have a checksum (sha1 or sha256)") + } + + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, buildName, artHttpDetails) +} + +// ==================== requestedBy chains ==================== + +// TestApkAdd_DepRequestedBy verifies that transitive dependencies carry non-empty RequestedBy. +func TestApkAdd_DepRequestedBy(t *testing.T) { + initApkTest(t) + if !apkAvailable() { + t.Skip("apk binary not found — test requires Alpine Linux.") + } + + oldHomeDir, newHomeDir := prepareHomeDir(t) + defer func() { + clientTestUtils.SetEnvAndAssert(t, coreutils.HomeDir, oldHomeDir) + clientTestUtils.RemoveAllAndAssert(t, newHomeDir) + }() + + buildName := tests.AlpineBuildName + "-requestedby" + buildNumber := "1" + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + // curl has libcurl as a transitive dep, which should carry RequestedBy=[["curl:..."]] + err := jfrogCli.Exec("apk", "add", "curl", + "--build-name="+buildName, "--build-number="+buildNumber, + "--repo="+tests.AlpineVirtualRepo) + if err != nil { + t.Skipf("jf apk add failed: %v", err) + } + + assert.NoError(t, artifactoryCli.Exec("bp", buildName, buildNumber)) + + publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, buildName, buildNumber) + assert.NoError(t, err) + assert.True(t, found) + + if found && len(publishedBuildInfo.BuildInfo.Modules) > 0 { + deps := publishedBuildInfo.BuildInfo.Modules[0].Dependencies + if len(deps) <= 1 { + t.Skip("only one dep installed; transitive RequestedBy cannot be validated") + } + hasTransitiveRequestedBy := false + for _, dep := range deps { + if len(dep.RequestedBy) > 0 && len(dep.RequestedBy[0]) > 0 { + hasTransitiveRequestedBy = true + // Each RequestedBy entry should be a non-empty parent ID + for _, chain := range dep.RequestedBy { + assert.NotEmpty(t, chain, "RequestedBy chain entry should not be empty for %s", dep.Id) + } + break + } + } + assert.True(t, hasTransitiveRequestedBy, + "at least one transitive dep should have RequestedBy with a parent ID") + } + + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, buildName, artHttpDetails) +} + +// ==================== Multiple builds isolation ==================== + +// TestApkAdd_MultipleBuildsIsolated verifies that two sequential `jf apk add` calls with +// different build numbers produce independent build-info records. +func TestApkAdd_MultipleBuildsIsolated(t *testing.T) { + initApkTest(t) + if !apkAvailable() { + t.Skip("apk binary not found — test requires Alpine Linux.") + } + + oldHomeDir, newHomeDir := prepareHomeDir(t) + defer func() { + clientTestUtils.SetEnvAndAssert(t, coreutils.HomeDir, oldHomeDir) + clientTestUtils.RemoveAllAndAssert(t, newHomeDir) + }() + + buildName := tests.AlpineBuildName + "-multi-isolated" + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + + err := jfrogCli.Exec("apk", "add", "curl", + "--build-name="+buildName, "--build-number=1", + "--repo="+tests.AlpineVirtualRepo) + if err != nil { + t.Skipf("jf apk add (build 1) failed: %v", err) + } + assert.NoError(t, artifactoryCli.Exec("bp", buildName, "1")) + + err = jfrogCli.Exec("apk", "add", "jq", + "--build-name="+buildName, "--build-number=2", + "--repo="+tests.AlpineVirtualRepo) + if err != nil { + t.Skipf("jf apk add (build 2) failed: %v", err) + } + assert.NoError(t, artifactoryCli.Exec("bp", buildName, "2")) + + bi1, found1, err := tests.GetBuildInfo(serverDetails, buildName, "1") + assert.NoError(t, err) + assert.True(t, found1, "build 1 should be found") + + bi2, found2, err := tests.GetBuildInfo(serverDetails, buildName, "2") + assert.NoError(t, err) + assert.True(t, found2, "build 2 should be found") + + if found1 && found2 { + assert.NotEqual(t, + bi1.BuildInfo.Modules[0].Dependencies, + bi2.BuildInfo.Modules[0].Dependencies, + "build 1 (curl) and build 2 (jq) should have different dependency sets") + } + + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, buildName, artHttpDetails) +} + +// ==================== Passthrough commands (no build info) ==================== + +// TestApkPassthroughCommands verifies that every apk sub-command that does not +// produce build-info (update, del, info, search, fetch, fix, audit, version, stats) +// is forwarded to the native apk binary without panicking. +// Each case also confirms that passing --build-name/--build-number does NOT result +// in build-info being published — these commands are read-only or destructive, not +// installation events. +func TestApkPassthroughCommands(t *testing.T) { + initApkTest(t) + if !apkAvailable() { + t.Skip("apk binary not found — test requires Alpine Linux.") + } + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + + cases := []struct { + name string + args []string + }{ + {"update", []string{"apk", "update"}}, + {"update-with-build-flags", []string{"apk", "update", "--build-name=" + tests.AlpineBuildName + "-pt", "--build-number=1"}}, + {"del", []string{"apk", "del", "curl", "--build-name=" + tests.AlpineBuildName + "-pt", "--build-number=1"}}, + {"info", []string{"apk", "info", "musl"}}, + {"search", []string{"apk", "search", "curl"}}, + {"fix", []string{"apk", "fix"}}, + {"audit", []string{"apk", "audit"}}, + {"version", []string{"apk", "version"}}, + {"stats", []string{"apk", "stats"}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // A non-zero exit is acceptable (e.g. nothing to fix, package absent); + // what must not happen is a panic or an unknown-flag error caused by + // JFrog CLI flags leaking into the native apk invocation. + err := jfrogCli.Exec(tc.args...) + if err != nil { + t.Logf("jf %v: %v (non-zero exit is acceptable for passthrough)", tc.args, err) + } + }) + } +} + +// ==================== jf apk config ==================== + +// TestApkConfig_SetsUpRepo verifies that `jf apk config` runs without error when the +// Artifactory repo key and server details are valid. +// Note: this test requires write access to /etc/apk/repositories (root on Alpine). +func TestApkConfig_SetsUpRepo(t *testing.T) { + initApkTest(t) + if !apkAvailable() { + t.Skip("apk binary not found — test requires Alpine Linux.") + } + if os.Getuid() != 0 { + t.Skip("jf apk config modifies /etc/apk/repositories and requires root.") + } + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + err := jfrogCli.Exec("apk", "config", + "--server-id=default", + "--repo="+tests.AlpineVirtualRepo) + if err != nil { + t.Logf("jf apk config: %v (Artifactory may not be reachable or missing RSA key)", err) + } +} + +// TestApkConfig_UnknownServerID verifies that jf apk config with an unknown +// --server-id returns a clear error rather than silently continuing. +func TestApkConfig_UnknownServerID(t *testing.T) { + initApkTest(t) + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + err := jfrogCli.Exec("apk", "config", + "--server-id=nonexistent-server-id-xyz", + "--repo="+tests.AlpineVirtualRepo) + assert.Error(t, err, "jf apk config with an unknown --server-id should return a clear error") +} + +// ==================== P0: package not found ==================== + +// TestApkAdd_PackageNotFound verifies that jf apk add with a package that does +// not exist in Artifactory returns a clear error and does not silently fall back +// to the public Alpine CDN. +func TestApkAdd_PackageNotFound(t *testing.T) { + initApkTest(t) + if !apkAvailable() { + t.Skip("apk binary not found — test requires Alpine Linux.") + } + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + err := jfrogCli.Exec("apk", "add", "nonexistent-pkg-xyz-jfrog-test-12345", + "--repo="+tests.AlpineVirtualRepo, + "--build-name="+tests.AlpineBuildName+"-pkg-not-found", + "--build-number=1") + assert.Error(t, err, + "jf apk add with a nonexistent package should fail, not silently succeed or fall back to CDN") +} + +// ==================== P0: checksum stored in Artifactory ==================== + +// TestApkUpload_ChecksumNotUntrusted verifies that after jf apk upload the +// artifact stored in Artifactory has a non-empty, non-"untrusted" SHA256 +// checksum. Artifactory marks artifacts as "untrusted" when upload does not +// supply X-Checksum headers — this test catches that integration gap. +func TestApkUpload_ChecksumNotUntrusted(t *testing.T) { + initApkTest(t) + + tmpDir, err := os.MkdirTemp("", "apk-chksum-stored-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer clientTestUtils.RemoveAllAndAssert(t, tmpDir) + + fakePkgInfo := "pkgname = testpkg-chksum\npkgver = 1.0.0-r0\narch = x86_64\n" + if writeErr := os.WriteFile(tmpDir+"/.PKGINFO", []byte(fakePkgInfo), 0644); writeErr != nil { + t.Fatalf("failed to write .PKGINFO: %v", writeErr) + } + apkPath := tmpDir + "/testpkg-chksum-1.0.0-r0.apk" + if tarErr := exec.Command("tar", "-czf", apkPath, "-C", tmpDir, ".PKGINFO").Run(); tarErr != nil { + t.Skipf("tar not available to build test .apk: %v", tarErr) + } + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + if uploadErr := jfrogCli.Exec("apk", "upload", apkPath, "--repo="+tests.AlpineLocalRepo); uploadErr != nil { + t.Skipf("jf apk upload failed: %v", uploadErr) + } + + // Search Artifactory for the uploaded artifact and verify the stored sha256. + searchSpec := spec.NewBuilder().Pattern(tests.AlpineLocalRepo + "/testpkg-chksum-1.0.0-r0.apk").BuildSpec() + searchCmd := generic.NewSearchCommand() + searchCmd.SetServerDetails(serverDetails).SetSpec(searchSpec) + reader, searchErr := searchCmd.Search() + require.NoError(t, searchErr, "AQL search for uploaded artifact should succeed") + defer func() { _ = reader.Close() }() + + item := new(artUtils.SearchResult) + require.NoError(t, reader.NextRecord(item), + "uploaded artifact must be found in Artifactory — jf apk upload may have failed silently") + assert.NotEmpty(t, item.Sha256, + "Artifactory must store a sha256 checksum for the uploaded .apk artifact") + assert.NotEqual(t, "untrusted", strings.ToLower(item.Sha256), + "Artifactory must not mark the .apk artifact as 'untrusted' — X-Checksum headers may be missing on upload") +} + +// ==================== jf apk upload ==================== + +// TestApkUpload_LocalApkFile verifies that `jf apk upload` can upload a real .apk file +// to Artifactory. The test creates a minimal (but valid enough) .apk archive. +func TestApkUpload_LocalApkFile(t *testing.T) { + initApkTest(t) + if !apkAvailable() { + t.Skip("apk binary not found — test requires Alpine Linux.") + } + + tmpDir, err := os.MkdirTemp("", "apk-upload-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer clientTestUtils.RemoveAllAndAssert(t, tmpDir) + + // Build a minimal .apk (tgz containing just a PKGINFO) via abuild-tar or tar. + // If abuild-tar is not available, skip gracefully. + fakePkgInfo := "pkgname = testpkg\npkgver = 1.0.0-r0\narch = x86_64\n" + pkgInfoPath := tmpDir + "/.PKGINFO" + if writeErr := os.WriteFile(pkgInfoPath, []byte(fakePkgInfo), 0644); writeErr != nil { + t.Fatalf("failed to write .PKGINFO: %v", writeErr) + } + + apkFilePath := fmt.Sprintf("%s/testpkg-1.0.0-r0.apk", tmpDir) + // Create a minimal tar.gz containing the PKGINFO + tarCmd := exec.Command("tar", "-czf", apkFilePath, "-C", tmpDir, ".PKGINFO") + if tarOut, tarErr := tarCmd.CombinedOutput(); tarErr != nil { + t.Skipf("failed to create fake .apk archive with tar: %v\n%s", tarErr, tarOut) + } + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + err = jfrogCli.Exec("apk", "upload", apkFilePath, + "--repo="+tests.AlpineLocalRepo, + "--alpine-version=3.18", + "--server-id=default") + if err != nil { + t.Logf("jf apk upload: %v (Artifactory may not accept the synthetic .apk)", err) + } +} + +// ==================== Project key ==================== + +// TestApkAdd_ProjectKey verifies that `jf apk add` with --project is passed through +// correctly; the test is lenient if the project does not exist in Artifactory. +func TestApkAdd_ProjectKey(t *testing.T) { + initApkTest(t) + if !apkAvailable() { + t.Skip("apk binary not found — test requires Alpine Linux.") + } + + oldHomeDir, newHomeDir := prepareHomeDir(t) + defer func() { + clientTestUtils.SetEnvAndAssert(t, coreutils.HomeDir, oldHomeDir) + clientTestUtils.RemoveAllAndAssert(t, newHomeDir) + }() + + buildName := tests.AlpineBuildName + "-project-key" + buildNumber := "1" + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + err := jfrogCli.Exec("apk", "add", "busybox", + "--build-name="+buildName, "--build-number="+buildNumber, + "--project=testprj", + "--repo="+tests.AlpineVirtualRepo) + if err != nil { + t.Skipf("jf apk add --project failed: %v", err) + } + + publishErr := artifactoryCli.Exec("bp", buildName, buildNumber, "--project=testprj") + if publishErr != nil { + t.Logf("build-publish with --project failed (project may not exist): %v", publishErr) + } + + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, buildName, artHttpDetails) +} + +// ==================== JFrog CLI flag stripping ==================== + +// TestApkAdd_JFlagStripping verifies that JFrog CLI flags (--build-name, --build-number, +// --repo, --server-id, etc.) are not forwarded to the native apk binary. +// If they were forwarded, apk would reject the unknown flags and exit non-zero. +func TestApkAdd_JFlagStripping(t *testing.T) { + initApkTest(t) + if !apkAvailable() { + t.Skip("apk binary not found — test requires Alpine Linux.") + } + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + // If --build-name etc. are leaked to the native apk binary it will fail with + // "ERROR: unknown option --build-name". A clean exit confirms flag stripping. + err := jfrogCli.Exec("apk", "add", "busybox", + "--build-name=flag-strip-test", + "--build-number=99", + "--repo="+tests.AlpineVirtualRepo, + "--server-id=default") + if err != nil { + // A non-zero exit that is NOT due to unknown flags is OK (package already installed). + t.Logf("jf apk add with all JF flags: %v", err) + } +} + +// ==================== Environment secret filtering ==================== + +// TestApkAdd_EnvSecretFiltering verifies that secret environment variables are not +// leaked into the build-info (e.g. JFROG_CLI_ENV_EXCLUDE works as expected). +func TestApkAdd_EnvSecretFiltering(t *testing.T) { + initApkTest(t) + if !apkAvailable() { + t.Skip("apk binary not found — test requires Alpine Linux.") + } + + setEnvCallback := clientTestUtils.SetEnvWithCallbackAndAssert(t, "MY_SECRET_TOKEN", "super-secret-value") + defer setEnvCallback() + + oldHomeDir, newHomeDir := prepareHomeDir(t) + defer func() { + clientTestUtils.SetEnvAndAssert(t, coreutils.HomeDir, oldHomeDir) + clientTestUtils.RemoveAllAndAssert(t, newHomeDir) + }() + + buildName := tests.AlpineBuildName + "-env-secrets" + buildNumber := "1" + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + err := jfrogCli.Exec("apk", "add", "curl", + "--build-name="+buildName, "--build-number="+buildNumber, + "--repo="+tests.AlpineVirtualRepo) + if err != nil { + t.Skipf("jf apk add failed: %v", err) + } + + assert.NoError(t, artifactoryCli.Exec("bp", buildName, buildNumber)) + + publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, buildName, buildNumber) + assert.NoError(t, err) + assert.True(t, found) + + if found { + // Scan all env vars captured in build-info; none should contain the secret value. + for k, v := range publishedBuildInfo.BuildInfo.Properties { + assert.NotContains(t, v, "super-secret-value", + "env var %s should not contain secret value in build-info", k) + } + } + + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, buildName, artHttpDetails) +} + +// ==================== Alpine version flag ==================== + +// TestApkAdd_AlpineVersionFlag verifies that the --alpine-version flag is accepted and +// recorded in the module ID without being passed to the native apk binary. +func TestApkAdd_AlpineVersionFlag(t *testing.T) { + initApkTest(t) + if !apkAvailable() { + t.Skip("apk binary not found — test requires Alpine Linux.") + } + + oldHomeDir, newHomeDir := prepareHomeDir(t) + defer func() { + clientTestUtils.SetEnvAndAssert(t, coreutils.HomeDir, oldHomeDir) + clientTestUtils.RemoveAllAndAssert(t, newHomeDir) + }() + + buildName := tests.AlpineBuildName + "-alpine-version" + buildNumber := "1" + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + err := jfrogCli.Exec("apk", "add", "curl", + "--build-name="+buildName, "--build-number="+buildNumber, + "--repo="+tests.AlpineVirtualRepo, + "--alpine-version=3.18") + if err != nil { + t.Skipf("jf apk add --alpine-version failed: %v", err) + } + + assert.NoError(t, artifactoryCli.Exec("bp", buildName, buildNumber)) + + publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, buildName, buildNumber) + assert.NoError(t, err) + assert.True(t, found) + + if found { + require.Len(t, publishedBuildInfo.BuildInfo.Modules, 1) + assert.Equal(t, buildinfo.Alpine, publishedBuildInfo.BuildInfo.Modules[0].Type) + } + + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, buildName, artHttpDetails) +} + +// ==================== P0: missing required flag ==================== + +// TestApkAdd_MissingRepo verifies that jf apk add without a --repo flag returns +// a clear error rather than proceeding silently. +func TestApkAdd_MissingRepo(t *testing.T) { + initApkTest(t) + if !apkAvailable() { + t.Skip("apk binary not found — test requires Alpine Linux.") + } + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + // Deliberately omit --repo. + err := jfrogCli.Exec("apk", "add", "curl", + "--build-name="+tests.AlpineBuildName+"-missing-repo", + "--build-number=1") + assert.Error(t, err, "jf apk add without --repo should return a clear error") +} + +// ==================== P0: CI/VCS properties ==================== + +// TestApkUpload_CIVCSPropertiesStamped verifies that when jf apk upload is run inside +// a CI environment (GitHub Actions), the published build info causes vcs.provider, +// vcs.org, and vcs.repo properties to be stamped on the artifact in Artifactory. +func TestApkUpload_CIVCSPropertiesStamped(t *testing.T) { + initApkTest(t) + + tmpDir, err := os.MkdirTemp("", "apk-civcs-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer clientTestUtils.RemoveAllAndAssert(t, tmpDir) + + buildName := tests.AlpineBuildName + "-civcs" + buildNumber := "1" + + // Simulate GitHub Actions environment (uses real env vars on CI, mock values locally). + cleanupEnv, actualOrg, actualRepo := tests.SetupGitHubActionsEnv(t) + defer cleanupEnv() + + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, buildName, artHttpDetails) + defer inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, buildName, artHttpDetails) + + // Build a minimal .apk file. + fakePkgInfo := "pkgname = testpkg-civcs\npkgver = 1.0.0-r0\narch = x86_64\n" + if writeErr := os.WriteFile(tmpDir+"/.PKGINFO", []byte(fakePkgInfo), 0644); writeErr != nil { + t.Fatalf("failed to write .PKGINFO: %v", writeErr) + } + apkPath := tmpDir + "/testpkg-civcs-1.0.0-r0.apk" + if tarErr := exec.Command("tar", "-czf", apkPath, "-C", tmpDir, ".PKGINFO").Run(); tarErr != nil { + t.Skipf("tar not available: %v", tarErr) + } + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + if uploadErr := jfrogCli.Exec("apk", "upload", apkPath, + "--repo="+tests.AlpineLocalRepo, + "--build-name="+buildName, "--build-number="+buildNumber); uploadErr != nil { + t.Skipf("jf apk upload failed: %v", uploadErr) + } + + require.NoError(t, artifactoryCli.Exec("bp", buildName, buildNumber)) + + // Retrieve the published build info to find the artifact path. + publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, buildName, buildNumber) + require.NoError(t, err) + require.True(t, found, "build info must be published before checking VCS properties") + require.NotEmpty(t, publishedBuildInfo.BuildInfo.Modules, "build info must contain at least one module") + require.NotEmpty(t, publishedBuildInfo.BuildInfo.Modules[0].Artifacts, "module must contain at least one artifact") + + // Use the service manager to query per-artifact properties from Artifactory. + sm, err := artUtils.CreateServiceManager(serverDetails, 3, 1000, false) + require.NoError(t, err) + + for _, module := range publishedBuildInfo.BuildInfo.Modules { + for _, artifact := range module.Artifacts { + fullPath := artifact.OriginalDeploymentRepo + "/" + artifact.Path + props, propsErr := sm.GetItemProps(fullPath) + require.NoError(t, propsErr, "failed to get properties for artifact: %s", fullPath) + require.NotNil(t, props, "properties must not be nil for artifact: %s", fullPath) + + assert.Contains(t, props.Properties, "vcs.provider", + "vcs.provider must be stamped on artifact: %s", artifact.Name) + assert.Contains(t, props.Properties["vcs.provider"], "github", + "vcs.provider must be 'github' for artifact: %s", artifact.Name) + + assert.Contains(t, props.Properties, "vcs.org", + "vcs.org must be stamped on artifact: %s", artifact.Name) + assert.Contains(t, props.Properties["vcs.org"], actualOrg, + "vcs.org must match expected org for artifact: %s", artifact.Name) + + assert.Contains(t, props.Properties, "vcs.repo", + "vcs.repo must be stamped on artifact: %s", artifact.Name) + assert.Contains(t, props.Properties["vcs.repo"], actualRepo, + "vcs.repo must match expected repo for artifact: %s", artifact.Name) + } + } +} + +// ==================== P0 gaps ==================== + +// TestApkUpload_BuildPropertiesStamped verifies that build.name, build.number, and +// build.timestamp are stamped on an uploaded .apk artifact after jf bp. +func TestApkUpload_BuildPropertiesStamped(t *testing.T) { + initApkTest(t) + + tmpDir, err := os.MkdirTemp("", "apk-props-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer clientTestUtils.RemoveAllAndAssert(t, tmpDir) + + buildName := tests.AlpineBuildName + "-props" + buildNumber := "1" + + fakePkgInfo := "pkgname = testpkg-props\npkgver = 1.0.0-r0\narch = x86_64\n" + if writeErr := os.WriteFile(tmpDir+"/.PKGINFO", []byte(fakePkgInfo), 0644); writeErr != nil { + t.Fatalf("failed to write .PKGINFO: %v", writeErr) + } + apkPath := tmpDir + "/testpkg-props-1.0.0-r0.apk" + if tarErr := exec.Command("tar", "-czf", apkPath, "-C", tmpDir, ".PKGINFO").Run(); tarErr != nil { + t.Skipf("tar not available to create test .apk: %v", tarErr) + } + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + if uploadErr := jfrogCli.Exec("apk", "upload", apkPath, + "--repo="+tests.AlpineLocalRepo, + "--build-name="+buildName, "--build-number="+buildNumber); uploadErr != nil { + t.Skipf("jf apk upload failed: %v", uploadErr) + } + + assert.NoError(t, artifactoryCli.Exec("bp", buildName, buildNumber)) + + verifyExistInArtifactoryByProps( + []string{tests.AlpineLocalRepo + "/testpkg-props-1.0.0-r0.apk"}, + tests.AlpineLocalRepo+"/testpkg-props-1.0.0-r0.apk", + fmt.Sprintf("build.name=%v;build.number=%v;build.timestamp=*", buildName, buildNumber), + t, + ) + + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, buildName, artHttpDetails) +} + +// TestApkAdd_InvalidRepo verifies that jf apk add with a nonexistent --repo +// exits with a non-zero status code and does not silently succeed. +func TestApkAdd_InvalidRepo(t *testing.T) { + initApkTest(t) + if !apkAvailable() { + t.Skip("apk binary not found — test requires Alpine Linux.") + } + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + err := jfrogCli.Exec("apk", "add", "curl", + "--repo=nonexistent-repo-xyz-123", + "--build-name="+tests.AlpineBuildName+"-invalid-repo", + "--build-number=1") + assert.Error(t, err, "jf apk add with a nonexistent repo should return an error") +} + +// ==================== P1 gaps ==================== + +// TestApkAdd_BuildNameFromEnvVars verifies that JFROG_CLI_BUILD_NAME and +// JFROG_CLI_BUILD_NUMBER environment variables trigger build-info collection +// even when --build-name / --build-number flags are not passed explicitly. +func TestApkAdd_BuildNameFromEnvVars(t *testing.T) { + initApkTest(t) + if !apkAvailable() { + t.Skip("apk binary not found — test requires Alpine Linux.") + } + + oldHomeDir, newHomeDir := prepareHomeDir(t) + defer func() { + clientTestUtils.SetEnvAndAssert(t, coreutils.HomeDir, oldHomeDir) + clientTestUtils.RemoveAllAndAssert(t, newHomeDir) + }() + + envBuildName := tests.AlpineBuildName + "-envvar" + envBuildNumber := "1" + + t.Setenv("JFROG_CLI_BUILD_NAME", envBuildName) + t.Setenv("JFROG_CLI_BUILD_NUMBER", envBuildNumber) + + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, envBuildName, artHttpDetails) + defer inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, envBuildName, artHttpDetails) + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + if err := jfrogCli.Exec("apk", "add", "curl", "--repo="+tests.AlpineVirtualRepo); err != nil { + t.Skipf("jf apk add failed: %v", err) + } + + require.NoError(t, artifactoryCli.Exec("bp", envBuildName, envBuildNumber)) + + _, found, err := tests.GetBuildInfo(serverDetails, envBuildName, envBuildNumber) + require.NoError(t, err) + assert.True(t, found, "build info should be captured from JFROG_CLI_BUILD_NAME/NUMBER env vars") +} + +// TestApkAdd_UnknownServerID verifies that supplying an unknown --server-id +// causes jf apk add to exit with a non-zero status code. +func TestApkAdd_UnknownServerID(t *testing.T) { + initApkTest(t) + if !apkAvailable() { + t.Skip("apk binary not found — test requires Alpine Linux.") + } + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + err := jfrogCli.Exec("apk", "add", "curl", + "--repo="+tests.AlpineVirtualRepo, + "--server-id=nonexistent-server-id-xyz") + assert.Error(t, err, "jf apk add with an unknown --server-id should return an error") +} + +// TestApkAdd_ArtifactoryUnreachable verifies that when Artifactory is unreachable +// jf apk add returns a clear error and does not silently fall back to the public CDN. +func TestApkAdd_ArtifactoryUnreachable(t *testing.T) { + initApkTest(t) + if !apkAvailable() { + t.Skip("apk binary not found — test requires Alpine Linux.") + } + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + err := jfrogCli.Exec("apk", "add", "curl", + "--repo="+tests.AlpineVirtualRepo, + "--server-id=nonexistent-server-id-xyz") + assert.Error(t, err, "jf apk add should fail when Artifactory is unreachable, not fall back to CDN") +} + +// TestApkAdd_VirtualRepoAggregates verifies that install through the virtual repo +// (which aggregates the local and remote repos) succeeds and records build-info. +func TestApkAdd_VirtualRepoAggregates(t *testing.T) { + initApkTest(t) + if !apkAvailable() { + t.Skip("apk binary not found — test requires Alpine Linux.") + } + + oldHomeDir, newHomeDir := prepareHomeDir(t) + defer func() { + clientTestUtils.SetEnvAndAssert(t, coreutils.HomeDir, oldHomeDir) + clientTestUtils.RemoveAllAndAssert(t, newHomeDir) + }() + + buildName := tests.AlpineBuildName + "-virtual-repo" + buildNumber := "1" + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + if err := jfrogCli.Exec("apk", "add", "busybox", + "--repo="+tests.AlpineVirtualRepo, + "--build-name="+buildName, "--build-number="+buildNumber); err != nil { + t.Skipf("jf apk add via virtual repo failed: %v", err) + } + + assert.NoError(t, artifactoryCli.Exec("bp", buildName, buildNumber)) + + publishedBuildInfo, found, err := tests.GetBuildInfo(serverDetails, buildName, buildNumber) + assert.NoError(t, err) + assert.True(t, found, "build info should be published when installing via virtual repo") + if found { + assert.GreaterOrEqual(t, len(publishedBuildInfo.BuildInfo.Modules[0].Dependencies), 1, + "at least one dependency should be recorded when installing via virtual repo") + } + + inttestutils.DeleteBuild(serverDetails.ArtifactoryUrl, buildName, artHttpDetails) +} + +// TestApkUpload_ChecksumRoundTrip verifies that the SHA256 checksum of an artifact +// downloaded from Artifactory matches the checksum of the originally uploaded file. +func TestApkUpload_ChecksumRoundTrip(t *testing.T) { + initApkTest(t) + + tmpDir, err := os.MkdirTemp("", "apk-checksum-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer clientTestUtils.RemoveAllAndAssert(t, tmpDir) + + fakePkgInfo := "pkgname = testpkg-rt\npkgver = 2.0.0-r0\narch = x86_64\n" + if writeErr := os.WriteFile(tmpDir+"/.PKGINFO", []byte(fakePkgInfo), 0644); writeErr != nil { + t.Fatalf("failed to write .PKGINFO: %v", writeErr) + } + apkPath := tmpDir + "/testpkg-rt-2.0.0-r0.apk" + if tarErr := exec.Command("tar", "-czf", apkPath, "-C", tmpDir, ".PKGINFO").Run(); tarErr != nil { + t.Skipf("tar not available to build test .apk: %v", tarErr) + } + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + if uploadErr := jfrogCli.Exec("apk", "upload", apkPath, "--repo="+tests.AlpineLocalRepo); uploadErr != nil { + t.Skipf("jf apk upload failed: %v", uploadErr) + } + + downloadDir := tmpDir + "/downloaded" + require.NoError(t, os.MkdirAll(downloadDir, 0755)) + assert.NoError(t, artifactoryCli.Exec("dl", + tests.AlpineLocalRepo+"/testpkg-rt-2.0.0-r0.apk", + downloadDir+"/", "--flat")) + + downloadedPath := downloadDir + "/testpkg-rt-2.0.0-r0.apk" + _, statErr := os.Stat(downloadedPath) + assert.NoError(t, statErr, "downloaded artifact should exist on disk") + + assert.Equal(t, + computeFileSHA256(t, apkPath), + computeFileSHA256(t, downloadedPath), + "downloaded artifact SHA256 should match the originally uploaded file") +} + +// TestApkUpload_InsecureTLS verifies --insecure-tls behaviour on jf apk upload. +// Without the flag a self-signed cert connection should fail; with it it should succeed. +// Skipped unless JFROG_CLI_TESTS_INSECURE_TLS_URL is set. +func TestApkUpload_InsecureTLS(t *testing.T) { + initApkTest(t) + + if os.Getenv("JFROG_CLI_TESTS_INSECURE_TLS_URL") == "" { + t.Skip("Skipping TLS test: set JFROG_CLI_TESTS_INSECURE_TLS_URL to an Artifactory with a self-signed cert.") + } + + tmpDir, err := os.MkdirTemp("", "apk-tls-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer clientTestUtils.RemoveAllAndAssert(t, tmpDir) + + fakePkgInfo := "pkgname = testpkg-tls\npkgver = 1.0.0-r0\narch = x86_64\n" + if writeErr := os.WriteFile(tmpDir+"/.PKGINFO", []byte(fakePkgInfo), 0644); writeErr != nil { + t.Fatalf("failed to write .PKGINFO: %v", writeErr) + } + apkPath := tmpDir + "/testpkg-tls-1.0.0-r0.apk" + if tarErr := exec.Command("tar", "-czf", apkPath, "-C", tmpDir, ".PKGINFO").Run(); tarErr != nil { + t.Skipf("tar not available: %v", tarErr) + } + + jfrogCli := coretests.NewJfrogCli(execMain, "jfrog", "") + + // Without --insecure-tls the self-signed cert should cause an error. + assert.Error(t, + jfrogCli.Exec("apk", "upload", apkPath, "--repo="+tests.AlpineLocalRepo), + "upload to self-signed Artifactory without --insecure-tls should fail") + + // With --insecure-tls it should succeed. + assert.NoError(t, + jfrogCli.Exec("apk", "upload", apkPath, "--repo="+tests.AlpineLocalRepo, "--insecure-tls"), + "upload to self-signed Artifactory with --insecure-tls should succeed") +} diff --git a/buildtools/cli.go b/buildtools/cli.go index 4daff389c..23a228369 100644 --- a/buildtools/cli.go +++ b/buildtools/cli.go @@ -3,6 +3,7 @@ package buildtools import ( "errors" "fmt" + alpinecommand "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/alpine" conancommand "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/conan" nixcommand "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/nix" "io/fs" @@ -68,6 +69,7 @@ import ( huggingfaceuploaddocs "github.com/jfrog/jfrog-cli/docs/buildtools/huggingfaceupload" mvndoc "github.com/jfrog/jfrog-cli/docs/buildtools/mvn" "github.com/jfrog/jfrog-cli/docs/buildtools/mvnconfig" + "github.com/jfrog/jfrog-cli/docs/buildtools/apkcommand" "github.com/jfrog/jfrog-cli/docs/buildtools/nix" "github.com/jfrog/jfrog-cli/docs/buildtools/npmcommand" "github.com/jfrog/jfrog-cli/docs/buildtools/npmconfig" @@ -430,6 +432,18 @@ func GetCommands() []cli.Command { Category: buildToolsCategory, Action: NixCmd, }, + { + Name: "apk", + Flags: cliutils.GetCommandFlags(cliutils.Apk), + Usage: apkcommand.GetDescription(), + HelpName: corecommon.CreateUsage("apk", apkcommand.GetDescription(), apkcommand.Usage), + UsageText: apkcommand.GetArguments(), + ArgsUsage: common.CreateEnvVars(), + SkipFlagParsing: true, + BashComplete: corecommon.CreateBashCompletionFunc("config", "upload", "add", "upgrade", "update", "fetch", "search", "del", "info"), + Category: buildToolsCategory, + Action: ApkCmd, + }, { Name: "ruby-config", Flags: cliutils.GetCommandFlags(cliutils.RubyConfig), @@ -2118,6 +2132,137 @@ func NixCmd(c *cli.Context) error { return commands.ExecWithPackageManager(cmd, "nix") } +// ApkCmd dispatches jf apk subcommands: config, upload, and native apk operations. +func ApkCmd(c *cli.Context) error { + if show, err := cliutils.ShowCmdHelpIfNeeded(c, c.Args()); show || err != nil { + return err + } + if c.NArg() < 1 { + return cliutils.WrongNumberOfArgumentsHandler(c) + } + + args := cliutils.ExtractCommand(c) + subcmd, remainingArgs := getCommandName(args) + + var ( + serverID string + err error + ) + remainingArgs, serverID, err = coreutils.ExtractServerIdFromCommand(remainingArgs) + if err != nil { + return errorutils.CheckErrorf("failed to extract --server-id: %w", err) + } + + remainingArgs, repoKey, err := coreutils.ExtractStringOptionFromArgs(remainingArgs, "repo") + if err != nil { + return errorutils.CheckErrorf("failed to extract --repo: %w", err) + } + remainingArgs, alpineVersion, err := coreutils.ExtractStringOptionFromArgs(remainingArgs, "alpine-version") + if err != nil { + return errorutils.CheckErrorf("failed to extract --alpine-version: %w", err) + } + remainingArgs, username, err := coreutils.ExtractStringOptionFromArgs(remainingArgs, "user") + if err != nil { + return errorutils.CheckErrorf("failed to extract --user: %w", err) + } + remainingArgs, password, err := coreutils.ExtractStringOptionFromArgs(remainingArgs, "password") + if err != nil { + return errorutils.CheckErrorf("failed to extract --password: %w", err) + } + + var serverDetails *coreConfig.ServerDetails + if serverID != "" { + serverDetails, err = coreConfig.GetSpecificConfig(serverID, false, false) + } else { + serverDetails, err = coreConfig.GetDefaultServerConf() + } + if err != nil { + log.Warn("No JFrog server configured — skipping credential injection. Run: jf c add") + } + + switch subcmd { + case "config": + return apkConfigSubCmd(remainingArgs, serverDetails, repoKey, alpineVersion, username, password) + case "upload": + return apkUploadSubCmd(c, remainingArgs, serverDetails, repoKey, alpineVersion, username, password) + default: + filteredArgs, buildConfiguration, err := build.ExtractBuildDetailsFromArgs(remainingArgs) + if err != nil { + return err + } + cmd := alpinecommand.NewApkCommand(subcmd). + SetArgs(filteredArgs). + SetServerDetails(serverDetails). + SetBuildConfiguration(buildConfiguration). + SetRepo(repoKey). + SetAlpineVersion(alpineVersion). + SetUsername(username). + SetPassword(password) + return commands.ExecWithPackageManager(cmd, "apk") + } +} + +// apkConfigSubCmd handles jf apk config: downloads the Artifactory RSA public key and +// optionally writes it to disk. +func apkConfigSubCmd(args []string, serverDetails *coreConfig.ServerDetails, repoKey, alpineVersion, username, password string) error { + args, branch, err := coreutils.ExtractStringOptionFromArgs(args, "branch") + if err != nil { + return errorutils.CheckErrorf("failed to extract --branch: %w", err) + } + if branch == "" { + branch = "main" + } + _, applyFlag, err := coreutils.ExtractBoolFlagFromArgs(args, "apply") + if err != nil { + return errorutils.CheckErrorf("failed to extract --apply: %w", err) + } + cmd := alpinecommand.NewApkConfigCommand(). + SetServerDetails(serverDetails). + SetRepo(repoKey). + SetAlpineVersion(alpineVersion). + SetBranch(branch). + SetUsername(username). + SetPassword(password). + SetApply(applyFlag) + return commands.ExecWithPackageManager(cmd, "apk") +} + +// apkUploadSubCmd handles jf apk upload: publishes a local .apk file to Artifactory. +func apkUploadSubCmd(c *cli.Context, args []string, serverDetails *coreConfig.ServerDetails, repoKey, alpineVersion, username, password string) error { + filteredArgs, buildConfiguration, err := build.ExtractBuildDetailsFromArgs(args) + if err != nil { + return err + } + + filteredArgs, branch, err := coreutils.ExtractStringOptionFromArgs(filteredArgs, "branch") + if err != nil { + return errorutils.CheckErrorf("failed to extract --branch: %w", err) + } + if branch == "" { + branch = "main" + } + filteredArgs, arch, err := coreutils.ExtractStringOptionFromArgs(filteredArgs, "arch") + if err != nil { + return errorutils.CheckErrorf("failed to extract --arch: %w", err) + } + + if len(filteredArgs) != 1 { + return cliutils.WrongNumberOfArgumentsHandler(c) + } + filePath := filteredArgs[0] + + cmd := alpinecommand.NewApkUploadCommand(filePath). + SetServerDetails(serverDetails). + SetBuildConfiguration(buildConfiguration). + SetRepo(repoKey). + SetAlpineVersion(alpineVersion). + SetBranch(branch). + SetArch(arch). + SetUsername(username). + SetPassword(password) + return commands.ExecWithPackageManager(cmd, "apk") +} + func pythonCmd(c *cli.Context, projectType project.ProjectType) error { if show, err := cliutils.ShowCmdHelpIfNeeded(c, c.Args()); show || err != nil { return err diff --git a/docs/buildtools/apkcommand/help.go b/docs/buildtools/apkcommand/help.go new file mode 100644 index 000000000..4989746f4 --- /dev/null +++ b/docs/buildtools/apkcommand/help.go @@ -0,0 +1,82 @@ +package apkcommand + +var Usage = []string{ + "apk config --server-id --repo --alpine-version [--branch ]", + "apk upload --repo --alpine-version [--branch ] [--arch ] [jf-flags]", + "apk add [jf-flags]", + "apk upgrade [packages...] [jf-flags]", + "apk update [jf-flags]", + "apk fetch [jf-flags]", + "apk search [jf-flags]", + "apk del ", + "apk info [packages...]", +} + +// GetDescription returns the short command description shown in jf --help. +func GetDescription() string { + return "Manage Alpine packages via Artifactory: configure trust, upload packages, and run native apk commands with credential injection and Build Info capture." +} + +// GetAIDescription returns the extended description used by AI-assisted help. +func GetAIDescription() string { + return `jf apk provides three modes of operation for working with Artifactory Alpine repositories: + +1. jf apk config — Bootstrap RSA key trust and repository URL. + Downloads the Artifactory RSA public key for the target Alpine repo, + writes it to /etc/apk/keys/, and adds the repo URL to /etc/apk/repositories. + +2. jf apk upload — Publish a local .apk file to Artifactory. + Performs a direct REST PUT (no native apk binary required). Infers arch + and package metadata from the filename. Sets Artifactory properties and + records a Build Info artifact. + +3. jf apk — Wrap native apk commands with Artifactory + credentials and Build Info capture. + Injects HTTP_AUTH into the apk subprocess environment. + For 'add' and 'upgrade', diffs the installed package list before/after + to record dependencies into a Build Info alpine module.` +} + +// GetArguments returns the argument reference shown in jf apk --help. +func GetArguments() string { + return ` apk subcommand + Subcommands: + config — bootstrap RSA key trust and repository URL for an Artifactory Alpine repo + upload — publish a local .apk file to Artifactory + add — install packages (Build Info + HTTP_AUTH) + upgrade — upgrade packages (Build Info + HTTP_AUTH) + update — refresh index (HTTP_AUTH only) + fetch — download .apk files (HTTP_AUTH only) + search — search index (HTTP_AUTH only) + del — remove packages (passthrough) + info — query package info (passthrough) + + Common flags (all subcommands): + --server-id JFrog server config ID (from jf c add). Default: active server. + --repo Artifactory Alpine repository key. + --alpine-version Alpine release, e.g. v3.20. + --user Override Artifactory username. + --password Override Artifactory password or token. + + config-specific flags: + --branch Alpine repo branch (main|community|edge|). Default: main. + + upload-specific flags: + --branch Alpine repo branch. Default: main. + --arch CPU architecture. Default: inferred from filename. + --build-name Build Info name. Env fallback: JFROG_CLI_BUILD_NAME. + --build-number Build Info number. Env fallback: JFROG_CLI_BUILD_NUMBER. + --project JFrog Projects key. Env fallback: JFROG_CLI_BUILD_PROJECT. + + add/upgrade flags: + --build-name Build Info name. + --build-number Build Info number. + --project JFrog Projects key. + + Examples: + jf apk config --server-id my-server --repo my-alpine-repo --alpine-version v3.20 + jf apk upload ./myapp-1.0.0-r0.x86_64.apk --repo my-alpine-repo --alpine-version v3.20 + jf apk add curl bash --server-id my-server --repo my-alpine-repo \ + --build-name ci-image --build-number 42 + jf apk upgrade --server-id my-server --repo my-alpine-repo` +} diff --git a/packagealias/packagealias.go b/packagealias/packagealias.go index de3f01629..8c1881f82 100644 --- a/packagealias/packagealias.go +++ b/packagealias/packagealias.go @@ -33,6 +33,7 @@ var SupportedTools = []string{ "docker", "gem", "bundle", + "apk", } // AliasMode represents how a tool should be handled diff --git a/utils/cliutils/commandsflags.go b/utils/cliutils/commandsflags.go index 4cf45994e..33dd7107e 100644 --- a/utils/cliutils/commandsflags.go +++ b/utils/cliutils/commandsflags.go @@ -89,6 +89,7 @@ const ( ConanConfig = "conan-config" Conan = "conan" Nix = "nix" + Apk = "apk" Ping = "ping" RtCurl = "rt-curl" TemplateConsumer = "template-consumer" @@ -161,6 +162,10 @@ const ( serverIdNpm = "server-id-npm" disableTokenRefresh = "disable-token-refresh" + // Alpine (apk) flag keys — repo and branch reuse the existing flag constants. + apkAlpineVersion = "alpine-version" + apkArch = "arch" + passwordStdin = "password-stdin" accessTokenStdin = "access-token-stdin" @@ -776,6 +781,14 @@ var flagsMap = map[string]cli.Flag{ Name: serverId, Usage: "[Optional] Server ID configured using the 'jf config' command. Used in native mode (JFROG_RUN_NATIVE=true) to identify the JFrog server for usage reporting.` `", }, + apkAlpineVersion: cli.StringFlag{ + Name: apkAlpineVersion, + Usage: "[Optional] Alpine release version, e.g. v3.20.` `", + }, + apkArch: cli.StringFlag{ + Name: apkArch, + Usage: "[Optional] CPU architecture override (x86_64, aarch64, armhf, …). Inferred from filename by default.` `", + }, passwordStdin: cli.BoolFlag{ Name: passwordStdin, Usage: "[Default: false] Set to true to provide the password via stdin.` `", @@ -2243,6 +2256,9 @@ var commandFlags = map[string][]string{ Nix: { BuildName, BuildNumber, module, Project, serverId, }, + Apk: { + serverId, repo, apkAlpineVersion, branch, apkArch, BuildName, BuildNumber, module, Project, user, password, + }, Stats: { XrFormat, accessToken, serverId, },