diff --git a/pkg/container/client.go b/pkg/container/client.go index c3e500eca0..9ea4de61ab 100644 --- a/pkg/container/client.go +++ b/pkg/container/client.go @@ -403,7 +403,7 @@ func (cl *Client) GetManifest(ctx context.Context, instanceDigest digest.Digest, if local { localId, err = cl.getLocalImageIDFromDigest(instanceDigest) if err != nil { - return r, err + return RawManifest{}, err } } else { // We can pass the instance digest, if it is nil, then this is the primary manifest. @@ -417,12 +417,12 @@ func (cl *Client) GetManifest(ctx context.Context, instanceDigest digest.Digest, ref, err := cl.getImageRef(localId, local) if err != nil { - return + return RawManifest{}, err } src, err := ref.NewImageSource(ctx, cl.sysCtx) if err != nil { - return + return RawManifest{}, err } defer func() { @@ -454,10 +454,10 @@ func (cl *Client) GetManifest(ctx context.Context, instanceDigest digest.Digest, return nil }, &retryOpts); err != nil { - return + return RawManifest{}, err } - return + return r, nil } type manifestList interface { @@ -533,11 +533,9 @@ func (cl *Client) resolveRawManifest(ctx context.Context, rm RawManifest, local case manifest.DockerV2Schema2MediaType: m, err := manifest.Schema2FromManifest(rm.Data) - if err != nil { return resolvedIds{}, nil, nil } - imageID = m.ConfigInfo().Digest default: @@ -545,7 +543,6 @@ func (cl *Client) resolveRawManifest(ctx context.Context, rm RawManifest, local } dg, err := rm.Digest() - if err != nil { return resolvedIds{}, nil, err } diff --git a/pkg/distro/bootc/bootc.go b/pkg/distro/bootc/bootc.go new file mode 100644 index 0000000000..c9e9f204e9 --- /dev/null +++ b/pkg/distro/bootc/bootc.go @@ -0,0 +1,445 @@ +package bootc + +import ( + "errors" + "fmt" + "sort" + "strings" + + "github.com/osbuild/blueprint/pkg/blueprint" + + "github.com/osbuild/images/internal/common" + "github.com/osbuild/images/pkg/arch" + bibcontainer "github.com/osbuild/images/pkg/bib/container" + "github.com/osbuild/images/pkg/bib/osinfo" + "github.com/osbuild/images/pkg/container" + "github.com/osbuild/images/pkg/customizations/users" + "github.com/osbuild/images/pkg/disk" + "github.com/osbuild/images/pkg/distro" + "github.com/osbuild/images/pkg/image" + "github.com/osbuild/images/pkg/manifest" + "github.com/osbuild/images/pkg/platform" + "github.com/osbuild/images/pkg/policies" + "github.com/osbuild/images/pkg/rpmmd" + "github.com/osbuild/images/pkg/runner" +) + +var _ = distro.Distro(&BootcDistro{}) + +type BootcDistro struct { + imgref string + buildImgref string + sourceInfo *osinfo.Info + buildSourceInfo *osinfo.Info + + name string + defaultFs string + releasever string + rootfsMinSize uint64 + + arches map[string]distro.Arch +} + +var _ = distro.Arch(&BootcArch{}) + +type BootcArch struct { + distro *BootcDistro + arch arch.Arch + + imageTypes map[string]distro.ImageType +} + +var _ = distro.ImageType(&BootcImageType{}) + +type BootcImageType struct { + arch *BootcArch + + name string + export string +} + +func (d *BootcDistro) SetBuildContainer(imgref string) (err error) { + if imgref == "" { + return nil + } + + cnt, err := bibcontainer.New(imgref) + if err != nil { + return err + } + defer func() { + err = errors.Join(err, cnt.Stop()) + }() + + info, err := osinfo.Load(cnt.Root()) + if err != nil { + return err + } + d.buildSourceInfo = info + return nil +} + +func (d *BootcDistro) SetDefaultFs(defaultFs string) error { + if defaultFs == "" { + return nil + } + + d.defaultFs = defaultFs + return nil +} + +func (d *BootcDistro) Name() string { + return d.name +} + +func (d *BootcDistro) Codename() string { + return "" +} + +func (d *BootcDistro) Releasever() string { + return d.releasever +} + +func (d *BootcDistro) OsVersion() string { + return d.releasever +} + +func (d *BootcDistro) Product() string { + return d.name +} + +func (d *BootcDistro) ModulePlatformID() string { + return "" +} + +func (d *BootcDistro) OSTreeRef() string { + return "" +} + +func (d *BootcDistro) ListArches() []string { + archs := make([]string, 0, len(d.arches)) + for name := range d.arches { + archs = append(archs, name) + } + sort.Strings(archs) + return archs +} + +func (d *BootcDistro) GetArch(arch string) (distro.Arch, error) { + a, exists := d.arches[arch] + if !exists { + return nil, errors.New("invalid arch: " + arch) + } + return a, nil +} + +func (d *BootcDistro) addArches(arches ...*BootcArch) { + if d.arches == nil { + d.arches = map[string]distro.Arch{} + } + + for _, a := range arches { + a.distro = d + d.arches[a.Name()] = a + } +} + +func (a *BootcArch) Name() string { + return a.arch.String() +} + +func (a *BootcArch) Distro() distro.Distro { + return a.distro +} + +func (a *BootcArch) ListImageTypes() []string { + formats := make([]string, 0, len(a.imageTypes)) + for name := range a.imageTypes { + formats = append(formats, name) + } + sort.Strings(formats) + return formats +} + +func (a *BootcArch) GetImageType(imageType string) (distro.ImageType, error) { + t, exists := a.imageTypes[imageType] + if !exists { + return nil, errors.New("invalid image type: " + imageType) + } + + return t, nil +} + +func (a *BootcArch) addImageTypes(imageTypes ...BootcImageType) { + if a.imageTypes == nil { + a.imageTypes = map[string]distro.ImageType{} + } + for idx := range imageTypes { + it := imageTypes[idx] + it.arch = a + a.imageTypes[it.Name()] = &it + } +} + +func (t *BootcImageType) Name() string { + return t.name +} + +func (t *BootcImageType) Aliases() []string { + return nil +} + +func (t *BootcImageType) Arch() distro.Arch { + return t.arch +} + +func (t *BootcImageType) Filename() string { + return "disk" +} + +func (t *BootcImageType) MIMEType() string { + return "application/x-test" +} + +func (t *BootcImageType) OSTreeRef() string { + return "" +} + +func (t *BootcImageType) ISOLabel() (string, error) { + return "", nil +} + +func (t *BootcImageType) Size(size uint64) uint64 { + if size == 0 { + size = 1073741824 + } + return size +} + +func (t *BootcImageType) PartitionType() disk.PartitionTableType { + return disk.PT_NONE +} + +func (t *BootcImageType) BasePartitionTable() (*disk.PartitionTable, error) { + return nil, nil +} + +func (t *BootcImageType) BootMode() platform.BootMode { + return platform.BOOT_HYBRID +} + +func (t *BootcImageType) BuildPipelines() []string { + return []string{"build"} +} + +func (t *BootcImageType) PayloadPipelines() []string { + return []string{""} +} + +func (t *BootcImageType) PayloadPackageSets() []string { + return nil +} + +func (t *BootcImageType) Exports() []string { + return []string{t.export} +} + +func (t *BootcImageType) SupportedBlueprintOptions() []string { + return []string{ + "customizations.directories", + "customizations.disk", + "customizations.files", + "customizations.filesystem", + "customizations.group", + "customizations.kernel", + "customizations.user", + } +} +func (t *BootcImageType) RequiredBlueprintOptions() []string { + return nil +} + +func (t *BootcImageType) Manifest(bp *blueprint.Blueprint, options distro.ImageOptions, repos []rpmmd.RepoConfig, seedp *int64) (*manifest.Manifest, []string, error) { + if t.arch.distro.imgref == "" { + return nil, nil, fmt.Errorf("internal error: no base image defined") + } + + containerSource := container.SourceSpec{ + Source: t.arch.distro.imgref, + Name: t.arch.distro.imgref, + Local: true, + } + buildContainerSource := container.SourceSpec{ + Source: t.arch.distro.buildImgref, + Name: t.arch.distro.buildImgref, + Local: true, + } + + var customizations *blueprint.Customizations + if bp != nil { + customizations = bp.Customizations + } + + archi := common.Must(arch.FromString(t.arch.Name())) + platform := &platform.Data{ + Arch: archi, + UEFIVendor: t.arch.distro.sourceInfo.UEFIVendor, + QCOW2Compat: "1.1", + } + switch archi { + case arch.ARCH_X86_64: + platform.BIOSPlatform = "i386-pc" + case arch.ARCH_PPC64LE: + platform.BIOSPlatform = "powerpc-ieee1275" + case arch.ARCH_S390X: + platform.ZiplSupport = true + } + // For the bootc-disk image, the filename is the basename and + // the extension is added automatically for each disk format + filename := "disk" + + img := image.NewBootcDiskImage(platform, filename, containerSource, buildContainerSource) + img.OSCustomizations.Users = users.UsersFromBP(customizations.GetUsers()) + img.OSCustomizations.Groups = users.GroupsFromBP(customizations.GetGroups()) + img.OSCustomizations.SELinux = t.arch.distro.sourceInfo.SELinuxPolicy + img.OSCustomizations.BuildSELinux = img.OSCustomizations.SELinux + if t.arch.distro.buildSourceInfo != nil { + img.OSCustomizations.BuildSELinux = t.arch.distro.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", + } + + if kopts := customizations.GetKernel(); kopts != nil && kopts.Append != "" { + img.OSCustomizations.KernelOptionsAppend = append(img.OSCustomizations.KernelOptionsAppend, kopts.Append) + } + + rootfsMinSize := max(t.arch.distro.rootfsMinSize, options.Size) + rng := createRand() + pt, err := t.genPartitionTable(customizations, rootfsMinSize, rng) + if err != nil { + return nil, 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, nil, err + } + if err := blueprint.CheckDirectoryCustomizationsPolicy(dc, policies.OstreeCustomDirectoriesPolicies); err != nil { + return nil, nil, err + } + if err := blueprint.CheckFileCustomizationsPolicy(fc, policies.OstreeCustomFilesPolicies); err != nil { + return nil, nil, err + } + img.OSCustomizations.Files, err = blueprint.FileCustomizationsToFsNodeFiles(fc) + if err != nil { + return nil, nil, err + } + img.OSCustomizations.Directories, err = blueprint.DirectoryCustomizationsToFsNodeDirectories(dc) + if err != nil { + return nil, nil, err + } + + 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, nil, err + } + + return &mf, nil, nil +} + +// newBootcDistro returns a new instance of BootcDistro +// from the given url +func NewBootcDistro(imgref string) (bd *BootcDistro, err error) { + cnt, err := bibcontainer.New(imgref) + if err != nil { + return nil, err + } + defer func() { + err = errors.Join(err, cnt.Stop()) + }() + + info, err := osinfo.Load(cnt.Root()) + if err != nil { + return nil, err + } + + // XXX: provide a way to set defaultfs (needed for bib) + defaultFs, err := cnt.DefaultRootfsType() + if err != nil { + return nil, err + } + cntSize, err := getContainerSize(imgref) + if err != nil { + return nil, fmt.Errorf("cannot get container size: %w", err) + } + + nameVer := fmt.Sprintf("bootc-%s-%s", info.OSRelease.ID, info.OSRelease.VersionID) + bd = &BootcDistro{ + name: nameVer, + releasever: info.OSRelease.VersionID, + defaultFs: defaultFs, + sourceInfo: info, + rootfsMinSize: cntSize * containerSizeToDiskSizeMultiplier, + + imgref: imgref, + buildImgref: imgref, + } + + for _, archStr := range []string{"x86_64", "aarch64", "ppc64le", "s390x", "riscv64"} { + ba := &BootcArch{ + arch: common.Must(arch.FromString(archStr)), + } + // TODO: add iso image types, see bootc-image-builder + ba.addImageTypes( + BootcImageType{ + name: "ami", + export: "image", + }, + BootcImageType{ + name: "qcow2", + export: "qcow2", + }, + BootcImageType{ + name: "raw", + export: "image", + }, + BootcImageType{ + name: "vmdk", + export: "vmdk", + }, + BootcImageType{ + name: "vhd", + export: "bpc", + }, + BootcImageType{ + name: "gce", + export: "gce", + }, + ) + bd.addArches(ba) + } + + return bd, nil +} + +func DistroFactory(idStr string) distro.Distro { + l := strings.SplitN(idStr, ":", 2) + if l[0] != "bootc" { + return nil + } + imgRef := l[1] + + return common.Must(NewBootcDistro(imgRef)) +} diff --git a/pkg/distro/bootc/bootc_test.go b/pkg/distro/bootc/bootc_test.go new file mode 100644 index 0000000000..275e18c39d --- /dev/null +++ b/pkg/distro/bootc/bootc_test.go @@ -0,0 +1,248 @@ +package bootc + +import ( + "encoding/json" + "errors" + "fmt" + "math/rand" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/osbuild/blueprint/pkg/blueprint" + + "github.com/osbuild/images/internal/common" + "github.com/osbuild/images/pkg/container" + "github.com/osbuild/images/pkg/distro" + "github.com/osbuild/images/pkg/dnfjson" + "github.com/osbuild/images/pkg/manifest" +) + +type manifestTestCase struct { + config *blueprint.Blueprint + imageOptions distro.ImageOptions + imageRef string + imageTypes []string + depsolved map[string]dnfjson.DepsolveResult + containers map[string][]container.Spec + expStages map[string][]string + notExpectedStages map[string][]string + err interface{} +} + +func TestManifestGenerationEmptyConfig(t *testing.T) { + imgType := NewTestBootcImageType() + + testCases := map[string]manifestTestCase{ + "qcow2-base": { + imageRef: "example-img-ref", + imageTypes: []string{"qcow2"}, + }, + "empty-imgref": { + imageRef: "", + imageTypes: []string{"qcow2"}, + err: errors.New("internal error: no base image defined"), + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + imgType.arch.distro.imgref = tc.imageRef + _, _, err := imgType.Manifest(tc.config, tc.imageOptions, nil, common.ToPtr(int64(0))) + assert.Equal(t, err, tc.err) + }) + } +} + +func randomLetters(n int) string { + const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + + b := make([]byte, n) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + return string(b) +} + +func getUserConfig() *blueprint.Blueprint { + // add a user + pass := randomLetters(20) + key := "ssh-ed25519 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + return &blueprint.Blueprint{ + Customizations: &blueprint.Customizations{ + User: []blueprint.UserCustomization{ + { + Name: "tester", + Password: &pass, + Key: &key, + }, + }, + }, + } +} + +func TestManifestGenerationUserConfig(t *testing.T) { + imgType := NewTestBootcImageType() + + userConfig := getUserConfig() + testCases := map[string]manifestTestCase{ + "qcow2-user": { + config: userConfig, + imageTypes: []string{"qcow2"}, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + _, _, err := imgType.Manifest(tc.config, tc.imageOptions, nil, common.ToPtr(int64(0))) + assert.NoError(t, err) + }) + } +} + +// Disk images require a container for the build/image pipelines +var containerSpec = container.Spec{ + Source: "test-container", + Digest: "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + ImageID: "sha256:1111111111111111111111111111111111111111111111111111111111111111", +} + +// diskContainers can be passed to Serialize() to get a minimal disk image +var diskContainers = map[string][]container.Spec{ + "build": { + containerSpec, + }, + "image": { + containerSpec, + }, + "target": { + containerSpec, + }, +} + +// simplified representation of a manifest +type testManifest struct { + Pipelines []pipeline `json:"pipelines"` +} +type pipeline struct { + Name string `json:"name"` + Stages []stage `json:"stages"` +} +type stage struct { + Type string `json:"type"` +} + +func checkStages(serialized manifest.OSBuildManifest, pipelineStages map[string][]string, missingStages map[string][]string) error { + mf := &testManifest{} + if err := json.Unmarshal(serialized, mf); err != nil { + return err + } + pipelineMap := map[string]pipeline{} + for _, pl := range mf.Pipelines { + pipelineMap[pl.Name] = pl + } + + for plname, stages := range pipelineStages { + pl, found := pipelineMap[plname] + if !found { + return fmt.Errorf("pipeline %q not found", plname) + } + + stageMap := map[string]bool{} + for _, stage := range pl.Stages { + stageMap[stage.Type] = true + } + for _, stage := range stages { + if _, found := stageMap[stage]; !found { + return fmt.Errorf("pipeline %q - stage %q - not found", plname, stage) + } + } + } + + for plname, stages := range missingStages { + pl, found := pipelineMap[plname] + if !found { + return fmt.Errorf("pipeline %q not found", plname) + } + + stageMap := map[string]bool{} + for _, stage := range pl.Stages { + stageMap[stage.Type] = true + } + for _, stage := range stages { + if _, found := stageMap[stage]; found { + return fmt.Errorf("pipeline %q - stage %q - found (but should not be)", plname, stage) + } + } + } + + return nil +} + +func TestManifestSerialization(t *testing.T) { + baseConfig := &blueprint.Blueprint{} + userConfig := getUserConfig() + testCases := map[string]manifestTestCase{ + "qcow2-base": { + config: baseConfig, + imageTypes: []string{"qcow2"}, + containers: diskContainers, + expStages: map[string][]string{ + "build": {"org.osbuild.container-deploy"}, + "image": { + "org.osbuild.bootc.install-to-filesystem", + }, + }, + notExpectedStages: map[string][]string{ + "build": {"org.osbuild.rpm"}, + "image": { + "org.osbuild.users", + }, + }, + }, + "qcow2-user": { + config: userConfig, + imageTypes: []string{"qcow2"}, + containers: diskContainers, + expStages: map[string][]string{ + "build": {"org.osbuild.container-deploy"}, + "image": { + "org.osbuild.users", // user creation stage when we add users + "org.osbuild.bootc.install-to-filesystem", + }, + }, + notExpectedStages: map[string][]string{ + "build": {"org.osbuild.rpm"}, + }, + }, + "qcow2-nocontainer": { + config: userConfig, + imageTypes: []string{"qcow2"}, + // errors come from BuildrootFromContainer() + // TODO: think about better error and testing here (not the ideal layer or err msg) + err: "serialization not started", + }, + } + + // Use an empty config: only the imgref is required + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + imgType := NewTestBootcImageType() + + assert := assert.New(t) + mf, _, err := imgType.Manifest(tc.config, tc.imageOptions, nil, common.ToPtr(int64(0))) + assert.NoError(err) // this isn't the error we're testing for + + if tc.err != nil { + assert.PanicsWithValue(tc.err, func() { + _, err := mf.Serialize(tc.depsolved, tc.containers, nil, nil) + assert.NoError(err) + }) + } else { + manifestJson, err := mf.Serialize(tc.depsolved, tc.containers, nil, nil) + assert.NoError(err) + assert.NoError(checkStages(manifestJson, tc.expStages, tc.notExpectedStages)) + } + }) + } +} diff --git a/pkg/distro/bootc/export_test.go b/pkg/distro/bootc/export_test.go new file mode 100644 index 0000000000..6d85a9b967 --- /dev/null +++ b/pkg/distro/bootc/export_test.go @@ -0,0 +1,44 @@ +package bootc + +import ( + "math/rand" + + "github.com/osbuild/blueprint/pkg/blueprint" + + "github.com/osbuild/images/pkg/arch" + "github.com/osbuild/images/pkg/bib/osinfo" + "github.com/osbuild/images/pkg/disk" +) + +var ( + CheckFilesystemCustomizations = checkFilesystemCustomizations + PartitionTables = partitionTables + UpdateFilesystemSizes = updateFilesystemSizes + CreateRand = createRand + CalcRequiredDirectorySizes = calcRequiredDirectorySizes +) + +func NewTestBootcImageType() *BootcImageType { + d := &BootcDistro{ + sourceInfo: &osinfo.Info{ + OSRelease: osinfo.OSRelease{ + ID: "bootc-test", + }, + }, + imgref: "quay.io/example/example:ref", + defaultFs: "xfs", + } + a := &BootcArch{distro: d, arch: arch.ARCH_X86_64} + imgType := &BootcImageType{ + arch: a, + name: "qcow2", + export: "qcow2", + } + a.addImageTypes(*imgType) + + return imgType +} + +func (t *BootcImageType) GenPartitionTable(customizations *blueprint.Customizations, rootfsMinSize uint64, rng *rand.Rand) (*disk.PartitionTable, error) { + return t.genPartitionTable(customizations, rootfsMinSize, rng) +} diff --git a/pkg/distro/bootc/image_test.go b/pkg/distro/bootc/image_test.go new file mode 100644 index 0000000000..320bb78288 --- /dev/null +++ b/pkg/distro/bootc/image_test.go @@ -0,0 +1,662 @@ +package bootc_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/osbuild/blueprint/pkg/blueprint" + + "github.com/osbuild/images/internal/common" + "github.com/osbuild/images/pkg/datasizes" + "github.com/osbuild/images/pkg/disk" + "github.com/osbuild/images/pkg/disk/partition" + "github.com/osbuild/images/pkg/distro" + "github.com/osbuild/images/pkg/distro/bootc" +) + +func TestCheckFilesystemCustomizationsValidates(t *testing.T) { + for _, tc := range []struct { + fsCust []blueprint.FilesystemCustomization + ptmode partition.PartitioningMode + expectedErr string + }{ + // happy + { + fsCust: []blueprint.FilesystemCustomization{}, + expectedErr: "", + }, + { + fsCust: []blueprint.FilesystemCustomization{}, + ptmode: partition.BtrfsPartitioningMode, + expectedErr: "", + }, + { + fsCust: []blueprint.FilesystemCustomization{ + {Mountpoint: "/"}, {Mountpoint: "/boot"}, + }, + ptmode: partition.RawPartitioningMode, + expectedErr: "", + }, + { + fsCust: []blueprint.FilesystemCustomization{ + {Mountpoint: "/"}, {Mountpoint: "/boot"}, + }, + ptmode: partition.BtrfsPartitioningMode, + expectedErr: "", + }, + { + fsCust: []blueprint.FilesystemCustomization{ + {Mountpoint: "/"}, + {Mountpoint: "/boot"}, + {Mountpoint: "/var/log"}, + {Mountpoint: "/var/data"}, + }, + expectedErr: "", + }, + // sad + { + fsCust: []blueprint.FilesystemCustomization{ + {Mountpoint: "/"}, + {Mountpoint: "/ostree"}, + }, + ptmode: partition.RawPartitioningMode, + expectedErr: "the following errors occurred while validating custom mountpoints:\npath \"/ostree\" is not allowed", + }, + { + fsCust: []blueprint.FilesystemCustomization{ + {Mountpoint: "/"}, + {Mountpoint: "/var"}, + }, + ptmode: partition.RawPartitioningMode, + expectedErr: "the following errors occurred while validating custom mountpoints:\npath \"/var\" is not allowed", + }, + { + fsCust: []blueprint.FilesystemCustomization{ + {Mountpoint: "/"}, + {Mountpoint: "/var/data"}, + }, + ptmode: partition.BtrfsPartitioningMode, + expectedErr: "the following errors occurred while validating custom mountpoints:\npath \"/var/data\" is not allowed", + }, + { + fsCust: []blueprint.FilesystemCustomization{ + {Mountpoint: "/"}, + {Mountpoint: "/boot/"}, + }, + ptmode: partition.BtrfsPartitioningMode, + expectedErr: "the following errors occurred while validating custom mountpoints:\npath \"/boot/\" must be canonical", + }, + { + fsCust: []blueprint.FilesystemCustomization{ + {Mountpoint: "/"}, + {Mountpoint: "/boot/"}, + {Mountpoint: "/opt"}, + }, + ptmode: partition.BtrfsPartitioningMode, + expectedErr: "the following errors occurred while validating custom mountpoints:\npath \"/boot/\" must be canonical\npath \"/opt\" is not allowed", + }, + } { + if tc.expectedErr == "" { + assert.NoError(t, bootc.CheckFilesystemCustomizations(tc.fsCust, tc.ptmode)) + } else { + assert.ErrorContains(t, bootc.CheckFilesystemCustomizations(tc.fsCust, tc.ptmode), tc.expectedErr) + } + } +} + +func TestLocalMountpointPolicy(t *testing.T) { + // extended testing of the general mountpoint policy (non-minimal) + type testCase struct { + path string + allowed bool + } + + testCases := []testCase{ + // existing mountpoints / and /boot are fine for sizing + {"/", true}, + {"/boot", true}, + + // root mountpoints are not allowed + {"/data", false}, + {"/opt", false}, + {"/stuff", false}, + {"/usr", false}, + + // /var explicitly is not allowed + {"/var", false}, + + // subdirs of /boot are not allowed + {"/boot/stuff", false}, + {"/boot/loader", false}, + + // /var subdirectories are allowed + {"/var/data", true}, + {"/var/scratch", true}, + {"/var/log", true}, + {"/var/opt", true}, + {"/var/opt/application", true}, + + // but not these + {"/var/home", false}, + {"/var/lock", false}, // symlink to ../run/lock which is on tmpfs + {"/var/mail", false}, // symlink to spool/mail + {"/var/mnt", false}, + {"/var/roothome", false}, + {"/var/run", false}, // symlink to ../run which is on tmpfs + {"/var/srv", false}, + {"/var/usrlocal", false}, + + // nor their subdirs + {"/var/run/subrun", false}, + {"/var/srv/test", false}, + {"/var/home/user", false}, + {"/var/usrlocal/bin", false}, + } + + for _, tc := range testCases { + t.Run(tc.path, func(t *testing.T) { + err := bootc.CheckFilesystemCustomizations([]blueprint.FilesystemCustomization{{Mountpoint: tc.path}}, partition.RawPartitioningMode) + if err != nil && tc.allowed { + t.Errorf("expected %s to be allowed, but got error: %v", tc.path, err) + } else if err == nil && !tc.allowed { + t.Errorf("expected %s to be denied, but got no error", tc.path) + } + }) + } +} + +func TestBasePartitionTablesHaveRoot(t *testing.T) { + // make sure that all base partition tables have at least a root partition defined + for arch, pt := range bootc.PartitionTables { + rootMountable := pt.FindMountable("/") + if rootMountable == nil { + t.Errorf("partition table %q does not define a root filesystem", arch) + } + _, isFS := rootMountable.(*disk.Filesystem) + if !isFS { + t.Errorf("root mountable for %q is not an ordinary filesystem", arch) + } + } +} + +func TestUpdateFilesystemSizes(t *testing.T) { + type testCase struct { + customizations []blueprint.FilesystemCustomization + minRootSize uint64 + expected []blueprint.FilesystemCustomization + } + + testCases := map[string]testCase{ + "simple": { + customizations: nil, + minRootSize: 999, + expected: []blueprint.FilesystemCustomization{ + { + Mountpoint: "/", + MinSize: 999, + }, + }, + }, + "container-is-larger": { + customizations: []blueprint.FilesystemCustomization{ + { + Mountpoint: "/", + MinSize: 10, + }, + }, + minRootSize: 999, + expected: []blueprint.FilesystemCustomization{ + { + Mountpoint: "/", + MinSize: 999, + }, + }, + }, + "container-is-smaller": { + customizations: []blueprint.FilesystemCustomization{ + { + Mountpoint: "/", + MinSize: 1000, + }, + }, + minRootSize: 892, + expected: []blueprint.FilesystemCustomization{ + { + Mountpoint: "/", + MinSize: 1000, + }, + }, + }, + "customizations-noroot": { + customizations: []blueprint.FilesystemCustomization{ + { + Mountpoint: "/var/data", + MinSize: 1_000_000, + }, + }, + minRootSize: 9000, + expected: []blueprint.FilesystemCustomization{ + { + Mountpoint: "/var/data", + MinSize: 1_000_000, + }, + { + Mountpoint: "/", + MinSize: 9000, + }, + }, + }, + "customizations-withroot-smallcontainer": { + customizations: []blueprint.FilesystemCustomization{ + { + Mountpoint: "/var/data", + MinSize: 1_000_000, + }, + { + Mountpoint: "/", + MinSize: 2_000_000, + }, + }, + minRootSize: 9000, + expected: []blueprint.FilesystemCustomization{ + { + Mountpoint: "/var/data", + MinSize: 1_000_000, + }, + { + Mountpoint: "/", + MinSize: 2_000_000, + }, + }, + }, + "customizations-withroot-largecontainer": { + customizations: []blueprint.FilesystemCustomization{ + { + Mountpoint: "/var/data", + MinSize: 1_000_000, + }, + { + Mountpoint: "/", + MinSize: 2_000_000, + }, + }, + minRootSize: 9_000_000, + expected: []blueprint.FilesystemCustomization{ + { + Mountpoint: "/var/data", + MinSize: 1_000_000, + }, + { + Mountpoint: "/", + MinSize: 9_000_000, + }, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert.ElementsMatch(t, bootc.UpdateFilesystemSizes(tc.customizations, tc.minRootSize), tc.expected) + }) + } + +} + +func findMountableSizeableFor(pt *disk.PartitionTable, needle string) (disk.Mountable, disk.Sizeable) { + var foundMnt disk.Mountable + var foundParent disk.Sizeable + err := pt.ForEachMountable(func(mnt disk.Mountable, path []disk.Entity) error { + if mnt.GetMountpoint() == needle { + foundMnt = mnt + for idx := len(path) - 1; idx >= 0; idx-- { + if sz, ok := path[idx].(disk.Sizeable); ok { + foundParent = sz + break + } + } + } + return nil + }) + if err != nil { + panic(err) + } + return foundMnt, foundParent +} + +func TestGenPartitionTableSetsRootfsForAllFilesystemsXFS(t *testing.T) { + rng := bootc.CreateRand() + + imgType := bootc.NewTestBootcImageType() + + cus := &blueprint.Customizations{ + Filesystem: []blueprint.FilesystemCustomization{ + {Mountpoint: "/var/data", MinSize: 2_000_000}, + {Mountpoint: "/var/stuff", MinSize: 10_000_000}, + }, + } + rootfsMinSize := uint64(0) + pt, err := imgType.GenPartitionTable(cus, rootfsMinSize, rng) + assert.NoError(t, err) + + for _, mntPoint := range []string{"/", "/boot", "/var/data"} { + mnt, _ := findMountableSizeableFor(pt, mntPoint) + assert.Equal(t, "xfs", mnt.GetFSType()) + } + _, parent := findMountableSizeableFor(pt, "/var/data") + assert.True(t, parent.GetSize() >= 2_000_000) + + _, parent = findMountableSizeableFor(pt, "/var/stuff") + assert.True(t, parent.GetSize() >= 10_000_000) + + // ESP is always vfat + mnt, _ := findMountableSizeableFor(pt, "/boot/efi") + assert.Equal(t, "vfat", mnt.GetFSType()) +} + +func TestGenPartitionTableSetsRootfsForAllFilesystemsBtrfs(t *testing.T) { + rng := bootc.CreateRand() + + imgType := bootc.NewTestBootcImageType() + err := imgType.Arch().Distro().(*bootc.BootcDistro).SetDefaultFs("btrfs") + assert.NoError(t, err) + cus := &blueprint.Customizations{} + rootfsMinSize := uint64(0) + pt, err := imgType.GenPartitionTable(cus, rootfsMinSize, rng) + assert.NoError(t, err) + + mnt, _ := findMountableSizeableFor(pt, "/") + assert.Equal(t, "btrfs", mnt.GetFSType()) + + // btrfs has a default (ext4) /boot + mnt, _ = findMountableSizeableFor(pt, "/boot") + assert.Equal(t, "ext4", mnt.GetFSType()) + + // ESP is always vfat + mnt, _ = findMountableSizeableFor(pt, "/boot/efi") + assert.Equal(t, "vfat", mnt.GetFSType()) +} +func TestGenPartitionTableDiskCustomizationRunsValidateLayoutConstraints(t *testing.T) { + rng := bootc.CreateRand() + + imgType := bootc.NewTestBootcImageType() + + cus := &blueprint.Customizations{ + Disk: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "lvm", + VGCustomization: blueprint.VGCustomization{}, + }, + { + Type: "lvm", + VGCustomization: blueprint.VGCustomization{}, + }, + }, + }, + } + _, err := imgType.GenPartitionTable(cus, 0, rng) + assert.EqualError(t, err, "cannot use disk customization: multiple LVM volume groups are not yet supported") +} + +func TestGenPartitionTableDiskCustomizationUnknownTypesError(t *testing.T) { + cus := &blueprint.Customizations{ + Disk: &blueprint.DiskCustomization{ + Partitions: []blueprint.PartitionCustomization{ + { + Type: "rando", + }, + }, + }, + } + _, err := bootc.CalcRequiredDirectorySizes(cus.Disk, 5*datasizes.GiB) + assert.EqualError(t, err, `unknown disk customization type "rando"`) +} + +func TestGenPartitionTableDiskCustomizationSizes(t *testing.T) { + rng := bootc.CreateRand() + + for _, tc := range []struct { + name string + rootfsMinSize uint64 + partitions []blueprint.PartitionCustomization + expectedMinRootSize uint64 + }{ + { + "empty disk customizaton, root expands to rootfsMinsize", + 2 * datasizes.GiB, + nil, + 2 * datasizes.GiB, + }, + // plain + { + "plain, no root minsize, expands to rootfsMinSize", + 5 * datasizes.GiB, + []blueprint.PartitionCustomization{ + { + MinSize: 10 * datasizes.GiB, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/var", + FSType: "xfs", + }, + }, + }, + 5 * datasizes.GiB, + }, + { + "plain, small root minsize, expands to rootfsMnSize", + 5 * datasizes.GiB, + []blueprint.PartitionCustomization{ + { + MinSize: 1 * datasizes.GiB, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/", + FSType: "xfs", + }, + }, + }, + 5 * datasizes.GiB, + }, + { + "plain, big root minsize", + 5 * datasizes.GiB, + []blueprint.PartitionCustomization{ + { + MinSize: 10 * datasizes.GiB, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/", + FSType: "xfs", + }, + }, + }, + 10 * datasizes.GiB, + }, + // btrfs + { + "btrfs, no root minsize, expands to rootfsMinSize", + 5 * datasizes.GiB, + []blueprint.PartitionCustomization{ + { + Type: "btrfs", + MinSize: 10 * datasizes.GiB, + BtrfsVolumeCustomization: blueprint.BtrfsVolumeCustomization{ + Subvolumes: []blueprint.BtrfsSubvolumeCustomization{ + { + Mountpoint: "/var", + Name: "varvol", + }, + }, + }, + }, + }, + 5 * datasizes.GiB, + }, + { + "btrfs, small root minsize, expands to rootfsMnSize", + 5 * datasizes.GiB, + []blueprint.PartitionCustomization{ + { + Type: "btrfs", + MinSize: 1 * datasizes.GiB, + BtrfsVolumeCustomization: blueprint.BtrfsVolumeCustomization{ + Subvolumes: []blueprint.BtrfsSubvolumeCustomization{ + { + Mountpoint: "/", + Name: "rootvol", + }, + }, + }, + }, + }, + 5 * datasizes.GiB, + }, + { + "btrfs, big root minsize", + 5 * datasizes.GiB, + []blueprint.PartitionCustomization{ + { + Type: "btrfs", + MinSize: 10 * datasizes.GiB, + BtrfsVolumeCustomization: blueprint.BtrfsVolumeCustomization{ + Subvolumes: []blueprint.BtrfsSubvolumeCustomization{ + { + Mountpoint: "/", + Name: "rootvol", + }, + }, + }, + }, + }, + 10 * datasizes.GiB, + }, + // lvm + { + "lvm, no root minsize, expands to rootfsMinSize", + 5 * datasizes.GiB, + []blueprint.PartitionCustomization{ + { + Type: "lvm", + MinSize: 10 * datasizes.GiB, + VGCustomization: blueprint.VGCustomization{ + LogicalVolumes: []blueprint.LVCustomization{ + { + MinSize: 10 * datasizes.GiB, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/var", + FSType: "xfs", + }, + }, + }, + }, + }, + }, + 5 * datasizes.GiB, + }, + { + "lvm, small root minsize, expands to rootfsMnSize", + 5 * datasizes.GiB, + []blueprint.PartitionCustomization{ + { + Type: "lvm", + MinSize: 1 * datasizes.GiB, + VGCustomization: blueprint.VGCustomization{ + LogicalVolumes: []blueprint.LVCustomization{ + { + MinSize: 1 * datasizes.GiB, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/", + FSType: "xfs", + }, + }, + }, + }, + }, + }, + 5 * datasizes.GiB, + }, + { + "lvm, big root minsize", + 5 * datasizes.GiB, + []blueprint.PartitionCustomization{ + { + Type: "lvm", + MinSize: 10 * datasizes.GiB, + VGCustomization: blueprint.VGCustomization{ + LogicalVolumes: []blueprint.LVCustomization{ + { + MinSize: 10 * datasizes.GiB, + FilesystemTypedCustomization: blueprint.FilesystemTypedCustomization{ + Mountpoint: "/", + FSType: "xfs", + }, + }, + }, + }, + }, + }, + 10 * datasizes.GiB, + }, + } { + t.Run(tc.name, func(t *testing.T) { + imgType := bootc.NewTestBootcImageType() + + rootfsMinsize := tc.rootfsMinSize + cus := &blueprint.Customizations{ + Disk: &blueprint.DiskCustomization{ + Partitions: tc.partitions, + }, + } + pt, err := imgType.GenPartitionTable(cus, rootfsMinsize, rng) + assert.NoError(t, err) + + var rootSize uint64 + err = pt.ForEachMountable(func(mnt disk.Mountable, path []disk.Entity) error { + if mnt.GetMountpoint() == "/" { + for idx := len(path) - 1; idx >= 0; idx-- { + if parent, ok := path[idx].(disk.Sizeable); ok { + rootSize = parent.GetSize() + break + } + } + } + return nil + }) + assert.NoError(t, err) + // expected size is within a reasonable limit + assert.True(t, rootSize >= tc.expectedMinRootSize && rootSize < tc.expectedMinRootSize+5*datasizes.MiB) + }) + } +} + +func TestManifestFilecustomizationsSad(t *testing.T) { + imgType := bootc.NewTestBootcImageType() + bp := &blueprint.Blueprint{ + Customizations: &blueprint.Customizations{ + Files: []blueprint.FileCustomization{ + { + Path: "/not/allowed", + Data: "some-data", + }, + }, + }, + } + + _, _, err := imgType.Manifest(bp, distro.ImageOptions{}, nil, common.ToPtr(int64(0))) + assert.EqualError(t, err, `the following custom files are not allowed: ["/not/allowed"]`) +} + +func TestManifestDirCustomizationsSad(t *testing.T) { + imgType := bootc.NewTestBootcImageType() + bp := &blueprint.Blueprint{ + Customizations: &blueprint.Customizations{ + Directories: []blueprint.DirectoryCustomization{ + { + Path: "/dir/not/allowed", + }, + }, + }, + } + + _, _, err := imgType.Manifest(bp, distro.ImageOptions{}, nil, common.ToPtr(int64(0))) + assert.EqualError(t, err, `the following custom directories are not allowed: ["/dir/not/allowed"]`) +} diff --git a/pkg/distro/bootc/partition.go b/pkg/distro/bootc/partition.go new file mode 100644 index 0000000000..6dbd853a07 --- /dev/null +++ b/pkg/distro/bootc/partition.go @@ -0,0 +1,295 @@ +package bootc + +import ( + "errors" + "fmt" + "math/rand" + + "github.com/osbuild/blueprint/pkg/blueprint" + + "github.com/osbuild/images/pkg/disk" + "github.com/osbuild/images/pkg/disk/partition" + "github.com/osbuild/images/pkg/pathpolicy" + "github.com/osbuild/images/pkg/platform" +) + +const ( + DEFAULT_SIZE = uint64(10 * GibiByte) + + // As a baseline heuristic we double the size of + // the input container to support in-place updates. + // This is planned to be more configurable in the + // future. + containerSizeToDiskSizeMultiplier = 2 +) + +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 (t *BootcImageType) genPartitionTable(customizations *blueprint.Customizations, rootfsMinSize uint64, 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 && t.arch.distro.sourceInfo != nil && t.arch.distro.sourceInfo.ImageCustomization != nil { + imageCustomizations := t.arch.distro.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 = t.genPartitionTableDiskCust(diskCust, rootfsMinSize, rng) + if err != nil { + return nil, err + } + default: + partitionTable, err = t.genPartitionTableFsCust(fsCust, rootfsMinSize, 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 +} + +func (t *BootcImageType) genPartitionTableDiskCust(diskCust *blueprint.DiskCustomization, rootfsMinSize uint64, 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, rootfsMinSize) + + basept, ok := partitionTables[t.arch.Name()] + if !ok { + return nil, fmt.Errorf("pipelines: no partition tables defined for %s", t.arch.Name()) + } + defaultFSType, err := disk.NewFSType(t.arch.distro.defaultFs) + if err != nil { + return nil, err + } + requiredMinSizes, err := calcRequiredDirectorySizes(diskCust, 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: t.arch.arch, + } + return disk.NewCustomPartitionTable(diskCust, partOptions, rng) +} + +func (t *BootcImageType) genPartitionTableFsCust(fsCust []blueprint.FilesystemCustomization, rootfsMinSize uint64, rng *rand.Rand) (*disk.PartitionTable, error) { + basept, ok := partitionTables[t.arch.Name()] + if !ok { + return nil, fmt.Errorf("pipelines: no partition tables defined for %s", t.arch.Name()) + } + + partitioningMode := partition.RawPartitioningMode + if t.arch.distro.defaultFs == "btrfs" { + partitioningMode = partition.BtrfsPartitioningMode + } + if err := checkFilesystemCustomizations(fsCust, partitioningMode); err != nil { + return nil, err + } + fsCustomizations := updateFilesystemSizes(fsCust, rootfsMinSize) + + pt, err := disk.NewPartitionTable(&basept, fsCustomizations, DEFAULT_SIZE, partitioningMode, t.arch.arch, nil, rng) + if err != nil { + return nil, err + } + + if err := setFSTypes(pt, t.arch.distro.defaultFs); err != nil { + return nil, fmt.Errorf("error setting root filesystem type: %w", err) + } + return pt, nil +} + +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 +} + +// 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 checkFilesystemCustomizations(fsCustomizations []blueprint.FilesystemCustomization, ptmode partition.PartitioningMode) error { + var policy *pathpolicy.PathPolicies + switch ptmode { + case partition.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 + } + }) +} diff --git a/pkg/distro/bootc/partition_tables.go b/pkg/distro/bootc/partition_tables.go new file mode 100644 index 0000000000..2e09dd808a --- /dev/null +++ b/pkg/distro/bootc/partition_tables.go @@ -0,0 +1,130 @@ +package bootc + +import ( + "github.com/osbuild/images/pkg/arch" + "github.com/osbuild/images/pkg/disk" + "github.com/osbuild/images/pkg/distro" +) + +const ( + MebiByte = 1024 * 1024 // MiB + GibiByte = 1024 * 1024 * 1024 // GiB + // BootOptions defines the mountpoint options for /boot + // See https://github.com/containers/bootc/pull/341 for the rationale for + // using `ro` by default. Briefly it protects against corruption + // by non-ostree aware tools. + BootOptions = "ro" + // And we default to `ro` for the rootfs too, because we assume the input + // container image is using composefs. For more info, see + // https://github.com/containers/bootc/pull/417 and + // https://github.com/ostreedev/ostree/issues/3193 + RootOptions = "ro" +) + +// diskUuidOfUnknownOrigin is used by default for disk images, +// picked by someone in the past for unknown reasons. More in +// e.g. https://github.com/osbuild/bootc-image-builder/pull/568 and +// https://github.com/osbuild/images/pull/823 +const diskUuidOfUnknownOrigin = "D209C89E-EA5E-4FBD-B161-B461CCE297E0" + +// efiPartition defines the default ESP. See also +// https://en.wikipedia.org/wiki/EFI_system_partition +var efiPartition = disk.Partition{ + Size: 501 * MebiByte, + Type: disk.EFISystemPartitionGUID, + UUID: disk.EFISystemPartitionUUID, + Payload: &disk.Filesystem{ + Type: "vfat", + UUID: disk.EFIFilesystemUUID, + Mountpoint: "/boot/efi", + Label: "EFI-SYSTEM", + FSTabOptions: "umask=0077,shortname=winnt", + FSTabFreq: 0, + FSTabPassNo: 2, + }, +} + +// bootPartition defines a distinct filesystem for /boot +// which is needed for e.g. LVM or LUKS when using GRUB +// (which this project doesn't support today...) +// See also https://github.com/containers/bootc/pull/529/commits/e5548d8765079171e6ed39a3ab0479bc8681a1c9 +var bootPartition = disk.Partition{ + Size: 1 * GibiByte, + Type: disk.FilesystemDataGUID, + UUID: disk.DataPartitionUUID, + Payload: &disk.Filesystem{ + Type: "ext4", + Mountpoint: "/boot", + Label: "boot", + FSTabOptions: BootOptions, + FSTabFreq: 1, + FSTabPassNo: 2, + }, +} + +// rootPartition holds the root filesystem; however note +// that while the type here defines "ext4" because the data +// type requires something there, in practice we pull +// the rootfs type from the container image by default. +// See https://containers.github.io/bootc/bootc-install.html +var rootPartition = disk.Partition{ + Size: 2 * GibiByte, + Type: disk.FilesystemDataGUID, + UUID: disk.RootPartitionUUID, + Payload: &disk.Filesystem{ + Type: "ext4", + Label: "root", + Mountpoint: "/", + FSTabOptions: RootOptions, + FSTabFreq: 1, + FSTabPassNo: 1, + }, +} + +var partitionTables = distro.BasePartitionTableMap{ + arch.ARCH_X86_64.String(): disk.PartitionTable{ + UUID: diskUuidOfUnknownOrigin, + Type: disk.PT_GPT, + Partitions: []disk.Partition{ + { + Size: 1 * MebiByte, + Bootable: true, + Type: disk.BIOSBootPartitionGUID, + UUID: disk.BIOSBootPartitionUUID, + }, + efiPartition, + bootPartition, + rootPartition, + }, + }, + arch.ARCH_AARCH64.String(): disk.PartitionTable{ + UUID: diskUuidOfUnknownOrigin, + Type: disk.PT_GPT, + Partitions: []disk.Partition{ + efiPartition, + bootPartition, + rootPartition, + }, + }, + arch.ARCH_S390X.String(): disk.PartitionTable{ + UUID: diskUuidOfUnknownOrigin, + Type: disk.PT_GPT, + Partitions: []disk.Partition{ + bootPartition, + rootPartition, + }, + }, + arch.ARCH_PPC64LE.String(): disk.PartitionTable{ + UUID: diskUuidOfUnknownOrigin, + Type: disk.PT_GPT, + Partitions: []disk.Partition{ + { + Size: 4 * MebiByte, + Type: disk.PRePartitionGUID, + Bootable: true, + }, + bootPartition, + rootPartition, + }, + }, +} diff --git a/pkg/distro/bootc/util.go b/pkg/distro/bootc/util.go new file mode 100644 index 0000000000..1049550dcb --- /dev/null +++ b/pkg/distro/bootc/util.go @@ -0,0 +1,37 @@ +package bootc + +import ( + cryptorand "crypto/rand" + "fmt" + "math" + "math/big" + "math/rand" + "os/exec" + "strconv" + "strings" +) + +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())) +} + +// getContainerSize returns the size of an already pulled container image in bytes +func getContainerSize(imgref string) (uint64, error) { + output, err := exec.Command("podman", "image", "inspect", imgref, "--format", "{{.Size}}").Output() + if err != nil { + return 0, fmt.Errorf("failed inspect image: %w, output\n%s", err, output) + } + size, err := strconv.ParseUint(strings.TrimSpace(string(output)), 10, 64) + if err != nil { + return 0, fmt.Errorf("cannot parse image size: %w", err) + } + + return size, nil +} diff --git a/pkg/distrofactory/distrofactory.go b/pkg/distrofactory/distrofactory.go index 693989b5fb..d5cdfc586f 100644 --- a/pkg/distrofactory/distrofactory.go +++ b/pkg/distrofactory/distrofactory.go @@ -5,6 +5,7 @@ import ( "sort" "github.com/osbuild/images/pkg/distro" + "github.com/osbuild/images/pkg/distro/bootc" "github.com/osbuild/images/pkg/distro/generic" "github.com/osbuild/images/pkg/distro/test_distro" ) @@ -106,6 +107,7 @@ func New(factories ...FactoryFunc) *Factory { func NewDefault() *Factory { return New( generic.DistroFactory, + bootc.DistroFactory, ) } diff --git a/pkg/manifestgen/manifestgen.go b/pkg/manifestgen/manifestgen.go index a595480d3d..b689abb81f 100644 --- a/pkg/manifestgen/manifestgen.go +++ b/pkg/manifestgen/manifestgen.go @@ -3,6 +3,7 @@ package manifestgen import ( "bytes" "encoding/json" + "errors" "fmt" "io" "os" @@ -29,6 +30,10 @@ const ( defaultDepsolveCacheDir = "osbuild-depsolve-dnf" ) +var ( + ErrContainerArchMismatch = errors.New("requested container architecture does not match resolved container") +) + // Options contains the optional settings for the manifest generation. // For unset values defaults will be used. type Options struct { @@ -174,6 +179,14 @@ func (mg *Generator) Generate(bp *blueprint.Blueprint, dist distro.Distro, imgTy if err != nil { return err } + for _, specs := range containerSpecs { + for _, spec := range specs { + if spec.Arch.String() != a.Name() { + return fmt.Errorf("%w: %q != %q", ErrContainerArchMismatch, spec.Arch, a.Name()) + } + } + } + commitSpecs, err := mg.commitResolver(preManifest.GetOSTreeSourceSpecs()) if err != nil { return err diff --git a/pkg/manifestgen/manifestgen_test.go b/pkg/manifestgen/manifestgen_test.go index 6415ab4c7f..5ab6af74e2 100644 --- a/pkg/manifestgen/manifestgen_test.go +++ b/pkg/manifestgen/manifestgen_test.go @@ -13,6 +13,8 @@ import ( "github.com/stretchr/testify/require" "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/distro" "github.com/osbuild/images/pkg/distrofactory" @@ -194,6 +196,7 @@ func fakeContainerResolver(containerSources map[string][]container.SourceSpec, a Source: fmt.Sprintf("resolved-cnt-%s", spec.Source), Digest: "sha256:" + sha256For("digest:"+spec.Source), ImageID: "sha256:" + sha256For("id:"+spec.Source), + Arch: common.Must(arch.FromString(archName)), }) } containerSpecs[plName] = containers