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..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,21 +19,37 @@ 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) require.NoError(t, err) + defer func() { + assert.NoError(t, cnt.Stop()) + }() + err = cnt.InitDNF() require.NoError(t, err) @@ -82,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() @@ -100,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) @@ -116,6 +124,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 +135,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"}, @@ -131,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") +}