docker-devkit 0.2.1.dev37490360141__py3-none-any.whl → 0.3.0.dev113__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import hashlib
3
4
  import json
4
5
  import os
5
6
  import platform
@@ -7,12 +8,12 @@ import re
7
8
  from pathlib import Path
8
9
  from subprocess import CalledProcessError
9
10
  from tempfile import TemporaryDirectory
10
- from typing import Annotated, Any, Literal, TypedDict
11
+ from typing import Annotated, Any, TypedDict
11
12
 
12
13
  import typer
13
14
  from bashrun.bash import bash, bash_output
14
- from ci_devkit.builds import push_build
15
- from ci_devkit.setup_oras import install_oras
15
+ from build_artifact_registry.builds import push_build
16
+ from build_artifact_registry.setup_oras import install_oras
16
17
  from .detect_gpu import GPU_TYPES, Gpu, detect_gpu
17
18
  from .documents import BakeDocument, digest_pinned, parse_bake
18
19
  from pydantic_settings import BaseSettings
@@ -32,8 +33,6 @@ settings = Settings.model_validate({})
32
33
 
33
34
  METADATA_PATH = Path("metadata.json")
34
35
 
35
- Mode = Literal["local", "ci"]
36
-
37
36
  DIGEST_PROJECT = "images-digests"
38
37
  DIGEST_PLATFORM = "all"
39
38
  DIGEST_FILE_NAME = "images-digests.json"
@@ -50,55 +49,43 @@ app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
50
49
 
51
50
  @app.command()
52
51
  def build(
52
+ push: Annotated[
53
+ bool, typer.Option("--push", help="Push images (+ registry caches) and shelve the digest manifest.")
54
+ ] = False,
55
+ targets_opt: Annotated[
56
+ list[str] | None,
57
+ typer.Option("--targets", "-t", help="Build only these services (from the image manifest)."),
58
+ ] = None,
59
+ gpu: Annotated[Gpu, typer.Option("--gpu", help="auto|cuda|rocm|none")] = "auto",
53
60
  upgrade: bool = typer.Option(False, "--upgrade", "-u", help="Re-resolve and rewrite base digests."),
54
61
  lock_only: bool = typer.Option(False, "--lock-only", help="Update lock file without building images."),
55
- mode: Annotated[
56
- Mode, typer.Option("--mode", help="local: --load images; ci: --push images + registry caches.")
57
- ] = "local",
58
- gpu: Annotated[Gpu, typer.Option("--gpu", help="auto|cuda|rocm|none")] = "auto",
59
62
  gpu_only: bool = typer.Option(
60
63
  False, "--gpu-only", help="Build only the services suffixed for this gpu (requires a concrete --gpu)."
61
64
  ),
62
65
  no_cache: bool = typer.Option(False, "--no-cache", help="Force rebuild by disabling cache usage."),
63
- targets_opt: Annotated[
64
- list[str] | None,
65
- typer.Option("--targets", "-t", help="Build only these services (from the image manifest)."),
66
- ] = None,
67
- builds_registry: Annotated[
68
- str | None,
69
- typer.Option(
70
- "--builds-registry", help="OCI registry path to push the digest manifest to (CI build artifacts)."
71
- ),
72
- ] = None,
73
- run_number: Annotated[
74
- int, typer.Option("--run-number", help="CI run number baked into the digest manifest's builds-shelf tag.")
75
- ] = 0,
76
66
  ) -> None:
67
+ targets = [service for value in targets_opt or [] for service in value.split()] or None
77
68
  run_build(
69
+ push=push,
70
+ targets_opt=targets,
71
+ gpu=gpu,
78
72
  upgrade=upgrade,
79
73
  lock_only=lock_only,
80
- mode=mode,
81
- gpu=gpu,
82
74
  gpu_only=gpu_only,
83
75
  no_cache=no_cache,
84
- targets_opt=targets_opt,
85
- builds_registry=builds_registry,
86
- run_number=run_number,
87
76
  )
88
77
 
89
78
 
90
79
  def run_build(
91
80
  *,
81
+ push: bool = False,
82
+ targets_opt: list[str] | None = None,
83
+ gpu: Gpu = "auto",
92
84
  upgrade: bool = False,
93
85
  lock_only: bool = False,
94
- mode: Mode = "local",
95
- gpu: Gpu = "auto",
96
86
  gpu_only: bool = False,
97
87
  no_cache: bool = False,
98
- targets_opt: list[str] | None = None,
99
88
  env_overlay: dict[str, str] | None = None,
100
- builds_registry: str | None = None,
101
- run_number: int = 0,
102
89
  ) -> None:
103
90
  root = Path.cwd()
104
91
  manifest = require_manifest(root)
@@ -113,18 +100,17 @@ def run_build(
113
100
  # Load any existing lock
114
101
  lock_data = parse_env_file(lock_file) if lock_file.exists() else {}
115
102
 
116
- # TOOD: Create separate commands for ci and local modes so typer can do this validation instead of us
117
- if mode == "ci" and gpu == "auto":
118
- raise typer.BadParameter("In CI mode, --gpu cannot be 'auto'; specify 'cuda' or 'rocm'.")
103
+ if push and gpu == "auto":
104
+ raise typer.BadParameter("With --push, --gpu cannot be 'auto'; specify 'cuda' or 'rocm'.")
119
105
 
120
- if mode == "ci" and bake.registry_cache is None:
121
- raise typer.BadParameter("In CI mode, the bake file must declare x-registry-cache.")
106
+ if push and bake.registry_cache is None:
107
+ raise typer.BadParameter("With --push, the bake file must declare x-registry-cache.")
122
108
 
123
109
  if gpu_only and gpu not in GPU_TYPES:
124
110
  raise typer.BadParameter("--gpu-only requires a concrete gpu (cuda or rocm), not 'auto' or 'none'.")
125
111
 
126
112
  # For local builds, ensure Docker GC limits are high enough that GPU builds don't cause cache evictions
127
- if mode == "local" and not lock_only and not targets_opt:
113
+ if not push and not lock_only and not targets_opt:
128
114
  _check_gc_limits(min_gb=60)
129
115
 
130
116
  # Resolve gpu
@@ -176,8 +162,8 @@ def run_build(
176
162
  else:
177
163
  targets = compute_default_targets(bake, gpu, gpu_only)
178
164
 
179
- # Configure registry caches in CI mode
180
- if mode == "ci":
165
+ # Configure registry caches for push builds
166
+ if push:
181
167
  for target in targets:
182
168
  target_cache = f"{bake.registry_cache}:{target}"
183
169
  command_arguments.append(
@@ -187,9 +173,9 @@ def run_build(
187
173
 
188
174
  # `docker buildx --load` only writes a single arch to the host image store,
189
175
  # so bake targets declared multi-platform get overridden to host arch in
190
- # local builds. CI uses `--push` and produces the full manifest list.
176
+ # local builds. --push produces the full manifest list.
191
177
  # Override key is `platform` (singular) even though the bake field is plural.
192
- if mode == "local":
178
+ if not push:
193
179
  machine = platform.machine().lower()
194
180
  if machine in ("x86_64", "amd64"):
195
181
  host_platform = "linux/amd64"
@@ -201,8 +187,8 @@ def run_build(
201
187
  if len(bake.services[target].build.platforms) > 1:
202
188
  command_arguments.append(f"--set {target}.platform={host_platform}")
203
189
 
204
- # Load or push images based on mode
205
- command_arguments.append("--load" if mode == "local" else "--push")
190
+ # Load or push images
191
+ command_arguments.append("--push" if push else "--load")
206
192
 
207
193
  # Handle no-cache option
208
194
  if no_cache:
@@ -230,29 +216,39 @@ def run_build(
230
216
  if not set(targets) <= baked_images.keys():
231
217
  raise RuntimeError("Bake output does not cover the requested targets")
232
218
 
233
- push_image_digests(builds_registry, run_number, baked_images, targets)
219
+ # Push the digest manifest to the repo's builds shelf (registry supplied explicitly, SHA from the checkout)
220
+ if push:
221
+ registry = os.environ.get("REGISTRY", "")
222
+ if not registry:
223
+ raise SystemExit("REGISTRY not set — --push shelves the digest manifest under it")
224
+ shelf = f"{registry}/builds"
225
+ sha = bash_output("git rev-parse HEAD").strip()
226
+ push_image_digests(shelf, sha, baked_images, targets)
227
+
234
228
 
229
+ def leg_key(targets: list[str]) -> str:
230
+ return hashlib.sha256(",".join(sorted(targets)).encode("utf-8")).hexdigest()[:8]
235
231
 
232
+
233
+ # Each matrix leg pushes its own manifest shard under sha-{sha}-{key}: legs run in
234
+ # parallel, so a shared tag would be last-writer-wins. The pull side enumerates a
235
+ # SHA's shards and merges — consumption happens only after every leg finished.
236
236
  def push_image_digests(
237
- builds_registry: str | None,
238
- run_number: int,
237
+ shelf: str,
238
+ sha: str,
239
239
  baked_images: dict[str, Any],
240
240
  targets: list[str],
241
241
  ) -> None:
242
- if builds_registry is None:
243
- return
244
- if run_number <= 0:
245
- raise typer.BadParameter("--builds-registry requires a positive --run-number")
246
242
  install_oras()
247
243
  manifest = distill_digest_manifest(baked_images, targets)
248
244
  with TemporaryDirectory(prefix="digest-manifest-") as staging:
249
245
  manifest_path = Path(staging) / DIGEST_FILE_NAME
250
246
  manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
251
247
  push_build(
252
- builds_registry,
248
+ shelf,
253
249
  DIGEST_PROJECT,
254
250
  DIGEST_PLATFORM,
255
- f"run-{run_number}",
251
+ f"sha-{sha}-{leg_key(targets)}",
256
252
  Path(staging),
257
253
  [DIGEST_FILE_NAME],
258
254
  )
@@ -273,6 +269,8 @@ def distill_digest_manifest(baked_images: dict[str, Any], targets: list[str]) ->
273
269
  if separator:
274
270
  image_ref = repository
275
271
  tags.append(tag)
272
+ if not tags:
273
+ raise ValueError(f"target '{target}' produced no tagged image references from '{image_name}'")
276
274
  manifest[target] = {"ref": image_ref, "digest": digest, "tags": tags}
277
275
  return manifest
278
276
 
docker_devkit/matrix.py CHANGED
@@ -18,32 +18,49 @@ PLAIN_VARIANT = "common"
18
18
  @app.command()
19
19
  def matrix_main() -> None:
20
20
  bake = parse_bake(require_manifest(Path.cwd()))
21
- print(f"matrix={json.dumps({'include': compute_matrix_entries(bake)})}")
21
+ print(json.dumps(compute_matrix_entries(bake)))
22
22
 
23
23
 
24
- # One entry per variant: targets are the dependency-closure groups of that variant's
25
- # explicitly-buildable services, coalesced into a single leg — the shape a single
26
- # gpu-resolved build invocation would produce, with dependents grouped with the base
27
- # their additional_contexts `target:` references pull in.
24
+ # One entry per variant and cohort cell: targets are the dependency-closure groups
25
+ # of that cell's explicitly-buildable services, coalesced into a single leg — the
26
+ # shape a single gpu-resolved build invocation would produce, with dependents
27
+ # grouped with the base their additional_contexts `target:` references pull in.
28
+ # The cross-compile cohort gets its own legs: per-arch builds are the QEMU-slow
29
+ # ones, so they parallelize away from native builds instead of serializing them
30
+ # into one invocation.
28
31
  def compute_matrix_entries(bake: BakeDocument) -> list[dict[str, str]]:
29
- entries_by_variant: dict[str, list[str]] = {}
32
+ cohort = cross_compile_cohort(bake)
33
+ entries_by_cell: dict[tuple[str, bool], list[str]] = {}
30
34
  for group in dependency_groups(bake):
31
- explicit = sorted(
32
- service
33
- for service in group
34
- if bake.services[service].build.tags and service not in set(bake.cross_compile_targets)
35
- )
35
+ explicit = sorted(service for service in group if bake.services[service].build.tags)
36
36
  if not explicit:
37
37
  continue
38
38
  variant = group_variant(bake, explicit)
39
- entries_by_variant.setdefault(variant, []).extend(explicit)
39
+ memberships = {service in cohort for service in explicit}
40
+ if len(memberships) > 1:
41
+ raise RuntimeError(
42
+ f"dependency group spans cross-compile cohort membership ({', '.join(explicit)}) — "
43
+ "additional_contexts must stay within one cohort"
44
+ )
45
+ entries_by_cell.setdefault((variant, memberships.pop()), []).extend(explicit)
40
46
 
41
47
  return [
42
- {"targets": " ".join(sorted(entries_by_variant[variant])), "variant": variant}
43
- for variant in sorted(entries_by_variant)
48
+ {"targets": " ".join(sorted(targets)), "variant": variant}
49
+ for (variant, _), targets in sorted(entries_by_cell.items())
44
50
  ]
45
51
 
46
52
 
53
+ # The cross-compile cohort: every service declaring `platforms:` (per-arch
54
+ # treatment is a manifest fact, not an operator judgment) plus the hand-listed
55
+ # x-cross-compile-targets members.
56
+ def cross_compile_cohort(bake: BakeDocument) -> set[str]:
57
+ unknown = set(bake.cross_compile_targets) - set(bake.services)
58
+ if unknown:
59
+ raise RuntimeError(f"x-cross-compile-targets names unknown services: {', '.join(sorted(unknown))}")
60
+ pinned = {service for service, config in bake.services.items() if config.build.platforms}
61
+ return pinned | set(bake.cross_compile_targets)
62
+
63
+
47
64
  def dependency_groups(bake: BakeDocument) -> list[list[str]]:
48
65
  parent = {service: service for service in bake.services}
49
66
 
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: docker-devkit
3
- Version: 0.2.1.dev37490360141
3
+ Version: 0.3.0.dev113
4
4
  Summary: Docker-stack lifecycle commands (up/down/build) driven by a pyproject-declared compose assembly
5
5
  License-File: LICENSE
6
6
  License-File: NOTICE
7
7
  Requires-Python: >=3.13
8
8
  Requires-Dist: bashrun>=0.1.0
9
- Requires-Dist: ci-devkit>=0.1.15
9
+ Requires-Dist: build-artifact-registry>=0.2.0.dev0
10
10
  Requires-Dist: packaging>=24.1
11
11
  Requires-Dist: pathspec>=1.0.4
12
12
  Requires-Dist: pydantic-settings>=2.9.1
@@ -1,5 +1,5 @@
1
1
  docker_devkit/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- docker_devkit/build_docker.py,sha256=mpYSZgWHyLvtwBscqBAn_j8oL8l2Xuc6z5QWGIs02XY,12813
2
+ docker_devkit/build_docker.py,sha256=DtvTUTGHdQAkpBVelGObWyYD5Ldc05iPWXkNpCMQt-E,12927
3
3
  docker_devkit/config.py,sha256=UMAS6yF1E9PIbsRs2TVig9XwSNCvQe5w9eTLUHOPKq0,3112
4
4
  docker_devkit/context_sha.py,sha256=lhCxUcYM98xCdDum3hsFRFw5WYrjvZUSMbOu_7ZmQjE,3041
5
5
  docker_devkit/detect_gpu.py,sha256=C4e3vixvSSJS6xQiMVWp2IdWSAgMr551KrBTuIrkTk4,456
@@ -7,15 +7,15 @@ docker_devkit/documents.py,sha256=c6jqbBNUKyJOltex-8u536_niWhEL7ScOab_MwZmM9k,21
7
7
  docker_devkit/down.py,sha256=9OMXPZQ3SIdaSELc46hzkqNpLSwOJ1athCD8Bmlz2BI,2189
8
8
  docker_devkit/image_refs.py,sha256=6pHYPWh4lL6qMqHtQBHBbeWijVsT0PhsMS6CMDho5nY,5294
9
9
  docker_devkit/lifecycle.py,sha256=FuwaCSRK7qVx_qj-HmIFqMm8Ak4cpAQEqqXRPDNCLmo,2259
10
- docker_devkit/matrix.py,sha256=__qZhGdCOdYqjeexDYHsm55ehMyyD1Nw67m2T-DTvFg,3489
10
+ docker_devkit/matrix.py,sha256=6NyGL4Ol849dYlcMRzAL2yjFoN6sGkGwVaaxcDm3bG4,4530
11
11
  docker_devkit/mirror.py,sha256=tissqnoqMwRA6s0ykwXZOEgy6F1d3AAebPKGEkSpJRg,3224
12
12
  docker_devkit/modes.py,sha256=Lwhblzu5Kn7qhfy_QMqE28XuflSIJtCRBYNX7Iq8Qfo,1510
13
13
  docker_devkit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
14
  docker_devkit/score_generate.py,sha256=psbQm-MWf9A5YP0ov0rLPUiuVAQbUWn6yfUmB-CXurU,9434
15
15
  docker_devkit/up.py,sha256=32A2_6mxAF00XILecxYTzCQw0QvSjevBWCnayF5X1Xs,4284
16
- docker_devkit-0.2.1.dev37490360141.dist-info/METADATA,sha256=LrhBD-2sSaPdnDYpQeFABN4d7FvkcQwU8p4c6-2xbCc,530
17
- docker_devkit-0.2.1.dev37490360141.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
18
- docker_devkit-0.2.1.dev37490360141.dist-info/entry_points.txt,sha256=6cAHqlMvuXI00uTJLf73MTqWnLd1-m5sDgJrSyHWv4U,244
19
- docker_devkit-0.2.1.dev37490360141.dist-info/licenses/LICENSE,sha256=RFhQPdSOiMTguUX7JSoIuTxA7HVzCbj_p8WU36HjUQQ,10947
20
- docker_devkit-0.2.1.dev37490360141.dist-info/licenses/NOTICE,sha256=jvglZza3GCdN7v4_TLuXUqX9C_uABlwAKUkcIeKwk_8,26
21
- docker_devkit-0.2.1.dev37490360141.dist-info/RECORD,,
16
+ docker_devkit-0.3.0.dev113.dist-info/METADATA,sha256=0NT3OLGPZKW3hL5cvP-avLSORfSbo57cbDvwrZV2aOs,540
17
+ docker_devkit-0.3.0.dev113.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
18
+ docker_devkit-0.3.0.dev113.dist-info/entry_points.txt,sha256=6cAHqlMvuXI00uTJLf73MTqWnLd1-m5sDgJrSyHWv4U,244
19
+ docker_devkit-0.3.0.dev113.dist-info/licenses/LICENSE,sha256=RFhQPdSOiMTguUX7JSoIuTxA7HVzCbj_p8WU36HjUQQ,10947
20
+ docker_devkit-0.3.0.dev113.dist-info/licenses/NOTICE,sha256=jvglZza3GCdN7v4_TLuXUqX9C_uABlwAKUkcIeKwk_8,26
21
+ docker_devkit-0.3.0.dev113.dist-info/RECORD,,