From ee3435bfa03c4ec71f38d72dd87c8e99043cbb5b Mon Sep 17 00:00:00 2001 From: Michael Vogt Date: Tue, 5 Nov 2024 12:03:07 +0100 Subject: [PATCH 1/2] bib: resolve container content from the external dnf again This commit goes back to the approach we had in PR#565 to resolve using the solver from outside of the container. The reason to back is that some containers (like f41) do not have the python dnf available so they break with the "run dnfjson inside" approach (that was meant as a quick fix only anyway). Huge kudos to Achilleas for most of the work here. Co-Authored-By: Achilleas Koutsou --- bib/internal/container/solver.go | 97 +++++++++++++++++++++------ bib/internal/container/solver_test.go | 9 +++ 2 files changed, 86 insertions(+), 20 deletions(-) diff --git a/bib/internal/container/solver.go b/bib/internal/container/solver.go index 0214bb51..9381b92b 100644 --- a/bib/internal/container/solver.go +++ b/bib/internal/container/solver.go @@ -2,6 +2,7 @@ package container import ( "fmt" + "os" "os/exec" "path/filepath" @@ -11,6 +12,13 @@ import ( "github.com/osbuild/bootc-image-builder/bib/internal/source" ) +func forceSymlink(symlinkPath, target string) error { + if output, err := exec.Command("ln", "-sf", target, symlinkPath).CombinedOutput(); err != nil { + return fmt.Errorf("cannot run ln: %w, output:\n%s", err, output) + } + return nil +} + // InitDNF initializes dnf in the container. This is necessary when // the caller wants to read the image's dnf repositories, but they are // not static, but rather configured by dnf dynamically. The primaru @@ -31,38 +39,87 @@ func (c *Container) InitDNF() error { return nil } -func (cnt *Container) injectDNFJson() ([]string, error) { - if err := cnt.CopyInto("/usr/libexec/osbuild-depsolve-dnf", "/osbuild-depsolve-dnf"); err != nil { - return nil, fmt.Errorf("cannot prepare depsolve in the container: %w", err) +func (cnt *Container) hasRunSecrets() bool { + _, err := os.Stat(filepath.Join(cnt.root, "/run/secrets/redhat.repo")) + return err == nil +} + +// setupRunSecretsBindMount will synthesise a /run/secrets dir +// in the container root +func (cnt *Container) setupRunSecrets() error { + if cnt.hasRunSecrets() { + return nil } - // copy the python module too - globPath := "/usr/lib/*/site-packages/osbuild" - matches, err := filepath.Glob(globPath) - if err != nil || len(matches) == 0 { - return nil, fmt.Errorf("cannot find osbuild python module in %q: %w", globPath, err) + dst := filepath.Join(cnt.root, "/run/secrets") + if err := os.MkdirAll(dst, 0755); err != nil { + return err + } + + // We cannot just bind mount here because + // /usr/share/rhel/secrets contains a bunch of relative symlinks + // that will point to the container root not the host when resolved + // from the outside (via the host container mount). + // + // So instead of bind mounting we create a copy of the + // /run/secrets/ - they are static so that should be fine. + // + // We want to support /usr/share/rhel/secrets too to be able + // to run "bootc-image-builder manifest" directly on the host + // (which is useful for e.g. composer). + for _, src := range []string{"/run/secrets", "/usr/share/rhel/secrets"} { + if st, err := os.Stat(src); err != nil || !st.IsDir() { + continue + } + + dents, err := filepath.Glob(src + "/*") + if err != nil { + return err + } + for _, ent := range dents { + // Check if the target file actually exists (i.e. for + // symlinks that they are valid) and only copy if so. + // This covers unsubscribed machines. + if _, err := os.Stat(ent); err != nil { + continue + } + + // Note the use of "-L" here to dereference/copy links + if output, err := exec.Command("cp", "-rvL", ent, dst).CombinedOutput(); err != nil { + return fmt.Errorf("failed to setup /run/secrets: %w, output:\n%s", err, string(output)) + } + } } - if len(matches) != 1 { - return nil, fmt.Errorf("unexpected number of osbuild python module matches: %v", matches) + + // workaround broken containers (like f41) that use absolute symlinks + // to point to the entitlements-host and rhsm-host, they need to be + // relative so that the "SetRootdir()" from the resolver works, i.e. + // they need to point into the mounted container. + symlink := filepath.Join(cnt.root, "/etc/pki/entitlement-host") + target := "../../run/secrets/etc-pki-entitlement" + if err := forceSymlink(symlink, target); err != nil { + return err } - if err := cnt.CopyInto(matches[0], "/"); err != nil { - return nil, fmt.Errorf("cannot prepare depsolve python-modules in the container: %w", err) + symlink = filepath.Join(cnt.root, "/etc/rhsm-host") + target = "../run/secrets/rhsm" + if err := forceSymlink(symlink, target); err != nil { + return err } - return append(cnt.ExecArgv(), "/osbuild-depsolve-dnf"), nil + return nil } func (cnt *Container) NewContainerSolver(cacheRoot string, architecture arch.Arch, sourceInfo *source.Info) (*dnfjson.Solver, error) { - depsolverCmd, err := cnt.injectDNFJson() - if err != nil { - return nil, fmt.Errorf("cannot inject depsolve into the container: %w", err) - } - solver := dnfjson.NewSolver( sourceInfo.OSRelease.PlatformID, sourceInfo.OSRelease.VersionID, architecture.String(), fmt.Sprintf("%s-%s", sourceInfo.OSRelease.ID, sourceInfo.OSRelease.VersionID), cacheRoot) - solver.SetDNFJSONPath(depsolverCmd[0], depsolverCmd[1:]...) - solver.SetRootDir("/") + + // we copy the data directly into the cnt.root, no need to + // cleanup here because podman stop will remove the dir + if err := cnt.setupRunSecrets(); err != nil { + return nil, err + } + solver.SetRootDir(cnt.root) return solver, nil } diff --git a/bib/internal/container/solver_test.go b/bib/internal/container/solver_test.go index a5ab9e5e..bbf32dac 100644 --- a/bib/internal/container/solver_test.go +++ b/bib/internal/container/solver_test.go @@ -33,6 +33,10 @@ func TestDNFJsonWorks(t *testing.T) { cnt, err := container.New(dnfTestingImageCentos) require.NoError(t, err) + defer func() { + assert.NoError(t, cnt.Stop()) + }() + err = cnt.InitDNF() require.NoError(t, err) @@ -116,6 +120,10 @@ func TestDNFJsonWorkWithSubscribedContent(t *testing.T) { cnt, err := container.New(dnfTestingImageRHEL) require.NoError(t, err) + defer func() { + assert.NoError(t, cnt.Stop()) + }() + err = cnt.InitDNF() require.NoError(t, err) @@ -123,6 +131,7 @@ func TestDNFJsonWorkWithSubscribedContent(t *testing.T) { require.NoError(t, err) solver, err := cnt.NewContainerSolver(cacheRoot, arch.ARCH_X86_64, sourceInfo) require.NoError(t, err) + res, err := solver.Depsolve([]rpmmd.PackageSet{ { Include: []string{"coreutils"}, From 41f3a90fd4fdb7c1dc0cff2cdb76b51d48a434cb Mon Sep 17 00:00:00 2001 From: Michael Vogt Date: Tue, 5 Nov 2024 17:42:59 +0100 Subject: [PATCH 2/2] container: add nested RHSM rpm test This commit adds a test that ensures that access to subscribed content works in a nested container similar to how we run the nested container in bootc-image-builder. I.e. here we run in a privilidged container that then runs a normal container to depsolve against that. The test works by running the already existing `TestDNFJsonWorkWithSubscribedContent` inside the container to double check that subscribed content is available both from the host and when nesting. The extra complication is that for efficiency we compile the test binary on the host (to avoid having to have a go toolchain inside the container) which means the container needs to have a recent version of glibc (building our code with CGO_ENABLED=0 does not work) to support developing/running tests on f41. --- bib/internal/container/solver_test.go | 86 ++++++++++++++++++++++----- 1 file changed, 71 insertions(+), 15 deletions(-) diff --git a/bib/internal/container/solver_test.go b/bib/internal/container/solver_test.go index bbf32dac..ce5e72d7 100644 --- a/bib/internal/container/solver_test.go +++ b/bib/internal/container/solver_test.go @@ -5,6 +5,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -18,17 +19,29 @@ import ( ) const ( - dnfTestingImageRHEL = "registry.access.redhat.com/ubi9:latest" - dnfTestingImageCentos = "quay.io/centos/centos:stream9" + dnfTestingImageRHEL = "registry.access.redhat.com/ubi9:latest" + dnfTestingImageCentos = "quay.io/centos/centos:stream9" + dnfTestingImageFedoraLatest = "registry.fedoraproject.org/fedora:latest" ) -func TestDNFJsonWorks(t *testing.T) { +func ensureCanRunDNFJsonTests(t *testing.T) { if os.Geteuid() != 0 { t.Skip("skipping test; not running as root") } if _, err := os.Stat("/usr/libexec/osbuild-depsolve-dnf"); err != nil { t.Skip("cannot find /usr/libexec/osbuild-depsolve-dnf") } +} + +func ensureAMD64(t *testing.T) { + if runtime.GOARCH != "amd64" { + t.Skip("skipping test; only runs on x86_64") + } +} + +func TestDNFJsonWorks(t *testing.T) { + ensureCanRunDNFJsonTests(t) + cacheRoot := t.TempDir() cnt, err := container.New(dnfTestingImageCentos) @@ -86,9 +99,7 @@ func TestDNFInitGivesAccessToSubscribedContent(t *testing.T) { if os.Geteuid() != 0 { t.Skip("skipping test; not running as root") } - if runtime.GOARCH != "amd64" { - t.Skip("skipping test; only runs on x86_64") - } + ensureAMD64(t) restore := subscribeMachine(t) defer restore() @@ -104,15 +115,8 @@ func TestDNFInitGivesAccessToSubscribedContent(t *testing.T) { } func TestDNFJsonWorkWithSubscribedContent(t *testing.T) { - if os.Geteuid() != 0 { - t.Skip("skipping test; not running as root") - } - if runtime.GOARCH != "amd64" { - t.Skip("skipping test; only runs on x86_64") - } - if _, err := os.Stat("/usr/libexec/osbuild-depsolve-dnf"); err != nil { - t.Skip("cannot find /usr/libexec/osbuild-depsolve-dnf") - } + ensureCanRunDNFJsonTests(t) + ensureAMD64(t) cacheRoot := t.TempDir() restore := subscribeMachine(t) @@ -140,3 +144,55 @@ func TestDNFJsonWorkWithSubscribedContent(t *testing.T) { require.NoError(t, err) assert.True(t, len(res.Packages) > 0) } + +func runCmd(t *testing.T, args ...string) { + cmd := exec.Command(args[0], args[1:]...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err := cmd.Run() + require.NoError(t, err) +} + +func TestDNFJsonWorkWithSubscribedContentNestedContainers(t *testing.T) { + ensureCanRunDNFJsonTests(t) + ensureAMD64(t) + tmpdir := t.TempDir() + + restore := subscribeMachine(t) + defer restore() + + // build a test binary from the existing + // TestDNFJsonWorkWithSubscribedContent that is then + // transfered and run *inside* the centos container + testBinary := filepath.Join(tmpdir, "dnftest") + runCmd(t, "go", "test", + "-c", + "-o", testBinary, + "-run", "^TestDNFJsonWorkWithSubscribedContent$") + + output, err := exec.Command( + "podman", "run", "--rm", + "--privileged", + "--init", + "--detach", + "--entrypoint", "sleep", + // use a fedora container as intermediate so that we + // always have the latest glibc (we cannot fully + // static link the test) + dnfTestingImageFedoraLatest, + "infinity", + ).Output() + require.NoError(t, err, string(output)) + cntID := strings.TrimSpace(string(output)) + defer func() { + err := exec.Command("podman", "stop", cntID).Run() + assert.NoError(t, err) + }() + + runCmd(t, "podman", "cp", testBinary, cntID+":/dnftest") + // we need these test dependencies inside the container + runCmd(t, "podman", "exec", cntID, "dnf", "install", "-y", + "gpgme", "podman") + // run the test + runCmd(t, "podman", "exec", cntID, "/dnftest") +}