diff --git a/README.md b/README.md index 0b7122737..cd5d9c323 100644 --- a/README.md +++ b/README.md @@ -349,6 +349,12 @@ sudo podman run \ The configuration can also be passed in via stdin when `--config -` is used. Only JSON configuration is supported in this mode. +Additionally, images can embed a build config file, either as +`config.json` or `config.toml` in the `/usr/lib/bootc-image-builder` +directory. If this exist, and contains filesystem or disk +customizations, then these are used by default if no such +customization are specified in the regular build config. + ### Users (`user`, array) Possible fields: @@ -534,7 +540,6 @@ By default, the following modules are enabled for all Anaconda ISOs: The `disable` list is processed after the `enable` list and therefore takes priority. In other words, adding the same module in both `enable` and `disable` will result in the module being **disabled**. Furthermore, adding a module that is enabled by default to `disable` will result in the module being **disabled**. - ## Building To build the container locally you can run diff --git a/bib/cmd/bootc-image-builder/image.go b/bib/cmd/bootc-image-builder/image.go index 278147785..a041ce3c3 100644 --- a/bib/cmd/bootc-image-builder/image.go +++ b/bib/cmd/bootc-image-builder/image.go @@ -209,6 +209,18 @@ func genPartitionTable(c *ManifestConfig, customizations *blueprint.Customizatio 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) + } + } + switch { // XXX: move into images library case fsCust != nil && diskCust != nil: diff --git a/bib/internal/buildconfig/config.go b/bib/internal/buildconfig/config.go index 253c5e199..599e2ebbf 100644 --- a/bib/internal/buildconfig/config.go +++ b/bib/internal/buildconfig/config.go @@ -101,6 +101,16 @@ func loadConfig(path string) (*externalBlueprint.Blueprint, error) { } } +func LoadConfig(path string) (*imagesBlueprint.Blueprint, error) { + externalBp, err := loadConfig(path) + if err != nil { + return nil, err + } + + bp := externalBlueprint.Convert(*externalBp) + return &bp, nil +} + func readWithFallback(userConfig string) (*externalBlueprint.Blueprint, error) { // user asked for an explicit config if userConfig != "" { diff --git a/bib/internal/source/source.go b/bib/internal/source/source.go index 4288e2387..059bfcccc 100644 --- a/bib/internal/source/source.go +++ b/bib/internal/source/source.go @@ -8,9 +8,13 @@ import ( "github.com/sirupsen/logrus" + "github.com/osbuild/bootc-image-builder/bib/internal/buildconfig" + "github.com/osbuild/images/pkg/blueprint" "github.com/osbuild/images/pkg/distro" ) +const bibPathPrefix = "usr/lib/bootc-image-builder" + type OSRelease struct { PlatformID string ID string @@ -21,8 +25,9 @@ type OSRelease struct { } type Info struct { - OSRelease OSRelease - UEFIVendor string + OSRelease OSRelease + UEFIVendor string + ImageCustomization *blueprint.Customizations } func validateOSRelease(osrelease map[string]string) error { @@ -58,6 +63,26 @@ func uefiVendor(root string) (string, error) { return "", fmt.Errorf("cannot find UEFI vendor in %s", bootupdEfiDir) } +func readImageCustomization(root string) (*blueprint.Customizations, error) { + prefix := path.Join(root, bibPathPrefix) + config, err := buildconfig.LoadConfig(path.Join(prefix, "config.json")) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + if config == nil { + config, err = buildconfig.LoadConfig(path.Join(prefix, "config.toml")) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + } + // no config found in either toml/json + if config == nil { + return nil, nil + } + + return config.Customizations, nil +} + func LoadInfo(root string) (*Info, error) { osrelease, err := distro.ReadOSReleaseFromTree(root) if err != nil { @@ -71,6 +96,12 @@ func LoadInfo(root string) (*Info, error) { if err != nil { logrus.Debugf("cannot read UEFI vendor: %v, setting it to none", err) } + + customization, err := readImageCustomization(root) + if err != nil { + return nil, err + } + var idLike []string if osrelease["ID_LIKE"] != "" { idLike = strings.Split(osrelease["ID_LIKE"], " ") @@ -86,6 +117,7 @@ func LoadInfo(root string) (*Info, error) { IDLike: idLike, }, - UEFIVendor: vendor, + UEFIVendor: vendor, + ImageCustomization: customization, }, nil } diff --git a/bib/internal/source/source_test.go b/bib/internal/source/source_test.go index 152f941e7..7060cb29a 100644 --- a/bib/internal/source/source_test.go +++ b/bib/internal/source/source_test.go @@ -1,6 +1,7 @@ package source import ( + "fmt" "os" "path" "strings" @@ -47,6 +48,52 @@ func createBootupdEFI(root, uefiVendor string) error { return os.Mkdir(path.Join(root, "usr/lib/bootupd/updates/EFI", uefiVendor), 0755) } +func createImageCustomization(root, custType string) error { + bibDir := path.Join(root, "usr/lib/bootc-image-builder/") + err := os.MkdirAll(bibDir, 0755) + if err != nil { + return err + } + + var buf string + var filename string + switch custType { + case "json": + buf = `{ + "customizations": { + "disk": { + "partitions": [ + { + "label": "var", + "mountpoint": "/var", + "fs_type": "ext4", + "minsize": "3 GiB", + "part_type": "01234567-89ab-cdef-0123-456789abcdef" + } + ] + } + } + }` + filename = "config.json" + case "toml": + buf = `[[customizations.disk.partitions]] +label = "var" +mountpoint = "/var" +fs_type = "ext4" +minsize = "3 GiB" +part_type = "01234567-89ab-cdef-0123-456789abcdef" +` + filename = "config.toml" + case "broken": + buf = "{" + filename = "config.json" + default: + return fmt.Errorf("unsupported customization type %s", custType) + } + + return os.WriteFile(path.Join(bibDir, filename), []byte(buf), 0644) +} + func TestLoadInfo(t *testing.T) { cases := []struct { desc string @@ -57,16 +104,20 @@ func TestLoadInfo(t *testing.T) { platformID string variantID string idLike string + custType string errorStr string }{ - {"happy", "fedora", "40", "Fedora Linux", "fedora", "platform:f40", "coreos", "", ""}, - {"happy-no-uefi", "fedora", "40", "Fedora Linux", "", "platform:f40", "coreos", "", ""}, - {"happy-no-variant_id", "fedora", "40", "Fedora Linux", "", "platform:f40", "", "", ""}, - {"happy-no-id", "fedora", "43", "Fedora Linux", "fedora", "", "", "", ""}, - {"happy-with-id-like", "centos", "9", "CentOS Stream", "", "platform:el9", "", "rhel fedora", ""}, - {"sad-no-id", "", "40", "Fedora Linux", "fedora", "platform:f40", "", "", "missing ID in os-release"}, - {"sad-no-id", "fedora", "", "Fedora Linux", "fedora", "platform:f40", "", "", "missing VERSION_ID in os-release"}, - {"sad-no-id", "fedora", "40", "", "fedora", "platform:f40", "", "", "missing NAME in os-release"}, + {"happy", "fedora", "40", "Fedora Linux", "fedora", "platform:f40", "coreos", "", "json", ""}, + {"happy-no-uefi", "fedora", "40", "Fedora Linux", "", "platform:f40", "coreos", "", "json", ""}, + {"happy-no-variant_id", "fedora", "40", "Fedora Linux", "", "platform:f40", "", "", "json", ""}, + {"happy-no-id", "fedora", "43", "Fedora Linux", "fedora", "", "", "", "json", ""}, + {"happy-with-id-like", "centos", "9", "CentOS Stream", "", "platform:el9", "", "rhel fedora", "json", ""}, + {"happy-no-cust", "fedora", "40", "Fedora Linux", "fedora", "platform:f40", "coreos", "", "", ""}, + {"happy-toml", "fedora", "40", "Fedora Linux", "fedora", "platform:f40", "coreos", "", "toml", ""}, + {"sad-no-id", "", "40", "Fedora Linux", "fedora", "platform:f40", "", "", "json", "missing ID in os-release"}, + {"sad-no-id", "fedora", "", "Fedora Linux", "fedora", "platform:f40", "", "", "json", "missing VERSION_ID in os-release"}, + {"sad-no-id", "fedora", "40", "", "fedora", "platform:f40", "", "", "json", "missing NAME in os-release"}, + {"sad-broken-json", "fedora", "40", "Fedora Linux", "fedora", "platform:f40", "coreos", "", "broken", "cannot decode \"$ROOT/usr/lib/bootc-image-builder/config.json\": unexpected EOF"}, } for _, c := range cases { @@ -76,12 +127,16 @@ func TestLoadInfo(t *testing.T) { if c.uefiVendor != "" { require.NoError(t, createBootupdEFI(root, c.uefiVendor)) + } + if c.custType != "" { + require.NoError(t, createImageCustomization(root, c.custType)) + } info, err := LoadInfo(root) if c.errorStr != "" { - require.EqualError(t, err, c.errorStr) + require.EqualError(t, err, strings.ReplaceAll(c.errorStr, "$ROOT", root)) return } require.NoError(t, err) @@ -91,6 +146,19 @@ func TestLoadInfo(t *testing.T) { assert.Equal(t, c.uefiVendor, info.UEFIVendor) assert.Equal(t, c.platformID, info.OSRelease.PlatformID) assert.Equal(t, c.variantID, info.OSRelease.VariantID) + if c.custType != "" { + assert.NotNil(t, info.ImageCustomization) + assert.NotNil(t, info.ImageCustomization.Disk) + assert.NotEmpty(t, info.ImageCustomization.Disk.Partitions) + part := info.ImageCustomization.Disk.Partitions[0] + assert.Equal(t, part.Label, "var") + assert.Equal(t, part.MinSize, uint64(3*1024*1024*1024)) + assert.Equal(t, part.FSType, "ext4") + assert.Equal(t, part.Mountpoint, "/var") + // TODO: Validate part.PartType when it is fixed + } else { + assert.Nil(t, info.ImageCustomization) + } if c.idLike == "" { assert.Equal(t, len(info.OSRelease.IDLike), 0) } else { diff --git a/test/test_build_disk.py b/test/test_build_disk.py index 63699aac2..d2467aaa0 100644 --- a/test/test_build_disk.py +++ b/test/test_build_disk.py @@ -137,7 +137,8 @@ def registry_conf_fixture(shared_tmpdir, request): "-p", f"{registry_port}:5000", "--restart", "always", "--name", registry_container_name, - "registry:2" + # We use a copy of docker.io registry to avoid running into docker.io pull rate limits + "ghcr.io/osbuild/bootc-image-builder/registry:2" ], check=True) registry_container_state = subprocess.run([ diff --git a/test/test_manifest.py b/test/test_manifest.py index 2b1493698..90ed1c699 100644 --- a/test/test_manifest.py +++ b/test/test_manifest.py @@ -826,3 +826,101 @@ def test_manifest_customization_custom_file_smoke(tmp_path, build_container): '[{"path":"/etc/custom_dir","exist_ok":true}]},' '"devices":{"disk":{"type":"org.osbuild.loopback"' ',"options":{"filename":"disk.raw"') in output + + +def find_sfdisk_stage_from(manifest_str): + manifest = json.loads(manifest_str) + for pipl in manifest["pipelines"]: + if pipl["name"] == "image": + for st in pipl["stages"]: + if st["type"] == "org.osbuild.sfdisk": + return st["options"] + raise ValueError(f"cannot find sfdisk stage manifest:\n{manifest_str}") + + +def test_manifest_image_customize_filesystem(tmp_path, build_container): + # no need to parameterize this test, overrides behaves same for all containers + container_ref = "quay.io/centos-bootc/centos-bootc:stream9" + testutil.pull_container(container_ref) + + cfg = { + "blueprint": { + "customizations": { + "filesystem": [ + { + "mountpoint": "/boot", + "minsize": "3GiB" + } + ] + }, + }, + } + + config_json_path = tmp_path / "config.json" + config_json_path.write_text(json.dumps(cfg), encoding="utf-8") + + # create derrived container with filesystem customization + cntf_path = tmp_path / "Containerfile" + cntf_path.write_text(textwrap.dedent(f"""\n + FROM {container_ref} + RUN mkdir -p -m 0755 /usr/lib/bootc-image-builder + COPY config.json /usr/lib/bootc-image-builder/ + """), encoding="utf8") + + print(f"building filesystem customize container from {container_ref}") + with make_container(tmp_path) as container_tag: + print(f"using {container_tag}") + manifest_str = subprocess.check_output([ + *testutil.podman_run_common, + build_container, + "manifest", + f"localhost/{container_tag}", + ], encoding="utf8") + sfdisk_options = find_sfdisk_stage_from(manifest_str) + assert sfdisk_options["partitions"][2]["size"] == 3 * 1024 * 1024 * 1024 / 512 + + +def test_manifest_image_customize_disk(tmp_path, build_container): + # no need to parameterize this test, overrides behaves same for all containers + container_ref = "quay.io/centos-bootc/centos-bootc:stream9" + testutil.pull_container(container_ref) + + cfg = { + "blueprint": { + "customizations": { + "disk": { + "partitions": [ + { + "label": "var", + "mountpoint": "/var", + "fs_type": "ext4", + "minsize": "3 GiB", + }, + ], + }, + }, + }, + } + + config_json_path = tmp_path / "config.json" + config_json_path.write_text(json.dumps(cfg), encoding="utf-8") + + # create derrived container with disk customization + cntf_path = tmp_path / "Containerfile" + cntf_path.write_text(textwrap.dedent(f"""\n + FROM {container_ref} + RUN mkdir -p -m 0755 /usr/lib/bootc-image-builder + COPY config.json /usr/lib/bootc-image-builder/ + """), encoding="utf8") + + print(f"building filesystem customize container from {container_ref}") + with make_container(tmp_path) as container_tag: + print(f"using {container_tag}") + manifest_str = subprocess.check_output([ + *testutil.podman_run_common, + build_container, + "manifest", + f"localhost/{container_tag}", + ], encoding="utf8") + sfdisk_options = find_sfdisk_stage_from(manifest_str) + assert sfdisk_options["partitions"][2]["size"] == 3 * 1024 * 1024 * 1024 / 512