From 928f278fcb88b27fd2a3fd26298ea53da6250190 Mon Sep 17 00:00:00 2001 From: Brian Goff Date: Tue, 10 Mar 2026 16:15:46 -0700 Subject: [PATCH 1/2] frontend: prefer dnf over tdnf to work around tdnf GPG and forcearch limitations tdnf fails when installing signed local RPMs (from the cmdline virtual repo) into an installroot with a populated RPM database, because it requires a gpgkey entry for cmdline which is a synthetic repo with no config. This manifests when building containers with a custom base image on azlinux/mariner distros. Rather than working around individual tdnf bugs, prefer dnf when it is available. The install script now checks for dnf at runtime and switches from tdnf transparently. The same-platform worker bootstrap is updated to install dnf as a separate first step (mirroring the cross-arch path) so that subsequent installs benefit from dnf. Also remove hardcoded GPG email from test key ID lookups in favor of selecting the first available key. Signed-off-by: Brian Goff --- targets/linux/rpm/distro/dnf_install.go | 18 +++-- targets/linux/rpm/distro/worker.go | 9 +++ test/signing_test.go | 102 ++++++++++++++++++++++++ test/target_almalinux_test.go | 17 +++- test/target_azlinux_test.go | 55 ++----------- test/target_rockylinux_test.go | 17 +++- test/target_ubuntu_test.go | 2 +- 7 files changed, 158 insertions(+), 62 deletions(-) diff --git a/targets/linux/rpm/distro/dnf_install.go b/targets/linux/rpm/distro/dnf_install.go index 9779bb05f..0db90d27e 100644 --- a/targets/linux/rpm/distro/dnf_install.go +++ b/targets/linux/rpm/distro/dnf_install.go @@ -183,12 +183,18 @@ if [ -x "$import_keys_path" ]; then "$import_keys_path" fi +if [ "$cmd" = "tdnf" ] && command -v dnf &>/dev/null; then + # tdnf has a lot of limitations that cause issues (no --forcearch, issues with gpg keys on local file installs) + # We already have dnf, so prefer that. + cmd="dnf" +fi + if [ -n "$force_arch" ]; then - if [ "$cmd" = "tdnf" ]; then - echo "tdnf does not support --forcearch; cross-arch installs must use dnf" >&2 - exit 70 - fi - install_flags="$install_flags --forcearch=$force_arch" + if [ "$cmd" = "tdnf" ]; then + echo "tdnf does not support --forcearch; cross-arch installs must use dnf" >&2 + exit 70 + fi + install_flags="$install_flags --forcearch=$force_arch" fi $cmd $dnf_sub_cmd $install_flags "${@}" @@ -276,6 +282,8 @@ func DnfInstall(cfg *dnfInstallConfig, releaseVer string, pkgs []string) llb.Run return dnfCommand(cfg, releaseVer, "dnf", append([]string{"install"}, pkgs...), nil) } +// TdnfInstall uses tdnf to install packages +// NOTE: tdnf will be automatically upgraded to dnf to work around tdnf limitations *if* dnf is available func TdnfInstall(cfg *dnfInstallConfig, releaseVer string, pkgs []string) llb.RunOption { return dnfCommand(cfg, releaseVer, "tdnf", append([]string{"install"}, pkgs...), nil) } diff --git a/targets/linux/rpm/distro/worker.go b/targets/linux/rpm/distro/worker.go index 2418d7e91..9281bbefa 100644 --- a/targets/linux/rpm/distro/worker.go +++ b/targets/linux/rpm/distro/worker.go @@ -3,6 +3,7 @@ package distro import ( "context" "encoding/json" + "slices" "github.com/containerd/platforms" "github.com/moby/buildkit/client/llb" @@ -142,6 +143,14 @@ func (cfg *Config) workerWithBuildPlatform(sOpt dalec.SourceOpts, buildPlat ocis } if samePlatform(targetPlat, buildPlat) { + if slices.Contains(cfg.BuilderPackages, "dnf") { + // Install dnf first since this will be bootstrapped with a different package manager + // This keeps the package cache for the bootstrap mananager separate from the other base packages we use. + targetBase = targetBase.Run( + dalec.WithConstraints(append(opts, llb.Platform(targetPlat))...), + cfg.Install([]string{"dnf"}, installOpts...), + ).Root() + } return targetBase.Run( dalec.WithConstraints(append(opts, llb.Platform(targetPlat))...), cfg.Install(cfg.BuilderPackages, installOpts...), diff --git a/test/signing_test.go b/test/signing_test.go index 36f907640..6d511747b 100644 --- a/test/signing_test.go +++ b/test/signing_test.go @@ -598,3 +598,105 @@ func distroSkipSigningTest(t *testing.T, spec *dalec.Spec, buildTarget string, e } } } + +// signRPMs signs all RPM files in the package state using a GPG key. +// The worker state must have rpmsign available (or tdnf/dnf to install it). +// The gpgKey state is expected to have a private key at /private.key +// (as produced by [generateGPGKey]). +// The pkgState is expected to have RPMs under /RPMS//*.rpm +// (the standard package target output). +// It returns the modified package state with signed RPMs. +func signRPMs(worker llb.State, gpgKey llb.State, pkgState llb.State) llb.State { + pg := dalec.ProgressGroup("Sign RPMs with GPG key") + + scriptDt := `#!/usr/bin/env bash +set -eux -o pipefail + +if ! command -v rpmsign &> /dev/null; then + if command -v tdnf &> /dev/null; then + tdnf install -y rpm-sign + elif command -v dnf &> /dev/null; then + dnf install -y rpm-sign + fi +fi + +gpg --import < /tmp/gpg/private.key +ID=$(gpg --list-keys --keyid-format LONG | awk '/^pub/{print $2}' | cut -d/ -f2 | head -1) + +echo "%_gpg_name $ID" > ~/.rpmmacros + +find /tmp/rpms/RPMS -name "*.rpm" -exec rpmsign --addsign {} \; +` + + script := llb.Scratch().File( + llb.Mkfile("/script.sh", 0o755, []byte(scriptDt)), + pg, + ) + + return worker.Run( + llb.AddMount("/tmp/signing", script, llb.Readonly), + llb.AddMount("/tmp/gpg", gpgKey, llb.Readonly), + llb.AddMount("/tmp/rpms", pkgState), + dalec.ShArgs("/tmp/signing/script.sh"), + pg, + ).GetMount("/tmp/rpms") +} + +// testSignedRPMCustomBaseImage tests that signed RPMs can be installed into +// a container with a custom base image. +// +// This reproduces a bug where the tdnfrepogpgcheck plugin rejects signed RPMs +// installed via "tdnf install /path/to/signed.rpm --installroot=/tmp/rootfs" +// because the @cmdline virtual repo has no gpgkey entry. +// +// The distroImageRef parameter is the image reference for the distro's base +// image (e.g., azlinux.Azlinux3Ref), which is used as the custom base image +// in the spec. +func testSignedRPMCustomBaseImage(ctx context.Context, t *testing.T, targetCfg targetConfig, distroImageRef string) { + t.Run("signed rpm with custom base image", func(t *testing.T) { + t.Parallel() + ctx := startTestSpan(ctx, t) + + testEnv.RunTest(ctx, t, func(ctx context.Context, client gwclient.Client) { + // Get the worker state — we need it to generate GPG keys and sign RPMs. + sr := newSolveRequest(withBuildTarget(targetCfg.Worker), withSpec(ctx, t, nil)) + w := reqToState(ctx, client, sr, t) + + // Generate a GPG key pair for signing. + gpgKey := generateGPGKey(w, true) + + // Create a simple spec and build the RPM package. + spec := newSimpleSpec() + pkgSr := newSolveRequest(withSpec(ctx, t, spec), withBuildTarget(targetCfg.Package)) + pkgSt := reqToState(ctx, client, pkgSr, t) + + // Sign the RPMs on the worker using rpmsign --addsign. + signedPkgSt := signRPMs(w, gpgKey, pkgSt) + + // Create a container spec with a custom base image. + // This triggers skipBase=true in BuildContainer, meaning the RPMs + // are installed via "tdnf install /path/to/signed.rpm --installroot=/tmp/rootfs" + // into the custom base image's rootfs. + spec.Image = &dalec.ImageConfig{ + Entrypoint: "/usr/bin/foo", + Bases: []dalec.BaseImage{ + { + Rootfs: dalec.Source{ + DockerImage: &dalec.SourceDockerImage{ + Ref: distroImageRef, + }, + }, + }, + }, + } + + containerSr := newSolveRequest( + withSpec(ctx, t, spec), + withBuildTarget(targetCfg.Container), + withBuildContext(ctx, t, dalec.GenericPkg, signedPkgSt), + ) + + solveT(ctx, t, client, containerSr) + }) + }) +} diff --git a/test/target_almalinux_test.go b/test/target_almalinux_test.go index f938a6694..8650b2e21 100644 --- a/test/target_almalinux_test.go +++ b/test/target_almalinux_test.go @@ -1,6 +1,7 @@ package test import ( + "context" "testing" ocispecs "github.com/opencontainers/image-spec/specs-go/v1" @@ -11,7 +12,7 @@ func TestAlmalinux9(t *testing.T) { t.Parallel() ctx := startTestSpan(baseCtx, t) - testLinuxDistro(ctx, t, testLinuxConfig{ + cfg := testLinuxConfig{ Target: targetConfig{ Key: "almalinux9", Package: "almalinux9/rpm", @@ -51,14 +52,16 @@ func TestAlmalinux9(t *testing.T) { {OS: "linux", Architecture: "arm64"}, }, PackageOutputPath: rpmTargetOutputPath("el9"), - }) + } + testLinuxDistro(ctx, t, cfg) + testAlmalinuxExtra(ctx, t, cfg, almalinux.ConfigV9.ImageRef) } func TestAlmalinux8(t *testing.T) { t.Parallel() ctx := startTestSpan(baseCtx, t) - testLinuxDistro(ctx, t, testLinuxConfig{ + cfg := testLinuxConfig{ Target: targetConfig{ Package: "almalinux8/rpm", Container: "almalinux8/container", @@ -97,5 +100,11 @@ func TestAlmalinux8(t *testing.T) { {OS: "linux", Architecture: "arm64"}, }, PackageOutputPath: rpmTargetOutputPath("el8"), - }) + } + testLinuxDistro(ctx, t, cfg) + testAlmalinuxExtra(ctx, t, cfg, almalinux.ConfigV8.ImageRef) +} + +func testAlmalinuxExtra(ctx context.Context, t *testing.T, cfg testLinuxConfig, distroImageRef string) { + testSignedRPMCustomBaseImage(ctx, t, cfg.Target, distroImageRef) } diff --git a/test/target_azlinux_test.go b/test/target_azlinux_test.go index fd1a94eff..1f115861e 100644 --- a/test/target_azlinux_test.go +++ b/test/target_azlinux_test.go @@ -63,7 +63,7 @@ func TestMariner2(t *testing.T) { Worker: workerConfig{ ContextName: azlinux.Mariner2WorkerContextName, CreateRepo: createYumRepo(azlinux.Mariner2Config), - SignRepo: signRepoAzLinux, + SignRepo: signRepoDnf, TestRepoConfig: azlinuxTestRepoConfig, }, Release: OSRelease{ @@ -79,7 +79,7 @@ func TestMariner2(t *testing.T) { } testLinuxDistro(ctx, t, cfg) - testAzlinuxExtra(ctx, t, cfg) + testAzlinuxExtra(ctx, t, cfg, azlinux.Mariner2Config.ImageRef) } func TestAzlinux3(t *testing.T) { @@ -106,7 +106,7 @@ func TestAzlinux3(t *testing.T) { Worker: workerConfig{ ContextName: azlinux.Azlinux3WorkerContextName, CreateRepo: createYumRepo(azlinux.Azlinux3Config), - SignRepo: signRepoAzLinux, + SignRepo: signRepoDnf, TestRepoConfig: azlinuxTestRepoConfig, SysextWorker: azlinux.Azlinux3Config.SysextWorker, }, @@ -122,7 +122,7 @@ func TestAzlinux3(t *testing.T) { PackageOutputPath: rpmTargetOutputPath("azl3"), } testLinuxDistro(ctx, t, cfg) - testAzlinuxExtra(ctx, t, cfg) + testAzlinuxExtra(ctx, t, cfg, azlinux.Azlinux3Config.ImageRef) t.Run("ca-certs override", func(t *testing.T) { t.Parallel() @@ -131,12 +131,14 @@ func TestAzlinux3(t *testing.T) { }) } -func testAzlinuxExtra(ctx context.Context, t *testing.T, cfg testLinuxConfig) { +func testAzlinuxExtra(ctx context.Context, t *testing.T, cfg testLinuxConfig, distroImageRef string) { t.Run("base deps", func(t *testing.T) { t.Parallel() ctx := startTestSpan(ctx, t) testAzlinuxBaseDeps(ctx, t, cfg.Target) }) + + testSignedRPMCustomBaseImage(ctx, t, cfg.Target, distroImageRef) } func testAzlinuxCaCertsOverride(ctx context.Context, t *testing.T, target targetConfig) { @@ -174,49 +176,6 @@ func azlinuxListSignFiles(ver string) func(*dalec.Spec, ocispecs.Platform) []str } } -func signRepoAzLinux(gpgKey llb.State, repoPath string) llb.StateOption { - // key should be a state that has a public key under /public.key - return func(in llb.State) llb.State { - // For tdnf-based distros (Azlinux, Mariner), only sign repo metadata. - // tdnf only verifies repo metadata signatures, not individual package signatures. - // This is different from dnf which verifies both. - - scriptDt := ` -#!/usr/bin/env bash - -set -eux -o pipefail - -gpg --import < /tmp/gpg/private.key -ID=$(gpg --list-keys --keyid-format LONG | grep -B 2 'test@example.com' | grep 'pub' | awk '{print $2}' | cut -d'/' -f2) - -# For tdnf-based distros, only sign repo metadata, not individual packages -# tdnf only checks repo metadata signatures, not package signatures -# Signing packages can hang if rpmsign tries to prompt for passphrase - -# Regenerate repo metadata -rm -rf ` + repoPath + `/repodata -createrepo --compatibility ` + repoPath + ` - -# Sign only the repo metadata -gpg --detach-sign --default-key "$ID" --armor --yes ` + repoPath + `/repodata/repomd.xml -` - - pg := dalec.ProgressGroup("in-signing-script") - - script := llb.Scratch().File( - llb.Mkfile("/script.sh", 0o755, []byte(scriptDt)), - pg, - ) - - return in.Run( - llb.AddMount("/tmp/signing", script, llb.Readonly), - llb.AddMount("/tmp/gpg", gpgKey, llb.Readonly), - dalec.ShArgs("/tmp/signing/script.sh"), - pg, - ).Root() - } -} - func signRepoDnf(gpgKey llb.State, repoPath string) llb.StateOption { // key should be a state that has a public key under /public.key return func(in llb.State) llb.State { diff --git a/test/target_rockylinux_test.go b/test/target_rockylinux_test.go index 364cbaa70..d912ac607 100644 --- a/test/target_rockylinux_test.go +++ b/test/target_rockylinux_test.go @@ -1,6 +1,7 @@ package test import ( + "context" "testing" ocispecs "github.com/opencontainers/image-spec/specs-go/v1" @@ -11,7 +12,7 @@ func TestRockylinux9(t *testing.T) { t.Parallel() ctx := startTestSpan(baseCtx, t) - testLinuxDistro(ctx, t, testLinuxConfig{ + cfg := testLinuxConfig{ Target: targetConfig{ Key: "rockylinux9", Package: "rockylinux9/rpm", @@ -51,14 +52,16 @@ func TestRockylinux9(t *testing.T) { {OS: "linux", Architecture: "arm64"}, }, PackageOutputPath: rpmTargetOutputPath("el9"), - }) + } + testLinuxDistro(ctx, t, cfg) + testRockylinuxExtra(ctx, t, cfg, rockylinux.ConfigV9.ImageRef) } func TestRockylinux8(t *testing.T) { t.Parallel() ctx := startTestSpan(baseCtx, t) - testLinuxDistro(ctx, t, testLinuxConfig{ + cfg := testLinuxConfig{ Target: targetConfig{ Package: "rockylinux8/rpm", Container: "rockylinux8/container", @@ -97,5 +100,11 @@ func TestRockylinux8(t *testing.T) { {OS: "linux", Architecture: "arm64"}, }, PackageOutputPath: rpmTargetOutputPath("el8"), - }) + } + testLinuxDistro(ctx, t, cfg) + testRockylinuxExtra(ctx, t, cfg, rockylinux.ConfigV8.ImageRef) +} + +func testRockylinuxExtra(ctx context.Context, t *testing.T, cfg testLinuxConfig, distroImageRef string) { + testSignedRPMCustomBaseImage(ctx, t, cfg.Target, distroImageRef) } diff --git a/test/target_ubuntu_test.go b/test/target_ubuntu_test.go index 730963170..62730040e 100644 --- a/test/target_ubuntu_test.go +++ b/test/target_ubuntu_test.go @@ -123,7 +123,7 @@ func signRepoUbuntu(gpgKey llb.State, repoPath string) llb.StateOption { llb.AddMount("/tmp/gpg", gpgKey, llb.Readonly), dalec.ProgressGroup("Importing gpg key")). Run( - dalec.ShArgs(`ID=$(gpg --list-keys --keyid-format LONG | grep -B 2 'test@example.com' | grep 'pub' | awk '{print $2}' | cut -d'/' -f2) && \ + dalec.ShArgs(`ID=$(gpg --list-keys --keyid-format LONG | awk '/^pub/{print $2}' | cut -d/ -f2 | head -1) && \ gpg --list-keys --keyid-format LONG && \ gpg --default-key $ID -abs -o `+repoPath+`/Release.gpg `+repoPath+`/Release && \ gpg --default-key "$ID" --clearsign -o `+repoPath+`/InRelease `+repoPath+`/Release`), From 0a95d13a3d16db5dee89216f1c922974a749a477 Mon Sep 17 00:00:00 2001 From: Brian Goff Date: Wed, 11 Mar 2026 11:46:49 -0700 Subject: [PATCH 2/2] frontend: rewrite HandleDepsOnly to use pkg/container build infra The way depsonly worked was always a bit janky and didn't actually support the full dependency constraint specification. Additionally I found the shift to dnf from tdnf broke due to `--alldeps` being missing (possibly just in mariner2, but still missing). This shifts depsonly to use BuildPkg and BuildContainer where we create a meta package with just the runtime deps. Because depsonly allows specifying a partial spec (ie missing things like name, license, other normally required fields) we have to fill in those details so rpmbuild can succeed. Add deps-only integration tests for all RPM distros with two sub-tests: - minimal spec: only runtime deps, verifies curl is installed - full spec: includes sources, build steps, and a shell script artifact; verifies runtime deps are installed and build artifacts are excluded - replaces the "e2e" test in docker-bake.hcl and tets all relevant distros Signed-off-by: Brian Goff --- docker-bake.hcl | 38 +---------- targets/linux/rpm/distro/container.go | 34 +++++++-- targets/linux/rpm/distro/dnf_install.go | 26 ------- test/linux_target_test.go | 91 +++++++++++++++++++++++++ test/target_almalinux_test.go | 2 + test/target_azlinux_test.go | 2 + test/target_rockylinux_test.go | 2 + 7 files changed, 125 insertions(+), 70 deletions(-) diff --git a/docker-bake.hcl b/docker-bake.hcl index 63e842195..b1c1024e1 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -3,7 +3,7 @@ group "default" { } group "test" { - targets = ["runc-test", "test-deps-only"] + targets = ["runc-test"] } variable "FRONTEND_REF" { @@ -227,42 +227,6 @@ target "examples" { tags = ["local/dalec/examples/${f}:${distro}"] } -target "deps-only" { - name = "deps-only-${distro}" - matrix = { - distro = ["mariner2"] - } - dockerfile-inline = <