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" 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" 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/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, } 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 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():