diff --git a/.github/workflows/aptTests.yml b/.github/workflows/aptTests.yml new file mode 100644 index 000000000..24686f6cd --- /dev/null +++ b/.github/workflows/aptTests.yml @@ -0,0 +1,102 @@ +name: apt Tests + +on: + push: + branches: [RTECO-1564] + workflow_call: + workflow_dispatch: + +jobs: + apt-Tests: + name: apt tests (${{ matrix.distro.name }}, ${{ 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 + - image: debian:13 + name: debian-trixie + dist: trixie + user_mode: + - root + - nonroot + + steps: + - 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 + + # 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' + + # 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: + CGO_ENABLED: "0" + + # --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: | + 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 + + 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 + ' diff --git a/apt_test.go b/apt_test.go new file mode 100644 index 000000000..bcfdad615 --- /dev/null +++ b/apt_test.go @@ -0,0 +1,791 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "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 } + +// 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) +} + +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 +// and that apt can then verify the repo's signature end-to-end. +// +// 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.) +// +// 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) + + // 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() + setRepoPrimaryKeyPairRef(t, repo, pairName) + defer setRepoPrimaryKeyPairRef(t, repo, "") + + err := runJfrogCliWithoutAssertion("setup", "apt", + "--repo="+repo, + "--dist="+dist, + "--component="+component, + "--import-key", + ) + require.NoError(t, err) + + assert.FileExists(t, keyringPath(repo, 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") +} + +// 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.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. +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 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() + // 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. + // --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", + ) + + _, err := os.Stat("/usr/bin/bzip2") + assert.NoError(t, err, "bzip2 must be installed after jf apt install") + + assertInstalledFromArtifactory(t, logOffset) +} + +// 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 := testDist() + + // 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", "trixie", "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 { + 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)) + }) + } +} + +// ── 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) } +} + +// 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") + if err != nil { + t.Logf("Warning: cannot read /var/log/apt/history.log: %v — skipping Artifactory source check", err) + return + } + if offset > int64(len(data)) { + 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:] + 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) +} + +// 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") + + 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, "/") + + 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"` + } + body, err := json.Marshal(keypairReq{ + PairName: pairName, + PairType: "GPG", + Alias: pairName, + 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) + respBody, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + require.Equal(t, wantStatus, resp.StatusCode, "%s %s\nresponse: %s", method, url, respBody) +} + +// 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 +} + +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..f2a679d28 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: 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, + 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..c9fa62d6b --- /dev/null +++ b/docs/buildtools/apt/help.go @@ -0,0 +1,47 @@ +package apt + +var Usage = []string{"apt [command options]"} + +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 + 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..9b2f68696 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.20260709150839-fe13c9b13f5b //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..0a1be4610 100644 --- a/go.sum +++ b/go.sum @@ -406,10 +406,10 @@ 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.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-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= 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_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_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..c72cfc69a --- /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}", "${APT_DEBIAN_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..f65689259 100644 --- a/utils/tests/consts.go +++ b/utils/tests/consts.go @@ -111,6 +111,10 @@ 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" + 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" PoetryVirtualRepositoryConfig = "poetry_virtual_repository_config.json" @@ -223,6 +227,10 @@ var ( AlpineLocalRepo = "cli-alpine-local" AlpineRemoteRepo = "cli-alpine-remote" 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" PoetryVirtualRepo = "cli-poetry-virtual" diff --git a/utils/tests/utils.go b/utils/tests/utils.go index c3de68a9d..366313618 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,10 @@ var reposConfigMap = map[*string]string{ &AlpineLocalRepo: AlpineLocalRepositoryConfig, &AlpineRemoteRepo: AlpineRemoteRepositoryConfig, &AlpineVirtualRepo: AlpineVirtualRepositoryConfig, + &AptLocalRepo: AptLocalRepositoryConfig, + &AptRemoteRepo: AptRemoteRepositoryConfig, + &AptDebianRemoteRepo: AptDebianRemoteRepositoryConfig, + &AptVirtualRepo: AptVirtualRepositoryConfig, &ConanLocalRepo: ConanLocalRepositoryConfig, &ConanRemoteRepo: ConanRemoteRepositoryConfig, &ConanVirtualRepo: ConanVirtualRepositoryConfig, @@ -404,6 +410,7 @@ func GetNonVirtualRepositories() map[*string]string { TestUv: {&UvLocalRepo, &UvRemoteRepo}, TestNix: {&NixLocalRepo, &NixRemoteRepo}, TestAlpine: {&AlpineLocalRepo, &AlpineRemoteRepo}, + TestApt: {&AptLocalRepo, &AptRemoteRepo, &AptDebianRemoteRepo}, TestAgentPlugins: {&AgentPluginsLocalRepo}, TestConan: {&ConanLocalRepo, &ConanRemoteRepo}, TestHelm: {&HelmLocalRepo}, @@ -438,6 +445,7 @@ func GetVirtualRepositories() map[*string]string { TestUv: {&UvVirtualRepo}, TestNix: {&NixVirtualRepo}, TestAlpine: {&AlpineVirtualRepo}, + TestApt: {&AptVirtualRepo}, TestAgentPlugins: {}, TestConan: {&ConanVirtualRepo}, TestHelm: {}, @@ -554,6 +562,10 @@ 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_DEBIAN_REMOTE_REPO}": AptDebianRemoteRepo, + "${APT_VIRTUAL_REPO}": AptVirtualRepo, "${CONAN_LOCAL_REPO}": ConanLocalRepo, "${CONAN_REMOTE_REPO}": ConanRemoteRepo, "${CONAN_VIRTUAL_REPO}": ConanVirtualRepo, @@ -634,6 +646,10 @@ func AddTimestampToGlobalVars() { AlpineLocalRepo += uniqueSuffix AlpineRemoteRepo += uniqueSuffix AlpineVirtualRepo += uniqueSuffix + AptLocalRepo += uniqueSuffix + AptRemoteRepo += uniqueSuffix + AptDebianRemoteRepo += uniqueSuffix + AptVirtualRepo += uniqueSuffix ConanLocalRepo += uniqueSuffix ConanRemoteRepo += uniqueSuffix ConanVirtualRepo += uniqueSuffix