package main_test import ( "fmt" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/osbuild/images/pkg/arch" "github.com/osbuild/images/pkg/blueprint" "github.com/osbuild/images/pkg/datasizes" "github.com/osbuild/images/pkg/disk" "github.com/osbuild/images/pkg/manifest" "github.com/osbuild/images/pkg/runner" bib "github.com/osbuild/bootc-image-builder/bib/cmd/bootc-image-builder" "github.com/osbuild/images/pkg/bib/osinfo" ) func TestGetDistroAndRunner(t *testing.T) { cases := []struct { id string versionID string expectedDistro manifest.Distro expectedRunner runner.Runner expectedErr string }{ // Happy {"debian", "13", manifest.DISTRO_NULL, &runner.Linux{}, ""}, {"debian", "12", manifest.DISTRO_NULL, &runner.Linux{}, ""}, {"debian", "14", manifest.DISTRO_NULL, &runner.Linux{}, ""}, {"toucanos", "42", manifest.DISTRO_NULL, &runner.Linux{}, ""}, // Sad {"debian", "asdf", manifest.DISTRO_NULL, nil, "cannot parse Debian version (asdf)"}, {"debian", "0", manifest.DISTRO_NULL, nil, "could not find def file for distro debian-0"}, } for _, c := range cases { t.Run(fmt.Sprintf("%s-%s", c.id, c.versionID), func(t *testing.T) { osRelease := osinfo.OSRelease{ ID: c.id, VersionID: c.versionID, } distro, runner, err := bib.GetDistroAndRunner(osRelease) if c.expectedErr != "" { assert.ErrorContains(t, err, c.expectedErr) } else { require.NoError(t, err) assert.Equal(t, c.expectedDistro, distro) assert.Equal(t, c.expectedRunner, runner) } }) } } func TestCheckFilesystemCustomizationsValidates(t *testing.T) { for _, tc := range []struct { fsCust []blueprint.FilesystemCustomization ptmode disk.PartitioningMode expectedErr string }{ // happy { fsCust: []blueprint.FilesystemCustomization{}, expectedErr: "", }, { fsCust: []blueprint.FilesystemCustomization{}, ptmode: disk.BtrfsPartitioningMode, expectedErr: "", }, { fsCust: []blueprint.FilesystemCustomization{ {Mountpoint: "/"}, {Mountpoint: "/boot"}, }, ptmode: disk.RawPartitioningMode, expectedErr: "", }, { fsCust: []blueprint.FilesystemCustomization{ {Mountpoint: "/"}, {Mountpoint: "/boot"}, }, ptmode: disk.BtrfsPartitioningMode, expectedErr: "", }, { fsCust: []blueprint.FilesystemCustomization{ {Mountpoint: "/"}, {Mountpoint: "/boot"}, {Mountpoint: "/var/log"}, {Mountpoint: "/var/data"}, }, expectedErr: "", }, // sad { fsCust: []blueprint.FilesystemCustomization{ {Mountpoint: "/"}, {Mountpoint: "/ostree"}, }, ptmode: disk.RawPartitioningMode, expectedErr: "the following errors occurred while validating custom mountpoints:\npath \"/ostree\" is not allowed", }, { fsCust: []blueprint.FilesystemCustomization{ {Mountpoint: "/"}, {Mountpoint: "/var"}, }, ptmode: disk.RawPartitioningMode, expectedErr: "the following errors occurred while validating custom mountpoints:\npath \"/var\" is not allowed", }, { fsCust: []blueprint.FilesystemCustomization{ {Mountpoint: "/"}, {Mountpoint: "/var/data"}, }, ptmode: disk.BtrfsPartitioningMode, expectedErr: "the following errors occurred while validating custom mountpoints:\npath \"/var/data\" is not allowed", }, { fsCust: []blueprint.FilesystemCustomization{ {Mountpoint: "/"}, {Mountpoint: "/boot/"}, }, ptmode: disk.BtrfsPartitioningMode, expectedErr: "the following errors occurred while validating custom mountpoints:\npath \"/boot/\" must be canonical", }, { fsCust: []blueprint.FilesystemCustomization{ {Mountpoint: "/"}, {Mountpoint: "/boot/"}, {Mountpoint: "/opt"}, }, ptmode: disk.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, bib.CheckFilesystemCustomizations(tc.fsCust, tc.ptmode)) } else { assert.ErrorContains(t, bib.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 := bib.CheckFilesystemCustomizations([]blueprint.FilesystemCustomization{{Mountpoint: tc.path}}, disk.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 bib.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, bib.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 := bib.CreateRand() cnf := &bib.ManifestConfig{ Architecture: arch.ARCH_X86_64, RootFSType: "xfs", } cus := &blueprint.Customizations{ Filesystem: []blueprint.FilesystemCustomization{ {Mountpoint: "/var/data", MinSize: 2_000_000}, {Mountpoint: "/var/stuff", MinSize: 10_000_000}, }, } pt, err := bib.GenPartitionTable(cnf, cus, 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 := bib.CreateRand() cnf := &bib.ManifestConfig{ Architecture: arch.ARCH_X86_64, RootFSType: "btrfs", } cus := &blueprint.Customizations{} pt, err := bib.GenPartitionTable(cnf, cus, 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 := bib.CreateRand() cnf := &bib.ManifestConfig{ Architecture: arch.ARCH_X86_64, RootFSType: "xfs", } cus := &blueprint.Customizations{ Disk: &blueprint.DiskCustomization{ Partitions: []blueprint.PartitionCustomization{ { Type: "lvm", VGCustomization: blueprint.VGCustomization{}, }, { Type: "lvm", VGCustomization: blueprint.VGCustomization{}, }, }, }, } _, err := bib.GenPartitionTable(cnf, cus, 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 := bib.CalcRequiredDirectorySizes(cus.Disk, 5*datasizes.GiB) assert.EqualError(t, err, `unknown disk customization type "rando"`) } func TestGenPartitionTableDiskCustomizationSizes(t *testing.T) { rng := bib.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) { cnf := &bib.ManifestConfig{ Architecture: arch.ARCH_X86_64, RootFSType: "xfs", RootfsMinsize: tc.rootfsMinSize, } cus := &blueprint.Customizations{ Disk: &blueprint.DiskCustomization{ Partitions: tc.partitions, }, } pt, err := bib.GenPartitionTable(cnf, cus, 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) { config := getBaseConfig() config.ImageTypes = []string{"qcow2"} config.Config = &blueprint.Blueprint{ Customizations: &blueprint.Customizations{ Files: []blueprint.FileCustomization{ { Path: "/not/allowed", Data: "some-data", }, }, }, } _, err := bib.Manifest(config) assert.EqualError(t, err, `the following custom files are not allowed: ["/not/allowed"]`) } func TestManifestDirCustomizationsSad(t *testing.T) { config := getBaseConfig() config.ImageTypes = []string{"qcow2"} config.Config = &blueprint.Blueprint{ Customizations: &blueprint.Customizations{ Directories: []blueprint.DirectoryCustomization{ { Path: "/dir/not/allowed", }, }, }, } _, err := bib.Manifest(config) assert.EqualError(t, err, `the following custom directories are not allowed: ["/dir/not/allowed"]`) }