Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 17 additions & 13 deletions posit-bakery/posit_bakery/cli/ci.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,19 +197,23 @@ def merge(
log.info(f"Found {len(loaded_targets)} targets")
log.debug(", ".join(loaded_targets))

results = config.merge_targets(dry_run=dry_run)

error_flag = False
for uid, manifest, error in results:
if error:
log.error(f"Error merging sources for UID '{uid}': {error}")
error_flag = True
elif manifest is not None:
stdout_console.print_json(manifest.model_dump_json(indent=2, exclude_unset=True, exclude_none=True))

if error_flag:
log.error("One or more errors occurred during merge.")
raise typer.Exit(code=1)
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:
import python_on_whales

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)


@app.command()
Expand Down
2 changes: 1 addition & 1 deletion posit-bakery/posit_bakery/cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,4 @@ def dgoss(

dgoss_plugin = get_plugin("dgoss")
results = dgoss_plugin.execute(c.base_path, c.targets, platform=image_platform)
dgoss_plugin.display_results(results)
dgoss_plugin.results(results)
48 changes: 0 additions & 48 deletions posit-bakery/posit_bakery/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@
from posit_bakery.image.bake.bake import BakePlan
from posit_bakery.image.image_metadata import MetadataFile
from posit_bakery.image.image_target import ImageTarget, ImageBuildStrategy, ImageTargetSettings
from posit_bakery.image.oras import OrasMergeWorkflow
from posit_bakery.registry_management import ghcr

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -982,53 +981,6 @@ def build_targets(
log.info(f"Writing build metadata to '{str(metadata_file)}'.")
json.dump(self._merge_sequential_build_metadata_files(), f, indent=2)

def merge_targets(
self,
dry_run: bool = False,
) -> list[tuple[str, Any, str | None]]:
"""Merge multi-platform images for all targets that have loaded build metadata.

Only targets with non-empty merge sources (from loaded metadata files) will be processed.

:param dry_run: If True, log commands without executing them.
:return: List of tuples containing (uid, manifest, error) for each processed target.
"""
results: list[tuple[str, Any, str | None]] = []

for target in self.targets:
# Skip targets without merge sources (no metadata loaded)
sources = target.get_merge_sources()

if not sources:
log.debug(f"Skipping target '{str(target)}' because it has no merge sources (no metadata loaded).")
continue

# Check temp_registry is configured
if not target.settings.temp_registry:
results.append(
(target.uid, None, f"Cannot merge '{str(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)
result = workflow.run(dry_run=dry_run)

if not result.success:
results.append((target.uid, None, result.error))
continue

if dry_run:
results.append((target.uid, None, None))
continue

# Return the final manifest as a sanity check
manifest = python_on_whales.docker.buildx.imagetools.inspect(str(target.tags[0]))
results.append((target.uid, manifest, None))

return results

def clean_caches(
self,
remove_untagged: bool = True,
Expand Down
23 changes: 0 additions & 23 deletions posit-bakery/posit_bakery/image/oras/__init__.py

This file was deleted.

5 changes: 3 additions & 2 deletions posit-bakery/posit_bakery/plugins/builtin/dgoss/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,14 +160,15 @@ def run(
c.load_build_metadata_from_file(metadata_file)

results = plugin.execute(c.base_path, c.targets, platform=platform)
plugin.display_results(results)
plugin.results(results)

app.add_typer(dgoss_app, name="dgoss", help="Run Goss tests against container images")

def execute(
self,
base_path: Path,
targets: list[ImageTarget],
*,
platform: str | None = None,
**kwargs,
) -> list[ToolCallResult]:
Expand Down Expand Up @@ -224,7 +225,7 @@ def execute(

return results

def display_results(self, results: list[ToolCallResult]) -> None:
def results(self, results: list[ToolCallResult]) -> None:
"""Display dgoss results as a table and raise typer.Exit(1) on failures.

Reconstructs a GossJsonReportCollection from ToolCallResult artifacts
Expand Down
168 changes: 168 additions & 0 deletions posit-bakery/posit_bakery/plugins/builtin/oras/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
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."""
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("\u274c ORAS merge(s) failed", style="error")
raise typer.Exit(code=1)

stderr_console.print("\u2705 ORAS merge completed", style="success")
5 changes: 4 additions & 1 deletion posit-bakery/posit_bakery/plugins/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,11 @@ def execute(
self,
base_path: Path,
targets: list[ImageTarget],
platform: str | None = None,
**kwargs,
) -> list[ToolCallResult]:
"""Execute the plugin's tools against the given ImageTarget objects."""
...

def results(self, results: list[ToolCallResult]) -> None:
"""Display the results of the plugin's execution and exit non-zero on failures."""
...
1 change: 1 addition & 0 deletions posit-bakery/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ 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"

[build-system]
requires = ["hatchling", "uv-dynamic-versioning"]
Expand Down
28 changes: 19 additions & 9 deletions posit-bakery/test/cli/test_ci.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import json
import re
from unittest.mock import MagicMock

from pytest_bdd import scenarios, then, parsers, given
from python_on_whales.components.buildx.imagetools.models import Manifest

from posit_bakery.config import BakeryConfig
from posit_bakery.plugins.protocol import ToolCallResult

scenarios(
"cli/ci/matrix.feature",
Expand Down Expand Up @@ -39,24 +39,34 @@ def copy_ci_testdata_to_context(bakery_command, ci_testdata, testdata_path):
def patch_image_target_merge_method(mocker):
calls = []

def patched_merge_targets(self, dry_run: bool = False):
def patched_execute(base_path, targets, platform=None, **kwargs):
results = []
for target in self.targets:
for target in targets:
try:
sources = target.get_merge_sources()
except Exception:
continue
if not sources:
continue
dry_run = kwargs.get("dry_run", False)
calls.append((sources, dry_run))
manifest = Manifest(
schemaVersion=2,
mediaType="application/vnd.docker.distribution.manifest.v2+json",
results.append(
ToolCallResult(
exit_code=0,
tool_name="oras",
target=target,
stdout="",
stderr="",
artifacts={"workflow_result": MagicMock(success=True, destinations=[])},
)
)
results.append((target.uid, manifest, None))
return results

mocker.patch.object(BakeryConfig, "merge_targets", patched_merge_targets)
mock_plugin = MagicMock()
mock_plugin.execute = patched_execute
mock_plugin.results = MagicMock()

mocker.patch("posit_bakery.plugins.registry.get_plugin", return_value=mock_plugin)
return calls


Expand Down
Loading
Loading