Skip to content
Merged
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
3 changes: 3 additions & 0 deletions posit-bakery/posit_bakery/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1197,10 +1197,13 @@ def build_targets(
clean_bakefile=self.settings.clean_temporary,
platforms=platforms,
set_opts=set_opts,
metadata_file=metadata_file,
),
retry=retry,
label="bake plan",
)
if metadata_file is not None:
self.load_build_metadata_from_file(metadata_file)
elif strategy == ImageBuildStrategy.BUILD:
sink = PrefixedLogSink()
# Mirrors ImageTarget.build()'s own quiet check: streaming is pointless (and
Expand Down
9 changes: 8 additions & 1 deletion posit-bakery/posit_bakery/image/bake/bake.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,8 +264,14 @@ def build(
platforms: list[str] | None = None,
set_opts: dict[str, Any] | None = None,
clean_bakefile: bool = True,
metadata_file: Path | None = None,
):
"""Run the bake plan to build all targets."""
"""Run the bake plan to build all targets.

:param metadata_file: Optional path to write JSON build metadata to. Passed through to
`docker buildx bake --metadata-file`, which writes one entry per bake target, keyed
by the same target name used in the bake plan (i.e. the image target UID).
"""
original_cwd = os.getcwd()
os.chdir(self.context)

Expand All @@ -289,6 +295,7 @@ def build(
pull=pull,
cache=cache,
set=_set,
metadata_file=metadata_file,
progress=False if SETTINGS.log_level >= logging.ERROR else "auto",
)
if clean_bakefile:
Expand Down
2 changes: 1 addition & 1 deletion posit-bakery/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ dependencies = [
"rich>=14.1,<15.1",
"gitpython~=3.1.50",
"pydantic[email]>=2.0,<3.0",
"python-on-whales>=0.79.0,<0.82.0",
"python-on-whales>=0.81.0,<0.82.0",
"ruamel-yaml>=0.18.14,<0.20.0",
"requests>=2.32.5,<3.0.0",
"requests-cache>=1.2.1,<2.0.0",
Expand Down
45 changes: 45 additions & 0 deletions posit-bakery/test/config/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2547,6 +2547,51 @@ def fake_build(self, **kwargs):
mock_time_sleep.assert_not_called()


class TestBuildTargetsBakeStrategy:
"""Tests for BakeryConfig.build_targets() with ImageBuildStrategy.BAKE."""

def test_metadata_file_forwarded_to_bake_plan(self, get_config_obj, mocker):
"""metadata_file must be forwarded to BakePlan.build() so it reaches
`docker buildx bake --metadata-file`."""
config = get_config_obj("basic")
metadata_path = Path("/tmp/does-not-matter.json")
mock_build = mocker.patch("posit_bakery.image.bake.BakePlan.build")
mocker.patch.object(BakeryConfig, "load_build_metadata_from_file")

config.build_targets(strategy=ImageBuildStrategy.BAKE, metadata_file=metadata_path)

mock_build.assert_called_once()
assert mock_build.call_args.kwargs["metadata_file"] == metadata_path

def test_no_metadata_file_by_default(self, get_config_obj, mocker):
"""When metadata_file is not given, None must be forwarded and no metadata load attempted."""
config = get_config_obj("basic")
mock_build = mocker.patch("posit_bakery.image.bake.BakePlan.build")
mock_load = mocker.patch.object(BakeryConfig, "load_build_metadata_from_file")

config.build_targets(strategy=ImageBuildStrategy.BAKE)

assert mock_build.call_args.kwargs["metadata_file"] is None
mock_load.assert_not_called()

def test_metadata_loaded_back_into_targets(self, get_config_obj, mocker, tmp_path):
"""After a successful bake with a metadata_file, the resulting file (written by
`docker buildx bake --metadata-file`, keyed by bake target/UID) must be loaded back
into each target's build_metadata, mirroring the BUILD strategy's behavior."""
config = get_config_obj("basic")
metadata_path = tmp_path / "metadata.json"

expected_metadata_path = CONFIG_TESTDATA_DIR / "build_metadata" / "expected.json"
shutil.copyfile(expected_metadata_path, metadata_path)

mocker.patch("posit_bakery.image.bake.BakePlan.build")

config.build_targets(strategy=ImageBuildStrategy.BAKE, metadata_file=metadata_path)

for target in config.targets:
assert len(target.build_metadata) == 1


class TestApplyDevSpecReleaseBranch:
"""release_branch is set from YYYY.MM when version is set, or directly from spec."""

Expand Down
22 changes: 22 additions & 0 deletions posit-bakery/test/image/bake/test_bake.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,13 +329,35 @@ def test_build_args(
"pull": False,
"cache": True,
"set": {},
"metadata_file": None,
"progress": "auto",
}

with patch("python_on_whales.docker.buildx.bake") as mock_bake:
plan.build()
mock_bake.assert_called_once_with(**expected_build_args)

@pytest.mark.parametrize("suite", SUCCESS_SUITES)
def test_build_args_metadata_file(
self,
patch_os_getcwd,
patch_os_chdir,
patch_bakeplan_write,
patch_bakeplan_remove,
suite,
get_config_obj,
tmp_path,
):
"""Test that a given metadata_file is forwarded to `docker buildx bake --metadata-file`."""
config_obj = get_config_obj(suite)

plan = BakePlan.from_image_targets(config_obj.base_path, config_obj.targets)
metadata_file = tmp_path / "metadata.json"

with patch("python_on_whales.docker.buildx.bake") as mock_bake:
plan.build(metadata_file=metadata_file)
assert mock_bake.call_args.kwargs["metadata_file"] == metadata_file

patch_os_getcwd.assert_called_once()
patch_os_chdir.assert_has_calls([call(plan.context), call("/cwd")])
patch_bakeplan_write.assert_called_once()
Expand Down
118 changes: 118 additions & 0 deletions posit-bakery/test/image/test_image_metadata.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import datetime
import json
import re

import pytest

Expand Down Expand Up @@ -194,3 +195,120 @@ def test_repr(self, image_testdata_path):
repr_str = repr(metadata_file)
assert "MetadataFile" in repr_str
assert str(metadata_filepath.absolute()) in repr_str


STRATEGY_METADATA_FIXTURES = ("strategy-bake-metadata.json", "strategy-build-metadata.json")
"""Real metadata files captured from both `bakery build` strategies for the same targets.

Both files were produced from the same `images-package-manager` checkout (commit
`adec3d9184f76c6e7725da5f28bdb9410010833c`) for `package-manager` 2026.06.0 --- 6 targets,
Standard/Minimal x Ubuntu 22.04/24.04/26.04 --- with:

bakery build --strategy bake --image-name '^package-manager$' --image-platform linux/amd64 \\
--temp-registry ghcr.io/posit-dev --push --metadata-file ./bake-amd64-metadata.json
bakery build --strategy build --image-name '^package-manager$' --image-platform linux/amd64 \\
--temp-registry ghcr.io/posit-dev --push --metadata-file ./build-amd64-metadata.json

They are committed verbatim (only renamed) so the two producers can be compared as-shipped:
`--strategy bake` output is raw `docker buildx bake --metadata-file` passthrough, while
`--strategy build` output is synthesized by
`BakeryConfig._merge_sequential_build_metadata_files()`. Regenerate with the commands above
if the metadata contract intentionally changes.
"""


class TestStrategyMetadataCompatibility:
"""Pin the metadata contract shared by `--strategy bake` and `--strategy build`.

`bakery dgoss run --metadata-file` and `bakery ci publish` consume metadata files through
`MetadataFile.load()` -> `get_target_metadata_by_uid()` -> `image_ref`/`platform`/
`created_at`, and must keep working regardless of which strategy produced the file. The two
shapes come from entirely separate code paths, so without these assertions they can drift
silently and only fail in CI at publish time.
"""

@pytest.mark.parametrize("fixture_name", STRATEGY_METADATA_FIXTURES)
def test_fixture_loads(self, image_testdata_path, fixture_name):
"""Both real fixtures validate through the same loader consumers use."""
metadata_file = MetadataFile.load(image_testdata_path / fixture_name)
assert len(metadata_file.metadata_map.root) == 6

@pytest.mark.parametrize("fixture_name", STRATEGY_METADATA_FIXTURES)
def test_uid_keys_are_target_uids(self, image_testdata_path, fixture_name):
"""Top-level keys are image target UIDs, which is how consumers look entries up."""
metadata_file = MetadataFile.load(image_testdata_path / fixture_name)
assert sorted(metadata_file.metadata_map.root) == [
"package-manager-2026-06-0-minimal-ubuntu-22-04",
"package-manager-2026-06-0-minimal-ubuntu-24-04",
"package-manager-2026-06-0-minimal-ubuntu-26-04",
"package-manager-2026-06-0-standard-ubuntu-22-04",
"package-manager-2026-06-0-standard-ubuntu-24-04",
"package-manager-2026-06-0-standard-ubuntu-26-04",
]

@pytest.mark.parametrize("fixture_name", STRATEGY_METADATA_FIXTURES)
def test_every_entry_has_resolvable_image_ref(self, image_testdata_path, fixture_name):
"""`ImageTarget.get_merge_sources()` emits nothing for entries without an image ref."""
metadata_file = MetadataFile.load(image_testdata_path / fixture_name)
for uid, metadata in metadata_file.metadata_map.root.items():
assert metadata.image_ref is not None, uid
assert re.fullmatch(r"[^@]+@sha256:[0-9a-f]{64}", metadata.image_ref), uid
assert metadata.image_ref.endswith(f"@{metadata.container_image_digest}"), uid

@pytest.mark.parametrize("fixture_name", STRATEGY_METADATA_FIXTURES)
def test_every_entry_reports_platform(self, image_testdata_path, fixture_name):
"""Platform drives dgoss reference selection and per-platform merge sources."""
metadata_file = MetadataFile.load(image_testdata_path / fixture_name)
for uid, metadata in metadata_file.metadata_map.root.items():
assert metadata.platform == "linux/amd64", uid

@pytest.mark.parametrize("fixture_name", STRATEGY_METADATA_FIXTURES)
def test_created_at_resolves_from_descriptor_annotation(self, image_testdata_path, fixture_name):
"""`created_at` must come from the descriptor, not the `datetime.now()` fallback.

The fallback silently makes every entry look freshly built, which breaks the
most-recent-wins ordering in `image_reference()` and `get_merge_sources()`.
"""
metadata_file = MetadataFile.load(image_testdata_path / fixture_name)
for uid, metadata in metadata_file.metadata_map.root.items():
annotations = metadata.container_image_descriptor.annotations
expected = datetime.datetime.fromisoformat(annotations["org.opencontainers.image.created"])
assert metadata.created_at == expected, uid

def test_strategies_agree_on_uids_and_primary_tags(self, image_testdata_path):
"""The two strategies are interchangeable for the fields consumers actually read."""
bake, build = (MetadataFile.load(image_testdata_path / name) for name in STRATEGY_METADATA_FIXTURES)

assert bake.metadata_map.root.keys() == build.metadata_map.root.keys()
for uid, bake_metadata in bake.metadata_map.root.items():
build_metadata = build.get_target_metadata_by_uid(uid)
assert build_metadata is not None, uid
# Digests differ (different builds); the tag set and primary tag must not.
assert bake_metadata.image_tags == build_metadata.image_tags, uid
assert bake_metadata.image_tags[0] == build_metadata.image_tags[0], uid
assert bake_metadata.platform == build_metadata.platform, uid

def test_multiplatform_entry_has_no_platform(self):
"""Document the degradation when one invocation builds several platforms.

A multi-platform build emits a single index descriptor with no `platform`, for both
strategies. `platform` is then None, so `ImageTarget.image_reference(platform=...)`
falls back to a tag reference and `get_merge_sources()` collapses to one source. This
is why CI must keep one platform per `bakery build` invocation.
"""
metadata = BuildMetadata.model_validate(
{
"image.name": "ghcr.io/posit-dev/package-manager/tmp",
"containerimage.digest": "sha256:" + "ab" * 32,
"containerimage.descriptor": {
"mediaType": "application/vnd.oci.image.index.v1+json",
"digest": "sha256:" + "ab" * 32,
"size": 1234,
"annotations": {"org.opencontainers.image.created": "2026-08-11T15:50:15Z"},
# No platform: an index covers several platforms.
},
}
)

assert metadata.platform is None
assert metadata.image_ref == "ghcr.io/posit-dev/package-manager/tmp@sha256:" + "ab" * 32
Loading
Loading