package main import ( cryptorand "crypto/rand" "errors" "fmt" "math" "math/big" "math/rand" "strconv" "strings" "github.com/osbuild/images/pkg/arch" "github.com/osbuild/images/pkg/bib/osinfo" "github.com/osbuild/images/pkg/blueprint" "github.com/osbuild/images/pkg/container" "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/disk" "github.com/osbuild/images/pkg/image" "github.com/osbuild/images/pkg/manifest" "github.com/osbuild/images/pkg/osbuild" "github.com/osbuild/images/pkg/pathpolicy" "github.com/osbuild/images/pkg/platform" "github.com/osbuild/images/pkg/policies" "github.com/osbuild/images/pkg/runner" "github.com/sirupsen/logrus" "github.com/particle-os/debian-bootc-image-builder/bib/internal/distrodef" "github.com/particle-os/debian-bootc-image-builder/bib/internal/imagetypes" "github.com/particle-os/debian-bootc-image-builder/bib/internal/debian-patch" ) // TODO: Auto-detect this from container image metadata const DEFAULT_SIZE = uint64(10 * GibiByte) type ManifestConfig struct { // OCI image path (without the transport, that is always docker://) Imgref string BuildImgref string ImageTypes imagetypes.ImageTypes // Build config Config *blueprint.Blueprint // CPU architecture of the image Architecture arch.Arch // The minimum size required for the root fs in order to fit the container // contents RootfsMinsize uint64 // Paths to the directory with the distro definitions DistroDefPaths []string // Extracted information about the source container image SourceInfo *osinfo.Info BuildSourceInfo *osinfo.Info // RootFSType specifies the filesystem type for the root partition RootFSType string // use librepo ad the rpm downlaod backend UseLibrepo bool } func Manifest(c *ManifestConfig) (*manifest.Manifest, error) { rng := createRand() if c.ImageTypes.BuildsISO() { return manifestForISO(c, rng) } return manifestForDiskImage(c, rng) } var ( // The mountpoint policy for bootc images is more restrictive than the // ostree mountpoint policy defined in osbuild/images. It only allows / // (for sizing the root partition) and custom mountpoints under /var but // not /var itself. // Since our policy library doesn't support denying a path while allowing // its subpaths (only the opposite), we augment the standard policy check // with a simple search through the custom mountpoints to deny /var // specifically. mountpointPolicy = pathpolicy.NewPathPolicies(map[string]pathpolicy.PathPolicy{ // allow all existing mountpoints (but no subdirs) to support size customizations "/": {Deny: false, Exact: true}, "/boot": {Deny: false, Exact: true}, // /var is not allowed, but we need to allow any subdirectories that // are not denied below, so we allow it initially and then check it // separately (in checkMountpoints()) "/var": {Deny: false}, // /var subdir denials "/var/home": {Deny: true}, "/var/lock": {Deny: true}, // symlink to ../run/lock which is on tmpfs "/var/mail": {Deny: true}, // symlink to spool/mail "/var/mnt": {Deny: true}, "/var/roothome": {Deny: true}, "/var/run": {Deny: true}, // symlink to ../run which is on tmpfs "/var/srv": {Deny: true}, "/var/usrlocal": {Deny: true}, }) mountpointMinimalPolicy = pathpolicy.NewPathPolicies(map[string]pathpolicy.PathPolicy{ // allow all existing mountpoints to support size customizations "/": {Deny: false, Exact: true}, "/boot": {Deny: false, Exact: true}, }) ) func checkMountpoints(filesystems []blueprint.FilesystemCustomization, policy *pathpolicy.PathPolicies) error { errs := []error{} for _, fs := range filesystems { if err := policy.Check(fs.Mountpoint); err != nil { errs = append(errs, err) } if fs.Mountpoint == "/var" { // this error message is consistent with the errors returned by policy.Check() // TODO: remove trailing space inside the quoted path when the function is fixed in osbuild/images. errs = append(errs, fmt.Errorf(`path "/var" is not allowed`)) } } if len(errs) > 0 { return fmt.Errorf("the following errors occurred while validating custom mountpoints:\n%w", errors.Join(errs...)) } return nil } func checkFilesystemCustomizations(fsCustomizations []blueprint.FilesystemCustomization, ptmode disk.PartitioningMode) error { var policy *pathpolicy.PathPolicies switch ptmode { case disk.BtrfsPartitioningMode: // btrfs subvolumes are not supported at build time yet, so we only // allow / and /boot to be customized when building a btrfs disk (the // minimal policy) policy = mountpointMinimalPolicy default: policy = mountpointPolicy } if err := checkMountpoints(fsCustomizations, policy); err != nil { return err } return nil } // updateFilesystemSizes updates the size of the root filesystem customization // based on the minRootSize. The new min size whichever is larger between the // existing size and the minRootSize. If the root filesystem is not already // configured, a new customization is added. func updateFilesystemSizes(fsCustomizations []blueprint.FilesystemCustomization, minRootSize uint64) []blueprint.FilesystemCustomization { updated := make([]blueprint.FilesystemCustomization, len(fsCustomizations), len(fsCustomizations)+1) hasRoot := false for idx, fsc := range fsCustomizations { updated[idx] = fsc if updated[idx].Mountpoint == "/" { updated[idx].MinSize = max(updated[idx].MinSize, minRootSize) hasRoot = true } } if !hasRoot { // no root customization found: add it updated = append(updated, blueprint.FilesystemCustomization{Mountpoint: "/", MinSize: minRootSize}) } return updated } // setFSTypes sets the filesystem types for all mountable entities to match the // selected rootfs type. // If rootfs is 'btrfs', the function will keep '/boot' to its default. func setFSTypes(pt *disk.PartitionTable, rootfs string) error { if rootfs == "" { return fmt.Errorf("root filesystem type is empty") } return pt.ForEachMountable(func(mnt disk.Mountable, _ []disk.Entity) error { switch mnt.GetMountpoint() { case "/boot/efi": // never change the efi partition's type return nil case "/boot": // change only if we're not doing btrfs if rootfs == "btrfs" { return nil } fallthrough default: switch elem := mnt.(type) { case *disk.Filesystem: elem.Type = rootfs case *disk.BtrfsSubvolume: // nothing to do default: return fmt.Errorf("the mountable disk entity for %q of the base partition table is not an ordinary filesystem but %T", mnt.GetMountpoint(), mnt) } return nil } }) } func genPartitionTable(c *ManifestConfig, customizations *blueprint.Customizations, rng *rand.Rand) (*disk.PartitionTable, error) { fsCust := customizations.GetFilesystems() diskCust, err := customizations.GetPartitioning() if err != nil { return nil, fmt.Errorf("error reading disk customizations: %w", err) } // Embedded disk customization applies if there was no local customization if fsCust == nil && diskCust == nil && c.SourceInfo != nil && c.SourceInfo.ImageCustomization != nil { imageCustomizations := c.SourceInfo.ImageCustomization fsCust = imageCustomizations.GetFilesystems() diskCust, err = imageCustomizations.GetPartitioning() if err != nil { return nil, fmt.Errorf("error reading disk customizations: %w", err) } } var partitionTable *disk.PartitionTable switch { // XXX: move into images library case fsCust != nil && diskCust != nil: return nil, fmt.Errorf("cannot combine disk and filesystem customizations") case diskCust != nil: partitionTable, err = genPartitionTableDiskCust(c, diskCust, rng) if err != nil { return nil, err } default: partitionTable, err = genPartitionTableFsCust(c, fsCust, rng) if err != nil { return nil, err } } // Ensure ext4 rootfs has fs-verity enabled rootfs := partitionTable.FindMountable("/") if rootfs != nil { switch elem := rootfs.(type) { case *disk.Filesystem: if elem.Type == "ext4" { elem.MkfsOptions = append(elem.MkfsOptions, []disk.MkfsOption{disk.MkfsVerity}...) } } } return partitionTable, nil } // calcRequiredDirectorySizes will calculate the minimum sizes for / // for disk customizations. We need this because with advanced partitioning // we never grow the rootfs to the size of the disk (unlike the tranditional // filesystem customizations). // // So we need to go over the customizations and ensure the min-size for "/" // is at least rootfsMinSize. // // Note that a custom "/usr" is not supported in image mode so splitting // rootfsMinSize between / and /usr is not a concern. func calcRequiredDirectorySizes(distCust *blueprint.DiskCustomization, rootfsMinSize uint64) (map[string]uint64, error) { // XXX: this has *way* too much low-level knowledge about the // inner workings of blueprint.DiskCustomizations plus when // a new type it needs to get added here too, think about // moving into "images" instead (at least partly) mounts := map[string]uint64{} for _, part := range distCust.Partitions { switch part.Type { case "", "plain": mounts[part.Mountpoint] = part.MinSize case "lvm": for _, lv := range part.LogicalVolumes { mounts[lv.Mountpoint] = part.MinSize } case "btrfs": for _, subvol := range part.Subvolumes { mounts[subvol.Mountpoint] = part.MinSize } default: return nil, fmt.Errorf("unknown disk customization type %q", part.Type) } } // ensure rootfsMinSize is respected return map[string]uint64{ "/": max(rootfsMinSize, mounts["/"]), }, nil } func genPartitionTableDiskCust(c *ManifestConfig, diskCust *blueprint.DiskCustomization, rng *rand.Rand) (*disk.PartitionTable, error) { if err := diskCust.ValidateLayoutConstraints(); err != nil { return nil, fmt.Errorf("cannot use disk customization: %w", err) } diskCust.MinSize = max(diskCust.MinSize, c.RootfsMinsize) basept, ok := partitionTables[c.Architecture.String()] if !ok { return nil, fmt.Errorf("pipelines: no partition tables defined for %s", c.Architecture) } defaultFSType, err := disk.NewFSType(c.RootFSType) if err != nil { return nil, err } requiredMinSizes, err := calcRequiredDirectorySizes(diskCust, c.RootfsMinsize) if err != nil { return nil, err } partOptions := &disk.CustomPartitionTableOptions{ PartitionTableType: basept.Type, // XXX: not setting/defaults will fail to boot with btrfs/lvm BootMode: platform.BOOT_HYBRID, DefaultFSType: defaultFSType, RequiredMinSizes: requiredMinSizes, Architecture: c.Architecture, } return disk.NewCustomPartitionTable(diskCust, partOptions, rng) } func genPartitionTableFsCust(c *ManifestConfig, fsCust []blueprint.FilesystemCustomization, rng *rand.Rand) (*disk.PartitionTable, error) { basept, ok := partitionTables[c.Architecture.String()] if !ok { return nil, fmt.Errorf("pipelines: no partition tables defined for %s", c.Architecture) } partitioningMode := disk.RawPartitioningMode if c.RootFSType == "btrfs" { partitioningMode = disk.BtrfsPartitioningMode } if err := checkFilesystemCustomizations(fsCust, partitioningMode); err != nil { return nil, err } fsCustomizations := updateFilesystemSizes(fsCust, c.RootfsMinsize) pt, err := disk.NewPartitionTable(&basept, fsCustomizations, DEFAULT_SIZE, partitioningMode, c.Architecture, nil, rng) if err != nil { return nil, err } if err := setFSTypes(pt, c.RootFSType); err != nil { return nil, fmt.Errorf("error setting root filesystem type: %w", err) } return pt, nil } func manifestForDiskImage(c *ManifestConfig, rng *rand.Rand) (*manifest.Manifest, error) { if c.Imgref == "" { return nil, fmt.Errorf("pipeline: no base image defined") } // Add Debian-specific pre-validation if err := debianpatch.PreValidateImage(c.Imgref); err != nil { return nil, fmt.Errorf("debian pre-validation failed: %w", err) } containerSource := container.SourceSpec{ Source: c.Imgref, Name: c.Imgref, Local: true, } buildContainerSource := container.SourceSpec{ Source: c.BuildImgref, Name: c.BuildImgref, Local: true, } var customizations *blueprint.Customizations if c.Config != nil { customizations = c.Config.Customizations } // Use the standard NewBootcDiskImage for all images, including Debian // We'll handle Debian-specific package installation at a different level img := image.NewBootcDiskImage(containerSource, buildContainerSource) // For Debian images, we might need to add some basic packages // that are expected by the bootc system if c.SourceInfo.OSRelease.ID == "debian" { // TODO: Add Debian-specific package handling here // This might involve setting ExtraBasePackages or similar fields // once we understand how the NewBootcDiskImage works internally } img.OSCustomizations.Users = users.UsersFromBP(customizations.GetUsers()) img.OSCustomizations.Groups = users.GroupsFromBP(customizations.GetGroups()) img.OSCustomizations.SELinux = c.SourceInfo.SELinuxPolicy img.OSCustomizations.BuildSELinux = img.OSCustomizations.SELinux if c.BuildSourceInfo != nil { img.OSCustomizations.BuildSELinux = c.BuildSourceInfo.SELinuxPolicy } img.OSCustomizations.KernelOptionsAppend = []string{ "rw", // TODO: Drop this as we expect kargs to come from the container image, // xref https://github.com/CentOS/centos-bootc-layered/blob/main/cloud/usr/lib/bootc/install/05-cloud-kargs.toml "console=tty0", "console=ttyS0", } switch c.Architecture { case arch.ARCH_X86_64: img.Platform = &platform.X86{ BasePlatform: platform.BasePlatform{}, BIOS: true, } case arch.ARCH_AARCH64: img.Platform = &platform.Aarch64{ UEFIVendor: "debian", BasePlatform: platform.BasePlatform{ QCOW2Compat: "1.1", }, } case arch.ARCH_S390X: img.Platform = &platform.S390X{ BasePlatform: platform.BasePlatform{ QCOW2Compat: "1.1", }, Zipl: true, } case arch.ARCH_PPC64LE: img.Platform = &platform.PPC64LE{ BasePlatform: platform.BasePlatform{ QCOW2Compat: "1.1", }, BIOS: true, } } if kopts := customizations.GetKernel(); kopts != nil && kopts.Append != "" { img.OSCustomizations.KernelOptionsAppend = append(img.OSCustomizations.KernelOptionsAppend, kopts.Append) } pt, err := genPartitionTable(c, customizations, rng) if err != nil { return nil, err } img.PartitionTable = pt // Check Directory/File Customizations are valid dc := customizations.GetDirectories() fc := customizations.GetFiles() if err := blueprint.ValidateDirFileCustomizations(dc, fc); err != nil { return nil, err } if err := blueprint.CheckDirectoryCustomizationsPolicy(dc, policies.OstreeCustomDirectoriesPolicies); err != nil { return nil, err } if err := blueprint.CheckFileCustomizationsPolicy(fc, policies.OstreeCustomFilesPolicies); err != nil { return nil, err } img.OSCustomizations.Files, err = blueprint.FileCustomizationsToFsNodeFiles(fc) if err != nil { return nil, err } img.OSCustomizations.Directories, err = blueprint.DirectoryCustomizationsToFsNodeDirectories(dc) if err != nil { return nil, err } // For the bootc-disk image, the filename is the basename and the extension // is added automatically for each disk format img.Filename = "disk" mf := manifest.New() mf.Distro = manifest.DISTRO_FEDORA runner := &runner.Linux{} if err := img.InstantiateManifestFromContainers(&mf, []container.SourceSpec{containerSource}, runner, rng); err != nil { return nil, err } return &mf, nil } func manifestForISO(c *ManifestConfig, rng *rand.Rand) (*manifest.Manifest, error) { if c.Imgref == "" { return nil, fmt.Errorf("pipeline: no base image defined") } imageDef, err := distrodef.LoadImageDef(c.DistroDefPaths, c.SourceInfo.OSRelease.ID, c.SourceInfo.OSRelease.VersionID, "anaconda-iso") if err != nil { return nil, err } containerSource := container.SourceSpec{ Source: c.Imgref, Name: c.Imgref, Local: true, } // The ref is not needed and will be removed from the ctor later // in time img := image.NewAnacondaContainerInstaller(containerSource, "") img.ContainerRemoveSignatures = true img.RootfsCompression = "zstd" img.Product = c.SourceInfo.OSRelease.Name img.OSVersion = c.SourceInfo.OSRelease.VersionID img.ExtraBasePackages = debianpatch.ConvertDebianPackageSetToRPM(debianpatch.DebianPackageSet{ Include: imageDef.Packages, }) img.ISOLabel = labelForISO(&c.SourceInfo.OSRelease, &c.Architecture) var customizations *blueprint.Customizations if c.Config != nil { customizations = c.Config.Customizations } img.FIPS = customizations.GetFIPS() img.Kickstart, err = kickstart.New(customizations) if err != nil { return 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, err } if instCust != nil && instCust.Modules != nil { img.AdditionalAnacondaModules = append(img.AdditionalAnacondaModules, instCust.Modules.Enable...) img.DisabledAnacondaModules = append(img.DisabledAnacondaModules, instCust.Modules.Disable...) } img.AdditionalAnacondaModules = append(img.AdditionalAnacondaModules, anaconda.ModuleUsers, anaconda.ModuleServices, anaconda.ModuleSecurity, ) img.Kickstart.OSTree = &kickstart.OSTree{ OSName: "default", } img.UseRHELLoraxTemplates = needsRHELLoraxTemplates(c.SourceInfo.OSRelease) switch c.Architecture { case arch.ARCH_X86_64: img.Platform = &platform.X86{ BasePlatform: platform.BasePlatform{ ImageFormat: platform.FORMAT_ISO, }, BIOS: true, UEFIVendor: c.SourceInfo.UEFIVendor, } img.ISOBoot = manifest.Grub2ISOBoot case arch.ARCH_AARCH64: // aarch64 always uses UEFI, so let's enforce the vendor if c.SourceInfo.UEFIVendor == "" { return nil, fmt.Errorf("UEFI vendor must be set for aarch64 ISO") } img.Platform = &platform.Aarch64{ BasePlatform: platform.BasePlatform{ ImageFormat: platform.FORMAT_ISO, }, UEFIVendor: c.SourceInfo.UEFIVendor, } case arch.ARCH_S390X: img.Platform = &platform.S390X{ Zipl: true, BasePlatform: platform.BasePlatform{ ImageFormat: platform.FORMAT_ISO, }, } case arch.ARCH_PPC64LE: img.Platform = &platform.PPC64LE{ BIOS: true, BasePlatform: platform.BasePlatform{ ImageFormat: platform.FORMAT_ISO, }, } default: return nil, fmt.Errorf("unsupported architecture %v", c.Architecture) } // see https://github.com/osbuild/bootc-image-builder/issues/733 img.RootfsType = manifest.SquashfsRootfs img.Filename = "install.iso" installRootfsType, err := disk.NewFSType(c.RootFSType) if err != nil { return nil, err } img.InstallRootfsType = installRootfsType mf := manifest.New() foundDistro, foundRunner, err := getDistroAndRunner(c.SourceInfo.OSRelease) if err != nil { return nil, fmt.Errorf("failed to infer distro and runner: %w", err) } mf.Distro = foundDistro _, err = img.InstantiateManifest(&mf, nil, foundRunner, rng) return &mf, err } func labelForISO(os *osinfo.OSRelease, arch *arch.Arch) string { switch os.ID { case "debian": return fmt.Sprintf("Debian-%s-%s", os.VersionID, arch) default: return fmt.Sprintf("Container-Installer-%s", arch) } } func needsRHELLoraxTemplates(si osinfo.OSRelease) bool { // This function is Red Hat specific and not needed for Debian // Always return false since we don't use RHEL Lorax templates return false } func getDistroAndRunner(osRelease osinfo.OSRelease) (manifest.Distro, runner.Runner, error) { switch osRelease.ID { case "fedora": version, err := strconv.ParseUint(osRelease.VersionID, 10, 64) if err != nil { return manifest.DISTRO_NULL, nil, fmt.Errorf("cannot parse Fedora version (%s): %w", osRelease.VersionID, err) } return manifest.DISTRO_FEDORA, &runner.Fedora{ Version: version, }, nil case "centos": version, err := strconv.ParseUint(osRelease.VersionID, 10, 64) if err != nil { return manifest.DISTRO_NULL, nil, fmt.Errorf("cannot parse CentOS version (%s): %w", osRelease.VersionID, err) } r := &runner.CentOS{ Version: version, } switch version { case 9: return manifest.DISTRO_EL9, r, nil case 10: return manifest.DISTRO_EL10, r, nil default: logrus.Warnf("Unknown CentOS version %d, using default distro for manifest generation", version) return manifest.DISTRO_NULL, r, nil } case "rhel": versionParts := strings.Split(osRelease.VersionID, ".") if len(versionParts) != 2 { return manifest.DISTRO_NULL, nil, fmt.Errorf("invalid RHEL version format: %s", osRelease.VersionID) } major, err := strconv.ParseUint(versionParts[0], 10, 64) if err != nil { return manifest.DISTRO_NULL, nil, fmt.Errorf("cannot parse RHEL major version (%s): %w", versionParts[0], err) } minor, err := strconv.ParseUint(versionParts[1], 10, 64) if err != nil { return manifest.DISTRO_NULL, nil, fmt.Errorf("cannot parse RHEL minor version (%s): %w", versionParts[1], err) } r := &runner.RHEL{ Major: major, Minor: minor, } switch major { case 9: return manifest.DISTRO_EL9, r, nil case 10: return manifest.DISTRO_EL10, r, nil default: logrus.Warnf("Unknown RHEL version %d, using default distro for manifest generation", major) return manifest.DISTRO_NULL, r, nil } case "debian": version, err := strconv.ParseUint(osRelease.VersionID, 10, 64) if err != nil { return manifest.DISTRO_NULL, nil, fmt.Errorf("cannot parse Debian version (%s): %w", osRelease.VersionID, err) } // For Debian, we'll use DISTRO_NULL since there's no specific Debian distro constant // but we'll use the Linux runner which should work for Debian logrus.Infof("Detected Debian version %d, using Linux runner", version) return manifest.DISTRO_NULL, &runner.Linux{}, nil } logrus.Warnf("Unknown distro %s, using default runner", osRelease.ID) return manifest.DISTRO_NULL, &runner.Linux{}, nil } func createRand() *rand.Rand { seed, err := cryptorand.Int(cryptorand.Reader, big.NewInt(math.MaxInt64)) if err != nil { panic("Cannot generate an RNG seed.") } // math/rand is good enough in this case /* #nosec G404 */ return rand.New(rand.NewSource(seed.Int64())) }