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/up.py
ADDED
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
"""``devcake up`` — the stack bring-up verb (ADR-0038 Decision 1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from . import envfile
|
|
14
|
+
from .paths import require_checkout_root
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class UpOptions:
|
|
19
|
+
bake: bool = False
|
|
20
|
+
bake_targets: list[str] = field(default_factory=list)
|
|
21
|
+
dry_run: bool = False
|
|
22
|
+
foreground_baker: bool = False
|
|
23
|
+
no_hello_smoke: bool = False
|
|
24
|
+
compose_services: list[str] = field(default_factory=list)
|
|
25
|
+
as_json: bool = False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class UpPlan:
|
|
30
|
+
docker_gid: str
|
|
31
|
+
ws_host: str
|
|
32
|
+
tag: str
|
|
33
|
+
sock: str
|
|
34
|
+
bake: bool
|
|
35
|
+
bake_targets: list[str]
|
|
36
|
+
compose_services: list[str]
|
|
37
|
+
foreground_baker: bool
|
|
38
|
+
no_hello_smoke: bool
|
|
39
|
+
env_seeded: bool
|
|
40
|
+
env_generated: list[str]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def discover_docker_gid(repo: Path, sock: str) -> tuple[str, str]:
|
|
44
|
+
"""Return (gid, human_resolution_line). Raises RuntimeError on failure."""
|
|
45
|
+
script = f"""
|
|
46
|
+
set -euo pipefail
|
|
47
|
+
source "{repo / "scripts/lib/stack_env.sh"}"
|
|
48
|
+
SOCK={sock!r}
|
|
49
|
+
host_gid=""
|
|
50
|
+
in_gid=""
|
|
51
|
+
if ! host_gid="$(devcake_docker_gid "$SOCK")"; then
|
|
52
|
+
echo "error: cannot derive DOCKER_GID from $SOCK — is the Docker daemon running?" >&2
|
|
53
|
+
exit 1
|
|
54
|
+
fi
|
|
55
|
+
if in_gid="$(devcake_docker_gid_incontainer "$SOCK")"; then
|
|
56
|
+
GID="$in_gid"
|
|
57
|
+
if [[ "$in_gid" != "$host_gid" ]]; then
|
|
58
|
+
LINE="── DOCKER_GID=${{GID}} (in-container view; host path says ${{host_gid}})"
|
|
59
|
+
else
|
|
60
|
+
LINE="── DOCKER_GID=${{GID}} (from ${{SOCK}})"
|
|
61
|
+
fi
|
|
62
|
+
else
|
|
63
|
+
GID="$host_gid"
|
|
64
|
+
LINE="── DOCKER_GID=${{GID}} (from ${{SOCK}}; in-container probe failed — using host-stat)"
|
|
65
|
+
fi
|
|
66
|
+
printf '%s\\n' "$GID"
|
|
67
|
+
printf '%s\\n' "$LINE"
|
|
68
|
+
"""
|
|
69
|
+
proc = subprocess.run(
|
|
70
|
+
["bash", "-c", script],
|
|
71
|
+
cwd=str(repo),
|
|
72
|
+
text=True,
|
|
73
|
+
capture_output=True,
|
|
74
|
+
)
|
|
75
|
+
if proc.returncode != 0:
|
|
76
|
+
err = (proc.stderr or proc.stdout or "").strip()
|
|
77
|
+
raise RuntimeError(err or "cannot derive DOCKER_GID")
|
|
78
|
+
lines = [ln for ln in (proc.stdout or "").splitlines() if ln.strip()]
|
|
79
|
+
if len(lines) < 2:
|
|
80
|
+
raise RuntimeError("DOCKER_GID discovery returned incomplete output")
|
|
81
|
+
gid, line = lines[0].strip(), lines[1]
|
|
82
|
+
if not gid.isdigit():
|
|
83
|
+
raise RuntimeError(f"invalid DOCKER_GID: {gid!r}")
|
|
84
|
+
return gid, line
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def resolve_ws_host(repo: Path, env_path: Path) -> str:
|
|
88
|
+
script = f"""
|
|
89
|
+
set -euo pipefail
|
|
90
|
+
source "{repo / "scripts/lib/stack_env.sh"}"
|
|
91
|
+
devcake_ws_host {env_path.as_posix()!r} {repo.as_posix()!r}
|
|
92
|
+
"""
|
|
93
|
+
proc = subprocess.run(
|
|
94
|
+
["bash", "-c", script],
|
|
95
|
+
cwd=str(repo),
|
|
96
|
+
text=True,
|
|
97
|
+
capture_output=True,
|
|
98
|
+
)
|
|
99
|
+
if proc.returncode != 0:
|
|
100
|
+
raise RuntimeError((proc.stderr or "DEVCAKE_WS_HOST resolve failed").strip())
|
|
101
|
+
ws = (proc.stdout or "").strip()
|
|
102
|
+
if not ws.startswith("/"):
|
|
103
|
+
raise RuntimeError(
|
|
104
|
+
f"DEVCAKE_WS_HOST must be an absolute host path, got: {ws!r}"
|
|
105
|
+
)
|
|
106
|
+
return ws
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def resolve_tag(env_path: Path) -> str:
|
|
110
|
+
tag = os.environ.get("DEVCAKE_TAG", "").strip()
|
|
111
|
+
if tag:
|
|
112
|
+
return tag
|
|
113
|
+
data = envfile.parse_env_file(env_path)
|
|
114
|
+
tag = (data.get("DEVCAKE_TAG") or "").strip()
|
|
115
|
+
return tag or "latest"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _log(msg: str, *, as_json: bool) -> None:
|
|
119
|
+
# Progress always on stderr when --json; otherwise human on stdout.
|
|
120
|
+
stream = sys.stderr if as_json else sys.stdout
|
|
121
|
+
stream.write(msg + "\n")
|
|
122
|
+
stream.flush()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def prepare_env(
|
|
126
|
+
repo: Path,
|
|
127
|
+
opts: UpOptions,
|
|
128
|
+
*,
|
|
129
|
+
mutate: bool,
|
|
130
|
+
) -> tuple[UpPlan, str]:
|
|
131
|
+
"""Discover GID/WS/TAG, seed+auto-init .env. Returns (plan, gid_line)."""
|
|
132
|
+
sock = os.environ.get("DOCKER_SOCK", "/var/run/docker.sock")
|
|
133
|
+
env_path = repo / ".env"
|
|
134
|
+
example = repo / ".env.example"
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
gid, gid_line = discover_docker_gid(repo, sock)
|
|
138
|
+
except RuntimeError as exc:
|
|
139
|
+
msg = str(exc)
|
|
140
|
+
_log(msg if msg.startswith("error:") else f"error: {msg}", as_json=opts.as_json)
|
|
141
|
+
raise SystemExit(3) from exc # preflight
|
|
142
|
+
|
|
143
|
+
if gid == "0":
|
|
144
|
+
_log(
|
|
145
|
+
"── WARNING: DOCKER_GID=0 grants the dagu service root-group access to the\n"
|
|
146
|
+
" Docker socket (root-equivalent control of the engine host — see\n"
|
|
147
|
+
" docs/14-security.md). Any docker.sock grant is already root-equivalent;\n"
|
|
148
|
+
" this is not a new privilege class. Continuing non-interactively.",
|
|
149
|
+
as_json=opts.as_json,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
ws_host = resolve_ws_host(repo, env_path)
|
|
153
|
+
tag = resolve_tag(env_path)
|
|
154
|
+
_log(gid_line, as_json=opts.as_json)
|
|
155
|
+
_log(f"── DEVCAKE_WS_HOST={ws_host}", as_json=opts.as_json)
|
|
156
|
+
_log(f"── DEVCAKE_TAG={tag} (bake + compose lockstep)", as_json=opts.as_json)
|
|
157
|
+
|
|
158
|
+
env_seeded = False
|
|
159
|
+
env_generated: list[str] = []
|
|
160
|
+
|
|
161
|
+
if not env_path.is_file():
|
|
162
|
+
if not example.is_file():
|
|
163
|
+
_log(
|
|
164
|
+
"error: no .env and no .env.example — create .env with bootstrap passwords first",
|
|
165
|
+
as_json=opts.as_json,
|
|
166
|
+
)
|
|
167
|
+
raise SystemExit(3)
|
|
168
|
+
_log("── creating .env from .env.example", as_json=opts.as_json)
|
|
169
|
+
if mutate:
|
|
170
|
+
envfile.seed_env_from_example(env_path, example)
|
|
171
|
+
env_seeded = True
|
|
172
|
+
else:
|
|
173
|
+
env_seeded = True # would seed
|
|
174
|
+
|
|
175
|
+
if mutate and env_path.is_file():
|
|
176
|
+
env_generated = envfile.auto_init_bootstrap(env_path)
|
|
177
|
+
if env_generated:
|
|
178
|
+
_log(
|
|
179
|
+
f"── auto-init generated bootstrap keys: {', '.join(env_generated)}",
|
|
180
|
+
as_json=opts.as_json,
|
|
181
|
+
)
|
|
182
|
+
try:
|
|
183
|
+
envfile.validate_oo_passwords(env_path)
|
|
184
|
+
except ValueError as exc:
|
|
185
|
+
_log(f"error: {exc}", as_json=opts.as_json)
|
|
186
|
+
raise SystemExit(3) from exc
|
|
187
|
+
envfile.upsert_env_var("DOCKER_GID", gid, env_path)
|
|
188
|
+
envfile.upsert_env_var("DEVCAKE_WS_HOST", ws_host, env_path)
|
|
189
|
+
envfile.upsert_env_var("DEVCAKE_TAG", tag, env_path)
|
|
190
|
+
envfile.ensure_permission_floor(env_path)
|
|
191
|
+
Path(ws_host).mkdir(parents=True, exist_ok=True)
|
|
192
|
+
os.chmod(ws_host, 0o700)
|
|
193
|
+
elif not mutate and env_path.is_file():
|
|
194
|
+
# dry-run: still report what auto-init would generate without writing
|
|
195
|
+
data = envfile.parse_env_file(env_path)
|
|
196
|
+
for key in envfile.REQUIRED_BOOTSTRAP_KEYS:
|
|
197
|
+
proc_val = os.environ.get(key)
|
|
198
|
+
if proc_val is not None and not envfile.needs_generation(key, proc_val):
|
|
199
|
+
continue
|
|
200
|
+
existing = data.get(key, "")
|
|
201
|
+
if envfile.needs_generation(key, existing):
|
|
202
|
+
env_generated.append(key)
|
|
203
|
+
|
|
204
|
+
plan = UpPlan(
|
|
205
|
+
docker_gid=gid,
|
|
206
|
+
ws_host=ws_host,
|
|
207
|
+
tag=tag,
|
|
208
|
+
sock=sock,
|
|
209
|
+
bake=opts.bake,
|
|
210
|
+
bake_targets=list(opts.bake_targets),
|
|
211
|
+
compose_services=list(opts.compose_services),
|
|
212
|
+
foreground_baker=opts.foreground_baker,
|
|
213
|
+
no_hello_smoke=opts.no_hello_smoke,
|
|
214
|
+
env_seeded=env_seeded,
|
|
215
|
+
env_generated=env_generated,
|
|
216
|
+
)
|
|
217
|
+
return plan, gid_line
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _print_dry_run(plan: UpPlan, *, as_json: bool) -> None:
|
|
221
|
+
if as_json:
|
|
222
|
+
payload = {
|
|
223
|
+
"ok": True,
|
|
224
|
+
"schema_version": 1,
|
|
225
|
+
"dry_run": True,
|
|
226
|
+
"docker_gid": plan.docker_gid,
|
|
227
|
+
"devcake_ws_host": plan.ws_host,
|
|
228
|
+
"devcake_tag": plan.tag,
|
|
229
|
+
"bake": plan.bake,
|
|
230
|
+
"bake_targets": plan.bake_targets or ["app", "admin", "hello"],
|
|
231
|
+
"compose_services": plan.compose_services,
|
|
232
|
+
"foreground_baker": plan.foreground_baker,
|
|
233
|
+
"no_hello_smoke": plan.no_hello_smoke,
|
|
234
|
+
"env_seeded": plan.env_seeded,
|
|
235
|
+
"env_generated": plan.env_generated,
|
|
236
|
+
}
|
|
237
|
+
sys.stdout.write(json.dumps(payload, indent=2) + "\n")
|
|
238
|
+
return
|
|
239
|
+
_log(f"── would upsert DOCKER_GID={plan.docker_gid} in .env", as_json=False)
|
|
240
|
+
_log(
|
|
241
|
+
f"── would upsert DEVCAKE_WS_HOST={plan.ws_host} in .env (+ mkdir -p, chmod 700)",
|
|
242
|
+
as_json=False,
|
|
243
|
+
)
|
|
244
|
+
_log(f"── would upsert DEVCAKE_TAG={plan.tag} in .env", as_json=False)
|
|
245
|
+
if plan.env_generated:
|
|
246
|
+
_log(
|
|
247
|
+
f"── would auto-init bootstrap keys: {', '.join(plan.env_generated)}",
|
|
248
|
+
as_json=False,
|
|
249
|
+
)
|
|
250
|
+
if plan.bake:
|
|
251
|
+
_log("── would: docker compose stop dagu (deploy window — ADR-0025 R9)", as_json=False)
|
|
252
|
+
_log("── would: compute DEVCAKE_APP_DIGEST from scripts/app_digest.py", as_json=False)
|
|
253
|
+
targets = " ".join(plan.bake_targets) if plan.bake_targets else "app admin hello"
|
|
254
|
+
_log(f"── would: DEVCAKE_TAG={plan.tag} docker buildx bake {targets}", as_json=False)
|
|
255
|
+
if plan.no_hello_smoke:
|
|
256
|
+
_log("── would: skip hello dispatch smoke (--no-hello-smoke)", as_json=False)
|
|
257
|
+
else:
|
|
258
|
+
_log("── would: hello dispatch smoke (scripts/ci_dispatch_hello.sh)", as_json=False)
|
|
259
|
+
services = " ".join(plan.compose_services) if plan.compose_services else ""
|
|
260
|
+
_log(f"── would: docker compose up -d {services}".rstrip(), as_json=False)
|
|
261
|
+
if plan.foreground_baker:
|
|
262
|
+
_log(
|
|
263
|
+
"── would: run host baker in foreground (exec `devcake baker run`; no supervisor)",
|
|
264
|
+
as_json=False,
|
|
265
|
+
)
|
|
266
|
+
else:
|
|
267
|
+
_log(
|
|
268
|
+
"── would: start host baker detached (launchd / systemd --user / flock respawn; "
|
|
269
|
+
".factory/watch.pid) — not a compose service",
|
|
270
|
+
as_json=False,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _compose_env(plan: UpPlan) -> dict[str, str]:
|
|
275
|
+
env = os.environ.copy()
|
|
276
|
+
env["DOCKER_GID"] = plan.docker_gid
|
|
277
|
+
env["DEVCAKE_WS_HOST"] = plan.ws_host
|
|
278
|
+
env["DEVCAKE_TAG"] = plan.tag
|
|
279
|
+
return env
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _bake(repo: Path, plan: UpPlan, *, as_json: bool) -> None:
|
|
283
|
+
env = _compose_env(plan)
|
|
284
|
+
# Deploy window: stop dagu before multi-minute bake (ADR-0025 R9).
|
|
285
|
+
ps = subprocess.run(
|
|
286
|
+
["docker", "compose", "ps", "-q", "dagu"],
|
|
287
|
+
cwd=str(repo),
|
|
288
|
+
env=env,
|
|
289
|
+
text=True,
|
|
290
|
+
capture_output=True,
|
|
291
|
+
)
|
|
292
|
+
dagu_was_up = bool((ps.stdout or "").strip())
|
|
293
|
+
restore_needed = False
|
|
294
|
+
|
|
295
|
+
def _restore_dagu(*_args: object) -> None:
|
|
296
|
+
nonlocal restore_needed
|
|
297
|
+
if not restore_needed:
|
|
298
|
+
return
|
|
299
|
+
_log(
|
|
300
|
+
"── bake interrupted/failed: restarting dagu (half-down stack guard)",
|
|
301
|
+
as_json=as_json,
|
|
302
|
+
)
|
|
303
|
+
subprocess.run(
|
|
304
|
+
["docker", "compose", "start", "dagu"],
|
|
305
|
+
cwd=str(repo),
|
|
306
|
+
env=env,
|
|
307
|
+
check=False,
|
|
308
|
+
)
|
|
309
|
+
restore_needed = False
|
|
310
|
+
|
|
311
|
+
prev_sigint = signal.getsignal(signal.SIGINT)
|
|
312
|
+
prev_sigterm = signal.getsignal(signal.SIGTERM)
|
|
313
|
+
|
|
314
|
+
def _on_interrupt(signum: int, frame: object) -> None:
|
|
315
|
+
_restore_dagu()
|
|
316
|
+
signal.signal(signum, signal.SIG_DFL)
|
|
317
|
+
os.kill(os.getpid(), signum)
|
|
318
|
+
|
|
319
|
+
if dagu_was_up:
|
|
320
|
+
_log(
|
|
321
|
+
"── stopping dagu before bake (deploy window — ADR-0025 R9)",
|
|
322
|
+
as_json=as_json,
|
|
323
|
+
)
|
|
324
|
+
subprocess.run(
|
|
325
|
+
["docker", "compose", "stop", "dagu"],
|
|
326
|
+
cwd=str(repo),
|
|
327
|
+
env=env,
|
|
328
|
+
check=False,
|
|
329
|
+
)
|
|
330
|
+
restore_needed = True
|
|
331
|
+
signal.signal(signal.SIGINT, _on_interrupt)
|
|
332
|
+
signal.signal(signal.SIGTERM, _on_interrupt)
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
digest_proc = subprocess.run(
|
|
336
|
+
[sys.executable, str(repo / "scripts" / "app_digest.py")],
|
|
337
|
+
cwd=str(repo),
|
|
338
|
+
text=True,
|
|
339
|
+
capture_output=True,
|
|
340
|
+
check=True,
|
|
341
|
+
)
|
|
342
|
+
digest = (digest_proc.stdout or "").strip()
|
|
343
|
+
env["DEVCAKE_APP_DIGEST"] = digest
|
|
344
|
+
_log(f"── DEVCAKE_APP_DIGEST={digest}", as_json=as_json)
|
|
345
|
+
targets = plan.bake_targets or ["app", "admin", "hello"]
|
|
346
|
+
_log(f"── docker buildx bake {' '.join(targets)}", as_json=as_json)
|
|
347
|
+
bake = subprocess.run(
|
|
348
|
+
["docker", "buildx", "bake", *targets],
|
|
349
|
+
cwd=str(repo),
|
|
350
|
+
env=env,
|
|
351
|
+
)
|
|
352
|
+
if bake.returncode != 0:
|
|
353
|
+
_restore_dagu()
|
|
354
|
+
raise SystemExit(4)
|
|
355
|
+
except Exception:
|
|
356
|
+
_restore_dagu()
|
|
357
|
+
raise
|
|
358
|
+
finally:
|
|
359
|
+
restore_needed = False
|
|
360
|
+
signal.signal(signal.SIGINT, prev_sigint)
|
|
361
|
+
signal.signal(signal.SIGTERM, prev_sigterm)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _compose_up(repo: Path, plan: UpPlan, *, as_json: bool) -> None:
|
|
365
|
+
env = _compose_env(plan)
|
|
366
|
+
argv = ["docker", "compose", "up", "-d", *plan.compose_services]
|
|
367
|
+
_log("── " + " ".join(argv[0:4] + (plan.compose_services or [])), as_json=as_json)
|
|
368
|
+
proc = subprocess.run(argv, cwd=str(repo), env=env)
|
|
369
|
+
if proc.returncode != 0:
|
|
370
|
+
raise SystemExit(4)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _health_gate(repo: Path, plan: UpPlan, *, as_json: bool) -> None:
|
|
374
|
+
env = _compose_env(plan)
|
|
375
|
+
_log("── waiting for the app to report healthy…", as_json=as_json)
|
|
376
|
+
live_py = (
|
|
377
|
+
"import urllib.request as u; "
|
|
378
|
+
"u.urlopen('http://localhost:8000/api/v1/health/live', timeout=3)"
|
|
379
|
+
)
|
|
380
|
+
ok = False
|
|
381
|
+
for _ in range(30):
|
|
382
|
+
proc = subprocess.run(
|
|
383
|
+
["docker", "compose", "exec", "-T", "app", "python", "-c", live_py],
|
|
384
|
+
cwd=str(repo),
|
|
385
|
+
env=env,
|
|
386
|
+
capture_output=True,
|
|
387
|
+
)
|
|
388
|
+
if proc.returncode == 0:
|
|
389
|
+
ok = True
|
|
390
|
+
break
|
|
391
|
+
time.sleep(2)
|
|
392
|
+
if ok:
|
|
393
|
+
_log("── app live ✓", as_json=as_json)
|
|
394
|
+
deps_py = """
|
|
395
|
+
import base64, json, os, urllib.request
|
|
396
|
+
u, p = os.environ.get("ADMIN_USER", ""), os.environ.get("ADMIN_PASSWORD", "")
|
|
397
|
+
tok = base64.b64encode(f"{u}:{p}".encode()).decode()
|
|
398
|
+
req = urllib.request.Request(
|
|
399
|
+
"http://localhost:8000/api/v1/health",
|
|
400
|
+
headers={"Authorization": f"Basic {tok}"})
|
|
401
|
+
body = json.loads(urllib.request.urlopen(req, timeout=10).read())
|
|
402
|
+
bad = [k for k in ("redis", "dagu") if body.get(k) is False]
|
|
403
|
+
raise SystemExit(1 if bad else 0)
|
|
404
|
+
"""
|
|
405
|
+
deps = subprocess.run(
|
|
406
|
+
["docker", "compose", "exec", "-T", "app", "python", "-c", deps_py],
|
|
407
|
+
cwd=str(repo),
|
|
408
|
+
env=env,
|
|
409
|
+
capture_output=True,
|
|
410
|
+
)
|
|
411
|
+
if deps.returncode == 0:
|
|
412
|
+
_log("── app redis+dagu probes ok ✓", as_json=as_json)
|
|
413
|
+
else:
|
|
414
|
+
_log(
|
|
415
|
+
"── WARNING: app is live but redis/dagu probe is red — check: "
|
|
416
|
+
"docker compose logs --tail=50 app",
|
|
417
|
+
as_json=as_json,
|
|
418
|
+
)
|
|
419
|
+
else:
|
|
420
|
+
_log(
|
|
421
|
+
"── WARNING: app did not report live within ~60s. The stack is up,\n"
|
|
422
|
+
" but the app may be wedged — check: docker compose logs --tail=50 app\n"
|
|
423
|
+
" (OpenObserve crash-loop on a weak root password? also: "
|
|
424
|
+
"docker compose logs openobserve)",
|
|
425
|
+
as_json=as_json,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
# Fatal: dagu sock writability as uid 1000 / gid DOCKER_GID
|
|
429
|
+
_log("── verifying dagu can write the Docker socket…", as_json=as_json)
|
|
430
|
+
sock_ok = False
|
|
431
|
+
for _ in range(15):
|
|
432
|
+
proc = subprocess.run(
|
|
433
|
+
[
|
|
434
|
+
"docker",
|
|
435
|
+
"compose",
|
|
436
|
+
"exec",
|
|
437
|
+
"-T",
|
|
438
|
+
"--user",
|
|
439
|
+
f"1000:{plan.docker_gid}",
|
|
440
|
+
"dagu",
|
|
441
|
+
"sh",
|
|
442
|
+
"-c",
|
|
443
|
+
"test -w /var/run/docker.sock",
|
|
444
|
+
],
|
|
445
|
+
cwd=str(repo),
|
|
446
|
+
env=env,
|
|
447
|
+
capture_output=True,
|
|
448
|
+
)
|
|
449
|
+
if proc.returncode == 0:
|
|
450
|
+
sock_ok = True
|
|
451
|
+
break
|
|
452
|
+
time.sleep(2)
|
|
453
|
+
if not sock_ok:
|
|
454
|
+
obs = subprocess.run(
|
|
455
|
+
[
|
|
456
|
+
"docker",
|
|
457
|
+
"compose",
|
|
458
|
+
"exec",
|
|
459
|
+
"-T",
|
|
460
|
+
"dagu",
|
|
461
|
+
"sh",
|
|
462
|
+
"-c",
|
|
463
|
+
"stat -c %g /var/run/docker.sock",
|
|
464
|
+
],
|
|
465
|
+
cwd=str(repo),
|
|
466
|
+
env=env,
|
|
467
|
+
capture_output=True,
|
|
468
|
+
text=True,
|
|
469
|
+
)
|
|
470
|
+
obs_gid = (obs.stdout or "").strip() or "unknown"
|
|
471
|
+
_log(
|
|
472
|
+
f"error: dagu cannot write /var/run/docker.sock "
|
|
473
|
+
f"(resolved DOCKER_GID={plan.docker_gid}; socket gid inside the "
|
|
474
|
+
f"container={obs_gid}).\n"
|
|
475
|
+
f"Fix: set the gid the container actually sees, e.g. in "
|
|
476
|
+
f"docker-compose.override.yml:\n\n"
|
|
477
|
+
f"services:\n"
|
|
478
|
+
f" dagu:\n"
|
|
479
|
+
f" environment:\n"
|
|
480
|
+
f' DOCKER_GID: "0"\n\n'
|
|
481
|
+
f"Then re-run: devcake up",
|
|
482
|
+
as_json=as_json,
|
|
483
|
+
)
|
|
484
|
+
raise SystemExit(4)
|
|
485
|
+
_log("── dagu docker.sock writable ✓", as_json=as_json)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _hello_smoke(repo: Path, plan: UpPlan, *, as_json: bool) -> None:
|
|
489
|
+
if plan.no_hello_smoke:
|
|
490
|
+
_log("── skipping hello dispatch smoke (--no-hello-smoke)", as_json=as_json)
|
|
491
|
+
return
|
|
492
|
+
data = envfile.parse_env_file(repo / ".env")
|
|
493
|
+
user = data.get("ADMIN_USER", "")
|
|
494
|
+
password = data.get("ADMIN_PASSWORD", "")
|
|
495
|
+
if not user or not password:
|
|
496
|
+
_log(
|
|
497
|
+
"── WARNING: skipping hello dispatch smoke — ADMIN_USER / "
|
|
498
|
+
"ADMIN_PASSWORD missing from .env",
|
|
499
|
+
as_json=as_json,
|
|
500
|
+
)
|
|
501
|
+
return
|
|
502
|
+
_log("── hello dispatch smoke (scripts/ci_dispatch_hello.sh)…", as_json=as_json)
|
|
503
|
+
env = _compose_env(plan)
|
|
504
|
+
env["ADMIN_USER"] = user
|
|
505
|
+
env["ADMIN_PASSWORD"] = password
|
|
506
|
+
proc = subprocess.run(
|
|
507
|
+
[str(repo / "scripts" / "ci_dispatch_hello.sh")],
|
|
508
|
+
cwd=str(repo),
|
|
509
|
+
env=env,
|
|
510
|
+
)
|
|
511
|
+
if proc.returncode != 0:
|
|
512
|
+
_log(
|
|
513
|
+
"── ERROR: hello dispatch smoke failed — the stack is up but Dagu\n"
|
|
514
|
+
" cannot complete a Dev container run. Check:\n"
|
|
515
|
+
" docker compose logs --tail=50 dagu\n"
|
|
516
|
+
" Look for the preceding 'hello run_id=…' line for the run id.\n"
|
|
517
|
+
" Known cause: Docker-socket permissions (Docker Desktop hosts especially).",
|
|
518
|
+
as_json=as_json,
|
|
519
|
+
)
|
|
520
|
+
raise SystemExit(4)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _start_baker(repo: Path, plan: UpPlan, *, as_json: bool) -> None:
|
|
524
|
+
factory = repo / ".factory"
|
|
525
|
+
factory.mkdir(parents=True, exist_ok=True)
|
|
526
|
+
pidfile = factory / "watch.pid"
|
|
527
|
+
logfile = factory / "watch.log"
|
|
528
|
+
env = _compose_env(plan)
|
|
529
|
+
data = envfile.parse_env_file(repo / ".env")
|
|
530
|
+
for key in ("OO_INGEST_EMAIL", "OO_INGEST_PASSWORD", "OO_ORG"):
|
|
531
|
+
if key in data and data[key]:
|
|
532
|
+
env[key] = data[key]
|
|
533
|
+
env["PYTHONUNBUFFERED"] = "1"
|
|
534
|
+
env["PYTHONPATH"] = f"{repo / 'scripts'}:{repo / 'app'}"
|
|
535
|
+
env["DEVCAKE_OO_URL"] = "http://127.0.0.1:5080"
|
|
536
|
+
env["DEVCAKE_FACTORY_DIR"] = str(factory)
|
|
537
|
+
env["DEVCAKE_FACTORY_LOG"] = str(logfile)
|
|
538
|
+
|
|
539
|
+
# prepare pidfile + displace via baker_host.sh chokepoint
|
|
540
|
+
prep = f"""
|
|
541
|
+
set -euo pipefail
|
|
542
|
+
source "{repo / "scripts/lib/baker_host.sh"}"
|
|
543
|
+
devcake_baker_prepare_pidfile {pidfile.as_posix()!r}
|
|
544
|
+
devcake_baker_displace_orphans {factory.as_posix()!r}
|
|
545
|
+
"""
|
|
546
|
+
proc = subprocess.run(["bash", "-c", prep], cwd=str(repo), env=env, text=True)
|
|
547
|
+
if proc.returncode != 0:
|
|
548
|
+
raise SystemExit(6)
|
|
549
|
+
|
|
550
|
+
if plan.foreground_baker:
|
|
551
|
+
resolve = f"""
|
|
552
|
+
set -euo pipefail
|
|
553
|
+
source "{repo / "scripts/lib/baker_host.sh"}"
|
|
554
|
+
devcake_baker_resolve_entry
|
|
555
|
+
"""
|
|
556
|
+
entry = subprocess.run(
|
|
557
|
+
["bash", "-c", resolve],
|
|
558
|
+
cwd=str(repo),
|
|
559
|
+
env=env,
|
|
560
|
+
text=True,
|
|
561
|
+
capture_output=True,
|
|
562
|
+
check=True,
|
|
563
|
+
)
|
|
564
|
+
cmd = (entry.stdout or "").strip()
|
|
565
|
+
_log(
|
|
566
|
+
f"── host baker in foreground (pidfile {pidfile}; Ctrl-C to stop)",
|
|
567
|
+
as_json=as_json,
|
|
568
|
+
)
|
|
569
|
+
_log(
|
|
570
|
+
"── stack up (admin: http://localhost:8080); baker takes this terminal",
|
|
571
|
+
as_json=as_json,
|
|
572
|
+
)
|
|
573
|
+
pidfile.write_text(f"{os.getpid()}\n", encoding="utf-8")
|
|
574
|
+
# Replace this process with the baker entry (shell-words).
|
|
575
|
+
os.execvp("bash", ["bash", "-c", f"exec {cmd}"])
|
|
576
|
+
|
|
577
|
+
if not logfile.is_file():
|
|
578
|
+
logfile.write_text("", encoding="utf-8")
|
|
579
|
+
baseline = logfile.stat().st_size
|
|
580
|
+
|
|
581
|
+
install = f"""
|
|
582
|
+
set -euo pipefail
|
|
583
|
+
source "{repo / "scripts/lib/baker_host.sh"}"
|
|
584
|
+
REPO={repo.as_posix()!r}
|
|
585
|
+
FACTORY={factory.as_posix()!r}
|
|
586
|
+
LOG={logfile.as_posix()!r}
|
|
587
|
+
PIDFILE={pidfile.as_posix()!r}
|
|
588
|
+
BASELINE={baseline}
|
|
589
|
+
PLAT="$(devcake_baker_platform)"
|
|
590
|
+
SUPERVISED=0
|
|
591
|
+
LAUNCH=""
|
|
592
|
+
PID=""
|
|
593
|
+
if [[ "$PLAT" == "darwin" ]]; then
|
|
594
|
+
if devcake_baker_launchd_available \\
|
|
595
|
+
&& devcake_baker_launchd_install "$REPO" "$FACTORY" "$LOG" "$PIDFILE"; then
|
|
596
|
+
PID="$(cat "$PIDFILE" 2>/dev/null || true)"
|
|
597
|
+
LAUNCH="launchctl kickstart gui/$(id -u)/${{DEVCAKE_BAKER_LAUNCHD_LABEL:-com.devcake.baker}}"
|
|
598
|
+
SUPERVISED=1
|
|
599
|
+
fi
|
|
600
|
+
elif [[ "$PLAT" == "linux" ]] && devcake_baker_systemd_available; then
|
|
601
|
+
if devcake_baker_systemd_install "$REPO" "$FACTORY" "$LOG" "$PIDFILE"; then
|
|
602
|
+
PID="$(cat "$PIDFILE" 2>/dev/null || true)"
|
|
603
|
+
LAUNCH="systemctl --user start ${{DEVCAKE_BAKER_UNIT:-devcake-baker.service}}"
|
|
604
|
+
SUPERVISED=1
|
|
605
|
+
else
|
|
606
|
+
systemctl --user stop "${{DEVCAKE_BAKER_UNIT:-devcake-baker.service}}" \\
|
|
607
|
+
>/dev/null 2>&1 || true
|
|
608
|
+
fi
|
|
609
|
+
fi
|
|
610
|
+
if [[ "$SUPERVISED" -eq 0 ]]; then
|
|
611
|
+
case "$PLAT" in
|
|
612
|
+
darwin) devcake_baker_degraded_gap "launchd install/start failed" ;;
|
|
613
|
+
linux) devcake_baker_degraded_gap "$(devcake_baker_linux_degraded_reason)" ;;
|
|
614
|
+
*) devcake_baker_degraded_gap "platform ${{PLAT}} has no native supervisor" ;;
|
|
615
|
+
esac
|
|
616
|
+
if ! devcake_baker_respawn_install "$REPO" "$FACTORY" "$LOG" "$PIDFILE"; then
|
|
617
|
+
echo "── failed to install flock-guarded baker respawn supervisor" >&2
|
|
618
|
+
exit 6
|
|
619
|
+
fi
|
|
620
|
+
PID="$(cat "$PIDFILE" 2>/dev/null || true)"
|
|
621
|
+
LAUNCH="baker_respawn.sh $REPO $FACTORY"
|
|
622
|
+
fi
|
|
623
|
+
devcake_baker_wait_liveness "$PID" "$LOG" "$PIDFILE" "$LAUNCH" 12 "$BASELINE"
|
|
624
|
+
"""
|
|
625
|
+
proc = subprocess.run(["bash", "-c", install], cwd=str(repo), env=env)
|
|
626
|
+
if proc.returncode != 0:
|
|
627
|
+
raise SystemExit(6)
|
|
628
|
+
_log("── stack starting (admin: http://localhost:8080)", as_json=as_json)
|
|
629
|
+
_log(
|
|
630
|
+
" bootstrap passwords come from .env (auto-init fills empties); "
|
|
631
|
+
"operator secrets via Config.",
|
|
632
|
+
as_json=as_json,
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def run_up(opts: UpOptions, *, repo: Path | None = None) -> int:
|
|
637
|
+
try:
|
|
638
|
+
root = repo or require_checkout_root()
|
|
639
|
+
except FileNotFoundError as exc:
|
|
640
|
+
sys.stderr.write(f"devcake up: {exc}\n")
|
|
641
|
+
return 3
|
|
642
|
+
|
|
643
|
+
try:
|
|
644
|
+
plan, _ = prepare_env(root, opts, mutate=not opts.dry_run)
|
|
645
|
+
except SystemExit as exc:
|
|
646
|
+
return int(exc.code or 1)
|
|
647
|
+
|
|
648
|
+
if opts.dry_run:
|
|
649
|
+
_print_dry_run(plan, as_json=opts.as_json)
|
|
650
|
+
return 0
|
|
651
|
+
|
|
652
|
+
try:
|
|
653
|
+
if plan.bake:
|
|
654
|
+
_bake(root, plan, as_json=opts.as_json)
|
|
655
|
+
_compose_up(root, plan, as_json=opts.as_json)
|
|
656
|
+
_health_gate(root, plan, as_json=opts.as_json)
|
|
657
|
+
if plan.bake:
|
|
658
|
+
_hello_smoke(root, plan, as_json=opts.as_json)
|
|
659
|
+
_start_baker(root, plan, as_json=opts.as_json)
|
|
660
|
+
except SystemExit as exc:
|
|
661
|
+
return int(exc.code or 1)
|
|
662
|
+
|
|
663
|
+
if opts.as_json:
|
|
664
|
+
payload = {
|
|
665
|
+
"ok": True,
|
|
666
|
+
"schema_version": 1,
|
|
667
|
+
"dry_run": False,
|
|
668
|
+
"docker_gid": plan.docker_gid,
|
|
669
|
+
"devcake_ws_host": plan.ws_host,
|
|
670
|
+
"devcake_tag": plan.tag,
|
|
671
|
+
"bake": plan.bake,
|
|
672
|
+
"env_seeded": plan.env_seeded,
|
|
673
|
+
"env_generated": plan.env_generated,
|
|
674
|
+
}
|
|
675
|
+
sys.stdout.write(json.dumps(payload, indent=2) + "\n")
|
|
676
|
+
return 0
|