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/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