From f182b8e6e3ab3ef5946e0b53519c1374cade11fc Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Fri, 12 Jun 2026 10:36:49 -0600 Subject: [PATCH 1/2] Merge soci and oras plugins into imagetools plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SOCI and ORAS plugins are almost exclusively used together in CI (the `bakery ci publish` orchestration composes oras index-create → soci-convert → oras index-copy → verify), so consolidate them into a single `imagetools` plugin and move the publish orchestration logic out of the CLI layer and into the plugin. - New `imagetools` plugin (`ImageToolsPlugin`): `execute`/`results` drive SOCI conversion, `merge_execute`/`merge_results` drive ORAS merge, and `publish` holds the multi-phase orchestration migrated from `cli/ci.py`. - CLI: canonical `bakery imagetools merge` / `bakery imagetools soci-convert`, with `bakery oras merge` / `bakery soci convert` preserved as hidden back-compat aliases. `bakery ci publish` / `bakery ci merge` are unchanged thin wrappers delegating to `ImageToolsPlugin.publish`. - pyproject: replace the `oras` + `soci` entry points with `imagetools`. - SociOptions keeps its `tool: soci` literal, so existing bakery.yaml files parse unchanged. - Consolidate tests under test/plugins/builtin/imagetools/ and update import paths, patch targets, and the ci publish/merge delegation. Co-Authored-By: Claude Opus 4.8 (1M context) --- posit-bakery/posit_bakery/cli/ci.py | 176 +---- .../plugins/builtin/imagetools/__init__.py | 20 + .../plugins/builtin/imagetools/imagetools.py | 634 ++++++++++++++++++ .../builtin/{soci => imagetools}/options.py | 0 .../builtin/{oras => imagetools}/oras.py | 0 .../builtin/{soci => imagetools}/soci.py | 4 +- .../plugins/builtin/oras/__init__.py | 169 ----- .../plugins/builtin/soci/__init__.py | 266 -------- posit-bakery/pyproject.toml | 3 +- posit-bakery/test/cli/test_ci.py | 10 +- posit-bakery/test/cli/test_ci_publish.py | 86 ++- posit-bakery/test/cli/test_dev_spec.py | 58 +- .../test/cli/test_dev_stream_deprecated.py | 24 +- .../builtin/{oras => imagetools}/__init__.py | 0 .../plugins/builtin/imagetools/test_cli.py | 65 ++ .../{soci => imagetools}/test_command_base.py | 2 +- .../{soci => imagetools}/test_convert.py | 2 +- .../builtin/imagetools/test_discovery.py | 23 + .../{soci => imagetools}/test_options.py | 2 +- .../builtin/{oras => imagetools}/test_oras.py | 6 +- .../{oras => imagetools}/test_oras_plugin.py | 67 +- .../test_plugin_execute.py | 64 +- .../test_plugin_results.py | 12 +- .../{soci => imagetools}/test_workflow.py | 10 +- .../test/plugins/builtin/soci/__init__.py | 0 .../test/plugins/builtin/soci/test_cli.py | 41 -- .../plugins/builtin/soci/test_discovery.py | 15 - .../builtin/wizcli/testdata/scan_result.json | 2 +- 28 files changed, 940 insertions(+), 821 deletions(-) create mode 100644 posit-bakery/posit_bakery/plugins/builtin/imagetools/__init__.py create mode 100644 posit-bakery/posit_bakery/plugins/builtin/imagetools/imagetools.py rename posit-bakery/posit_bakery/plugins/builtin/{soci => imagetools}/options.py (100%) rename posit-bakery/posit_bakery/plugins/builtin/{oras => imagetools}/oras.py (100%) rename posit-bakery/posit_bakery/plugins/builtin/{soci => imagetools}/soci.py (98%) delete mode 100644 posit-bakery/posit_bakery/plugins/builtin/oras/__init__.py delete mode 100644 posit-bakery/posit_bakery/plugins/builtin/soci/__init__.py rename posit-bakery/test/plugins/builtin/{oras => imagetools}/__init__.py (100%) create mode 100644 posit-bakery/test/plugins/builtin/imagetools/test_cli.py rename posit-bakery/test/plugins/builtin/{soci => imagetools}/test_command_base.py (96%) rename posit-bakery/test/plugins/builtin/{soci => imagetools}/test_convert.py (96%) create mode 100644 posit-bakery/test/plugins/builtin/imagetools/test_discovery.py rename posit-bakery/test/plugins/builtin/{soci => imagetools}/test_options.py (97%) rename posit-bakery/test/plugins/builtin/{oras => imagetools}/test_oras.py (99%) rename posit-bakery/test/plugins/builtin/{oras => imagetools}/test_oras_plugin.py (73%) rename posit-bakery/test/plugins/builtin/{soci => imagetools}/test_plugin_execute.py (77%) rename posit-bakery/test/plugins/builtin/{soci => imagetools}/test_plugin_results.py (78%) rename posit-bakery/test/plugins/builtin/{soci => imagetools}/test_workflow.py (94%) delete mode 100644 posit-bakery/test/plugins/builtin/soci/__init__.py delete mode 100644 posit-bakery/test/plugins/builtin/soci/test_cli.py delete mode 100644 posit-bakery/test/plugins/builtin/soci/test_discovery.py diff --git a/posit-bakery/posit_bakery/cli/ci.py b/posit-bakery/posit_bakery/cli/ci.py index e8183fa5e..f29876996 100644 --- a/posit-bakery/posit_bakery/cli/ci.py +++ b/posit-bakery/posit_bakery/cli/ci.py @@ -1,8 +1,6 @@ -import glob import json import logging import re -import python_on_whales from enum import Enum from pathlib import Path from typing import Annotated, Optional @@ -316,180 +314,28 @@ def publish( Temporary indexes are left in place and cleaned up out-of-band by the clean.yml workflow (bakery clean temp-registry) rather than deleted here. + The orchestration itself lives in the ``imagetools`` plugin + (:meth:`ImageToolsPlugin.publish`); this command is a thin wrapper. + Replaces `bakery ci merge`; the latter is preserved as a thin alias. """ - # Imports kept local to mirror existing patterns and to avoid bloating - # module load time when this command isn't invoked. - from posit_bakery.error import BakeryToolRuntimeError - from posit_bakery.plugins.builtin.oras.oras import ( - OrasIndexCopyWorkflow, - OrasIndexCreateWorkflow, - OrasIndexVerifyWorkflow, - OrasWaitForSourcesWorkflow, - find_oras_bin, - ) + # Imported locally to avoid bloating module load time when this command + # isn't invoked. from posit_bakery.plugins.registry import get_plugin if dev_stream is not None: log.warning("--dev-stream is deprecated, use --dev-channel instead.") if dev_channel is None: dev_channel = dev_stream - settings = BakerySettings( - filter=BakeryConfigFilter(image_name=image_name), - dev_versions=DevVersionInclusionEnum.INCLUDE, - dev_channel=dev_channel, - dev_spec=dev_spec, # type: ignore[arg-type] # typer requires str annotation; parse_dev_spec callback delivers DevBuildSpec at runtime - matrix_versions=MatrixVersionInclusionEnum.INCLUDE, - clean_temporary=False, + get_plugin("imagetools").publish( + metadata_file=metadata_file, + context=context, + image_name=image_name, temp_registry=temp_registry, - ) - config: BakeryConfig = BakeryConfig.from_context(context, settings) - - resolved_files: list[Path] = [] - for f in metadata_file: - s = str(f) - if "*" in s or "?" in s or "[" in s: - resolved_files.extend(sorted(Path(x).absolute() for x in glob.glob(s))) - else: - resolved_files.append(f.absolute()) - metadata_file = resolved_files - - log.info(f"Reading targets from {', '.join(f.name for f in metadata_file)}") - - files_ok = True - loaded_targets: list[str] = [] - for f in metadata_file: - try: - loaded_targets.extend(config.load_build_metadata_from_file(f)) - except Exception as e: - log.error(f"Failed to load metadata from file '{f}': {e}") - files_ok = False - if not files_ok: - raise typer.Exit(code=1) - - loaded_targets = list(set(loaded_targets)) # Deduplicate targets in case of overlap across files - log.info(f"Found {len(loaded_targets)} targets") - log.debug(", ".join(loaded_targets)) - - oras_bin = find_oras_bin(config.base_path) - - # Act only on targets that were actually present in the provided metadata - # files, not every target defined in the config. Publishing a single set of - # files (e.g. one version / dev stream) otherwise drags in every other - # version and variant, which each phase then has to re-skip individually. - # The UIDs in loaded_targets all originate from config.targets, so the - # lookups always resolve. - targets = sorted( - (t for uid in loaded_targets if (t := config.get_image_target_by_uid(uid)) is not None), - key=lambda t: t.push_sort_key, - ) - - # Pre-flight: wait for every per-platform source digest to be readable - # before we touch them. Those manifests are pushed by digest from separate - # build runners, and registries with read-after-write (eventual - # consistency) behaviour — notably GHCR — can briefly 404 them. Polling - # here turns propagation lag into condition-based waiting and logs exactly - # which digest lagged, rather than failing a downstream phase opaquely. - all_sources = sorted({s for t in targets for s in t.get_merge_sources()}) - if all_sources: - log.info(f"Waiting for {len(all_sources)} source digest(s) to be readable before publishing.") - try: - wait = OrasWaitForSourcesWorkflow( - oras_bin=oras_bin, - sources=all_sources, - ).run(dry_run=dry_run) - except BakeryToolRuntimeError as e: - # A non-transient registry error (auth, bad reference, ...) while - # probing sources is fatal and won't self-heal — surface it cleanly - # rather than letting it escape as an unhandled traceback. - log.error(f"Failed while waiting for source digests: {e.dump_stderr() or e}") - raise typer.Exit(code=1) - if not wait.success: - log.error(f"Source digests not available: {wait.error}") - raise typer.Exit(code=1) - if wait.ready: - log.info(f"All {len(wait.ready)} source digest(s) readable after {wait.waited_seconds:.0f}s.") - - # Phase 1: index create. Failures abort. - temp_refs: dict[str, str] = {} - for t in targets: - if not t.get_merge_sources(): - log.debug(f"Skipping target '{t}' (no merge sources).") - continue - if not t.settings.temp_registry: - log.error(f"Cannot publish '{t}': temp_registry not configured.") - raise typer.Exit(code=1) - res = OrasIndexCreateWorkflow( - oras_bin=oras_bin, - image_target=t, - annotations=t.labels, - ).run(dry_run=dry_run) - if not res.success: - log.error(f"index-create failed for '{t}': {res.error}") - raise typer.Exit(code=1) - temp_refs[t.uid] = res.temp_ref - - # Phase 2: SOCI convert. Driven by per-target config; targets whose - # resolved SOCI options have enabled=False are skipped by the plugin. - soci = get_plugin("soci") - soci_results = soci.execute( - config.base_path, - targets, - source_refs=temp_refs, dry_run=dry_run, + dev_channel=dev_channel, + dev_spec=dev_spec, ) - soci_failed = False - for r in soci_results: - artifacts = r.artifacts or {} - if artifacts.get("skipped"): - continue - wf = artifacts.get("workflow_result") - if r.exit_code != 0: - soci_failed = True - continue - if wf and getattr(wf, "destination_ref", None): - temp_refs[r.target.uid] = wf.destination_ref - if soci_failed: - soci.results(soci_results) # raises typer.Exit(1) - - # Phase 3: index copy. - copy_failed = False - copied_targets: list = [] - for t in targets: - if t.uid not in temp_refs: - continue - copy = OrasIndexCopyWorkflow( - oras_bin=oras_bin, - image_target=t, - ).run(source=temp_refs[t.uid], dry_run=dry_run) - if not copy.success: - log.error(f"index-copy failed for '{t}': {copy.error}") - copy_failed = True - else: - copied_targets.append(t) - - # Phase 4: verify each final destination tag resolves. This replaces the - # `docker buildx imagetools inspect` check the old `bakery ci merge` ran; - # ORAS is faster and more reliable for the existence check. - verify_failed = False - if not dry_run: - for t in copied_targets: - verify = OrasIndexVerifyWorkflow( - oras_bin=oras_bin, - image_target=t, - ).run(dry_run=dry_run) - if not verify.success: - log.error(f"verification failed for '{t}': {verify.error}") - verify_failed = True - else: - log.info(f"Verified '{t}' -> {', '.join(verify.verified)}") - - # The temporary indexes (and any SOCI-converted variants) are intentionally - # left in place; they are cleaned up out-of-band by the clean.yml workflow - # (bakery clean temp-registry) rather than deleted here. - - if copy_failed or verify_failed: - raise typer.Exit(code=1) @app.command() diff --git a/posit-bakery/posit_bakery/plugins/builtin/imagetools/__init__.py b/posit-bakery/posit_bakery/plugins/builtin/imagetools/__init__.py new file mode 100644 index 000000000..ad3d5df8a --- /dev/null +++ b/posit-bakery/posit_bakery/plugins/builtin/imagetools/__init__.py @@ -0,0 +1,20 @@ +"""imagetools plugin: merge (ORAS) and SOCI-convert multi-platform images. + +This package merges the former standalone ``oras`` and ``soci`` plugins into a +single plugin, since they are almost exclusively used together in CI (see the +``bakery ci publish`` orchestration). +""" + +from posit_bakery.plugins.builtin.imagetools.imagetools import ( + ImageToolsPlugin, + get_soci_options_for_target, +) +from posit_bakery.plugins.builtin.imagetools.oras import find_oras_bin +from posit_bakery.plugins.builtin.imagetools.soci import find_soci_bin + +__all__ = [ + "ImageToolsPlugin", + "get_soci_options_for_target", + "find_oras_bin", + "find_soci_bin", +] diff --git a/posit-bakery/posit_bakery/plugins/builtin/imagetools/imagetools.py b/posit-bakery/posit_bakery/plugins/builtin/imagetools/imagetools.py new file mode 100644 index 000000000..4232d36d8 --- /dev/null +++ b/posit-bakery/posit_bakery/plugins/builtin/imagetools/imagetools.py @@ -0,0 +1,634 @@ +"""imagetools plugin: merge (ORAS) and SOCI-convert multi-platform images. + +Combines the former standalone ``oras`` and ``soci`` plugins. ORAS handles +multi-platform manifest index creation/copy/verify; SOCI converts images into +SOCI-enabled images. The two tools are almost exclusively used together in CI +— the ``bakery ci publish`` orchestration composes ``oras index-create`` → +``soci-convert`` → ``oras index-copy`` → ``oras verify`` — so they live in a +single plugin. + +The protocol :meth:`ImageToolsPlugin.execute` / :meth:`ImageToolsPlugin.results` +pair maps to the config-driven SOCI conversion (the operation the publish +orchestration delegates to per target). ORAS merge is exposed via the dedicated +:meth:`merge_execute` / :meth:`merge_results` pair, and the full multi-phase +publish orchestration via :meth:`publish`. +""" + +import logging +from pathlib import Path +from typing import Any + +import typer + +from posit_bakery.error import BakeryToolNotFoundError +from posit_bakery.image.image_target import ImageTarget +from posit_bakery.plugins.builtin.imagetools.options import SociOptions +from posit_bakery.plugins.builtin.imagetools.oras import OrasMergeWorkflow, find_oras_bin +from posit_bakery.plugins.builtin.imagetools.soci import SociConvertWorkflow, find_soci_bin +from posit_bakery.plugins.protocol import BakeryToolPlugin, ToolCallResult + +log = logging.getLogger(__name__) + + +def get_soci_options_for_target(target: ImageTarget) -> SociOptions: + """Resolve effective SociOptions for the given target, merging + variant-level options over image-version-parent-level options where + both exist. Returns a defaulted SociOptions (enabled=False) if no + soci configuration is present. + """ + # Local helper to keep the resolution logic in one place. + image_opts = None + variant_opts = None + parent = getattr(target.image_version, "parent", None) + for opt in getattr(parent, "options", []) or []: + if isinstance(opt, SociOptions): + image_opts = opt + break + variant = getattr(target, "image_variant", None) + for opt in getattr(variant, "options", []) or []: + if isinstance(opt, SociOptions): + variant_opts = opt + break + if variant_opts and image_opts: + return variant_opts.update(image_opts) + return variant_opts or image_opts or SociOptions() + + +class ImageToolsPlugin(BakeryToolPlugin): + name: str = "imagetools" + description: str = "Merge and SOCI-convert multi-platform images (ORAS + SOCI)" + tool_options_class = SociOptions + + def register_cli(self, app: typer.Typer) -> None: + """Register the imagetools CLI commands. + + Canonical group is ``bakery imagetools`` with ``merge`` and + ``soci-convert`` subcommands. The former ``bakery oras`` and + ``bakery soci`` groups are preserved as hidden back-compat aliases. + """ + import glob as glob_module + from typing import Annotated, Optional + + from posit_bakery.cli.common import with_verbosity_flags + from posit_bakery.config.config import BakeryConfig, BakerySettings + from posit_bakery.const import DevVersionInclusionEnum, MatrixVersionInclusionEnum + from posit_bakery.util import auto_path + + plugin = self + + def _resolve_metadata_files(metadata_file: list[Path]) -> list[Path]: + """Expand any glob patterns in the metadata file arguments.""" + resolved_files: list[Path] = [] + for f in metadata_file: + s = str(f) + if "*" in s or "?" in s or "[" in s: + resolved_files.extend(sorted(Path(x).absolute() for x in glob_module.glob(s))) + else: + resolved_files.append(f.absolute()) + return resolved_files + + @with_verbosity_flags + def merge( + metadata_file: Annotated[ + list[Path], typer.Argument(help="Path to input build metadata JSON file(s) to merge.") + ], + context: Annotated[ + Path, + typer.Option(help="The root path to use. Defaults to the current working directory where invoked."), + ] = auto_path(), + temp_registry: Annotated[ + Optional[str], + typer.Option( + help="Temporary registry to use for multiplatform split/merge builds.", + rich_help_panel="Build Configuration & Outputs", + ), + ] = None, + dry_run: Annotated[ + bool, typer.Option(help="If set, the merged images will not be pushed to the registry.") + ] = False, + ): + """Merge multi-platform images from build metadata files using ORAS. + + \b + Takes one or more build metadata JSON files (produced by `bakery build --strategy build`) + and merges platform-specific images into multi-platform manifest indexes. + """ + settings = BakerySettings( + dev_versions=DevVersionInclusionEnum.INCLUDE, + matrix_versions=MatrixVersionInclusionEnum.INCLUDE, + clean_temporary=False, + temp_registry=temp_registry, + ) + config: BakeryConfig = BakeryConfig.from_context(context, settings) + + metadata_file = _resolve_metadata_files(metadata_file) + log.info(f"Reading targets from {', '.join(f.name for f in metadata_file)}") + + files_ok = True + loaded_targets: list[str] = [] + for file in metadata_file: + try: + loaded_targets.extend(config.load_build_metadata_from_file(file)) + except Exception as e: + log.error(f"Failed to load metadata from file '{file}'") + log.error(str(e)) + files_ok = False + loaded_targets = list(set(loaded_targets)) + + if not files_ok: + log.error("One or more metadata files are invalid, aborting merge.") + raise typer.Exit(code=1) + + log.info(f"Found {len(loaded_targets)} targets") + log.debug(", ".join(loaded_targets)) + + results = plugin.merge_execute(config.base_path, config.targets, dry_run=dry_run) + plugin.merge_results(results) + + @with_verbosity_flags + def soci_convert( + metadata_file: Annotated[list[Path], typer.Argument(help="Path to input build metadata JSON file(s).")], + context: Annotated[ + Path, + typer.Option(help="The root path to use. Defaults to the current working directory."), + ] = auto_path(), + temp_registry: Annotated[ + Optional[str], + typer.Option(help="Temporary registry to use for split/merge builds."), + ] = None, + dry_run: Annotated[ + bool, + typer.Option(help="Log commands without executing them."), + ] = False, + ) -> None: + """Convert images referenced by build-metadata JSON files into SOCI-enabled images. + + \b + Conversion runs in standalone (no-containerd) mode via oras. Targets + without `tool: soci, enabled: true` in bakery.yaml are skipped. + """ + settings = BakerySettings( + dev_versions=DevVersionInclusionEnum.INCLUDE, + matrix_versions=MatrixVersionInclusionEnum.INCLUDE, + clean_temporary=False, + temp_registry=temp_registry, + ) + config: BakeryConfig = BakeryConfig.from_context(context, settings) + + metadata_file = _resolve_metadata_files(metadata_file) + log.info(f"Reading targets from {', '.join(f.name for f in metadata_file)}") + files_ok = True + for f in metadata_file: + try: + config.load_build_metadata_from_file(f) + except Exception as e: + log.error(f"Failed to load metadata from file '{f}': {e}") + files_ok = False + if not files_ok: + raise typer.Exit(code=1) + + # Build source_refs from each target's most recent build metadata. + source_refs: dict[str, str] = {} + for t in config.targets: + if t.build_metadata: + latest = max(t.build_metadata, key=lambda m: m.created_at) + source_refs[t.uid] = latest.image_ref + + results = plugin.execute( + config.base_path, + config.targets, + source_refs=source_refs, + dry_run=dry_run, + ) + plugin.results(results) + + # Canonical group: `bakery imagetools merge` / `bakery imagetools soci-convert`. + imagetools_app = typer.Typer(no_args_is_help=True) + imagetools_app.command(name="merge")(merge) + imagetools_app.command(name="soci-convert")(soci_convert) + app.add_typer(imagetools_app, name="imagetools", help=self.description) + + # Hidden back-compat aliases for the former standalone plugin groups. + oras_app = typer.Typer(no_args_is_help=True) + oras_app.command(name="merge")(merge) + app.add_typer(oras_app, name="oras", hidden=True, help="Deprecated alias for `bakery imagetools merge`.") + + soci_app = typer.Typer(no_args_is_help=True) + soci_app.command(name="convert")(soci_convert) + app.add_typer(soci_app, name="soci", hidden=True, help="Deprecated alias for `bakery imagetools soci-convert`.") + + # ------------------------------------------------------------------ + # SOCI conversion (protocol execute/results) + # ------------------------------------------------------------------ + def execute( + self, + base_path: Path, + targets: list[ImageTarget], + *, + source_refs: dict[str, str] | None = None, + dry_run: bool = False, + **kwargs: Any, + ) -> list[ToolCallResult]: + """Run SOCI convert workflows against eligible targets. + + ``source_refs`` maps ``target.uid`` -> the temp-registry ref to + convert (typically produced by the oras index-create phase). The refs + are registry refs; the OCI image layouts that ``soci convert + --standalone`` reads and writes are internal scratch that the workflow + materializes and pushes via oras. + + Conversion always runs in standalone (containerd-free, oras-based) + mode. + + Targets whose resolved SociOptions has ``enabled=False`` are + skipped with a ``skipped=True`` artifact entry. + """ + source_refs = source_refs or {} + + eligible: list[tuple[ImageTarget, SociOptions, str]] = [] + results: list[ToolCallResult] = [] + for target in targets: + opts = get_soci_options_for_target(target) + if not opts.enabled: + results.append( + ToolCallResult( + exit_code=0, + tool_name="soci", + target=target, + stdout="", + stderr="", + artifacts={"skipped": True, "reason": "soci.enabled is false"}, + ) + ) + continue + ref = source_refs.get(target.uid) + if not ref: + # SOCI is enabled for this target but it is not part of the + # current run — no source ref was produced for it (e.g. it has + # no merge sources / build metadata in the provided metadata, + # as happens for the other versions and dev streams when + # publishing a single set of files). There is nothing to + # convert, so skip it like a disabled target rather than + # reporting a spurious conversion failure. + results.append( + ToolCallResult( + exit_code=0, + tool_name="soci", + target=target, + stdout="", + stderr="", + artifacts={"skipped": True, "reason": "no source ref provided for this run"}, + ) + ) + continue + eligible.append((target, opts, ref)) + + if not eligible: + log.info( + "imagetools soci convert: no targets have SOCI enabled (or no source refs " + "were provided for the enabled ones); skipping conversion." + ) + return results + + # Standalone conversion bridges the registry with oras and never + # touches containerd; both soci and oras must be present. + def resolve_bin(finder: Any, fallback: str) -> str: + # A tool only has to resolve when it will actually be executed: a + # dry run executes nothing, so fall back to the bare name purely + # for any logged command. When the tool resolves we keep its real + # path so output stays accurate. A tool a real run needs but cannot + # find is still a hard error. + try: + return finder(base_path) + except BakeryToolNotFoundError: + if dry_run: + return fallback + raise + + soci_bin = resolve_bin(find_soci_bin, "soci") + oras_bin = resolve_bin(find_oras_bin, "oras") + + for target, opts, ref in eligible: + workflow = SociConvertWorkflow( + soci_bin=soci_bin, + oras_bin=oras_bin, + image_target=target, + options=opts, + source_ref=ref, + ) + wf_result = workflow.run(dry_run=dry_run) + results.append( + ToolCallResult( + exit_code=0 if wf_result.success else 1, + tool_name="soci", + target=target, + stdout="", + stderr=wf_result.error or "", + artifacts={"workflow_result": wf_result}, + ) + ) + + return results + + def results(self, results: list[ToolCallResult]) -> None: + """Display SOCI conversion results and raise typer.Exit(1) on failure.""" + from posit_bakery.log import stderr_console + + has_errors = False + for r in results: + artifacts = r.artifacts or {} + if artifacts.get("skipped"): + log.info(f"SOCI skipped for {r.target}: {artifacts.get('reason')}") + continue + wf = artifacts.get("workflow_result") + if r.exit_code != 0: + has_errors = True + stderr_console.print( + f"SOCI convert failed for '{r.target}': {r.stderr}", + style="error", + ) + elif wf: + log.info(f"SOCI converted '{r.target}' -> {wf.destination_ref}") + + if has_errors: + stderr_console.print("❌ SOCI conversion(s) failed", style="error") + raise typer.Exit(code=1) + + stderr_console.print("✅ SOCI conversion(s) completed", style="success") + + # ------------------------------------------------------------------ + # ORAS merge + # ------------------------------------------------------------------ + def merge_execute( + self, + base_path: Path, + targets: list[ImageTarget], + *, + dry_run: bool = False, + **kwargs, + ) -> list[ToolCallResult]: + """Execute ORAS merge workflow against the given image targets.""" + # Sort so latest pushes last; Docker Hub displays tags by push-time order. + targets = sorted(targets, key=lambda t: t.push_sort_key) + log.info("ORAS merge order: %s", ", ".join(str(t) for t in targets)) + results = [] + + for target in targets: + # Skip targets without merge sources + if not target.get_merge_sources(): + log.debug(f"Skipping target '{target}' — no merge sources.") + continue + + # Validate temp_registry + if not target.settings.temp_registry: + results.append( + ToolCallResult( + exit_code=1, + tool_name="oras", + target=target, + stdout="", + stderr=f"Cannot merge '{target}': temp_registry must be configured in settings.", + ) + ) + continue + + log.info(f"Merging sources for image UID '{target.uid}'") + workflow = OrasMergeWorkflow.from_image_target(target) + workflow_result = workflow.run(dry_run=dry_run) + + results.append( + ToolCallResult( + exit_code=0 if workflow_result.success else 1, + tool_name="oras", + target=target, + stdout="", + stderr=workflow_result.error or "", + artifacts={"workflow_result": workflow_result}, + ) + ) + + return results + + def merge_results(self, results: list[ToolCallResult]) -> None: + """Display ORAS merge results and exit non-zero on failures.""" + from posit_bakery.log import stderr_console + + has_errors = False + for result in results: + workflow_result = result.artifacts.get("workflow_result") if result.artifacts else None + if result.exit_code != 0: + has_errors = True + stderr_console.print( + f"Error merging '{result.target}': {result.stderr}", + style="error", + ) + elif workflow_result: + log.info(f"Merged '{result.target}' -> {', '.join(workflow_result.destinations)}") + + if has_errors: + stderr_console.print("❌ ORAS merge(s) failed", style="error") + raise typer.Exit(code=1) + + stderr_console.print("✅ ORAS merge completed", style="success") + + # ------------------------------------------------------------------ + # Publish orchestration (migrated from `bakery ci publish`) + # ------------------------------------------------------------------ + def publish( + self, + metadata_file: list[Path], + context: Path, + *, + image_name: str | None = None, + temp_registry: str | None = None, + dry_run: bool = False, + dev_channel: Any = None, + dev_spec: Any = None, + ) -> None: + """Publish multi-platform images by composing oras index-create → + soci-convert → oras index-copy → verify. + + Which targets are converted is driven by configuration: each target is + converted only when its resolved SOCI options have ``enabled: true`` + (set via the ``soci`` tool options on an image or variant). Targets + without SOCI enabled pass through the convert phase untouched. + Conversion runs in standalone (no containerd) mode via oras. + + Temporary indexes are left in place and cleaned up out-of-band by the + clean.yml workflow (``bakery clean temp-registry``) rather than deleted + here. + + Raises ``typer.Exit(1)`` on any phase failure. + """ + import glob as glob_module + + from posit_bakery.config import BakeryConfig + from posit_bakery.config.config import BakeryConfigFilter, BakerySettings + from posit_bakery.const import DevVersionInclusionEnum, MatrixVersionInclusionEnum + from posit_bakery.error import BakeryToolRuntimeError + + # Imported here to mirror existing patterns and keep the test seams + # (which patch these on the source module) working at call time. + from posit_bakery.plugins.builtin.imagetools.oras import ( + OrasIndexCopyWorkflow, + OrasIndexCreateWorkflow, + OrasIndexVerifyWorkflow, + OrasWaitForSourcesWorkflow, + find_oras_bin, + ) + + settings = BakerySettings( + filter=BakeryConfigFilter(image_name=image_name), + dev_versions=DevVersionInclusionEnum.INCLUDE, + dev_channel=dev_channel, + dev_spec=dev_spec, # type: ignore[arg-type] # typer requires str annotation; parse_dev_spec callback delivers DevBuildSpec at runtime + matrix_versions=MatrixVersionInclusionEnum.INCLUDE, + clean_temporary=False, + temp_registry=temp_registry, + ) + config: BakeryConfig = BakeryConfig.from_context(context, settings) + + resolved_files: list[Path] = [] + for f in metadata_file: + s = str(f) + if "*" in s or "?" in s or "[" in s: + resolved_files.extend(sorted(Path(x).absolute() for x in glob_module.glob(s))) + else: + resolved_files.append(f.absolute()) + metadata_file = resolved_files + + log.info(f"Reading targets from {', '.join(f.name for f in metadata_file)}") + + files_ok = True + loaded_targets: list[str] = [] + for f in metadata_file: + try: + loaded_targets.extend(config.load_build_metadata_from_file(f)) + except Exception as e: + log.error(f"Failed to load metadata from file '{f}': {e}") + files_ok = False + if not files_ok: + raise typer.Exit(code=1) + + loaded_targets = list(set(loaded_targets)) # Deduplicate targets in case of overlap across files + log.info(f"Found {len(loaded_targets)} targets") + log.debug(", ".join(loaded_targets)) + + oras_bin = find_oras_bin(config.base_path) + + # Act only on targets that were actually present in the provided metadata + # files, not every target defined in the config. Publishing a single set of + # files (e.g. one version / dev stream) otherwise drags in every other + # version and variant, which each phase then has to re-skip individually. + # The UIDs in loaded_targets all originate from config.targets, so the + # lookups always resolve. + targets = sorted( + (t for uid in loaded_targets if (t := config.get_image_target_by_uid(uid)) is not None), + key=lambda t: t.push_sort_key, + ) + + # Pre-flight: wait for every per-platform source digest to be readable + # before we touch them. Those manifests are pushed by digest from separate + # build runners, and registries with read-after-write (eventual + # consistency) behaviour — notably GHCR — can briefly 404 them. Polling + # here turns propagation lag into condition-based waiting and logs exactly + # which digest lagged, rather than failing a downstream phase opaquely. + all_sources = sorted({s for t in targets for s in t.get_merge_sources()}) + if all_sources: + log.info(f"Waiting for {len(all_sources)} source digest(s) to be readable before publishing.") + try: + wait = OrasWaitForSourcesWorkflow( + oras_bin=oras_bin, + sources=all_sources, + ).run(dry_run=dry_run) + except BakeryToolRuntimeError as e: + # A non-transient registry error (auth, bad reference, ...) while + # probing sources is fatal and won't self-heal — surface it cleanly + # rather than letting it escape as an unhandled traceback. + log.error(f"Failed while waiting for source digests: {e.dump_stderr() or e}") + raise typer.Exit(code=1) + if not wait.success: + log.error(f"Source digests not available: {wait.error}") + raise typer.Exit(code=1) + if wait.ready: + log.info(f"All {len(wait.ready)} source digest(s) readable after {wait.waited_seconds:.0f}s.") + + # Phase 1: index create. Failures abort. + temp_refs: dict[str, str] = {} + for t in targets: + if not t.get_merge_sources(): + log.debug(f"Skipping target '{t}' (no merge sources).") + continue + if not t.settings.temp_registry: + log.error(f"Cannot publish '{t}': temp_registry not configured.") + raise typer.Exit(code=1) + res = OrasIndexCreateWorkflow( + oras_bin=oras_bin, + image_target=t, + annotations=t.labels, + ).run(dry_run=dry_run) + if not res.success: + log.error(f"index-create failed for '{t}': {res.error}") + raise typer.Exit(code=1) + temp_refs[t.uid] = res.temp_ref + + # Phase 2: SOCI convert. Driven by per-target config; targets whose + # resolved SOCI options have enabled=False are skipped by execute(). + soci_results = self.execute( + config.base_path, + targets, + source_refs=temp_refs, + dry_run=dry_run, + ) + soci_failed = False + for r in soci_results: + artifacts = r.artifacts or {} + if artifacts.get("skipped"): + continue + wf = artifacts.get("workflow_result") + if r.exit_code != 0: + soci_failed = True + continue + if wf and getattr(wf, "destination_ref", None): + temp_refs[r.target.uid] = wf.destination_ref + if soci_failed: + self.results(soci_results) # raises typer.Exit(1) + + # Phase 3: index copy. + copy_failed = False + copied_targets: list = [] + for t in targets: + if t.uid not in temp_refs: + continue + copy = OrasIndexCopyWorkflow( + oras_bin=oras_bin, + image_target=t, + ).run(source=temp_refs[t.uid], dry_run=dry_run) + if not copy.success: + log.error(f"index-copy failed for '{t}': {copy.error}") + copy_failed = True + else: + copied_targets.append(t) + + # Phase 4: verify each final destination tag resolves. This replaces the + # `docker buildx imagetools inspect` check the old `bakery ci merge` ran; + # ORAS is faster and more reliable for the existence check. + verify_failed = False + if not dry_run: + for t in copied_targets: + verify = OrasIndexVerifyWorkflow( + oras_bin=oras_bin, + image_target=t, + ).run(dry_run=dry_run) + if not verify.success: + log.error(f"verification failed for '{t}': {verify.error}") + verify_failed = True + else: + log.info(f"Verified '{t}' -> {', '.join(verify.verified)}") + + # The temporary indexes (and any SOCI-converted variants) are intentionally + # left in place; they are cleaned up out-of-band by the clean.yml workflow + # (bakery clean temp-registry) rather than deleted here. + + if copy_failed or verify_failed: + raise typer.Exit(code=1) diff --git a/posit-bakery/posit_bakery/plugins/builtin/soci/options.py b/posit-bakery/posit_bakery/plugins/builtin/imagetools/options.py similarity index 100% rename from posit-bakery/posit_bakery/plugins/builtin/soci/options.py rename to posit-bakery/posit_bakery/plugins/builtin/imagetools/options.py diff --git a/posit-bakery/posit_bakery/plugins/builtin/oras/oras.py b/posit-bakery/posit_bakery/plugins/builtin/imagetools/oras.py similarity index 100% rename from posit-bakery/posit_bakery/plugins/builtin/oras/oras.py rename to posit-bakery/posit_bakery/plugins/builtin/imagetools/oras.py diff --git a/posit-bakery/posit_bakery/plugins/builtin/soci/soci.py b/posit-bakery/posit_bakery/plugins/builtin/imagetools/soci.py similarity index 98% rename from posit-bakery/posit_bakery/plugins/builtin/soci/soci.py rename to posit-bakery/posit_bakery/plugins/builtin/imagetools/soci.py index ff2836277..4168ab71b 100644 --- a/posit-bakery/posit_bakery/plugins/builtin/soci/soci.py +++ b/posit-bakery/posit_bakery/plugins/builtin/imagetools/soci.py @@ -13,8 +13,8 @@ from posit_bakery.error import BakeryToolRuntimeError from posit_bakery.image.image_target import ImageTarget -from posit_bakery.plugins.builtin.oras.oras import OrasCopy -from posit_bakery.plugins.builtin.soci.options import SociOptions +from posit_bakery.plugins.builtin.imagetools.oras import OrasCopy +from posit_bakery.plugins.builtin.imagetools.options import SociOptions from posit_bakery.retry import RetryPolicy, retry_on_transient from posit_bakery.util import find_bin diff --git a/posit-bakery/posit_bakery/plugins/builtin/oras/__init__.py b/posit-bakery/posit_bakery/plugins/builtin/oras/__init__.py deleted file mode 100644 index 414338efe..000000000 --- a/posit-bakery/posit_bakery/plugins/builtin/oras/__init__.py +++ /dev/null @@ -1,169 +0,0 @@ -import logging -from pathlib import Path - -import typer - -from posit_bakery.image.image_target import ImageTarget -from posit_bakery.plugins.builtin.oras.oras import OrasMergeWorkflow, find_oras_bin -from posit_bakery.plugins.protocol import BakeryToolPlugin, ToolCallResult - -log = logging.getLogger(__name__) - - -class OrasPlugin(BakeryToolPlugin): - name: str = "oras" - description: str = "Merge multi-platform images using ORAS" - - def register_cli(self, app: typer.Typer) -> None: - """Register the oras CLI commands with the given Typer app.""" - import glob as glob_module - from typing import Annotated, Optional - - from posit_bakery.cli.common import with_verbosity_flags - from posit_bakery.config.config import BakeryConfig, BakerySettings - from posit_bakery.const import DevVersionInclusionEnum, MatrixVersionInclusionEnum - from posit_bakery.util import auto_path - - oras_app = typer.Typer(no_args_is_help=True) - plugin = self - - @oras_app.command() - @with_verbosity_flags - def merge( - metadata_file: Annotated[ - list[Path], typer.Argument(help="Path to input build metadata JSON file(s) to merge.") - ], - context: Annotated[ - Path, - typer.Option(help="The root path to use. Defaults to the current working directory where invoked."), - ] = auto_path(), - temp_registry: Annotated[ - Optional[str], - typer.Option( - help="Temporary registry to use for multiplatform split/merge builds.", - rich_help_panel="Build Configuration & Outputs", - ), - ] = None, - dry_run: Annotated[ - bool, typer.Option(help="If set, the merged images will not be pushed to the registry.") - ] = False, - ): - """Merge multi-platform images from build metadata files using ORAS. - - \b - Takes one or more build metadata JSON files (produced by `bakery build --strategy build`) - and merges platform-specific images into multi-platform manifest indexes. - """ - settings = BakerySettings( - dev_versions=DevVersionInclusionEnum.INCLUDE, - matrix_versions=MatrixVersionInclusionEnum.INCLUDE, - clean_temporary=False, - temp_registry=temp_registry, - ) - config: BakeryConfig = BakeryConfig.from_context(context, settings) - - # Resolve glob patterns in metadata_file arguments - resolved_files: list[Path] = [] - for file in metadata_file: - if "*" in str(file) or "?" in str(file) or "[" in str(file): - resolved_files.extend(sorted(Path(x).absolute() for x in glob_module.glob(str(file)))) - else: - resolved_files.append(file.absolute()) - metadata_file = resolved_files - - log.info(f"Reading targets from {', '.join(f.name for f in metadata_file)}") - - files_ok = True - loaded_targets: list[str] = [] - for file in metadata_file: - try: - loaded_targets.extend(config.load_build_metadata_from_file(file)) - except Exception as e: - log.error(f"Failed to load metadata from file '{file}'") - log.error(str(e)) - files_ok = False - loaded_targets = list(set(loaded_targets)) - - if not files_ok: - log.error("One or more metadata files are invalid, aborting merge.") - raise typer.Exit(code=1) - - log.info(f"Found {len(loaded_targets)} targets") - log.debug(", ".join(loaded_targets)) - - results = plugin.execute(config.base_path, config.targets, dry_run=dry_run) - plugin.results(results) - - app.add_typer(oras_app, name="oras", help="Merge multi-platform images using ORAS") - - def execute( - self, - base_path: Path, - targets: list[ImageTarget], - *, - dry_run: bool = False, - **kwargs, - ) -> list[ToolCallResult]: - """Execute ORAS merge workflow against the given image targets.""" - # Sort so latest pushes last; Docker Hub displays tags by push-time order. - targets = sorted(targets, key=lambda t: t.push_sort_key) - log.info("ORAS merge order: %s", ", ".join(str(t) for t in targets)) - results = [] - - for target in targets: - # Skip targets without merge sources - if not target.get_merge_sources(): - log.debug(f"Skipping target '{target}' — no merge sources.") - continue - - # Validate temp_registry - if not target.settings.temp_registry: - results.append( - ToolCallResult( - exit_code=1, - tool_name="oras", - target=target, - stdout="", - stderr=f"Cannot merge '{target}': temp_registry must be configured in settings.", - ) - ) - continue - - log.info(f"Merging sources for image UID '{target.uid}'") - workflow = OrasMergeWorkflow.from_image_target(target) - workflow_result = workflow.run(dry_run=dry_run) - - results.append( - ToolCallResult( - exit_code=0 if workflow_result.success else 1, - tool_name="oras", - target=target, - stdout="", - stderr=workflow_result.error or "", - artifacts={"workflow_result": workflow_result}, - ) - ) - - return results - - def results(self, results: list[ToolCallResult]) -> None: - """Display ORAS merge results and exit non-zero on failures.""" - from posit_bakery.log import stderr_console - - has_errors = False - for result in results: - workflow_result = result.artifacts.get("workflow_result") if result.artifacts else None - if result.exit_code != 0: - has_errors = True - stderr_console.print( - f"Error merging '{result.target}': {result.stderr}", - style="error", - ) - elif workflow_result: - log.info(f"Merged '{result.target}' -> {', '.join(workflow_result.destinations)}") - - if has_errors: - stderr_console.print("❌ ORAS merge(s) failed", style="error") - raise typer.Exit(code=1) - - stderr_console.print("✅ ORAS merge completed", style="success") diff --git a/posit-bakery/posit_bakery/plugins/builtin/soci/__init__.py b/posit-bakery/posit_bakery/plugins/builtin/soci/__init__.py deleted file mode 100644 index 78aa693d2..000000000 --- a/posit-bakery/posit_bakery/plugins/builtin/soci/__init__.py +++ /dev/null @@ -1,266 +0,0 @@ -"""SOCI plugin: convert built images into SOCI-enabled images.""" - -import logging -from pathlib import Path -from typing import Any - -import typer - -from posit_bakery.error import BakeryToolNotFoundError -from posit_bakery.image.image_target import ImageTarget -from posit_bakery.plugins.builtin.soci.options import SociOptions -from posit_bakery.plugins.builtin.oras.oras import find_oras_bin -from posit_bakery.plugins.builtin.soci.soci import ( - SociConvertWorkflow, - find_soci_bin, -) -from posit_bakery.plugins.protocol import BakeryToolPlugin, ToolCallResult - -log = logging.getLogger(__name__) - - -def get_soci_options_for_target(target: ImageTarget) -> SociOptions: - """Resolve effective SociOptions for the given target, merging - variant-level options over image-version-parent-level options where - both exist. Returns a defaulted SociOptions (enabled=False) if no - soci configuration is present. - """ - # Local helper to keep the resolution logic in one place. - image_opts = None - variant_opts = None - parent = getattr(target.image_version, "parent", None) - for opt in getattr(parent, "options", []) or []: - if isinstance(opt, SociOptions): - image_opts = opt - break - variant = getattr(target, "image_variant", None) - for opt in getattr(variant, "options", []) or []: - if isinstance(opt, SociOptions): - variant_opts = opt - break - if variant_opts and image_opts: - return variant_opts.update(image_opts) - return variant_opts or image_opts or SociOptions() - - -class SociPlugin(BakeryToolPlugin): - name: str = "soci" - description: str = "Convert images to SOCI-enabled images" - tool_options_class = SociOptions - - def register_cli(self, app: typer.Typer) -> None: - """Register the soci CLI commands.""" - import glob as glob_module - from typing import Annotated, Optional - - from posit_bakery.cli.common import with_verbosity_flags - from posit_bakery.config.config import BakeryConfig, BakerySettings - from posit_bakery.const import DevVersionInclusionEnum, MatrixVersionInclusionEnum - from posit_bakery.util import auto_path - - soci_app = typer.Typer(no_args_is_help=True) - plugin = self - - @soci_app.command() - @with_verbosity_flags - def convert( - metadata_file: Annotated[list[Path], typer.Argument(help="Path to input build metadata JSON file(s).")], - context: Annotated[ - Path, - typer.Option(help="The root path to use. Defaults to the current working directory."), - ] = auto_path(), - temp_registry: Annotated[ - Optional[str], - typer.Option(help="Temporary registry to use for split/merge builds."), - ] = None, - dry_run: Annotated[ - bool, - typer.Option(help="Log commands without executing them."), - ] = False, - ) -> None: - """Convert images referenced by build-metadata JSON files into SOCI-enabled images. - - \b - Conversion runs in standalone (no-containerd) mode via oras. Targets - without `tool: soci, enabled: true` in bakery.yaml are skipped. - """ - settings = BakerySettings( - dev_versions=DevVersionInclusionEnum.INCLUDE, - matrix_versions=MatrixVersionInclusionEnum.INCLUDE, - clean_temporary=False, - temp_registry=temp_registry, - ) - config: BakeryConfig = BakeryConfig.from_context(context, settings) - - resolved_files: list[Path] = [] - for f in metadata_file: - s = str(f) - if "*" in s or "?" in s or "[" in s: - resolved_files.extend(sorted(Path(x).absolute() for x in glob_module.glob(s))) - else: - resolved_files.append(f.absolute()) - metadata_file = resolved_files - - log.info(f"Reading targets from {', '.join(f.name for f in metadata_file)}") - files_ok = True - for f in metadata_file: - try: - config.load_build_metadata_from_file(f) - except Exception as e: - log.error(f"Failed to load metadata from file '{f}': {e}") - files_ok = False - if not files_ok: - raise typer.Exit(code=1) - - # Build source_refs from each target's most recent build metadata. - source_refs: dict[str, str] = {} - for t in config.targets: - if t.build_metadata: - latest = max(t.build_metadata, key=lambda m: m.created_at) - source_refs[t.uid] = latest.image_ref - - results = plugin.execute( - config.base_path, - config.targets, - source_refs=source_refs, - dry_run=dry_run, - ) - plugin.results(results) - - app.add_typer(soci_app, name="soci", help=self.description) - - def execute( - self, - base_path: Path, - targets: list[ImageTarget], - *, - source_refs: dict[str, str] | None = None, - dry_run: bool = False, - **kwargs: Any, - ) -> list[ToolCallResult]: - """Run SOCI convert workflows against eligible targets. - - ``source_refs`` maps ``target.uid`` -> the temp-registry ref to - convert (typically produced by the oras index-create phase). The refs - are registry refs; the OCI image layouts that ``soci convert - --standalone`` reads and writes are internal scratch that the workflow - materializes and pushes via oras. - - Conversion always runs in standalone (containerd-free, oras-based) - mode. - - Targets whose resolved SociOptions has ``enabled=False`` are - skipped with a ``skipped=True`` artifact entry. - """ - source_refs = source_refs or {} - - eligible: list[tuple[ImageTarget, SociOptions, str]] = [] - results: list[ToolCallResult] = [] - for target in targets: - opts = get_soci_options_for_target(target) - if not opts.enabled: - results.append( - ToolCallResult( - exit_code=0, - tool_name="soci", - target=target, - stdout="", - stderr="", - artifacts={"skipped": True, "reason": "soci.enabled is false"}, - ) - ) - continue - ref = source_refs.get(target.uid) - if not ref: - # SOCI is enabled for this target but it is not part of the - # current run — no source ref was produced for it (e.g. it has - # no merge sources / build metadata in the provided metadata, - # as happens for the other versions and dev streams when - # publishing a single set of files). There is nothing to - # convert, so skip it like a disabled target rather than - # reporting a spurious conversion failure. - results.append( - ToolCallResult( - exit_code=0, - tool_name="soci", - target=target, - stdout="", - stderr="", - artifacts={"skipped": True, "reason": "no source ref provided for this run"}, - ) - ) - continue - eligible.append((target, opts, ref)) - - if not eligible: - log.info( - "soci.execute: no targets have SOCI enabled (or no source refs " - "were provided for the enabled ones); skipping conversion." - ) - return results - - # Standalone conversion bridges the registry with oras and never - # touches containerd; both soci and oras must be present. - def resolve_bin(finder: Any, fallback: str) -> str: - # A tool only has to resolve when it will actually be executed: a - # dry run executes nothing, so fall back to the bare name purely - # for any logged command. When the tool resolves we keep its real - # path so output stays accurate. A tool a real run needs but cannot - # find is still a hard error. - try: - return finder(base_path) - except BakeryToolNotFoundError: - if dry_run: - return fallback - raise - - soci_bin = resolve_bin(find_soci_bin, "soci") - oras_bin = resolve_bin(find_oras_bin, "oras") - - for target, opts, ref in eligible: - workflow = SociConvertWorkflow( - soci_bin=soci_bin, - oras_bin=oras_bin, - image_target=target, - options=opts, - source_ref=ref, - ) - wf_result = workflow.run(dry_run=dry_run) - results.append( - ToolCallResult( - exit_code=0 if wf_result.success else 1, - tool_name="soci", - target=target, - stdout="", - stderr=wf_result.error or "", - artifacts={"workflow_result": wf_result}, - ) - ) - - return results - - def results(self, results: list[ToolCallResult]) -> None: - """Display SOCI conversion results and raise typer.Exit(1) on failure.""" - from posit_bakery.log import stderr_console - - has_errors = False - for r in results: - artifacts = r.artifacts or {} - if artifacts.get("skipped"): - log.info(f"SOCI skipped for {r.target}: {artifacts.get('reason')}") - continue - wf = artifacts.get("workflow_result") - if r.exit_code != 0: - has_errors = True - stderr_console.print( - f"SOCI convert failed for '{r.target}': {r.stderr}", - style="error", - ) - elif wf: - log.info(f"SOCI converted '{r.target}' -> {wf.destination_ref}") - - if has_errors: - stderr_console.print("❌ SOCI conversion(s) failed", style="error") - raise typer.Exit(code=1) - - stderr_console.print("✅ SOCI conversion(s) completed", style="success") diff --git a/posit-bakery/pyproject.toml b/posit-bakery/pyproject.toml index 23a5e160d..f59fb2a4c 100644 --- a/posit-bakery/pyproject.toml +++ b/posit-bakery/pyproject.toml @@ -57,10 +57,9 @@ bakery = "posit_bakery.cli.main:app" [project.entry-points."bakery.plugins"] dgoss = "posit_bakery.plugins.builtin.dgoss:DGossPlugin" -oras = "posit_bakery.plugins.builtin.oras:OrasPlugin" +imagetools = "posit_bakery.plugins.builtin.imagetools:ImageToolsPlugin" hadolint = "posit_bakery.plugins.builtin.hadolint:HadolintPlugin" wizcli = "posit_bakery.plugins.builtin.wizcli:WizCLIPlugin" -soci = "posit_bakery.plugins.builtin.soci:SociPlugin" [build-system] requires = ["hatchling", "uv-dynamic-versioning"] diff --git a/posit-bakery/test/cli/test_ci.py b/posit-bakery/test/cli/test_ci.py index 356c3cc39..67cd257ef 100644 --- a/posit-bakery/test/cli/test_ci.py +++ b/posit-bakery/test/cli/test_ci.py @@ -94,23 +94,23 @@ def run(self, dry_run=False, **kwargs): # Patch the imports inside the publish function mocker.patch( - "posit_bakery.plugins.builtin.oras.oras.OrasWaitForSourcesWorkflow", + "posit_bakery.plugins.builtin.imagetools.oras.OrasWaitForSourcesWorkflow", MockOrasWaitForSourcesWorkflow, ) mocker.patch( - "posit_bakery.plugins.builtin.oras.oras.OrasIndexCreateWorkflow", + "posit_bakery.plugins.builtin.imagetools.oras.OrasIndexCreateWorkflow", MockOrasIndexCreateWorkflow, ) mocker.patch( - "posit_bakery.plugins.builtin.oras.oras.OrasIndexCopyWorkflow", + "posit_bakery.plugins.builtin.imagetools.oras.OrasIndexCopyWorkflow", MockOrasIndexCopyWorkflow, ) mocker.patch( - "posit_bakery.plugins.builtin.oras.oras.OrasIndexVerifyWorkflow", + "posit_bakery.plugins.builtin.imagetools.oras.OrasIndexVerifyWorkflow", MockOrasIndexVerifyWorkflow, ) mocker.patch( - "posit_bakery.plugins.builtin.oras.oras.find_oras_bin", + "posit_bakery.plugins.builtin.imagetools.oras.find_oras_bin", return_value="/mock/oras", ) return calls diff --git a/posit-bakery/test/cli/test_ci_publish.py b/posit-bakery/test/cli/test_ci_publish.py index e8e20addb..667eafc4b 100644 --- a/posit-bakery/test/cli/test_ci_publish.py +++ b/posit-bakery/test/cli/test_ci_publish.py @@ -1,4 +1,9 @@ -"""Tests for the `bakery ci publish` orchestrator.""" +"""Tests for the `bakery ci publish` orchestrator. + +The orchestration logic lives in the ``imagetools`` plugin +(``ImageToolsPlugin.publish``); ``bakery ci publish`` is a thin wrapper that +delegates to it. +""" from unittest.mock import MagicMock, patch @@ -43,7 +48,10 @@ def _fake_target(uid: str, merge_sources: list[str] | None = None): return t -def test_publish_invokes_soci_execute_without_mode(tmp_path): +def test_publish_invokes_soci_convert_without_mode(tmp_path): + """`ci publish` should drive the plugin's SOCI conversion phase + (``ImageToolsPlugin.execute``) without threading any mode/standalone + selector through it.""" captured = {} fake_config = MagicMock() @@ -51,20 +59,20 @@ def test_publish_invokes_soci_execute_without_mode(tmp_path): fake_config.load_build_metadata_from_file.return_value = ["uid1"] fake_config.get_image_target_by_uid.return_value = _fake_target("uid1") - fake_soci = MagicMock() - - def fake_execute(base_path, targets, *, source_refs=None, dry_run=False, **kwargs): + def fake_execute(self, base_path, targets, *, source_refs=None, dry_run=False, **kwargs): captured["called"] = True captured["kwargs"] = kwargs return [] - fake_soci.execute.side_effect = fake_execute - runner = CliRunner() with ( - patch("posit_bakery.cli.ci.BakeryConfig.from_context", return_value=fake_config), - patch("posit_bakery.plugins.builtin.oras.oras.find_oras_bin", return_value="oras"), - patch("posit_bakery.plugins.registry.get_plugin", return_value=fake_soci), + patch("posit_bakery.config.BakeryConfig.from_context", return_value=fake_config), + patch("posit_bakery.plugins.builtin.imagetools.oras.find_oras_bin", return_value="oras"), + patch( + "posit_bakery.plugins.builtin.imagetools.imagetools.ImageToolsPlugin.execute", + autospec=True, + side_effect=fake_execute, + ), ): result = runner.invoke( app, @@ -79,7 +87,12 @@ def fake_execute(base_path, targets, *, source_refs=None, dry_run=False, **kwarg def test_publish_waits_for_sources_then_proceeds(tmp_path): - """The pre-flight wait is invoked with the targets' merge-source digests.""" + """The pre-flight wait is invoked with the targets' merge-source digests. + + The orchestration now lives in ``ImageToolsPlugin.publish``; its SOCI + convert phase (``ImageToolsPlugin.execute``) is stubbed so we can exercise + the path from the wait through the ORAS phases. + """ sources = [ "ghcr.io/posit-dev/test/tmp@sha256:amd64", "ghcr.io/posit-dev/test/tmp@sha256:arm64", @@ -101,9 +114,6 @@ def fake_wait_ctor(**kwargs): captured["wait_kwargs"] = kwargs return fake_wait_instance - fake_soci = MagicMock() - fake_soci.execute.return_value = [] - # Make the downstream phases succeed so we exercise the path past the wait. fake_create_result = MagicMock(success=True, temp_ref="ghcr.io/posit-dev/test/tmp:created") fake_copy_result = MagicMock(success=True, destinations=["ghcr.io/posit-dev/test:1.0.0"], error=None) @@ -112,21 +122,25 @@ def fake_wait_ctor(**kwargs): runner = CliRunner() with ( patch("posit_bakery.cli.ci.BakeryConfig.from_context", return_value=fake_config), - patch("posit_bakery.plugins.builtin.oras.oras.find_oras_bin", return_value="oras"), - patch("posit_bakery.plugins.builtin.oras.oras.OrasWaitForSourcesWorkflow", side_effect=fake_wait_ctor), + patch("posit_bakery.plugins.builtin.imagetools.oras.find_oras_bin", return_value="oras"), + patch("posit_bakery.plugins.builtin.imagetools.oras.OrasWaitForSourcesWorkflow", side_effect=fake_wait_ctor), patch( - "posit_bakery.plugins.builtin.oras.oras.OrasIndexCreateWorkflow", + "posit_bakery.plugins.builtin.imagetools.oras.OrasIndexCreateWorkflow", return_value=MagicMock(run=MagicMock(return_value=fake_create_result)), ), patch( - "posit_bakery.plugins.builtin.oras.oras.OrasIndexCopyWorkflow", + "posit_bakery.plugins.builtin.imagetools.oras.OrasIndexCopyWorkflow", return_value=MagicMock(run=MagicMock(return_value=fake_copy_result)), ), patch( - "posit_bakery.plugins.builtin.oras.oras.OrasIndexVerifyWorkflow", + "posit_bakery.plugins.builtin.imagetools.oras.OrasIndexVerifyWorkflow", return_value=MagicMock(run=MagicMock(return_value=fake_verify_result)), ), - patch("posit_bakery.plugins.registry.get_plugin", return_value=fake_soci), + patch( + "posit_bakery.plugins.builtin.imagetools.imagetools.ImageToolsPlugin.execute", + autospec=True, + return_value=[], + ), ): result = runner.invoke(app, ["ci", "publish", "meta.json"], env=_WIDE_TERM_ENV) @@ -151,20 +165,24 @@ def test_publish_aborts_when_sources_never_ready(tmp_path): success=False, ready=[], missing=sources, waited_seconds=600.0, error="still unreadable" ) - fake_soci = MagicMock() - runner = CliRunner() with ( patch("posit_bakery.cli.ci.BakeryConfig.from_context", return_value=fake_config), - patch("posit_bakery.plugins.builtin.oras.oras.find_oras_bin", return_value="oras"), - patch("posit_bakery.plugins.builtin.oras.oras.OrasWaitForSourcesWorkflow", return_value=fake_wait_instance), - patch("posit_bakery.plugins.registry.get_plugin", return_value=fake_soci), + patch("posit_bakery.plugins.builtin.imagetools.oras.find_oras_bin", return_value="oras"), + patch( + "posit_bakery.plugins.builtin.imagetools.oras.OrasWaitForSourcesWorkflow", + return_value=fake_wait_instance, + ), + patch( + "posit_bakery.plugins.builtin.imagetools.imagetools.ImageToolsPlugin.execute", + autospec=True, + ) as mock_execute, ): result = runner.invoke(app, ["ci", "publish", "meta.json"], env=_WIDE_TERM_ENV) assert result.exit_code == 1 # Aborted before SOCI convert. - fake_soci.execute.assert_not_called() + mock_execute.assert_not_called() def test_publish_surfaces_clean_error_on_non_transient_wait_failure(tmp_path): @@ -190,18 +208,22 @@ def test_publish_surfaces_clean_error_on_non_transient_wait_failure(tmp_path): stderr=b"unauthorized: authentication required", ) - fake_soci = MagicMock() - runner = CliRunner() with ( patch("posit_bakery.cli.ci.BakeryConfig.from_context", return_value=fake_config), - patch("posit_bakery.plugins.builtin.oras.oras.find_oras_bin", return_value="oras"), - patch("posit_bakery.plugins.builtin.oras.oras.OrasWaitForSourcesWorkflow", return_value=fake_wait_instance), - patch("posit_bakery.plugins.registry.get_plugin", return_value=fake_soci), + patch("posit_bakery.plugins.builtin.imagetools.oras.find_oras_bin", return_value="oras"), + patch( + "posit_bakery.plugins.builtin.imagetools.oras.OrasWaitForSourcesWorkflow", + return_value=fake_wait_instance, + ), + patch( + "posit_bakery.plugins.builtin.imagetools.imagetools.ImageToolsPlugin.execute", + autospec=True, + ) as mock_execute, ): result = runner.invoke(app, ["ci", "publish", "meta.json"], env=_WIDE_TERM_ENV) # Clean exit, not an unhandled exception. assert result.exit_code == 1 assert result.exception is None or isinstance(result.exception, SystemExit) - fake_soci.execute.assert_not_called() + mock_execute.assert_not_called() diff --git a/posit-bakery/test/cli/test_dev_spec.py b/posit-bakery/test/cli/test_dev_spec.py index 2c4983d94..3c193d296 100644 --- a/posit-bakery/test/cli/test_dev_spec.py +++ b/posit-bakery/test/cli/test_dev_spec.py @@ -321,56 +321,56 @@ def test_dev_spec_invalid_schema_rejected(self): class TestCiPublishDevSpec: - """Tests for --dev-spec / BAKERY_DEV_SPEC in bakery ci publish.""" + """Tests for --dev-spec / BAKERY_DEV_SPEC in bakery ci publish. + + The orchestration lives in ``ImageToolsPlugin.publish``; ``ci publish`` is a + thin wrapper, so these tests assert the parsed ``dev_spec`` is forwarded to + the plugin (which threads it into BakerySettings). + """ def _invoke(self, extra_args: list[str], env: dict | None = None): - with ( - patch("posit_bakery.cli.ci.BakeryConfig") as mock_config, - patch("posit_bakery.plugins.builtin.oras.oras.find_oras_bin", return_value=Path("/fake/oras")), - patch("posit_bakery.plugins.registry.get_plugin") as mock_get_plugin, - ): - instance = MagicMock() - instance.load_build_metadata_from_file.return_value = [] - instance.base_path = Path(BASIC_CONTEXT) - mock_config.from_context.return_value = instance - mock_soci = MagicMock() - mock_soci.execute.return_value = [] - mock_get_plugin.return_value = mock_soci + with patch("posit_bakery.plugins.registry.get_plugin") as mock_get_plugin: + plugin = MagicMock() + mock_get_plugin.return_value = plugin result = runner.invoke( app, ["ci", "publish", "/fake/metadata.json", "--context", BASIC_CONTEXT] + extra_args, env=env, catch_exceptions=False, ) - return result, mock_config + return result, plugin + + @staticmethod + def _forwarded_dev_spec(plugin): + """Extract the dev_spec forwarded to the delegated plugin.publish call.""" + return plugin.publish.call_args.kwargs["dev_spec"] def test_dev_spec_via_flag(self): - """--dev-spec JSON is parsed and forwarded to BakerySettings in ci publish.""" - result, mock = self._invoke(["--dev-spec", '{"version": "2026.05.0-dev+185-gSHA", "channel": "daily"}']) + """--dev-spec JSON is parsed and forwarded to the plugin in ci publish.""" + result, plugin = self._invoke(["--dev-spec", '{"version": "2026.05.0-dev+185-gSHA", "channel": "daily"}']) assert result.exit_code == 0, result.output - settings = settings_from_call(mock) - assert isinstance(settings.dev_spec, DevBuildSpec) - assert settings.dev_spec.version == "2026.05.0-dev+185-gSHA" - assert settings.dev_spec.channel == ReleaseChannelEnum.DAILY + dev_spec = self._forwarded_dev_spec(plugin) + assert isinstance(dev_spec, DevBuildSpec) + assert dev_spec.version == "2026.05.0-dev+185-gSHA" + assert dev_spec.channel == ReleaseChannelEnum.DAILY def test_dev_spec_via_env_var(self): """BAKERY_DEV_SPEC env var works in ci publish.""" - result, mock = self._invoke( + result, plugin = self._invoke( [], env={"BAKERY_DEV_SPEC": '{"version": "2026.05.0-dev+185-gSHA"}'}, ) assert result.exit_code == 0, result.output - settings = settings_from_call(mock) - assert isinstance(settings.dev_spec, DevBuildSpec) - assert settings.dev_spec.version == "2026.05.0-dev+185-gSHA" - assert settings.dev_spec.channel is None + dev_spec = self._forwarded_dev_spec(plugin) + assert isinstance(dev_spec, DevBuildSpec) + assert dev_spec.version == "2026.05.0-dev+185-gSHA" + assert dev_spec.channel is None def test_dev_spec_absent_is_none(self): - """When --dev-spec is absent, BakerySettings.dev_spec is None.""" - result, mock = self._invoke([]) + """When --dev-spec is absent, the forwarded dev_spec is None.""" + result, plugin = self._invoke([]) assert result.exit_code == 0, result.output - settings = settings_from_call(mock) - assert settings.dev_spec is None + assert self._forwarded_dev_spec(plugin) is None def test_dev_spec_invalid_json_rejected(self): """Invalid JSON in --dev-spec causes a non-zero exit.""" diff --git a/posit-bakery/test/cli/test_dev_stream_deprecated.py b/posit-bakery/test/cli/test_dev_stream_deprecated.py index 8e6d023ef..f27280cd4 100644 --- a/posit-bakery/test/cli/test_dev_stream_deprecated.py +++ b/posit-bakery/test/cli/test_dev_stream_deprecated.py @@ -22,38 +22,34 @@ class TestCiMergeDevStreamDeprecation: @pytest.fixture def mock_merge(self, tmp_path): + # `ci merge` -> `ci publish` delegates the orchestration to the + # imagetools plugin; capture the dev_channel it forwards. metadata_file = tmp_path / "meta.json" metadata_file.write_text("{}") - with patch("posit_bakery.cli.ci.BakeryConfig") as mock_config: - instance = MagicMock() - instance.load_build_metadata_from_file.return_value = [] - instance.targets = [] - mock_config.from_context.return_value = instance - with patch("posit_bakery.plugins.registry.get_plugin") as mock_plugin: - mock_oras = MagicMock() - mock_oras.execute.return_value = [] - mock_plugin.return_value = mock_oras - yield mock_config, str(metadata_file) + with patch("posit_bakery.plugins.registry.get_plugin") as mock_get_plugin: + plugin = MagicMock() + mock_get_plugin.return_value = plugin + yield plugin, str(metadata_file) def test_dev_stream_coalesces(self, mock_merge): - mock, meta = mock_merge + plugin, meta = mock_merge result = runner.invoke( app, ["ci", "merge", meta, "--context", BASIC_CONTEXT, "--dev-stream", "daily"], catch_exceptions=False, ) assert result.exit_code == 0 - assert settings_from_call(mock).dev_channel == ReleaseChannelEnum.DAILY + assert plugin.publish.call_args.kwargs["dev_channel"] == ReleaseChannelEnum.DAILY def test_dev_channel_wins(self, mock_merge): - mock, meta = mock_merge + plugin, meta = mock_merge result = runner.invoke( app, ["ci", "merge", meta, "--context", BASIC_CONTEXT, "--dev-stream", "daily", "--dev-channel", "preview"], catch_exceptions=False, ) assert result.exit_code == 0 - assert settings_from_call(mock).dev_channel == ReleaseChannelEnum.PREVIEW + assert plugin.publish.call_args.kwargs["dev_channel"] == ReleaseChannelEnum.PREVIEW class TestCiReadmeDevStreamDeprecation: diff --git a/posit-bakery/test/plugins/builtin/oras/__init__.py b/posit-bakery/test/plugins/builtin/imagetools/__init__.py similarity index 100% rename from posit-bakery/test/plugins/builtin/oras/__init__.py rename to posit-bakery/test/plugins/builtin/imagetools/__init__.py diff --git a/posit-bakery/test/plugins/builtin/imagetools/test_cli.py b/posit-bakery/test/plugins/builtin/imagetools/test_cli.py new file mode 100644 index 000000000..9edb0a1aa --- /dev/null +++ b/posit-bakery/test/plugins/builtin/imagetools/test_cli.py @@ -0,0 +1,65 @@ +"""Tests for the imagetools CLI commands (`bakery imagetools merge` / +`bakery imagetools soci-convert`) and their hidden back-compat aliases +(`bakery oras merge` / `bakery soci convert`).""" + +import pytest +from typer.testing import CliRunner + +from posit_bakery.cli.main import app + +pytestmark = [pytest.mark.unit] + +# Force a wide, unstyled terminal so rich/typer doesn't line-wrap option +# names across rows with embedded ANSI escapes, which defeats substring +# assertions on narrow CI terminals. +_WIDE_TERM_ENV = {"COLUMNS": "200", "TERM": "dumb", "NO_COLOR": "1"} + + +def test_imagetools_help_lists_subcommands(): + runner = CliRunner() + result = runner.invoke(app, ["imagetools", "--help"], env=_WIDE_TERM_ENV) + assert result.exit_code == 0 + assert "merge" in result.stdout + assert "soci-convert" in result.stdout + + +def test_soci_convert_requires_metadata_file_argument(): + runner = CliRunner() + result = runner.invoke(app, ["imagetools", "soci-convert"]) + assert result.exit_code != 0 + + +def test_soci_convert_help_has_no_mode_option(): + runner = CliRunner() + result = runner.invoke(app, ["imagetools", "soci-convert", "--help"], env=_WIDE_TERM_ENV) + assert result.exit_code == 0 + # Conversion is standalone-only; there is no mode selector. + assert "--soci-mode" not in result.stdout + assert "--standalone" not in result.stdout + + +def test_soci_convert_rejects_unknown_option(): + runner = CliRunner() + result = runner.invoke(app, ["imagetools", "soci-convert", "meta.json", "--soci-mode", "containerd"]) + assert result.exit_code != 0 + + +def test_merge_requires_metadata_file_argument(): + runner = CliRunner() + result = runner.invoke(app, ["imagetools", "merge"]) + assert result.exit_code != 0 + + +# --- Hidden back-compat aliases still resolve --- + + +def test_hidden_soci_convert_alias_still_works(): + runner = CliRunner() + result = runner.invoke(app, ["soci", "convert", "--help"], env=_WIDE_TERM_ENV) + assert result.exit_code == 0 + + +def test_hidden_oras_merge_alias_still_works(): + runner = CliRunner() + result = runner.invoke(app, ["oras", "merge", "--help"], env=_WIDE_TERM_ENV) + assert result.exit_code == 0 diff --git a/posit-bakery/test/plugins/builtin/soci/test_command_base.py b/posit-bakery/test/plugins/builtin/imagetools/test_command_base.py similarity index 96% rename from posit-bakery/test/plugins/builtin/soci/test_command_base.py rename to posit-bakery/test/plugins/builtin/imagetools/test_command_base.py index ff3a8bf87..1407f8bc3 100644 --- a/posit-bakery/test/plugins/builtin/soci/test_command_base.py +++ b/posit-bakery/test/plugins/builtin/imagetools/test_command_base.py @@ -10,7 +10,7 @@ from typing import Annotated from posit_bakery.error import BakeryToolNotFoundError, BakeryToolRuntimeError -from posit_bakery.plugins.builtin.soci.soci import SociCommand, find_soci_bin +from posit_bakery.plugins.builtin.imagetools.soci import SociCommand, find_soci_bin pytestmark = [pytest.mark.unit] diff --git a/posit-bakery/test/plugins/builtin/soci/test_convert.py b/posit-bakery/test/plugins/builtin/imagetools/test_convert.py similarity index 96% rename from posit-bakery/test/plugins/builtin/soci/test_convert.py rename to posit-bakery/test/plugins/builtin/imagetools/test_convert.py index 96d0ffeb3..c02234f0f 100644 --- a/posit-bakery/test/plugins/builtin/soci/test_convert.py +++ b/posit-bakery/test/plugins/builtin/imagetools/test_convert.py @@ -2,7 +2,7 @@ import pytest -from posit_bakery.plugins.builtin.soci.soci import SociConvert +from posit_bakery.plugins.builtin.imagetools.soci import SociConvert pytestmark = [pytest.mark.unit] diff --git a/posit-bakery/test/plugins/builtin/imagetools/test_discovery.py b/posit-bakery/test/plugins/builtin/imagetools/test_discovery.py new file mode 100644 index 000000000..c154fe159 --- /dev/null +++ b/posit-bakery/test/plugins/builtin/imagetools/test_discovery.py @@ -0,0 +1,23 @@ +"""Tests for imagetools plugin discovery.""" + +import pytest + +from posit_bakery.plugins.registry import discover_plugins +from posit_bakery.plugins.protocol import BakeryToolPlugin + +pytestmark = [pytest.mark.unit] + + +def test_imagetools_plugin_is_discovered(): + plugins = discover_plugins() + assert "imagetools" in plugins + assert isinstance(plugins["imagetools"], BakeryToolPlugin) + assert plugins["imagetools"].name == "imagetools" + + +def test_legacy_soci_oras_plugins_are_not_registered(): + """The standalone soci/oras plugins were merged into imagetools; they + should no longer be discovered as separate plugins.""" + plugins = discover_plugins() + assert "soci" not in plugins + assert "oras" not in plugins diff --git a/posit-bakery/test/plugins/builtin/soci/test_options.py b/posit-bakery/test/plugins/builtin/imagetools/test_options.py similarity index 97% rename from posit-bakery/test/plugins/builtin/soci/test_options.py rename to posit-bakery/test/plugins/builtin/imagetools/test_options.py index 63d8e5405..b244d1ff6 100644 --- a/posit-bakery/test/plugins/builtin/soci/test_options.py +++ b/posit-bakery/test/plugins/builtin/imagetools/test_options.py @@ -2,7 +2,7 @@ import pytest -from posit_bakery.plugins.builtin.soci.options import SociOptions +from posit_bakery.plugins.builtin.imagetools.options import SociOptions pytestmark = [pytest.mark.unit] diff --git a/posit-bakery/test/plugins/builtin/oras/test_oras.py b/posit-bakery/test/plugins/builtin/imagetools/test_oras.py similarity index 99% rename from posit-bakery/test/plugins/builtin/oras/test_oras.py rename to posit-bakery/test/plugins/builtin/imagetools/test_oras.py index 91448d577..ca54f6eae 100644 --- a/posit-bakery/test/plugins/builtin/oras/test_oras.py +++ b/posit-bakery/test/plugins/builtin/imagetools/test_oras.py @@ -9,7 +9,7 @@ from posit_bakery.error import BakeryToolRuntimeError from posit_bakery.image.image_target import StringableList, ImageTarget, ImageTargetContext, ImageTargetSettings -from posit_bakery.plugins.builtin.oras.oras import ( +from posit_bakery.plugins.builtin.imagetools.oras import ( find_oras_bin, get_repository_from_ref, OrasCopy, @@ -400,7 +400,7 @@ def mock_image_target(self): def test_from_image_target(self, mock_image_target): """Test creating workflow from ImageTarget.""" - with patch("posit_bakery.plugins.builtin.oras.oras.find_oras_bin", return_value="oras"): + with patch("posit_bakery.plugins.builtin.imagetools.oras.find_oras_bin", return_value="oras"): workflow = OrasMergeWorkflow.from_image_target(mock_image_target) assert workflow.oras_bin == "oras" @@ -786,7 +786,7 @@ def test_from_image_target_with_plain_http(self, mock_image_target_for_local_reg "localhost:5000/test/tmp@sha256:digest", ] - with patch("posit_bakery.plugins.builtin.oras.oras.find_oras_bin", return_value="oras"): + with patch("posit_bakery.plugins.builtin.imagetools.oras.find_oras_bin", return_value="oras"): workflow = OrasMergeWorkflow.from_image_target(mock_target, plain_http=True) assert workflow.plain_http is True diff --git a/posit-bakery/test/plugins/builtin/oras/test_oras_plugin.py b/posit-bakery/test/plugins/builtin/imagetools/test_oras_plugin.py similarity index 73% rename from posit-bakery/test/plugins/builtin/oras/test_oras_plugin.py rename to posit-bakery/test/plugins/builtin/imagetools/test_oras_plugin.py index 87bb1c8fe..fdbbd20ad 100644 --- a/posit-bakery/test/plugins/builtin/oras/test_oras_plugin.py +++ b/posit-bakery/test/plugins/builtin/imagetools/test_oras_plugin.py @@ -1,4 +1,4 @@ -"""Tests for the OrasPlugin.""" +"""Tests for the ORAS merge side of the ImageToolsPlugin (merge_execute).""" import logging import subprocess @@ -9,8 +9,8 @@ import typer from posit_bakery.image.image_target import ImageTarget, ImageTargetContext, ImageTargetSettings, StringableList -from posit_bakery.plugins.builtin.oras import OrasPlugin -from posit_bakery.plugins.builtin.oras.oras import OrasMergeWorkflowResult +from posit_bakery.plugins.builtin.imagetools import ImageToolsPlugin +from posit_bakery.plugins.builtin.imagetools.oras import OrasMergeWorkflowResult from posit_bakery.plugins.protocol import BakeryToolPlugin pytestmark = [pytest.mark.unit] @@ -18,7 +18,7 @@ @pytest.fixture def plugin(): - return OrasPlugin() + return ImageToolsPlugin() @pytest.fixture @@ -59,25 +59,25 @@ def mock_target_without_sources(): return mock_target -class TestOrasPluginProtocol: +class TestImageToolsPluginProtocol: def test_implements_protocol(self, plugin): assert isinstance(plugin, BakeryToolPlugin) def test_name(self, plugin): - assert plugin.name == "oras" + assert plugin.name == "imagetools" def test_description(self, plugin): - assert plugin.description == "Merge multi-platform images using ORAS" + assert plugin.description == "Merge and SOCI-convert multi-platform images (ORAS + SOCI)" -class TestOrasPluginExecute: - def test_execute_success(self, plugin, mock_target_with_sources): +class TestImageToolsPluginMergeExecute: + def test_merge_execute_success(self, plugin, mock_target_with_sources): with ( - patch("posit_bakery.plugins.builtin.oras.oras.find_oras_bin", return_value="oras"), + patch("posit_bakery.plugins.builtin.imagetools.oras.find_oras_bin", return_value="oras"), patch("subprocess.run") as mock_run, ): mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout=b"", stderr=b"") - results = plugin.execute( + results = plugin.merge_execute( Path("/project"), [mock_target_with_sources], ) @@ -88,18 +88,18 @@ def test_execute_success(self, plugin, mock_target_with_sources): assert results[0].target is mock_target_with_sources assert results[0].artifacts["workflow_result"].success is True - def test_execute_skips_targets_without_sources(self, plugin, mock_target_without_sources): - results = plugin.execute( + def test_merge_execute_skips_targets_without_sources(self, plugin, mock_target_without_sources): + results = plugin.merge_execute( Path("/project"), [mock_target_without_sources], ) assert len(results) == 0 - def test_execute_missing_temp_registry(self, plugin, mock_target_with_sources): + def test_merge_execute_missing_temp_registry(self, plugin, mock_target_with_sources): mock_target_with_sources.settings.temp_registry = None - results = plugin.execute( + results = plugin.merge_execute( Path("/project"), [mock_target_with_sources], ) @@ -108,15 +108,15 @@ def test_execute_missing_temp_registry(self, plugin, mock_target_with_sources): assert results[0].exit_code == 1 assert "temp_registry" in results[0].stderr - def test_execute_workflow_failure(self, plugin, mock_target_with_sources): + def test_merge_execute_workflow_failure(self, plugin, mock_target_with_sources): with ( - patch("posit_bakery.plugins.builtin.oras.oras.find_oras_bin", return_value="oras"), + patch("posit_bakery.plugins.builtin.imagetools.oras.find_oras_bin", return_value="oras"), patch("subprocess.run") as mock_run, ): mock_run.return_value = subprocess.CompletedProcess( args=[], returncode=1, stdout=b"", stderr=b"create failed" ) - results = plugin.execute( + results = plugin.merge_execute( Path("/project"), [mock_target_with_sources], ) @@ -125,12 +125,12 @@ def test_execute_workflow_failure(self, plugin, mock_target_with_sources): assert results[0].exit_code == 1 assert results[0].artifacts["workflow_result"].success is False - def test_execute_dry_run(self, plugin, mock_target_with_sources): + def test_merge_execute_dry_run(self, plugin, mock_target_with_sources): with ( - patch("posit_bakery.plugins.builtin.oras.oras.find_oras_bin", return_value="oras"), + patch("posit_bakery.plugins.builtin.imagetools.oras.find_oras_bin", return_value="oras"), patch("subprocess.run") as mock_run, ): - results = plugin.execute( + results = plugin.merge_execute( Path("/project"), [mock_target_with_sources], dry_run=True, @@ -141,13 +141,13 @@ def test_execute_dry_run(self, plugin, mock_target_with_sources): assert results[0].exit_code == 0 assert results[0].artifacts["workflow_result"].success is True - def test_execute_mixed_targets(self, plugin, mock_target_with_sources, mock_target_without_sources): + def test_merge_execute_mixed_targets(self, plugin, mock_target_with_sources, mock_target_without_sources): with ( - patch("posit_bakery.plugins.builtin.oras.oras.find_oras_bin", return_value="oras"), + patch("posit_bakery.plugins.builtin.imagetools.oras.find_oras_bin", return_value="oras"), patch("subprocess.run") as mock_run, ): mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout=b"", stderr=b"") - results = plugin.execute( + results = plugin.merge_execute( Path("/project"), [mock_target_with_sources, mock_target_without_sources], ) @@ -156,7 +156,7 @@ def test_execute_mixed_targets(self, plugin, mock_target_with_sources, mock_targ assert len(results) == 1 assert results[0].target is mock_target_with_sources - def test_execute_processes_targets_in_push_sort_key_order(self, plugin, caplog): + def test_merge_execute_processes_targets_in_push_sort_key_order(self, plugin, caplog): """Targets are processed in ascending push_sort_key order, regardless of input order.""" def make_target(name, sort_key): @@ -196,15 +196,15 @@ def fake_run(self_workflow, dry_run=False): return OrasMergeWorkflowResult(success=True, destinations=[]) with ( - patch("posit_bakery.plugins.builtin.oras.oras.find_oras_bin", return_value="oras"), + patch("posit_bakery.plugins.builtin.imagetools.oras.find_oras_bin", return_value="oras"), patch( - "posit_bakery.plugins.builtin.oras.OrasMergeWorkflow.run", + "posit_bakery.plugins.builtin.imagetools.oras.OrasMergeWorkflow.run", autospec=True, side_effect=fake_run, ), - caplog.at_level(logging.INFO, logger="posit_bakery.plugins.builtin.oras"), + caplog.at_level(logging.INFO, logger="posit_bakery.plugins.builtin.imagetools.imagetools"), ): - plugin.execute(Path("/project"), targets) + plugin.merge_execute(Path("/project"), targets) assert call_order == expected_order, f"got {call_order}, want {expected_order}" order_log_lines = [r for r in caplog.records if "ORAS merge order:" in r.getMessage()] @@ -213,11 +213,14 @@ def fake_run(self_workflow, dry_run=False): assert msg.endswith("a-first, c-second, b-third, d-last"), msg -class TestOrasPluginCLI: - def test_register_cli_adds_oras_command(self, plugin): +class TestImageToolsPluginCLI: + def test_register_cli_adds_command_groups(self, plugin): app = typer.Typer() plugin.register_cli(app) - # Verify the oras group was registered + # The canonical `imagetools` group plus the hidden back-compat aliases + # (`oras`, `soci`) should all be registered. group_names = [g.name for g in app.registered_groups] + assert "imagetools" in group_names assert "oras" in group_names + assert "soci" in group_names diff --git a/posit-bakery/test/plugins/builtin/soci/test_plugin_execute.py b/posit-bakery/test/plugins/builtin/imagetools/test_plugin_execute.py similarity index 77% rename from posit-bakery/test/plugins/builtin/soci/test_plugin_execute.py rename to posit-bakery/test/plugins/builtin/imagetools/test_plugin_execute.py index d3f65b072..5f3c3079c 100644 --- a/posit-bakery/test/plugins/builtin/soci/test_plugin_execute.py +++ b/posit-bakery/test/plugins/builtin/imagetools/test_plugin_execute.py @@ -1,4 +1,4 @@ -"""Tests for SociPlugin.execute().""" +"""Tests for ImageToolsPlugin.execute() (SOCI conversion).""" import subprocess from unittest.mock import MagicMock, patch @@ -7,9 +7,9 @@ import posit_bakery.util as util from posit_bakery.image.image_target import ImageTarget -from posit_bakery.plugins.builtin.soci import SociPlugin -from posit_bakery.plugins.builtin.soci.options import SociOptions -from posit_bakery.plugins.builtin.soci.soci import SociConvertWorkflow, SociConvertWorkflowResult +from posit_bakery.plugins.builtin.imagetools import ImageToolsPlugin +from posit_bakery.plugins.builtin.imagetools.options import SociOptions +from posit_bakery.plugins.builtin.imagetools.soci import SociConvertWorkflow, SociConvertWorkflowResult pytestmark = [pytest.mark.unit] @@ -28,7 +28,7 @@ def _make_target(uid: str, enabled: bool, image_name: str = "test-image") -> Ima def test_skips_targets_without_enabled_option(tmp_path): - plugin = SociPlugin() + plugin = ImageToolsPlugin() t_off = _make_target("a", enabled=False) t_on = _make_target("b", enabled=True) @@ -37,19 +37,19 @@ def fake_options(target): with ( patch( - "posit_bakery.plugins.builtin.soci.get_soci_options_for_target", + "posit_bakery.plugins.builtin.imagetools.imagetools.get_soci_options_for_target", side_effect=fake_options, ), patch( - "posit_bakery.plugins.builtin.soci.find_soci_bin", + "posit_bakery.plugins.builtin.imagetools.imagetools.find_soci_bin", return_value="soci", ), patch( - "posit_bakery.plugins.builtin.soci.find_oras_bin", + "posit_bakery.plugins.builtin.imagetools.imagetools.find_oras_bin", return_value="oras", ), patch( - "posit_bakery.plugins.builtin.soci.soci.SociConvertWorkflow._read_converted_digest", + "posit_bakery.plugins.builtin.imagetools.soci.SociConvertWorkflow._read_converted_digest", return_value="sha256:abc", ), patch("subprocess.run") as mock_run, @@ -81,19 +81,19 @@ def test_enabled_target_without_source_ref_is_skipped_not_failed(tmp_path): skipped, not reported as a conversion failure. Regression: such targets surfaced as "SOCI convert failed: no source ref provided" and flipped the whole `ci publish` run to a failure.""" - plugin = SociPlugin() + plugin = ImageToolsPlugin() t_ref = _make_target("a", enabled=True) t_noref = _make_target("b", enabled=True) with ( patch( - "posit_bakery.plugins.builtin.soci.get_soci_options_for_target", + "posit_bakery.plugins.builtin.imagetools.imagetools.get_soci_options_for_target", return_value=SociOptions(enabled=True), ), - patch("posit_bakery.plugins.builtin.soci.find_soci_bin", return_value="soci"), - patch("posit_bakery.plugins.builtin.soci.find_oras_bin", return_value="oras"), + patch("posit_bakery.plugins.builtin.imagetools.imagetools.find_soci_bin", return_value="soci"), + patch("posit_bakery.plugins.builtin.imagetools.imagetools.find_oras_bin", return_value="oras"), patch( - "posit_bakery.plugins.builtin.soci.soci.SociConvertWorkflow._read_converted_digest", + "posit_bakery.plugins.builtin.imagetools.soci.SociConvertWorkflow._read_converted_digest", return_value="sha256:abc", ), patch("subprocess.run") as mock_run, @@ -116,23 +116,23 @@ def test_enabled_target_without_source_ref_is_skipped_not_failed(tmp_path): def test_logs_summary_when_no_enabled_targets(tmp_path, caplog): - plugin = SociPlugin() + plugin = ImageToolsPlugin() t = _make_target("a", enabled=False) import logging - caplog.set_level(logging.INFO, logger="posit_bakery.plugins.builtin.soci") + caplog.set_level(logging.INFO, logger="posit_bakery.plugins.builtin.imagetools.imagetools") with ( patch( - "posit_bakery.plugins.builtin.soci.get_soci_options_for_target", + "posit_bakery.plugins.builtin.imagetools.imagetools.get_soci_options_for_target", return_value=SociOptions(enabled=False), ), patch( - "posit_bakery.plugins.builtin.soci.find_soci_bin", + "posit_bakery.plugins.builtin.imagetools.imagetools.find_soci_bin", return_value="soci", ), patch( - "posit_bakery.plugins.builtin.soci.find_oras_bin", + "posit_bakery.plugins.builtin.imagetools.imagetools.find_oras_bin", return_value="oras", ), ): @@ -150,19 +150,19 @@ def test_logs_summary_when_no_enabled_targets(tmp_path, caplog): def test_no_eligible_targets_does_not_invoke_binary_lookup(tmp_path): """When all targets are disabled, execute should not require soci/oras binaries to be installed — the lookups should be skipped.""" - plugin = SociPlugin() + plugin = ImageToolsPlugin() t = _make_target("a", enabled=False) with ( patch( - "posit_bakery.plugins.builtin.soci.get_soci_options_for_target", + "posit_bakery.plugins.builtin.imagetools.imagetools.get_soci_options_for_target", return_value=SociOptions(enabled=False), ), patch( - "posit_bakery.plugins.builtin.soci.find_soci_bin", + "posit_bakery.plugins.builtin.imagetools.imagetools.find_soci_bin", ) as mock_find_soci, patch( - "posit_bakery.plugins.builtin.soci.find_oras_bin", + "posit_bakery.plugins.builtin.imagetools.imagetools.find_oras_bin", ) as mock_find_oras, ): results = plugin.execute( @@ -191,17 +191,17 @@ def test_dry_run_does_not_require_tools_installed(tmp_path, missing_tools): absent from the host. Regression: ``ci publish --dry-run`` raised BakeryToolNotFoundError because execute() resolved the binaries eagerly, before the dry-run-aware workflow ran.""" - plugin = SociPlugin() + plugin = ImageToolsPlugin() t = _make_target("a", enabled=True) with ( patch( - "posit_bakery.plugins.builtin.soci.get_soci_options_for_target", + "posit_bakery.plugins.builtin.imagetools.imagetools.get_soci_options_for_target", return_value=SociOptions(enabled=True), ), patch("subprocess.run") as mock_run, patch( - "posit_bakery.plugins.builtin.soci.soci.SociConvertWorkflow._read_converted_digest", + "posit_bakery.plugins.builtin.imagetools.soci.SociConvertWorkflow._read_converted_digest", return_value="sha256:abc", ), ): @@ -222,7 +222,7 @@ def test_dry_run_uses_resolved_path_when_tool_present(tmp_path, monkeypatch): the logged commands remain accurate.""" monkeypatch.setenv("SOCI_PATH", "/custom/soci") monkeypatch.setenv("ORAS_PATH", "/custom/oras") - plugin = SociPlugin() + plugin = ImageToolsPlugin() t = _make_target("a", enabled=True) captured = {} @@ -234,10 +234,10 @@ def fake_run(self, dry_run=False): with ( patch( - "posit_bakery.plugins.builtin.soci.get_soci_options_for_target", + "posit_bakery.plugins.builtin.imagetools.imagetools.get_soci_options_for_target", return_value=SociOptions(enabled=True), ), - patch("posit_bakery.plugins.builtin.soci.soci.SociConvertWorkflow.run", fake_run), + patch("posit_bakery.plugins.builtin.imagetools.soci.SociConvertWorkflow.run", fake_run), ): plugin.execute( base_path=tmp_path, @@ -258,17 +258,17 @@ def test_run_does_not_require_ctr(tmp_path, monkeypatch): monkeypatch.setenv("ORAS_PATH", "/custom/oras") monkeypatch.delenv("CTR_PATH", raising=False) monkeypatch.setattr(util, "which", lambda name: None) - plugin = SociPlugin() + plugin = ImageToolsPlugin() t = _make_target("a", enabled=True) with ( patch( - "posit_bakery.plugins.builtin.soci.get_soci_options_for_target", + "posit_bakery.plugins.builtin.imagetools.imagetools.get_soci_options_for_target", return_value=SociOptions(enabled=True), ), patch("subprocess.run") as mock_run, patch( - "posit_bakery.plugins.builtin.soci.soci.SociConvertWorkflow._read_converted_digest", + "posit_bakery.plugins.builtin.imagetools.soci.SociConvertWorkflow._read_converted_digest", return_value="sha256:abc", ), ): diff --git a/posit-bakery/test/plugins/builtin/soci/test_plugin_results.py b/posit-bakery/test/plugins/builtin/imagetools/test_plugin_results.py similarity index 78% rename from posit-bakery/test/plugins/builtin/soci/test_plugin_results.py rename to posit-bakery/test/plugins/builtin/imagetools/test_plugin_results.py index 37737c85f..9d5440875 100644 --- a/posit-bakery/test/plugins/builtin/soci/test_plugin_results.py +++ b/posit-bakery/test/plugins/builtin/imagetools/test_plugin_results.py @@ -1,4 +1,4 @@ -"""Tests for SociPlugin.results().""" +"""Tests for ImageToolsPlugin.results() (SOCI conversion).""" from unittest.mock import MagicMock @@ -6,8 +6,8 @@ import typer from posit_bakery.image.image_target import ImageTarget -from posit_bakery.plugins.builtin.soci import SociPlugin -from posit_bakery.plugins.builtin.soci.soci import SociConvertWorkflowResult +from posit_bakery.plugins.builtin.imagetools import ImageToolsPlugin +from posit_bakery.plugins.builtin.imagetools.soci import SociConvertWorkflowResult from posit_bakery.plugins.protocol import ToolCallResult pytestmark = [pytest.mark.unit] @@ -35,12 +35,12 @@ def _result(exit_code: int, workflow_success: bool, target_uid: str = "t") -> To def test_all_success_does_not_raise(): - SociPlugin().results([_result(0, True)]) + ImageToolsPlugin().results([_result(0, True)]) def test_any_failure_raises_typer_exit(): with pytest.raises(typer.Exit) as exc: - SociPlugin().results([_result(0, True), _result(1, False, "u")]) + ImageToolsPlugin().results([_result(0, True), _result(1, False, "u")]) assert exc.value.exit_code == 1 @@ -56,4 +56,4 @@ def test_skipped_results_do_not_raise(): stderr="", artifacts={"skipped": True, "reason": "soci.enabled is false"}, ) - SociPlugin().results([skipped]) + ImageToolsPlugin().results([skipped]) diff --git a/posit-bakery/test/plugins/builtin/soci/test_workflow.py b/posit-bakery/test/plugins/builtin/imagetools/test_workflow.py similarity index 94% rename from posit-bakery/test/plugins/builtin/soci/test_workflow.py rename to posit-bakery/test/plugins/builtin/imagetools/test_workflow.py index d74092a9f..11a0a011e 100644 --- a/posit-bakery/test/plugins/builtin/soci/test_workflow.py +++ b/posit-bakery/test/plugins/builtin/imagetools/test_workflow.py @@ -6,8 +6,8 @@ import pytest from posit_bakery.image.image_target import ImageTarget -from posit_bakery.plugins.builtin.soci.options import SociOptions -from posit_bakery.plugins.builtin.soci.soci import SociConvertWorkflow +from posit_bakery.plugins.builtin.imagetools.options import SociOptions +from posit_bakery.plugins.builtin.imagetools.soci import SociConvertWorkflow pytestmark = [pytest.mark.unit] @@ -125,7 +125,9 @@ def test_cleans_up_scratch_dir(workflow, tmp_path): scratch = tmp_path / "soci-scratch" with ( - patch("posit_bakery.plugins.builtin.soci.soci.tempfile.mkdtemp", return_value=str(scratch)) as mock_mkdtemp, + patch( + "posit_bakery.plugins.builtin.imagetools.soci.tempfile.mkdtemp", return_value=str(scratch) + ) as mock_mkdtemp, patch("subprocess.run") as mock_run, patch.object(SociConvertWorkflow, "_read_converted_digest", return_value="sha256:abc123"), ): @@ -142,7 +144,7 @@ def test_cleans_up_scratch_dir_on_failure(workflow, tmp_path): scratch = tmp_path / "soci-scratch" with ( - patch("posit_bakery.plugins.builtin.soci.soci.tempfile.mkdtemp", return_value=str(scratch)), + patch("posit_bakery.plugins.builtin.imagetools.soci.tempfile.mkdtemp", return_value=str(scratch)), patch("subprocess.run", side_effect=lambda cmd, capture_output: _ok_proc(cmd)), patch.object(SociConvertWorkflow, "_read_converted_digest", side_effect=RuntimeError("boom")), ): diff --git a/posit-bakery/test/plugins/builtin/soci/__init__.py b/posit-bakery/test/plugins/builtin/soci/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/posit-bakery/test/plugins/builtin/soci/test_cli.py b/posit-bakery/test/plugins/builtin/soci/test_cli.py deleted file mode 100644 index 6626e0157..000000000 --- a/posit-bakery/test/plugins/builtin/soci/test_cli.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Tests for the `bakery soci convert` CLI command.""" - -import pytest -from typer.testing import CliRunner - -from posit_bakery.cli.main import app - -pytestmark = [pytest.mark.unit] - -# Force a wide, unstyled terminal so rich/typer doesn't line-wrap option -# names across rows with embedded ANSI escapes, which defeats substring -# assertions on narrow CI terminals. -_WIDE_TERM_ENV = {"COLUMNS": "200", "TERM": "dumb", "NO_COLOR": "1"} - - -def test_soci_convert_help_lists_subcommand(): - runner = CliRunner() - result = runner.invoke(app, ["soci", "--help"], env=_WIDE_TERM_ENV) - assert result.exit_code == 0 - assert "convert" in result.stdout - - -def test_soci_convert_requires_metadata_file_argument(): - runner = CliRunner() - result = runner.invoke(app, ["soci", "convert"]) - assert result.exit_code != 0 - - -def test_soci_convert_help_has_no_mode_option(): - runner = CliRunner() - result = runner.invoke(app, ["soci", "convert", "--help"], env=_WIDE_TERM_ENV) - assert result.exit_code == 0 - # Conversion is standalone-only; there is no mode selector. - assert "--soci-mode" not in result.stdout - assert "--standalone" not in result.stdout - - -def test_soci_convert_rejects_unknown_option(): - runner = CliRunner() - result = runner.invoke(app, ["soci", "convert", "meta.json", "--soci-mode", "containerd"]) - assert result.exit_code != 0 diff --git a/posit-bakery/test/plugins/builtin/soci/test_discovery.py b/posit-bakery/test/plugins/builtin/soci/test_discovery.py deleted file mode 100644 index 888f6097b..000000000 --- a/posit-bakery/test/plugins/builtin/soci/test_discovery.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Tests for soci plugin discovery.""" - -import pytest - -from posit_bakery.plugins.registry import discover_plugins -from posit_bakery.plugins.protocol import BakeryToolPlugin - -pytestmark = [pytest.mark.unit] - - -def test_soci_plugin_is_discovered(): - plugins = discover_plugins() - assert "soci" in plugins - assert isinstance(plugins["soci"], BakeryToolPlugin) - assert plugins["soci"].name == "soci" diff --git a/posit-bakery/test/plugins/builtin/wizcli/testdata/scan_result.json b/posit-bakery/test/plugins/builtin/wizcli/testdata/scan_result.json index 18d36acba..6bba051cd 100644 --- a/posit-bakery/test/plugins/builtin/wizcli/testdata/scan_result.json +++ b/posit-bakery/test/plugins/builtin/wizcli/testdata/scan_result.json @@ -2192,4 +2192,4 @@ "discoveredResources": null }, "reportUrl": "This scan did not generate a report in the Wiz portal" -} +} \ No newline at end of file From b48a30e222584d8c8a4c4c9ffd3c20dd113a8224 Mon Sep 17 00:00:00 2001 From: "Ian H. Pittwood" Date: Tue, 16 Jun 2026 14:30:07 -0600 Subject: [PATCH 2/2] chore: fix empty line on scan_result.json --- .../test/plugins/builtin/wizcli/testdata/scan_result.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/posit-bakery/test/plugins/builtin/wizcli/testdata/scan_result.json b/posit-bakery/test/plugins/builtin/wizcli/testdata/scan_result.json index 6bba051cd..18d36acba 100644 --- a/posit-bakery/test/plugins/builtin/wizcli/testdata/scan_result.json +++ b/posit-bakery/test/plugins/builtin/wizcli/testdata/scan_result.json @@ -2192,4 +2192,4 @@ "discoveredResources": null }, "reportUrl": "This scan did not generate a report in the Wiz portal" -} \ No newline at end of file +}