docker-devkit 0.1.7__tar.gz → 0.1.8__tar.gz
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.
- docker_devkit-0.1.8/.github/workflows/ci.yml +26 -0
- {docker_devkit-0.1.7 → docker_devkit-0.1.8}/.github/workflows/release.yml +7 -1
- {docker_devkit-0.1.7 → docker_devkit-0.1.8}/AGENTS.md +1 -1
- {docker_devkit-0.1.7 → docker_devkit-0.1.8}/PKG-INFO +1 -1
- {docker_devkit-0.1.7 → docker_devkit-0.1.8}/pyproject.toml +1 -1
- {docker_devkit-0.1.7 → docker_devkit-0.1.8}/src/docker_devkit/build_docker.py +59 -75
- {docker_devkit-0.1.7 → docker_devkit-0.1.8}/src/docker_devkit/context_sha.py +11 -35
- {docker_devkit-0.1.7 → docker_devkit-0.1.8}/src/docker_devkit/down.py +1 -6
- {docker_devkit-0.1.7 → docker_devkit-0.1.8}/src/docker_devkit/image_refs.py +47 -52
- {docker_devkit-0.1.7 → docker_devkit-0.1.8}/src/docker_devkit/up.py +1 -6
- docker_devkit-0.1.7/.github/workflows/ci.yml +0 -15
- {docker_devkit-0.1.7 → docker_devkit-0.1.8}/.gitattributes +0 -0
- {docker_devkit-0.1.7 → docker_devkit-0.1.8}/.gitignore +0 -0
- {docker_devkit-0.1.7 → docker_devkit-0.1.8}/.python-version +0 -0
- {docker_devkit-0.1.7 → docker_devkit-0.1.8}/CLAUDE.md +0 -0
- {docker_devkit-0.1.7 → docker_devkit-0.1.8}/LICENSE +0 -0
- {docker_devkit-0.1.7 → docker_devkit-0.1.8}/NOTICE +0 -0
- {docker_devkit-0.1.7 → docker_devkit-0.1.8}/README.md +0 -0
- {docker_devkit-0.1.7 → docker_devkit-0.1.8}/publish-config.json +0 -0
- {docker_devkit-0.1.7 → docker_devkit-0.1.8}/ruff.base.toml +0 -0
- {docker_devkit-0.1.7 → docker_devkit-0.1.8}/ruff.toml +0 -0
- {docker_devkit-0.1.7 → docker_devkit-0.1.8}/src/docker_devkit/__init__.py +0 -0
- {docker_devkit-0.1.7 → docker_devkit-0.1.8}/src/docker_devkit/detect_gpu.py +0 -0
- {docker_devkit-0.1.7 → docker_devkit-0.1.8}/src/docker_devkit/modes.py +11 -11
- {docker_devkit-0.1.7 → docker_devkit-0.1.8}/src/docker_devkit/py.typed +0 -0
- {docker_devkit-0.1.7 → docker_devkit-0.1.8}/tests/test_context_sha.py +0 -0
- {docker_devkit-0.1.7 → docker_devkit-0.1.8}/tests/test_default_targets.py +0 -0
- {docker_devkit-0.1.7 → docker_devkit-0.1.8}/tests/test_image_refs.py +0 -0
- {docker_devkit-0.1.7 → docker_devkit-0.1.8}/uv.lock +0 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
concurrency:
|
|
10
|
+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
|
11
|
+
cancel-in-progress: true
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
check:
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
permissions:
|
|
17
|
+
contents: read
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v5
|
|
20
|
+
|
|
21
|
+
- uses: astral-sh/setup-uv@v7
|
|
22
|
+
with:
|
|
23
|
+
enable-cache: true
|
|
24
|
+
|
|
25
|
+
- name: Preflight
|
|
26
|
+
run: uvx --from python-devkit==0.1.5 preflight-python
|
|
@@ -16,4 +16,10 @@ jobs:
|
|
|
16
16
|
steps:
|
|
17
17
|
- uses: actions/checkout@v5
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
# Workaround: fetch-tags is broken with shallow clones
|
|
20
|
+
- run: git fetch --tags origin
|
|
21
|
+
|
|
22
|
+
- uses: astral-sh/setup-uv@v7
|
|
23
|
+
|
|
24
|
+
- name: Publish
|
|
25
|
+
run: uvx --from release-devkit==0.1.3 publish-packages --config publish-config.json
|
|
@@ -8,7 +8,7 @@ The package is `docker_devkit` (src-layout under `src/docker_devkit/`); all depe
|
|
|
8
8
|
|
|
9
9
|
## Release flow
|
|
10
10
|
|
|
11
|
-
Publishing rides `release.yml`, triggered by a successful CI run on a `main` push: the machinery — release-devkit's publish
|
|
11
|
+
Publishing rides `release.yml`, triggered by a successful CI run on a `main` push: the machinery — release-devkit's `publish-packages` (an inlined, version-pinned `uvx` step in the publish job, keeping OIDC identity local), never a project dependency (docker-devkit sits inside its own dependency graph; a project-level release-devkit edge is a resolver cycle) — computes the plan from the tag ledger and path-diff, patches the version ephemerally, and publishes to PyPI under OIDC trusted publishing (publisher bound to `release.yml`, no environment). The committed `pyproject.toml` version is permanently the `0.0.0.dev0` sentinel; the `docker-devkit-v*` tags are the version ledger (first release `0.1.0`, patch-auto thereafter). API-breaking changes ship with a manually bumped version — patch-auto assumes additive changes.
|
|
12
12
|
|
|
13
13
|
## Shape
|
|
14
14
|
|
|
@@ -5,6 +5,7 @@ import os
|
|
|
5
5
|
import platform
|
|
6
6
|
import re
|
|
7
7
|
from pathlib import Path
|
|
8
|
+
from subprocess import CalledProcessError
|
|
8
9
|
from typing import Annotated, Any, Literal
|
|
9
10
|
|
|
10
11
|
import typer
|
|
@@ -15,6 +16,7 @@ from pydantic_settings import BaseSettings
|
|
|
15
16
|
|
|
16
17
|
from .context_sha import compute_service_shas
|
|
17
18
|
from .image_refs import bake_base_image_refs, compose_service_refs, resolve_remote_digest
|
|
19
|
+
from .modes import parse_env_file
|
|
18
20
|
|
|
19
21
|
|
|
20
22
|
class Settings(BaseSettings):
|
|
@@ -35,70 +37,6 @@ Mode = Literal["local", "ci"]
|
|
|
35
37
|
app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
|
|
36
38
|
|
|
37
39
|
|
|
38
|
-
def _load_lock_file(path: Path):
|
|
39
|
-
return {
|
|
40
|
-
(p := line.split("=", 1))[0].strip(): p[1].strip()
|
|
41
|
-
for line in path.read_text(encoding="utf-8").splitlines()
|
|
42
|
-
if "=" in line and not line.startswith("#")
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def _write_lock_file(path: Path, variables: dict[str, str]):
|
|
47
|
-
path.write_text(
|
|
48
|
-
"# Generated by lock.py\n" + "\n".join(f"{k}={v}" for k, v in sorted(variables.items())) + "\n",
|
|
49
|
-
encoding="utf-8",
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
# Vibe code - Gemini 3
|
|
54
|
-
def _check_gc_limits(min_gb: int = 60):
|
|
55
|
-
candidates = [Path("/etc/docker/daemon.json"), Path(os.path.expanduser("~/.docker/daemon.json"))]
|
|
56
|
-
if settings.wsl_distro_name is not None:
|
|
57
|
-
try:
|
|
58
|
-
win_home = bash_output('wslpath $(cmd.exe /c "echo %UserProfile%" 2>/dev/null)').strip()
|
|
59
|
-
candidates.append(Path(win_home) / ".docker" / "daemon.json")
|
|
60
|
-
except Exception:
|
|
61
|
-
pass
|
|
62
|
-
|
|
63
|
-
config = next((p for p in candidates if p.exists() and os.access(p, os.R_OK)), None)
|
|
64
|
-
if not config:
|
|
65
|
-
print(f" [WARN] No readable daemon.json found. Ensure defaultKeepStorage > {min_gb}GB.")
|
|
66
|
-
return
|
|
67
|
-
|
|
68
|
-
data = json.loads(config.read_text())
|
|
69
|
-
raw = data.get("builder", {}).get("gc", {}).get("defaultKeepStorage")
|
|
70
|
-
if not raw:
|
|
71
|
-
sample = json.dumps({"builder": {"gc": {"defaultKeepStorage": f"{min_gb}GB"}}}, indent=2)
|
|
72
|
-
raise RuntimeError(
|
|
73
|
-
f"Missing 'builder.gc.defaultKeepStorage' in {config}; Docker's default is too low for GPU builds "
|
|
74
|
-
f"(need >= {min_gb}GB). Add the following (merging into any existing keys) and restart Docker:\n\n{sample}"
|
|
75
|
-
)
|
|
76
|
-
|
|
77
|
-
m = re.match(r"^(\d+(?:\.\d+)?)\s*([TGMK]i?B)?$", str(raw), re.IGNORECASE)
|
|
78
|
-
if not m:
|
|
79
|
-
raise RuntimeError(f"Could not parse 'defaultKeepStorage' value: {raw}")
|
|
80
|
-
|
|
81
|
-
mult = {"T": 1024, "G": 1, "M": 1 / 1024, "K": 1 / 1024**2, "B": 1 / 1024**3}
|
|
82
|
-
unit = (m.group(2) or "B")[0].upper()
|
|
83
|
-
val = float(m.group(1)) * mult.get(unit, 1 / 1024**3)
|
|
84
|
-
|
|
85
|
-
if val < min_gb:
|
|
86
|
-
raise RuntimeError(
|
|
87
|
-
f"UNSAFE GC LIMIT: {val:.1f}GB in {config} (Required: {min_gb}GB). RESTART DOCKER AFTER FIXING."
|
|
88
|
-
)
|
|
89
|
-
|
|
90
|
-
print(f" [OK] Docker GC Limit verified: {val:.1f}GB")
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def _load_compose(path: Path) -> dict[str, Any]:
|
|
94
|
-
data: dict[str, Any] = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
95
|
-
for include in data.pop("include", []):
|
|
96
|
-
include_path = path.parent / (include if isinstance(include, str) else include["path"])
|
|
97
|
-
included: dict[str, Any] = yaml.safe_load(include_path.read_text(encoding="utf-8"))
|
|
98
|
-
data.setdefault("services", {}).update(included.get("services", {}))
|
|
99
|
-
return data
|
|
100
|
-
|
|
101
|
-
|
|
102
40
|
@app.command()
|
|
103
41
|
def build(
|
|
104
42
|
upgrade: bool = typer.Option(False, "--upgrade", "-u", help="Re-resolve and rewrite base digests."),
|
|
@@ -153,8 +91,12 @@ def run_build(
|
|
|
153
91
|
|
|
154
92
|
# Read bake, compose, and lock files
|
|
155
93
|
bake_data: dict[str, Any] = yaml.safe_load(bake_file.read_text(encoding="utf-8"))
|
|
156
|
-
compose_data: dict[str, Any] =
|
|
157
|
-
|
|
94
|
+
compose_data: dict[str, Any] = yaml.safe_load(COMPOSE_FILE.read_text(encoding="utf-8"))
|
|
95
|
+
for include in compose_data.pop("include", []):
|
|
96
|
+
include_path = COMPOSE_FILE.parent / (include if isinstance(include, str) else include["path"])
|
|
97
|
+
included: dict[str, Any] = yaml.safe_load(include_path.read_text(encoding="utf-8"))
|
|
98
|
+
compose_data.setdefault("services", {}).update(included.get("services", {}))
|
|
99
|
+
lock_data = parse_env_file(LOCK_FILE) if LOCK_FILE.exists() else {}
|
|
158
100
|
|
|
159
101
|
# TOOD: Create separate commands for ci and local modes so typer can do this validation instead of us
|
|
160
102
|
if mode == "ci" and gpu == "auto":
|
|
@@ -185,18 +127,20 @@ def run_build(
|
|
|
185
127
|
for occurrence in compose_service_refs(compose_data)
|
|
186
128
|
}
|
|
187
129
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
for image, ref in {image: ref for image, ref in third_party_images.items() if _needs_resolve(image, ref)}.items():
|
|
130
|
+
# Re-resolve when explicitly requested, when unseen, or when the image name in compose.yml
|
|
131
|
+
# changed from what's in the lock file (e.g. postgres:16-alpine → postgis/postgres:16-3.4-alpine)
|
|
132
|
+
for image, ref in {
|
|
133
|
+
image: ref
|
|
134
|
+
for image, ref in third_party_images.items()
|
|
135
|
+
if upgrade or image not in lock_data or not lock_data[image].startswith(ref + "@")
|
|
136
|
+
}.items():
|
|
196
137
|
lock_data[image] = f"{ref}@{'' if '$' in ref else resolve_remote_digest(ref)}"
|
|
197
138
|
|
|
198
139
|
# Update main lock file
|
|
199
|
-
|
|
140
|
+
LOCK_FILE.write_text(
|
|
141
|
+
"# Generated by lock.py\n" + "\n".join(f"{key}={value}" for key, value in sorted(lock_data.items())) + "\n",
|
|
142
|
+
encoding="utf-8",
|
|
143
|
+
)
|
|
200
144
|
|
|
201
145
|
if lock_only:
|
|
202
146
|
return
|
|
@@ -272,6 +216,46 @@ def run_build(
|
|
|
272
216
|
raise RuntimeError("Baked images do not match target images; something went wrong during the bake.")
|
|
273
217
|
|
|
274
218
|
|
|
219
|
+
# Vibe code - Gemini 3
|
|
220
|
+
def _check_gc_limits(min_gb: int = 60):
|
|
221
|
+
candidates = [Path("/etc/docker/daemon.json"), Path(os.path.expanduser("~/.docker/daemon.json"))]
|
|
222
|
+
if settings.wsl_distro_name is not None:
|
|
223
|
+
try:
|
|
224
|
+
win_home = bash_output('wslpath $(cmd.exe /c "echo %UserProfile%" 2>/dev/null)').strip()
|
|
225
|
+
candidates.append(Path(win_home) / ".docker" / "daemon.json")
|
|
226
|
+
except CalledProcessError:
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
config = next((p for p in candidates if p.exists() and os.access(p, os.R_OK)), None)
|
|
230
|
+
if not config:
|
|
231
|
+
print(f" [WARN] No readable daemon.json found. Ensure defaultKeepStorage > {min_gb}GB.")
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
data = json.loads(config.read_text())
|
|
235
|
+
raw = data.get("builder", {}).get("gc", {}).get("defaultKeepStorage")
|
|
236
|
+
if not raw:
|
|
237
|
+
sample = json.dumps({"builder": {"gc": {"defaultKeepStorage": f"{min_gb}GB"}}}, indent=2)
|
|
238
|
+
raise RuntimeError(
|
|
239
|
+
f"Missing 'builder.gc.defaultKeepStorage' in {config}; Docker's default is too low for GPU builds "
|
|
240
|
+
f"(need >= {min_gb}GB). Add the following (merging into any existing keys) and restart Docker:\n\n{sample}"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
m = re.match(r"^(\d+(?:\.\d+)?)\s*([TGMK]i?B)?$", str(raw), re.IGNORECASE)
|
|
244
|
+
if not m:
|
|
245
|
+
raise RuntimeError(f"Could not parse 'defaultKeepStorage' value: {raw}")
|
|
246
|
+
|
|
247
|
+
mult = {"T": 1024, "G": 1, "M": 1 / 1024, "K": 1 / 1024**2, "B": 1 / 1024**3}
|
|
248
|
+
unit = (m.group(2) or "B")[0].upper()
|
|
249
|
+
val = float(m.group(1)) * mult.get(unit, 1 / 1024**3)
|
|
250
|
+
|
|
251
|
+
if val < min_gb:
|
|
252
|
+
raise RuntimeError(
|
|
253
|
+
f"UNSAFE GC LIMIT: {val:.1f}GB in {config} (Required: {min_gb}GB). RESTART DOCKER AFTER FIXING."
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
print(f" [OK] Docker GC Limit verified: {val:.1f}GB")
|
|
257
|
+
|
|
258
|
+
|
|
275
259
|
# x-cross-compile-targets: services declared here are excluded from the default
|
|
276
260
|
# target list because they need special (usually per-arch) treatment; an operator
|
|
277
261
|
# can still opt them in explicitly via --targets. The field is a top-level bake key.
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import os
|
|
4
|
-
import subprocess
|
|
5
3
|
from pathlib import Path, PurePosixPath
|
|
6
4
|
from tempfile import TemporaryDirectory
|
|
7
5
|
|
|
@@ -9,33 +7,21 @@ from typing import Any
|
|
|
9
7
|
|
|
10
8
|
import pathspec
|
|
11
9
|
import yaml
|
|
10
|
+
from bashrun.bash import bash, bash_output
|
|
12
11
|
|
|
13
12
|
|
|
14
|
-
def
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
def compute_service_shas(repo_root: Path, bake_file: Path) -> dict[str, str]:
|
|
14
|
+
repo_root = repo_root.resolve()
|
|
17
15
|
|
|
18
|
-
|
|
16
|
+
bake_data: dict[str, Any] = yaml.safe_load(bake_file.read_text(encoding="utf-8"))
|
|
19
17
|
service_dirs: set[str] = set()
|
|
20
|
-
|
|
21
|
-
for config in services.values():
|
|
18
|
+
for config in bake_data["services"].values():
|
|
22
19
|
build: dict[str, Any] = config.get("build", {})
|
|
23
20
|
if not build.get("tags"):
|
|
24
21
|
continue
|
|
25
|
-
|
|
26
|
-
service_dirs.add(str(PurePosixPath(dockerfile).parent))
|
|
27
|
-
return service_dirs
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def compute_service_shas(repo_root: Path, bake_file: Path) -> dict[str, str]:
|
|
31
|
-
repo_root = repo_root.resolve()
|
|
32
|
-
|
|
33
|
-
bake_data: dict[str, Any] = yaml.safe_load(bake_file.read_text(encoding="utf-8"))
|
|
34
|
-
service_dirs = _extract_service_dirs(bake_data)
|
|
22
|
+
service_dirs.add(str(PurePosixPath(build.get("dockerfile", "")).parent))
|
|
35
23
|
|
|
36
|
-
tree_entries =
|
|
37
|
-
["git", "ls-tree", "-r", "HEAD"], cwd=str(repo_root), capture_output=True, text=True, check=True
|
|
38
|
-
).stdout.splitlines()
|
|
24
|
+
tree_entries = bash_output("git ls-tree -r HEAD", cwd=repo_root).splitlines()
|
|
39
25
|
|
|
40
26
|
dockerignore = repo_root / ".dockerignore"
|
|
41
27
|
spec = pathspec.PathSpec.from_lines("gitignore", dockerignore.read_text().splitlines())
|
|
@@ -67,20 +53,10 @@ def compute_service_shas(repo_root: Path, bake_file: Path) -> dict[str, str]:
|
|
|
67
53
|
index_input = "\n".join(f"{mode} {obj_hash}\t{path}" for mode, obj_hash, path in entries) + "\n"
|
|
68
54
|
|
|
69
55
|
with TemporaryDirectory() as tmpdir:
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
cwd=str(repo_root),
|
|
74
|
-
env=env,
|
|
75
|
-
input=index_input.encode(),
|
|
76
|
-
capture_output=True,
|
|
77
|
-
check=True,
|
|
78
|
-
)
|
|
79
|
-
tree_hash = subprocess.run(
|
|
80
|
-
["git", "write-tree"], cwd=str(repo_root), env=env, capture_output=True, text=True, check=True
|
|
81
|
-
).stdout.strip()
|
|
56
|
+
index_env = {"GIT_INDEX_FILE": str(Path(tmpdir) / "index")}
|
|
57
|
+
bash("git update-index --index-info", cwd=repo_root, env=index_env, stdin_text=index_input)
|
|
58
|
+
tree_hash = bash_output("git write-tree", cwd=repo_root, env=index_env).strip()
|
|
82
59
|
|
|
83
|
-
|
|
84
|
-
result[var_name] = f"tree-{tree_hash}"
|
|
60
|
+
result[PurePosixPath(service_dir).name.upper().replace("-", "_") + "_SHA"] = f"tree-{tree_hash}"
|
|
85
61
|
|
|
86
62
|
return result
|
|
@@ -13,11 +13,6 @@ ENV_FILE = Path(".env")
|
|
|
13
13
|
LOCK_FILE = Path(".env.lock")
|
|
14
14
|
BAKE_FILE = Path("compose.bake.yml")
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
def _resolve_service_shas() -> None:
|
|
18
|
-
os.environ.update(compute_service_shas(Path.cwd(), BAKE_FILE))
|
|
19
|
-
|
|
20
|
-
|
|
21
16
|
app = typer.Typer(add_completion=False)
|
|
22
17
|
|
|
23
18
|
|
|
@@ -53,7 +48,7 @@ def down(
|
|
|
53
48
|
resolve_auth_mode(ENV_FILE)
|
|
54
49
|
|
|
55
50
|
if BAKE_FILE.exists():
|
|
56
|
-
|
|
51
|
+
os.environ.update(compute_service_shas(Path.cwd(), BAKE_FILE))
|
|
57
52
|
|
|
58
53
|
if native:
|
|
59
54
|
compose_files = (
|
|
@@ -76,6 +76,17 @@ class VersionCoupling:
|
|
|
76
76
|
sites: tuple[VersionSite, ...]
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
def unpinned_references(root: Path) -> list[str]:
|
|
80
|
+
return [
|
|
81
|
+
occurrence.reference
|
|
82
|
+
for occurrence in collect_repo_references(root)
|
|
83
|
+
if "/" in (reference := strip_build_args(occurrence.reference))
|
|
84
|
+
and not BUILD_ARG_PATTERN.search(occurrence.reference)
|
|
85
|
+
and ":" not in (tail := reference[reference.rfind("/") + 1 :])
|
|
86
|
+
and "@" not in tail
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
|
|
79
90
|
def collect_repo_references(
|
|
80
91
|
root: Path,
|
|
81
92
|
compose_glob: str = "compose*.yml",
|
|
@@ -121,47 +132,37 @@ def collect_repo_references(
|
|
|
121
132
|
)
|
|
122
133
|
|
|
123
134
|
|
|
124
|
-
def
|
|
135
|
+
def version_coupling_violations(root: Path, couplings: list[VersionCoupling]) -> list[str]:
|
|
125
136
|
return [
|
|
126
|
-
|
|
127
|
-
for
|
|
128
|
-
|
|
137
|
+
f"{coupling.name}: {site.description} at {path}: expected {expected}, found {found}"
|
|
138
|
+
for coupling in couplings
|
|
139
|
+
for expected in [re.sub(r"^[^0-9]+", "", _pyproject_value(root, coupling.pyproject_key)).split(",")[0].strip()]
|
|
140
|
+
for site in coupling.sites
|
|
141
|
+
for path in sorted(root.glob(site.glob)) or [Path(site.glob)]
|
|
142
|
+
for found in [_site_version(path, site)]
|
|
143
|
+
if found != expected
|
|
129
144
|
]
|
|
130
145
|
|
|
131
146
|
|
|
132
|
-
def
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def strip_build_args(reference: str) -> str:
|
|
141
|
-
return BUILD_ARG_PATTERN.sub("", reference).strip()
|
|
147
|
+
def resolve_remote_digest(reference: str) -> str:
|
|
148
|
+
print(f"Resolving digest for: {reference}")
|
|
149
|
+
output = bash_output(f"docker buildx imagetools inspect {shlex.quote(reference)}")
|
|
150
|
+
match = DIGEST_PATTERN.search(output)
|
|
151
|
+
if match is None:
|
|
152
|
+
raise RuntimeError(f"Could not parse digest for image reference: {reference}")
|
|
153
|
+
return match.group(1)
|
|
142
154
|
|
|
143
155
|
|
|
144
|
-
def
|
|
156
|
+
def compose_service_refs(document: object) -> list[ImageReference]:
|
|
145
157
|
return [
|
|
146
|
-
|
|
147
|
-
for
|
|
148
|
-
if
|
|
149
|
-
and not BUILD_ARG_PATTERN.search(occurrence.reference)
|
|
150
|
-
and ":" not in (tail := reference[reference.rfind("/") + 1 :])
|
|
151
|
-
and "@" not in tail
|
|
158
|
+
ImageReference(service_name, service.image_ref)
|
|
159
|
+
for service_name, service in ComposeDocument.model_validate(document).services.items()
|
|
160
|
+
if service.image_ref is not None
|
|
152
161
|
]
|
|
153
162
|
|
|
154
163
|
|
|
155
|
-
def
|
|
156
|
-
return
|
|
157
|
-
f"{coupling.name}: {site.description} at {path}: expected {expected}, found {found}"
|
|
158
|
-
for coupling in couplings
|
|
159
|
-
for expected in [_declared_version(_pyproject_value(root, coupling.pyproject_key))]
|
|
160
|
-
for site in coupling.sites
|
|
161
|
-
for path in _site_paths(root, site)
|
|
162
|
-
for found in [_site_version(path, site)]
|
|
163
|
-
if found != expected
|
|
164
|
-
]
|
|
164
|
+
def strip_build_args(reference: str) -> str:
|
|
165
|
+
return BUILD_ARG_PATTERN.sub("", reference).strip()
|
|
165
166
|
|
|
166
167
|
|
|
167
168
|
def _pyproject_value(root: Path, dotted_key: str) -> str:
|
|
@@ -175,33 +176,27 @@ def _pyproject_value(root: Path, dotted_key: str) -> str:
|
|
|
175
176
|
return value
|
|
176
177
|
|
|
177
178
|
|
|
178
|
-
def _declared_version(specifier: str) -> str:
|
|
179
|
-
return re.sub(r"^[^0-9]+", "", specifier).split(",")[0].strip()
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
def _site_paths(root: Path, site: VersionSite) -> list[Path]:
|
|
183
|
-
return sorted(root.glob(site.glob)) or [Path(site.glob)]
|
|
184
|
-
|
|
185
|
-
|
|
186
179
|
def _site_version(path: Path, site: VersionSite) -> str:
|
|
187
180
|
if not path.exists():
|
|
188
181
|
return MISSING_VERSION
|
|
189
|
-
|
|
182
|
+
if site.base_image:
|
|
183
|
+
haystack = next(
|
|
184
|
+
(
|
|
185
|
+
reference.reference
|
|
186
|
+
for reference in bake_base_image_refs(_load_yaml_document(path))
|
|
187
|
+
if reference.name == site.base_image
|
|
188
|
+
),
|
|
189
|
+
"",
|
|
190
|
+
)
|
|
191
|
+
else:
|
|
192
|
+
haystack = path.read_text(encoding="utf-8")
|
|
190
193
|
match = re.search(site.pattern, haystack)
|
|
191
194
|
return match.group(1) if match else MISSING_VERSION
|
|
192
195
|
|
|
193
196
|
|
|
194
|
-
def
|
|
195
|
-
return
|
|
196
|
-
(reference.reference for reference in bake_base_image_refs(_load_yaml_document(path)) if reference.name == key),
|
|
197
|
-
"",
|
|
198
|
-
)
|
|
197
|
+
def bake_base_image_refs(document: object) -> list[ImageReference]:
|
|
198
|
+
return list(starmap(ImageReference, BakeDocument.model_validate(document).base_images.items()))
|
|
199
199
|
|
|
200
200
|
|
|
201
|
-
def
|
|
202
|
-
|
|
203
|
-
output = bash_output(f"docker buildx imagetools inspect {shlex.quote(reference)}")
|
|
204
|
-
match = DIGEST_PATTERN.search(output)
|
|
205
|
-
if match is None:
|
|
206
|
-
raise RuntimeError(f"Could not parse digest for image reference: {reference}")
|
|
207
|
-
return match.group(1)
|
|
201
|
+
def _load_yaml_document(path: Path) -> object:
|
|
202
|
+
return yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
@@ -14,11 +14,6 @@ ENV_FILE = Path(".env")
|
|
|
14
14
|
LOCK_FILE = Path(".env.lock")
|
|
15
15
|
BAKE_FILE = Path("compose.bake.yml")
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
def _resolve_service_shas() -> None:
|
|
19
|
-
os.environ.update(compute_service_shas(Path.cwd(), BAKE_FILE))
|
|
20
|
-
|
|
21
|
-
|
|
22
17
|
app = typer.Typer(add_completion=False)
|
|
23
18
|
|
|
24
19
|
|
|
@@ -77,7 +72,7 @@ def up(
|
|
|
77
72
|
run_build(gpu=gpu)
|
|
78
73
|
|
|
79
74
|
if BAKE_FILE.exists():
|
|
80
|
-
|
|
75
|
+
os.environ.update(compute_service_shas(Path.cwd(), BAKE_FILE))
|
|
81
76
|
|
|
82
77
|
profile_flag = "--profile keycloak " if auth_mode == "keycloak" else ""
|
|
83
78
|
if native:
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
name: CI
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
branches: [main]
|
|
6
|
-
pull_request:
|
|
7
|
-
branches: [main]
|
|
8
|
-
|
|
9
|
-
concurrency:
|
|
10
|
-
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
|
11
|
-
cancel-in-progress: true
|
|
12
|
-
|
|
13
|
-
jobs:
|
|
14
|
-
check:
|
|
15
|
-
uses: outernet-foundation/python-devkit/.github/workflows/check.yml@19b4656947784ce1d3225e35a96a21c45f7725f9
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -6,17 +6,6 @@ VALID_AUTH_MODES = ("keycloak", "disabled")
|
|
|
6
6
|
DEFAULT_AUTH_MODE = "keycloak"
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
def parse_env_file(path: Path) -> dict[str, str]:
|
|
10
|
-
result: dict[str, str] = {}
|
|
11
|
-
for raw_line in path.read_text().splitlines():
|
|
12
|
-
line = raw_line.strip()
|
|
13
|
-
if not line or line.startswith("#") or "=" not in line:
|
|
14
|
-
continue
|
|
15
|
-
key, _, value = line.partition("=")
|
|
16
|
-
result[key.strip()] = value.strip().strip("'\"")
|
|
17
|
-
return result
|
|
18
|
-
|
|
19
|
-
|
|
20
9
|
def resolve_auth_mode(env_file: Path) -> str:
|
|
21
10
|
file_values = parse_env_file(env_file)
|
|
22
11
|
public_url = environ.get("PUBLIC_URL") or file_values.get("PUBLIC_URL")
|
|
@@ -37,3 +26,14 @@ def resolve_auth_mode(env_file: Path) -> str:
|
|
|
37
26
|
)
|
|
38
27
|
|
|
39
28
|
return auth_mode
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_env_file(path: Path) -> dict[str, str]:
|
|
32
|
+
result: dict[str, str] = {}
|
|
33
|
+
for raw_line in path.read_text().splitlines():
|
|
34
|
+
line = raw_line.strip()
|
|
35
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
36
|
+
continue
|
|
37
|
+
key, _, value = line.partition("=")
|
|
38
|
+
result[key.strip()] = value.strip().strip("'\"")
|
|
39
|
+
return result
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|