From 00e68bc6be9c240ab70cc699b6166347c80ca5f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anna=20V=C3=ADtov=C3=A1?= Date: Wed, 24 Jun 2026 18:31:29 +0200 Subject: [PATCH 1/5] cmd/image-builder: add bootc-remote, size flags Wire new CLI flags --bootc-remote and --image-size through manifest so that test configs can be built via image-builder build/manifest. Related: HMS-10855 --- cmd/image-builder/main.go | 13 +++++++++++++ cmd/image-builder/manifest.go | 8 ++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/cmd/image-builder/main.go b/cmd/image-builder/main.go index 3fcc027b3c..5b0ac99833 100644 --- a/cmd/image-builder/main.go +++ b/cmd/image-builder/main.go @@ -413,6 +413,15 @@ func cmdManifestWrapper(pbar progress.ProgressBar, cmd *cobra.Command, args []st if err != nil { return err } + bootcRemote, err := cmd.Flags().GetBool("bootc-pull-container") + if err != nil { + return err + } + imageSize, err := cmd.Flags().GetUint64("image-size") + if err != nil { + return err + } + var preview *bool // Verify that the flag was actually passed. If it wasn't passed // we keep our nil value so that images used the distro-defined @@ -502,6 +511,8 @@ func cmdManifestWrapper(pbar progress.ProgressBar, cmd *cobra.Command, args []st BootcRef: bootcRef, BootcInstallerPayloadRef: bootcInstallerPayloadRef, BootcOmitDefaultKernelArgs: bootcOmitDefaultKernelArgs, + BootcRemote: bootcRemote, + ImageSize: imageSize, WithSBOM: withSBOM, WithRPMList: withRPMList, IgnoreWarnings: ignoreWarnings, @@ -844,6 +855,8 @@ operating systems like Fedora, CentOS and RHEL with easy customizations support. manifestCmd.Flags().String("bootc-installer-payload-ref", "", `bootc installer payload ref`) manifestCmd.Flags().String("bootc-default-fs", "", `default filesystem to use for the bootc install (e.g. ext4)`) manifestCmd.Flags().Bool("bootc-no-default-kernel-args", false, `don't use the default kernel arguments`) + manifestCmd.Flags().Bool("bootc-pull-container", false, `pull bootc container from remote location instead of using it from local container storage`) + manifestCmd.Flags().Uint64("image-size", 0, `override the default image size in bytes`) manifestCmd.Flags().Bool("use-librepo", true, `use librepo to download packages (disable if you use old versions of osbuild)`) if err := manifestCmd.Flags().MarkHidden("use-librepo"); err != nil { return err diff --git a/cmd/image-builder/manifest.go b/cmd/image-builder/manifest.go index a794310009..90b4c13215 100644 --- a/cmd/image-builder/manifest.go +++ b/cmd/image-builder/manifest.go @@ -31,6 +31,8 @@ type manifestOptions struct { BootcRef string BootcInstallerPayloadRef string BootcOmitDefaultKernelArgs bool + BootcRemote bool + ImageSize uint64 Subscription *subscription.ImageOptions RpmDownloader osbuild.RpmDownloader WithSBOM bool @@ -106,9 +108,11 @@ func generateManifest(repoDir string, extraRepos []string, img *imagefilter.Resu Facts: &facts.ImageOptions{APIType: facts.IBCLI_APITYPE}, OSTree: opts.Ostree, Subscription: opts.Subscription, + Size: opts.ImageSize, Bootc: &distro.BootcImageOptions{ - InstallerPayloadRef: opts.BootcInstallerPayloadRef, - OmitDefaultKernelArgs: opts.BootcOmitDefaultKernelArgs, + InstallerPayloadRef: opts.BootcInstallerPayloadRef, + OmitDefaultKernelArgs: opts.BootcOmitDefaultKernelArgs, + UseRemoteContainerSource: opts.BootcRemote, }, Preview: opts.Preview, } From 68616583bd508d1b1a7ef33833d2ba1f9e3b2492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anna=20V=C3=ADtov=C3=A1?= Date: Thu, 25 Jun 2026 12:40:58 +0200 Subject: [PATCH 2/5] imgtestlib: use image-builder instead of build This commit swaps usage of build in tests to using image-builder directly. After swapping from build to image-builder in tests, it is possible to use options instead of solely just a config file. This commit extracts options from the json config: --ostree-ref, --ostree-url, --ostree-parent, --bootc-installer-payload-ref, --bootc-remote, --image-size, and --extra-repo. It also extracts the blueprint part of the config into a standalone file, so that it can be used for building with image-builder. The commit also changes find_image_file function which now looks for different filename, since image-builder creates a different file than ./bin/build. Related: HMS-10855 --- cmd/build/main.go | 256 ------------------------------- test/scripts/imgtestlib/build.py | 63 +++++++- test/scripts/imgtestlib/core.py | 46 ++++-- test/scripts/test_imgtestlib.py | 174 ++++++--------------- 4 files changed, 135 insertions(+), 404 deletions(-) delete mode 100644 cmd/build/main.go diff --git a/cmd/build/main.go b/cmd/build/main.go deleted file mode 100644 index ed959d5fc0..0000000000 --- a/cmd/build/main.go +++ /dev/null @@ -1,256 +0,0 @@ -// Standalone executable for building a test image. -package main - -import ( - "flag" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/osbuild/blueprint/pkg/blueprint" - "github.com/osbuild/image-builder/internal/buildconfig" - "github.com/osbuild/image-builder/internal/cmdutil" - "github.com/osbuild/image-builder/pkg/arch" - "github.com/osbuild/image-builder/pkg/bootc" - "github.com/osbuild/image-builder/pkg/distro" - "github.com/osbuild/image-builder/pkg/distro/generic" - "github.com/osbuild/image-builder/pkg/distrofactory" - "github.com/osbuild/image-builder/pkg/manifestgen" - "github.com/osbuild/image-builder/pkg/osbuild" - "github.com/osbuild/image-builder/pkg/reporegistry" - "github.com/osbuild/image-builder/pkg/rhsm/facts" - "github.com/osbuild/image-builder/pkg/rpmmd" -) - -func u(s string) string { - return strings.ReplaceAll(s, "-", "_") -} - -func run() error { - // common args - var outputDir, osbuildStore, rpmCacheRoot, repositories, archName string - flag.StringVar(&outputDir, "output", ".", "artifact output directory") - flag.StringVar(&osbuildStore, "store", ".osbuild", "osbuild store for intermediate pipeline trees") - flag.StringVar(&rpmCacheRoot, "rpmmd", "/tmp/rpmmd", "rpm metadata cache directory") - flag.StringVar(&repositories, "repositories", "test/data/repositories", "path to repository file or directory") - flag.StringVar(&archName, "arch", "", "target architecture") - - // osbuild checkpoint arg - var checkpoints cmdutil.MultiValue - flag.Var(&checkpoints, "checkpoints", "comma-separated list of pipeline names to checkpoint (passed to osbuild --checkpoint)") - - // image selection args - var distroName, imgTypeName, configFile string - flag.StringVar(&distroName, "distro", "", "distribution (required)") - 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 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 - // also with the minimum osbuild version. Although the depsolve may fail - // with an error, it is for the best to fail with the version mismatch - // error. - if err := osbuild.CheckMinimumOSBuildVersion(); err != nil { - return err - } - - config, err := buildconfig.New(configFile, nil) - if err != nil { - return err - } - - if err := os.MkdirAll(outputDir, 0777); err != nil { - return fmt.Errorf("failed to create target directory: %w", err) - } - - 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) - } - } - - 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, distribution.Name(), err) - } - - 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) - } - - imgType, err := archi.GetImageType(imgTypeName) - if err != nil { - 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. - 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) - if err != nil { - return err - } - - // Extend the repositories with the custom repositories from the build config - if len(config.CustomRepos) > 0 { - allRepos = append(allRepos, config.CustomRepos...) - } - - fmt.Printf("Generating manifest for %s: ", config.Name) - manifestOpts := manifestgen.Options{ - Cachedir: filepath.Join(rpmCacheRoot, archName+distribution.Name()), - WarningsOutput: os.Stderr, - OverrideRepos: allRepos, - CustomSeed: &seedArg, - } - if archName != arch.Current().String() { - manifestOpts.UseBootstrapContainer = true - } - // add RHSM fact to detect changes - config.Options.Facts = &facts.ImageOptions{ - APIType: facts.TEST_APITYPE, - } - if config.Blueprint == nil { - 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) - } - mf, err := mg.Generate(config.Blueprint, imgType, &config.Options) - if err != nil { - return fmt.Errorf("[ERROR] manifest generation failed: %w", err) - } - fmt.Print("DONE\n") - - manifestPath := filepath.Join(buildDir, "manifest.json") - // nolint:gosec - if err := os.WriteFile(manifestPath, mf, 0644); err != nil { - return fmt.Errorf("failed to write output file %q: %w", manifestPath, err) - } - - fmt.Printf("Building manifest: %s\n", manifestPath) - - jobOutput := filepath.Join(outputDir, buildName) - _, err = osbuild.RunOSBuild(mf, &osbuild.OSBuildOptions{ - StoreDir: osbuildStore, - OutputDir: jobOutput, - Exports: imgType.Exports(), - Checkpoints: checkpoints, - JSONOutput: false, - }) - if err != nil { - return err - } - - fmt.Printf("Jobs done. Results saved in\n%s\n", outputDir) - return nil -} - -func main() { - if err := run(); err != nil { - fmt.Fprintf(os.Stderr, "error: %s\n", err) - os.Exit(1) - } -} diff --git a/test/scripts/imgtestlib/build.py b/test/scripts/imgtestlib/build.py index b9d65304e8..02a2fba466 100644 --- a/test/scripts/imgtestlib/build.py +++ b/test/scripts/imgtestlib/build.py @@ -1,37 +1,86 @@ import json import os -from typing import Dict +import tempfile +from typing import Dict, List from .gitlab import log_section from .run import runcmd, runcmd_nc from .testenv import get_host_distro, get_osbuild_commit, rng_seed_env +def config_to_cli_args(config: dict) -> List[str]: + args: List[str] = [] + + blueprint = config.get("blueprint", {}) + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as bp_file: + json.dump(blueprint, bp_file) + args.append(f"--blueprint={bp_file.name}") + + options = config.get("options", {}) + ostree = options.get("ostree", {}) + if ref := ostree.get("ref"): + args.append(f"--ostree-ref={ref}") + if url := ostree.get("url"): + args.append(f"--ostree-url={url}") + if parent := ostree.get("parent"): + args.append(f"--ostree-parent={parent}") + + bootc = options.get("bootc", {}) + if payload_ref := bootc.get("installer_payload_ref"): + args.append(f"--bootc-installer-payload-ref={payload_ref}") + if bootc.get("use_remote_container_source"): + args.append("--bootc-pull-container") + + if size := options.get("size"): + args.append(f"--image-size={size}") + + for repo in config.get("custom_repos", []): + for url in repo.get("baseurls", []): + args.append(f"--extra-repo={url}") + + return args + + @log_section("Building image") def build_image(distro, arch, image_type, config_path): with open(config_path, "r", encoding="utf-8") as config_file: config = json.load(config_file) config_name = config["name"] + build_name = gen_build_name(distro, arch, image_type, config_name) + build_dir = os.path.join("build", build_name) print(f"👷 Building image {distro}/{image_type} using config {config_path}") # print the config for logging print(json.dumps(config, indent=2)) - runcmd(["go", "build", "-o", "./bin/build", "./cmd/build"]) - - cmd = ["sudo", "-E", "./bin/build", "--output", "./build", "--checkpoints", "build", - "--distro", distro, "--arch", arch, "--type", image_type, "--config", config_path] - runcmd_nc(cmd, extra_env=rng_seed_env()) + runcmd(["go", "build", "-o", "./bin/image-builder", "./cmd/image-builder"]) + seed = rng_seed_env()["OSBUILD_TESTING_RNG_SEED"] + cmd = [ + "sudo", "-E", "./bin/image-builder", "build", image_type, + "--distro", distro, + "--arch", arch, + "--force-repo-dir", "test/data/repositories", + "--output-dir", build_dir, + "--output-name", build_name, + "--with-manifest", + "--ignore-warnings", + "--seed", str(seed), + ] + cmd.extend(config_to_cli_args(config)) + runcmd_nc(cmd) print("✅ Build finished!!") # Build artifacts are owned by root. Make them world accessible. runcmd(["sudo", "chmod", "a+rwX", "-R", "./build"]) - build_dir = os.path.join("build", gen_build_name(distro, arch, image_type, config_name)) + osbuild_manifest = os.path.join(build_dir, f"{build_name}.osbuild-manifest.json") manifest_path = os.path.join(build_dir, "manifest.json") + if os.path.exists(osbuild_manifest) and not os.path.exists(manifest_path): + os.symlink(f"{build_name}.osbuild-manifest.json", manifest_path) + with open(manifest_path, "r", encoding="utf-8") as manifest_fp: manifest_data = json.load(manifest_fp) manifest_id = get_manifest_id(manifest_data) diff --git a/test/scripts/imgtestlib/core.py b/test/scripts/imgtestlib/core.py index 650c51db7f..3b058ce9cd 100644 --- a/test/scripts/imgtestlib/core.py +++ b/test/scripts/imgtestlib/core.py @@ -373,30 +373,44 @@ def get_tag_for(runner): +def build_metadata_filenames(basename: str) -> set[str]: + """Return filenames in a build directory that are not the image artifact.""" + return { + "manifest.json", + "info.json", + "rpmlist.json", + f"{basename}.osbuild-manifest.json", + f"{basename}.buildlog", + } + + def find_image_file(build_path: str) -> str: """ - 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. + Find the image file in an image-builder build output directory. """ - manifest_file = os.path.join(build_path, "manifest.json") - with open(manifest_file, encoding="utf-8") as manifest: - data = json.load(manifest) + basename = os.path.basename(os.path.normpath(build_path)) + prefix = f"{basename}." + skip_names = build_metadata_filenames(basename) + + candidates = [] + for name in os.listdir(build_path): + if name in skip_names or not name.startswith(prefix): + continue - 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))] + path = os.path.join(build_path, name) + if os.path.isfile(path): + candidates.append(path) - if len(export_dirs) != 1: - raise RuntimeError(f"Expected exactly one exported pipeline directory in {build_path}, found: {export_dirs}") + if len(candidates) == 1: + return candidates[0] - files = os.listdir(os.path.join(build_path, export_dirs[0])) - if len(files) != 1: + if not candidates: raise RuntimeError( - f"Expected exactly one file in export directory '{export_dirs[0]}', found: {files}") + f"Expected exactly one image artifact in {build_path}, found none") - return os.path.join(build_path, export_dirs[0], files[0]) + names = [os.path.basename(path) for path in candidates] + raise RuntimeError( + f"Expected exactly one image artifact in {build_path}, found: {names}") def read_manifest(build_path: str) -> Dict: diff --git a/test/scripts/test_imgtestlib.py b/test/scripts/test_imgtestlib.py index 465ce6cfcf..56f6c4f611 100644 --- a/test/scripts/test_imgtestlib.py +++ b/test/scripts/test_imgtestlib.py @@ -1,5 +1,4 @@ import contextlib -import json import os import shutil import subprocess as sp @@ -266,153 +265,78 @@ def test_skopeo_inspect_localstore(arch): assert testlib.core.skopeo_inspect_id(f"{transport}[vfs@{tmpdir}]{image}", arch) == image_ids[arch] +def _make_build_dir(tmpdir: str, build_name: str) -> str: + build_dir = os.path.join(tmpdir, build_name) + os.makedirs(build_dir) + return build_dir + + def test_find_image_file_single_export(): - """Test find_image_file with a single exported pipeline (excluding build).""" + """Test find_image_file finds a single image artifact (e.g. qcow2).""" with tempfile.TemporaryDirectory() as tmpdir: - # Create manifest with build and one export pipeline - manifest = { - "pipelines": [ - {"name": "build"}, - {"name": "qcow2"} - ] - } - manifest_path = os.path.join(tmpdir, "manifest.json") - with open(manifest_path, "w", encoding="utf-8") as f: - json.dump(manifest, f) - - # Create the export directory and file - export_dir = os.path.join(tmpdir, "qcow2") - os.makedirs(export_dir) - image_file = os.path.join(export_dir, "disk.qcow2") + build_name = "fedora_41-x86_64-qcow2-empty" + build_dir = _make_build_dir(tmpdir, build_name) + + image_file = os.path.join(build_dir, f"{build_name}.qcow2") with open(image_file, "w", encoding="utf-8") as f: f.write("fake image") - # Test that it finds the correct file - result = testlib.core.find_image_file(tmpdir) - assert result == image_file + assert testlib.core.find_image_file(build_dir) == image_file def test_find_image_file_multiple_pipelines_one_export(): - """Test find_image_file when manifest has multiple pipelines but only one is exported.""" + """Test find_image_file ignores metadata when only one image artifact exists.""" with tempfile.TemporaryDirectory() as tmpdir: - # Create manifest with build and multiple pipelines, but only one exported - manifest = { - "pipelines": [ - {"name": "build"}, - {"name": "image"}, - {"name": "qcow2"}, - {"name": "archive"} - ] - } - manifest_path = os.path.join(tmpdir, "manifest.json") - with open(manifest_path, "w", encoding="utf-8") as f: - json.dump(manifest, f) - - # Create only the archive directory (the actual export) - export_dir = os.path.join(tmpdir, "archive") - os.makedirs(export_dir) - image_file = os.path.join(export_dir, "image.tar") + build_name = "fedora_41-x86_64-archive-empty" + build_dir = _make_build_dir(tmpdir, build_name) + + image_file = os.path.join(build_dir, f"{build_name}.tar") with open(image_file, "w", encoding="utf-8") as f: f.write("fake archive") - # Test that it finds the correct file from the exported pipeline - result = testlib.core.find_image_file(tmpdir) - assert result == image_file - - -def test_find_image_file_no_export_directory(): - """Test find_image_file raises error when no export directory exists.""" - with tempfile.TemporaryDirectory() as tmpdir: - # Create manifest but no export directories - manifest = { - "pipelines": [ - {"name": "build"}, - {"name": "qcow2"} - ] - } - manifest_path = os.path.join(tmpdir, "manifest.json") - with open(manifest_path, "w", encoding="utf-8") as f: - json.dump(manifest, f) - - # Don't create any export directories - with pytest.raises(RuntimeError, match="Expected exactly one exported pipeline directory"): - testlib.core.find_image_file(tmpdir) - - -def test_find_image_file_multiple_export_directories(): - """Test find_image_file raises error when multiple export directories exist.""" - with tempfile.TemporaryDirectory() as tmpdir: - # Create manifest with multiple pipelines - manifest = { - "pipelines": [ - {"name": "build"}, - {"name": "qcow2"}, - {"name": "vmdk"} - ] - } - manifest_path = os.path.join(tmpdir, "manifest.json") - with open(manifest_path, "w", encoding="utf-8") as f: - json.dump(manifest, f) - - # Create multiple export directories - for pipeline in ["qcow2", "vmdk"]: - export_dir = os.path.join(tmpdir, pipeline) - os.makedirs(export_dir) - with open(os.path.join(export_dir, f"disk.{pipeline}"), "w", encoding="utf-8") as f: - f.write("fake image") + for name in ( + "manifest.json", + "info.json", + f"{build_name}.osbuild-manifest.json", + f"{build_name}.buildlog", + ): + with open(os.path.join(build_dir, name), "w", encoding="utf-8") as f: + f.write("{}") - # Should raise error about multiple export directories - with pytest.raises(RuntimeError, match="Expected exactly one exported pipeline directory"): - testlib.core.find_image_file(tmpdir) + assert testlib.core.find_image_file(build_dir) == image_file def test_find_image_file_no_files_in_export(): - """Test find_image_file raises error when export directory is empty.""" + """Test find_image_file raises error when only metadata files are present.""" with tempfile.TemporaryDirectory() as tmpdir: - # Create manifest - manifest = { - "pipelines": [ - {"name": "build"}, - {"name": "qcow2"} - ] - } - manifest_path = os.path.join(tmpdir, "manifest.json") - with open(manifest_path, "w", encoding="utf-8") as f: - json.dump(manifest, f) - - # Create export directory but no files - export_dir = os.path.join(tmpdir, "qcow2") - os.makedirs(export_dir) - - # Should raise error about no files - with pytest.raises(RuntimeError, match="Expected exactly one file in export directory"): - testlib.core.find_image_file(tmpdir) + build_name = "fedora_41-x86_64-qcow2-empty" + build_dir = _make_build_dir(tmpdir, build_name) + + for name in ( + "manifest.json", + "info.json", + f"{build_name}.osbuild-manifest.json", + ): + with open(os.path.join(build_dir, name), "w", encoding="utf-8") as f: + f.write("{}") + + # Should raise error about no files in export dir + with pytest.raises(RuntimeError, match="Expected exactly one image artifact"): + testlib.core.find_image_file(build_dir) def test_find_image_file_multiple_files_in_export(): - """Test find_image_file raises error when export directory has multiple files.""" + """Test find_image_file raises error when multiple image artifacts share the build prefix.""" with tempfile.TemporaryDirectory() as tmpdir: - # Create manifest - manifest = { - "pipelines": [ - {"name": "build"}, - {"name": "qcow2"} - ] - } - manifest_path = os.path.join(tmpdir, "manifest.json") - with open(manifest_path, "w", encoding="utf-8") as f: - json.dump(manifest, f) - - # Create export directory with multiple files - export_dir = os.path.join(tmpdir, "qcow2") - os.makedirs(export_dir) - for filename in ["disk1.qcow2", "disk2.qcow2"]: - with open(os.path.join(export_dir, filename), "w", encoding="utf-8") as f: + build_name = "fedora_41-x86_64-qcow2-empty" + build_dir = _make_build_dir(tmpdir, build_name) + + for ext in ("qcow2", "raw"): + with open(os.path.join(build_dir, f"{build_name}.{ext}"), "w", encoding="utf-8") as f: f.write("fake image") - # Should raise error about multiple files - with pytest.raises(RuntimeError, match="Expected exactly one file in export directory"): - testlib.core.find_image_file(tmpdir) + with pytest.raises(RuntimeError, match="Expected exactly one image artifact"): + testlib.core.find_image_file(build_dir) def test_get_free_port(): From 7e657024672c4f5b44e367ff20bf5a21ae7f1b01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anna=20V=C3=ADtov=C3=A1?= Date: Fri, 3 Jul 2026 15:44:52 +0200 Subject: [PATCH 3/5] ci: disable ppc64le cross-arch integration test The test keep failing due to missing fuse. Disabling the test since this is experimental. --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b06589f4d2..0f4fd240e8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -292,7 +292,7 @@ jobs: strategy: matrix: arch: - - ppc64le + # ppc64le disabled - s390x fail-fast: false # if one fails, keep the other(s) running name: "Cross-arch qemu based image build/boot smoke test" From 70c390696bf12ac6073b728c78cf916f0c977f91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anna=20V=C3=ADtov=C3=A1?= Date: Tue, 7 Jul 2026 10:33:46 +0200 Subject: [PATCH 4/5] Schutzfile: bump the rngseed --- Schutzfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Schutzfile b/Schutzfile index 246f1cde28..c48fed450d 100644 --- a/Schutzfile +++ b/Schutzfile @@ -1,6 +1,6 @@ { "common": { - "rngseed": 2026063000, + "rngseed": 2026070700, "dependencies": { "bootc-image-builder": { "ref": "quay.io/centos-bootc/bootc-image-builder@sha256:9893e7209e5f449b86ababfd2ee02a58cca2e5990f77b06c3539227531fc8120" From 9c7cbe37ff6120504aa790c16169784f5ccc02c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anna=20V=C3=ADtov=C3=A1?= Date: Wed, 8 Jul 2026 11:02:07 +0200 Subject: [PATCH 5/5] image-builder: check for missing openstack options For uploaders for both AWS and Azure, we check for missing arguments, and fail early when uploading, and skip upload during build when the flag is not provided. For openstack, we do not do that. As a result, upload to openstack fails. This commit adds a check for --openstack-image before creating an openstack uploader and fails with ErrUploadConfigNotProvided if not provided. --- cmd/image-builder/upload.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmd/image-builder/upload.go b/cmd/image-builder/upload.go index a61fc7d8a6..da3ff99328 100644 --- a/cmd/image-builder/upload.go +++ b/cmd/image-builder/upload.go @@ -198,6 +198,9 @@ func uploaderForCmdOpenstack(cmd *cobra.Command, targetArchStr string, bootMode if err != nil { return nil, err } + if image == "" { + return nil, fmt.Errorf("%w: %q", ErrUploadConfigNotProvided, []string{"--openstack-image"}) + } diskFormat, err := cmd.Flags().GetString("openstack-disk-format") if err != nil { return nil, err