From e4edb2de1a047acbed525c7007ce99b4b852a7fa Mon Sep 17 00:00:00 2001 From: Kanishk Date: Thu, 9 Jul 2026 17:19:51 +0530 Subject: [PATCH 01/18] RTECO-1564 - Add apt command support for JFrog Artifactory - Introduced `apt` command to run apt-get commands against JFrog Artifactory Debian repositories. - Implemented `aptSetupCmd` for persistent authentication setup. - Added necessary flags and help documentation for the `apt` command. - Created test data for local, remote, and virtual APT repositories. - Updated integration tests to include APT command tests. - Enhanced CLI utility functions to support APT-specific flags and commands. --- .github/workflows/aptTests.yml | 113 ++++ apt_test.go | 611 ++++++++++++++++++++ buildtools/cli.go | 159 ++++- docs/buildtools/apt/help.go | 22 + go.mod | 4 +- go.sum | 12 +- main_test.go | 4 +- testdata/apt_local_repository_config.json | 8 + testdata/apt_remote_repository_config.json | 8 + testdata/apt_virtual_repository_config.json | 8 + utils/cliutils/commandsflags.go | 37 ++ utils/tests/consts.go | 6 + utils/tests/utils.go | 13 + 13 files changed, 994 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/aptTests.yml create mode 100644 apt_test.go create mode 100644 docs/buildtools/apt/help.go create mode 100644 testdata/apt_local_repository_config.json create mode 100644 testdata/apt_remote_repository_config.json create mode 100644 testdata/apt_virtual_repository_config.json diff --git a/.github/workflows/aptTests.yml b/.github/workflows/aptTests.yml new file mode 100644 index 000000000..c339098f1 --- /dev/null +++ b/.github/workflows/aptTests.yml @@ -0,0 +1,113 @@ +name: apt Tests + +on: + workflow_call: + workflow_dispatch: + inputs: + jfrog_url: + description: "External JFrog Platform URL. Leave empty for local Artifactory." + type: string + required: false + default: "" + jfrog_admin_token: + description: "Admin token for external JFrog Platform." + type: string + required: false + default: "" + +jobs: + apt-Tests: + name: apt tests (${{ matrix.distro.image }}, ${{ matrix.user_mode }}) + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + distro: + - image: ubuntu:20.04 + name: ubuntu-focal + dist: focal + - image: ubuntu:22.04 + name: ubuntu-jammy + dist: jammy + - image: ubuntu:24.04 + name: ubuntu-noble + dist: noble + - image: debian:11 + name: debian-bullseye + dist: bullseye + - image: debian:12 + name: debian-bookworm + dist: bookworm + user_mode: + - root + - nonroot + + container: + image: ${{ matrix.distro.image }} + options: --user root --privileged + + steps: + - name: Install prerequisites + run: | + apt-get update -y + apt-get install -y --no-install-recommends \ + git curl ca-certificates sudo wget gnupg + + - name: Checkout code + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha || github.ref }} + + - name: Setup FastCI + uses: jfrog-fastci/fastci@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + fastci_otel_token: ${{ secrets.FASTCI_TOKEN }} + + - name: Setup Go with cache + uses: jfrog/.github/actions/install-go-with-cache@main + + - name: Create non-root test user + if: matrix.user_mode == 'nonroot' + run: | + useradd -m -u 1001 -s /bin/bash testuser + echo "testuser ALL=(root) NOPASSWD: /usr/bin/apt-get, /usr/bin/apt" >> /etc/sudoers.d/testuser-apt + chmod 0440 /etc/sudoers.d/testuser-apt + chown -R testuser:testuser /etc/apt/sources.list.d + chown -R testuser:testuser /etc/apt/preferences.d + mkdir -p /etc/apt/keyrings + chown -R testuser:testuser /etc/apt/keyrings + chown -R testuser:testuser "$GITHUB_WORKSPACE" + + - name: Install local Artifactory + uses: jfrog/.github/actions/install-local-artifactory@main + with: + RTLIC: ${{ secrets.RTLIC }} + JFROG_URL: ${{ inputs.jfrog_url }} + JFROG_ADMIN_TOKEN: ${{ inputs.jfrog_admin_token }} + RT_CONNECTION_TIMEOUT_SECONDS: '1200' + + - name: Run apt tests (root) + if: matrix.user_mode == 'root' + run: >- + go test -v github.com/jfrog/jfrog-cli --timeout 0 --test.apt + ${{ env.JFROG_TESTS_IS_EXTERNAL == 'true' && format('--jfrog.url={0} --jfrog.adminToken={1}', env.JFROG_TESTS_URL, env.JFROG_TESTS_LOCAL_ACCESS_TOKEN) || '' }} + --run "TestApt" + env: + APT_TEST_DIST: ${{ matrix.distro.dist }} + + - name: Run apt tests (non-root with dir perms) + if: matrix.user_mode == 'nonroot' + run: | + su -c "go test -v github.com/jfrog/jfrog-cli --timeout 0 --test.apt \ + ${{ env.JFROG_TESTS_IS_EXTERNAL == 'true' && format('--jfrog.url={0} --jfrog.adminToken={1}', env.JFROG_TESTS_URL, env.JFROG_TESTS_LOCAL_ACCESS_TOKEN) || '' }} \ + --run 'TestApt'" testuser + env: + APT_TEST_DIST: ${{ matrix.distro.dist }} + + - name: Cleanup apt configuration + if: always() + run: | + rm -f /etc/apt/sources.list.d/jfrog-*.list + rm -f /etc/apt/preferences.d/jfrog-*.pref + rm -f /etc/apt/keyrings/jfrog-*.asc diff --git a/apt_test.go b/apt_test.go new file mode 100644 index 000000000..41f9b1263 --- /dev/null +++ b/apt_test.go @@ -0,0 +1,611 @@ +package main + +import ( + "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/jfrog/jfrog-cli/utils/tests" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ── test lifecycle ──────────────────────────────────────────────────────────── + +func initAptTest(t *testing.T) { + t.Helper() + if !*tests.TestApt { + t.Skip("Skipping apt test. To run add '-test.apt=true' option.") + } + if runtime.GOOS != "linux" { + t.Skip("apt tests only run on Linux") + } + createJfrogHomeConfig(t, true) +} + +func cleanAptTest(t *testing.T) { + t.Helper() + // Remove any files written to the system apt dirs by the test. + for _, pattern := range []string{ + "/etc/apt/sources.list.d/jfrog-cli-apt-*.list", + "/etc/apt/preferences.d/jfrog-cli-apt-*.pref", + "/etc/apt/keyrings/jfrog-cli-apt-*.asc", + } { + matches, _ := filepath.Glob(pattern) + for _, f := range matches { + _ = os.Remove(f) + } + } + tests.CleanFileSystem() +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +// aptRepo returns the virtual repo name used across apt integration tests. +func aptRepo() string { return tests.AptVirtualRepo } + +// sourcesListPath returns the expected path for a given repo+dist. +func sourcesListPath(repo, dist string) string { + return fmt.Sprintf("/etc/apt/sources.list.d/jfrog-%s-%s.list", repo, dist) +} + +func prefPath(repo, dist string) string { + return fmt.Sprintf("/etc/apt/preferences.d/jfrog-%s-%s.pref", repo, dist) +} + +func keyringPath(repo, dist string) string { + return fmt.Sprintf("/etc/apt/keyrings/jfrog-%s-%s.asc", repo, dist) +} + +// requireRoot skips the test unless running as root (euid 0). +func requireRoot(t *testing.T) { + t.Helper() + if os.Getuid() != 0 { + t.Skip("test requires root — run with sudo or in a root container") + } +} + +// requireNonRoot skips the test if running as root. +func requireNonRoot(t *testing.T) { + t.Helper() + if os.Getuid() == 0 { + t.Skip("test requires non-root user") + } +} + +// ── jf setup apt ───────────────────────────────────────────────────────────── + +// TestAptSetup_BasicPersistentSetup verifies the happy path: sources.list entry +// and pinning file are written and apt-get update succeeds. +func TestAptSetup_BasicPersistentSetup(t *testing.T) { + initAptTest(t) + requireRoot(t) + defer cleanAptTest(t) + + repo := aptRepo() + runJfrogCli(t, "setup", "apt", + "--repo="+repo, + "--dist=noble", + "--component=main", + "--trusted", + ) + + assert.FileExists(t, sourcesListPath(repo, "noble")) + assert.FileExists(t, prefPath(repo, "noble")) + + content, err := os.ReadFile(sourcesListPath(repo, "noble")) + require.NoError(t, err) + assert.Contains(t, string(content), "[trusted=yes]") + assert.Contains(t, string(content), repo) + assert.Contains(t, string(content), "noble main") +} + +// TestAptSetup_Idempotent verifies re-running with the same args does not +// produce duplicate entries or errors. +func TestAptSetup_Idempotent(t *testing.T) { + initAptTest(t) + requireRoot(t) + defer cleanAptTest(t) + + repo := aptRepo() + args := []string{"setup", "apt", "--repo=" + repo, "--dist=noble", "--trusted"} + runJfrogCli(t, args...) + runJfrogCli(t, args...) // second run — must not error + + content, err := os.ReadFile(sourcesListPath(repo, "noble")) + require.NoError(t, err) + // file should contain exactly one deb line, not duplicated + count := 0 + for _, line := range splitLines(string(content)) { + if len(line) > 0 { + count++ + } + } + assert.Equal(t, 1, count, "sources.list should contain exactly one entry") +} + +// TestAptSetup_MultipleComponents verifies space-separated components work. +func TestAptSetup_MultipleComponents(t *testing.T) { + initAptTest(t) + requireRoot(t) + defer cleanAptTest(t) + + repo := aptRepo() + runJfrogCli(t, "setup", "apt", + "--repo="+repo, + "--dist=noble", + "--component=main contrib non-free", + "--trusted", + ) + + content, err := os.ReadFile(sourcesListPath(repo, "noble")) + require.NoError(t, err) + assert.Contains(t, string(content), "main contrib non-free") +} + +// TestAptSetup_PinningFile verifies the .pref file has correct priority. +func TestAptSetup_PinningFile(t *testing.T) { + initAptTest(t) + requireRoot(t) + defer cleanAptTest(t) + + repo := aptRepo() + runJfrogCli(t, "setup", "apt", "--repo="+repo, "--dist=noble", "--trusted") + + pref, err := os.ReadFile(prefPath(repo, "noble")) + require.NoError(t, err) + assert.Contains(t, string(pref), "Pin-Priority: 1001") +} + +// TestAptSetup_TrustedFlag verifies --trusted injects [trusted=yes]. +func TestAptSetup_TrustedFlag(t *testing.T) { + initAptTest(t) + requireRoot(t) + defer cleanAptTest(t) + + repo := aptRepo() + runJfrogCli(t, "setup", "apt", "--repo="+repo, "--dist=noble", "--trusted") + + content, err := os.ReadFile(sourcesListPath(repo, "noble")) + require.NoError(t, err) + assert.Contains(t, string(content), "[trusted=yes]", "trusted flag must produce [trusted=yes] in sources line") +} + +// TestAptSetup_ImportKey verifies --import-key fetches and installs the GPG key. +// Skipped when the Artifactory repo has no GPG key configured. +func TestAptSetup_ImportKey(t *testing.T) { + initAptTest(t) + requireRoot(t) + defer cleanAptTest(t) + + repo := aptRepo() + err := runJfrogCliWithoutAssertion("setup", "apt", "--repo="+repo, "--dist=noble", "--import-key") + if err != nil { + t.Skipf("Skipping: Artifactory repo %q has no GPG key configured: %v", repo, err) + } + + // keyring file written + assert.FileExists(t, keyringPath(repo, "noble")) + + // sources line uses signed-by, not trusted=yes + content, err := os.ReadFile(sourcesListPath(repo, "noble")) + require.NoError(t, err) + assert.Contains(t, string(content), "signed-by=") + assert.NotContains(t, string(content), "trusted=yes") +} + +// TestAptSetup_TrustedAndImportKeyMutuallyExclusive verifies both flags together error. +func TestAptSetup_TrustedAndImportKeyMutuallyExclusive(t *testing.T) { + initAptTest(t) + requireRoot(t) + defer cleanAptTest(t) + + err := runJfrogCliWithoutAssertion("setup", "apt", + "--repo="+aptRepo(), + "--dist=noble", + "--trusted", + "--import-key", + ) + assert.Error(t, err, "combining --trusted and --import-key must return an error") +} + +// TestAptSetup_MissingRepo verifies --repo is required. +func TestAptSetup_MissingRepo(t *testing.T) { + initAptTest(t) + defer cleanAptTest(t) + + err := runJfrogCliWithoutAssertion("setup", "apt", "--dist=noble") + assert.Error(t, err) +} + +// TestAptSetup_MissingDist verifies --dist is required. +func TestAptSetup_MissingDist(t *testing.T) { + initAptTest(t) + defer cleanAptTest(t) + + err := runJfrogCliWithoutAssertion("setup", "apt", "--repo="+aptRepo()) + assert.Error(t, err) +} + +// TestAptSetup_NonRootPermissionDenied verifies non-root without perms gets a clear error. +func TestAptSetup_NonRootPermissionDenied(t *testing.T) { + initAptTest(t) + requireNonRoot(t) + + err := runJfrogCliWithoutAssertion("setup", "apt", + "--repo="+aptRepo(), + "--dist=noble", + "--trusted", + ) + require.Error(t, err, "non-root without perms must fail") + assert.Contains(t, err.Error(), "sudo", "error message must suggest sudo") +} + +// TestAptSetup_NonRootWithPermission verifies a non-root user who owns the apt +// dirs can run setup without sudo. +func TestAptSetup_NonRootWithPermission(t *testing.T) { + initAptTest(t) + requireNonRoot(t) + + // Grant write access to apt dirs for current user (requires prior sudo setup in CI). + dirs := []string{ + "/etc/apt/sources.list.d", + "/etc/apt/preferences.d", + "/etc/apt/keyrings", + } + for _, dir := range dirs { + info, err := os.Stat(dir) + if err != nil || !isWritable(info) { + t.Skipf("directory %s not writable by current user — skip", dir) + } + } + defer cleanAptTest(t) + + runJfrogCli(t, "setup", "apt", + "--repo="+aptRepo(), + "--dist=noble", + "--trusted", + ) + assert.FileExists(t, sourcesListPath(aptRepo(), "noble")) +} + +// ── jf setup apt --remove ───────────────────────────────────────────────────── + +// TestAptSetupRemove_RemovesAllFiles verifies --remove cleans all managed files. +func TestAptSetupRemove_RemovesAllFiles(t *testing.T) { + initAptTest(t) + requireRoot(t) + defer cleanAptTest(t) + + repo := aptRepo() + runJfrogCli(t, "setup", "apt", "--repo="+repo, "--dist=noble", "--trusted") + require.FileExists(t, sourcesListPath(repo, "noble")) + + runJfrogCli(t, "setup", "apt", "--remove") + + assert.NoFileExists(t, sourcesListPath(repo, "noble")) + assert.NoFileExists(t, prefPath(repo, "noble")) +} + +// TestAptSetupRemove_DistFilteredRemoval verifies --remove --dist only removes +// files for the specified distribution. +func TestAptSetupRemove_DistFilteredRemoval(t *testing.T) { + initAptTest(t) + requireRoot(t) + defer cleanAptTest(t) + + repo := aptRepo() + // Set up two dists. + runJfrogCli(t, "setup", "apt", "--repo="+repo, "--dist=noble", "--trusted") + runJfrogCli(t, "setup", "apt", "--repo="+repo, "--dist=jammy", "--trusted") + + // Remove only noble. + runJfrogCli(t, "setup", "apt", "--remove", "--dist=noble") + + assert.NoFileExists(t, sourcesListPath(repo, "noble"), "noble must be removed") + assert.FileExists(t, sourcesListPath(repo, "jammy"), "jammy must survive") +} + +// TestAptSetupRemove_Idempotent verifies --remove on empty dir does not error. +func TestAptSetupRemove_Idempotent(t *testing.T) { + initAptTest(t) + requireRoot(t) + defer cleanAptTest(t) + + assert.NoError(t, runJfrogCliWithoutAssertion("setup", "apt", "--remove")) +} + +// ── jf apt install (on-the-fly) ─────────────────────────────────────────────── + +// TestAptInstall_OnTheFlyInstall verifies curl can be installed via on-the-fly auth +// and that the install came from Artifactory (not a system source). +func TestAptInstall_OnTheFlyInstall(t *testing.T) { + initAptTest(t) + requireRoot(t) + defer cleanAptTest(t) + + runJfrogCli(t, "apt", "install", "-y", "curl", + "--repo="+aptRepo(), + "--dist=noble", + "--trusted", + ) + + _, err := os.Stat("/usr/bin/curl") + assert.NoError(t, err, "curl must be installed after jf apt install") + + assertInstalledFromArtifactory(t) +} + +// TestAptInstall_SkipLoginUsesSystemConfig verifies --skip-login bypasses auth injection: +// no temp sources.list is written and no jfrog-* files appear in system apt dirs. +func TestAptInstall_SkipLoginUsesSystemConfig(t *testing.T) { + initAptTest(t) + requireRoot(t) + defer cleanAptTest(t) + + tmpGlob := filepath.Join(os.TempDir(), "jfrog-apt-*") + + before, _ := filepath.Glob(tmpGlob) + beforeSet := make(map[string]bool, len(before)) + for _, f := range before { + beforeSet[f] = true + } + + // --skip-login should bypass auth injection entirely; outcome doesn't matter. + _ = runJfrogCliWithoutAssertion("apt", "install", "-y", "curl", "--skip-login") + + // No new jfrog-apt-* temp files must have been created. + after, _ := filepath.Glob(tmpGlob) + var newFiles []string + for _, f := range after { + if !beforeSet[f] { + newFiles = append(newFiles, f) + } + } + assert.Empty(t, newFiles, "--skip-login must not create temp sources.list files: %v", newFiles) + + // No persistent jfrog-* sources files must have been written. + sysFiles, _ := filepath.Glob("/etc/apt/sources.list.d/jfrog-*.list") + assert.Empty(t, sysFiles, "--skip-login must not write persistent sources.list entries") +} + +// TestAptInstall_MissingRepoAndDist verifies warning path (no auth injection). +func TestAptInstall_MissingRepoAndDist(t *testing.T) { + initAptTest(t) + defer cleanAptTest(t) + + // Should not error — falls back to unauthenticated apt (warn + passthrough). + // We don't assert success because package resolution depends on system config. + _ = runJfrogCliWithoutAssertion("apt", "show", "curl") +} + +// TestAptInstall_TrustedFlag verifies --trusted injects [trusted=yes] into the +// temporary sources.list used for on-the-fly auth. +func TestAptInstall_TrustedFlag(t *testing.T) { + initAptTest(t) + requireRoot(t) + defer cleanAptTest(t) + + // Capture the temp sources.list content while apt-get is running. + content, cancel := captureTempSourcesList(t) + defer cancel() + + runJfrogCli(t, "apt", "install", "--dry-run", "-y", "curl", + "--repo="+aptRepo(), + "--dist=noble", + "--trusted", + ) + + select { + case src := <-content: + assert.Contains(t, src, "[trusted=yes]", "temp sources.list must contain [trusted=yes]") + assert.Contains(t, src, aptRepo(), "temp sources.list must reference the Artifactory repo") + case <-time.After(10 * time.Second): + t.Error("temp sources.list was not created or deleted before it could be read") + } +} + +// TestAptInstall_AptCacheDispatch verifies apt-cache is dispatched without auth injection. +func TestAptInstall_AptCacheDispatch(t *testing.T) { + initAptTest(t) + defer cleanAptTest(t) + + // apt-cache show doesn't need auth + runJfrogCli(t, "apt", "apt-cache", "show", "base-files") +} + +// TestAptInstall_PackageNotFound verifies a 404 in Artifactory returns an error. +func TestAptInstall_PackageNotFound(t *testing.T) { + initAptTest(t) + requireRoot(t) + defer cleanAptTest(t) + + err := runJfrogCliWithoutAssertion("apt", "install", "-y", "jfrog-nonexistent-package-xyz", + "--repo="+aptRepo(), + "--dist=noble", + "--trusted", + ) + assert.Error(t, err, "installing a nonexistent package must return an error") +} + +// TestAptSetupThenNativeInstall verifies that after 'jf setup apt', a plain +// 'apt-get install' installs the package from Artifactory (not a system mirror) +// because the pinning file gives the Artifactory source Pin-Priority: 1001. +func TestAptSetupThenNativeInstall(t *testing.T) { + initAptTest(t) + requireRoot(t) + defer cleanAptTest(t) + + repo := aptRepo() + dist := "noble" + + // Persistent setup — writes sources.list + Pin-Priority: 1001 pinning file. + runJfrogCli(t, "setup", "apt", + "--repo="+repo, + "--dist="+dist, + "--trusted", + ) + require.FileExists(t, sourcesListPath(repo, dist)) + require.FileExists(t, prefPath(repo, dist)) + + // Native apt-get install — no jf wrapper. + // --allow-downgrades is needed when the Artifactory remote has an older version + // than the container's pre-installed one; the test still proves the pin is effective. + out, err := exec.Command("apt-get", "install", "-y", "--allow-downgrades", "curl").CombinedOutput() + require.NoError(t, err, "native apt-get install failed: %s", out) + + assertPersistentInstallFromArtifactory(t, "curl", *tests.JfrogUrl) +} + +// ── distribution matrix ─────────────────────────────────────────────────────── + +// TestAptSetup_DistributionMatrix runs setup across multiple dist values. +// In CI this is driven by the container image; here we parametrize the dist string. +func TestAptSetup_DistributionMatrix(t *testing.T) { + initAptTest(t) + requireRoot(t) + + dists := []string{"noble", "jammy", "focal", "bookworm", "bullseye"} + for _, dist := range dists { + dist := dist + t.Run(dist, func(t *testing.T) { + defer func() { + _ = runJfrogCliWithoutAssertion("setup", "apt", "--remove", "--dist="+dist) + }() + + err := runJfrogCliWithoutAssertion("setup", "apt", + "--repo="+aptRepo(), + "--dist="+dist, + "--trusted", + ) + if err != nil && strings.Contains(err.Error(), "apt-get update failed") { + t.Skipf("dist %q not available in this Artifactory remote repo — skipping", dist) + } + require.NoError(t, err) + assert.FileExists(t, sourcesListPath(aptRepo(), dist)) + }) + } +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +// captureTempSourcesList starts a background goroutine that polls /tmp for a +// jfrog-apt-* file created by WriteTempSourcesList, reads its content, and sends +// it on the returned channel. The caller must defer the cancel func. +// This lets tests inspect the on-the-fly sources.list before the defer in +// AptCommand.Run() removes it. +func captureTempSourcesList(t *testing.T) (<-chan string, func()) { + t.Helper() + tmpGlob := filepath.Join(os.TempDir(), "jfrog-apt-*") + + existing, _ := filepath.Glob(tmpGlob) + seen := make(map[string]bool, len(existing)) + for _, f := range existing { + seen[f] = true + } + + ch := make(chan string, 1) + done := make(chan struct{}) + go func() { + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-done: + return + case <-ticker.C: + matches, _ := filepath.Glob(tmpGlob) + for _, m := range matches { + if seen[m] { + continue + } + content, err := os.ReadFile(m) + if err != nil || len(content) == 0 { + continue + } + seen[m] = true + select { + case ch <- string(content): + default: + } + return // captured what we need + } + } + } + }() + return ch, func() { close(done) } +} + +// assertInstalledFromArtifactory reads /var/log/apt/history.log and verifies the +// most recent apt commandline used on-the-fly Artifactory auth (Dir::Etc::sourcelist= +// pointing to a jfrog temp file and Dir::Etc::sourceparts=- to block other sources). +func assertInstalledFromArtifactory(t *testing.T) { + t.Helper() + data, err := os.ReadFile("/var/log/apt/history.log") + if err != nil { + t.Logf("Warning: cannot read /var/log/apt/history.log: %v — skipping Artifactory source check", err) + return + } + s := string(data) + idx := strings.LastIndex(s, "\nCommandline:") + if idx == -1 { + idx = strings.Index(s, "Commandline:") + } + require.NotEqual(t, -1, idx, "no Commandline entry found in apt history log") + line := s[idx+1:] + if end := strings.IndexByte(line, '\n'); end != -1 { + line = line[:end] + } + assert.Contains(t, line, "Dir::Etc::sourcelist=", "apt must have used on-the-fly Artifactory source") + assert.Contains(t, line, "Dir::Etc::sourceparts=-", "apt must have disabled system sources during install") +} + +// assertPersistentInstallFromArtifactory verifies that pkg's installed version +// was sourced from the Artifactory instance identified by artURL. +// It runs 'apt-cache policy ' and checks that the line under the installed +// version (marked ***) contains the Artifactory host. +func assertPersistentInstallFromArtifactory(t *testing.T, pkg, artURL string) { + t.Helper() + out, err := exec.Command("apt-cache", "policy", pkg).Output() + require.NoError(t, err, "apt-cache policy failed") + + u, _ := url.Parse(artURL) + artHost := u.Hostname() + + lines := strings.Split(string(out), "\n") + for i, line := range lines { + if strings.Contains(line, "***") { + // The source URL appears on one of the following indented lines. + for j := i + 1; j < len(lines) && j <= i+3; j++ { + if strings.Contains(lines[j], artHost) { + return // found — Artifactory was the source + } + } + t.Errorf("installed version of %q not sourced from Artifactory (%s).\napt-cache policy output:\n%s", pkg, artURL, out) + return + } + } + t.Errorf("%q does not appear to be installed; apt-cache policy output:\n%s", pkg, out) +} + +func isWritable(info os.FileInfo) bool { + mode := info.Mode() + return mode&0200 != 0 +} + +func splitLines(text string) []string { + var lines []string + for _, l := range strings.Split(text, "\n") { + l = strings.TrimSpace(l) + if l != "" && l[0] != '#' { + lines = append(lines, l) + } + } + return lines +} diff --git a/buildtools/cli.go b/buildtools/cli.go index 4daff389c..6b7dbe61a 100644 --- a/buildtools/cli.go +++ b/buildtools/cli.go @@ -3,6 +3,7 @@ package buildtools import ( "errors" "fmt" + aptcommand "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/apt" 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" + aptdocs "github.com/jfrog/jfrog-cli/docs/buildtools/apt" "github.com/jfrog/jfrog-cli/docs/buildtools/nix" "github.com/jfrog/jfrog-cli/docs/buildtools/npmcommand" "github.com/jfrog/jfrog-cli/docs/buildtools/npmconfig" @@ -87,6 +89,7 @@ import ( "github.com/jfrog/jfrog-cli/docs/common" "github.com/jfrog/jfrog-cli/utils/buildinfo" "github.com/jfrog/jfrog-cli/utils/cliutils" + "github.com/jfrog/jfrog-client-go/artifactory/services" "github.com/jfrog/jfrog-client-go/utils/errorutils" "github.com/jfrog/jfrog-client-go/utils/log" "github.com/urfave/cli" @@ -430,6 +433,19 @@ func GetCommands() []cli.Command { Category: buildToolsCategory, Action: NixCmd, }, + { + Name: "apt", + Aliases: []string{"apt-get"}, + Flags: cliutils.GetCommandFlags(cliutils.Apt), + Usage: aptdocs.GetDescription(), + HelpName: corecommon.CreateUsage("apt", aptdocs.GetDescription(), aptdocs.Usage), + UsageText: aptdocs.GetArguments(), + ArgsUsage: common.CreateEnvVars(), + SkipFlagParsing: true, + BashComplete: corecommon.CreateBashCompletionFunc(), + Category: buildToolsCategory, + Action: AptCmd, + }, { Name: "ruby-config", Flags: cliutils.GetCommandFlags(cliutils.RubyConfig), @@ -1801,8 +1817,14 @@ func setupCmd(c *cli.Context) (err error) { if c.NArg() > 1 { return cliutils.WrongNumberOfArgumentsHandler(c) } - var packageManager project.ProjectType packageManagerStr := c.Args().Get(0) + + // Apt requires dist+component and has its own setup path. + if packageManagerStr == "apt" { + return aptSetupCmd(c) + } + + var packageManager project.ProjectType // If the package manager was provided as an argument, validate it. if packageManagerStr != "" { packageManager = project.FromString(packageManagerStr) @@ -2118,6 +2140,141 @@ func NixCmd(c *cli.Context) error { return commands.ExecWithPackageManager(cmd, "nix") } +// AptCmd runs apt-get/apt-cache commands with on-the-fly JFrog Artifactory authentication. +// +// Authentication is injected via a temporary sources.list file (D3 in design doc) unless +// --skip-login is set, in which case the system's existing sources.list is used. +func AptCmd(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) + + // Extract JFrog-specific flags before passing remaining args to apt-get + // (SkipFlagParsing=true means urfave/cli hands them through untouched). + var serverID string + var err error + args, serverID, err = coreutils.ExtractServerIdFromCommand(args) + if err != nil { + return fmt.Errorf("failed to extract server ID: %w", err) + } + args, skipLogin, err := coreutils.ExtractSkipLoginFromArgs(args) + if err != nil { + return err + } + args, repoName, err := coreutils.ExtractStringOptionFromArgs(args, "repo") + if err != nil { + return err + } + args, dist, err := coreutils.ExtractStringOptionFromArgs(args, "dist") + if err != nil { + return err + } + args, component, err := coreutils.ExtractStringOptionFromArgs(args, "component") + if err != nil { + return err + } + args, trusted, err := coreutils.ExtractBoolFlagFromArgs(args, "trusted") + if err != nil { + return err + } + // Strip build flags so they aren't passed through to apt-get. Build-info + // collection is out of scope for the auth flow. + filteredArgs, _, err := build.ExtractBuildDetailsFromArgs(args) + if err != nil { + return err + } + + // Resolve server details. Fail fast on explicit --server-id; fall through + // without auth injection when no default is configured (matches --skip-login UX). + var serverDetails *coreConfig.ServerDetails + if serverID != "" { + serverDetails, err = coreConfig.GetSpecificConfig(serverID, false, false) + if err != nil { + return fmt.Errorf("could not load server configuration for '%s': %w", serverID, err) + } + } else { + serverDetails, err = coreConfig.GetDefaultServerConf() + if err != nil { + log.Debug("No default server configuration found — auth injection skipped: " + err.Error()) + } + } + + cmd := aptcommand.NewAptCommand(). + SetArgs(filteredArgs). + SetSkipLogin(skipLogin). + SetTrusted(trusted). + SetRepoName(repoName). + SetDist(dist). + SetComponent(component) + if serverDetails != nil { + cmd.SetServerDetails(serverDetails) + } + + return commands.ExecWithPackageManager(cmd, "apt") +} + +// aptSetupCmd handles 'jf setup apt' — writes a persistent sources.list entry. +func aptSetupCmd(c *cli.Context) error { + // --remove only needs root (enforced in Run); skip server/repo validation. + if c.Bool("remove") { + cmd := aptcommand.NewAptSetupCommand(). + SetDist(c.String("dist")). + SetRemove(true) + return commands.ExecWithPackageManager(cmd, "apt") + } + + artDetails, err := cliutils.CreateArtifactoryDetailsByFlags(c) + if err != nil { + return err + } + + repoName := c.String("repo") + if repoName == "" { + if !log.IsStdOutTerminal() { + return fmt.Errorf("--repo is required (non-interactive mode)") + } + // Interactive: prompt user to select a virtual debian repository. + repoName, err = utils.SelectRepositoryInteractively( + artDetails, + services.RepositoriesFilterParams{ + RepoType: utils.Virtual.String(), + PackageType: "debian", + }, + "To configure apt, select a virtual debian repository:") + if err != nil { + return err + } + } else { + // Fail fast on a bad repo name instead of writing an unusable source (matches setupCmd). + if err = validateRepoExists(repoName, artDetails); err != nil { + return err + } + } + + dist := c.String("dist") + if dist == "" { + if !log.IsStdOutTerminal() { + return fmt.Errorf("--dist is required (non-interactive mode)") + } + dist = ioutils.AskString("", "Distribution name (e.g. noble, jammy, bookworm):", false, false) + } + + cmd := aptcommand.NewAptSetupCommand(). + SetServerDetails(artDetails). + SetRepoName(repoName). + SetDist(dist). + SetComponent(c.String("component")). + SetTrusted(c.Bool("trusted")). + SetImportKey(c.Bool("import-key")) + + return commands.ExecWithPackageManager(cmd, "apt") +} + 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/apt/help.go b/docs/buildtools/apt/help.go new file mode 100644 index 000000000..489a37bb0 --- /dev/null +++ b/docs/buildtools/apt/help.go @@ -0,0 +1,22 @@ +package apt + +var Usage = []string{"apt [command options]"} + +func GetDescription() string { + return "Run apt-get commands against a JFrog Artifactory Debian repository." +} + +func GetArguments() string { + return ` apt native-command + Wraps apt-get/apt-cache/dpkg-query commands with JFrog Artifactory + authentication. Credentials are injected via a temporary sources.list + file that is removed after the command completes. + + Examples: + - jf apt install curl --repo=ci-debian-local --dist=bookworm + - jf apt install curl vim --repo=ci-debian-local --dist=bookworm --component=main + - jf apt install curl --skip-login (uses existing sources.list auth) + + Setup (persistent authentication): + jf setup apt --repo ci-debian-local --dist bookworm --component main` +} diff --git a/go.mod b/go.mod index 5a36cccc5..baa37465b 100644 --- a/go.mod +++ b/go.mod @@ -242,12 +242,12 @@ require ( sigs.k8s.io/yaml v1.6.0 // indirect ) -// replace github.com/jfrog/jfrog-cli-artifactory => github.com/jfrog/jfrog-cli-artifactory main +replace github.com/jfrog/jfrog-cli-artifactory => github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260709114056-10a9adef5b17 //replace github.com/gfleury/go-bitbucket-v1 => github.com/gfleury/go-bitbucket-v1 v0.0.0-20230825095122-9bc1711434ab //replace github.com/ktrysmt/go-bitbucket => github.com/ktrysmt/go-bitbucket v0.9.80 -// replace github.com/jfrog/jfrog-cli-core/v2 => github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260604085947-7c110b77b4b4 +replace github.com/jfrog/jfrog-cli-core/v2 => github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260709111737-f804ba105004 //replace github.com/jfrog/jfrog-client-go => github.com/jfrog/jfrog-client-go v1.54.2-0.20251007084958-5eeaa42c31a6 diff --git a/go.sum b/go.sum index 8918acd1e..15ec916cb 100644 --- a/go.sum +++ b/go.sum @@ -404,12 +404,12 @@ 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-apps-config v1.0.1 h1:mtv6k7g8A8BVhlHGlSveapqf4mJfonwvXYLipdsOFMY= github.com/jfrog/jfrog-apps-config v1.0.1/go.mod h1:8AIIr1oY9JuH5dylz2S6f8Ym2MaadPLR6noCBO4C22w= -github.com/jfrog/jfrog-cli-application v1.0.2-0.20260707110954-b31a04f5ce6c h1:5LZKsRZpwXolMxYwC9f82swfF7UcXQMUZcnZIl4y+Zw= -github.com/jfrog/jfrog-cli-application v1.0.2-0.20260707110954-b31a04f5ce6c/go.mod h1:p8yLtbmCxxQucIbLZKnWu0F+EDtj6NLXbRQCEK/nb6o= -github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260708065639-7c53c506fbcf h1:bj+nN5obLG+O/52ATMT1JzVNBqskeOwliVjEijayPv0= -github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260708065639-7c53c506fbcf/go.mod h1:VqV0Bed11HoBlugAEGa3RumbwnDVslEf0gKocTzLs9s= -github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260624085155-5ba797de2616 h1:bioFXGzf3pF2qnC3LZD1S1saWiHSekL4vdsDSWksj/4= -github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260624085155-5ba797de2616/go.mod h1:9R90mhbczGXwW5EGlDs7F08ejQU/xdoDhYHMvzBiqgE= +github.com/jfrog/jfrog-cli-application v1.0.2-0.20260621072921-cadb78770a3e h1:jUfQzLCVbUazw7FEXf3+57vQheDSHa/Px/Gp4pf/sNI= +github.com/jfrog/jfrog-cli-application v1.0.2-0.20260621072921-cadb78770a3e/go.mod h1:p8yLtbmCxxQucIbLZKnWu0F+EDtj6NLXbRQCEK/nb6o= +github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260709114056-10a9adef5b17 h1:rNahL4i3j0Uwsr7eT/4rtehQt+iLaoyr5SPcJ21zsEI= +github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260709114056-10a9adef5b17/go.mod h1:xhPEU1u8+QZVgZwH2vMghDvBxky60zMAbBGgillQNow= +github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260709111737-f804ba105004 h1:x97HlYSY2ylZGbHyW9Pq+Meao8Li2fTW1tENZ9nmhLU= +github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260709111737-f804ba105004/go.mod h1:9R90mhbczGXwW5EGlDs7F08ejQU/xdoDhYHMvzBiqgE= github.com/jfrog/jfrog-cli-evidence v0.9.5-0.20260618135203-4d2bdd4ee35f h1:MV4BATdkEoUYJmdPDvaB9EBb8JQZg28n/K4X7dcmyAY= github.com/jfrog/jfrog-cli-evidence v0.9.5-0.20260618135203-4d2bdd4ee35f/go.mod h1:t2luv7YHtrKe/Yf1xLZgLOkkiPtk1DsKj0OLXL2GwYo= github.com/jfrog/jfrog-cli-platform-services v1.10.1-0.20260618062042-6053ab368cab h1:Zn/qB8LYhSu82YDtbqXwErN1RPHTHe/a3gQY6Ti/OBE= diff --git a/main_test.go b/main_test.go index 2b713da71..c81673082 100644 --- a/main_test.go +++ b/main_test.go @@ -77,7 +77,7 @@ func setupIntegrationTests() { InitArtifactoryTests() } - if *tests.TestNpm || *tests.TestPnpm || *tests.TestGradle || *tests.TestMaven || *tests.TestGo || *tests.TestNuget || *tests.TestPip || *tests.TestPipenv || *tests.TestPoetry || *tests.TestConan || *tests.TestHelm || *tests.TestUv || *tests.TestNix || *tests.TestAlpine || (*tests.TestArtifactory && !*tests.TestArtifactoryProxy) || *tests.TestArtifactoryProject { + if *tests.TestNpm || *tests.TestPnpm || *tests.TestGradle || *tests.TestMaven || *tests.TestGo || *tests.TestNuget || *tests.TestPip || *tests.TestPipenv || *tests.TestPoetry || *tests.TestConan || *tests.TestHelm || *tests.TestUv || *tests.TestNix || *tests.TestApt || *tests.TestAlpine || (*tests.TestArtifactory && !*tests.TestArtifactoryProxy) || *tests.TestArtifactoryProject { InitBuildToolsTests() } if *tests.TestDocker || *tests.TestPodman || *tests.TestDockerScan { @@ -122,7 +122,7 @@ func tearDownIntegrationTests() { if (*tests.TestArtifactory && !*tests.TestArtifactoryProxy) || *tests.TestArtifactoryProject { CleanArtifactoryTests() } - if *tests.TestNpm || *tests.TestPnpm || *tests.TestGradle || *tests.TestMaven || *tests.TestGo || *tests.TestNuget || *tests.TestPip || *tests.TestPipenv || *tests.TestPoetry || *tests.TestConan || *tests.TestHelm || *tests.TestNix || *tests.TestAlpine || *tests.TestDocker || *tests.TestPodman || *tests.TestDockerScan || (*tests.TestArtifactory && !*tests.TestArtifactoryProxy) || *tests.TestArtifactoryProject { + if *tests.TestNpm || *tests.TestPnpm || *tests.TestGradle || *tests.TestMaven || *tests.TestGo || *tests.TestNuget || *tests.TestPip || *tests.TestPipenv || *tests.TestPoetry || *tests.TestConan || *tests.TestHelm || *tests.TestNix || *tests.TestApt || *tests.TestAlpine || *tests.TestDocker || *tests.TestPodman || *tests.TestDockerScan || (*tests.TestArtifactory && !*tests.TestArtifactoryProxy) || *tests.TestArtifactoryProject { CleanBuildToolsTests() } if *tests.TestDistribution { diff --git a/testdata/apt_local_repository_config.json b/testdata/apt_local_repository_config.json new file mode 100644 index 000000000..14c6bf7e0 --- /dev/null +++ b/testdata/apt_local_repository_config.json @@ -0,0 +1,8 @@ +{ + "key": "${APT_LOCAL_REPO}", + "rclass": "local", + "packageType": "debian", + "repoLayoutRef": "simple-default", + "xrayIndex": false, + "primaryKeyPairRef": "" +} diff --git a/testdata/apt_remote_repository_config.json b/testdata/apt_remote_repository_config.json new file mode 100644 index 000000000..e4924ab5e --- /dev/null +++ b/testdata/apt_remote_repository_config.json @@ -0,0 +1,8 @@ +{ + "key": "${APT_REMOTE_REPO}", + "rclass": "remote", + "packageType": "debian", + "url": "http://archive.ubuntu.com/ubuntu", + "repoLayoutRef": "simple-default", + "xrayIndex": false +} diff --git a/testdata/apt_virtual_repository_config.json b/testdata/apt_virtual_repository_config.json new file mode 100644 index 000000000..a640c15c2 --- /dev/null +++ b/testdata/apt_virtual_repository_config.json @@ -0,0 +1,8 @@ +{ + "key": "${APT_VIRTUAL_REPO}", + "rclass": "virtual", + "packageType": "debian", + "repositories": ["${APT_LOCAL_REPO}", "${APT_REMOTE_REPO}"], + "repoLayoutRef": "simple-default", + "defaultDeploymentRepo": "${APT_LOCAL_REPO}" +} diff --git a/utils/cliutils/commandsflags.go b/utils/cliutils/commandsflags.go index 4cf45994e..77ba9d06c 100644 --- a/utils/cliutils/commandsflags.go +++ b/utils/cliutils/commandsflags.go @@ -89,6 +89,8 @@ const ( ConanConfig = "conan-config" Conan = "conan" Nix = "nix" + Apt = "apt" + AptSetup = "apt-setup" Ping = "ping" RtCurl = "rt-curl" TemplateConsumer = "template-consumer" @@ -383,6 +385,13 @@ const ( skipLogin = "skip-login" validateSha = "validate-sha" + // Apt-specific flags + aptDistribution = "dist" + aptComponent = "component" + aptTrusted = "trusted" + aptImportKey = "import-key" + aptRemove = "remove" + // Unique docker promote flags dockerPromotePrefix = "docker-promote-" targetDockerImage = "target-docker-image" @@ -1388,6 +1397,27 @@ var flagsMap = map[string]cli.Flag{ Name: skipLogin, Usage: "[Default: false] Set to true if you'd like the command to skip performing docker login.` `", }, + aptDistribution: cli.StringFlag{ + Name: aptDistribution, + Usage: "[apt only] [Required for apt setup] Debian distribution name (e.g. noble, jammy).` `", + }, + aptComponent: cli.StringFlag{ + Name: aptComponent, + Value: "main", + Usage: "[apt only] [Default: main] Debian component (e.g. main, contrib, non-free). Multiple components: --component \"main contrib non-free\".` `", + }, + aptTrusted: cli.BoolFlag{ + Name: aptTrusted, + Usage: "[apt only] [Default: false] Skip GPG signature verification. Use only for testing when the repository has no GPG key configured. Mutually exclusive with --import-key.` `", + }, + aptImportKey: cli.BoolFlag{ + Name: aptImportKey, + Usage: "[apt only] [Default: false] Fetch the Artifactory repository's GPG public key and install it to /etc/apt/keyrings/. Uses signed-by= in the sources entry for scoped trust. Mutually exclusive with --trusted.` `", + }, + aptRemove: cli.BoolFlag{ + Name: aptRemove, + Usage: "[apt only] [Default: false] Remove all JFrog-managed apt source and pinning files. Combine with --dist to limit to a specific distribution.` `", + }, npmDetailedSummary: cli.BoolFlag{ Name: detailedSummary, Usage: "[Default: false] Set to true to include a list of the affected files in the command summary.` `", @@ -2243,6 +2273,12 @@ var commandFlags = map[string][]string{ Nix: { BuildName, BuildNumber, module, Project, serverId, }, + Apt: { + BuildName, BuildNumber, module, Project, serverId, skipLogin, setupRepo, aptDistribution, aptComponent, aptTrusted, + }, + AptSetup: { + serverId, setupRepo, aptDistribution, aptComponent, aptTrusted, aptImportKey, aptRemove, + }, Stats: { XrFormat, accessToken, serverId, }, @@ -2387,6 +2423,7 @@ var commandFlags = map[string][]string{ }, Setup: { serverId, url, user, password, accessToken, sshPassphrase, sshKeyPath, ClientCertPath, ClientCertKeyPath, Project, setupRepo, + aptDistribution, aptComponent, aptTrusted, aptImportKey, aptRemove, }, Login: { serverId, diff --git a/utils/tests/consts.go b/utils/tests/consts.go index 31fdb9704..186888026 100644 --- a/utils/tests/consts.go +++ b/utils/tests/consts.go @@ -111,6 +111,9 @@ const ( AlpineLocalRepositoryConfig = "alpine_local_repository_config.json" AlpineRemoteRepositoryConfig = "alpine_remote_repository_config.json" AlpineVirtualRepositoryConfig = "alpine_virtual_repository_config.json" + AptLocalRepositoryConfig = "apt_local_repository_config.json" + AptRemoteRepositoryConfig = "apt_remote_repository_config.json" + AptVirtualRepositoryConfig = "apt_virtual_repository_config.json" PoetryLocalRepositoryConfig = "poetry_local_repository_config.json" PoetryRemoteRepositoryConfig = "poetry_remote_repository_config.json" PoetryVirtualRepositoryConfig = "poetry_virtual_repository_config.json" @@ -223,6 +226,9 @@ var ( AlpineLocalRepo = "cli-alpine-local" AlpineRemoteRepo = "cli-alpine-remote" AlpineVirtualRepo = "cli-alpine-virtual" + AptLocalRepo = "cli-apt-local" + AptRemoteRepo = "cli-apt-remote" + AptVirtualRepo = "cli-apt-virtual" PoetryLocalRepo = "cli-poetry-local" PoetryRemoteRepo = "cli-poetry-remote" PoetryVirtualRepo = "cli-poetry-virtual" diff --git a/utils/tests/utils.go b/utils/tests/utils.go index c3de68a9d..175f01408 100644 --- a/utils/tests/utils.go +++ b/utils/tests/utils.go @@ -72,6 +72,7 @@ var ( TestUv *bool TestNix *bool TestAlpine *bool + TestApt *bool TestAgentPlugins *bool TestConan *bool TestHelm *bool @@ -141,6 +142,7 @@ func init() { TestUv = flag.Bool("test.uv", false, "Test UV") TestNix = flag.Bool("test.nix", false, "Test Nix") TestAlpine = flag.Bool("test.alpine", false, "Test Alpine APK") + TestApt = flag.Bool("test.apt", false, "Test apt (Debian/Ubuntu package manager)") TestAgentPlugins = flag.Bool("test.agentPlugins", false, "Test Agent Plugins") TestConan = flag.Bool("test.conan", false, "Test Conan") TestHelm = flag.Bool("test.helm", false, "Test Helm") @@ -331,6 +333,9 @@ var reposConfigMap = map[*string]string{ &AlpineLocalRepo: AlpineLocalRepositoryConfig, &AlpineRemoteRepo: AlpineRemoteRepositoryConfig, &AlpineVirtualRepo: AlpineVirtualRepositoryConfig, + &AptLocalRepo: AptLocalRepositoryConfig, + &AptRemoteRepo: AptRemoteRepositoryConfig, + &AptVirtualRepo: AptVirtualRepositoryConfig, &ConanLocalRepo: ConanLocalRepositoryConfig, &ConanRemoteRepo: ConanRemoteRepositoryConfig, &ConanVirtualRepo: ConanVirtualRepositoryConfig, @@ -404,6 +409,7 @@ func GetNonVirtualRepositories() map[*string]string { TestUv: {&UvLocalRepo, &UvRemoteRepo}, TestNix: {&NixLocalRepo, &NixRemoteRepo}, TestAlpine: {&AlpineLocalRepo, &AlpineRemoteRepo}, + TestApt: {&AptLocalRepo, &AptRemoteRepo}, TestAgentPlugins: {&AgentPluginsLocalRepo}, TestConan: {&ConanLocalRepo, &ConanRemoteRepo}, TestHelm: {&HelmLocalRepo}, @@ -438,6 +444,7 @@ func GetVirtualRepositories() map[*string]string { TestUv: {&UvVirtualRepo}, TestNix: {&NixVirtualRepo}, TestAlpine: {&AlpineVirtualRepo}, + TestApt: {&AptVirtualRepo}, TestAgentPlugins: {}, TestConan: {&ConanVirtualRepo}, TestHelm: {}, @@ -554,6 +561,9 @@ func getSubstitutionMap() map[string]string { "${ALPINE_LOCAL_REPO}": AlpineLocalRepo, "${ALPINE_REMOTE_REPO}": AlpineRemoteRepo, "${ALPINE_VIRTUAL_REPO}": AlpineVirtualRepo, + "${APT_LOCAL_REPO}": AptLocalRepo, + "${APT_REMOTE_REPO}": AptRemoteRepo, + "${APT_VIRTUAL_REPO}": AptVirtualRepo, "${CONAN_LOCAL_REPO}": ConanLocalRepo, "${CONAN_REMOTE_REPO}": ConanRemoteRepo, "${CONAN_VIRTUAL_REPO}": ConanVirtualRepo, @@ -634,6 +644,9 @@ func AddTimestampToGlobalVars() { AlpineLocalRepo += uniqueSuffix AlpineRemoteRepo += uniqueSuffix AlpineVirtualRepo += uniqueSuffix + AptLocalRepo += uniqueSuffix + AptRemoteRepo += uniqueSuffix + AptVirtualRepo += uniqueSuffix ConanLocalRepo += uniqueSuffix ConanRemoteRepo += uniqueSuffix ConanVirtualRepo += uniqueSuffix From cde607ebcd357eeb0161886f107464cc93db14d7 Mon Sep 17 00:00:00 2001 From: Kanishk Date: Thu, 9 Jul 2026 17:32:23 +0530 Subject: [PATCH 02/18] RTECO-1564 - Update aptTests.yml and dependency versions for JFrog Artifactory --- .github/workflows/aptTests.yml | 2 ++ go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/aptTests.yml b/.github/workflows/aptTests.yml index c339098f1..5caca8f1a 100644 --- a/.github/workflows/aptTests.yml +++ b/.github/workflows/aptTests.yml @@ -1,6 +1,8 @@ name: apt Tests on: + push: + branches: [RTECO-1564] workflow_call: workflow_dispatch: inputs: diff --git a/go.mod b/go.mod index baa37465b..b123ec888 100644 --- a/go.mod +++ b/go.mod @@ -242,7 +242,7 @@ require ( sigs.k8s.io/yaml v1.6.0 // indirect ) -replace github.com/jfrog/jfrog-cli-artifactory => github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260709114056-10a9adef5b17 +replace github.com/jfrog/jfrog-cli-artifactory => github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260709115840-fdfd3d59f8fc //replace github.com/gfleury/go-bitbucket-v1 => github.com/gfleury/go-bitbucket-v1 v0.0.0-20230825095122-9bc1711434ab diff --git a/go.sum b/go.sum index 15ec916cb..6ea8f8e79 100644 --- a/go.sum +++ b/go.sum @@ -406,8 +406,8 @@ github.com/jfrog/jfrog-apps-config v1.0.1 h1:mtv6k7g8A8BVhlHGlSveapqf4mJfonwvXYL github.com/jfrog/jfrog-apps-config v1.0.1/go.mod h1:8AIIr1oY9JuH5dylz2S6f8Ym2MaadPLR6noCBO4C22w= github.com/jfrog/jfrog-cli-application v1.0.2-0.20260621072921-cadb78770a3e h1:jUfQzLCVbUazw7FEXf3+57vQheDSHa/Px/Gp4pf/sNI= github.com/jfrog/jfrog-cli-application v1.0.2-0.20260621072921-cadb78770a3e/go.mod h1:p8yLtbmCxxQucIbLZKnWu0F+EDtj6NLXbRQCEK/nb6o= -github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260709114056-10a9adef5b17 h1:rNahL4i3j0Uwsr7eT/4rtehQt+iLaoyr5SPcJ21zsEI= -github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260709114056-10a9adef5b17/go.mod h1:xhPEU1u8+QZVgZwH2vMghDvBxky60zMAbBGgillQNow= +github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260709115840-fdfd3d59f8fc h1:y+eCrtMj7dvJQIka91DOpzqz6+eJR/APizHZcdPk8Is= +github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260709115840-fdfd3d59f8fc/go.mod h1:xhPEU1u8+QZVgZwH2vMghDvBxky60zMAbBGgillQNow= github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260709111737-f804ba105004 h1:x97HlYSY2ylZGbHyW9Pq+Meao8Li2fTW1tENZ9nmhLU= github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260709111737-f804ba105004/go.mod h1:9R90mhbczGXwW5EGlDs7F08ejQU/xdoDhYHMvzBiqgE= github.com/jfrog/jfrog-cli-evidence v0.9.5-0.20260618135203-4d2bdd4ee35f h1:MV4BATdkEoUYJmdPDvaB9EBb8JQZg28n/K4X7dcmyAY= From f6088e9ba9b63ea227718639514c17313e01d7db Mon Sep 17 00:00:00 2001 From: Kanishk Date: Thu, 9 Jul 2026 17:47:04 +0530 Subject: [PATCH 03/18] RTECO-1564 - Add polling step to wait for Artifactory readiness in apt tests --- .github/workflows/aptTests.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/aptTests.yml b/.github/workflows/aptTests.yml index 5caca8f1a..8f34d9256 100644 --- a/.github/workflows/aptTests.yml +++ b/.github/workflows/aptTests.yml @@ -89,6 +89,21 @@ jobs: JFROG_ADMIN_TOKEN: ${{ inputs.jfrog_admin_token }} RT_CONNECTION_TIMEOUT_SECONDS: '1200' + - name: Wait for Artifactory to be fully ready + if: env.JFROG_TESTS_IS_EXTERNAL != 'true' + run: | + echo "Polling Artifactory system ping..." + for i in $(seq 1 60); do + status=$(curl -sf -o /dev/null -w "%{http_code}" http://localhost:8081/artifactory/api/system/ping 2>/dev/null || echo "000") + if [ "$status" = "200" ]; then + echo "Artifactory ready after ${i} attempts." + exit 0 + fi + echo "Attempt $i: status=$status — retrying in 5s..." + sleep 5 + done + echo "Artifactory did not become ready in time." && exit 1 + - name: Run apt tests (root) if: matrix.user_mode == 'root' run: >- From 7cab244e51beedf2278fea932589d8d5a8580ee4 Mon Sep 17 00:00:00 2001 From: Kanishk Date: Thu, 9 Jul 2026 17:51:05 +0530 Subject: [PATCH 04/18] RTECO-1564 - Update jfrog-cli-artifactory dependency to v0.8.1-0.20260709121941-de06fac87c33 in go.mod and go.sum --- go.mod | 2 +- go.sum | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index b123ec888..f75700e24 100644 --- a/go.mod +++ b/go.mod @@ -242,7 +242,7 @@ require ( sigs.k8s.io/yaml v1.6.0 // indirect ) -replace github.com/jfrog/jfrog-cli-artifactory => github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260709115840-fdfd3d59f8fc +replace github.com/jfrog/jfrog-cli-artifactory => github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260709121941-de06fac87c33 //replace github.com/gfleury/go-bitbucket-v1 => github.com/gfleury/go-bitbucket-v1 v0.0.0-20230825095122-9bc1711434ab diff --git a/go.sum b/go.sum index 6ea8f8e79..65b174f47 100644 --- a/go.sum +++ b/go.sum @@ -404,10 +404,10 @@ 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-apps-config v1.0.1 h1:mtv6k7g8A8BVhlHGlSveapqf4mJfonwvXYLipdsOFMY= github.com/jfrog/jfrog-apps-config v1.0.1/go.mod h1:8AIIr1oY9JuH5dylz2S6f8Ym2MaadPLR6noCBO4C22w= -github.com/jfrog/jfrog-cli-application v1.0.2-0.20260621072921-cadb78770a3e h1:jUfQzLCVbUazw7FEXf3+57vQheDSHa/Px/Gp4pf/sNI= -github.com/jfrog/jfrog-cli-application v1.0.2-0.20260621072921-cadb78770a3e/go.mod h1:p8yLtbmCxxQucIbLZKnWu0F+EDtj6NLXbRQCEK/nb6o= -github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260709115840-fdfd3d59f8fc h1:y+eCrtMj7dvJQIka91DOpzqz6+eJR/APizHZcdPk8Is= -github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260709115840-fdfd3d59f8fc/go.mod h1:xhPEU1u8+QZVgZwH2vMghDvBxky60zMAbBGgillQNow= +github.com/jfrog/jfrog-cli-application v1.0.2-0.20260707110954-b31a04f5ce6c h1:5LZKsRZpwXolMxYwC9f82swfF7UcXQMUZcnZIl4y+Zw= +github.com/jfrog/jfrog-cli-application v1.0.2-0.20260707110954-b31a04f5ce6c/go.mod h1:p8yLtbmCxxQucIbLZKnWu0F+EDtj6NLXbRQCEK/nb6o= +github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260709121941-de06fac87c33 h1:weRysjudEayHA3jJXrnhFynoBl7wIp4RHHbShYvmct0= +github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260709121941-de06fac87c33/go.mod h1:xhPEU1u8+QZVgZwH2vMghDvBxky60zMAbBGgillQNow= github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260709111737-f804ba105004 h1:x97HlYSY2ylZGbHyW9Pq+Meao8Li2fTW1tENZ9nmhLU= github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260709111737-f804ba105004/go.mod h1:9R90mhbczGXwW5EGlDs7F08ejQU/xdoDhYHMvzBiqgE= github.com/jfrog/jfrog-cli-evidence v0.9.5-0.20260618135203-4d2bdd4ee35f h1:MV4BATdkEoUYJmdPDvaB9EBb8JQZg28n/K4X7dcmyAY= From 246aae8d3d603ff61306cc942ccd226816225afa Mon Sep 17 00:00:00 2001 From: Kanishk Date: Thu, 9 Jul 2026 17:57:57 +0530 Subject: [PATCH 05/18] RTECO-1564 - Enhance apt tests by ensuring preference files are removed for filtered packages --- .github/workflows/aptTests.yml | 2 +- apt_test.go | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/aptTests.yml b/.github/workflows/aptTests.yml index 8f34d9256..bf9e17ec6 100644 --- a/.github/workflows/aptTests.yml +++ b/.github/workflows/aptTests.yml @@ -116,7 +116,7 @@ jobs: - name: Run apt tests (non-root with dir perms) if: matrix.user_mode == 'nonroot' run: | - su -c "go test -v github.com/jfrog/jfrog-cli --timeout 0 --test.apt \ + su -c "PATH='$PATH' go test -v github.com/jfrog/jfrog-cli --timeout 0 --test.apt \ ${{ env.JFROG_TESTS_IS_EXTERNAL == 'true' && format('--jfrog.url={0} --jfrog.adminToken={1}', env.JFROG_TESTS_URL, env.JFROG_TESTS_LOCAL_ACCESS_TOKEN) || '' }} \ --run 'TestApt'" testuser env: diff --git a/apt_test.go b/apt_test.go index 41f9b1263..604dfde4a 100644 --- a/apt_test.go +++ b/apt_test.go @@ -309,7 +309,9 @@ func TestAptSetupRemove_DistFilteredRemoval(t *testing.T) { runJfrogCli(t, "setup", "apt", "--remove", "--dist=noble") assert.NoFileExists(t, sourcesListPath(repo, "noble"), "noble must be removed") + assert.NoFileExists(t, prefPath(repo, "noble"), "noble pref must be removed") assert.FileExists(t, sourcesListPath(repo, "jammy"), "jammy must survive") + assert.FileExists(t, prefPath(repo, "jammy"), "jammy pref must survive") } // TestAptSetupRemove_Idempotent verifies --remove on empty dir does not error. From 0c695d0c2b94a3ffc761608335001cf2b5638417 Mon Sep 17 00:00:00 2001 From: Kanishk Date: Thu, 9 Jul 2026 18:11:33 +0530 Subject: [PATCH 06/18] RTECO-1564 - Improve Artifactory readiness check in apt tests by requiring consecutive successful responses from the repositories API --- .github/workflows/aptTests.yml | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/.github/workflows/aptTests.yml b/.github/workflows/aptTests.yml index bf9e17ec6..d6d090c91 100644 --- a/.github/workflows/aptTests.yml +++ b/.github/workflows/aptTests.yml @@ -92,14 +92,28 @@ jobs: - name: Wait for Artifactory to be fully ready if: env.JFROG_TESTS_IS_EXTERNAL != 'true' run: | - echo "Polling Artifactory system ping..." - for i in $(seq 1 60); do - status=$(curl -sf -o /dev/null -w "%{http_code}" http://localhost:8081/artifactory/api/system/ping 2>/dev/null || echo "000") + echo "Waiting until Artifactory can serve the repositories API..." + # system/ping returns 200 from the router while the platform is still + # initializing, but repository APIs still respond with "Artifactory is + # not in a ready state". Gate on the exact endpoint the tests hit, and + # require several consecutive successes to ride out the startup flap. + required_streak=5 + streak=0 + for i in $(seq 1 120); do + status=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer ${JFROG_TESTS_LOCAL_ACCESS_TOKEN}" \ + http://localhost:8081/artifactory/api/repositories 2>/dev/null || echo "000") if [ "$status" = "200" ]; then - echo "Artifactory ready after ${i} attempts." - exit 0 + streak=$((streak + 1)) + echo "Attempt $i: repositories API ready (${streak}/${required_streak})." + if [ "$streak" -ge "$required_streak" ]; then + echo "Artifactory fully ready." + exit 0 + fi + else + streak=0 + echo "Attempt $i: status=$status — not ready, retrying in 5s..." fi - echo "Attempt $i: status=$status — retrying in 5s..." sleep 5 done echo "Artifactory did not become ready in time." && exit 1 From c968a9dfd09d82bd4fd1585c16d669a4ae6c6681 Mon Sep 17 00:00:00 2001 From: Kanishk Date: Thu, 9 Jul 2026 20:05:16 +0530 Subject: [PATCH 07/18] RTECO-1564 - Add Debian remote repository configuration and update apt tests to include it --- .github/workflows/aptTests.yml | 58 +++---------------- .../apt_debian_remote_repository_config.json | 8 +++ testdata/apt_virtual_repository_config.json | 2 +- utils/tests/consts.go | 2 + utils/tests/utils.go | 5 +- 5 files changed, 23 insertions(+), 52 deletions(-) create mode 100644 testdata/apt_debian_remote_repository_config.json diff --git a/.github/workflows/aptTests.yml b/.github/workflows/aptTests.yml index d6d090c91..abb86ea03 100644 --- a/.github/workflows/aptTests.yml +++ b/.github/workflows/aptTests.yml @@ -5,17 +5,6 @@ on: branches: [RTECO-1564] workflow_call: workflow_dispatch: - inputs: - jfrog_url: - description: "External JFrog Platform URL. Leave empty for local Artifactory." - type: string - required: false - default: "" - jfrog_admin_token: - description: "Admin token for external JFrog Platform." - type: string - required: false - default: "" jobs: apt-Tests: @@ -81,48 +70,14 @@ jobs: chown -R testuser:testuser /etc/apt/keyrings chown -R testuser:testuser "$GITHUB_WORKSPACE" - - name: Install local Artifactory - uses: jfrog/.github/actions/install-local-artifactory@main - with: - RTLIC: ${{ secrets.RTLIC }} - JFROG_URL: ${{ inputs.jfrog_url }} - JFROG_ADMIN_TOKEN: ${{ inputs.jfrog_admin_token }} - RT_CONNECTION_TIMEOUT_SECONDS: '1200' - - - name: Wait for Artifactory to be fully ready - if: env.JFROG_TESTS_IS_EXTERNAL != 'true' - run: | - echo "Waiting until Artifactory can serve the repositories API..." - # system/ping returns 200 from the router while the platform is still - # initializing, but repository APIs still respond with "Artifactory is - # not in a ready state". Gate on the exact endpoint the tests hit, and - # require several consecutive successes to ride out the startup flap. - required_streak=5 - streak=0 - for i in $(seq 1 120); do - status=$(curl -s -o /dev/null -w "%{http_code}" \ - -H "Authorization: Bearer ${JFROG_TESTS_LOCAL_ACCESS_TOKEN}" \ - http://localhost:8081/artifactory/api/repositories 2>/dev/null || echo "000") - if [ "$status" = "200" ]; then - streak=$((streak + 1)) - echo "Attempt $i: repositories API ready (${streak}/${required_streak})." - if [ "$streak" -ge "$required_streak" ]; then - echo "Artifactory fully ready." - exit 0 - fi - else - streak=0 - echo "Attempt $i: status=$status — not ready, retrying in 5s..." - fi - sleep 5 - done - echo "Artifactory did not become ready in time." && exit 1 - - name: Run apt tests (root) if: matrix.user_mode == 'root' run: >- go test -v github.com/jfrog/jfrog-cli --timeout 0 --test.apt - ${{ env.JFROG_TESTS_IS_EXTERNAL == 'true' && format('--jfrog.url={0} --jfrog.adminToken={1}', env.JFROG_TESTS_URL, env.JFROG_TESTS_LOCAL_ACCESS_TOKEN) || '' }} + --jfrog.url=${{ secrets.PLATFORM_URL }} + --jfrog.adminToken=${{ secrets.PLATFORM_ADMIN_TOKEN }} + --jfrog.user=${{ secrets.PLATFORM_USER }} + --ci.runId=${{ matrix.distro.name }}-root --run "TestApt" env: APT_TEST_DIST: ${{ matrix.distro.dist }} @@ -131,7 +86,10 @@ jobs: if: matrix.user_mode == 'nonroot' run: | su -c "PATH='$PATH' go test -v github.com/jfrog/jfrog-cli --timeout 0 --test.apt \ - ${{ env.JFROG_TESTS_IS_EXTERNAL == 'true' && format('--jfrog.url={0} --jfrog.adminToken={1}', env.JFROG_TESTS_URL, env.JFROG_TESTS_LOCAL_ACCESS_TOKEN) || '' }} \ + --jfrog.url='${{ secrets.PLATFORM_URL }}' \ + --jfrog.adminToken='${{ secrets.PLATFORM_ADMIN_TOKEN }}' \ + --jfrog.user='${{ secrets.PLATFORM_USER }}' \ + --ci.runId='${{ matrix.distro.name }}-nonroot' \ --run 'TestApt'" testuser env: APT_TEST_DIST: ${{ matrix.distro.dist }} diff --git a/testdata/apt_debian_remote_repository_config.json b/testdata/apt_debian_remote_repository_config.json new file mode 100644 index 000000000..679ea45a7 --- /dev/null +++ b/testdata/apt_debian_remote_repository_config.json @@ -0,0 +1,8 @@ +{ + "key": "${APT_DEBIAN_REMOTE_REPO}", + "rclass": "remote", + "packageType": "debian", + "url": "http://deb.debian.org/debian", + "repoLayoutRef": "simple-default", + "xrayIndex": false +} diff --git a/testdata/apt_virtual_repository_config.json b/testdata/apt_virtual_repository_config.json index a640c15c2..c72cfc69a 100644 --- a/testdata/apt_virtual_repository_config.json +++ b/testdata/apt_virtual_repository_config.json @@ -2,7 +2,7 @@ "key": "${APT_VIRTUAL_REPO}", "rclass": "virtual", "packageType": "debian", - "repositories": ["${APT_LOCAL_REPO}", "${APT_REMOTE_REPO}"], + "repositories": ["${APT_LOCAL_REPO}", "${APT_REMOTE_REPO}", "${APT_DEBIAN_REMOTE_REPO}"], "repoLayoutRef": "simple-default", "defaultDeploymentRepo": "${APT_LOCAL_REPO}" } diff --git a/utils/tests/consts.go b/utils/tests/consts.go index 186888026..f65689259 100644 --- a/utils/tests/consts.go +++ b/utils/tests/consts.go @@ -113,6 +113,7 @@ const ( AlpineVirtualRepositoryConfig = "alpine_virtual_repository_config.json" AptLocalRepositoryConfig = "apt_local_repository_config.json" AptRemoteRepositoryConfig = "apt_remote_repository_config.json" + AptDebianRemoteRepositoryConfig = "apt_debian_remote_repository_config.json" AptVirtualRepositoryConfig = "apt_virtual_repository_config.json" PoetryLocalRepositoryConfig = "poetry_local_repository_config.json" PoetryRemoteRepositoryConfig = "poetry_remote_repository_config.json" @@ -228,6 +229,7 @@ var ( AlpineVirtualRepo = "cli-alpine-virtual" AptLocalRepo = "cli-apt-local" AptRemoteRepo = "cli-apt-remote" + AptDebianRemoteRepo = "cli-apt-debian-remote" AptVirtualRepo = "cli-apt-virtual" PoetryLocalRepo = "cli-poetry-local" PoetryRemoteRepo = "cli-poetry-remote" diff --git a/utils/tests/utils.go b/utils/tests/utils.go index 175f01408..366313618 100644 --- a/utils/tests/utils.go +++ b/utils/tests/utils.go @@ -335,6 +335,7 @@ var reposConfigMap = map[*string]string{ &AlpineVirtualRepo: AlpineVirtualRepositoryConfig, &AptLocalRepo: AptLocalRepositoryConfig, &AptRemoteRepo: AptRemoteRepositoryConfig, + &AptDebianRemoteRepo: AptDebianRemoteRepositoryConfig, &AptVirtualRepo: AptVirtualRepositoryConfig, &ConanLocalRepo: ConanLocalRepositoryConfig, &ConanRemoteRepo: ConanRemoteRepositoryConfig, @@ -409,7 +410,7 @@ func GetNonVirtualRepositories() map[*string]string { TestUv: {&UvLocalRepo, &UvRemoteRepo}, TestNix: {&NixLocalRepo, &NixRemoteRepo}, TestAlpine: {&AlpineLocalRepo, &AlpineRemoteRepo}, - TestApt: {&AptLocalRepo, &AptRemoteRepo}, + TestApt: {&AptLocalRepo, &AptRemoteRepo, &AptDebianRemoteRepo}, TestAgentPlugins: {&AgentPluginsLocalRepo}, TestConan: {&ConanLocalRepo, &ConanRemoteRepo}, TestHelm: {&HelmLocalRepo}, @@ -563,6 +564,7 @@ func getSubstitutionMap() map[string]string { "${ALPINE_VIRTUAL_REPO}": AlpineVirtualRepo, "${APT_LOCAL_REPO}": AptLocalRepo, "${APT_REMOTE_REPO}": AptRemoteRepo, + "${APT_DEBIAN_REMOTE_REPO}": AptDebianRemoteRepo, "${APT_VIRTUAL_REPO}": AptVirtualRepo, "${CONAN_LOCAL_REPO}": ConanLocalRepo, "${CONAN_REMOTE_REPO}": ConanRemoteRepo, @@ -646,6 +648,7 @@ func AddTimestampToGlobalVars() { AlpineVirtualRepo += uniqueSuffix AptLocalRepo += uniqueSuffix AptRemoteRepo += uniqueSuffix + AptDebianRemoteRepo += uniqueSuffix AptVirtualRepo += uniqueSuffix ConanLocalRepo += uniqueSuffix ConanRemoteRepo += uniqueSuffix From 8e9765a16f2107fa0447946cb6f98179e513a89b Mon Sep 17 00:00:00 2001 From: Kanishk Date: Thu, 9 Jul 2026 20:41:25 +0530 Subject: [PATCH 08/18] RTECO-1564 - Update jfrog-cli-artifactory dependency to v0.8.1-0.20260709150839-fe13c9b13f5b and adjust apt test concurrency settings --- .github/workflows/aptTests.yml | 3 +++ go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/aptTests.yml b/.github/workflows/aptTests.yml index abb86ea03..58df2f5ce 100644 --- a/.github/workflows/aptTests.yml +++ b/.github/workflows/aptTests.yml @@ -12,6 +12,9 @@ jobs: runs-on: ubuntu-24.04 strategy: fail-fast: false + # Throttle concurrency: all jobs share one external JFrog platform, and + # 10 parallel jobs creating/deleting repos overload it (nginx 502s). + max-parallel: 2 matrix: distro: - image: ubuntu:20.04 diff --git a/go.mod b/go.mod index f75700e24..9b2f68696 100644 --- a/go.mod +++ b/go.mod @@ -242,7 +242,7 @@ require ( sigs.k8s.io/yaml v1.6.0 // indirect ) -replace github.com/jfrog/jfrog-cli-artifactory => github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260709121941-de06fac87c33 +replace github.com/jfrog/jfrog-cli-artifactory => github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260709150839-fe13c9b13f5b //replace github.com/gfleury/go-bitbucket-v1 => github.com/gfleury/go-bitbucket-v1 v0.0.0-20230825095122-9bc1711434ab diff --git a/go.sum b/go.sum index 65b174f47..0a1be4610 100644 --- a/go.sum +++ b/go.sum @@ -406,8 +406,8 @@ github.com/jfrog/jfrog-apps-config v1.0.1 h1:mtv6k7g8A8BVhlHGlSveapqf4mJfonwvXYL github.com/jfrog/jfrog-apps-config v1.0.1/go.mod h1:8AIIr1oY9JuH5dylz2S6f8Ym2MaadPLR6noCBO4C22w= github.com/jfrog/jfrog-cli-application v1.0.2-0.20260707110954-b31a04f5ce6c h1:5LZKsRZpwXolMxYwC9f82swfF7UcXQMUZcnZIl4y+Zw= github.com/jfrog/jfrog-cli-application v1.0.2-0.20260707110954-b31a04f5ce6c/go.mod h1:p8yLtbmCxxQucIbLZKnWu0F+EDtj6NLXbRQCEK/nb6o= -github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260709121941-de06fac87c33 h1:weRysjudEayHA3jJXrnhFynoBl7wIp4RHHbShYvmct0= -github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260709121941-de06fac87c33/go.mod h1:xhPEU1u8+QZVgZwH2vMghDvBxky60zMAbBGgillQNow= +github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260709150839-fe13c9b13f5b h1:BOr2LQDi2xvxyBuoFyMVX+tO5yziEaUxFIywtEr4K6I= +github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260709150839-fe13c9b13f5b/go.mod h1:xhPEU1u8+QZVgZwH2vMghDvBxky60zMAbBGgillQNow= github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260709111737-f804ba105004 h1:x97HlYSY2ylZGbHyW9Pq+Meao8Li2fTW1tENZ9nmhLU= github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260709111737-f804ba105004/go.mod h1:9R90mhbczGXwW5EGlDs7F08ejQU/xdoDhYHMvzBiqgE= github.com/jfrog/jfrog-cli-evidence v0.9.5-0.20260618135203-4d2bdd4ee35f h1:MV4BATdkEoUYJmdPDvaB9EBb8JQZg28n/K4X7dcmyAY= From 0aa67465bf00fb2af304e7f16a5315f2f06be080 Mon Sep 17 00:00:00 2001 From: Kanishk Date: Fri, 10 Jul 2026 10:07:21 +0530 Subject: [PATCH 09/18] RTECO-1564 - Increase apt test concurrency limit to 4 and add dynamic distribution handling in tests --- .github/workflows/aptTests.yml | 2 +- apt_test.go | 190 +++++++++++++++++++++++++++++---- 2 files changed, 169 insertions(+), 23 deletions(-) diff --git a/.github/workflows/aptTests.yml b/.github/workflows/aptTests.yml index 58df2f5ce..60d30bbef 100644 --- a/.github/workflows/aptTests.yml +++ b/.github/workflows/aptTests.yml @@ -14,7 +14,7 @@ jobs: fail-fast: false # Throttle concurrency: all jobs share one external JFrog platform, and # 10 parallel jobs creating/deleting repos overload it (nginx 502s). - max-parallel: 2 + max-parallel: 4 matrix: distro: - image: ubuntu:20.04 diff --git a/apt_test.go b/apt_test.go index 604dfde4a..15430ab2e 100644 --- a/apt_test.go +++ b/apt_test.go @@ -1,7 +1,10 @@ package main import ( + "bytes" + "encoding/json" "fmt" + "net/http" "net/url" "os" "os/exec" @@ -50,6 +53,16 @@ func cleanAptTest(t *testing.T) { // aptRepo returns the virtual repo name used across apt integration tests. func aptRepo() string { return tests.AptVirtualRepo } +// testDist returns the apt distribution codename for the current container, +// driven by the APT_TEST_DIST env var set in aptTests.yml. Falls back to +// "noble" so local runs without the env still work. +func testDist() string { + if d := os.Getenv("APT_TEST_DIST"); d != "" { + return d + } + return "noble" +} + // sourcesListPath returns the expected path for a given repo+dist. func sourcesListPath(repo, dist string) string { return fmt.Sprintf("/etc/apt/sources.list.d/jfrog-%s-%s.list", repo, dist) @@ -178,22 +191,25 @@ func TestAptSetup_TrustedFlag(t *testing.T) { } // TestAptSetup_ImportKey verifies --import-key fetches and installs the GPG key. -// Skipped when the Artifactory repo has no GPG key configured. +// Creates a throwaway GPG keypair in Artifactory at test runtime so the test +// is not skipped when the platform has no key pre-configured. func TestAptSetup_ImportKey(t *testing.T) { initAptTest(t) requireRoot(t) defer cleanAptTest(t) repo := aptRepo() + + pairName, cleanupKeypair := createArtifactoryGPGKeypair(t) + defer cleanupKeypair() + setRepoPrimaryKeyPairRef(t, repo, pairName) + defer setRepoPrimaryKeyPairRef(t, repo, "") + err := runJfrogCliWithoutAssertion("setup", "apt", "--repo="+repo, "--dist=noble", "--import-key") - if err != nil { - t.Skipf("Skipping: Artifactory repo %q has no GPG key configured: %v", repo, err) - } + require.NoError(t, err) - // keyring file written assert.FileExists(t, keyringPath(repo, "noble")) - // sources line uses signed-by, not trusted=yes content, err := os.ReadFile(sourcesListPath(repo, "noble")) require.NoError(t, err) assert.Contains(t, string(content), "signed-by=") @@ -325,23 +341,28 @@ func TestAptSetupRemove_Idempotent(t *testing.T) { // ── jf apt install (on-the-fly) ─────────────────────────────────────────────── -// TestAptInstall_OnTheFlyInstall verifies curl can be installed via on-the-fly auth -// and that the install came from Artifactory (not a system source). +// TestAptInstall_OnTheFlyInstall verifies a package can be installed via +// on-the-fly auth and that the install came from Artifactory. func TestAptInstall_OnTheFlyInstall(t *testing.T) { initAptTest(t) requireRoot(t) defer cleanAptTest(t) + // Snapshot history.log size before installing so assertInstalledFromArtifactory + // reads only the entry written by this test, not the workflow's prereq step. + logOffset := aptHistoryLogSize() + + dist := testDist() runJfrogCli(t, "apt", "install", "-y", "curl", "--repo="+aptRepo(), - "--dist=noble", + "--dist="+dist, "--trusted", ) _, err := os.Stat("/usr/bin/curl") assert.NoError(t, err, "curl must be installed after jf apt install") - assertInstalledFromArtifactory(t) + assertInstalledFromArtifactory(t, logOffset) } // TestAptInstall_SkipLoginUsesSystemConfig verifies --skip-login bypasses auth injection: @@ -445,7 +466,7 @@ func TestAptSetupThenNativeInstall(t *testing.T) { defer cleanAptTest(t) repo := aptRepo() - dist := "noble" + dist := testDist() // Persistent setup — writes sources.list + Pin-Priority: 1001 pinning file. runJfrogCli(t, "setup", "apt", @@ -544,23 +565,44 @@ func captureTempSourcesList(t *testing.T) (<-chan string, func()) { return ch, func() { close(done) } } -// assertInstalledFromArtifactory reads /var/log/apt/history.log and verifies the -// most recent apt commandline used on-the-fly Artifactory auth (Dir::Etc::sourcelist= -// pointing to a jfrog temp file and Dir::Etc::sourceparts=- to block other sources). -func assertInstalledFromArtifactory(t *testing.T) { +// aptHistoryLogSize returns the current byte length of /var/log/apt/history.log. +// Call before an install to get an offset; pass to assertInstalledFromArtifactory +// so it only inspects lines written during the test, not earlier workflow steps. +func aptHistoryLogSize() int64 { + info, err := os.Stat("/var/log/apt/history.log") + if err != nil { + return 0 + } + return info.Size() +} + +// assertInstalledFromArtifactory reads /var/log/apt/history.log from offset and +// verifies the first apt commandline entry uses on-the-fly Artifactory auth +// (Dir::Etc::sourcelist= pointing to a jfrog temp file, Dir::Etc::sourceparts=- +// to suppress other sources). offset should be from aptHistoryLogSize() before +// the install; pass 0 to search the full log. +func assertInstalledFromArtifactory(t *testing.T, offset int64) { t.Helper() - data, err := os.ReadFile("/var/log/apt/history.log") + f, err := os.Open("/var/log/apt/history.log") if err != nil { t.Logf("Warning: cannot read /var/log/apt/history.log: %v — skipping Artifactory source check", err) return } - s := string(data) - idx := strings.LastIndex(s, "\nCommandline:") - if idx == -1 { - idx = strings.Index(s, "Commandline:") + defer f.Close() + if offset > 0 { + if _, err = f.Seek(offset, 0); err != nil { + t.Logf("Warning: seek history.log to %d: %v", offset, err) + } + } + data, err := os.ReadFile("/var/log/apt/history.log") + if err != nil { + t.Logf("Warning: read history.log: %v", err) + return } - require.NotEqual(t, -1, idx, "no Commandline entry found in apt history log") - line := s[idx+1:] + s := string(data[min(offset, int64(len(data))):]) + idx := strings.Index(s, "Commandline:") + require.NotEqual(t, -1, idx, "no Commandline entry found in apt history log after test install") + line := s[idx:] if end := strings.IndexByte(line, '\n'); end != -1 { line = line[:end] } @@ -596,6 +638,110 @@ func assertPersistentInstallFromArtifactory(t *testing.T, pkg, artURL string) { t.Errorf("%q does not appear to be installed; apt-cache policy output:\n%s", pkg, out) } +// createArtifactoryGPGKeypair generates a throwaway GPG keypair and uploads it +// to Artifactory via the REST API. Returns the pair name and a cleanup func that +// deletes it from Artifactory. The caller must defer the cleanup. +// Skips the test if gpg is not available. +func createArtifactoryGPGKeypair(t *testing.T) (pairName string, cleanup func()) { + t.Helper() + if _, err := exec.LookPath("gpg"); err != nil { + t.Skip("gpg not found — cannot create test GPG keypair") + } + + // Isolated GPG home so we don't pollute the system keyring. + gpgHome := t.TempDir() + require.NoError(t, os.Chmod(gpgHome, 0700)) + + keyParams := `%no-protection +Key-Type: RSA +Key-Length: 2048 +Name-Real: JFrog Apt Test +Name-Email: jfrog-apt-test@example.com +Expire-Date: 1d +%commit +` + paramFile := filepath.Join(gpgHome, "keygen.conf") + require.NoError(t, os.WriteFile(paramFile, []byte(keyParams), 0600)) + + gpgArgs := func(args ...string) *exec.Cmd { + cmd := exec.Command("gpg", args...) + cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome) + return cmd + } + + out, err := gpgArgs("--batch", "--gen-key", paramFile).CombinedOutput() + require.NoError(t, err, "gpg key generation failed: %s", out) + + pubKey, err := gpgArgs("--armor", "--export", "jfrog-apt-test@example.com").Output() + require.NoError(t, err, "gpg export public key failed") + + privKey, err := gpgArgs("--armor", "--batch", "--passphrase", "", "--export-secret-keys", "jfrog-apt-test@example.com").Output() + require.NoError(t, err, "gpg export private key failed") + + pairName = fmt.Sprintf("jfrog-apt-test-%d", time.Now().UnixNano()) + artURL := strings.TrimSuffix(*tests.JfrogUrl+tests.ArtifactoryEndpoint, "/") + + type keypairReq struct { + PairName string `json:"pairName"` + PairType string `json:"pairType"` + PassPhrase string `json:"passPhrase"` + PublicKey string `json:"publicKey"` + PrivateKey string `json:"privateKey"` + } + body, err := json.Marshal(keypairReq{ + PairName: pairName, + PairType: "GPG", + PassPhrase: "", + PublicKey: string(pubKey), + PrivateKey: string(privKey), + }) + require.NoError(t, err) + + doArtRequest(t, http.MethodPost, artURL+"/api/security/keypair", body, http.StatusCreated) + + cleanup = func() { + req, _ := http.NewRequest(http.MethodDelete, artURL+"/api/security/keypair/"+pairName, nil) + setArtAuth(req) + resp, err := http.DefaultClient.Do(req) + if err == nil { + _ = resp.Body.Close() + } + } + return pairName, cleanup +} + +// setRepoPrimaryKeyPairRef updates the repo's primaryKeyPairRef in Artifactory. +// Pass an empty pairName to clear the field. +func setRepoPrimaryKeyPairRef(t *testing.T, repoName, pairName string) { + t.Helper() + artURL := strings.TrimSuffix(*tests.JfrogUrl+tests.ArtifactoryEndpoint, "/") + body, err := json.Marshal(map[string]string{"primaryKeyPairRef": pairName}) + require.NoError(t, err) + doArtRequest(t, http.MethodPost, artURL+"/api/repositories/"+repoName, body, http.StatusOK) +} + +// doArtRequest performs an authenticated Artifactory REST call and asserts the status. +func doArtRequest(t *testing.T, method, url string, body []byte, wantStatus int) { + t.Helper() + req, err := http.NewRequest(method, url, bytes.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + setArtAuth(req) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + _ = resp.Body.Close() + require.Equal(t, wantStatus, resp.StatusCode, "%s %s", method, url) +} + +// setArtAuth attaches admin credentials to a request using the test flags. +func setArtAuth(req *http.Request) { + if *tests.JfrogAccessToken != "" { + req.Header.Set("Authorization", "Bearer "+*tests.JfrogAccessToken) + } else { + req.SetBasicAuth(*tests.JfrogUser, *tests.JfrogPassword) + } +} + func isWritable(info os.FileInfo) bool { mode := info.Mode() return mode&0200 != 0 From 646ad42afae5af89b6b00f7ef81f223fcba66475 Mon Sep 17 00:00:00 2001 From: Kanishk Date: Fri, 10 Jul 2026 10:29:46 +0530 Subject: [PATCH 10/18] RTECO-1564 - Increase apt test concurrency to 5 and add support for Debian 13 (trixie) in distribution matrix --- .github/workflows/aptTests.yml | 5 ++++- apt_test.go | 14 ++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/.github/workflows/aptTests.yml b/.github/workflows/aptTests.yml index 60d30bbef..ea755e816 100644 --- a/.github/workflows/aptTests.yml +++ b/.github/workflows/aptTests.yml @@ -14,7 +14,7 @@ jobs: fail-fast: false # Throttle concurrency: all jobs share one external JFrog platform, and # 10 parallel jobs creating/deleting repos overload it (nginx 502s). - max-parallel: 4 + max-parallel: 5 matrix: distro: - image: ubuntu:20.04 @@ -32,6 +32,9 @@ jobs: - image: debian:12 name: debian-bookworm dist: bookworm + - image: debian:13 + name: debian-trixie + dist: trixie user_mode: - root - nonroot diff --git a/apt_test.go b/apt_test.go index 15430ab2e..14a3e9b46 100644 --- a/apt_test.go +++ b/apt_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "io" "net/http" "net/url" "os" @@ -353,7 +354,9 @@ func TestAptInstall_OnTheFlyInstall(t *testing.T) { logOffset := aptHistoryLogSize() dist := testDist() - runJfrogCli(t, "apt", "install", "-y", "curl", + // --reinstall ensures apt writes a history.log entry even when curl is + // already at the latest version (it is pre-installed by the workflow). + runJfrogCli(t, "apt", "install", "-y", "--reinstall", "curl", "--repo="+aptRepo(), "--dist="+dist, "--trusted", @@ -494,7 +497,7 @@ func TestAptSetup_DistributionMatrix(t *testing.T) { initAptTest(t) requireRoot(t) - dists := []string{"noble", "jammy", "focal", "bookworm", "bullseye"} + dists := []string{"noble", "jammy", "focal", "trixie", "bookworm", "bullseye"} for _, dist := range dists { dist := dist t.Run(dist, func(t *testing.T) { @@ -675,8 +678,10 @@ Expire-Date: 1d pubKey, err := gpgArgs("--armor", "--export", "jfrog-apt-test@example.com").Output() require.NoError(t, err, "gpg export public key failed") - privKey, err := gpgArgs("--armor", "--batch", "--passphrase", "", "--export-secret-keys", "jfrog-apt-test@example.com").Output() + privKeyCmd := gpgArgs("--armor", "--batch", "--yes", "--pinentry-mode", "loopback", "--passphrase", "", "--export-secret-keys", "jfrog-apt-test@example.com") + privKey, err := privKeyCmd.Output() require.NoError(t, err, "gpg export private key failed") + require.NotEmpty(t, privKey, "gpg exported empty private key — check GnuPG version and pinentry mode") pairName = fmt.Sprintf("jfrog-apt-test-%d", time.Now().UnixNano()) artURL := strings.TrimSuffix(*tests.JfrogUrl+tests.ArtifactoryEndpoint, "/") @@ -729,8 +734,9 @@ func doArtRequest(t *testing.T, method, url string, body []byte, wantStatus int) setArtAuth(req) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) + respBody, _ := io.ReadAll(resp.Body) _ = resp.Body.Close() - require.Equal(t, wantStatus, resp.StatusCode, "%s %s", method, url) + require.Equal(t, wantStatus, resp.StatusCode, "%s %s\nresponse: %s", method, url, respBody) } // setArtAuth attaches admin credentials to a request using the test flags. From a043c016e52c8a93dc4f4075fe631ada24202195 Mon Sep 17 00:00:00 2001 From: Kanishk Date: Fri, 10 Jul 2026 10:44:31 +0530 Subject: [PATCH 11/18] RTECO-1564 - Refactor apt tests to install bzip2 instead of curl and enhance error handling for distribution matrix --- apt_test.go | 40 +++++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/apt_test.go b/apt_test.go index 14a3e9b46..bcf7e4049 100644 --- a/apt_test.go +++ b/apt_test.go @@ -354,16 +354,17 @@ func TestAptInstall_OnTheFlyInstall(t *testing.T) { logOffset := aptHistoryLogSize() dist := testDist() - // --reinstall ensures apt writes a history.log entry even when curl is - // already at the latest version (it is pre-installed by the workflow). - runJfrogCli(t, "apt", "install", "-y", "--reinstall", "curl", + // Use bzip2: small, available in all Ubuntu/Debian distros via Artifactory + // remote, and not pre-installed by the workflow prereq step — so apt always + // performs a real install and writes a Commandline entry to history.log. + runJfrogCli(t, "apt", "install", "-y", "bzip2", "--repo="+aptRepo(), "--dist="+dist, "--trusted", ) - _, err := os.Stat("/usr/bin/curl") - assert.NoError(t, err, "curl must be installed after jf apt install") + _, err := os.Stat("/usr/bin/bzip2") + assert.NoError(t, err, "bzip2 must be installed after jf apt install") assertInstalledFromArtifactory(t, logOffset) } @@ -510,8 +511,17 @@ func TestAptSetup_DistributionMatrix(t *testing.T) { "--dist="+dist, "--trusted", ) - if err != nil && strings.Contains(err.Error(), "apt-get update failed") { - t.Skipf("dist %q not available in this Artifactory remote repo — skipping", dist) + if err != nil { + msg := err.Error() + if strings.Contains(msg, "apt-get update failed") || + strings.Contains(msg, "not available in this Artifactory remote repo") { + t.Skipf("dist %q not available in this Artifactory remote repo — skipping", dist) + } + // 502 / transient platform error — skip rather than fail the suite. + if strings.Contains(msg, "502") || strings.Contains(msg, "Bad Gateway") || + strings.Contains(msg, "executor timeout") { + t.Skipf("dist %q: transient platform error — skipping: %v", dist, err) + } } require.NoError(t, err) assert.FileExists(t, sourcesListPath(aptRepo(), dist)) @@ -586,23 +596,15 @@ func aptHistoryLogSize() int64 { // the install; pass 0 to search the full log. func assertInstalledFromArtifactory(t *testing.T, offset int64) { t.Helper() - f, err := os.Open("/var/log/apt/history.log") + data, err := os.ReadFile("/var/log/apt/history.log") if err != nil { t.Logf("Warning: cannot read /var/log/apt/history.log: %v — skipping Artifactory source check", err) return } - defer f.Close() - if offset > 0 { - if _, err = f.Seek(offset, 0); err != nil { - t.Logf("Warning: seek history.log to %d: %v", offset, err) - } - } - data, err := os.ReadFile("/var/log/apt/history.log") - if err != nil { - t.Logf("Warning: read history.log: %v", err) - return + if offset > int64(len(data)) { + offset = int64(len(data)) } - s := string(data[min(offset, int64(len(data))):]) + s := string(data[offset:]) idx := strings.Index(s, "Commandline:") require.NotEqual(t, -1, idx, "no Commandline entry found in apt history log after test install") line := s[idx:] From a3d6b435b7952ca51b541cd49bbd69a8183517af Mon Sep 17 00:00:00 2001 From: Kanishk Date: Fri, 10 Jul 2026 11:03:41 +0530 Subject: [PATCH 12/18] RTECO-1564 - Reduce apt test concurrency limit from 5 to 4 to prevent overload on JFrog platform --- .github/workflows/aptTests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/aptTests.yml b/.github/workflows/aptTests.yml index ea755e816..f3eaa8e45 100644 --- a/.github/workflows/aptTests.yml +++ b/.github/workflows/aptTests.yml @@ -14,7 +14,7 @@ jobs: fail-fast: false # Throttle concurrency: all jobs share one external JFrog platform, and # 10 parallel jobs creating/deleting repos overload it (nginx 502s). - max-parallel: 5 + max-parallel: 4 matrix: distro: - image: ubuntu:20.04 From 7731c7bad5ee8fbd782a7ceaaa1123189bd5eea3 Mon Sep 17 00:00:00 2001 From: Kanishk Date: Fri, 10 Jul 2026 12:38:28 +0530 Subject: [PATCH 13/18] RTECO-1564 - Refactor apt test workflow to improve user mode handling and streamline test execution --- .github/workflows/aptTests.yml | 96 ++++++++++++++++------------------ 1 file changed, 45 insertions(+), 51 deletions(-) diff --git a/.github/workflows/aptTests.yml b/.github/workflows/aptTests.yml index f3eaa8e45..24686f6cd 100644 --- a/.github/workflows/aptTests.yml +++ b/.github/workflows/aptTests.yml @@ -8,13 +8,10 @@ on: jobs: apt-Tests: - name: apt tests (${{ matrix.distro.image }}, ${{ matrix.user_mode }}) + name: apt tests (${{ matrix.distro.name }}, ${{ matrix.user_mode }}) runs-on: ubuntu-24.04 strategy: fail-fast: false - # Throttle concurrency: all jobs share one external JFrog platform, and - # 10 parallel jobs creating/deleting repos overload it (nginx 502s). - max-parallel: 4 matrix: distro: - image: ubuntu:20.04 @@ -39,17 +36,7 @@ jobs: - root - nonroot - container: - image: ${{ matrix.distro.image }} - options: --user root --privileged - steps: - - name: Install prerequisites - run: | - apt-get update -y - apt-get install -y --no-install-recommends \ - git curl ca-certificates sudo wget gnupg - - name: Checkout code uses: actions/checkout@v7 with: @@ -64,45 +51,52 @@ jobs: - name: Setup Go with cache uses: jfrog/.github/actions/install-go-with-cache@main - - name: Create non-root test user - if: matrix.user_mode == 'nonroot' - run: | - useradd -m -u 1001 -s /bin/bash testuser - echo "testuser ALL=(root) NOPASSWD: /usr/bin/apt-get, /usr/bin/apt" >> /etc/sudoers.d/testuser-apt - chmod 0440 /etc/sudoers.d/testuser-apt - chown -R testuser:testuser /etc/apt/sources.list.d - chown -R testuser:testuser /etc/apt/preferences.d - mkdir -p /etc/apt/keyrings - chown -R testuser:testuser /etc/apt/keyrings - chown -R testuser:testuser "$GITHUB_WORKSPACE" + # Runs on the host runner, not inside a distro container - stable, no OOM. + - name: Install local Artifactory + uses: jfrog/.github/actions/install-local-artifactory@main + with: + RTLIC: ${{ secrets.RTLIC }} + RT_CONNECTION_TIMEOUT_SECONDS: '1200' - - name: Run apt tests (root) - if: matrix.user_mode == 'root' - run: >- - go test -v github.com/jfrog/jfrog-cli --timeout 0 --test.apt - --jfrog.url=${{ secrets.PLATFORM_URL }} - --jfrog.adminToken=${{ secrets.PLATFORM_ADMIN_TOKEN }} - --jfrog.user=${{ secrets.PLATFORM_USER }} - --ci.runId=${{ matrix.distro.name }}-root - --run "TestApt" + # Compiled once on the host, then bind-mounted into every distro container below. + # CGO_ENABLED=0 keeps the binary statically linked so it runs unchanged across + # glibc versions from Ubuntu 20.04 through Debian 13. + - name: Compile apt test binary + run: go test -c -o apt.test . env: - APT_TEST_DIST: ${{ matrix.distro.dist }} + CGO_ENABLED: "0" - - name: Run apt tests (non-root with dir perms) - if: matrix.user_mode == 'nonroot' + # --network host puts the container on the runner's network namespace, so + # localhost:8081 inside the container reaches the Artifactory started above. + # exec.Command("apt-get", ...) and /etc/apt/, /var/log/apt/ inside apt.test + # all hit this container's native environment, not the host's. + - name: Run apt tests (${{ matrix.distro.image }}, ${{ matrix.user_mode }}) run: | - su -c "PATH='$PATH' go test -v github.com/jfrog/jfrog-cli --timeout 0 --test.apt \ - --jfrog.url='${{ secrets.PLATFORM_URL }}' \ - --jfrog.adminToken='${{ secrets.PLATFORM_ADMIN_TOKEN }}' \ - --jfrog.user='${{ secrets.PLATFORM_USER }}' \ - --ci.runId='${{ matrix.distro.name }}-nonroot' \ - --run 'TestApt'" testuser - env: - APT_TEST_DIST: ${{ matrix.distro.dist }} + docker run --rm --network host \ + -v "$GITHUB_WORKSPACE:/workspace" \ + -w /workspace \ + -e JFROG_TESTS_LOCAL_ACCESS_TOKEN \ + -e APT_TEST_DIST="${{ matrix.distro.dist }}" \ + -e USER_MODE="${{ matrix.user_mode }}" \ + -e CI_RUN_ID="${{ matrix.distro.name }}-${{ matrix.user_mode }}" \ + "${{ matrix.distro.image }}" \ + bash -c ' + set -eu + apt-get update -y + apt-get install -y --no-install-recommends gnupg sudo - - name: Cleanup apt configuration - if: always() - run: | - rm -f /etc/apt/sources.list.d/jfrog-*.list - rm -f /etc/apt/preferences.d/jfrog-*.pref - rm -f /etc/apt/keyrings/jfrog-*.asc + if [ "$USER_MODE" = "nonroot" ]; then + useradd -m -u 1001 -s /bin/bash testuser + echo "testuser ALL=(root) NOPASSWD: /usr/bin/apt-get, /usr/bin/apt" > /etc/sudoers.d/testuser-apt + chmod 0440 /etc/sudoers.d/testuser-apt + mkdir -p /etc/apt/keyrings + chown -R testuser:testuser \ + /etc/apt/sources.list.d /etc/apt/preferences.d /etc/apt/keyrings /workspace + + su -c "/workspace/apt.test -test.v -test.timeout=0 -test.run=TestApt \ + --test.apt --ci.runId=$CI_RUN_ID" testuser + else + /workspace/apt.test -test.v -test.timeout=0 -test.run=TestApt \ + --test.apt --ci.runId="$CI_RUN_ID" + fi + ' From cb73af5e9f7052670142dd207fe34b473253f8d4 Mon Sep 17 00:00:00 2001 From: Kanishk Date: Fri, 10 Jul 2026 12:54:57 +0530 Subject: [PATCH 14/18] RTECO-1564 - Enhance apt install command with --allow-downgrades option and add alias field to keypair request --- apt_test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apt_test.go b/apt_test.go index bcf7e4049..0b0910ca4 100644 --- a/apt_test.go +++ b/apt_test.go @@ -357,7 +357,10 @@ func TestAptInstall_OnTheFlyInstall(t *testing.T) { // Use bzip2: small, available in all Ubuntu/Debian distros via Artifactory // remote, and not pre-installed by the workflow prereq step — so apt always // performs a real install and writes a Commandline entry to history.log. - runJfrogCli(t, "apt", "install", "-y", "bzip2", + // --allow-downgrades: Artifactory's cached remote can trail the live base image + // (e.g. bzip2's exact-pinned libbz2-1.0 dep vs a security-updated build already + // in the container) — same allowance TestAptSetupThenNativeInstall already uses. + runJfrogCli(t, "apt", "install", "-y", "--allow-downgrades", "bzip2", "--repo="+aptRepo(), "--dist="+dist, "--trusted", @@ -691,6 +694,7 @@ Expire-Date: 1d type keypairReq struct { PairName string `json:"pairName"` PairType string `json:"pairType"` + Alias string `json:"alias"` PassPhrase string `json:"passPhrase"` PublicKey string `json:"publicKey"` PrivateKey string `json:"privateKey"` @@ -698,6 +702,7 @@ Expire-Date: 1d body, err := json.Marshal(keypairReq{ PairName: pairName, PairType: "GPG", + Alias: pairName, PassPhrase: "", PublicKey: string(pubKey), PrivateKey: string(privKey), From bb4f627c38ab0b448ade57c9835af2c0b62baa51 Mon Sep 17 00:00:00 2001 From: Kanishk Date: Fri, 10 Jul 2026 14:19:31 +0530 Subject: [PATCH 15/18] RTECO-1564 - Refactor apt import key test to ensure end-to-end signature verification and add minimal .deb builder --- apt_test.go | 103 +++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 94 insertions(+), 9 deletions(-) diff --git a/apt_test.go b/apt_test.go index 0b0910ca4..c3d8ecb4c 100644 --- a/apt_test.go +++ b/apt_test.go @@ -191,27 +191,60 @@ func TestAptSetup_TrustedFlag(t *testing.T) { assert.Contains(t, string(content), "[trusted=yes]", "trusted flag must produce [trusted=yes] in sources line") } -// TestAptSetup_ImportKey verifies --import-key fetches and installs the GPG key. -// Creates a throwaway GPG keypair in Artifactory at test runtime so the test -// is not skipped when the platform has no key pre-configured. +// TestAptSetup_ImportKey verifies --import-key fetches and installs the GPG key +// and that apt can then verify the repo's signature end-to-end. +// +// This must run against the *local* Debian repo, not the virtual repo: only a +// local repo's generated index is signed by Artifactory with the repo's +// primaryKeyPairRef. The virtual repo proxies upstream dists (e.g. Ubuntu +// "noble") whose InRelease is signed by the distro's own key, which the +// throwaway keypair we import can never verify. +// +// So we: set our keypair on the local repo, upload a real .deb under a +// local-only distribution, wait for Artifactory to generate a signed InRelease, +// then run setup --import-key and let its apt-get update verify against it. func TestAptSetup_ImportKey(t *testing.T) { initAptTest(t) requireRoot(t) defer cleanAptTest(t) - repo := aptRepo() + if _, err := exec.LookPath("dpkg-deb"); err != nil { + t.Skip("dpkg-deb not found — cannot build test .deb") + } + + localRepo := tests.AptLocalRepo + // A codename served only by the local repo, so apt fetches the + // Artifactory-signed index rather than an upstream-proxied one. + const dist = "jfrogtest" + const component = "main" pairName, cleanupKeypair := createArtifactoryGPGKeypair(t) defer cleanupKeypair() - setRepoPrimaryKeyPairRef(t, repo, pairName) - defer setRepoPrimaryKeyPairRef(t, repo, "") + // Sign the local repo (not the virtual) so its generated index carries our key. + setRepoPrimaryKeyPairRef(t, localRepo, pairName) + defer setRepoPrimaryKeyPairRef(t, localRepo, "") + + debPath := buildMinimalDeb(t) + runJfrogCli(t, "rt", "upload", debPath, localRepo+"/pool/", + "--deb="+dist+"/"+component+"/amd64", + "--flat=true", + ) + + // Artifactory calculates the Debian index asynchronously; wait for the + // signed InRelease to appear before pointing apt at it. + waitForSignedInRelease(t, localRepo, dist) - err := runJfrogCliWithoutAssertion("setup", "apt", "--repo="+repo, "--dist=noble", "--import-key") + err := runJfrogCliWithoutAssertion("setup", "apt", + "--repo="+localRepo, + "--dist="+dist, + "--component="+component, + "--import-key", + ) require.NoError(t, err) - assert.FileExists(t, keyringPath(repo, "noble")) + assert.FileExists(t, keyringPath(localRepo, dist)) - content, err := os.ReadFile(sourcesListPath(repo, "noble")) + content, err := os.ReadFile(sourcesListPath(localRepo, dist)) require.NoError(t, err) assert.Contains(t, string(content), "signed-by=") assert.NotContains(t, string(content), "trusted=yes") @@ -755,6 +788,58 @@ func setArtAuth(req *http.Request) { } } +// buildMinimalDeb builds a tiny valid .deb via dpkg-deb and returns its path. +// The package content is irrelevant — it exists only so Artifactory generates a +// Debian index for the target distribution. +func buildMinimalDeb(t *testing.T) string { + t.Helper() + root := t.TempDir() + pkgDir := filepath.Join(root, "pkg") + debianDir := filepath.Join(pkgDir, "DEBIAN") + require.NoError(t, os.MkdirAll(debianDir, 0755)) + + control := "Package: jfrog-apt-test-pkg\n" + + "Version: 1.0.0\n" + + "Architecture: amd64\n" + + "Maintainer: JFrog Apt Test \n" + + "Description: throwaway package for the apt import-key test\n" + require.NoError(t, os.WriteFile(filepath.Join(debianDir, "control"), []byte(control), 0644)) + + debPath := filepath.Join(root, "jfrog-apt-test-pkg_1.0.0_amd64.deb") + out, err := exec.Command("dpkg-deb", "--build", "--root-owner-group", pkgDir, debPath).CombinedOutput() + require.NoError(t, err, "dpkg-deb build failed: %s", out) + return debPath +} + +// waitForSignedInRelease polls the local repo's dists//InRelease until +// Artifactory has generated a GPG-signed index (async), or fails after a timeout. +func waitForSignedInRelease(t *testing.T, repo, dist string) { + t.Helper() + artURL := strings.TrimSuffix(*tests.JfrogUrl+tests.ArtifactoryEndpoint, "/") + inReleaseURL := fmt.Sprintf("%s/%s/dists/%s/InRelease", artURL, repo, dist) + + deadline := time.Now().Add(120 * time.Second) + var lastStatus int + for time.Now().Before(deadline) { + req, err := http.NewRequest(http.MethodGet, inReleaseURL, nil) + require.NoError(t, err) + setArtAuth(req) + resp, err := http.DefaultClient.Do(req) + if err == nil { + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + lastStatus = resp.StatusCode + // InRelease is the inline-signed variant; presence of the PGP header + // confirms Artifactory signed it with the repo's primaryKeyPairRef. + if resp.StatusCode == http.StatusOK && strings.Contains(string(body), "BEGIN PGP SIGNED MESSAGE") { + return + } + } + time.Sleep(2 * time.Second) + } + t.Fatalf("timed out waiting for signed InRelease at %s (last status %d)", inReleaseURL, lastStatus) +} + func isWritable(info os.FileInfo) bool { mode := info.Mode() return mode&0200 != 0 From bb3314df91060fd6586a650ab9b07dd0107930d4 Mon Sep 17 00:00:00 2001 From: Kanishk Date: Fri, 10 Jul 2026 14:30:36 +0530 Subject: [PATCH 16/18] RTECO-1564 - Enhance apt command descriptions to include detailed usage and prerequisites for better user guidance --- buildtools/cli.go | 4 ++-- docs/buildtools/apt/help.go | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/buildtools/cli.go b/buildtools/cli.go index 6b7dbe61a..f2a679d28 100644 --- a/buildtools/cli.go +++ b/buildtools/cli.go @@ -437,8 +437,8 @@ func GetCommands() []cli.Command { Name: "apt", Aliases: []string{"apt-get"}, Flags: cliutils.GetCommandFlags(cliutils.Apt), - Usage: aptdocs.GetDescription(), - HelpName: corecommon.CreateUsage("apt", aptdocs.GetDescription(), aptdocs.Usage), + Usage: corecommon.ResolveDescription(aptdocs.GetDescription(), aptdocs.GetAIDescription()), + HelpName: corecommon.CreateUsage("apt", corecommon.ResolveDescription(aptdocs.GetDescription(), aptdocs.GetAIDescription()), aptdocs.Usage), UsageText: aptdocs.GetArguments(), ArgsUsage: common.CreateEnvVars(), SkipFlagParsing: true, diff --git a/docs/buildtools/apt/help.go b/docs/buildtools/apt/help.go index 489a37bb0..c9fa62d6b 100644 --- a/docs/buildtools/apt/help.go +++ b/docs/buildtools/apt/help.go @@ -6,6 +6,31 @@ func GetDescription() string { return "Run apt-get commands against a JFrog Artifactory Debian repository." } +func GetAIDescription() string { + return `Run apt package-manager commands (install, apt-cache, dpkg-query, etc.) against a JFrog Artifactory Debian repository. Wraps the native apt-get/apt-cache/dpkg-query binaries and injects Artifactory authentication via a temporary sources.list that is removed after the command completes. + +When to use: +- Installing Debian/Ubuntu packages from an Artifactory Debian repository with on-the-fly authentication. +- Running one-off apt commands against Artifactory without persistently editing system apt config. + +Prerequisites: +- A Debian/Ubuntu host with apt-get available. +- A configured server. +- Root (or sudo) to modify apt state, unless the operation is read-only (e.g. apt-cache). + +Common patterns: + $ jf apt install curl --repo=ci-debian-local --dist=bookworm + $ jf apt install curl vim --repo=ci-debian-local --dist=bookworm --component=main + $ jf apt install curl --skip-login + +Gotchas: +- --repo and --dist are required for on-the-fly auth; without them apt falls back to the system config. +- --skip-login bypasses auth injection and uses the existing sources.list. +- For persistent authentication, use 'jf setup apt' to write a managed sources.list entry instead. + +Related: jf setup apt` +} + func GetArguments() string { return ` apt native-command Wraps apt-get/apt-cache/dpkg-query commands with JFrog Artifactory From 54e81310bc69f9a2c616f5e2b750dcd015761afe Mon Sep 17 00:00:00 2001 From: Kanishk Date: Fri, 10 Jul 2026 14:34:10 +0530 Subject: [PATCH 17/18] RTECO-1564 - Trigger Debian reindex in apt import-key test The import-key test uploaded a .deb but polled for a signed InRelease that never appeared (404 for the full timeout): local-rt-setup's Artifactory does not auto-calculate the Debian index on deploy. Explicitly POST /api/deb/reindex/{repo} after upload, re-nudge it periodically while polling, and extend the wait to 180s so the signed InRelease is generated before apt consumes it. Co-Authored-By: Claude Opus 4.8 --- apt_test.go | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/apt_test.go b/apt_test.go index c3d8ecb4c..11a011d93 100644 --- a/apt_test.go +++ b/apt_test.go @@ -230,8 +230,10 @@ func TestAptSetup_ImportKey(t *testing.T) { "--flat=true", ) - // Artifactory calculates the Debian index asynchronously; wait for the - // signed InRelease to appear before pointing apt at it. + // local-rt-setup's Artifactory does not auto-calculate the Debian index on + // deploy, so trigger it explicitly, then wait for the signed InRelease to + // appear before pointing apt at it. + reindexDebianRepo(t, localRepo) waitForSignedInRelease(t, localRepo, dist) err := runJfrogCliWithoutAssertion("setup", "apt", @@ -811,15 +813,37 @@ func buildMinimalDeb(t *testing.T) string { return debPath } +// reindexDebianRepo triggers Artifactory's Debian metadata calculation for the +// repo. local-rt-setup's Artifactory does not index automatically on deploy, so +// the dists//{Release,InRelease,Packages} files only exist after this call. +func reindexDebianRepo(t *testing.T, repo string) { + t.Helper() + artURL := strings.TrimSuffix(*tests.JfrogUrl+tests.ArtifactoryEndpoint, "/") + req, err := http.NewRequest(http.MethodPost, artURL+"/api/deb/reindex/"+repo, nil) + require.NoError(t, err) + setArtAuth(req) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + // 200 OK or 202 Accepted (async) are both fine; calculation still completes + // after the response, so the caller must poll for the generated index. + require.Truef(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusAccepted, + "reindex %s returned %d: %s", repo, resp.StatusCode, body) +} + // waitForSignedInRelease polls the local repo's dists//InRelease until // Artifactory has generated a GPG-signed index (async), or fails after a timeout. +// It re-triggers reindex periodically in case the first calculation missed the +// freshly uploaded package. func waitForSignedInRelease(t *testing.T, repo, dist string) { t.Helper() artURL := strings.TrimSuffix(*tests.JfrogUrl+tests.ArtifactoryEndpoint, "/") inReleaseURL := fmt.Sprintf("%s/%s/dists/%s/InRelease", artURL, repo, dist) - deadline := time.Now().Add(120 * time.Second) + deadline := time.Now().Add(180 * time.Second) var lastStatus int + attempts := 0 for time.Now().Before(deadline) { req, err := http.NewRequest(http.MethodGet, inReleaseURL, nil) require.NoError(t, err) @@ -835,6 +859,11 @@ func waitForSignedInRelease(t *testing.T, repo, dist string) { return } } + attempts++ + // Nudge the calculation again every ~30s while waiting. + if attempts%15 == 0 { + reindexDebianRepo(t, repo) + } time.Sleep(2 * time.Second) } t.Fatalf("timed out waiting for signed InRelease at %s (last status %d)", inReleaseURL, lastStatus) From 2eb4793c7345974506cdbb86f7da6372e4fb17ce Mon Sep 17 00:00:00 2001 From: Kanishk Date: Fri, 10 Jul 2026 14:56:40 +0530 Subject: [PATCH 18/18] RTECO-1564 - Refactor apt import key test to use remote Debian repo and simplify keypair handling --- apt_test.go | 131 ++++++++-------------------------------------------- 1 file changed, 18 insertions(+), 113 deletions(-) diff --git a/apt_test.go b/apt_test.go index 11a011d93..bcfdad615 100644 --- a/apt_test.go +++ b/apt_test.go @@ -194,59 +194,43 @@ func TestAptSetup_TrustedFlag(t *testing.T) { // TestAptSetup_ImportKey verifies --import-key fetches and installs the GPG key // and that apt can then verify the repo's signature end-to-end. // -// This must run against the *local* Debian repo, not the virtual repo: only a -// local repo's generated index is signed by Artifactory with the repo's -// primaryKeyPairRef. The virtual repo proxies upstream dists (e.g. Ubuntu -// "noble") whose InRelease is signed by the distro's own key, which the -// throwaway keypair we import can never verify. +// This runs against a *remote* Debian repo. When a Debian remote repo has a +// primaryKeyPairRef, Artifactory strips the upstream signature and re-signs the +// proxied metadata with that keypair, so the key we import can verify it. (The +// virtual repo proxies the upstream signature through unchanged, so the imported +// key would never match.) // -// So we: set our keypair on the local repo, upload a real .deb under a -// local-only distribution, wait for Artifactory to generate a signed InRelease, -// then run setup --import-key and let its apt-get update verify against it. +// The re-sign happens on the first fetch after the key is attached. Earlier +// tests in this suite fetch the "noble" dist, caching it upstream-signed, so we +// deliberately use a different codename ("jammy") that is still fresh here. func TestAptSetup_ImportKey(t *testing.T) { initAptTest(t) requireRoot(t) defer cleanAptTest(t) - if _, err := exec.LookPath("dpkg-deb"); err != nil { - t.Skip("dpkg-deb not found — cannot build test .deb") - } - - localRepo := tests.AptLocalRepo - // A codename served only by the local repo, so apt fetches the - // Artifactory-signed index rather than an upstream-proxied one. - const dist = "jfrogtest" + // Ubuntu remote (archive.ubuntu.com). "jammy" (22.04 LTS) is always available + // and is not fetched by earlier tests, so its index is first pulled — and thus + // re-signed with our key — after we attach the keypair below. + repo := tests.AptRemoteRepo + const dist = "jammy" const component = "main" pairName, cleanupKeypair := createArtifactoryGPGKeypair(t) defer cleanupKeypair() - // Sign the local repo (not the virtual) so its generated index carries our key. - setRepoPrimaryKeyPairRef(t, localRepo, pairName) - defer setRepoPrimaryKeyPairRef(t, localRepo, "") - - debPath := buildMinimalDeb(t) - runJfrogCli(t, "rt", "upload", debPath, localRepo+"/pool/", - "--deb="+dist+"/"+component+"/amd64", - "--flat=true", - ) - - // local-rt-setup's Artifactory does not auto-calculate the Debian index on - // deploy, so trigger it explicitly, then wait for the signed InRelease to - // appear before pointing apt at it. - reindexDebianRepo(t, localRepo) - waitForSignedInRelease(t, localRepo, dist) + setRepoPrimaryKeyPairRef(t, repo, pairName) + defer setRepoPrimaryKeyPairRef(t, repo, "") err := runJfrogCliWithoutAssertion("setup", "apt", - "--repo="+localRepo, + "--repo="+repo, "--dist="+dist, "--component="+component, "--import-key", ) require.NoError(t, err) - assert.FileExists(t, keyringPath(localRepo, dist)) + assert.FileExists(t, keyringPath(repo, dist)) - content, err := os.ReadFile(sourcesListPath(localRepo, dist)) + content, err := os.ReadFile(sourcesListPath(repo, dist)) require.NoError(t, err) assert.Contains(t, string(content), "signed-by=") assert.NotContains(t, string(content), "trusted=yes") @@ -790,85 +774,6 @@ func setArtAuth(req *http.Request) { } } -// buildMinimalDeb builds a tiny valid .deb via dpkg-deb and returns its path. -// The package content is irrelevant — it exists only so Artifactory generates a -// Debian index for the target distribution. -func buildMinimalDeb(t *testing.T) string { - t.Helper() - root := t.TempDir() - pkgDir := filepath.Join(root, "pkg") - debianDir := filepath.Join(pkgDir, "DEBIAN") - require.NoError(t, os.MkdirAll(debianDir, 0755)) - - control := "Package: jfrog-apt-test-pkg\n" + - "Version: 1.0.0\n" + - "Architecture: amd64\n" + - "Maintainer: JFrog Apt Test \n" + - "Description: throwaway package for the apt import-key test\n" - require.NoError(t, os.WriteFile(filepath.Join(debianDir, "control"), []byte(control), 0644)) - - debPath := filepath.Join(root, "jfrog-apt-test-pkg_1.0.0_amd64.deb") - out, err := exec.Command("dpkg-deb", "--build", "--root-owner-group", pkgDir, debPath).CombinedOutput() - require.NoError(t, err, "dpkg-deb build failed: %s", out) - return debPath -} - -// reindexDebianRepo triggers Artifactory's Debian metadata calculation for the -// repo. local-rt-setup's Artifactory does not index automatically on deploy, so -// the dists//{Release,InRelease,Packages} files only exist after this call. -func reindexDebianRepo(t *testing.T, repo string) { - t.Helper() - artURL := strings.TrimSuffix(*tests.JfrogUrl+tests.ArtifactoryEndpoint, "/") - req, err := http.NewRequest(http.MethodPost, artURL+"/api/deb/reindex/"+repo, nil) - require.NoError(t, err) - setArtAuth(req) - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - body, _ := io.ReadAll(resp.Body) - _ = resp.Body.Close() - // 200 OK or 202 Accepted (async) are both fine; calculation still completes - // after the response, so the caller must poll for the generated index. - require.Truef(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusAccepted, - "reindex %s returned %d: %s", repo, resp.StatusCode, body) -} - -// waitForSignedInRelease polls the local repo's dists//InRelease until -// Artifactory has generated a GPG-signed index (async), or fails after a timeout. -// It re-triggers reindex periodically in case the first calculation missed the -// freshly uploaded package. -func waitForSignedInRelease(t *testing.T, repo, dist string) { - t.Helper() - artURL := strings.TrimSuffix(*tests.JfrogUrl+tests.ArtifactoryEndpoint, "/") - inReleaseURL := fmt.Sprintf("%s/%s/dists/%s/InRelease", artURL, repo, dist) - - deadline := time.Now().Add(180 * time.Second) - var lastStatus int - attempts := 0 - for time.Now().Before(deadline) { - req, err := http.NewRequest(http.MethodGet, inReleaseURL, nil) - require.NoError(t, err) - setArtAuth(req) - resp, err := http.DefaultClient.Do(req) - if err == nil { - body, _ := io.ReadAll(resp.Body) - _ = resp.Body.Close() - lastStatus = resp.StatusCode - // InRelease is the inline-signed variant; presence of the PGP header - // confirms Artifactory signed it with the repo's primaryKeyPairRef. - if resp.StatusCode == http.StatusOK && strings.Contains(string(body), "BEGIN PGP SIGNED MESSAGE") { - return - } - } - attempts++ - // Nudge the calculation again every ~30s while waiting. - if attempts%15 == 0 { - reindexDebianRepo(t, repo) - } - time.Sleep(2 * time.Second) - } - t.Fatalf("timed out waiting for signed InRelease at %s (last status %d)", inReleaseURL, lastStatus) -} - func isWritable(info os.FileInfo) bool { mode := info.Mode() return mode&0200 != 0