diff --git a/Schutzfile b/Schutzfile index 320ba85692..db95984765 100644 --- a/Schutzfile +++ b/Schutzfile @@ -1,8 +1,13 @@ { "common": { - "rngseed": 2026030200, - "bootc-image-builder": { - "ref": "quay.io/centos-bootc/bootc-image-builder@sha256:9893e7209e5f449b86ababfd2ee02a58cca2e5990f77b06c3539227531fc8120" + "rngseed": 2026051400, + "dependencies": { + "bootc-image-builder": { + "ref": "quay.io/centos-bootc/bootc-image-builder@sha256:9893e7209e5f449b86ababfd2ee02a58cca2e5990f77b06c3539227531fc8120" + }, + "osbuild": { + "commit": "6a00632cbaaa211abf6ec43e46ddd14774745054" + } }, "gitlab-ci-runner": "aws/fedora-42", "gitlab-ci-runner-for": { @@ -20,26 +25,7 @@ } } }, - "centos-9": { - "dependencies": { - "osbuild": { - "commit": "6a00632cbaaa211abf6ec43e46ddd14774745054" - } - } - }, - "centos-10": { - "dependencies": { - "osbuild": { - "commit": "6a00632cbaaa211abf6ec43e46ddd14774745054" - } - } - }, "fedora-42": { - "dependencies": { - "osbuild": { - "commit": "6a00632cbaaa211abf6ec43e46ddd14774745054" - } - }, "repos": [ { "file": "/etc/yum.repos.d/fedora.repo", @@ -69,19 +55,5 @@ ] } ] - }, - "fedora-43": { - "dependencies": { - "osbuild": { - "commit": "6a00632cbaaa211abf6ec43e46ddd14774745054" - } - } - }, - "fedora-44": { - "dependencies": { - "osbuild": { - "commit": "6a00632cbaaa211abf6ec43e46ddd14774745054" - } - } } -} \ No newline at end of file +} diff --git a/cmd/build/main.go b/cmd/build/main.go index 41f6462bd1..11de1ea5f6 100644 --- a/cmd/build/main.go +++ b/cmd/build/main.go @@ -12,6 +12,9 @@ import ( "github.com/osbuild/images/internal/buildconfig" "github.com/osbuild/images/internal/cmdutil" "github.com/osbuild/images/pkg/arch" + "github.com/osbuild/images/pkg/bootc" + "github.com/osbuild/images/pkg/distro" + "github.com/osbuild/images/pkg/distro/generic" "github.com/osbuild/images/pkg/distrofactory" "github.com/osbuild/images/pkg/manifestgen" "github.com/osbuild/images/pkg/osbuild" @@ -43,12 +46,32 @@ func run() error { flag.StringVar(&imgTypeName, "type", "", "image type name (required)") flag.StringVar(&configFile, "config", "", "build config file (required)") + // bootc args + var bootcRef, bootcBuildRef string + var bootcRemote bool + flag.StringVar(&bootcRef, "bootc-ref", "", "bootc container image ref (e.g., localhost/bootc-foundry/stream10-qcow2:latest)") + flag.StringVar(&bootcBuildRef, "bootc-build-ref", "", "separate build container image ref") + flag.BoolVar(&bootcRemote, "bootc-remote", false, "use org.osbuild.skopeo sources instead of containers-storage") + flag.Parse() - if distroName == "" || imgTypeName == "" || configFile == "" { + if imgTypeName == "" || configFile == "" { + flag.Usage() + os.Exit(1) + } + if distroName == "" && bootcRef == "" { + fmt.Fprintf(os.Stderr, "error: either -distro or -bootc-ref is required\n") flag.Usage() os.Exit(1) } + if distroName != "" && bootcRef != "" { + fmt.Fprintf(os.Stderr, "error: -distro and -bootc-ref are mutually exclusive\n") + flag.Usage() + os.Exit(1) + } + if bootcRef != "" && repositories != "test/data/repositories" { + fmt.Fprintf(os.Stderr, "warning: -repositories is ignored when -bootc-ref is used\n") + } // NOTE: Check the minimum osbuild version before doing anything else. // Building the manifest would fail, but we need to depsolve the packages @@ -59,7 +82,6 @@ func run() error { return err } - distroFac := distrofactory.NewDefault() config, err := buildconfig.New(configFile, nil) if err != nil { return err @@ -69,20 +91,57 @@ func run() error { return fmt.Errorf("failed to create target directory: %w", err) } - distribution := distroFac.GetDistro(distroName) - if distribution == nil { - return fmt.Errorf("invalid or unsupported distribution: %q", distroName) - } + var distribution distro.Distro + if bootcRef != "" { + bootcInfo, err := bootc.ResolveBootcInfo(bootcRef) + if err != nil { + return fmt.Errorf("failed to resolve bootc container info: %w", err) + } + + bootcDistro, err := generic.NewBootc("bootc", bootcInfo) + if err != nil { + return fmt.Errorf("failed to create bootc distro: %w", err) + } + + if bootcBuildRef != "" { + buildInfo, err := bootc.ResolveBootcBuildInfo(bootcBuildRef) + if err != nil { + return fmt.Errorf("failed to resolve bootc build container info: %w", err) + } + if err := bootcDistro.SetBuildContainer(buildInfo); err != nil { + return fmt.Errorf("failed to set build container: %w", err) + } + } - if archName == "" { - archName = arch.Current().String() + distribution = bootcDistro + + if archName == "" { + // NOTE: bootcInfo.Arch contains the Docker/OCI arch name (e.g. "amd64"), + // which must be normalized to the standard name used by the images library + // (e.g. "x86_64") to match the BootcDistro's internal arch map keys. + a, err := arch.FromString(bootcInfo.Arch) + if err != nil { + return fmt.Errorf("unsupported container architecture %q: %w", bootcInfo.Arch, err) + } + archName = a.String() + } + } else { + distroFac := distrofactory.NewDefault() + distribution = distroFac.GetDistro(distroName) + if distribution == nil { + return fmt.Errorf("invalid or unsupported distribution: %q", distroName) + } + if archName == "" { + archName = arch.Current().String() + } } + archi, err := distribution.GetArch(archName) if err != nil { - return fmt.Errorf("invalid arch name %q for distro %q: %w", archName, distroName, err) + return fmt.Errorf("invalid arch name %q for distro %q: %w", archName, distribution.Name(), err) } - buildName := fmt.Sprintf("%s-%s-%s-%s", u(distroName), u(archName), u(imgTypeName), u(config.Name)) + buildName := fmt.Sprintf("%s-%s-%s-%s", u(distribution.Name()), u(archName), u(imgTypeName), u(config.Name)) buildDir := filepath.Join(outputDir, buildName) if err := os.MkdirAll(buildDir, 0777); err != nil { return fmt.Errorf("failed to create target directory: %w", err) @@ -90,30 +149,32 @@ func run() error { imgType, err := archi.GetImageType(imgTypeName) if err != nil { - return fmt.Errorf("invalid image type %q for distro %q and arch %q: %w", imgTypeName, distroName, archName, err) + return fmt.Errorf("invalid image type %q for distro %q and arch %q: %w", imgTypeName, distribution.Name(), archName, err) } // NOTE: we always put the repositories to be used into the allRepos slice, instead of passing the // RepoRegistry to the manifestgen. The reason is that the manifestgen API is too clunky to easily // extend the repos list with custom repositories. - var allRepos []rpmmd.RepoConfig - if st, err := os.Stat(repositories); err == nil && !st.IsDir() { - // anything that is not a dir is tried to be loaded as a file - // to allow "-repositories .json" - repoConfig, err := rpmmd.LoadRepositoriesFromFile(repositories) - if err != nil { - return fmt.Errorf("failed to load repositories from %q: %w", repositories, err) - } - allRepos = repoConfig[archName] - } else { - reporeg, err := reporegistry.New([]string{repositories}, nil) - if err != nil { - return fmt.Errorf("failed to load repositories from %q: %w", repositories, err) - } - allRepos, err = reporeg.ReposByImageTypeName(distribution.Name(), archName, imgTypeName) - if err != nil { - return fmt.Errorf( - "failed to get repositories for %s/%s/%s: %w", distribution.Name(), archName, imgTypeName, err) + allRepos := []rpmmd.RepoConfig{} + if bootcRef == "" { + if st, err := os.Stat(repositories); err == nil && !st.IsDir() { + // anything that is not a dir is tried to be loaded as a file + // to allow "-repositories .json" + repoConfig, err := rpmmd.LoadRepositoriesFromFile(repositories) + if err != nil { + return fmt.Errorf("failed to load repositories from %q: %w", repositories, err) + } + allRepos = repoConfig[archName] + } else { + reporeg, err := reporegistry.New([]string{repositories}, nil) + if err != nil { + return fmt.Errorf("failed to load repositories from %q: %w", repositories, err) + } + allRepos, err = reporeg.ReposByImageTypeName(distribution.Name(), archName, imgTypeName) + if err != nil { + return fmt.Errorf( + "failed to get repositories for %s/%s/%s: %w", distribution.Name(), archName, imgTypeName, err) + } } } seedArg, err := cmdutil.SeedArgFor(config, distribution.Name(), archName) @@ -144,6 +205,15 @@ func run() error { config.Blueprint = &blueprint.Blueprint{} } + if bootcRef != "" { + if config.Options.Bootc == nil { + config.Options.Bootc = &distro.BootcImageOptions{} + } + if bootcRemote { + config.Options.Bootc.UseRemoteContainerSource = true + } + } + mg, err := manifestgen.New(nil, &manifestOpts) if err != nil { return fmt.Errorf("[ERROR] manifest generator creation failed: %w", err) diff --git a/cmd/check-host-config/check/bootc_status.go b/cmd/check-host-config/check/bootc_status.go new file mode 100644 index 0000000000..4ce2258001 --- /dev/null +++ b/cmd/check-host-config/check/bootc_status.go @@ -0,0 +1,20 @@ +package check + +import ( + "github.com/osbuild/images/internal/buildconfig" +) + +func init() { + RegisterCheck(Metadata{ + Name: "bootc-status", + RequiresBootc: true, + }, bootcStatusCheck) +} + +func bootcStatusCheck(meta *Metadata, config *buildconfig.BuildConfig) error { + stdout, stderr, _, err := ExecString("sudo", "bootc", "status") + if err != nil { + return Fail("bootc status failed:", err, "\nstdout:", stdout, "\nstderr:", stderr) + } + return Pass() +} diff --git a/cmd/check-host-config/check/bootc_status_test.go b/cmd/check-host-config/check/bootc_status_test.go new file mode 100644 index 0000000000..a26b7f196a --- /dev/null +++ b/cmd/check-host-config/check/bootc_status_test.go @@ -0,0 +1,62 @@ +package check_test + +import ( + "errors" + "testing" + + check "github.com/osbuild/images/cmd/check-host-config/check" + "github.com/osbuild/images/internal/buildconfig" + "github.com/osbuild/images/pkg/distro" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBootcStatusCheck(t *testing.T) { + tests := []struct { + name string + config *buildconfig.BuildConfig + mockExec map[string]ExecResult + wantErr error + }{ + { + name: "pass when bootc status succeeds", + config: &buildconfig.BuildConfig{ + Options: distro.ImageOptions{ + Bootc: &distro.BootcImageOptions{}, + }, + }, + mockExec: map[string]ExecResult{ + "sudo bootc status": {Stdout: []byte("running")}, + }, + }, + { + name: "fail when bootc status fails", + config: &buildconfig.BuildConfig{ + Options: distro.ImageOptions{ + Bootc: &distro.BootcImageOptions{}, + }, + }, + mockExec: map[string]ExecResult{ + "sudo bootc status": {Err: errors.New("bootc not found"), Code: 1}, + }, + wantErr: check.ErrCheckFailed, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + installMockExec(t, tt.mockExec) + + chk, found := check.FindCheckByName("bootc-status") + require.True(t, found, "bootc-status check not found") + + err := chk.Func(chk.Meta, tt.config) + if tt.wantErr != nil { + require.Error(t, err) + assert.True(t, errors.Is(err, tt.wantErr)) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/cmd/check-host-config/check/meta.go b/cmd/check-host-config/check/meta.go index ef8538180b..3e51635cf3 100644 --- a/cmd/check-host-config/check/meta.go +++ b/cmd/check-host-config/check/meta.go @@ -10,6 +10,7 @@ type Metadata struct { Name string // Name of the check (used for lookup and logging) RequiresBlueprint bool // Ensure Blueprint is not nil, skip the check otherwise RequiresCustomizations bool // Ensure Customizations is not nil, skip the check otherwise + RequiresBootc bool // Ensure Options.Bootc is not nil, skip the check otherwise TempDisabled string // Set to non-empty string with URL to issue tracker to disable the check temporarily RunOn []string // List of OS IDs to run the check on (prefix with `!` to exclude) } diff --git a/cmd/check-host-config/main.go b/cmd/check-host-config/main.go index 9df66adf75..1b8fa30496 100644 --- a/cmd/check-host-config/main.go +++ b/cmd/check-host-config/main.go @@ -83,6 +83,8 @@ func runChecks(checks []check.RegisteredCheck, config *buildconfig.BuildConfig, err = check.Skip("no blueprint") case meta.RequiresCustomizations && (config == nil || config.Blueprint == nil || config.Blueprint.Customizations == nil): err = check.Skip("no customizations") + case meta.RequiresBootc && (config == nil || config.Options.Bootc == nil): + err = check.Skip("not a bootc image") default: err = chk.Func(meta, config) } diff --git a/cmd/check-host-config/main_test.go b/cmd/check-host-config/main_test.go index 1d80e1887b..18a6bdd1fb 100644 --- a/cmd/check-host-config/main_test.go +++ b/cmd/check-host-config/main_test.go @@ -16,6 +16,7 @@ import ( "github.com/osbuild/blueprint/pkg/blueprint" "github.com/osbuild/images/cmd/check-host-config/check" "github.com/osbuild/images/internal/buildconfig" + "github.com/osbuild/images/pkg/distro" ) func TestShouldRunOn(t *testing.T) { @@ -102,6 +103,58 @@ func TestShouldRunOn(t *testing.T) { } } +func TestRunChecks_RequiresBootc(t *testing.T) { + tests := []struct { + name string + config *buildconfig.BuildConfig + wantCheckRan bool + }{ + { + name: "skip when config is nil", + config: nil, + wantCheckRan: false, + }, + { + name: "skip when Options.Bootc is nil", + config: &buildconfig.BuildConfig{ + Blueprint: &blueprint.Blueprint{}, + }, + wantCheckRan: false, + }, + { + name: "run when Options.Bootc is set", + config: &buildconfig.BuildConfig{ + Blueprint: &blueprint.Blueprint{}, + Options: distro.ImageOptions{ + Bootc: &distro.BootcImageOptions{}, + }, + }, + wantCheckRan: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checkRan := false + dummyCheck := check.RegisteredCheck{ + Meta: &check.Metadata{ + Name: "test-bootc-check", + RequiresBootc: true, + }, + Func: func(meta *check.Metadata, config *buildconfig.BuildConfig) error { + checkRan = true + return nil + }, + } + + runChecks([]check.RegisteredCheck{dummyCheck}, tt.config, nil, true) + if checkRan != tt.wantCheckRan { + t.Errorf("check ran = %v, want %v", checkRan, tt.wantCheckRan) + } + }) + } +} + // generateSmokeCACert returns a CA cert PEM (serial 1, CN "Smoke Test CA"). func generateSmokeCACert(t *testing.T) string { t.Helper() diff --git a/test/README.md b/test/README.md index bb970cc364..6d75de34b7 100644 --- a/test/README.md +++ b/test/README.md @@ -166,7 +166,9 @@ in the form "osbuild-commit": "", "commit": "", "boot-success": true, - "pr": "" + "pr": "", + "runner-distro": "", + "iso-embedded-ks": "" } ``` @@ -184,7 +186,9 @@ for example: "osbuild-commit": "74392a0238dec6bfa3f030e46c840148df2814e0", "commit": "52ecfdf1eb345e09c6a6edf4a8d3dd5c8079c51c", "boot-success": true, - "pr": 42 + "pr": 42, + "runner-distro": "fedora-40", + "iso-embedded-ks": "embedded.ks" } ``` diff --git a/test/scripts/boot-image b/test/scripts/boot-image index d0b47bf74a..a9d31fdb89 100755 --- a/test/scripts/boot-image +++ b/test/scripts/boot-image @@ -13,6 +13,7 @@ import subprocess import textwrap import uuid from tempfile import TemporaryDirectory +from typing import Dict, Optional import imgtestlib as testlib @@ -27,25 +28,44 @@ WSL_TEST_SCRIPT = "test/scripts/wsl-entrypoint.bat" ISO_BOOT_TIMEOUT = 1800 +def ensure_env_vars(env_vars: Dict[str, Optional[str]]): + missing = [name for name, value in env_vars.items() if value is None or value == ""] + if missing: + raise RuntimeError(f"Missing or empty environment variables: {missing}") + + def get_aws_config(): - return { + env_vars = { "key_id": os.environ.get("AWS_ACCESS_KEY_ID"), "secret_key": os.environ.get("AWS_SECRET_ACCESS_KEY"), "bucket": os.environ.get("AWS_BUCKET"), "region": os.environ.get("AWS_REGION") } + ensure_env_vars(env_vars) + return env_vars def get_azure_config(): - return { + env_vars = { "subscription": os.environ.get("AZURE_SUBSCRIPTION"), "tenant": os.environ.get("AZURE_TENANT"), "client_id": os.environ.get("AZURE_CLIENT_ID"), "client_secret": os.environ.get("AZURE_CLIENT_SECRET"), "resource_group": os.environ.get("AZURE_RESOURCE_GROUP"), + } + ensure_env_vars(env_vars) + return env_vars + + +def get_wsl_config(): + azure_config = get_azure_config() + env_vars = { "windows_snapshot": os.environ.get("AZURE_WINDOWS_SNAPSHOT"), "windows_ssh_privkey": os.environ.get("AZURE_WINDOWS_SSH_PRIVKEY"), } + ensure_env_vars(env_vars) + env_vars.update(azure_config) + return env_vars @contextlib.contextmanager @@ -212,15 +232,25 @@ def boot_qemu(arch, image_path, config_file, keep_booted=False): signal.pause() -def boot_qemu_iso_no_unattended_support(arch, installer_iso_path, config_file): - # this is for ISOs that have no "unattneded" support in their blueprint, - # manually create one and modify the ISO - rootpw = "".join( - random.choices(string.ascii_uppercase + string.digits, k=18)) +def boot_qemu_iso_no_unattended_support(distro, arch, image_type, installer_iso_path, config_file, iso_embedded_ks_path=None): + """ + Boot an ISO that has no "unattended" support in its blueprint. + Manually create a custom kickstart file and modify the ISO to use it. + """ + # If an embedded kickstart was provided, prepend its content to preserve original directives + # (unless overridden by the unattended automation directives added below). + custom_ks_content = None + if iso_embedded_ks_path: + with open(iso_embedded_ks_path, "r", encoding="utf-8") as fp: + custom_ks_content = fp.read() + print(f"ISO embedded kickstart content: \n{custom_ks_content}\n\n") + + rootpw = "".join(random.choices(string.ascii_uppercase + string.digits, k=18)) rhsm = "" rhsm_unregister = "" - # this is too crude, use "distro" from info file - if "rhel" in installer_iso_path: + + # NOTE: we do not need to register the bootc image, since all content comes from the container + if distro.startswith("rhel") and not image_type.startswith("bootc-"): org_id = os.getenv("SUBSCRIPTION_ORG") activation_key = os.getenv("SUBSCRIPTION_ACTIVATION_KEY") if not org_id or not activation_key: @@ -231,12 +261,20 @@ def boot_qemu_iso_no_unattended_support(arch, installer_iso_path, config_file): # and show up in the inventory subscription-manager unregister """) + with contextlib.ExitStack() as cm: tmpdir = cm.enter_context(TemporaryDirectory(dir="/var/tmp")) (privkey_path, pubkey_path) = cm.enter_context(create_ssh_key()) pubkey = pathlib.Path(pubkey_path).read_text("utf8").strip() unattended_ks = pathlib.Path(tmpdir) / "ks.cfg" - unattended_ks.write_text(textwrap.dedent(f"""\ + + ks_content = "" + if custom_ks_content: + ks_content += "# Content of the original ks embedded in the ISO\n" + ks_content += custom_ks_content + "\n\n" + + ks_content += textwrap.dedent(f"""\ + # Unattended automation directives generated by boot_qemu_iso_no_unattended_support() text --non-interactive zerombr clearpart --all --initlabel @@ -252,11 +290,15 @@ def boot_qemu_iso_no_unattended_support(arch, installer_iso_path, config_file): bootloader --append="console=ttyS0 systemd.journald.forward_to_console=1" %post # workaround for centos-10 as it fails to start here and that causes issue - # with out "check-host-config.sh" that expects a non-degraded boot + # with our "check-host-config.sh" that expects a non-degraded boot systemctl mask mcelog.service || true + echo "osbuild ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/osbuild + chmod 0440 /etc/sudoers.d/osbuild {rhsm_unregister} %end - """)) + """) + unattended_ks.write_text(ks_content) + new_installer_iso_path = pathlib.Path(tmpdir) / os.path.basename(installer_iso_path) subprocess.check_call( ["sudo", "mkksiso", @@ -492,7 +534,7 @@ def boot_wsl(distro, arch, image_path, config): with ensure_uncompressed(image_path) as raw_image_path: cmd = [WSL_TEST_SCRIPT, raw_image_path, BASE_TEST_EXEC+arch, config] make_check_host_config(arch) - az_config = get_azure_config() + az_config = get_wsl_config() with create_ssh_key(privkey_file = az_config["windows_ssh_privkey"]) as (privkey, pubkey): # a lot of resources have <=64 character naming constraint name = f"{distro}-" + str(uuid.uuid4()) @@ -514,6 +556,7 @@ def boot_wsl(distro, arch, image_path, config): testlib.runcmd_nc(cmd) +# pylint: disable=too-many-branches def main(): desc = "Boot an image in the cloud environment it is built for and validate the configuration" parser = argparse.ArgumentParser(description=desc) @@ -533,10 +576,21 @@ def main(): arch = build_info["arch"] image_type = build_info["image-type"] + # NOTE: Some installer ISOs have embedded kickstart, but they are interactive, so we need to embed + # a custom kickstart with non-interactive settings in it. To preserve the original kickstart content, + # we need to read the original kickstart content from the build directory and merge it with the custom + # kickstart content. + iso_embedded_ks = build_info.get("iso-embedded-ks", None) + iso_embedded_ks_path = None + if iso_embedded_ks: + iso_embedded_ks_path = os.path.join(search_path, iso_embedded_ks) + if not os.path.exists(iso_embedded_ks_path): + raise RuntimeError(f"'iso-embedded-ks' specified in the info.json, but file not found: {iso_embedded_ks_path}") + config = json.loads(pathlib.Path(build_config_path).read_text(encoding="utf8")) if not testlib.can_boot_test(image_type, testlib.read_manifest(search_path), image_type, arch, distro, config.get("blueprint", {})): - print(f"{image_type} boot tests are not supported yet") + print(f"SKIP: {image_type} boot tests on {arch} are not supported ({distro})") return print(f"Testing image at {image_path}") @@ -548,8 +602,8 @@ def main(): boot_qemu(arch, image_path, build_config_path, keep_booted=args.keep_booted) case "image-installer" | "minimal-installer": boot_qemu_iso(arch, image_path, build_config_path) - case "network-installer" | "everything-network-installer": - boot_qemu_iso_no_unattended_support(arch, image_path, build_config_path) + case "network-installer" | "everything-network-installer" | "bootc-generic-iso": + boot_qemu_iso_no_unattended_support(distro, arch, image_type, image_path, build_config_path, iso_embedded_ks_path) case "pxe-tar-xz": boot_qemu_pxe(arch, image_path) case "ami" | "ec2" | "ec2-ha" | "ec2-sap" | "edge-ami" | "cloud-ec2": diff --git a/test/scripts/imgtestlib.py b/test/scripts/imgtestlib.py index bfb4e5f02c..7136f42b95 100644 --- a/test/scripts/imgtestlib.py +++ b/test/scripts/imgtestlib.py @@ -17,7 +17,8 @@ REGISTRY = "registry.gitlab.com/redhat/services/products/image-builder/ci/images" -SCHUTZFILE = "Schutzfile" +# Path to the Schutzfile relative to the root of the repository +SCHUTZFILE = str(pathlib.Path(__file__).resolve().parents[2] / "Schutzfile") OS_RELEASE_FILE = "/etc/os-release" # image types that can be boot tested @@ -37,6 +38,7 @@ "image-installer", "minimal-installer", "network-installer", "qcow2", "generic-qcow2", "cloud-qcow2", "wsl", "generic-wsl", + "bootc-generic-iso", ] } @@ -288,7 +290,22 @@ def read_manifests(path): return manifests -# pylint: disable=too-many-return-statements +def _is_bootc_manifest(manifest_data): + """ + Check if the manifest is a bootc manifest by looking for the + `org.osbuild.bootc.install-to-filesystem` stage in any of the pipelines + other than the build pipeline. + """ + for pipeline in manifest_data.get("pipelines", []): + if pipeline.get("name") == "build": + continue + for stage in pipeline.get("stages", []): + if stage.get("type") == "org.osbuild.bootc.install-to-filesystem": + return True + return False + + +# pylint: disable=too-many-return-statements,too-many-branches def can_boot_test(manifest_fname, manifest_data, image_type, arch, distro, blueprint): if not image_type in CAN_BOOT_TEST.get("*", []) + CAN_BOOT_TEST.get(arch, []): return False @@ -315,23 +332,24 @@ def can_boot_test(manifest_fname, manifest_data, image_type, arch, distro, bluep return False if image_type in ["qcow2", "generic-qcow2", "cloud-qcow2", "image-installer", "minimal-installer", - "network-installer", "everything-network-installer"]: + "network-installer", "everything-network-installer", "bootc-generic-iso"]: if blueprint.get("customizations", {}).get("fips") and distro.startswith("fedora"): print(" not bootable: fips on fedora is unstable, fails with e.g. dracut:" "FATAL: FIPS integrity test failed") return False - # Note that this needs adjustment when we switch to librepo - urls = [src["url"] for src in manifest_data["sources"]["org.osbuild.curl"]["items"].values()] - if not any("ssh-server" in url for url in urls): - # This can happen e.g. when an image is build with the "minimal: true" customization. - # We could use guestfs to inject keys, see PR#1995 - print(f" not bootable: ssh-server not found in manifest {manifest_fname} ({arch} {image_type})") - return False - # We need jq in the image many images do not have it - # (e.g. centos-9/rhel-9 with releasever config) so skip those too - if not any("jq" in url for url in urls): - print(f" not bootable: jq not found in {manifest_fname} ({arch} {image_type})") - return False + if not image_type.startswith("bootc-") and not _is_bootc_manifest(manifest_data): + # Note that this needs adjustment when we switch to librepo + urls = [src["url"] for src in manifest_data["sources"]["org.osbuild.curl"]["items"].values()] + if not any("ssh-server" in url for url in urls): + # This can happen e.g. when an image is build with the "minimal: true" customization. + # We could use guestfs to inject keys, see PR#1995 + print(f" not bootable: ssh-server not found in manifest {manifest_fname} ({arch} {image_type})") + return False + # We need jq in the image many images do not have it + # (e.g. centos-9/rhel-9 with releasever config) so skip those too + if not any("jq" in url for url in urls): + print(f" not bootable: jq not found in {manifest_fname} ({arch} {image_type})") + return False return True @@ -519,13 +537,16 @@ def get_host_distro(): def get_osbuild_commit(distro_version): """ - Get the osbuild commit defined in the Schutzfile for the host distro. + Get the osbuild commit defined in the Schutzfile for the host distro or common. If not set, returns None. """ with open(SCHUTZFILE, encoding="utf-8") as schutzfile: data = json.load(schutzfile) - return data.get(distro_version, {}).get("dependencies", {}).get("osbuild", {}).get("commit", None) + commit = data.get(distro_version, {}).get("dependencies", {}).get("osbuild", {}).get("commit", None) + if commit is None: + commit = data.get("common", {}).get("dependencies", {}).get("osbuild", {}).get("commit", None) + return commit def get_bib_ref(): @@ -536,7 +557,7 @@ def get_bib_ref(): with open(SCHUTZFILE, encoding="utf-8") as schutzfile: data = json.load(schutzfile) - return data.get("common", {}).get("bootc-image-builder", {}).get("ref", None) + return data.get("common", {}).get("dependencies", {}).get("bootc-image-builder", {}).get("ref", None) def rng_seed_env(): @@ -660,25 +681,28 @@ def get_common_ci_runner_distro(): def find_image_file(build_path: str) -> str: """ - Find the path to the image by reading the manifest to get the name of the last pipeline and searching for the file - under the directory named after the pipeline. Raises RuntimeError if no or multiple files are found in the expected - path. + Find the path to the image by reading the manifest and finding the exported pipeline's output directory. + A manifest may contain multiple pipelines but only one is exported during a build. This function finds the + exported pipeline by checking which pipeline directory exists in the build output. + Raises RuntimeError if no or multiple exported directories are found, or if the directory doesn't contain + exactly one file. """ manifest_file = os.path.join(build_path, "manifest.json") with open(manifest_file, encoding="utf-8") as manifest: data = json.load(manifest) - last_pipeline = data["pipelines"][-1]["name"] - files = os.listdir(os.path.join(build_path, last_pipeline)) - if len(files) > 1: - error = "Multiple files found in build path while searching for image file" - error += "\n".join(files) - raise RuntimeError(error) + pipeline_names = [p["name"] for p in data["pipelines"] if p["name"] != "build"] + export_dirs = [p for p in pipeline_names if os.path.isdir(os.path.join(build_path, p))] + + if len(export_dirs) != 1: + raise RuntimeError(f"Expected exactly one exported pipeline directory in {build_path}, found: {export_dirs}") - if len(files) == 0: - raise RuntimeError("No found in build path while searching for image file") + files = os.listdir(os.path.join(build_path, export_dirs[0])) + if len(files) != 1: + raise RuntimeError( + f"Expected exactly one file in export directory '{export_dirs[0]}', found: {files}") - return os.path.join(build_path, last_pipeline, files[0]) + return os.path.join(build_path, export_dirs[0], files[0]) def read_build_info(build_path: str) -> Dict: diff --git a/test/scripts/install-dependencies b/test/scripts/install-dependencies index c4949ca38e..cea254d75a 100755 --- a/test/scripts/install-dependencies +++ b/test/scripts/install-dependencies @@ -35,4 +35,5 @@ dnf -y install \ # out why it actually does not work: # # https://github.com/osbuild/images/issues/2288 -pip install . +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +pip install "${SCRIPT_DIR}/../.." diff --git a/test/scripts/update-schutzfile-bib b/test/scripts/update-schutzfile-bib index f35d9bdaaf..f24791ad1d 100755 --- a/test/scripts/update-schutzfile-bib +++ b/test/scripts/update-schutzfile-bib @@ -21,7 +21,7 @@ def main(): with open(testlib.SCHUTZFILE, encoding="utf-8") as schutzfile: data = json.load(schutzfile) - data["common"]["bootc-image-builder"]["ref"] = new_ref + data["common"]["dependencies"]["bootc-image-builder"]["ref"] = new_ref with open(testlib.SCHUTZFILE, "w", encoding="utf-8") as schutzfile: json.dump(data, schutzfile, indent=2) diff --git a/test/scripts/update-schutzfile-osbuild b/test/scripts/update-schutzfile-osbuild index cdf38f8d3e..19a59ab193 100755 --- a/test/scripts/update-schutzfile-osbuild +++ b/test/scripts/update-schutzfile-osbuild @@ -42,10 +42,9 @@ def update_osbuild_commit_ids(new): unique_changes = [] for distro in data.keys(): - if distro == "common": + old = data[distro].get("dependencies", {}).get("osbuild", {}).get("commit") + if old is None: continue - - old = data[distro].get("dependencies", {}).get("osbuild", {}).get("commit", "main") change = f"Changes: https://github.com/osbuild/osbuild/compare/{old}...{new}" if change not in unique_changes: unique_changes.append(change)