From 63c7f24421bb07f5186d051b6c690af4012fab90 Mon Sep 17 00:00:00 2001 From: Michael Vogt Date: Tue, 11 Nov 2025 10:46:45 +0100 Subject: [PATCH 1/7] distro,manifestgen: support custom depsolvers for distros The bootc based distros need a custom depsolver because they need to use the dnf from the container to ensure that all the plugins/custom config is used. So manifestgen now checks if the distro defines its own way to create a depsovler and if so passes it into the manifestgen.Depsolver. With that bootc ISOs (that require rpms) are compatible with custom depsolve helpers and we can use a custom depsolver to catpure the mTLS keys for the rpms that are required by bootc. --- cmd/gen-manifests/main.go | 2 +- pkg/distro/bootc/bootc.go | 28 +++++++++++++++++++++++- pkg/distro/distro.go | 8 +++++++ pkg/manifestgen/manifestgen.go | 34 +++++++++++++++++++++++++---- pkg/manifestgen/manifestgen_test.go | 2 +- 5 files changed, 67 insertions(+), 7 deletions(-) diff --git a/cmd/gen-manifests/main.go b/cmd/gen-manifests/main.go index cfac52d059..4a0ed05586 100644 --- a/cmd/gen-manifests/main.go +++ b/cmd/gen-manifests/main.go @@ -299,7 +299,7 @@ func makeManifestJob( var depsolvedSets map[string]depsolvednf.DepsolveResult if content["packages"] { - depsolvedSets, err = manifestgen.DefaultDepsolver(cacheDir, os.Stderr, common.Must(manifest.GetPackageSetChains()), distribution, archName) + depsolvedSets, err = manifestgen.DefaultDepsolver(cacheDir, os.Stderr, common.Must(manifest.GetPackageSetChains()), distribution, archName, nil) if err != nil { err = fmt.Errorf("[%s] depsolve failed: %s", filename, err.Error()) return diff --git a/pkg/distro/bootc/bootc.go b/pkg/distro/bootc/bootc.go index a176ffcf79..ca0328446a 100644 --- a/pkg/distro/bootc/bootc.go +++ b/pkg/distro/bootc/bootc.go @@ -20,6 +20,7 @@ import ( "github.com/osbuild/images/pkg/customizations/anaconda" "github.com/osbuild/images/pkg/customizations/kickstart" "github.com/osbuild/images/pkg/customizations/users" + "github.com/osbuild/images/pkg/depsolvednf" "github.com/osbuild/images/pkg/disk" "github.com/osbuild/images/pkg/distro" "github.com/osbuild/images/pkg/distro/defs" @@ -32,7 +33,7 @@ import ( "github.com/osbuild/images/pkg/runner" ) -var _ = distro.Distro(&BootcDistro{}) +var _ = distro.CustomDepsolverDistro(&BootcDistro{}) type BootcDistro struct { imgref string @@ -121,6 +122,31 @@ func (d *BootcDistro) OSTreeRef() string { return "" } +func (d *BootcDistro) Depsolver(rpmCacheRoot string, archi arch.Arch) (solver *depsolvednf.Solver, cleanup func() error, err error) { + cnt, err := bibcontainer.New(d.buildImgref) + if err != nil { + return nil, nil, err + } + defer func() { + if err != nil { + err = errors.Join(err, cnt.Stop()) + } + }() + + cleanup = func() error { + return cnt.Stop() + } + if err := cnt.InitDNF(); err != nil { + return nil, nil, err + } + solver, err = cnt.NewContainerSolver(rpmCacheRoot, archi, d.buildSourceInfo) + if err != nil { + return nil, nil, err + } + + return solver, cleanup, nil +} + func (d *BootcDistro) ListArches() []string { archs := make([]string, 0, len(d.arches)) for name := range d.arches { diff --git a/pkg/distro/distro.go b/pkg/distro/distro.go index 1a2fa0d2e7..9acf733b40 100644 --- a/pkg/distro/distro.go +++ b/pkg/distro/distro.go @@ -4,7 +4,9 @@ import ( "math/rand" "github.com/osbuild/blueprint/pkg/blueprint" + "github.com/osbuild/images/pkg/arch" "github.com/osbuild/images/pkg/customizations/subscription" + "github.com/osbuild/images/pkg/depsolvednf" "github.com/osbuild/images/pkg/disk" "github.com/osbuild/images/pkg/disk/partition" "github.com/osbuild/images/pkg/manifest" @@ -56,6 +58,12 @@ type Distro interface { GetArch(arch string) (Arch, error) } +type CustomDepsolverDistro interface { + Distro + + Depsolver(cacheDir string, archi arch.Arch) (solver *depsolvednf.Solver, cleanup func() error, err error) +} + // An Arch represents a given distribution's support for a given architecture. type Arch interface { // Returns the name of the architecture. diff --git a/pkg/manifestgen/manifestgen.go b/pkg/manifestgen/manifestgen.go index 1b760a090d..323dc132d1 100644 --- a/pkg/manifestgen/manifestgen.go +++ b/pkg/manifestgen/manifestgen.go @@ -12,6 +12,8 @@ import ( "strings" "github.com/osbuild/blueprint/pkg/blueprint" + "github.com/osbuild/images/internal/common" + "github.com/osbuild/images/pkg/arch" "github.com/osbuild/images/pkg/container" "github.com/osbuild/images/pkg/depsolvednf" "github.com/osbuild/images/pkg/distro" @@ -170,7 +172,24 @@ func (mg *Generator) Generate(bp *blueprint.Blueprint, imgType distro.ImageType, if err != nil { return nil, err } - depsolved, err := mg.depsolver(mg.cacheDir, mg.depsolveWarningsOutput, pkgSetChains, dist, a.Name()) + var solver *depsolvednf.Solver + if dd, ok := dist.(distro.CustomDepsolverDistro); ok { + // XXX: it would be nice to have access to arch.Arch + // from distro.Arch but we dont so we have to do without. + archi := common.Must(arch.FromString(a.Name())) + customSolver, cleanupFunc, err := dd.Depsolver(mg.cacheDir, archi) + if err != nil { + return nil, err + } + // Note that its fine if customSolver is nil, + solver = customSolver + defer func() { + if err := cleanupFunc(); err != nil { + fmt.Fprintf(mg.warningsOutput, "WARNING: cleanup failed: %v\n", err) + } + }() + } + depsolved, err := mg.depsolver(mg.cacheDir, mg.depsolveWarningsOutput, pkgSetChains, dist, a.Name(), solver) if err != nil { return nil, err } @@ -243,7 +262,9 @@ func xdgCacheHome() (string, error) { // DefaultDepsolver provides a default implementation for depsolving. // It should rarely be necessary to use it directly and will be used // by default by manifestgen (unless overriden) -func DefaultDepsolver(cacheDir string, depsolveWarningsOutput io.Writer, packageSets map[string][]rpmmd.PackageSet, d distro.Distro, arch string) (map[string]depsolvednf.DepsolveResult, error) { +// +// The customSolver argument can be nil +func DefaultDepsolver(cacheDir string, depsolveWarningsOutput io.Writer, packageSets map[string][]rpmmd.PackageSet, d distro.Distro, arch string, customSolver *depsolvednf.Solver) (map[string]depsolvednf.DepsolveResult, error) { if cacheDir == "" { xdgCacheHomeDir, err := xdgCacheHome() if err != nil { @@ -252,7 +273,12 @@ func DefaultDepsolver(cacheDir string, depsolveWarningsOutput io.Writer, package cacheDir = filepath.Join(xdgCacheHomeDir, defaultDepsolveCacheDir) } - solver := depsolvednf.NewSolver(d.ModulePlatformID(), d.Releasever(), arch, d.Name(), cacheDir) + var solver *depsolvednf.Solver + if customSolver != nil { + solver = customSolver + } else { + solver = depsolvednf.NewSolver(d.ModulePlatformID(), d.Releasever(), arch, d.Name(), cacheDir) + } if depsolveWarningsOutput != nil { solver.Stderr = depsolveWarningsOutput @@ -321,7 +347,7 @@ func DefaultCommitResolver(commitSources map[string][]ostree.SourceSpec) (map[st } type ( - DepsolveFunc func(cacheDir string, depsolveWarningsOutput io.Writer, packageSets map[string][]rpmmd.PackageSet, d distro.Distro, arch string) (map[string]depsolvednf.DepsolveResult, error) + DepsolveFunc func(cacheDir string, depsolveWarningsOutput io.Writer, packageSets map[string][]rpmmd.PackageSet, d distro.Distro, arch string, solver *depsolvednf.Solver) (map[string]depsolvednf.DepsolveResult, error) ContainerResolverFunc func(containerSources map[string][]container.SourceSpec, archName string) (map[string][]container.Spec, error) diff --git a/pkg/manifestgen/manifestgen_test.go b/pkg/manifestgen/manifestgen_test.go index ad363b26ed..ebc9bb8e62 100644 --- a/pkg/manifestgen/manifestgen_test.go +++ b/pkg/manifestgen/manifestgen_test.go @@ -127,7 +127,7 @@ func TestManifestGeneratorWithOstreeCommit(t *testing.T) { assert.Contains(t, string(osbuildManifest), expectedSha256) } -func fakeDepsolve(cacheDir string, depsolveWarningsOutput io.Writer, packageSets map[string][]rpmmd.PackageSet, d distro.Distro, arch string) (map[string]depsolvednf.DepsolveResult, error) { +func fakeDepsolve(cacheDir string, depsolveWarningsOutput io.Writer, packageSets map[string][]rpmmd.PackageSet, d distro.Distro, arch string, solver *depsolvednf.Solver) (map[string]depsolvednf.DepsolveResult, error) { depsolvedSets := make(map[string]depsolvednf.DepsolveResult) if depsolveWarningsOutput != nil { _, _ = depsolveWarningsOutput.Write([]byte(`fake depsolve output`)) From 747cce34cbd0571686a5d54a3c556f40709caae5 Mon Sep 17 00:00:00 2001 From: Michael Vogt Date: Tue, 11 Nov 2025 11:14:19 +0100 Subject: [PATCH 2/7] distro: add (legacy) rpm based anaconda-iso bootc installer This commit moves the code to generate the rpm anaconda-iso installer type into the images library. This is possible because we use the distro specific depsolver. In bib we need to define a custom depsolver function that wraps the `manifestgen.DefaultDepsolver()` so that we can captures the repositories and extract the mTLS keys from there. The image type is ugly as it violates our layering, i.e. it will first inspect the bootc container to find the distro id and then look into our "regular" imagetypes.yaml for the found distro for the rpms to install. This is convenient as we maintain our rpms all in the same place but its also annyoing. Ideally we would get rid of this image type and use the container based bootc-installer type [0] but we will probably need this RPM one for a while still. [0] https://github.com/osbuild/images/pull/1906 --- data/distrodefs/bootc-generic/imagetypes.yaml | 13 ++ pkg/distro/bootc/bootc.go | 171 ++++++++++++++++-- 2 files changed, 167 insertions(+), 17 deletions(-) diff --git a/data/distrodefs/bootc-generic/imagetypes.yaml b/data/distrodefs/bootc-generic/imagetypes.yaml index 189bdc0857..4a6b78122f 100644 --- a/data/distrodefs/bootc-generic/imagetypes.yaml +++ b/data/distrodefs/bootc-generic/imagetypes.yaml @@ -149,3 +149,16 @@ image_types: exports: ["bootiso"] filename: "install.iso" boot_iso: true + + # this image type is special (in many ways) and all is a bit ugly: + # - we want to get rid of it (here for compat with bib) + # - its "indirect" in the sense that it pulls the RPMs from a + # "real" distro, i.e. if a bootc centos-10 is found it will + # load the imagetypes.yaml for that to load the + # bootc-rpm-installer + anaconda-iso: + name_aliases: ["iso"] + mime_type: "application/x-iso9660-image" + exports: ["bootiso"] + filename: "install.iso" + boot_iso: true diff --git a/pkg/distro/bootc/bootc.go b/pkg/distro/bootc/bootc.go index ca0328446a..b68cba2649 100644 --- a/pkg/distro/bootc/bootc.go +++ b/pkg/distro/bootc/bootc.go @@ -341,13 +341,24 @@ func (t *BootcImageType) Manifest(bp *blueprint.Blueprint, options distro.ImageO } func (t *BootcImageType) manifestWithoutValidation(bp *blueprint.Blueprint, options distro.ImageOptions, repos []rpmmd.RepoConfig, seedp *int64) (*manifest.Manifest, []string, error) { - if t.BootISO { - return t.manifestForISO(bp, options, repos, seedp) + seed, err := cmdutil.SeedArgFor(nil, t.Name(), t.arch.Name(), t.arch.distro.Name()) + if err != nil { + return nil, nil, err + } + //nolint:gosec + rng := rand.New(rand.NewSource(seed)) + + switch { + case t.Name() == "anaconda-iso": + return t.manifestForLegacyISO(bp, options, repos, rng) + case t.BootISO: + return t.manifestForISO(bp, options, repos, rng) + default: + return t.manifestForDisk(bp, options, repos, rng) } - return t.manifestForDisk(bp, options, repos, seedp) } -func (t *BootcImageType) manifestForDisk(bp *blueprint.Blueprint, options distro.ImageOptions, repos []rpmmd.RepoConfig, seedp *int64) (*manifest.Manifest, []string, error) { +func (t *BootcImageType) manifestForDisk(bp *blueprint.Blueprint, options distro.ImageOptions, repos []rpmmd.RepoConfig, rng *rand.Rand) (*manifest.Manifest, []string, error) { if t.arch.distro.imgref == "" { return nil, nil, fmt.Errorf("internal error: no base image defined") } @@ -366,12 +377,6 @@ func (t *BootcImageType) manifestForDisk(bp *blueprint.Blueprint, options distro if bp != nil { customizations = bp.Customizations } - seed, err := cmdutil.SeedArgFor(nil, t.Name(), t.arch.Name(), t.arch.distro.Name()) - if err != nil { - return nil, nil, err - } - //nolint:gosec - rng := rand.New(rand.NewSource(seed)) platform := PlatformFor(t.arch.Name(), t.arch.distro.sourceInfo.UEFIVendor) // For the bootc-disk image, the filename is the basename and @@ -438,7 +443,7 @@ func (t *BootcImageType) manifestForDisk(bp *blueprint.Blueprint, options distro return &mf, nil, nil } -func (t *BootcImageType) manifestForISO(bp *blueprint.Blueprint, options distro.ImageOptions, repos []rpmmd.RepoConfig, seedp *int64) (*manifest.Manifest, []string, error) { +func (t *BootcImageType) manifestForISO(bp *blueprint.Blueprint, options distro.ImageOptions, repos []rpmmd.RepoConfig, rng *rand.Rand) (*manifest.Manifest, []string, error) { if t.arch.distro.imgref == "" { return nil, nil, fmt.Errorf("internal error: no base image defined") } @@ -461,12 +466,6 @@ func (t *BootcImageType) manifestForISO(bp *blueprint.Blueprint, options distro. if bp != nil { customizations = bp.Customizations } - seed, err := cmdutil.SeedArgFor(nil, t.Name(), t.arch.Name(), t.arch.distro.Name()) - if err != nil { - return nil, nil, err - } - //nolint:gosec - rng := rand.New(rand.NewSource(seed)) platformi := PlatformFor(t.arch.Name(), t.arch.distro.sourceInfo.UEFIVendor) platformi.ImageFormat = platform.FORMAT_ISO @@ -504,6 +503,7 @@ func (t *BootcImageType) manifestForISO(bp *blueprint.Blueprint, options distro. img.InstallerCustomizations.ISOLabel = LabelForISO(&t.arch.distro.sourceInfo.OSRelease, t.arch.Name()) img.InstallerCustomizations.FIPS = customizations.GetFIPS() + var err error img.Kickstart, err = kickstart.New(customizations) if err != nil { return nil, nil, err @@ -605,6 +605,143 @@ func NewBootcDistro(imgref string, opts *DistroOptions) (*BootcDistro, error) { return newBootcDistroAfterIntrospect(cnt.Arch(), info, imgref, defaultFs, cntSize) } +// newDistroYAMLFrom() returns the distroYAML for the given sourceInfo, +// if no direct match can be found it will it will use the ID_LIKE. +// This should ensure we work on every bootc image that puts a correct +// ID_LIKE= in /etc/os-release +func newDistroYAMLFrom(sourceInfo *osinfo.Info) (*defs.DistroYAML, *distro.ID, error) { + for _, distroID := range append([]string{sourceInfo.OSRelease.ID}, sourceInfo.OSRelease.IDLike...) { + nameVer := fmt.Sprintf("%s-%s", distroID, sourceInfo.OSRelease.VersionID) + id, err := distro.ParseID(nameVer) + if err != nil { + return nil, nil, err + } + distroYAML, err := defs.NewDistroYAML(nameVer) + if err != nil { + return nil, nil, err + } + if distroYAML != nil { + return distroYAML, id, nil + } + } + return nil, nil, fmt.Errorf("cannot load distro definitions for %s-%s or any of %v", sourceInfo.OSRelease.ID, sourceInfo.OSRelease.VersionID, sourceInfo.OSRelease.IDLike) +} + +func (t *BootcImageType) manifestForLegacyISO(bp *blueprint.Blueprint, options distro.ImageOptions, repos []rpmmd.RepoConfig, rng *rand.Rand) (*manifest.Manifest, []string, error) { + archStr := t.arch.Name() + imgref := t.arch.distro.imgref + sourceInfo := t.arch.distro.sourceInfo + + if t.arch.distro.imgref == "" { + return nil, nil, fmt.Errorf("pipeline: no base image defined") + } + distroYAML, id, err := newDistroYAMLFrom(t.arch.distro.sourceInfo) + if err != nil { + return nil, nil, err + } + + // XXX: or "bootc-legacy-installer"? + installerImgTypeName := "bootc-rpm-installer" + imgType, ok := distroYAML.ImageTypes()[installerImgTypeName] + if !ok { + return nil, nil, fmt.Errorf("cannot find image definition for %v", installerImgTypeName) + } + installerPkgSet, ok := imgType.PackageSets(*id, archStr)["installer"] + if !ok { + return nil, nil, fmt.Errorf("cannot find installer package set for %v", installerImgTypeName) + } + installerConfig := imgType.InstallerConfig(*id, archStr) + if installerConfig == nil { + return nil, nil, fmt.Errorf("empty installer config for %s", installerImgTypeName) + } + + containerSource := container.SourceSpec{ + Source: imgref, + Name: imgref, + Local: true, + } + + platformi := PlatformFor(archStr, sourceInfo.UEFIVendor) + platformi.ImageFormat = platform.FORMAT_ISO + + // The ref is not needed and will be removed from the ctor later + // in time + img := image.NewAnacondaContainerInstallerLegacy(platformi, t.Filename(), containerSource, "") + img.ContainerRemoveSignatures = true + img.RootfsCompression = "zstd" + + if archStr == arch.ARCH_X86_64.String() { + img.InstallerCustomizations.ISOBoot = manifest.Grub2ISOBoot + } + + img.InstallerCustomizations.Product = sourceInfo.OSRelease.Name + img.InstallerCustomizations.OSVersion = sourceInfo.OSRelease.VersionID + img.InstallerCustomizations.ISOLabel = LabelForISO(&sourceInfo.OSRelease, archStr) + img.ExtraBasePackages = installerPkgSet + + var customizations *blueprint.Customizations + if bp != nil { + customizations = bp.Customizations + } + img.InstallerCustomizations.FIPS = customizations.GetFIPS() + img.Kickstart, err = kickstart.New(customizations) + if err != nil { + return nil, nil, err + } + img.Kickstart.Path = osbuild.KickstartPathOSBuild + if kopts := customizations.GetKernel(); kopts != nil && kopts.Append != "" { + img.Kickstart.KernelOptionsAppend = append(img.Kickstart.KernelOptionsAppend, kopts.Append) + } + img.Kickstart.NetworkOnBoot = true + + instCust, err := customizations.GetInstaller() + if err != nil { + return nil, nil, err + } + if instCust != nil && instCust.Modules != nil { + img.InstallerCustomizations.EnabledAnacondaModules = append(img.InstallerCustomizations.EnabledAnacondaModules, instCust.Modules.Enable...) + img.InstallerCustomizations.DisabledAnacondaModules = append(img.InstallerCustomizations.DisabledAnacondaModules, instCust.Modules.Disable...) + } + img.InstallerCustomizations.EnabledAnacondaModules = append(img.InstallerCustomizations.EnabledAnacondaModules, + anaconda.ModuleUsers, + anaconda.ModuleServices, + anaconda.ModuleSecurity, + // XXX: get from the imagedefs + anaconda.ModuleNetwork, + anaconda.ModulePayloads, + anaconda.ModuleRuntime, + anaconda.ModuleStorage, + ) + + img.Kickstart.OSTree = &kickstart.OSTree{ + OSName: "default", + } + img.InstallerCustomizations.LoraxTemplates = installerConfig.LoraxTemplates + if installerConfig.LoraxTemplatePackage != nil { + img.InstallerCustomizations.LoraxTemplatePackage = *installerConfig.LoraxTemplatePackage + } + + // see https://github.com/osbuild/bootc-image-builder/issues/733 + img.InstallerCustomizations.ISORootfsType = manifest.SquashfsRootfs + + installRootfsType, err := disk.NewFSType(t.arch.distro.defaultFs) + if err != nil { + return nil, nil, err + } + img.InstallRootfsType = installRootfsType + + mf := manifest.New() + + foundDistro, foundRunner, err := GetDistroAndRunner(sourceInfo.OSRelease) + if err != nil { + return nil, nil, fmt.Errorf("failed to infer distro and runner: %w", err) + } + mf.Distro = foundDistro + + _, err = img.InstantiateManifest(&mf, nil, foundRunner, rng) + return &mf, nil, err +} + func newBootcDistroAfterIntrospect(archStr string, info *osinfo.Info, imgref, defaultFsStr string, cntSize uint64) (*BootcDistro, error) { nameVer := fmt.Sprintf("bootc-%s-%s", info.OSRelease.ID, info.OSRelease.VersionID) id, err := distro.ParseID(nameVer) From 79136fbad486e197c366aefae11e423caa3c929c Mon Sep 17 00:00:00 2001 From: Michael Vogt Date: Thu, 13 Nov 2025 09:21:18 +0100 Subject: [PATCH 3/7] data: use yaml anchorto define "iso" bootc-generic image type Ideally we would just use `name_aliases` but in the bib/ibcli code we use the low-level interface of the loader to get the image which does not look at name_alises. As this is quite a special case lets just be pragmatic and define "iso" via a YAML alias/anchor. --- data/distrodefs/bootc-generic/imagetypes.yaml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/data/distrodefs/bootc-generic/imagetypes.yaml b/data/distrodefs/bootc-generic/imagetypes.yaml index 4a6b78122f..c39516091f 100644 --- a/data/distrodefs/bootc-generic/imagetypes.yaml +++ b/data/distrodefs/bootc-generic/imagetypes.yaml @@ -156,9 +156,12 @@ image_types: # "real" distro, i.e. if a bootc centos-10 is found it will # load the imagetypes.yaml for that to load the # bootc-rpm-installer - anaconda-iso: - name_aliases: ["iso"] + anaconda-iso: &anaconda_iso mime_type: "application/x-iso9660-image" exports: ["bootiso"] filename: "install.iso" boot_iso: true + + # XXX: ideally we would use name_aliases but the loader lib + # does not not fully support this yet + iso: *anaconda_iso From 7941f917a47a3d0f07557b0e63825a87d8e3d35b Mon Sep 17 00:00:00 2001 From: Michael Vogt Date: Thu, 13 Nov 2025 09:37:16 +0100 Subject: [PATCH 4/7] distro: hardcode iso as a leagacy bootc iso --- pkg/distro/bootc/bootc.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/distro/bootc/bootc.go b/pkg/distro/bootc/bootc.go index b68cba2649..6bd4eb88e1 100644 --- a/pkg/distro/bootc/bootc.go +++ b/pkg/distro/bootc/bootc.go @@ -349,7 +349,8 @@ func (t *BootcImageType) manifestWithoutValidation(bp *blueprint.Blueprint, opti rng := rand.New(rand.NewSource(seed)) switch { - case t.Name() == "anaconda-iso": + // XXX: make this a yaml property + case slices.Contains([]string{"iso", "anaconda-iso"}, t.Name()): return t.manifestForLegacyISO(bp, options, repos, rng) case t.BootISO: return t.manifestForISO(bp, options, repos, rng) From b230e421401596e333dcb7ee61ef2f4391f0b68d Mon Sep 17 00:00:00 2001 From: Michael Vogt Date: Thu, 13 Nov 2025 20:20:22 +0100 Subject: [PATCH 5/7] bootc: handle containers without dnf gracefully Not all bootc container have dnf, so check if it can be run here and if not just return nil which will ensure the depsolver of the host is used. --- pkg/bib/container/solver.go | 8 ++++++++ pkg/bib/container/solver_test.go | 17 +++++++++++++++++ pkg/distro/bootc/bootc.go | 8 ++++++++ 3 files changed, 33 insertions(+) diff --git a/pkg/bib/container/solver.go b/pkg/bib/container/solver.go index 540dc03bb2..d51e139fbf 100644 --- a/pkg/bib/container/solver.go +++ b/pkg/bib/container/solver.go @@ -1,6 +1,7 @@ package container import ( + "errors" "fmt" "os" "os/exec" @@ -11,6 +12,8 @@ import ( "github.com/osbuild/images/pkg/depsolvednf" ) +var ErrNoDnf = errors.New("no dnf in container") + 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) @@ -31,6 +34,11 @@ func forceSymlink(symlinkPath, target string) error { // check" without arguments takes around 25s so that is not a great // option). func (c *Container) InitDNF() error { + /* #nosec G204 */ + if err := exec.Command("podman", "exec", c.id, "dnf", "--version").Run(); err != nil { + return ErrNoDnf + } + /* #nosec G204 */ if output, err := exec.Command("podman", "exec", c.id, "dnf", "check", "--duplicates").CombinedOutput(); err != nil { return fmt.Errorf("initializing dnf in %s container failed: %w\noutput:\n%s", c.id, err, string(output)) diff --git a/pkg/bib/container/solver_test.go b/pkg/bib/container/solver_test.go index 44d797273b..f2ff1683a7 100644 --- a/pkg/bib/container/solver_test.go +++ b/pkg/bib/container/solver_test.go @@ -21,6 +21,7 @@ import ( const ( dnfTestingImageRHEL = "registry.access.redhat.com/ubi9:latest" dnfTestingImageCentos = "quay.io/centos/centos:stream9" + dnfTestingImageNoDnf = "alpine:latest" dnfTestingImageFedoraLatest = "registry.fedoraproject.org/fedora:latest" ) @@ -200,3 +201,19 @@ func TestDepsolveDNFWorkWithSubscribedContentNestedContainers(t *testing.T) { // run the test runCmd(t, "podman", "exec", cntID, "/dnftest") } + +func TestDepsolveDNFdetectsMissingDnf(t *testing.T) { + if !hasPodman() { + t.Skip("skipping test: no podman") + } + ensureCanRunDepsolveDNFTests(t) + + cnt, err := container.New(dnfTestingImageNoDnf) + require.NoError(t, err) + defer func() { + assert.NoError(t, cnt.Stop()) + }() + + err = cnt.InitDNF() + require.Equal(t, container.ErrNoDnf, err) +} diff --git a/pkg/distro/bootc/bootc.go b/pkg/distro/bootc/bootc.go index 6bd4eb88e1..fd51fb45be 100644 --- a/pkg/distro/bootc/bootc.go +++ b/pkg/distro/bootc/bootc.go @@ -137,6 +137,14 @@ func (d *BootcDistro) Depsolver(rpmCacheRoot string, archi arch.Arch) (solver *d return cnt.Stop() } if err := cnt.InitDNF(); err != nil { + // Not all bootc container have dnf, so check if it can + // be run here and if not just return nil which will + // ensure the depsolver of the host is used + if errors.Is(err, bibcontainer.ErrNoDnf) { + return nil, cleanup, nil + } + // Return any other errors to the caller, it means + // dnf is installed but not working. return nil, nil, err } solver, err = cnt.NewContainerSolver(rpmCacheRoot, archi, d.buildSourceInfo) From cd04c29ad2e767d1d7c98f11be9041d06b7481f1 Mon Sep 17 00:00:00 2001 From: Michael Vogt Date: Mon, 17 Nov 2025 11:20:01 +0100 Subject: [PATCH 6/7] test: add bootc anaconda-iso image type checksums Now that the `anaconda-iso` image type for bootc is part of the image library we can (re)use the existing manifest testing system to generate fake manifests that validate that our images do not change accidentially. This commit adds: rhel-10.1, centos-9, fedora-43, fedora-42 which should be a good sample. --- test/bootc-fake-containers.yaml | 43 ++++++++++++++++++- .../bootc_centos_9-x86_64-anaconda_iso-empty | 1 + .../bootc_fedora_42-x86_64-anaconda_iso-empty | 1 + .../bootc_fedora_43-x86_64-anaconda_iso-empty | 1 + .../bootc_rhel_10.1-x86_64-anaconda_iso-empty | 1 + 5 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 test/data/manifest-checksums/bootc_centos_9-x86_64-anaconda_iso-empty create mode 100644 test/data/manifest-checksums/bootc_fedora_42-x86_64-anaconda_iso-empty create mode 100644 test/data/manifest-checksums/bootc_fedora_43-x86_64-anaconda_iso-empty create mode 100644 test/data/manifest-checksums/bootc_rhel_10.1-x86_64-anaconda_iso-empty diff --git a/test/bootc-fake-containers.yaml b/test/bootc-fake-containers.yaml index c406864e87..04884a0dd3 100644 --- a/test/bootc-fake-containers.yaml +++ b/test/bootc-fake-containers.yaml @@ -40,7 +40,7 @@ containers: os_release: id: "build-test-os" - # test ISO installer + # test bootc based ISO installer - <<: *default_fake_container image_types: ["bootc-installer"] arch: x86_64 @@ -49,3 +49,44 @@ containers: image_types: ["bootc-installer"] arch: aarch64 payload_container_ref: "payload-container-fake-ref" + + # test rpm based ISO installer + - <<: *default_fake_container + image_types: ["anaconda-iso"] + arch: x86_64 + info: + <<: *default_info + os_release: + # we need a "real" distro here as the packages will + # be selected from the distros + # imagetypes.yaml:bootc-rpm-installer + name: "RHEL 10" + id: "rhel" + versionid: "10.1" + - <<: *default_fake_container + image_types: ["anaconda-iso"] + arch: x86_64 + info: + <<: *default_info + os_release: + name: "Centos 9" + id: "centos" + versionid: "9" + - <<: *default_fake_container + image_types: ["anaconda-iso"] + arch: x86_64 + info: + <<: *default_info + os_release: + name: "Fedora 43" + id: "fedora" + versionid: "43" + - <<: *default_fake_container + image_types: ["anaconda-iso"] + arch: x86_64 + info: + <<: *default_info + os_release: + name: "Fedora 42" + id: "fedora" + versionid: "42" diff --git a/test/data/manifest-checksums/bootc_centos_9-x86_64-anaconda_iso-empty b/test/data/manifest-checksums/bootc_centos_9-x86_64-anaconda_iso-empty new file mode 100644 index 0000000000..4ba71d3448 --- /dev/null +++ b/test/data/manifest-checksums/bootc_centos_9-x86_64-anaconda_iso-empty @@ -0,0 +1 @@ +df04e2d96faf93fdfa68a29f5e80945f7d2d7f84 diff --git a/test/data/manifest-checksums/bootc_fedora_42-x86_64-anaconda_iso-empty b/test/data/manifest-checksums/bootc_fedora_42-x86_64-anaconda_iso-empty new file mode 100644 index 0000000000..a972f78ff8 --- /dev/null +++ b/test/data/manifest-checksums/bootc_fedora_42-x86_64-anaconda_iso-empty @@ -0,0 +1 @@ +85951658efd54e0afd1cad0199583b241d76cfef diff --git a/test/data/manifest-checksums/bootc_fedora_43-x86_64-anaconda_iso-empty b/test/data/manifest-checksums/bootc_fedora_43-x86_64-anaconda_iso-empty new file mode 100644 index 0000000000..595ed1330f --- /dev/null +++ b/test/data/manifest-checksums/bootc_fedora_43-x86_64-anaconda_iso-empty @@ -0,0 +1 @@ +b7653845d09fe19e23236046ee22992889596911 diff --git a/test/data/manifest-checksums/bootc_rhel_10.1-x86_64-anaconda_iso-empty b/test/data/manifest-checksums/bootc_rhel_10.1-x86_64-anaconda_iso-empty new file mode 100644 index 0000000000..6ccf3d02f2 --- /dev/null +++ b/test/data/manifest-checksums/bootc_rhel_10.1-x86_64-anaconda_iso-empty @@ -0,0 +1 @@ +9bb33e9355d4b7f85e2b703a23397d7261cecee9 From 6ba8221198b1015a5116abb461a67f8509348a51 Mon Sep 17 00:00:00 2001 From: Michael Vogt Date: Tue, 18 Nov 2025 11:26:12 +0100 Subject: [PATCH 7/7] manifesgen: make "solver" mandatory in DepsolveFunc This commit tries to address the concern from Achilleas in https://github.com/osbuild/images/pull/2010#discussion_r2533915884 When using a custom Depsolve function it is now mandatory to pass a valid `depsolvednf.Solver` instance. So the custom depsolve function will no longer create it on its own as this is no longer feasiable. With the bootc distro that requires a different depsolvednf.Solver it is no longer something that a user can override. The commit also renames the function from Depsolver to Depsolve as its doing the (dep) solving with an existing "solver". Getting the terminology more disentangled would be nice but I couldn't come up with better terms, maybe `DepsolveRun` ? --- cmd/gen-manifests/main.go | 3 +- pkg/manifestgen/manifestgen.go | 48 +++++++++++++---------------- pkg/manifestgen/manifestgen_test.go | 18 +++++------ 3 files changed, 33 insertions(+), 36 deletions(-) diff --git a/cmd/gen-manifests/main.go b/cmd/gen-manifests/main.go index 4a0ed05586..ec14a7258b 100644 --- a/cmd/gen-manifests/main.go +++ b/cmd/gen-manifests/main.go @@ -299,7 +299,8 @@ func makeManifestJob( var depsolvedSets map[string]depsolvednf.DepsolveResult if content["packages"] { - depsolvedSets, err = manifestgen.DefaultDepsolver(cacheDir, os.Stderr, common.Must(manifest.GetPackageSetChains()), distribution, archName, nil) + solver := depsolvednf.NewSolver(distribution.ModulePlatformID(), distribution.Releasever(), archName, distribution.Name(), cacheDir) + depsolvedSets, err = manifestgen.DefaultDepsolve(solver, cacheDir, os.Stderr, common.Must(manifest.GetPackageSetChains()), distribution, archName) if err != nil { err = fmt.Errorf("[%s] depsolve failed: %s", filename, err.Error()) return diff --git a/pkg/manifestgen/manifestgen.go b/pkg/manifestgen/manifestgen.go index 323dc132d1..ffb2d73a17 100644 --- a/pkg/manifestgen/manifestgen.go +++ b/pkg/manifestgen/manifestgen.go @@ -68,7 +68,7 @@ type Options struct { // Custom "solver" functions, if unset the defaults will be // used. Only needed for specialized use-cases. - Depsolver DepsolveFunc + Depsolve DepsolveFunc ContainerResolver ContainerResolverFunc CommitResolver CommitResolverFunc @@ -82,7 +82,7 @@ type Options struct { type Generator struct { cacheDir string - depsolver DepsolveFunc + depsolve DepsolveFunc containerResolver ContainerResolverFunc commitResolver CommitResolverFunc sbomWriter SBOMWriterFunc @@ -108,7 +108,7 @@ func New(reporegistry *reporegistry.RepoRegistry, opts *Options) (*Generator, er reporegistry: reporegistry, cacheDir: opts.Cachedir, - depsolver: opts.Depsolver, + depsolve: opts.Depsolve, containerResolver: opts.ContainerResolver, commitResolver: opts.CommitResolver, rpmDownloader: opts.RpmDownloader, @@ -119,8 +119,8 @@ func New(reporegistry *reporegistry.RepoRegistry, opts *Options) (*Generator, er overrideRepos: opts.OverrideRepos, useBootstrapContainer: opts.UseBootstrapContainer, } - if mg.depsolver == nil { - mg.depsolver = DefaultDepsolver + if mg.depsolve == nil { + mg.depsolve = DefaultDepsolve } if mg.containerResolver == nil { mg.containerResolver = DefaultContainerResolver @@ -128,6 +128,13 @@ func New(reporegistry *reporegistry.RepoRegistry, opts *Options) (*Generator, er if mg.commitResolver == nil { mg.commitResolver = DefaultCommitResolver } + if mg.cacheDir == "" { + xdgCacheHomeDir, err := xdgCacheHome() + if err != nil { + return nil, err + } + mg.cacheDir = filepath.Join(xdgCacheHomeDir, defaultDepsolveCacheDir) + } return mg, nil } @@ -172,7 +179,7 @@ func (mg *Generator) Generate(bp *blueprint.Blueprint, imgType distro.ImageType, if err != nil { return nil, err } - var solver *depsolvednf.Solver + solver := depsolvednf.NewSolver(dist.ModulePlatformID(), dist.Releasever(), a.Name(), dist.Name(), mg.cacheDir) if dd, ok := dist.(distro.CustomDepsolverDistro); ok { // XXX: it would be nice to have access to arch.Arch // from distro.Arch but we dont so we have to do without. @@ -181,15 +188,16 @@ func (mg *Generator) Generate(bp *blueprint.Blueprint, imgType distro.ImageType, if err != nil { return nil, err } - // Note that its fine if customSolver is nil, - solver = customSolver + if customSolver != nil { + solver = customSolver + } defer func() { if err := cleanupFunc(); err != nil { fmt.Fprintf(mg.warningsOutput, "WARNING: cleanup failed: %v\n", err) } }() } - depsolved, err := mg.depsolver(mg.cacheDir, mg.depsolveWarningsOutput, pkgSetChains, dist, a.Name(), solver) + depsolved, err := mg.depsolve(solver, mg.cacheDir, mg.depsolveWarningsOutput, pkgSetChains, dist, a.Name()) if err != nil { return nil, err } @@ -259,27 +267,15 @@ func xdgCacheHome() (string, error) { return filepath.Join(home, ".cache"), nil } -// DefaultDepsolver provides a default implementation for depsolving. +// DefaultDepsolve provides a default implementation for depsolving. // It should rarely be necessary to use it directly and will be used // by default by manifestgen (unless overriden) // // The customSolver argument can be nil -func DefaultDepsolver(cacheDir string, depsolveWarningsOutput io.Writer, packageSets map[string][]rpmmd.PackageSet, d distro.Distro, arch string, customSolver *depsolvednf.Solver) (map[string]depsolvednf.DepsolveResult, error) { - if cacheDir == "" { - xdgCacheHomeDir, err := xdgCacheHome() - if err != nil { - return nil, err - } - cacheDir = filepath.Join(xdgCacheHomeDir, defaultDepsolveCacheDir) +func DefaultDepsolve(solver *depsolvednf.Solver, cacheDir string, depsolveWarningsOutput io.Writer, packageSets map[string][]rpmmd.PackageSet, d distro.Distro, arch string) (map[string]depsolvednf.DepsolveResult, error) { + if solver == nil { + return nil, fmt.Errorf("need a valid solver, got nil") } - - var solver *depsolvednf.Solver - if customSolver != nil { - solver = customSolver - } else { - solver = depsolvednf.NewSolver(d.ModulePlatformID(), d.Releasever(), arch, d.Name(), cacheDir) - } - if depsolveWarningsOutput != nil { solver.Stderr = depsolveWarningsOutput } @@ -347,7 +343,7 @@ func DefaultCommitResolver(commitSources map[string][]ostree.SourceSpec) (map[st } type ( - DepsolveFunc func(cacheDir string, depsolveWarningsOutput io.Writer, packageSets map[string][]rpmmd.PackageSet, d distro.Distro, arch string, solver *depsolvednf.Solver) (map[string]depsolvednf.DepsolveResult, error) + DepsolveFunc func(solver *depsolvednf.Solver, cacheDir string, depsolveWarningsOutput io.Writer, packageSets map[string][]rpmmd.PackageSet, d distro.Distro, arch string) (map[string]depsolvednf.DepsolveResult, error) ContainerResolverFunc func(containerSources map[string][]container.SourceSpec, archName string) (map[string][]container.Spec, error) diff --git a/pkg/manifestgen/manifestgen_test.go b/pkg/manifestgen/manifestgen_test.go index ebc9bb8e62..e94c30e461 100644 --- a/pkg/manifestgen/manifestgen_test.go +++ b/pkg/manifestgen/manifestgen_test.go @@ -55,7 +55,7 @@ func TestManifestGeneratorDepsolve(t *testing.T) { } opts := &manifestgen.Options{ - Depsolver: fakeDepsolve, + Depsolve: fakeDepsolve, CommitResolver: panicCommitResolver, ContainerResolver: panicContainerResolver, @@ -96,7 +96,7 @@ func TestManifestGeneratorWithOstreeCommit(t *testing.T) { assert.Equal(t, 1, len(res)) opts := &manifestgen.Options{ - Depsolver: fakeDepsolve, + Depsolve: fakeDepsolve, CommitResolver: fakeCommitResolver, ContainerResolver: panicContainerResolver, } @@ -127,7 +127,7 @@ func TestManifestGeneratorWithOstreeCommit(t *testing.T) { assert.Contains(t, string(osbuildManifest), expectedSha256) } -func fakeDepsolve(cacheDir string, depsolveWarningsOutput io.Writer, packageSets map[string][]rpmmd.PackageSet, d distro.Distro, arch string, solver *depsolvednf.Solver) (map[string]depsolvednf.DepsolveResult, error) { +func fakeDepsolve(solver *depsolvednf.Solver, cacheDir string, depsolveWarningsOutput io.Writer, packageSets map[string][]rpmmd.PackageSet, d distro.Distro, arch string) (map[string]depsolvednf.DepsolveResult, error) { depsolvedSets := make(map[string]depsolvednf.DepsolveResult) if depsolveWarningsOutput != nil { _, _ = depsolveWarningsOutput.Write([]byte(`fake depsolve output`)) @@ -218,7 +218,7 @@ func TestManifestGeneratorContainers(t *testing.T) { assert.Equal(t, 1, len(res)) opts := &manifestgen.Options{ - Depsolver: fakeDepsolve, + Depsolve: fakeDepsolve, CommitResolver: panicCommitResolver, ContainerResolver: fakeContainerResolver, } @@ -253,7 +253,7 @@ func TestManifestGeneratorDepsolveWithSbomWriter(t *testing.T) { generatedSboms := map[string]string{} opts := &manifestgen.Options{ - Depsolver: fakeDepsolve, + Depsolve: fakeDepsolve, CommitResolver: panicCommitResolver, ContainerResolver: panicContainerResolver, @@ -295,7 +295,7 @@ func TestManifestGeneratorSeed(t *testing.T) { for _, withCustomSeed := range []bool{false, true} { opts := &manifestgen.Options{ - Depsolver: fakeDepsolve, + Depsolve: fakeDepsolve, } if withCustomSeed { customSeed := int64(123) @@ -333,7 +333,7 @@ func TestManifestGeneratorDepsolveOutput(t *testing.T) { var depsolveWarningsOutput bytes.Buffer opts := &manifestgen.Options{ - Depsolver: fakeDepsolve, + Depsolve: fakeDepsolve, DepsolveWarningsOutput: &depsolveWarningsOutput, } @@ -361,7 +361,7 @@ func TestManifestGeneratorOverrideRepos(t *testing.T) { for _, withOverrideRepos := range []bool{false, true} { t.Run(fmt.Sprintf("withOverrideRepos: %v", withOverrideRepos), func(t *testing.T) { opts := &manifestgen.Options{ - Depsolver: fakeDepsolve, + Depsolve: fakeDepsolve, } if withOverrideRepos { opts.OverrideRepos = []rpmmd.RepoConfig{ @@ -401,7 +401,7 @@ func TestManifestGeneratorUseBootstrapContainer(t *testing.T) { for _, useBootstrapContainer := range []bool{false, true} { t.Run(fmt.Sprintf("useBootstrapContainer: %v", useBootstrapContainer), func(t *testing.T) { opts := &manifestgen.Options{ - Depsolver: fakeDepsolve, + Depsolve: fakeDepsolve, ContainerResolver: fakeContainerResolver, UseBootstrapContainer: useBootstrapContainer, }