devcake-cli 0.1.0__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.
- devcake_cli/__init__.py +10 -0
- devcake_cli/__main__.py +8 -0
- devcake_cli/baker.py +15 -0
- devcake_cli/doctor.py +600 -0
- devcake_cli/down.py +37 -0
- devcake_cli/envfile.py +199 -0
- devcake_cli/main.py +209 -0
- devcake_cli/paths.py +29 -0
- devcake_cli/setup.py +933 -0
- devcake_cli/status.py +75 -0
- devcake_cli/up.py +676 -0
- devcake_cli-0.1.0.dist-info/METADATA +375 -0
- devcake_cli-0.1.0.dist-info/RECORD +17 -0
- devcake_cli-0.1.0.dist-info/WHEEL +5 -0
- devcake_cli-0.1.0.dist-info/entry_points.txt +2 -0
- devcake_cli-0.1.0.dist-info/licenses/LICENSE +674 -0
- devcake_cli-0.1.0.dist-info/top_level.txt +1 -0
devcake_cli/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""DevCake host CLI (userspace console script).
|
|
2
|
+
|
|
3
|
+
Import package name is ``devcake_cli`` so a checkout ``PYTHONPATH=…:app``
|
|
4
|
+
cannot shadow ``app/devcake``. Distribution / project name is ``devcake-cli``
|
|
5
|
+
(ADR-0038 Decision 3).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__all__ = ["__version__"]
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
devcake_cli/__main__.py
ADDED
devcake_cli/baker.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""``devcake baker run`` — thin wrapper over ``scripts/dev_factory.watch.main``.
|
|
2
|
+
|
|
3
|
+
Bake/receipt logic stays single-sited in ``dev_factory`` (ADR-0038 Decision 3 /
|
|
4
|
+
Honor Chokepoints). Supervisors keep ``PYTHONPATH=repo/scripts:repo/app`` so
|
|
5
|
+
``import dev_factory`` resolves the same way as ``python -m dev_factory``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run() -> int:
|
|
12
|
+
"""Enter the host baker loop. Returns the baker's exit code."""
|
|
13
|
+
from dev_factory.watch import main as watch_main
|
|
14
|
+
|
|
15
|
+
return int(watch_main() or 0)
|
devcake_cli/doctor.py
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
"""``devcake doctor`` — named preflight catalog (ADR-0038 Decision 1).
|
|
2
|
+
|
|
3
|
+
Never runs sudo / usermod / loginctl enable-linger. Exit 3 when a hard check
|
|
4
|
+
fails (steady-state would not work). Soft / platform-skip checks stay ok.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import platform
|
|
12
|
+
import shutil
|
|
13
|
+
import socket
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Callable, Sequence
|
|
19
|
+
|
|
20
|
+
from .paths import find_checkout_root
|
|
21
|
+
|
|
22
|
+
# Stable ids once shipped (ADR-0038 / CAKE-177 plan). Order is intentional.
|
|
23
|
+
CHECK_IDS: tuple[str, ...] = (
|
|
24
|
+
"docker_socket",
|
|
25
|
+
"docker_group",
|
|
26
|
+
"docker_gid",
|
|
27
|
+
"buildx",
|
|
28
|
+
"checkout_layout",
|
|
29
|
+
"digest_lockstep",
|
|
30
|
+
"user_session_linger",
|
|
31
|
+
"ports",
|
|
32
|
+
"baker_liveness",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Host ports the control plane publishes on loopback (docs/13).
|
|
36
|
+
_CONTROL_PORTS: tuple[tuple[int, str], ...] = (
|
|
37
|
+
(8080, "admin"),
|
|
38
|
+
(8525, "dagu UI"),
|
|
39
|
+
(5080, "OpenObserve"),
|
|
40
|
+
(3300, "Gitea"),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class CheckResult:
|
|
46
|
+
id: str
|
|
47
|
+
ok: bool
|
|
48
|
+
detail: str
|
|
49
|
+
hard: bool = True # hard failure → exit 3 when ok is False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _sock_path() -> Path:
|
|
53
|
+
return Path(os.environ.get("DOCKER_SOCK", "/var/run/docker.sock"))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def check_docker_socket(*, sock: Path | None = None) -> CheckResult:
|
|
57
|
+
path = sock or _sock_path()
|
|
58
|
+
if not path.exists():
|
|
59
|
+
return CheckResult(
|
|
60
|
+
id="docker_socket",
|
|
61
|
+
ok=False,
|
|
62
|
+
detail=(
|
|
63
|
+
f"Docker socket not found at {path}. "
|
|
64
|
+
f"Start the Docker daemon (or Docker Desktop), or set DOCKER_SOCK "
|
|
65
|
+
f"to the socket path."
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
if not os.access(path, os.R_OK):
|
|
69
|
+
return CheckResult(
|
|
70
|
+
id="docker_socket",
|
|
71
|
+
ok=False,
|
|
72
|
+
detail=(
|
|
73
|
+
f"Docker socket {path} exists but is not readable by this user. "
|
|
74
|
+
f"Fix permissions or join the docker group (see docker_group check)."
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
return CheckResult(
|
|
78
|
+
id="docker_socket",
|
|
79
|
+
ok=True,
|
|
80
|
+
detail=f"socket readable at {path}",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def check_docker_group(*, sock: Path | None = None) -> CheckResult:
|
|
85
|
+
"""Linux: user should be in the docker group when the sock is group-owned.
|
|
86
|
+
|
|
87
|
+
macOS / Docker Desktop typically uses a different access model — report ok
|
|
88
|
+
with an explanatory detail when not Linux.
|
|
89
|
+
"""
|
|
90
|
+
if platform.system() != "Linux":
|
|
91
|
+
return CheckResult(
|
|
92
|
+
id="docker_group",
|
|
93
|
+
ok=True,
|
|
94
|
+
detail=f"skipped on {platform.system()} (no Linux docker group)",
|
|
95
|
+
hard=False,
|
|
96
|
+
)
|
|
97
|
+
path = sock or _sock_path()
|
|
98
|
+
try:
|
|
99
|
+
import grp
|
|
100
|
+
|
|
101
|
+
st = path.stat() if path.exists() else None
|
|
102
|
+
if st is None:
|
|
103
|
+
return CheckResult(
|
|
104
|
+
id="docker_group",
|
|
105
|
+
ok=False,
|
|
106
|
+
detail=(
|
|
107
|
+
"cannot verify docker group membership — socket missing "
|
|
108
|
+
f"({path}). Start Docker first."
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
try:
|
|
112
|
+
group = grp.getgrgid(st.st_gid)
|
|
113
|
+
gname = group.gr_name
|
|
114
|
+
except KeyError:
|
|
115
|
+
gname = str(st.st_gid)
|
|
116
|
+
# Root-owned socket (gid 0) is common on Desktop / rootful engines —
|
|
117
|
+
# group membership is not the remedy then.
|
|
118
|
+
if st.st_gid == 0:
|
|
119
|
+
return CheckResult(
|
|
120
|
+
id="docker_group",
|
|
121
|
+
ok=True,
|
|
122
|
+
detail="socket gid is 0 (root group); docker group N/A",
|
|
123
|
+
hard=False,
|
|
124
|
+
)
|
|
125
|
+
user = os.environ.get("USER") or os.environ.get("LOGNAME") or ""
|
|
126
|
+
try:
|
|
127
|
+
import pwd
|
|
128
|
+
|
|
129
|
+
user = user or pwd.getpwuid(os.getuid()).pw_name
|
|
130
|
+
except KeyError:
|
|
131
|
+
pass
|
|
132
|
+
try:
|
|
133
|
+
members = set(grp.getgrnam(gname).gr_mem)
|
|
134
|
+
# primary group also counts
|
|
135
|
+
if os.getgid() == st.st_gid or user in members:
|
|
136
|
+
return CheckResult(
|
|
137
|
+
id="docker_group",
|
|
138
|
+
ok=True,
|
|
139
|
+
detail=f"user {user!r} is in group {gname!r}",
|
|
140
|
+
)
|
|
141
|
+
except KeyError:
|
|
142
|
+
pass
|
|
143
|
+
return CheckResult(
|
|
144
|
+
id="docker_group",
|
|
145
|
+
ok=False,
|
|
146
|
+
detail=(
|
|
147
|
+
f"user {user!r} is not in group {gname!r} (socket gid {st.st_gid}). "
|
|
148
|
+
f"One-time fix (printed only; this CLI will not run it): "
|
|
149
|
+
f"sudo usermod -aG {gname} {user} && newgrp {gname}"
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
except OSError as exc:
|
|
153
|
+
return CheckResult(
|
|
154
|
+
id="docker_group",
|
|
155
|
+
ok=False,
|
|
156
|
+
detail=f"could not inspect socket group: {exc}",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def check_docker_gid(
|
|
161
|
+
*,
|
|
162
|
+
repo_root: Path | None,
|
|
163
|
+
sock: Path | None = None,
|
|
164
|
+
run: Callable[..., subprocess.CompletedProcess[str]] | None = None,
|
|
165
|
+
) -> CheckResult:
|
|
166
|
+
"""DOCKER_GID must be derivable via scripts/lib/stack_env.sh chokepoint."""
|
|
167
|
+
path = sock or _sock_path()
|
|
168
|
+
if repo_root is None or not (repo_root / "scripts" / "lib" / "stack_env.sh").is_file():
|
|
169
|
+
return CheckResult(
|
|
170
|
+
id="docker_gid",
|
|
171
|
+
ok=False,
|
|
172
|
+
detail=(
|
|
173
|
+
"cannot derive DOCKER_GID — checkout scripts/lib/stack_env.sh missing. "
|
|
174
|
+
"Run from the DevCake repo root."
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
if not path.exists():
|
|
178
|
+
return CheckResult(
|
|
179
|
+
id="docker_gid",
|
|
180
|
+
ok=False,
|
|
181
|
+
detail=(
|
|
182
|
+
f"cannot derive DOCKER_GID — socket {path} missing. "
|
|
183
|
+
f"Start Docker / Docker Desktop, or set DOCKER_SOCK."
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
runner = run or subprocess.run
|
|
187
|
+
helper = repo_root / "scripts" / "lib" / "stack_env.sh"
|
|
188
|
+
script = (
|
|
189
|
+
f"set -euo pipefail\n"
|
|
190
|
+
f"source {helper.as_posix()!r}\n"
|
|
191
|
+
f"devcake_docker_gid {path.as_posix()!r}\n"
|
|
192
|
+
)
|
|
193
|
+
try:
|
|
194
|
+
proc = runner(
|
|
195
|
+
["bash", "-c", script],
|
|
196
|
+
capture_output=True,
|
|
197
|
+
text=True,
|
|
198
|
+
cwd=str(repo_root),
|
|
199
|
+
timeout=30,
|
|
200
|
+
)
|
|
201
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
202
|
+
return CheckResult(
|
|
203
|
+
id="docker_gid",
|
|
204
|
+
ok=False,
|
|
205
|
+
detail=f"DOCKER_GID probe failed: {exc}",
|
|
206
|
+
)
|
|
207
|
+
gid = (proc.stdout or "").strip()
|
|
208
|
+
if proc.returncode != 0 or not gid.isdigit():
|
|
209
|
+
return CheckResult(
|
|
210
|
+
id="docker_gid",
|
|
211
|
+
ok=False,
|
|
212
|
+
detail=(
|
|
213
|
+
f"cannot derive DOCKER_GID from {path} "
|
|
214
|
+
f"(rc={proc.returncode}). Is the Docker daemon running? "
|
|
215
|
+
f"On Docker Desktop, ensure the engine is up; override via "
|
|
216
|
+
f"DOCKER_GID in .env only after confirming the in-container view."
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
return CheckResult(
|
|
220
|
+
id="docker_gid",
|
|
221
|
+
ok=True,
|
|
222
|
+
detail=f"DOCKER_GID={gid} derivable from {path}",
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def check_buildx(
|
|
227
|
+
*,
|
|
228
|
+
run: Callable[..., subprocess.CompletedProcess[str]] | None = None,
|
|
229
|
+
) -> CheckResult:
|
|
230
|
+
runner = run or subprocess.run
|
|
231
|
+
docker = shutil.which("docker")
|
|
232
|
+
if not docker:
|
|
233
|
+
return CheckResult(
|
|
234
|
+
id="buildx",
|
|
235
|
+
ok=False,
|
|
236
|
+
detail="docker not on PATH — install Docker Engine + Buildx (or Docker Desktop).",
|
|
237
|
+
)
|
|
238
|
+
try:
|
|
239
|
+
proc = runner(
|
|
240
|
+
[docker, "buildx", "version"],
|
|
241
|
+
capture_output=True,
|
|
242
|
+
text=True,
|
|
243
|
+
timeout=30,
|
|
244
|
+
)
|
|
245
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
246
|
+
return CheckResult(
|
|
247
|
+
id="buildx",
|
|
248
|
+
ok=False,
|
|
249
|
+
detail=f"docker buildx probe failed: {exc}",
|
|
250
|
+
)
|
|
251
|
+
out = ((proc.stdout or "") + (proc.stderr or "")).strip()
|
|
252
|
+
# Real Buildx accepts `bake -f <file>`; buildah's shim rejects `-f` as an
|
|
253
|
+
# unknown shorthand (and often prints "buildah" from `buildx version`).
|
|
254
|
+
try:
|
|
255
|
+
bake = runner(
|
|
256
|
+
[docker, "buildx", "bake", "-f", "/dev/null"],
|
|
257
|
+
capture_output=True,
|
|
258
|
+
text=True,
|
|
259
|
+
timeout=30,
|
|
260
|
+
)
|
|
261
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
262
|
+
return CheckResult(
|
|
263
|
+
id="buildx",
|
|
264
|
+
ok=False,
|
|
265
|
+
detail=f"docker buildx bake probe failed: {exc}",
|
|
266
|
+
)
|
|
267
|
+
bake_out = ((bake.stdout or "") + (bake.stderr or "")).lower()
|
|
268
|
+
if "unknown shorthand flag" in bake_out or "unknown flag: 'f'" in bake_out:
|
|
269
|
+
return CheckResult(
|
|
270
|
+
id="buildx",
|
|
271
|
+
ok=False,
|
|
272
|
+
detail=(
|
|
273
|
+
"docker buildx bake is not available (buildah/podman shim detected). "
|
|
274
|
+
"Install Docker Engine + Buildx (or Docker Desktop). "
|
|
275
|
+
f"version: {out.splitlines()[0] if out else 'unknown'}"
|
|
276
|
+
),
|
|
277
|
+
)
|
|
278
|
+
# Any other response (missing file, HCL parse error, help) means the bake
|
|
279
|
+
# subcommand exists — good enough for preflight.
|
|
280
|
+
if bake.returncode == 0 or "bake" in bake_out or "hcl" in bake_out or "open" in bake_out:
|
|
281
|
+
return CheckResult(
|
|
282
|
+
id="buildx",
|
|
283
|
+
ok=True,
|
|
284
|
+
detail=f"docker buildx bake available ({out.splitlines()[0] if out else 'ok'})",
|
|
285
|
+
)
|
|
286
|
+
return CheckResult(
|
|
287
|
+
id="buildx",
|
|
288
|
+
ok=False,
|
|
289
|
+
detail=(
|
|
290
|
+
"docker buildx bake is not available. Install/enable Docker Buildx "
|
|
291
|
+
"(Engine + buildx plugin), or use Docker Desktop. "
|
|
292
|
+
f"Probe output: {out[:200] or f'rc={proc.returncode}'}"
|
|
293
|
+
),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def check_checkout_layout(*, repo_root: Path | None) -> CheckResult:
|
|
298
|
+
if repo_root is None:
|
|
299
|
+
return CheckResult(
|
|
300
|
+
id="checkout_layout",
|
|
301
|
+
ok=False,
|
|
302
|
+
detail=(
|
|
303
|
+
"not a DevCake checkout (need docker-compose.yml + docker-bake.hcl). "
|
|
304
|
+
"cd to the repo root or re-clone."
|
|
305
|
+
),
|
|
306
|
+
)
|
|
307
|
+
missing: list[str] = []
|
|
308
|
+
for rel in (
|
|
309
|
+
"docker-compose.yml",
|
|
310
|
+
"docker-bake.hcl",
|
|
311
|
+
"scripts/dev_factory",
|
|
312
|
+
"scripts/lib/stack_env.sh",
|
|
313
|
+
"scripts/lib/baker_host.sh",
|
|
314
|
+
):
|
|
315
|
+
p = repo_root / rel
|
|
316
|
+
if not (p.is_file() or p.is_dir()):
|
|
317
|
+
missing.append(rel)
|
|
318
|
+
if missing:
|
|
319
|
+
return CheckResult(
|
|
320
|
+
id="checkout_layout",
|
|
321
|
+
ok=False,
|
|
322
|
+
detail=(
|
|
323
|
+
f"checkout incomplete under {repo_root}: missing {', '.join(missing)}. "
|
|
324
|
+
f"Re-clone or run from the DevCake repo root."
|
|
325
|
+
),
|
|
326
|
+
)
|
|
327
|
+
return CheckResult(
|
|
328
|
+
id="checkout_layout",
|
|
329
|
+
ok=True,
|
|
330
|
+
detail=f"checkout layout ok at {repo_root}",
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def check_digest_lockstep(*, repo_root: Path | None) -> CheckResult:
|
|
335
|
+
if repo_root is None:
|
|
336
|
+
return CheckResult(
|
|
337
|
+
id="digest_lockstep",
|
|
338
|
+
ok=False,
|
|
339
|
+
detail="no checkout — cannot check app digest tooling",
|
|
340
|
+
)
|
|
341
|
+
digest_py = repo_root / "scripts" / "app_digest.py"
|
|
342
|
+
if not digest_py.is_file():
|
|
343
|
+
return CheckResult(
|
|
344
|
+
id="digest_lockstep",
|
|
345
|
+
ok=False,
|
|
346
|
+
detail=(
|
|
347
|
+
"scripts/app_digest.py missing — digest-stamped bake cannot run. "
|
|
348
|
+
"Re-clone the repo."
|
|
349
|
+
),
|
|
350
|
+
)
|
|
351
|
+
# Without a live stack we only prove the tooling exists; next step is bake.
|
|
352
|
+
return CheckResult(
|
|
353
|
+
id="digest_lockstep",
|
|
354
|
+
ok=True,
|
|
355
|
+
detail=(
|
|
356
|
+
"app digest tooling present (scripts/app_digest.py). "
|
|
357
|
+
"For a lockstep bake+compose pin run: devcake up --bake"
|
|
358
|
+
),
|
|
359
|
+
hard=False,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def check_user_session_linger(
|
|
364
|
+
*,
|
|
365
|
+
run: Callable[..., subprocess.CompletedProcess[str]] | None = None,
|
|
366
|
+
) -> CheckResult:
|
|
367
|
+
"""Linux systemd --user linger; skipped on macOS / non-systemd hosts."""
|
|
368
|
+
system = platform.system()
|
|
369
|
+
if system == "Darwin":
|
|
370
|
+
return CheckResult(
|
|
371
|
+
id="user_session_linger",
|
|
372
|
+
ok=True,
|
|
373
|
+
detail="skipped on macOS (launchd; no linger)",
|
|
374
|
+
hard=False,
|
|
375
|
+
)
|
|
376
|
+
if system != "Linux":
|
|
377
|
+
return CheckResult(
|
|
378
|
+
id="user_session_linger",
|
|
379
|
+
ok=True,
|
|
380
|
+
detail=f"skipped on {system}",
|
|
381
|
+
hard=False,
|
|
382
|
+
)
|
|
383
|
+
runner = run or subprocess.run
|
|
384
|
+
if not shutil.which("systemctl"):
|
|
385
|
+
return CheckResult(
|
|
386
|
+
id="user_session_linger",
|
|
387
|
+
ok=True,
|
|
388
|
+
detail="systemd not present — baker will use flock respawn (DEGRADED)",
|
|
389
|
+
hard=False,
|
|
390
|
+
)
|
|
391
|
+
# Probe user bus; missing session → linger remedy (printed only).
|
|
392
|
+
try:
|
|
393
|
+
probe = runner(
|
|
394
|
+
["systemctl", "--user", "is-system-running"],
|
|
395
|
+
capture_output=True,
|
|
396
|
+
text=True,
|
|
397
|
+
timeout=15,
|
|
398
|
+
)
|
|
399
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
400
|
+
return CheckResult(
|
|
401
|
+
id="user_session_linger",
|
|
402
|
+
ok=False,
|
|
403
|
+
hard=False,
|
|
404
|
+
detail=(
|
|
405
|
+
f"systemd --user probe failed ({exc}). "
|
|
406
|
+
f"One-time fix (printed only): loginctl enable-linger "
|
|
407
|
+
f"{os.environ.get('USER', '$USER')} then re-login, then "
|
|
408
|
+
f"devcake up"
|
|
409
|
+
),
|
|
410
|
+
)
|
|
411
|
+
if probe.returncode == 0:
|
|
412
|
+
return CheckResult(
|
|
413
|
+
id="user_session_linger",
|
|
414
|
+
ok=True,
|
|
415
|
+
detail="systemd --user session available",
|
|
416
|
+
hard=False,
|
|
417
|
+
)
|
|
418
|
+
user = os.environ.get("USER") or os.environ.get("LOGNAME") or "$USER"
|
|
419
|
+
# Soft: up still works via flock respawn, but name the native path.
|
|
420
|
+
return CheckResult(
|
|
421
|
+
id="user_session_linger",
|
|
422
|
+
ok=False,
|
|
423
|
+
hard=False,
|
|
424
|
+
detail=(
|
|
425
|
+
"systemd --user session missing (baker would fall back to DEGRADED "
|
|
426
|
+
"flock respawn). One-time fix (printed only; this CLI will not run it): "
|
|
427
|
+
f"loginctl enable-linger {user} && re-login (or reboot), then "
|
|
428
|
+
f"devcake up"
|
|
429
|
+
),
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _port_in_use(port: int, host: str = "127.0.0.1") -> bool:
|
|
434
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
435
|
+
s.settimeout(0.3)
|
|
436
|
+
return s.connect_ex((host, port)) == 0
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def check_ports(
|
|
440
|
+
*,
|
|
441
|
+
ports: Sequence[tuple[int, str]] | None = None,
|
|
442
|
+
probe: Callable[[int], bool] | None = None,
|
|
443
|
+
) -> CheckResult:
|
|
444
|
+
"""Warn when documented control-plane ports are already occupied.
|
|
445
|
+
|
|
446
|
+
Occupied ports are soft failures: an already-running DevCake stack is a
|
|
447
|
+
valid state; a foreign listener needs operator attention before a fresh up.
|
|
448
|
+
"""
|
|
449
|
+
probe_fn = probe or _port_in_use
|
|
450
|
+
conflicts: list[str] = []
|
|
451
|
+
for port, label in ports or _CONTROL_PORTS:
|
|
452
|
+
if probe_fn(port):
|
|
453
|
+
conflicts.append(f"{port} ({label})")
|
|
454
|
+
if conflicts:
|
|
455
|
+
return CheckResult(
|
|
456
|
+
id="ports",
|
|
457
|
+
ok=False,
|
|
458
|
+
hard=False,
|
|
459
|
+
detail=(
|
|
460
|
+
"host ports already in use: "
|
|
461
|
+
+ ", ".join(conflicts)
|
|
462
|
+
+ ". If this is an existing DevCake stack, ok — use "
|
|
463
|
+
"devcake status. If another process holds them, free the port "
|
|
464
|
+
"or change the published bind in compose override."
|
|
465
|
+
),
|
|
466
|
+
)
|
|
467
|
+
return CheckResult(
|
|
468
|
+
id="ports",
|
|
469
|
+
ok=True,
|
|
470
|
+
detail="documented control-plane ports appear free on 127.0.0.1",
|
|
471
|
+
hard=False,
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def check_baker_liveness(*, repo_root: Path | None) -> CheckResult:
|
|
476
|
+
"""When .factory implies a baker, check pidfile liveness; else honest skip."""
|
|
477
|
+
if repo_root is None:
|
|
478
|
+
return CheckResult(
|
|
479
|
+
id="baker_liveness",
|
|
480
|
+
ok=True,
|
|
481
|
+
hard=False,
|
|
482
|
+
detail="no checkout — baker check skipped",
|
|
483
|
+
)
|
|
484
|
+
factory = repo_root / ".factory"
|
|
485
|
+
pidfile = factory / "watch.pid"
|
|
486
|
+
if not factory.is_dir() and not pidfile.is_file():
|
|
487
|
+
return CheckResult(
|
|
488
|
+
id="baker_liveness",
|
|
489
|
+
ok=True,
|
|
490
|
+
hard=False,
|
|
491
|
+
detail="no .factory yet — baker not expected; run: devcake up",
|
|
492
|
+
)
|
|
493
|
+
if not pidfile.is_file():
|
|
494
|
+
return CheckResult(
|
|
495
|
+
id="baker_liveness",
|
|
496
|
+
ok=False,
|
|
497
|
+
hard=False,
|
|
498
|
+
detail=(
|
|
499
|
+
f"{factory} exists but watch.pid is missing — baker not running. "
|
|
500
|
+
f"Fix: devcake up"
|
|
501
|
+
),
|
|
502
|
+
)
|
|
503
|
+
raw = pidfile.read_text(encoding="utf-8", errors="replace").strip()
|
|
504
|
+
try:
|
|
505
|
+
pid = int(raw.splitlines()[0].strip())
|
|
506
|
+
except (ValueError, IndexError):
|
|
507
|
+
return CheckResult(
|
|
508
|
+
id="baker_liveness",
|
|
509
|
+
ok=False,
|
|
510
|
+
hard=False,
|
|
511
|
+
detail=f"invalid pidfile {pidfile}: {raw!r}. Fix: devcake up",
|
|
512
|
+
)
|
|
513
|
+
try:
|
|
514
|
+
os.kill(pid, 0)
|
|
515
|
+
except ProcessLookupError:
|
|
516
|
+
return CheckResult(
|
|
517
|
+
id="baker_liveness",
|
|
518
|
+
ok=False,
|
|
519
|
+
hard=False,
|
|
520
|
+
detail=(
|
|
521
|
+
f"baker pidfile names pid {pid} but process is dead. "
|
|
522
|
+
f"Fix: devcake up"
|
|
523
|
+
),
|
|
524
|
+
)
|
|
525
|
+
except PermissionError:
|
|
526
|
+
# Process exists but we cannot signal it — treat as alive-ish.
|
|
527
|
+
return CheckResult(
|
|
528
|
+
id="baker_liveness",
|
|
529
|
+
ok=True,
|
|
530
|
+
hard=False,
|
|
531
|
+
detail=f"baker pid {pid} exists (signal not permitted)",
|
|
532
|
+
)
|
|
533
|
+
return CheckResult(
|
|
534
|
+
id="baker_liveness",
|
|
535
|
+
ok=True,
|
|
536
|
+
hard=False,
|
|
537
|
+
detail=f"baker pid {pid} is alive",
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def run_checks(
|
|
542
|
+
*,
|
|
543
|
+
repo_root: Path | None = None,
|
|
544
|
+
sock: Path | None = None,
|
|
545
|
+
run: Callable[..., subprocess.CompletedProcess[str]] | None = None,
|
|
546
|
+
port_probe: Callable[[int], bool] | None = None,
|
|
547
|
+
) -> list[CheckResult]:
|
|
548
|
+
root = repo_root if repo_root is not None else find_checkout_root()
|
|
549
|
+
sock_path = sock or _sock_path()
|
|
550
|
+
return [
|
|
551
|
+
check_docker_socket(sock=sock_path),
|
|
552
|
+
check_docker_group(sock=sock_path),
|
|
553
|
+
check_docker_gid(repo_root=root, sock=sock_path, run=run),
|
|
554
|
+
check_buildx(run=run),
|
|
555
|
+
check_checkout_layout(repo_root=root),
|
|
556
|
+
check_digest_lockstep(repo_root=root),
|
|
557
|
+
check_user_session_linger(run=run),
|
|
558
|
+
check_ports(probe=port_probe),
|
|
559
|
+
check_baker_liveness(repo_root=root),
|
|
560
|
+
]
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _format_human(checks: Sequence[CheckResult]) -> str:
|
|
564
|
+
lines: list[str] = ["devcake doctor"]
|
|
565
|
+
for c in checks:
|
|
566
|
+
mark = "ok" if c.ok else "FAIL"
|
|
567
|
+
lines.append(f" [{mark}] {c.id}: {c.detail}")
|
|
568
|
+
hard_fails = [c for c in checks if (not c.ok) and c.hard]
|
|
569
|
+
soft_fails = [c for c in checks if (not c.ok) and not c.hard]
|
|
570
|
+
if hard_fails:
|
|
571
|
+
lines.append(
|
|
572
|
+
f"preflight failed ({len(hard_fails)} hard) — steady-state would not work"
|
|
573
|
+
)
|
|
574
|
+
elif soft_fails:
|
|
575
|
+
lines.append(
|
|
576
|
+
f"warnings ({len(soft_fails)} soft) — see remedies above; "
|
|
577
|
+
f"hard preflight ok"
|
|
578
|
+
)
|
|
579
|
+
else:
|
|
580
|
+
lines.append("all checks ok")
|
|
581
|
+
return "\n".join(lines) + "\n"
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def run_doctor(*, as_json: bool = False, repo_root: Path | None = None) -> int:
|
|
585
|
+
"""Execute the catalog. Returns 0 or 3 (ADR-0038 exit table)."""
|
|
586
|
+
checks = run_checks(repo_root=repo_root)
|
|
587
|
+
hard_fail = any((not c.ok) and c.hard for c in checks)
|
|
588
|
+
ok = not hard_fail
|
|
589
|
+
if as_json:
|
|
590
|
+
payload = {
|
|
591
|
+
"ok": ok,
|
|
592
|
+
"schema_version": 1,
|
|
593
|
+
"checks": [
|
|
594
|
+
{"id": c.id, "ok": c.ok, "detail": c.detail} for c in checks
|
|
595
|
+
],
|
|
596
|
+
}
|
|
597
|
+
sys.stdout.write(json.dumps(payload, indent=2) + "\n")
|
|
598
|
+
else:
|
|
599
|
+
sys.stdout.write(_format_human(checks))
|
|
600
|
+
return 0 if ok else 3
|
devcake_cli/down.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""``devcake down`` — ``docker compose down`` without ``-v`` (ADR-0038)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .paths import require_checkout_root
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run_down(*, as_json: bool = False, repo: Path | None = None) -> int:
|
|
14
|
+
try:
|
|
15
|
+
root = repo or require_checkout_root()
|
|
16
|
+
except FileNotFoundError as exc:
|
|
17
|
+
sys.stderr.write(f"devcake down: {exc}\n")
|
|
18
|
+
return 3
|
|
19
|
+
|
|
20
|
+
argv = ["docker", "compose", "down"]
|
|
21
|
+
# Never pass -v in v1 (ADR-0038 Decision 1).
|
|
22
|
+
assert "-v" not in argv
|
|
23
|
+
if not as_json:
|
|
24
|
+
sys.stdout.write("── docker compose down\n")
|
|
25
|
+
proc = subprocess.run(argv, cwd=str(root))
|
|
26
|
+
ok = proc.returncode == 0
|
|
27
|
+
if as_json:
|
|
28
|
+
payload = {
|
|
29
|
+
"ok": ok,
|
|
30
|
+
"schema_version": 1,
|
|
31
|
+
"argv": argv,
|
|
32
|
+
"volumes_removed": False,
|
|
33
|
+
}
|
|
34
|
+
sys.stdout.write(json.dumps(payload, indent=2) + "\n")
|
|
35
|
+
if not ok:
|
|
36
|
+
return 4
|
|
37
|
+
return 0
|