rig-cli 1.0.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.
Files changed (59) hide show
  1. rig/__init__.py +6 -0
  2. rig/__main__.py +8 -0
  3. rig/cli.py +107 -0
  4. rig/commands/__init__.py +23 -0
  5. rig/commands/check.py +125 -0
  6. rig/commands/common.py +149 -0
  7. rig/commands/dispatch.py +87 -0
  8. rig/commands/down/__init__.py +139 -0
  9. rig/commands/down/runner.py +136 -0
  10. rig/commands/init.py +148 -0
  11. rig/commands/logs.py +146 -0
  12. rig/commands/prune.py +142 -0
  13. rig/commands/ps.py +149 -0
  14. rig/commands/status.py +149 -0
  15. rig/commands/up/__init__.py +111 -0
  16. rig/commands/up/context.py +36 -0
  17. rig/commands/up/loop.py +150 -0
  18. rig/commands/up/relink.py +88 -0
  19. rig/commands/up/rollback.py +49 -0
  20. rig/commands/up/runner.py +118 -0
  21. rig/commands/up/service.py +136 -0
  22. rig/compose/__init__.py +1 -0
  23. rig/compose/client.py +144 -0
  24. rig/compose/context.py +56 -0
  25. rig/compose/discovery.py +121 -0
  26. rig/compose/docker.py +117 -0
  27. rig/compose/starter.py +145 -0
  28. rig/compose/stopper.py +71 -0
  29. rig/compose/supervisor.py +78 -0
  30. rig/core/__init__.py +1 -0
  31. rig/core/constants.py +65 -0
  32. rig/core/env.py +83 -0
  33. rig/core/errors.py +74 -0
  34. rig/core/identity.py +141 -0
  35. rig/core/locks.py +112 -0
  36. rig/core/state.py +150 -0
  37. rig/core/terminal.py +145 -0
  38. rig/manifest/__init__.py +1 -0
  39. rig/manifest/detector.py +138 -0
  40. rig/manifest/inspect.py +18 -0
  41. rig/manifest/loader.py +146 -0
  42. rig/manifest/models.py +129 -0
  43. rig/manifest/parser.py +123 -0
  44. rig/manifest/schema.py +89 -0
  45. rig/net/__init__.py +1 -0
  46. rig/net/health.py +76 -0
  47. rig/net/ports.py +141 -0
  48. rig/net/probe.py +56 -0
  49. rig/net/registry.py +132 -0
  50. rig/parser.py +72 -0
  51. rig/proc/__init__.py +1 -0
  52. rig/proc/process.py +133 -0
  53. rig/proc/record.py +54 -0
  54. rig/proc/spawn.py +130 -0
  55. rig/proc/teardown.py +139 -0
  56. rig_cli-1.0.0.dist-info/METADATA +503 -0
  57. rig_cli-1.0.0.dist-info/RECORD +59 -0
  58. rig_cli-1.0.0.dist-info/WHEEL +4 -0
  59. rig_cli-1.0.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,136 @@
1
+ """Checkout-specific teardown and service stopping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from rig.commands.common import (
10
+ _consumers_of,
11
+ _merged_depends_on,
12
+ _resolve_manifest_context,
13
+ _stop_record,
14
+ reverse_dependency_order,
15
+ )
16
+ from rig.core.constants import EXIT_OK, EXIT_OP_FAILED, EXIT_REFUSED
17
+ from rig.core.errors import RigError, format_human_error, print_json_envelope
18
+ from rig.core.locks import exclusive_lock
19
+ from rig.core.state import read_state, write_state
20
+ from rig.core.terminal import get_theme
21
+ from rig.net.ports import wait_for_port_release
22
+
23
+
24
+ def _stop_service_checkout(
25
+ name: str, state: dict[str, Any], ctx: tuple[Path, Path]
26
+ ) -> tuple[bool, str | None]:
27
+ root_path, state_path = ctx
28
+ if not (rec := state.get("services", {}).get(name)):
29
+ return False, None
30
+ outcome = _stop_record(rec, root_path)
31
+ if outcome in ("terminated", "killed", "stale"):
32
+ port = rec.get("port")
33
+ state["services"].pop(name, None)
34
+ write_state(state_path, state)
35
+ held = bool(port and not wait_for_port_release(int(port)))
36
+ return True, f"{name}: port {port} still held" if held else None
37
+ return False, f"{name}: {outcome}"
38
+
39
+
40
+ def _stop_target_step(
41
+ name: str,
42
+ state: dict[str, Any],
43
+ ctx: tuple[Any, tuple[Path, Path], tuple[list[str], list[str], set[str]]],
44
+ ) -> None:
45
+ manifest, paths, tracking = ctx
46
+ root_path, state_path = paths
47
+ stopped, failures, failed_svcs = tracking
48
+ services = state.get("services", {})
49
+ if name not in services:
50
+ return
51
+ deps = [d for d in _consumers_of(name, services, manifest) if d in failed_svcs or d in services]
52
+ if deps:
53
+ failures.append(f"{name}: preserved because dependent(s) {', '.join(deps)} are active")
54
+ return
55
+ ok, err = _stop_service_checkout(name, state, (root_path, state_path))
56
+ if ok:
57
+ stopped.append(name)
58
+ else:
59
+ failed_svcs.add(name)
60
+ if err:
61
+ failures.append(err)
62
+
63
+
64
+ def _teardown_targets(
65
+ targets: list[str], state: dict[str, Any], ctx: tuple[Any, Path, Path]
66
+ ) -> tuple[list[str], list[str]]:
67
+ manifest, root_path, state_path = ctx
68
+ stopped, failures, failed_svcs = [], [], set()
69
+ step_ctx = (manifest, (root_path, state_path), (stopped, failures, failed_svcs))
70
+ for name in targets:
71
+ _stop_target_step(name, state, step_ctx)
72
+ return stopped, failures
73
+
74
+
75
+ def _teardown_scope(
76
+ manifest: Any, state: dict[str, Any], scope: str
77
+ ) -> tuple[list[str], list[str]]:
78
+ services = state.get("services", {})
79
+ td_spec = {
80
+ n: {"depends_on": sorted(_merged_depends_on(n, services.get(n, {}), manifest))}
81
+ for n in manifest.teardown_scope(scope)
82
+ }
83
+ targets = reverse_dependency_order(td_spec)
84
+ blocked = [
85
+ f"{n} is still needed by running service {d}"
86
+ for n in targets
87
+ for d in _consumers_of(n, services, manifest)
88
+ if d in services and d not in targets
89
+ ]
90
+ return targets, blocked
91
+
92
+
93
+ def _handle_blocked(blocked: list[str], as_json: bool) -> int:
94
+ msg = "; ".join(blocked)
95
+ hint = "stop dependent first or use --scope full"
96
+ if as_json:
97
+ raise RigError(msg, code="E_REFUSED", exit_code=EXIT_REFUSED, hint=hint)
98
+ err = RigError(
99
+ msg,
100
+ code="E_REFUSED",
101
+ exit_code=EXIT_REFUSED,
102
+ headline="Cannot stop service because other active services depend on it",
103
+ context="\n".join(f"Blocked: {b}" for b in blocked),
104
+ hint="Stop dependents first, or run 'rig down --scope full'",
105
+ )
106
+ th = get_theme()
107
+ print(format_human_error(err, th), end="", file=sys.stderr)
108
+ return EXIT_REFUSED
109
+
110
+
111
+ def _finish_down_output(info: tuple[str, str, list[str], list[str]], as_json: bool) -> int:
112
+ inst, proj, stopped, failures = info
113
+ if as_json:
114
+ data = {"instance": inst, "project": proj, "stopped": stopped, "failures": failures}
115
+ print_json_envelope("down", data, ok=not failures)
116
+ elif stopped:
117
+ th = get_theme()
118
+ print(f"\n {th.green}✓ Stack stopped.{th.r} Stopped {len(stopped)} service(s).\n")
119
+ return EXIT_OP_FAILED if failures else EXIT_OK
120
+
121
+
122
+ def down_checkout(root: Path, manifest_path: Path, *args: Any, **kwargs: Any) -> int:
123
+ argv = list(args)
124
+ scope = kwargs.get("scope") or (argv.pop(0) if argv else "full")
125
+ as_json = bool(kwargs.get("as_json") or (argv.pop(0) if argv else False))
126
+ raw_m, root_path, inst, state_path, lock_path = _resolve_manifest_context(root, manifest_path)
127
+ with exclusive_lock(lock_path):
128
+ state = read_state(state_path)
129
+ manifest = raw_m.for_mode(state.get("mode")) if raw_m.modes else raw_m
130
+ targets, blocked = _teardown_scope(manifest, state, scope)
131
+ if blocked:
132
+ return _handle_blocked(blocked, as_json)
133
+ stopped, failures = _teardown_targets(targets, state, (manifest, root_path, state_path))
134
+ state["generation"] = int(state.get("generation", 0)) + 1
135
+ write_state(state_path, state)
136
+ return _finish_down_output((inst, manifest.project, stopped, failures), as_json)
rig/commands/init.py ADDED
@@ -0,0 +1,148 @@
1
+ """Auto-detection and project scaffolding command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import os
8
+ import re
9
+ import sys
10
+ import tempfile
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from rig.commands.up import cmd_up
15
+ from rig.core.constants import EXIT_OK, EXIT_USAGE
16
+ from rig.core.errors import RigError, print_json_envelope
17
+ from rig.manifest.detector import (
18
+ _extract_compose_services,
19
+ classify_compose_service,
20
+ detect_backend,
21
+ detect_frontend,
22
+ )
23
+
24
+ DEFAULT_COMPOSE_SERVICES = {
25
+ "postgres": (5432, "PostgreSQL database container"),
26
+ "redis": (6379, "Redis cache container"),
27
+ }
28
+
29
+
30
+ def _add_compose_service(base: dict[str, Any], detected: str, item: tuple[str, Any]) -> None:
31
+ svc, blk = item
32
+ if (kind := classify_compose_service(svc, blk)) and kind not in base:
33
+ port, desc = DEFAULT_COMPOSE_SERVICES[kind]
34
+ base[kind] = {
35
+ "type": "compose",
36
+ "compose_file": detected,
37
+ "compose_service": svc,
38
+ "compose_port": port,
39
+ "description": desc,
40
+ }
41
+
42
+
43
+ def _detect_compose(root: Path, base_services: dict[str, Any]) -> str | None:
44
+ cands = ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml")
45
+ if not (detected := next((c for c in cands if (root / c).is_file()), None)):
46
+ return None
47
+ try:
48
+ for item in _extract_compose_services((root / detected).read_text()).items():
49
+ _add_compose_service(base_services, detected, item)
50
+ except OSError:
51
+ pass
52
+ return detected
53
+
54
+
55
+ def _write_temp_manifest(target: Path, content: str, root: Path) -> None:
56
+ tmp: str | None = None
57
+ try:
58
+ with tempfile.NamedTemporaryFile(
59
+ mode="w", dir=root, delete=False, prefix=".rig.json.tmp."
60
+ ) as h:
61
+ tmp = h.name
62
+ h.write(content)
63
+ h.flush()
64
+ os.fsync(h.fileno())
65
+ os.chmod(tmp, 0o644)
66
+ os.replace(tmp, target)
67
+ except BaseException:
68
+ if tmp is not None:
69
+ with contextlib.suppress(OSError):
70
+ os.unlink(tmp)
71
+ raise
72
+
73
+
74
+ def _write_manifest_file(target: Path, content: str, opts: tuple[bool, Path]) -> None:
75
+ force, root = opts
76
+ if force:
77
+ _write_temp_manifest(target, content, root)
78
+ return
79
+ try:
80
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
81
+ with os.fdopen(os.open(str(target), flags, 0o644), "w") as f:
82
+ f.write(content)
83
+ except FileExistsError:
84
+ msg = f"'{target}' already exists. Pass --force to overwrite."
85
+ raise RigError(msg, code="E_USAGE", exit_code=EXIT_USAGE) from None
86
+
87
+
88
+ def _build_init_manifest(root: Path) -> dict[str, Any]:
89
+ proj = re.sub(r"[^a-zA-Z0-9]+", "-", root.name.lower()).strip("-") or "app"
90
+ base_services, native_services = {}, {}
91
+ _detect_compose(root, base_services)
92
+ has_backend = detect_backend(root, native_services, base_services)
93
+ detect_frontend(root, native_services, has_backend)
94
+
95
+ if not base_services and not native_services:
96
+ native_services["web"] = {
97
+ "type": "port",
98
+ "cwd": ".",
99
+ "command": [sys.executable, "-m", "http.server", "--bind", "127.0.0.1", "{port}"],
100
+ "healthcheck_path": "/",
101
+ "description": "Local HTTP static file server",
102
+ }
103
+
104
+ data: dict[str, Any] = {
105
+ "$schema": "https://raw.githubusercontent.com/evgesha9400/rig/main/rig.schema.json",
106
+ "project": proj,
107
+ }
108
+ if base_services:
109
+ data["services"] = base_services
110
+ if native_services:
111
+ data["default_mode"], data["modes"] = "native", {"native": {"services": native_services}}
112
+ return data
113
+
114
+
115
+ def _finish_init(target: Path, data: dict[str, Any], opts: tuple[bool, bool, Path]) -> int:
116
+ up, as_json, root = opts
117
+ if up:
118
+ return cmd_up(root, target, as_json=as_json)
119
+ payload = {"manifest": data, "path": str(target), "created": True}
120
+ print_json_envelope("init", payload) if as_json else print(f"Created {target}")
121
+ return EXIT_OK
122
+
123
+
124
+ def _unpack_init_flags(args: tuple[Any, ...], kwargs: dict[str, Any]) -> tuple[bool, ...]:
125
+ argv = list(args)
126
+ keys = ("dry_run", "force", "up", "as_json")
127
+ return tuple(bool(kwargs.get(k) or (argv.pop(0) if argv else False)) for k in keys)
128
+
129
+
130
+ def cmd_init(root: Path, *args: Any, **kwargs: Any) -> int:
131
+ dry_run, force, up, as_json = _unpack_init_flags(args, kwargs)
132
+ resolved_root = Path(root).resolve()
133
+ target = resolved_root / "rig.json"
134
+ if (target.is_symlink() or target.exists()) and not (force or dry_run):
135
+ msg = f"'{target}' already exists. Pass --force to overwrite."
136
+ hint = "pass --force to overwrite the existing manifest"
137
+ raise RigError(msg, code="E_USAGE", exit_code=EXIT_USAGE, hint=hint)
138
+
139
+ data = _build_init_manifest(resolved_root)
140
+ content = json.dumps(data, indent=2) + "\n"
141
+ if dry_run:
142
+ print_json_envelope("init", {"manifest": data, "dry_run": True}) if as_json else print(
143
+ content, end=""
144
+ )
145
+ return EXIT_OK
146
+
147
+ _write_manifest_file(target, content, (force, resolved_root))
148
+ return _finish_init(target, data, (up, as_json, resolved_root))
rig/commands/logs.py ADDED
@@ -0,0 +1,146 @@
1
+ """Process log inspection and tailing command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import collections
6
+ import sys
7
+ from collections.abc import Mapping
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from rig.commands.common import _resolve_manifest_context, record_status
12
+ from rig.compose.client import run_compose
13
+ from rig.core.constants import EXIT_NOT_FOUND, EXIT_OK
14
+ from rig.core.errors import RigError, print_json_envelope
15
+ from rig.core.identity import get_instances_dir
16
+ from rig.core.state import read_state
17
+ from rig.core.terminal import get_theme, highlight_log_line
18
+ from rig.manifest.models import Service
19
+
20
+
21
+ def _tail_log_file(path: Path, n: int) -> list[str]:
22
+ if not path.is_file():
23
+ return []
24
+ with open(path, encoding="utf-8", errors="replace") as f:
25
+ return list(collections.deque(f, maxlen=n))
26
+
27
+
28
+ def _fetch_compose_logs(
29
+ target: tuple[Mapping[str, Any], Service],
30
+ env_ctx: tuple[Path, str, int],
31
+ ) -> list[str]:
32
+ rec, svc = target
33
+ root, inst, n = env_ctx
34
+ cfile = Path(rec.get("compose_file") or (root / str(svc.compose_file)))
35
+ c_svc = str(rec.get("compose_service") or svc.compose_service)
36
+ cmd = ["logs", f"--tail={n}", c_svc]
37
+ res = run_compose(inst, root, cfile, cmd, context=rec.get("docker_context"))
38
+ return res.stdout.splitlines(keepends=True) if res.stdout else []
39
+
40
+
41
+ def _resolve_target_service(service_name: str | None, manifest_services: Mapping[str, Any]) -> str:
42
+ if service_name and service_name in manifest_services:
43
+ return service_name
44
+ avail = ", ".join(sorted(manifest_services.keys())) or "none"
45
+ if not service_name:
46
+ if len(manifest_services) == 1:
47
+ return next(iter(manifest_services.keys()))
48
+ raise RigError(
49
+ f"service name is required when multiple services exist (available: {avail})",
50
+ code="E_USAGE",
51
+ hint=f"Specify one of: {avail}",
52
+ )
53
+ raise RigError(
54
+ f"service '{service_name}' not found in manifest",
55
+ code="E_SERVICE_NOT_FOUND",
56
+ hint=f"Available services: {avail}",
57
+ )
58
+
59
+
60
+ def _print_logs_formatted(
61
+ target_info: tuple[str, str, str | None, str],
62
+ path_display: str,
63
+ lines: list[str],
64
+ ) -> None:
65
+ th = get_theme()
66
+ project, service, mode, st = target_info
67
+ st_badge = f"{th.green}● running{th.r}" if st == "running" else f"{th.red}○ {st}{th.r}"
68
+ mode_tag = f" {th.cyan}[{mode}]{th.r}" if mode else ""
69
+ hdr_left = f" {th.b}{project}{th.r}{mode_tag} {th.d}·{th.r} {th.b}{service}{th.r}"
70
+ print(f"\n{hdr_left} {th.d}·{th.r} {st_badge} {th.d}(last {len(lines)} lines){th.r}")
71
+ print(f" {th.d}➜{th.r} {path_display}\n")
72
+ for raw in lines:
73
+ cleaned = raw.rstrip("\r\n")
74
+ print(f" {th.cyan}│{th.r} {highlight_log_line(cleaned, th)}")
75
+ print()
76
+
77
+
78
+ def _render_logs(
79
+ target_info: tuple[str, str, str | None, str],
80
+ path_display: str,
81
+ ctx: tuple[list[str], bool],
82
+ ) -> int:
83
+ lines, as_json = ctx
84
+ project, service, mode, st = target_info
85
+ if as_json:
86
+ data = {
87
+ "project": project,
88
+ "service": service,
89
+ "mode": mode,
90
+ "status": st,
91
+ "path": path_display,
92
+ "count": len(lines),
93
+ "lines": [line.rstrip("\r\n") for line in lines],
94
+ }
95
+ print_json_envelope("logs", data)
96
+ return EXIT_OK
97
+ th = get_theme()
98
+ if not th.r:
99
+ for line in lines:
100
+ sys.stdout.write(line if line.endswith("\n") else line + "\n")
101
+ return EXIT_OK
102
+ _print_logs_formatted(target_info, path_display, lines)
103
+ return EXIT_OK
104
+
105
+
106
+ def _read_service_log(
107
+ service_def: Service,
108
+ ctx: tuple[Mapping[str, Any], Path, str],
109
+ tail: int,
110
+ ) -> tuple[list[str], str]:
111
+ rec, root, inst = ctx
112
+ if service_def.type == "compose":
113
+ lines = _fetch_compose_logs((rec, service_def), (root, inst, tail))
114
+ svc_target = rec.get("compose_service") or service_def.compose_service
115
+ return lines, f"docker://{svc_target!s}"
116
+ inst_dir = get_instances_dir() / inst
117
+ log_file = Path(rec.get("log") or (inst_dir / "logs" / f"{service_def.name}.log"))
118
+ if not log_file.is_file():
119
+ raise RigError(
120
+ f"no log file found for service '{service_def.name}' at {log_file}",
121
+ code="E_LOG_NOT_FOUND",
122
+ exit_code=EXIT_NOT_FOUND,
123
+ hint=f"Run 'rig up {service_def.name}' first to start the service",
124
+ )
125
+ return _tail_log_file(log_file, tail), f"file://{log_file.resolve()}"
126
+
127
+
128
+ def cmd_logs(root: Path, manifest_path: Path, *args: Any, **kwargs: Any) -> int:
129
+ """Inspect and tail logs for a single service in the current rig checkout."""
130
+ argv = list(args)
131
+ service = kwargs.get("service") or (argv.pop(0) if argv else None)
132
+ tail = int(kwargs.get("tail") or (argv.pop(0) if argv else 50))
133
+ mode = kwargs.get("mode") or (argv.pop(0) if argv else None)
134
+ as_json = bool(kwargs.get("as_json") or (argv.pop(0) if argv else False))
135
+
136
+ mf, res_root, inst_id, st_path, _ = _resolve_manifest_context(root, manifest_path)
137
+ if mode:
138
+ mf = mf.with_mode(mode)
139
+ sname = _resolve_target_service(service, mf.services)
140
+ state = read_state(st_path) if st_path.is_file() else {}
141
+ srec = state.get("services", {}).get(sname, {})
142
+ svc_def = mf.services[sname]
143
+ st = record_status(srec, res_root) if srec else "stopped"
144
+
145
+ lines, path_display = _read_service_log(svc_def, (srec, res_root, inst_id), tail)
146
+ return _render_logs((mf.project, sname, mf.active_mode, st), path_display, (lines, as_json))
rig/commands/prune.py ADDED
@@ -0,0 +1,142 @@
1
+ """Dead and orphaned instance registry cleanup command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import fcntl
7
+ import os
8
+ import shutil
9
+ from collections.abc import Mapping
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from rig.commands.common import (
14
+ _consumers_of,
15
+ _stop_record,
16
+ compose_record_status,
17
+ reverse_dependency_order,
18
+ )
19
+ from rig.core.constants import (
20
+ EXIT_OK,
21
+ EXIT_OP_FAILED,
22
+ FILE_MODE_PRIVATE,
23
+ LOCK_FILE_NAME,
24
+ STATE_FILE_NAME,
25
+ )
26
+ from rig.core.errors import print_json_envelope
27
+ from rig.core.identity import get_instances_dir
28
+ from rig.core.state import read_state, write_state
29
+ from rig.proc.process import identity_matches, pid_alive
30
+ from rig.proc.teardown import pgid_alive
31
+
32
+
33
+ def _is_service_live(record: Mapping[str, Any], root: Path) -> bool:
34
+ if record.get("type") == "compose":
35
+ return compose_record_status(record, root) != "absent"
36
+ pid, pgid = record.get("pid"), record.get("pgid")
37
+ ok_pid = isinstance(pid, int) and pid_alive(pid) and identity_matches(record)
38
+ return ok_pid or (isinstance(pgid, int) and pgid_alive(pgid))
39
+
40
+
41
+ def _instance_live_services(
42
+ services: Mapping[str, Mapping[str, Any]], root: Path
43
+ ) -> dict[str, Mapping[str, Any]]:
44
+ return {n: r for n, r in services.items() if _is_service_live(r, root)}
45
+
46
+
47
+ def _stop_instance_service(
48
+ name: str, services: dict[str, Any], ctx: tuple[Path, set[str]]
49
+ ) -> str | None:
50
+ root, failed = ctx
51
+ if not (rec := services.get(name)):
52
+ return None
53
+ if any(dep in failed for dep in _consumers_of(name, services)):
54
+ failed.add(name)
55
+ return f"{name}: preserved because dependent is still running"
56
+ outcome = _stop_record(rec, root, remove=True)
57
+ if outcome not in ("terminated", "killed", "stale"):
58
+ failed.add(name)
59
+ return f"{name}: {outcome}"
60
+ services.pop(name, None)
61
+ return None
62
+
63
+
64
+ def _force_stop_instance(state: dict[str, Any], state_file: Path, root: Path) -> list[str]:
65
+ services = state.get("services", {})
66
+ failures, failed_services = [], set()
67
+ ctx = (root, failed_services)
68
+ for name in reverse_dependency_order(services):
69
+ if (err := _stop_instance_service(name, services, ctx)) is not None:
70
+ failures.append(err)
71
+ state["generation"] = int(state.get("generation", 0)) + 1
72
+ write_state(state_file, state)
73
+ return failures
74
+
75
+
76
+ def _clear_instance_dir(inst_dir: Path) -> bool:
77
+ items = [i for i in inst_dir.iterdir() if i.name != LOCK_FILE_NAME]
78
+ for item in items:
79
+ if item.is_dir() and not item.is_symlink():
80
+ shutil.rmtree(item, ignore_errors=True)
81
+ else:
82
+ with contextlib.suppress(OSError):
83
+ item.unlink()
84
+ return bool(items)
85
+
86
+
87
+ def _try_lock_fd(lock_file: Path) -> int | None:
88
+ try:
89
+ flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0)
90
+ fd = os.open(str(lock_file), flags, FILE_MODE_PRIVATE)
91
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
92
+ except (BlockingIOError, OSError):
93
+ return None
94
+ else:
95
+ return fd
96
+
97
+
98
+ def _inspect_and_prune(inst_dir: Path, force: bool) -> tuple[str | None, dict[str, Any] | None]:
99
+ state_file = inst_dir / STATE_FILE_NAME
100
+ state = read_state(state_file) if state_file.is_file() else {}
101
+ services, root_str = state.get("services", {}), state.get("root")
102
+ root_exists = bool(root_str and Path(root_str).is_dir())
103
+ ref_root = Path(root_str) if root_exists else inst_dir
104
+
105
+ live = _instance_live_services(services, ref_root)
106
+ if live and not force:
107
+ return None, None
108
+ if live:
109
+ if errs := _force_stop_instance(state, state_file, ref_root):
110
+ return None, {"instance": inst_dir.name, "failed": errs}
111
+ services = state.get("services", {})
112
+
113
+ can_prune = force or not root_exists or len(services) == 0
114
+ return (inst_dir.name, None) if can_prune and _clear_instance_dir(inst_dir) else (None, None)
115
+
116
+
117
+ def _prune_instance(inst_dir: Path, force: bool) -> tuple[str | None, dict[str, Any] | None]:
118
+ if (lock_fd := _try_lock_fd(inst_dir / LOCK_FILE_NAME)) is None:
119
+ return None, None
120
+ try:
121
+ return _inspect_and_prune(inst_dir, force)
122
+ finally:
123
+ with contextlib.suppress(OSError):
124
+ os.close(lock_fd)
125
+
126
+
127
+ def _collect_prune_results(
128
+ instances_dir: Path, force: bool
129
+ ) -> tuple[list[str], list[dict[str, Any]]]:
130
+ dirs = [directory for directory in sorted(instances_dir.iterdir()) if directory.is_dir()]
131
+ results = [_prune_instance(directory, force) for directory in dirs]
132
+ return [pruned for pruned, _ in results if pruned], [failed for _, failed in results if failed]
133
+
134
+
135
+ def cmd_prune(force: bool = False, as_json: bool = False) -> int:
136
+ instances_dir = get_instances_dir()
137
+ pruned, failed = (
138
+ _collect_prune_results(instances_dir, force) if instances_dir.is_dir() else ([], [])
139
+ )
140
+ if as_json:
141
+ print_json_envelope("prune", {"pruned": pruned, "failed": failed}, ok=not failed)
142
+ return EXIT_OP_FAILED if failed else EXIT_OK