diff --git a/.github/workflows/bakery-build-native.yml b/.github/workflows/bakery-build-native.yml index 0bdcff59..83f46010 100644 --- a/.github/workflows/bakery-build-native.yml +++ b/.github/workflows/bakery-build-native.yml @@ -52,6 +52,11 @@ on: default: true required: false type: boolean + enable-soci: + description: "Convert merged images to SOCI-enabled images [default: false]" + default: false + required: false + type: boolean merge-builder: description: "The type of runner to use for merging [default: ubuntu-latest-4x]" default: "ubuntu-latest-4x" @@ -110,7 +115,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install - uses: "posit-dev/images-shared/setup-bakery@main" + uses: "posit-dev/images-shared/setup-bakery@feature/soci-images" with: version: ${{ inputs.version }} @@ -147,12 +152,12 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup bakery - uses: "posit-dev/images-shared/setup-bakery@main" + uses: "posit-dev/images-shared/setup-bakery@feature/soci-images" with: version: ${{ inputs.version }} - name: Setup goss - uses: "posit-dev/images-shared/setup-goss@main" + uses: "posit-dev/images-shared/setup-goss@feature/soci-images" - name: Set up Docker uses: docker/setup-docker-action@b2189fbf2a6592b51fee7cdd93ee2bfaeba733db # v5.1.0 @@ -287,7 +292,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup bakery - uses: "posit-dev/images-shared/setup-bakery@main" + uses: "posit-dev/images-shared/setup-bakery@feature/soci-images" with: version: ${{ inputs.version }} @@ -344,6 +349,10 @@ jobs: - name: Setup ORAS CLI uses: oras-project/setup-oras@38de303aac69abb66f3e6255b7198bff35f323e3 # v2.0.0 + - name: Setup SOCI + if: ${{ inputs.enable-soci }} + uses: "posit-dev/images-shared/setup-soci@feature/soci-images" + - name: Download Metadata uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -354,18 +363,21 @@ jobs: run: | ls -ltra . - - name: Merge/Push + - name: Publish env: GIT_SHA: ${{ github.sha }} CONTEXT: ${{ inputs.context }} REGISTRY: ghcr.io/${{ github.repository_owner }} PUSH: ${{ inputs.push }} + ENABLE_SOCI: ${{ inputs.enable-soci }} run: | if [ "$PUSH" = "true" ]; then PUSH_FLAG=""; else PUSH_FLAG="--dry-run"; fi - bakery ci merge \ + if [ "$ENABLE_SOCI" = "true" ]; then SOCI_FLAG="--enable-soci"; else SOCI_FLAG="--no-enable-soci"; fi + bakery ci publish \ --context "$CONTEXT" \ --temp-registry "$REGISTRY" \ $PUSH_FLAG \ + $SOCI_FLAG \ ./*-metadata.json readme: @@ -382,7 +394,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup bakery - uses: "posit-dev/images-shared/setup-bakery@main" + uses: "posit-dev/images-shared/setup-bakery@feature/soci-images" with: version: ${{ inputs.version }} diff --git a/.github/workflows/bakery-build.yml b/.github/workflows/bakery-build.yml index a0cf09fa..1c51f69a 100644 --- a/.github/workflows/bakery-build.yml +++ b/.github/workflows/bakery-build.yml @@ -3,6 +3,12 @@ # This workflow will be called to build individual images so should be kept # as shallow as possible. +# Note: SOCI conversion is intentionally not wired into this workflow. +# Unlike bakery-build-native.yml, this QEMU-based workflow has no +# separate merge phase to inject SOCI between. Product repos that need +# SOCI should use bakery-build-native.yml instead. See +# docs/superpowers/specs/2026-05-18-soci-indexing-design.md. + name: Bakery on: @@ -101,7 +107,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install - uses: "posit-dev/images-shared/setup-bakery@main" + uses: "posit-dev/images-shared/setup-bakery@feature/soci-images" with: version: ${{ inputs.version }} @@ -138,12 +144,12 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup bakery - uses: "posit-dev/images-shared/setup-bakery@main" + uses: "posit-dev/images-shared/setup-bakery@feature/soci-images" with: version: ${{ inputs.version }} - name: Setup goss - uses: "posit-dev/images-shared/setup-goss@main" + uses: "posit-dev/images-shared/setup-goss@feature/soci-images" - name: Setup QEMU uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 @@ -269,7 +275,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup bakery - uses: "posit-dev/images-shared/setup-bakery@main" + uses: "posit-dev/images-shared/setup-bakery@feature/soci-images" with: version: ${{ inputs.version }} diff --git a/posit-bakery/posit_bakery/cli/ci.py b/posit-bakery/posit_bakery/cli/ci.py index eae6af4c..e30a43fd 100644 --- a/posit-bakery/posit_bakery/cli/ci.py +++ b/posit-bakery/posit_bakery/cli/ci.py @@ -177,22 +177,61 @@ def merge( ), ] = None, ): - """Merges multiple metadata files with single-platform images into a single multi-platform image by UID. - This command is intended for use in CI workflows that utilize native builders for multiplatform builds. - Easier multiplatform builds can be achieved by using emulation (Docker and QEMU), but builds in emulation typically - suffer severe performance disadvantages. - This command should be ran after multiple `bakery build --strategy build --platform - --metadata-file --temp-registry ` commands have been executed for different platforms. The - resulting metadata files can be fed into this command to merge and push combined multi-platform images. Matches are - made by the top-level Image UID keys in the metadata files. Single entries with no other matches will be tagged and - pushed as is. If an entry has no matching UID in the project, it will be skipped with a delayed error. - Metadata files are expected to be JSON with the following structure: - ```json - { - "": {metadata...} - } - ``` + """Alias for `bakery ci publish --no-enable-soci`. + + Preserved for back-compat. New callers should prefer `bakery ci publish`. """ + publish( + metadata_file=metadata_file, + context=context, + temp_registry=temp_registry, + enable_soci=False, + dry_run=dry_run, + dev_stream=dev_stream, + ) + + +@app.command() +@with_verbosity_flags +def publish( + 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.", rich_help_panel="Build Configuration & Outputs" + ), + ] = None, + enable_soci: Annotated[ + bool, + typer.Option("--enable-soci/--no-enable-soci", help="Run SOCI conversion between merge-create and merge-copy."), + ] = False, + dry_run: Annotated[bool, typer.Option(help="If set, no images will be pushed.")] = False, + dev_stream: Annotated[ + Optional[ReleaseStreamEnum], + typer.Option( + help="Filter development versions to a specific release stream.", rich_help_panel=RichHelpPanelEnum.FILTERS + ), + ] = None, +) -> None: + """Publish multi-platform images by composing oras index-create → + optional soci-convert → oras index-copy → cleanup. + + Replaces `bakery ci merge`; the latter is preserved as a thin alias + that calls this command with `--no-enable-soci`. + """ + # Imports kept local to mirror existing patterns and to avoid bloating + # module load time when this command isn't invoked. + from posit_bakery.plugins.builtin.oras.oras import ( + OrasIndexCleanupWorkflow, + OrasIndexCopyWorkflow, + OrasIndexCreateWorkflow, + find_oras_bin, + ) + from posit_bakery.plugins.registry import get_plugin + settings = BakerySettings( dev_versions=DevVersionInclusionEnum.INCLUDE, dev_stream=dev_stream, @@ -202,51 +241,93 @@ def merge( ) 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.glob(str(file)))) + 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(file.absolute()) + 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 file in metadata_file: + for f in metadata_file: try: - loaded_targets.extend(config.load_build_metadata_from_file(file)) + loaded_targets.extend(config.load_build_metadata_from_file(f)) except Exception as e: - log.error(f"Failed to load metadata from file '{file}'") - log.error(str(e)) + log.error(f"Failed to load metadata from file '{f}': {e}") files_ok = False - loaded_targets = list(set(loaded_targets)) # Deduplicate targets in case of overlap across files - if not files_ok: - log.error("One or more metadata files are invalid, aborting merge.") 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)) - # Imported locally for patching in CLI tests - from posit_bakery.plugins.registry import get_plugin - - oras = get_plugin("oras") - results = oras.execute(config.base_path, config.targets, dry_run=dry_run) - - # CI-specific: verify final manifests with imagetools inspect - if not dry_run: - for result in results: - if result.exit_code == 0 and result.artifacts: - workflow_result = result.artifacts.get("workflow_result") - if workflow_result and workflow_result.destinations: - manifest = python_on_whales.docker.buildx.imagetools.inspect(workflow_result.destinations[0]) - stdout_console.print_json(manifest.model_dump_json(indent=2, exclude_unset=True, exclude_none=True)) - - oras.results(results) + oras_bin = find_oras_bin(config.base_path) + targets = sorted(config.targets, key=lambda t: t.push_sort_key) + + # 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 # type: ignore[assignment] + + # Phase 2: SOCI convert (conditional). + cleanup_refs: list[str] = list(temp_refs.values()) + if enable_soci: + soci = get_plugin("soci") + soci_results = soci.execute( + config.base_path, + targets, + source_refs=temp_refs, + dry_run=dry_run, + ) + 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.results(soci_results) # raises typer.Exit(1) + if wf and getattr(wf, "destination_ref", None): + temp_refs[r.target.uid] = wf.destination_ref + cleanup_refs.append(wf.destination_ref) + + # Phase 3: index copy. + copy_failed = False + 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 + + # Phase 4: cleanup (non-fatal). + OrasIndexCleanupWorkflow(oras_bin=oras_bin).run(refs=cleanup_refs, dry_run=dry_run) + + if copy_failed: + raise typer.Exit(code=1) @app.command() diff --git a/posit-bakery/posit_bakery/plugins/builtin/oras/oras.py b/posit-bakery/posit_bakery/plugins/builtin/oras/oras.py index 8032bc0d..b9b495e9 100644 --- a/posit-bakery/posit_bakery/plugins/builtin/oras/oras.py +++ b/posit-bakery/posit_bakery/plugins/builtin/oras/oras.py @@ -160,6 +160,85 @@ def command(self) -> list[str]: return cmd +class OrasIndexCreateResult(BaseModel): + """Result of an ORAS manifest-index-create phase.""" + + success: Annotated[bool, Field(description="Whether the create phase succeeded.")] + temp_ref: Annotated[str | None, Field(default=None, description="The temp ref of the created index.")] + error: Annotated[str | None, Field(default=None, description="Error message on failure.")] + + +class OrasIndexCreateWorkflow(BaseModel): + """Create the multi-platform manifest index at the temp registry.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + oras_bin: Annotated[str, Field(description="Path to the oras binary.")] + image_target: Annotated[ImageTarget, Field(description="Target this index represents.")] + annotations: Annotated[dict[str, str], Field(default_factory=dict)] + plain_http: Annotated[bool, Field(default=False)] + + @property + def temp_index_tag(self) -> str: + source_hash = hashlib.sha256("".join(self.image_target.get_merge_sources()).encode("UTF-8")).hexdigest()[:10] + return ( + f"{self.image_target.temp_registry}/{self.image_target.image_name}/tmp:{self.image_target.uid}{source_hash}" + ) + + def run(self, dry_run: bool = False) -> OrasIndexCreateResult: + try: + OrasManifestIndexCreate( + oras_bin=self.oras_bin, + sources=self.image_target.get_merge_sources(), + destination=self.temp_index_tag, + annotations=self.annotations, + plain_http=self.plain_http, + ).run(dry_run=dry_run) + return OrasIndexCreateResult(success=True, temp_ref=self.temp_index_tag) + except BakeryToolRuntimeError as e: + log.error(f"oras index-create failed: {e}") + return OrasIndexCreateResult(success=False, temp_ref=self.temp_index_tag, error=str(e)) + + +class OrasIndexCopyResult(BaseModel): + """Result of an ORAS index-copy phase.""" + + success: Annotated[bool, Field(description="Whether all copies succeeded.")] + destinations: Annotated[list[str], Field(default_factory=list)] + error: Annotated[str | None, Field(default=None)] + + +class OrasIndexCopyWorkflow(BaseModel): + """Copy a temp-registry ref to each configured destination.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + oras_bin: Annotated[str, Field(description="Path to the oras binary.")] + image_target: Annotated[ImageTarget, Field(description="Target whose tags to fan out to.")] + plain_http: Annotated[bool, Field(default=False)] + + def run(self, source: str, dry_run: bool = False) -> OrasIndexCopyResult: + try: + destinations = [] + for destination, tags in itertools.groupby(self.image_target.tags, lambda x: x.destination): + combined = destination + ":" + ",".join(t.suffix for t in tags) + OrasCopy( + oras_bin=self.oras_bin, + source=source, + destination=combined, + plain_http=self.plain_http, + ).run(dry_run=dry_run) + destinations.append(combined) + return OrasIndexCopyResult(success=True, destinations=destinations) + except BakeryToolRuntimeError as e: + log.error(f"oras index-copy failed: {e}") + return OrasIndexCopyResult( + success=False, + destinations=self.image_target.tags.as_strings(), + error=str(e), + ) + + class OrasMergeWorkflowResult(BaseModel): """Result of an ORAS merge workflow execution.""" @@ -169,6 +248,24 @@ class OrasMergeWorkflowResult(BaseModel): error: Annotated[str | None, Field(default=None, description="Error message if the workflow failed.")] +class OrasIndexCleanupWorkflow(BaseModel): + """Delete temp manifests; non-fatal on failure.""" + + oras_bin: Annotated[str, Field(description="Path to the oras binary.")] + plain_http: Annotated[bool, Field(default=False)] + + def run(self, refs: list[str], dry_run: bool = False) -> None: + for ref in refs: + try: + OrasManifestDelete( + oras_bin=self.oras_bin, + reference=ref, + plain_http=self.plain_http, + ).run(dry_run=dry_run) + except BakeryToolRuntimeError as e: + log.warning(f"oras manifest delete of '{ref}' failed (non-fatal): {e}") + + class OrasMergeWorkflow(BaseModel): """Orchestrates the multi-platform merge workflow using oras. @@ -206,69 +303,41 @@ def temp_index_tag(self) -> str: ) def run(self, dry_run: bool = False) -> OrasMergeWorkflowResult: - """Run the merge workflow. - - :param dry_run: If True, log commands without executing them. - :return: Result of the workflow execution. - """ - + """Compose create → copy → cleanup. Preserved as a single call for + back-compat with the `bakery oras merge` CLI.""" log.info(f"Starting ORAS merge workflow for {self.image_target.image_name}") - log.debug(f"Sources: {self.sources}") - log.debug(f"Temporary index: {self.temp_index_tag}") - log.debug(f"Destinations: {', '.join(self.image_target.tags.as_strings())}") - - try: - # Step 1: Create the manifest index - log.info(f"Creating manifest index at {self.temp_index_tag}") - create_cmd = OrasManifestIndexCreate( - oras_bin=self.oras_bin, - sources=self.image_target.get_merge_sources(), - destination=self.temp_index_tag, - annotations=self.annotations, - plain_http=self.plain_http, - ) - create_cmd.run(dry_run=dry_run) - - # Step 2: Copy to all destinations - for destination, tags in itertools.groupby(self.image_target.tags, lambda x: x.destination): - log.info(f"Copying index to {destination}") - combine_tag_str = destination + ":" + ",".join(tag.suffix for tag in tags) - copy_cmd = OrasCopy( - oras_bin=self.oras_bin, - source=self.temp_index_tag, - destination=combine_tag_str, - plain_http=self.plain_http, - ) - copy_cmd.run(dry_run=dry_run) - - # Step 3: Delete the temporary index (non-fatal) - log.info(f"Cleaning up temporary index {self.temp_index_tag}") - delete_cmd = OrasManifestDelete( - oras_bin=self.oras_bin, - reference=self.temp_index_tag, - plain_http=self.plain_http, - ) - try: - delete_cmd.run(dry_run=dry_run) - except BakeryToolRuntimeError as e: - log.warning(f"Failed to clean up temporary index {self.temp_index_tag}: {e}") - - log.info(f"ORAS merge workflow completed successfully") - return OrasMergeWorkflowResult( - success=True, - temp_index_ref=self.temp_index_tag, - destinations=self.image_target.tags.as_strings(), - ) - - except BakeryToolRuntimeError as e: - log.error(f"ORAS merge workflow failed: {e}") + create = OrasIndexCreateWorkflow( + oras_bin=self.oras_bin, + image_target=self.image_target, + annotations=self.annotations, + plain_http=self.plain_http, + ).run(dry_run=dry_run) + if not create.success: return OrasMergeWorkflowResult( success=False, - temp_index_ref=self.temp_index_tag, + temp_index_ref=create.temp_ref, destinations=self.image_target.tags.as_strings(), - error=str(e), + error=create.error, ) + copy = OrasIndexCopyWorkflow( + oras_bin=self.oras_bin, + image_target=self.image_target, + plain_http=self.plain_http, + ).run(source=create.temp_ref, dry_run=dry_run) + + OrasIndexCleanupWorkflow( + oras_bin=self.oras_bin, + plain_http=self.plain_http, + ).run(refs=[create.temp_ref], dry_run=dry_run) + + return OrasMergeWorkflowResult( + success=copy.success, + temp_index_ref=create.temp_ref, + destinations=self.image_target.tags.as_strings(), + error=copy.error, + ) + @classmethod def from_image_target( cls, target: "ImageTarget", oras_bin: str | None = None, plain_http: bool = False diff --git a/posit-bakery/posit_bakery/plugins/builtin/soci/__init__.py b/posit-bakery/posit_bakery/plugins/builtin/soci/__init__.py new file mode 100644 index 00000000..587e04d4 --- /dev/null +++ b/posit-bakery/posit_bakery/plugins/builtin/soci/__init__.py @@ -0,0 +1,248 @@ +"""SOCI plugin: convert built images into SOCI-enabled images.""" + +import logging +from pathlib import Path +from typing import Any + +import typer + +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, + find_ctr_bin, + 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, + standalone: Annotated[ + bool, + typer.Option(help="Run soci convert in standalone (no-containerd) mode."), + ] = False, + 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 + By default, operates against containerd (non-standalone mode). + 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, + standalone=standalone, + ) + 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, + standalone: 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). In + standalone mode, refs are filesystem paths instead. + + 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: + results.append( + ToolCallResult( + exit_code=1, + tool_name="soci", + target=target, + stdout="", + stderr=f"no source ref provided for target '{target.uid}'", + ) + ) + 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 + + soci_bin = find_soci_bin(base_path) + ctr_bin = find_ctr_bin(base_path) + + for target, opts, ref in eligible: + workflow_standalone = opts.standalone if opts.standalone is not None else standalone + candidates = opts.candidate_namespaces or ["default", "moby"] + workflow = SociConvertWorkflow( + soci_bin=soci_bin, + ctr_bin=ctr_bin, + image_target=target, + options=opts, + source_ref=ref, + candidate_namespaces=candidates, + standalone=workflow_standalone, + ) + 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/posit_bakery/plugins/builtin/soci/options.py b/posit-bakery/posit_bakery/plugins/builtin/soci/options.py new file mode 100644 index 00000000..259a4db3 --- /dev/null +++ b/posit-bakery/posit_bakery/plugins/builtin/soci/options.py @@ -0,0 +1,68 @@ +from copy import deepcopy +from typing import Annotated, Literal + +from pydantic import Field + +from posit_bakery.config.tools.base import ToolOptions + + +class SociOptions(ToolOptions): + """Configuration options for SOCI indexing.""" + + tool: Literal["soci"] = "soci" + enabled: Annotated[ + bool, + Field(default=False, description="Enable SOCI conversion for this image."), + ] + span_size: Annotated[ + int | None, + Field(default=None, description="SOCI zTOC span size in bytes. SOCI default if None."), + ] + min_layer_size: Annotated[ + int | None, + Field(default=None, description="Minimum layer size to index. SOCI default if None."), + ] + prefetch_files: Annotated[ + list[str], + Field(default_factory=list, description="Files to mark for prefetch in the SOCI index."), + ] + optimizations: Annotated[ + list[str], + Field(default_factory=list, description="Optional optimizations (e.g. 'xattr')."), + ] + platforms: Annotated[ + list[str] | None, + Field(default=None, description="Platforms to convert. None => --all-platforms."), + ] + standalone: Annotated[ + bool | None, + Field(default=None, description="Standalone (no-containerd) mode. None => use workflow default."), + ] + candidate_namespaces: Annotated[ + list[str] | None, + Field( + default=None, + description="Containerd namespaces to probe for the source image. None => ['default', 'moby'].", + ), + ] + + def update(self, other: "SociOptions") -> "SociOptions": + """Update this SociOptions instance with settings from another. + + The merge strategy is to use the values of the other instance if the value is not explicitly set in the current + instance. + """ + merged = deepcopy(self) + for field_name in ( + "enabled", + "span_size", + "min_layer_size", + "prefetch_files", + "optimizations", + "platforms", + "standalone", + "candidate_namespaces", + ): + if field_name not in self.model_fields_set: + setattr(merged, field_name, getattr(other, field_name)) + return merged diff --git a/posit-bakery/posit_bakery/plugins/builtin/soci/soci.py b/posit-bakery/posit_bakery/plugins/builtin/soci/soci.py new file mode 100644 index 00000000..ad638264 --- /dev/null +++ b/posit-bakery/posit_bakery/plugins/builtin/soci/soci.py @@ -0,0 +1,363 @@ +"""SOCI CLI integration module.""" + +import logging +import re +import subprocess +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Annotated, Literal + +from pydantic import BaseModel, ConfigDict, Field + +from posit_bakery.error import BakeryToolRuntimeError +from posit_bakery.image.image_target import ImageTarget +from posit_bakery.plugins.builtin.soci.options import SociOptions +from posit_bakery.util import find_bin + +log = logging.getLogger(__name__) + + +def find_soci_bin(context: Path) -> str: + """Resolve a path or PATH-resident name for the soci binary. + + :param context: Project context to search for the binary in. + :return: Path to the soci binary, or the bare name "soci" when it + resolves through PATH. + :raises BakeryToolNotFoundError: If soci cannot be found. + """ + return find_bin(context, "soci", "SOCI_PATH") or "soci" + + +class SociCommand(BaseModel, ABC): + """Base class for soci CLI invocations.""" + + soci_bin: Annotated[str, Field(description="Path to the soci binary.")] + containerd_address: Annotated[ + str | None, + Field(default=None, description="containerd GRPC address. None => soci default."), + ] + containerd_namespace: Annotated[ + str, + Field(default="default", description="containerd namespace for commands."), + ] + standalone: Annotated[ + bool, + Field(default=False, description="Run without containerd (file-to-file mode)."), + ] + + @property + @abstractmethod + def command(self) -> list[str]: + """Return the full command to execute.""" + ... + + def run(self, dry_run: bool = False) -> subprocess.CompletedProcess: + """Execute the soci command. + + :param dry_run: If True, log the command without executing it. + :return: The completed process result. + :raises BakeryToolRuntimeError: On non-zero exit. + """ + cmd = self.command + log.debug(f"Executing soci command: {' '.join(cmd)}") + + if dry_run: + log.info(f"[DRY RUN] Would execute: {' '.join(cmd)}") + return subprocess.CompletedProcess(args=cmd, returncode=0, stdout=b"", stderr=b"") + + result = subprocess.run(cmd, capture_output=True) + + if result.returncode != 0: + raise BakeryToolRuntimeError( + message="soci command failed", + tool_name="soci", + cmd=cmd, + stdout=result.stdout, + stderr=result.stderr, + exit_code=result.returncode, + ) + + return result + + +class SociConvert(SociCommand): + """`soci convert` wrapper. + + Source and destination are image refs in non-standalone mode and + filesystem paths (OCI archive or directory) in standalone mode. + """ + + source: Annotated[str, Field(description="Source image ref or OCI-layout path.")] + destination: Annotated[str, Field(description="Destination image ref or OCI-layout path.")] + platforms: Annotated[ + list[str] | None, + Field(default=None, description="Platforms to convert. None => --all-platforms."), + ] + span_size: Annotated[int | None, Field(default=None, description="zTOC span size in bytes.")] + min_layer_size: Annotated[int | None, Field(default=None, description="Minimum indexed layer size.")] + prefetch_files: Annotated[list[str], Field(default_factory=list, description="Files to prefetch.")] + optimizations: Annotated[list[str], Field(default_factory=list, description="Optional optimizations.")] + force: Annotated[bool, Field(default=False, description="Force regeneration of existing zTOCs.")] + output_format: Annotated[ + Literal["oci-archive", "oci-dir"], + Field(default="oci-archive", description="Standalone-mode output layout (ignored otherwise)."), + ] + + @property + def command(self) -> list[str]: + cmd: list[str] = [self.soci_bin] + if self.containerd_address: + cmd += ["--address", self.containerd_address] + cmd += ["--namespace", self.containerd_namespace, "convert"] + if self.standalone: + cmd += ["--standalone", "--format", self.output_format] + if self.platforms: + for p in self.platforms: + cmd += ["--platform", p] + else: + cmd.append("--all-platforms") + if self.span_size is not None: + cmd += ["--span-size", str(self.span_size)] + if self.min_layer_size is not None: + cmd += ["--min-layer-size", str(self.min_layer_size)] + for f in self.prefetch_files: + cmd += ["--prefetch-file", f] + for o in self.optimizations: + cmd += ["--optimizations", o] + if self.force: + cmd.append("--force") + cmd += [self.source, self.destination] + return cmd + + +class SociPush(SociCommand): + """`soci push` wrapper: upload SOCI-enabled artifacts from containerd.""" + + image_ref: Annotated[str, Field(description="Image ref to push.")] + platforms: Annotated[ + list[str] | None, + Field(default=None, description="Platforms to push. None => --all-platforms."), + ] + existing_index: Annotated[ + Literal["warn", "skip", "allow"], + Field(default="warn", description="Behavior when a SOCI index already exists for the ref."), + ] + plain_http: Annotated[bool, Field(default=False, description="Allow plain HTTP registry connections.")] + max_concurrent_uploads: Annotated[ + int | None, + Field(default=None, description="Max concurrent uploads. SOCI default if None."), + ] + + @property + def command(self) -> list[str]: + cmd: list[str] = [self.soci_bin] + if self.containerd_address: + cmd += ["--address", self.containerd_address] + cmd += ["--namespace", self.containerd_namespace, "push"] + if self.platforms: + for p in self.platforms: + cmd += ["--platform", p] + else: + cmd.append("--all-platforms") + cmd += ["--existing-index", self.existing_index] + if self.plain_http: + cmd.append("--plain-http") + if self.max_concurrent_uploads is not None: + cmd += ["--max-concurrent-uploads", str(self.max_concurrent_uploads)] + cmd.append(self.image_ref) + return cmd + + +IMAGE_NOT_FOUND_RE = re.compile(rb'image "[^"]+": not found') +"""Matches soci's canonical "image not found" error so namespace probes can +distinguish a missing image from a real error.""" + + +def find_ctr_bin(context: Path) -> str: + """Resolve a path or PATH-resident name for the ctr binary. + + :param context: Project context to search for the binary in. + :return: Path to the ctr binary, or the bare name "ctr" when it + resolves through PATH. + :raises BakeryToolNotFoundError: If ctr cannot be found. + """ + return find_bin(context, "ctr", "CTR_PATH") or "ctr" + + +class ContainerdImagePull(BaseModel): + """`ctr image pull` wrapper. + + Not a SociCommand because it shells out to containerd's CLI rather than + soci itself, but the surface is similar enough to keep it in this module + (it only exists to serve the SOCI workflow). + """ + + ctr_bin: Annotated[str, Field(description="Path to the ctr binary.")] + containerd_address: Annotated[str | None, Field(default=None)] + containerd_namespace: Annotated[str, Field(default="default")] + image_ref: Annotated[str, Field(description="Image ref to pull.")] + all_platforms: Annotated[ + bool, + Field( + default=False, + description="Pass --all-platforms; default ctr behavior is multi-platform without it, but explicit is safer.", + ), + ] + + @property + def command(self) -> list[str]: + cmd: list[str] = [self.ctr_bin] + if self.containerd_address: + cmd += ["--address", self.containerd_address] + cmd += ["--namespace", self.containerd_namespace, "image", "pull"] + if self.all_platforms: + cmd.append("--all-platforms") + cmd.append(self.image_ref) + return cmd + + def run(self, dry_run: bool = False) -> subprocess.CompletedProcess: + """Execute the ctr image pull command. + + :param dry_run: If True, log the command without executing it. + :return: The completed process result. + :raises BakeryToolRuntimeError: On non-zero exit. + """ + cmd = self.command + log.debug(f"Executing ctr command: {' '.join(cmd)}") + + if dry_run: + log.info(f"[DRY RUN] Would execute: {' '.join(cmd)}") + return subprocess.CompletedProcess(args=cmd, returncode=0, stdout=b"", stderr=b"") + + result = subprocess.run(cmd, capture_output=True) + + if result.returncode != 0: + raise BakeryToolRuntimeError( + message="ctr image pull failed", + tool_name="ctr", + cmd=cmd, + stdout=result.stdout, + stderr=result.stderr, + exit_code=result.returncode, + ) + + return result + + +class SociConvertWorkflowResult(BaseModel): + success: Annotated[bool, Field(description="Whether the workflow completed successfully.")] + destination_ref: Annotated[str | None, Field(default=None, description="SOCI-enabled destination ref.")] + resolved_namespace: Annotated[ + str | None, Field(default=None, description="Containerd namespace that held the source.") + ] + error: Annotated[str | None, Field(default=None, description="Error message if the workflow failed.")] + + +class SociConvertWorkflow(BaseModel): + """Pull a source ref into containerd, convert it to SOCI, and push back.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + soci_bin: Annotated[str, Field(description="Path to the soci binary.")] + ctr_bin: Annotated[str, Field(description="Path to the ctr binary.")] + image_target: Annotated[ImageTarget, Field(description="The image target.")] + options: Annotated[SociOptions, Field(description="Per-target SOCI configuration.")] + source_ref: Annotated[str, Field(description="Temp-registry ref to convert from.")] + candidate_namespaces: Annotated[ + list[str], + Field(default_factory=lambda: ["default", "moby"], description="Namespaces to probe."), + ] + standalone: Annotated[bool, Field(default=False, description="Standalone (no-containerd) mode.")] + + @property + def destination_ref(self) -> str: + return f"{self.source_ref}-soci" + + def _build_convert(self, namespace: str) -> SociConvert: + return SociConvert( + soci_bin=self.soci_bin, + containerd_namespace=namespace, + standalone=self.standalone, + source=self.source_ref, + destination=self.destination_ref, + platforms=self.options.platforms, + span_size=self.options.span_size, + min_layer_size=self.options.min_layer_size, + prefetch_files=self.options.prefetch_files, + optimizations=self.options.optimizations, + ) + + def _build_push(self, namespace: str) -> SociPush: + return SociPush( + soci_bin=self.soci_bin, + containerd_namespace=namespace, + image_ref=self.destination_ref, + platforms=self.options.platforms, + ) + + def run(self, dry_run: bool = False) -> SociConvertWorkflowResult: + """Materialize source (if non-standalone), convert, and push.""" + if self.standalone: + try: + self._build_convert(self.candidate_namespaces[0]).run(dry_run=dry_run) + except BakeryToolRuntimeError as e: + return SociConvertWorkflowResult( + success=False, + destination_ref=self.destination_ref, + resolved_namespace=None, + error=e.dump_stderr() or str(e), + ) + return SociConvertWorkflowResult( + success=True, + destination_ref=self.destination_ref, + resolved_namespace=None, + ) + + last_error: str | None = None + last_ns: str | None = None + for ns in self.candidate_namespaces: + last_ns = ns + try: + ContainerdImagePull( + ctr_bin=self.ctr_bin, + containerd_namespace=ns, + image_ref=self.source_ref, + all_platforms=True, + ).run(dry_run=dry_run) + except BakeryToolRuntimeError as e: + if e.stderr and IMAGE_NOT_FOUND_RE.search(e.stderr): + last_error = f'image "{self.source_ref}": not found in namespace "{ns}"' + log.debug(last_error) + continue + log.error(f"SOCI workflow: ctr pull failed: {e}") + return SociConvertWorkflowResult( + success=False, + destination_ref=self.destination_ref, + resolved_namespace=ns, + error=e.dump_stderr() or str(e), + ) + + try: + self._build_convert(ns).run(dry_run=dry_run) + self._build_push(ns).run(dry_run=dry_run) + except BakeryToolRuntimeError as e: + log.error(f"SOCI workflow: convert/push failed in namespace '{ns}': {e}") + return SociConvertWorkflowResult( + success=False, + destination_ref=self.destination_ref, + resolved_namespace=ns, + error=e.dump_stderr() or str(e), + ) + + return SociConvertWorkflowResult( + success=True, + destination_ref=self.destination_ref, + resolved_namespace=ns, + ) + + return SociConvertWorkflowResult( + success=False, + destination_ref=self.destination_ref, + resolved_namespace=last_ns, + error=last_error or "image not found in any candidate namespace", + ) diff --git a/posit-bakery/pyproject.toml b/posit-bakery/pyproject.toml index 67fc3b9f..23a5e160 100644 --- a/posit-bakery/pyproject.toml +++ b/posit-bakery/pyproject.toml @@ -60,6 +60,7 @@ dgoss = "posit_bakery.plugins.builtin.dgoss:DGossPlugin" oras = "posit_bakery.plugins.builtin.oras:OrasPlugin" 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 4c2992ea..f8786c7a 100644 --- a/posit-bakery/test/cli/test_ci.py +++ b/posit-bakery/test/cli/test_ci.py @@ -42,34 +42,58 @@ def copy_ci_testdata_to_context(bakery_command, ci_testdata, testdata_path): def patch_image_target_merge_method(mocker): calls = [] - def patched_execute(base_path, targets, platform=None, **kwargs): - results = [] - for target in targets: - try: - sources = target.get_merge_sources() - except Exception: - continue - if not sources: - continue - dry_run = kwargs.get("dry_run", False) + # Track calls to OrasIndexCreateWorkflow and OrasIndexCopyWorkflow + class MockOrasIndexCreateWorkflow: + def __init__(self, oras_bin, image_target, annotations, plain_http=False): + self.image_target = image_target + self.oras_bin = oras_bin + self.annotations = annotations + self.plain_http = plain_http + + def run(self, dry_run=False): + sources = self.image_target.get_merge_sources() calls.append((sources, dry_run)) - results.append( - ToolCallResult( - exit_code=0, - tool_name="oras", - target=target, - stdout="", - stderr="", - artifacts={"workflow_result": MagicMock(success=True, destinations=[])}, - ) - ) - return results - - mock_plugin = MagicMock() - mock_plugin.execute = patched_execute - mock_plugin.results = MagicMock() - - mocker.patch("posit_bakery.plugins.registry.get_plugin", return_value=mock_plugin) + result = MagicMock() + result.success = True + result.temp_ref = f"temp-ref-{self.image_target.uid}" + return result + + class MockOrasIndexCopyWorkflow: + def __init__(self, oras_bin, image_target): + self.image_target = image_target + self.oras_bin = oras_bin + + def run(self, source, dry_run=False): + result = MagicMock() + result.success = True + return result + + class MockOrasIndexCleanupWorkflow: + def __init__(self, oras_bin): + self.oras_bin = oras_bin + + def run(self, refs, dry_run=False): + result = MagicMock() + result.success = True + return result + + # Patch the imports inside the publish function + mocker.patch( + "posit_bakery.plugins.builtin.oras.oras.OrasIndexCreateWorkflow", + MockOrasIndexCreateWorkflow, + ) + mocker.patch( + "posit_bakery.plugins.builtin.oras.oras.OrasIndexCopyWorkflow", + MockOrasIndexCopyWorkflow, + ) + mocker.patch( + "posit_bakery.plugins.builtin.oras.oras.OrasIndexCleanupWorkflow", + MockOrasIndexCleanupWorkflow, + ) + mocker.patch( + "posit_bakery.plugins.builtin.oras.oras.find_oras_bin", + return_value="/mock/oras", + ) return calls diff --git a/posit-bakery/test/cli/test_ci_merge_alias.py b/posit-bakery/test/cli/test_ci_merge_alias.py new file mode 100644 index 00000000..f3acda53 --- /dev/null +++ b/posit-bakery/test/cli/test_ci_merge_alias.py @@ -0,0 +1,22 @@ +"""Tests for the `bakery ci merge` back-compat alias.""" + +from unittest.mock import patch + +import pytest +from typer.testing import CliRunner + +from posit_bakery.cli.main import app + +pytestmark = [pytest.mark.unit] + + +def test_merge_delegates_to_publish_with_soci_disabled(tmp_path): + runner = CliRunner() + metadata_file = tmp_path / "fake-metadata.json" + metadata_file.write_text("{}") + + with patch("posit_bakery.cli.ci.publish") as mock_publish: + runner.invoke(app, ["ci", "merge", str(metadata_file)]) + assert mock_publish.called + call_kwargs = mock_publish.call_args.kwargs + assert call_kwargs.get("enable_soci") is False diff --git a/posit-bakery/test/cli/test_ci_publish.py b/posit-bakery/test/cli/test_ci_publish.py new file mode 100644 index 00000000..7d20a1d9 --- /dev/null +++ b/posit-bakery/test/cli/test_ci_publish.py @@ -0,0 +1,29 @@ +"""Tests for the `bakery ci publish` orchestrator.""" + +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 long option +# names (e.g. ``--enable-soci/--no-enable-soci``) 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_publish_help_lists_command(): + runner = CliRunner() + result = runner.invoke(app, ["ci", "--help"], env=_WIDE_TERM_ENV) + assert result.exit_code == 0 + assert "publish" in result.stdout + + +def test_publish_command_flags_present(): + runner = CliRunner() + result = runner.invoke(app, ["ci", "publish", "--help"], env=_WIDE_TERM_ENV) + assert result.exit_code == 0 + assert "--enable-soci" in result.stdout + assert "--temp-registry" in result.stdout + assert "--dry-run" in result.stdout diff --git a/posit-bakery/test/features/cli/ci/merge.feature b/posit-bakery/test/features/cli/ci/merge.feature index c4e5257d..7849ff95 100644 --- a/posit-bakery/test/features/cli/ci/merge.feature +++ b/posit-bakery/test/features/cli/ci/merge.feature @@ -5,7 +5,9 @@ Feature: merge Given I call bakery ci merge * in a temp multiplatform context * with the arguments: - | *-metadata.json | + | --temp-registry | + | ghcr.io/posit-dev/temp | + | *-metadata.json | * with testdata ci/merge/multiplatform copied to context * with the context as the working directory * with image target merge method patched diff --git a/posit-bakery/test/plugins/builtin/oras/test_oras.py b/posit-bakery/test/plugins/builtin/oras/test_oras.py index a51e9e30..e82f0b77 100644 --- a/posit-bakery/test/plugins/builtin/oras/test_oras.py +++ b/posit-bakery/test/plugins/builtin/oras/test_oras.py @@ -13,6 +13,9 @@ find_oras_bin, get_repository_from_ref, OrasCopy, + OrasIndexCleanupWorkflow, + OrasIndexCopyWorkflow, + OrasIndexCreateWorkflow, OrasManifestDelete, OrasManifestIndexCreate, OrasMergeWorkflow, @@ -531,6 +534,75 @@ def test_manifest_delete_with_plain_http(self): assert cmd.command == expected +@pytest.fixture +def mock_image_target_factory(): + def _make(): + t = MagicMock(spec=ImageTarget) + t.image_name = "test-image" + t.uid = "test-image-1-0-0" + t.temp_registry = "ghcr.io/posit-dev" + t.get_merge_sources.return_value = [ + "ghcr.io/posit-dev/test/tmp@sha256:amd64digest", + "ghcr.io/posit-dev/test/tmp@sha256:arm64digest", + ] + t.labels = {"org.opencontainers.image.title": "Test Image"} + tag1 = MagicMock() + tag1.destination = "ghcr.io/posit-dev/test-image" + tag1.suffix = "1.0.0" + tag1.__str__ = lambda self: "ghcr.io/posit-dev/test-image:1.0.0" + t.tags = StringableList([tag1]) + return t + + return _make + + +class TestOrasIndexCreateWorkflow: + """Tests for the standalone index-create primitive.""" + + @pytest.fixture + def workflow(self, mock_image_target_factory): + target = mock_image_target_factory() + return OrasIndexCreateWorkflow( + oras_bin="oras", + image_target=target, + annotations={"k": "v"}, + ) + + def test_creates_index_at_temp_ref(self, workflow): + with patch("subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout=b"", stderr=b"") + result = workflow.run() + assert result.success is True + assert result.temp_ref == workflow.temp_index_tag + # exactly one subprocess call: oras manifest index create + assert mock_run.call_count == 1 + cmd = mock_run.call_args.args[0] + assert cmd[:4] == ["oras", "manifest", "index", "create"] + + +class TestOrasIndexCopyWorkflow: + """Tests for the standalone index-copy primitive.""" + + def test_copies_to_each_destination_grouped_by_repo(self, mock_image_target_factory): + target = mock_image_target_factory() + # Add a second-registry tag so we exercise the grouping. + extra_tag = MagicMock() + extra_tag.destination = "docker.io/posit/test-image" + extra_tag.suffix = "1.0.0" + extra_tag.__str__ = lambda self: "docker.io/posit/test-image:1.0.0" + target.tags.append(extra_tag) + + workflow = OrasIndexCopyWorkflow(oras_bin="oras", image_target=target) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout=b"", stderr=b"") + result = workflow.run(source="ghcr.io/posit-dev/test-image/tmp:src") + + assert result.success is True + # Two distinct destination repos => two oras cp invocations. + assert mock_run.call_count == 2 + + @pytest.mark.slow class TestOrasMergeWorkflowIntegration: """End-to-end tests for ORAS merge workflow using a local registry container. @@ -603,3 +675,16 @@ def test_from_image_target_with_plain_http(self, mock_image_target_for_local_reg assert workflow.plain_http is True assert workflow.oras_bin == "oras" + + +class TestOrasIndexCleanupWorkflow: + def test_deletes_all_refs_and_swallows_failures(self): + workflow = OrasIndexCleanupWorkflow(oras_bin="oras") + with patch("subprocess.run") as mock_run: + # First delete fails, second succeeds — cleanup must not raise. + mock_run.side_effect = [ + subprocess.CompletedProcess(args=[], returncode=1, stdout=b"", stderr=b"boom"), + subprocess.CompletedProcess(args=[], returncode=0, stdout=b"", stderr=b""), + ] + workflow.run(refs=["a", "b"]) + assert mock_run.call_count == 2 diff --git a/posit-bakery/test/plugins/builtin/soci/__init__.py b/posit-bakery/test/plugins/builtin/soci/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/posit-bakery/test/plugins/builtin/soci/test_cli.py b/posit-bakery/test/plugins/builtin/soci/test_cli.py new file mode 100644 index 00000000..aed5764f --- /dev/null +++ b/posit-bakery/test/plugins/builtin/soci/test_cli.py @@ -0,0 +1,26 @@ +"""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 diff --git a/posit-bakery/test/plugins/builtin/soci/test_command_base.py b/posit-bakery/test/plugins/builtin/soci/test_command_base.py new file mode 100644 index 00000000..ff3a8bf8 --- /dev/null +++ b/posit-bakery/test/plugins/builtin/soci/test_command_base.py @@ -0,0 +1,74 @@ +"""Tests for the SociCommand base class and find_soci_bin.""" + +import os +import subprocess +from pathlib import Path +from unittest.mock import patch + +import pytest +from pydantic import Field +from typing import Annotated + +from posit_bakery.error import BakeryToolNotFoundError, BakeryToolRuntimeError +from posit_bakery.plugins.builtin.soci.soci import SociCommand, find_soci_bin + +pytestmark = [pytest.mark.unit] + + +class _StubSociCommand(SociCommand): + """Concrete SociCommand used to exercise the base class .run() path.""" + + arg: Annotated[str, Field(description="A stub argument.")] + + @property + def command(self) -> list[str]: + return [self.soci_bin, "stub", self.arg] + + +def test_run_success(): + cmd = _StubSociCommand(soci_bin="soci", arg="x") + with patch("subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess(args=cmd.command, returncode=0, stdout=b"ok", stderr=b"") + result = cmd.run() + mock_run.assert_called_once_with(cmd.command, capture_output=True) + assert result.returncode == 0 + + +def test_run_failure_raises_tool_error(): + cmd = _StubSociCommand(soci_bin="soci", arg="x") + with patch("subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess(args=cmd.command, returncode=2, stdout=b"", stderr=b"boom") + with pytest.raises(BakeryToolRuntimeError) as exc: + cmd.run() + assert exc.value.tool_name == "soci" + assert exc.value.exit_code == 2 + + +def test_dry_run_does_not_invoke_subprocess(): + cmd = _StubSociCommand(soci_bin="soci", arg="x") + with patch("subprocess.run") as mock_run: + result = cmd.run(dry_run=True) + mock_run.assert_not_called() + assert result.returncode == 0 + + +def test_find_soci_bin_uses_env_var(tmp_path, monkeypatch): + monkeypatch.setenv("SOCI_PATH", "/custom/soci") + assert find_soci_bin(tmp_path) == "/custom/soci" + + +def test_find_soci_bin_falls_back_to_path_when_present(tmp_path, monkeypatch): + monkeypatch.delenv("SOCI_PATH", raising=False) + with patch("posit_bakery.util.which") as mock_which: + mock_which.return_value = "/usr/local/bin/soci" + # find_bin returns None when 'which' resolves, signaling "use the + # bare name on PATH". find_soci_bin normalizes that to "soci". + assert find_soci_bin(tmp_path) == "soci" + + +def test_find_soci_bin_raises_when_missing(tmp_path, monkeypatch): + monkeypatch.delenv("SOCI_PATH", raising=False) + with patch("posit_bakery.util.which") as mock_which: + mock_which.return_value = None + with pytest.raises(BakeryToolNotFoundError): + find_soci_bin(tmp_path) diff --git a/posit-bakery/test/plugins/builtin/soci/test_convert.py b/posit-bakery/test/plugins/builtin/soci/test_convert.py new file mode 100644 index 00000000..751e47a1 --- /dev/null +++ b/posit-bakery/test/plugins/builtin/soci/test_convert.py @@ -0,0 +1,97 @@ +"""Tests for the SociConvert command wrapper.""" + +import pytest + +from posit_bakery.plugins.builtin.soci.soci import SociConvert + +pytestmark = [pytest.mark.unit] + + +def test_default_non_standalone_command(): + cmd = SociConvert( + soci_bin="soci", + source="ghcr.io/posit-dev/test/tmp:src", + destination="ghcr.io/posit-dev/test/tmp:src-soci", + ) + assert cmd.command == [ + "soci", + "--namespace", + "default", + "convert", + "--all-platforms", + "ghcr.io/posit-dev/test/tmp:src", + "ghcr.io/posit-dev/test/tmp:src-soci", + ] + + +def test_with_explicit_namespace_and_address(): + cmd = SociConvert( + soci_bin="/opt/soci", + containerd_address="/run/containerd/alt.sock", + containerd_namespace="moby", + source="src", + destination="dst", + ) + assert cmd.command == [ + "/opt/soci", + "--address", + "/run/containerd/alt.sock", + "--namespace", + "moby", + "convert", + "--all-platforms", + "src", + "dst", + ] + + +def test_with_specific_platforms_and_options(): + cmd = SociConvert( + soci_bin="soci", + source="src", + destination="dst", + platforms=["linux/amd64", "linux/arm64"], + span_size=4 * 1024 * 1024, + min_layer_size=10 * 1024 * 1024, + prefetch_files=["/a", "/b"], + optimizations=["xattr"], + force=True, + ) + assert cmd.command == [ + "soci", + "--namespace", + "default", + "convert", + "--platform", + "linux/amd64", + "--platform", + "linux/arm64", + "--span-size", + "4194304", + "--min-layer-size", + "10485760", + "--prefetch-file", + "/a", + "--prefetch-file", + "/b", + "--optimizations", + "xattr", + "--force", + "src", + "dst", + ] + + +def test_standalone_mode_includes_flag_and_format(): + cmd = SociConvert( + soci_bin="soci", + source="./img.tar", + destination="./img-soci.tar", + standalone=True, + output_format="oci-archive", + ) + assert "--standalone" in cmd.command + assert "--format" in cmd.command + assert "oci-archive" in cmd.command + # namespace flag is still emitted even in standalone — soci ignores it + # there; we keep the construction uniform. diff --git a/posit-bakery/test/plugins/builtin/soci/test_ctr_pull.py b/posit-bakery/test/plugins/builtin/soci/test_ctr_pull.py new file mode 100644 index 00000000..d9f6ba48 --- /dev/null +++ b/posit-bakery/test/plugins/builtin/soci/test_ctr_pull.py @@ -0,0 +1,77 @@ +"""Tests for the ContainerdImagePull helper.""" + +import subprocess +from unittest.mock import patch + +import pytest + +from posit_bakery.error import BakeryToolRuntimeError +from posit_bakery.plugins.builtin.soci.soci import ContainerdImagePull, IMAGE_NOT_FOUND_RE + +pytestmark = [pytest.mark.unit] + + +def test_default_command(): + cmd = ContainerdImagePull(ctr_bin="ctr", image_ref="reg/img:tag") + assert cmd.command == [ + "ctr", + "--namespace", + "default", + "image", + "pull", + "reg/img:tag", + ] + + +def test_with_namespace_address_and_platform(): + cmd = ContainerdImagePull( + ctr_bin="/usr/local/bin/ctr", + containerd_address="/run/containerd/alt.sock", + containerd_namespace="moby", + image_ref="reg/img:tag", + all_platforms=True, + ) + assert cmd.command == [ + "/usr/local/bin/ctr", + "--address", + "/run/containerd/alt.sock", + "--namespace", + "moby", + "image", + "pull", + "--all-platforms", + "reg/img:tag", + ] + + +def test_run_success(): + cmd = ContainerdImagePull(ctr_bin="ctr", image_ref="reg/img:tag") + with patch("subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess(args=cmd.command, returncode=0, stdout=b"", stderr=b"") + cmd.run() + mock_run.assert_called_once_with(cmd.command, capture_output=True) + + +def test_run_failure_raises(): + cmd = ContainerdImagePull(ctr_bin="ctr", image_ref="reg/img:tag") + with patch("subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess(args=cmd.command, returncode=1, stdout=b"", stderr=b"boom") + with pytest.raises(BakeryToolRuntimeError): + cmd.run() + + +def test_image_not_found_regex_matches_canonical_message(): + sample = b'soci: image "ghcr.io/posit-dev/test:tag": not found' + assert IMAGE_NOT_FOUND_RE.search(sample) is not None + + +def test_image_not_found_regex_does_not_match_unrelated_errors(): + assert IMAGE_NOT_FOUND_RE.search(b"some other error") is None + + +def test_dry_run_does_not_invoke_subprocess(): + cmd = ContainerdImagePull(ctr_bin="ctr", image_ref="reg/img:tag") + with patch("subprocess.run") as mock_run: + result = cmd.run(dry_run=True) + mock_run.assert_not_called() + assert result.returncode == 0 diff --git a/posit-bakery/test/plugins/builtin/soci/test_discovery.py b/posit-bakery/test/plugins/builtin/soci/test_discovery.py new file mode 100644 index 00000000..888f6097 --- /dev/null +++ b/posit-bakery/test/plugins/builtin/soci/test_discovery.py @@ -0,0 +1,15 @@ +"""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/soci/test_options.py b/posit-bakery/test/plugins/builtin/soci/test_options.py new file mode 100644 index 00000000..8d4b3a28 --- /dev/null +++ b/posit-bakery/test/plugins/builtin/soci/test_options.py @@ -0,0 +1,91 @@ +"""Tests for SociOptions.""" + +import pytest + +from posit_bakery.plugins.builtin.soci.options import SociOptions + +pytestmark = [pytest.mark.unit] + + +def test_defaults(): + opts = SociOptions() + assert opts.tool == "soci" + assert opts.enabled is False + assert opts.span_size is None + assert opts.min_layer_size is None + assert opts.prefetch_files == [] + assert opts.optimizations == [] + assert opts.platforms is None + assert opts.standalone is None + assert opts.candidate_namespaces is None + + +def test_overrides(): + opts = SociOptions( + enabled=True, + span_size=4 * 1024 * 1024, + min_layer_size=10 * 1024 * 1024, + prefetch_files=["/a", "/b"], + optimizations=["xattr"], + platforms=["linux/amd64"], + standalone=False, + candidate_namespaces=["moby"], + ) + assert opts.enabled is True + assert opts.span_size == 4 * 1024 * 1024 + assert opts.min_layer_size == 10 * 1024 * 1024 + assert opts.prefetch_files == ["/a", "/b"] + assert opts.optimizations == ["xattr"] + assert opts.platforms == ["linux/amd64"] + assert opts.standalone is False + assert opts.candidate_namespaces == ["moby"] + + +def test_update_other_wins_when_self_unset(): + base = SociOptions() + override = SociOptions(enabled=True, span_size=8 * 1024 * 1024) + merged = base.update(override) + assert merged.enabled is True + assert merged.span_size == 8 * 1024 * 1024 + + +def test_update_self_wins_when_explicitly_set(): + base = SociOptions(enabled=True, span_size=16 * 1024 * 1024) + override = SociOptions(enabled=False, span_size=8 * 1024 * 1024) + merged = base.update(override) + assert merged.enabled is True + assert merged.span_size == 16 * 1024 * 1024 + + +def test_update_other_wins_for_list_fields_when_self_unset(): + base = SociOptions() + override = SociOptions(prefetch_files=["/a"], optimizations=["xattr"]) + merged = base.update(override) + assert merged.prefetch_files == ["/a"] + assert merged.optimizations == ["xattr"] + + +def test_update_self_wins_for_list_fields_when_explicitly_set(): + base = SociOptions(prefetch_files=["/x"], optimizations=["yyy"]) + override = SociOptions(prefetch_files=["/a"], optimizations=["xattr"]) + merged = base.update(override) + assert merged.prefetch_files == ["/x"] + assert merged.optimizations == ["yyy"] + + +def test_update_scalar_explicitly_set_to_default(): + """User explicitly sets a scalar to its default value; self should still win.""" + base = SociOptions(enabled=False) # explicitly set to default False + override = SociOptions(enabled=True) + merged = base.update(override) + # enabled is in base.model_fields_set, so base's value should win + assert merged.enabled is False + + +def test_update_list_explicitly_set_to_empty(): + """User explicitly sets a list to empty (its default); self should still win.""" + base = SociOptions(prefetch_files=[]) # explicitly set to [] + override = SociOptions(prefetch_files=["/a"]) + merged = base.update(override) + # prefetch_files is in base.model_fields_set, so base's value should win + assert merged.prefetch_files == [] diff --git a/posit-bakery/test/plugins/builtin/soci/test_plugin_execute.py b/posit-bakery/test/plugins/builtin/soci/test_plugin_execute.py new file mode 100644 index 00000000..08290a7c --- /dev/null +++ b/posit-bakery/test/plugins/builtin/soci/test_plugin_execute.py @@ -0,0 +1,131 @@ +"""Tests for SociPlugin.execute().""" + +import subprocess +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +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 + +pytestmark = [pytest.mark.unit] + + +def _make_target(uid: str, enabled: bool, image_name: str = "test-image") -> ImageTarget: + t = MagicMock(spec=ImageTarget) + t.uid = uid + t.image_name = image_name + t.temp_registry = "ghcr.io/posit-dev" + t.__str__ = lambda self: f"ImageTarget({uid})" + # Plugin reads SociOptions from target.image_version.parent.options or + # target.image_variant.options. For unit testing the plugin's gating + # behavior we let the plugin call get_soci_options(target) which we + # patch out via the helper exposed on the plugin module. + return t + + +def test_skips_targets_without_enabled_option(tmp_path): + plugin = SociPlugin() + t_off = _make_target("a", enabled=False) + t_on = _make_target("b", enabled=True) + + def fake_options(target): + return SociOptions(enabled=(target.uid == "b")) + + with ( + patch( + "posit_bakery.plugins.builtin.soci.get_soci_options_for_target", + side_effect=fake_options, + ), + patch( + "posit_bakery.plugins.builtin.soci.find_soci_bin", + return_value="soci", + ), + patch( + "posit_bakery.plugins.builtin.soci.find_ctr_bin", + return_value="ctr", + ), + patch("subprocess.run") as mock_run, + ): + mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout=b"", stderr=b"") + # source_ref is provided via kwargs from the orchestrator. For the + # test we set it explicitly via per-target kwargs map. + results = plugin.execute( + base_path=tmp_path, + targets=[t_off, t_on], + source_refs={"a": "ref-a", "b": "ref-b"}, + ) + + assert len(results) == 2 + off_result = next(r for r in results if r.target.uid == "a") + on_result = next(r for r in results if r.target.uid == "b") + assert off_result.exit_code == 0 + assert off_result.artifacts is not None + assert off_result.artifacts.get("skipped") is True + assert on_result.exit_code == 0 + assert on_result.artifacts is not None + assert on_result.artifacts["workflow_result"].success is True + + +def test_logs_summary_when_no_enabled_targets(tmp_path, caplog): + plugin = SociPlugin() + t = _make_target("a", enabled=False) + + import logging + + caplog.set_level(logging.INFO, logger="posit_bakery.plugins.builtin.soci") + with ( + patch( + "posit_bakery.plugins.builtin.soci.get_soci_options_for_target", + return_value=SociOptions(enabled=False), + ), + patch( + "posit_bakery.plugins.builtin.soci.find_soci_bin", + return_value="soci", + ), + patch( + "posit_bakery.plugins.builtin.soci.find_ctr_bin", + return_value="ctr", + ), + ): + results = plugin.execute( + base_path=tmp_path, + targets=[t], + source_refs={"a": "ref-a"}, + ) + + assert len(results) == 1 + assert results[0].artifacts.get("skipped") is True + assert "no targets have soci enabled" in caplog.text.lower() + + +def test_no_eligible_targets_does_not_invoke_binary_lookup(tmp_path): + """When all targets are disabled, execute should not require soci/ctr + binaries to be installed — the lookups should be skipped.""" + plugin = SociPlugin() + t = _make_target("a", enabled=False) + + with ( + patch( + "posit_bakery.plugins.builtin.soci.get_soci_options_for_target", + return_value=SociOptions(enabled=False), + ), + patch( + "posit_bakery.plugins.builtin.soci.find_soci_bin", + ) as mock_find_soci, + patch( + "posit_bakery.plugins.builtin.soci.find_ctr_bin", + ) as mock_find_ctr, + ): + results = plugin.execute( + base_path=tmp_path, + targets=[t], + source_refs={"a": "ref-a"}, + ) + + assert len(results) == 1 + assert results[0].artifacts.get("skipped") is True + mock_find_soci.assert_not_called() + mock_find_ctr.assert_not_called() diff --git a/posit-bakery/test/plugins/builtin/soci/test_plugin_results.py b/posit-bakery/test/plugins/builtin/soci/test_plugin_results.py new file mode 100644 index 00000000..37737c85 --- /dev/null +++ b/posit-bakery/test/plugins/builtin/soci/test_plugin_results.py @@ -0,0 +1,59 @@ +"""Tests for SociPlugin.results().""" + +from unittest.mock import MagicMock + +import pytest +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.protocol import ToolCallResult + +pytestmark = [pytest.mark.unit] + + +def _result(exit_code: int, workflow_success: bool, target_uid: str = "t") -> ToolCallResult: + target = MagicMock(spec=ImageTarget) + target.uid = target_uid + target.__str__ = lambda self: f"ImageTarget({target_uid})" + return ToolCallResult( + exit_code=exit_code, + tool_name="soci", + target=target, + stdout="", + stderr="failure" if exit_code else "", + artifacts={ + "workflow_result": SociConvertWorkflowResult( + success=workflow_success, + destination_ref="ref-soci", + resolved_namespace="default", + error=None if workflow_success else "failure", + ) + }, + ) + + +def test_all_success_does_not_raise(): + SociPlugin().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")]) + assert exc.value.exit_code == 1 + + +def test_skipped_results_do_not_raise(): + target = MagicMock(spec=ImageTarget) + target.uid = "s" + target.__str__ = lambda self: "ImageTarget(s)" + skipped = ToolCallResult( + exit_code=0, + tool_name="soci", + target=target, + stdout="", + stderr="", + artifacts={"skipped": True, "reason": "soci.enabled is false"}, + ) + SociPlugin().results([skipped]) diff --git a/posit-bakery/test/plugins/builtin/soci/test_push.py b/posit-bakery/test/plugins/builtin/soci/test_push.py new file mode 100644 index 00000000..a2c1b036 --- /dev/null +++ b/posit-bakery/test/plugins/builtin/soci/test_push.py @@ -0,0 +1,85 @@ +"""Tests for the SociPush command wrapper.""" + +import pytest + +from posit_bakery.plugins.builtin.soci.soci import SociPush + +pytestmark = [pytest.mark.unit] + + +def test_default_command(): + cmd = SociPush(soci_bin="soci", image_ref="ghcr.io/posit-dev/test:soci") + assert cmd.command == [ + "soci", + "--namespace", + "default", + "push", + "--all-platforms", + "--existing-index", + "warn", + "ghcr.io/posit-dev/test:soci", + ] + + +def test_with_namespace_platforms_and_skip_existing(): + cmd = SociPush( + soci_bin="soci", + containerd_namespace="moby", + image_ref="reg/img:tag", + platforms=["linux/amd64"], + existing_index="skip", + plain_http=True, + max_concurrent_uploads=5, + ) + assert cmd.command == [ + "soci", + "--namespace", + "moby", + "push", + "--platform", + "linux/amd64", + "--existing-index", + "skip", + "--plain-http", + "--max-concurrent-uploads", + "5", + "reg/img:tag", + ] + + +def test_with_containerd_address(): + cmd = SociPush( + soci_bin="soci", + containerd_address="/run/containerd/alt.sock", + image_ref="reg/img:tag", + ) + assert cmd.command == [ + "soci", + "--address", + "/run/containerd/alt.sock", + "--namespace", + "default", + "push", + "--all-platforms", + "--existing-index", + "warn", + "reg/img:tag", + ] + + +def test_existing_index_allow(): + cmd = SociPush( + soci_bin="soci", + image_ref="reg/img:tag", + existing_index="allow", + ) + assert cmd.command == [ + "soci", + "--namespace", + "default", + "push", + "--all-platforms", + "--existing-index", + "allow", + "reg/img:tag", + ] diff --git a/posit-bakery/test/plugins/builtin/soci/test_workflow.py b/posit-bakery/test/plugins/builtin/soci/test_workflow.py new file mode 100644 index 00000000..e58e71ae --- /dev/null +++ b/posit-bakery/test/plugins/builtin/soci/test_workflow.py @@ -0,0 +1,146 @@ +"""Tests for SociConvertWorkflow.""" + +import subprocess +from unittest.mock import MagicMock, patch + +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 + +pytestmark = [pytest.mark.unit] + + +@pytest.fixture +def mock_target(): + t = MagicMock(spec=ImageTarget) + t.image_name = "test-image" + t.uid = "test-image-1-0-0" + t.temp_registry = "ghcr.io/posit-dev" + return t + + +@pytest.fixture +def workflow(mock_target): + return SociConvertWorkflow( + soci_bin="soci", + ctr_bin="ctr", + image_target=mock_target, + options=SociOptions(enabled=True), + source_ref="ghcr.io/posit-dev/test-image/tmp:merged", + ) + + +def test_destination_ref_appends_soci_suffix(workflow): + assert workflow.destination_ref == "ghcr.io/posit-dev/test-image/tmp:merged-soci" + + +def test_run_executes_pull_convert_push_in_order(workflow): + with patch("subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout=b"", stderr=b"") + result = workflow.run() + + assert result.success is True + assert result.destination_ref == workflow.destination_ref + assert result.resolved_namespace == "default" + # 3 calls: ctr pull, soci convert, soci push + assert mock_run.call_count == 3 + pull_args = mock_run.call_args_list[0].args[0] + convert_args = mock_run.call_args_list[1].args[0] + push_args = mock_run.call_args_list[2].args[0] + assert pull_args[:1] == ["ctr"] + assert "pull" in pull_args + assert convert_args[:1] == ["soci"] + assert "convert" in convert_args + assert push_args[:1] == ["soci"] + assert "push" in push_args + + +def test_dry_run_does_not_invoke_subprocess(workflow): + with patch("subprocess.run") as mock_run: + result = workflow.run(dry_run=True) + mock_run.assert_not_called() + assert result.success is True + + +def _not_found_proc(cmd): + return subprocess.CompletedProcess( + args=cmd, + returncode=1, + stdout=b"", + stderr=b'soci: image "x": not found', + ) + + +def _ok_proc(cmd): + return subprocess.CompletedProcess(args=cmd, returncode=0, stdout=b"", stderr=b"") + + +def test_falls_back_to_second_namespace_on_not_found(workflow): + # ctr pull defaults to namespace 'default'; we simulate not-found there + # and success in 'moby'. ctr pull is the call that triggers the fallback. + call_count = {"n": 0} + + def fake_run(cmd, capture_output): + call_count["n"] += 1 + # First call: ctr pull in 'default' fails with not-found. + if call_count["n"] == 1: + return _not_found_proc(cmd) + return _ok_proc(cmd) + + with patch("subprocess.run", side_effect=fake_run): + result = workflow.run() + + assert result.success is True + assert result.resolved_namespace == "moby" + # ctr pull(default-fail) + ctr pull(moby-ok) + convert + push = 4 + assert call_count["n"] == 4 + + +def test_non_not_found_error_short_circuits(workflow): + def fake_run(cmd, capture_output): + return subprocess.CompletedProcess(args=cmd, returncode=1, stdout=b"", stderr=b"network error") + + with patch("subprocess.run", side_effect=fake_run): + result = workflow.run() + + assert result.success is False + assert "network error" in (result.error or "") + + +def test_all_namespaces_not_found_returns_failure(workflow): + with patch("subprocess.run", side_effect=lambda cmd, capture_output: _not_found_proc(cmd)): + result = workflow.run() + + assert result.success is False + assert "not found" in (result.error or "").lower() + + +@pytest.fixture +def standalone_workflow(mock_target): + return SociConvertWorkflow( + soci_bin="soci", + ctr_bin="ctr", + image_target=mock_target, + options=SociOptions(enabled=True, standalone=True), + source_ref="./img.tar", + standalone=True, + ) + + +def test_standalone_mode_skips_ctr_pull_and_push(standalone_workflow): + with patch("subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout=b"", stderr=b"") + result = standalone_workflow.run() + + assert result.success is True + # Only one call: soci convert. No ctr pull, no soci push (the caller is + # responsible for pushing the resulting OCI layout via ORAS). + assert mock_run.call_count == 1 + convert_cmd = mock_run.call_args.args[0] + assert "--standalone" in convert_cmd + + +def test_standalone_destination_ref_is_sibling_path(standalone_workflow): + assert standalone_workflow.destination_ref == "./img.tar-soci" diff --git a/setup-soci/action.yml b/setup-soci/action.yml new file mode 100644 index 00000000..fb145b1e --- /dev/null +++ b/setup-soci/action.yml @@ -0,0 +1,51 @@ +name: 'Setup SOCI' +description: 'Installs the soci CLI and prepares containerd access.' + +inputs: + version: + description: "The SOCI release version to install" + required: false + default: "v0.13.0" + architecture: + description: "The system architecture (e.g., amd64, arm64). If not set, it will be detected automatically." + required: false + default: "" + +runs: + using: "composite" + steps: + - name: Make tools directory + shell: bash + run: mkdir -p tools + - name: Install soci + shell: bash + env: + SOCI_VERSION: ${{ inputs.version }} + SOCI_ARCH: ${{ inputs.architecture }} + run: | + arch="$SOCI_ARCH" + if [ -z "$arch" ]; then + arch=$(uname -m) + fi + if [ "$arch" = "x86_64" ]; then arch="amd64"; fi + if [ "$arch" = "aarch64" ]; then arch="arm64"; fi + + version_no_v="${SOCI_VERSION#v}" + tarball="soci-snapshotter-${version_no_v}-linux-${arch}.tar.gz" + url="https://github.com/awslabs/soci-snapshotter/releases/download/${SOCI_VERSION}/${tarball}" + sums_url="https://github.com/awslabs/soci-snapshotter/releases/download/${SOCI_VERSION}/${tarball}.sha256sum" + + curl -fsSL "$url" -o "tools/${tarball}" + curl -fsSL "$sums_url" -o "tools/${tarball}.sha256sum" + (cd tools && sha256sum -c "${tarball}.sha256sum") + tar -xzf "tools/${tarball}" -C tools soci + chmod +rx tools/soci + rm -f "tools/${tarball}" "tools/${tarball}.sha256sum" + - name: Ensure containerd socket is accessible + shell: bash + run: | + if [ -S /run/containerd/containerd.sock ]; then + sudo chmod 666 /run/containerd/containerd.sock || true + else + echo "::warning::/run/containerd/containerd.sock not present; SOCI non-standalone mode will fail." + fi