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
rig/commands/ps.py ADDED
@@ -0,0 +1,149 @@
1
+ """Process status inspection across all machine checkouts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from rig.commands.common import compose_record_alive
11
+ from rig.core.constants import EXIT_OK, LOCK_FILE_NAME, STATE_FILE_NAME
12
+ from rig.core.errors import print_json_envelope
13
+ from rig.core.identity import get_instances_dir, is_locked
14
+ from rig.core.state import read_state
15
+ from rig.core.terminal import contract_path, format_table, get_theme, middle_truncate, visible_width
16
+ from rig.net.health import wait_for_http
17
+ from rig.proc.process import identity_matches, pid_alive
18
+
19
+ SUMMARY_MAX_LENGTH = 24
20
+
21
+
22
+ def _is_record_running(srec: dict[str, Any], root: Path | None, stype: str) -> bool:
23
+ if stype == "compose":
24
+ return compose_record_alive(srec, root or Path("."))
25
+ pid = srec.get("pid")
26
+ return isinstance(pid, int) and pid_alive(pid) and identity_matches(srec)
27
+
28
+
29
+ def _check_service_health(srec: dict[str, Any], port: Any, pid: Any) -> str:
30
+ hp = srec.get("healthcheck_path") or srec.get("health") or "/"
31
+ return "healthy" if wait_for_http(int(port), hp, (1.0, pid, srec.get("pgid"))) else "unhealthy"
32
+
33
+
34
+ def _inspect_instance_service(
35
+ srec: dict[str, Any], root: Path | None, health: bool
36
+ ) -> tuple[dict[str, Any], bool]:
37
+ stype, port, pid = srec.get("type", "unknown"), srec.get("port"), srec.get("pid")
38
+ alive = _is_record_running(srec, root, stype)
39
+ h_status = _check_service_health(srec, port, pid) if health and alive and port else None
40
+ return {
41
+ "type": stype,
42
+ "status": "running" if alive else "stopped",
43
+ "port": port,
44
+ "url": srec.get("url"),
45
+ "pid": pid,
46
+ "health": h_status,
47
+ }, alive
48
+
49
+
50
+ def _summarize_instance(inst_dir: Path, health: bool) -> dict[str, Any] | None:
51
+ if not (state_file := inst_dir / STATE_FILE_NAME).is_file():
52
+ return None
53
+ state = read_state(state_file)
54
+ inst_id, root_str = state.get("instance") or inst_dir.name, state.get("root")
55
+ root_path = Path(root_str).resolve() if root_str else None
56
+ root_exists = root_path.is_dir() if root_path else False
57
+ svcs = state.get("services", {})
58
+ inspected = {n: _inspect_instance_service(r, root_path, health) for n, r in svcs.items()}
59
+ running = sum(bool(alive) for _, alive in inspected.values())
60
+ if not root_exists:
61
+ status = "orphaned"
62
+ elif svcs and running == len(svcs):
63
+ status = "running"
64
+ else:
65
+ status = "partial" if running > 0 else "stopped"
66
+ return {
67
+ "instance": inst_id,
68
+ "project": state.get("project") or inst_id.rsplit("-", 1)[0],
69
+ "mode": state.get("mode") or "default",
70
+ "status": status,
71
+ "locked": is_locked(inst_dir / LOCK_FILE_NAME),
72
+ "root": root_str,
73
+ "root_exists": root_exists,
74
+ "services_running": running,
75
+ "services_total": len(svcs),
76
+ "services": {n: info for n, (info, _) in inspected.items()},
77
+ }
78
+
79
+
80
+ def _ps_badge(st: str, th: Any) -> str:
81
+ badges = {"running": (th.green, "●"), "partial": (th.yellow, "▲"), "stopped": (th.red, "○")}
82
+ col, gly = badges.get(st, (th.magenta, "✖"))
83
+ return f"{col}{gly} {st}{th.r}"
84
+
85
+
86
+ def _format_ps_prefix(it: dict[str, Any], th: Any, wide: bool) -> list[str]:
87
+ summary = ", ".join(f"{s}:{info['status']}" for s, info in it["services"].items()) or "none"
88
+ if len(summary) > SUMMARY_MAX_LENGTH:
89
+ summary = f"{it['services_running']}/{it['services_total']} up"
90
+ inst, proj = it["instance"], it["project"]
91
+ short_id = inst if wide or not inst.startswith(f"{proj}-") else inst[len(proj) + 1 :]
92
+ return [
93
+ f"{th.b}{proj}{th.r}",
94
+ f"{th.d}{short_id}{th.r}",
95
+ f"{th.cyan}{it['mode']}{th.r}",
96
+ _ps_badge(it["status"], th),
97
+ summary,
98
+ ]
99
+
100
+
101
+ def _format_ps_root(it: dict[str, Any], avail: int | None) -> str:
102
+ r_str = contract_path(it["root"] or "n/a")
103
+ suffix = "" if it["root_exists"] else " [deleted]"
104
+ if avail is None:
105
+ return r_str + suffix
106
+ budget = max(8, avail - len(suffix)) if suffix else avail
107
+ return middle_truncate(r_str, budget) + suffix
108
+
109
+
110
+ def _build_ps_rows(instances: list[dict[str, Any]], wide: bool) -> list[list[str]]:
111
+ th = get_theme()
112
+ pre = [_format_ps_prefix(it, th, wide) for it in instances]
113
+ avail = None
114
+ if not wide and sys.stdout.isatty():
115
+ all_p = [["PROJECT", "ID", "MODE", "STATUS", "SERVICES"], *pre]
116
+ p_w = 2 + sum(max(visible_width(r[i]) for r in all_p) for i in range(5)) + 10
117
+ avail = max(15, shutil.get_terminal_size((80, 24)).columns - p_w)
118
+ return [
119
+ [*p, f"{th.d}{_format_ps_root(it, avail)}{th.r}"]
120
+ for it, p in zip(instances, pre, strict=True)
121
+ ]
122
+
123
+
124
+ def _print_table(instances: list[dict[str, Any]], wide: bool = False) -> None:
125
+ headers = ["PROJECT", "INSTANCE" if wide else "ID", "MODE", "STATUS", "SERVICES", "ROOT"]
126
+ for line in format_table(headers, _build_ps_rows(instances, wide), gutter=4 if wide else 2):
127
+ print(f" {line}")
128
+ print()
129
+
130
+
131
+ def _collect_instances(instances_dir: Path, health: bool) -> list[dict[str, Any]]:
132
+ if not instances_dir.is_dir():
133
+ return []
134
+ return [
135
+ i
136
+ for d in sorted(instances_dir.iterdir())
137
+ if d.is_dir() and (i := _summarize_instance(d, health))
138
+ ]
139
+
140
+
141
+ def cmd_ps(health: bool = False, as_json: bool = False, wide: bool = False) -> int:
142
+ instances_data = _collect_instances(get_instances_dir(), health)
143
+ if as_json:
144
+ print_json_envelope("ps", {"instances": instances_data})
145
+ elif not instances_data:
146
+ print("No active or recorded rig instances found.")
147
+ else:
148
+ _print_table(instances_data, wide=wide)
149
+ return EXIT_OK
rig/commands/status.py ADDED
@@ -0,0 +1,149 @@
1
+ """Status inspection and formatting command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from rig.commands.common import (
10
+ _resolve_manifest_context,
11
+ is_service_verifiable_alive,
12
+ prune_state,
13
+ record_status,
14
+ )
15
+ from rig.core.constants import EXIT_OK
16
+ from rig.core.errors import print_json_envelope
17
+ from rig.core.identity import get_instances_dir
18
+ from rig.core.locks import exclusive_lock
19
+ from rig.core.state import read_state, write_state
20
+ from rig.core.terminal import Theme, format_table, get_theme
21
+ from rig.manifest.models import Manifest
22
+ from rig.net.health import wait_for_http
23
+
24
+
25
+ def _format_service_health(record: Mapping[str, Any], service: Any) -> str:
26
+ if not (service.healthcheck_path and record.get("port")):
27
+ return "-"
28
+ ctx = (1.0, record.get("pid"), record.get("pgid"))
29
+ ready = wait_for_http(int(record["port"]), service.healthcheck_path, ctx)
30
+ return "healthy" if ready else "unhealthy"
31
+
32
+
33
+ def _process_ident(record: Mapping[str, Any] | None) -> str:
34
+ if pid := (record or {}).get("pid"):
35
+ return f"pid:{pid}"
36
+ return f"container:{str(c)[:12]}" if (c := (record or {}).get("container")) else "-"
37
+
38
+
39
+ def _status_badge(st: str, h: str, th: Theme) -> str:
40
+ if st == "running":
41
+ return f"{th.yellow}▲ degraded{th.r}" if h == "unhealthy" else f"{th.green}● running{th.r}"
42
+ return f"{th.red}{'✖ error' if st == 'error' else '○ stopped'}{th.r}"
43
+
44
+
45
+ def _health_badge(raw_h: str, th: Theme) -> str:
46
+ tags = {"healthy": f"{th.green}✓ healthy{th.r}", "unhealthy": f"{th.red}✖ failing{th.r}"}
47
+ return tags.get(raw_h, f"{th.d}-{th.r}")
48
+
49
+
50
+ def _build_service_row(
51
+ name: str, ctx: tuple[Mapping[str, Any] | None, Any, Path]
52
+ ) -> tuple[str, str, list[str]]:
53
+ record, service, root = ctx
54
+ th = get_theme()
55
+ if record is None:
56
+ d = f"{th.d}-{th.r}"
57
+ return "stopped", "-", [f"{th.red}○ stopped{th.r}", f"{th.b}{name}{th.r}", d, d, d]
58
+ raw_st = record_status(record, root)
59
+ raw_h = _format_service_health(record, service) if raw_st == "running" else "-"
60
+ ep = f"{th.cyan}{u}{th.r}" if (u := record.get("url")) else f"{th.d}-{th.r}"
61
+ row = [
62
+ _status_badge(raw_st, raw_h, th),
63
+ f"{th.b}{name}{th.r}",
64
+ ep,
65
+ _health_badge(raw_h, th),
66
+ f"{th.d}{_process_ident(record)}{th.r}",
67
+ ]
68
+ return raw_st, raw_h, row
69
+
70
+
71
+ def _collect_status_rows(
72
+ manifest: Manifest, state: Mapping[str, Any], root: Path
73
+ ) -> tuple[list[list[str]], list[str], int]:
74
+ rows, degraded, running = [], [], 0
75
+ for name in sorted(manifest.services):
76
+ rec = state.get("services", {}).get(name)
77
+ st_val, hlth_val, row = _build_service_row(name, (rec, manifest.services[name], root))
78
+ running += int(st_val == "running")
79
+ if hlth_val == "unhealthy" or st_val == "error":
80
+ degraded.append(name)
81
+ rows.append(row)
82
+ return rows, degraded, running
83
+
84
+
85
+ def _print_degraded(degraded: list[str], inst_id: str | None, th: Theme) -> None:
86
+ if not (degraded and inst_id):
87
+ return
88
+ inst_dir = get_instances_dir() / inst_id
89
+ count = len(degraded)
90
+ label = "service degraded" if count == 1 else "services degraded"
91
+ print(f" {th.yellow}▲ {count} {label}. View logs:{th.r}")
92
+ for sname in degraded:
93
+ print(f" {th.d}{sname} ➜{th.r} {inst_dir / 'logs' / f'{sname}.log'}")
94
+ print()
95
+
96
+
97
+ def _print_status(manifest: Manifest, state: Mapping[str, Any], root: Path) -> None:
98
+ th, headers = get_theme(), ["STATUS", "SERVICE", "ENDPOINT", "HEALTH", "PROCESS"]
99
+ rows, degraded, running = _collect_status_rows(manifest, state, root)
100
+ total = len(manifest.services)
101
+ mode_str = f" {th.cyan}[{manifest.active_mode}]{th.r}" if manifest.active_mode else ""
102
+ stopped_str = f" {th.red}({total - running} stopped){th.r}" if running != total else ""
103
+ col = th.green if running == total and total > 0 else th.yellow
104
+ badge = f"{col}{running}/{total} running{th.r}{stopped_str}"
105
+ print(f"\n {th.b}{manifest.project}{th.r}{mode_str} {th.d}·{th.r} {badge}\n")
106
+ for line in format_table(headers, rows, gutter=4):
107
+ print(f" {line}")
108
+ print()
109
+ _print_degraded(degraded, state.get("instance"), th)
110
+
111
+
112
+ def _print_status_json(
113
+ manifest: Manifest, state: Mapping[str, Any], ctx: tuple[Path, str, str | None]
114
+ ) -> None:
115
+ root, instance, mode = ctx
116
+ info = {
117
+ s: {
118
+ "running": bool(r and is_service_verifiable_alive(r, root)),
119
+ "type": manifest.services[s].type,
120
+ "port": (r or {}).get("port"),
121
+ "url": (r or {}).get("url"),
122
+ "pid": (r or {}).get("pid"),
123
+ }
124
+ for s in sorted(manifest.services)
125
+ for r in [state.get("services", {}).get(s)]
126
+ }
127
+ payload = {
128
+ "project": manifest.project,
129
+ "instance": instance,
130
+ "mode": mode or "default",
131
+ "generation": state.get("generation", 0),
132
+ "services": info,
133
+ }
134
+ print_json_envelope("status", payload)
135
+
136
+
137
+ def cmd_status(root: Path, manifest_path: Path, as_json: bool = False) -> int:
138
+ raw_m, root_p, inst, s_path, l_path = _resolve_manifest_context(root, manifest_path)
139
+ with exclusive_lock(l_path):
140
+ state = read_state(s_path)
141
+ if prune_state(state, root_p):
142
+ write_state(s_path, state)
143
+ active_mode = state.get("mode")
144
+ manifest = raw_m.for_mode(active_mode) if raw_m.modes else raw_m
145
+ if as_json:
146
+ _print_status_json(manifest, state, (root_p, inst, active_mode))
147
+ else:
148
+ _print_status(manifest, state, root_p)
149
+ return EXIT_OK
@@ -0,0 +1,111 @@
1
+ """Orchestrator for rig up command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from rig.commands.common import _resolve_manifest_context, prune_state
9
+ from rig.commands.status import _print_status
10
+ from rig.commands.up.loop import _start_loop, _switch_mode
11
+ from rig.commands.up.relink import _relink_affected
12
+ from rig.commands.up.runner import _rollback, _start_with_retry
13
+ from rig.commands.up.service import (
14
+ _await_ready,
15
+ _check_port_listener,
16
+ _start_service,
17
+ )
18
+ from rig.core.constants import EXIT_OK
19
+ from rig.core.errors import print_json_envelope
20
+ from rig.core.identity import get_boot_id
21
+ from rig.core.locks import ensure_runtime_dir, exclusive_lock
22
+ from rig.core.state import read_state, write_state
23
+ from rig.manifest.models import Manifest
24
+
25
+
26
+ def _run_up_transaction(
27
+ order: list[str],
28
+ manifest: Manifest,
29
+ ctx: tuple[tuple[Path, Path, str, Path], tuple[str, bool, bool]],
30
+ ) -> int:
31
+ paths, cfgs = ctx
32
+ root_path, runtime, instance, state_path = paths
33
+ selected_mode, switch, as_json = cfgs
34
+ state = read_state(state_path)
35
+ state.update(
36
+ {
37
+ "instance": instance,
38
+ "project": manifest.project,
39
+ "root": str(root_path),
40
+ "boot_id": get_boot_id(),
41
+ }
42
+ )
43
+ _switch_mode(state, (root_path, state_path), (selected_mode, switch))
44
+ prune_state(state, root_path)
45
+ write_state(state_path, state)
46
+
47
+ relink = _relink_affected(order, manifest, (state, root_path, state_path, as_json))
48
+ if isinstance(relink, int):
49
+ return relink
50
+ res = _start_loop(relink, manifest, (state, root_path, runtime, instance, state_path, as_json))
51
+ if isinstance(res, int):
52
+ return res
53
+ state["generation"] = int(state.get("generation", 0)) + 1
54
+ write_state(state_path, state)
55
+ return EXIT_OK
56
+
57
+
58
+ def _finish_up_output(ctx: tuple[str, str, Path, Path], manifest: Manifest, as_json: bool) -> None:
59
+ instance, selected_mode, state_path, root_path = ctx
60
+ if as_json:
61
+ payload = {
62
+ "instance": instance,
63
+ "project": manifest.project,
64
+ "mode": selected_mode,
65
+ "generation": read_state(state_path).get("generation", 0),
66
+ "services": read_state(state_path).get("services", {}),
67
+ }
68
+ print_json_envelope("up", payload)
69
+ else:
70
+ _print_status(manifest, read_state(state_path), root_path)
71
+
72
+
73
+ def cmd_up(root: Path, manifest_path: Path, *args: Any, **kwargs: Any) -> int:
74
+ argv = list(args)
75
+ scope = kwargs.get("scope") or (argv.pop(0) if argv else "full")
76
+ mode = kwargs.get("mode") if "mode" in kwargs else (argv.pop(0) if argv else None)
77
+ switch = kwargs.get("switch") if "switch" in kwargs else (argv.pop(0) if argv else False)
78
+ as_json = kwargs.get("as_json") if "as_json" in kwargs else (argv.pop(0) if argv else False)
79
+
80
+ raw_m, root_path, instance, state_path, lock_path = _resolve_manifest_context(
81
+ root, manifest_path
82
+ )
83
+ manifest = raw_m.for_mode(mode) if mode or raw_m.modes else raw_m
84
+ selected_mode = manifest.active_mode or "default"
85
+ order = manifest.resolve_scope(scope)
86
+ runtime = ensure_runtime_dir(root_path)
87
+
88
+ with exclusive_lock(lock_path):
89
+ res = _run_up_transaction(
90
+ order,
91
+ manifest,
92
+ ((root_path, runtime, instance, state_path), (selected_mode, switch, as_json)),
93
+ )
94
+ if res != EXIT_OK:
95
+ return res
96
+
97
+ _finish_up_output((instance, selected_mode, state_path, root_path), manifest, as_json)
98
+ return EXIT_OK
99
+
100
+
101
+ __all__ = [
102
+ "_await_ready",
103
+ "_check_port_listener",
104
+ "_relink_affected",
105
+ "_rollback",
106
+ "_start_loop",
107
+ "_start_service",
108
+ "_start_with_retry",
109
+ "_switch_mode",
110
+ "cmd_up",
111
+ ]
@@ -0,0 +1,36 @@
1
+ """Typed parameter contexts for rig up services, retries, and rollbacks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from rig.manifest.models import Manifest
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ServiceContext:
15
+ root: Path
16
+ runtime: Path
17
+ instance: str = ""
18
+ values: Mapping[str, Any] = field(default_factory=dict)
19
+ candidate_ports: Sequence[int] | None = None
20
+
21
+
22
+ @dataclass
23
+ class RetryContext:
24
+ root: Path
25
+ runtime: Path
26
+ instance: str
27
+ state: dict[str, Any]
28
+ state_path: Path
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class RollbackContext:
33
+ state: dict[str, Any]
34
+ state_path: Path
35
+ root: Path = Path(".")
36
+ manifest: Manifest | None = None
@@ -0,0 +1,150 @@
1
+ """Mode switching and service initialization loop for rig up."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from rig.commands.common import (
9
+ _stop_record,
10
+ is_service_active_in_mode,
11
+ is_service_verifiable_alive,
12
+ reverse_dependency_order,
13
+ )
14
+ from rig.commands.up.context import RollbackContext
15
+ from rig.commands.up.rollback import rollback_started
16
+ from rig.commands.up.runner import _start_with_retry
17
+ from rig.core.constants import EXIT_MUTEX_CONFLICT, EXIT_OP_FAILED, EXIT_REFUSED
18
+ from rig.core.errors import RigError
19
+ from rig.core.state import write_state
20
+ from rig.manifest.models import Manifest
21
+
22
+ _ABORT_EXCEPTIONS = (
23
+ KeyboardInterrupt,
24
+ SystemExit,
25
+ RigError,
26
+ OSError,
27
+ RuntimeError,
28
+ ValueError,
29
+ )
30
+
31
+
32
+ def _stop_one_service_for_switch(sname: str, rec: dict[str, Any], root: Path) -> None:
33
+ outcome = _stop_record(rec, root)
34
+ if outcome not in ("terminated", "killed", "stale"):
35
+ raise RigError(
36
+ f"service {sname!r} failed to stop during mode switch ({outcome})",
37
+ code="E_SWITCH_FAILED",
38
+ exit_code=EXIT_REFUSED,
39
+ )
40
+
41
+
42
+ def _stop_services_for_switch(state: dict[str, Any], root: Path) -> None:
43
+ for sname in reverse_dependency_order(state.get("services", {})):
44
+ rec = state["services"].get(sname)
45
+ if rec:
46
+ _stop_one_service_for_switch(sname, rec, root)
47
+ state["services"].pop(sname, None)
48
+
49
+
50
+ def _switch_mode(
51
+ state: dict[str, Any], paths: tuple[Path, Path], mode_cfg: tuple[str, bool]
52
+ ) -> None:
53
+ root, state_path = paths
54
+ mode, switch = mode_cfg
55
+ current_mode = state.get("mode")
56
+ services = state.get("services", {}).values()
57
+ active = sum(1 for rec in services if is_service_active_in_mode(rec, root))
58
+ if current_mode and current_mode != mode and active > 0:
59
+ if not switch:
60
+ head = f"Stack is currently running in {current_mode!r} mode"
61
+ hint = f"Re-run with 'rig up --switch --mode {mode}' to stop {current_mode!r} first"
62
+ msg = f"stack in {current_mode!r}; cannot start in {mode!r} without --switch"
63
+ raise RigError(
64
+ msg,
65
+ code="E_MODE_CONFLICT",
66
+ exit_code=EXIT_MUTEX_CONFLICT,
67
+ headline=head,
68
+ hint=hint,
69
+ )
70
+ _stop_services_for_switch(state, root)
71
+ write_state(state_path, state)
72
+ state["mode"] = mode
73
+
74
+
75
+ def _reclaim_dead_service(name: str, root: Path, state_ctx: tuple[dict[str, Any], Path]) -> bool:
76
+ state, state_path = state_ctx
77
+ if not (existing := state["services"].get(name)):
78
+ return True
79
+ if _stop_record(existing, root, remove=True) not in ("terminated", "killed", "stale"):
80
+ return False
81
+ state["services"].pop(name, None)
82
+ write_state(state_path, state)
83
+ return True
84
+
85
+
86
+ def _start_step(
87
+ name: str,
88
+ manifest: Manifest,
89
+ ctx: tuple[Path, Path, str, dict[str, Any], Path],
90
+ ) -> tuple[dict[str, Any] | None, Exception | None]:
91
+ root, runtime, instance, state, state_path = ctx
92
+ service = manifest.services[name]
93
+ if not _reclaim_dead_service(name, root, (state, state_path)):
94
+ err = RigError(
95
+ f"service {name!r} could not be reclaimed before start",
96
+ code="E_SERVICE_UNHEALTHY",
97
+ exit_code=EXIT_OP_FAILED,
98
+ )
99
+ return None, err
100
+ try:
101
+ rec = _start_with_retry(service, root, runtime, instance, state, state_path)
102
+ except RigError as exc:
103
+ return None, exc
104
+ except _ABORT_EXCEPTIONS:
105
+ write_state(state_path, state)
106
+ raise
107
+ return rec, None
108
+
109
+
110
+ def _handle_step_failure(name: str, exc: Exception, as_json: bool) -> int:
111
+ if as_json:
112
+ if isinstance(exc, RigError):
113
+ raise exc
114
+ raise RigError(
115
+ f"failed to start {name}: {exc}", code="E_START_FAILED", exit_code=EXIT_OP_FAILED
116
+ ) from None
117
+ return exc.exit_code if isinstance(exc, RigError) else EXIT_OP_FAILED
118
+
119
+
120
+ def _handle_timeout(name: str, as_json: bool) -> int:
121
+ timeout_err = RigError(
122
+ f"service {name!r} failed to reach healthy state",
123
+ code="E_START_TIMEOUT",
124
+ exit_code=EXIT_OP_FAILED,
125
+ )
126
+ if as_json:
127
+ raise timeout_err
128
+ return timeout_err.exit_code
129
+
130
+
131
+ def _start_loop(
132
+ order: list[str],
133
+ manifest: Manifest,
134
+ ctx: tuple[dict[str, Any], Path, Path, str, Path, bool],
135
+ ) -> int | None:
136
+ state, root, runtime, instance, state_path, as_json = ctx
137
+ started: list[str] = []
138
+ for name in order:
139
+ existing = state["services"].get(name)
140
+ if existing and is_service_verifiable_alive(existing, root):
141
+ continue
142
+ rec, err = _start_step(name, manifest, (root, runtime, instance, state, state_path))
143
+ if err is not None:
144
+ rollback_started(started, RollbackContext(state, state_path, root, manifest))
145
+ return _handle_step_failure(name, err, as_json)
146
+ if rec is None:
147
+ rollback_started(started, RollbackContext(state, state_path, root, manifest))
148
+ return _handle_timeout(name, as_json)
149
+ started.append(name)
150
+ return None
@@ -0,0 +1,88 @@
1
+ """Affected dependent service resolution and cleanup for rig up."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from rig.commands.common import (
9
+ _consumers_of,
10
+ _merged_depends_on,
11
+ _stop_record,
12
+ is_service_verifiable_alive,
13
+ reverse_dependency_order,
14
+ )
15
+ from rig.core.constants import EXIT_OP_FAILED
16
+ from rig.core.errors import RigError
17
+ from rig.core.state import write_state
18
+ from rig.manifest.models import Manifest
19
+
20
+
21
+ def _track_consumer(
22
+ dep: str, svcs: dict[str, Any], tracking: tuple[set[str], list[str], set[str]]
23
+ ) -> None:
24
+ affected, queue, seen = tracking
25
+ if dep in svcs:
26
+ affected.add(dep)
27
+ if dep not in seen:
28
+ seen.add(dep)
29
+ queue.append(dep)
30
+
31
+
32
+ def _find_affected(
33
+ order: list[str], manifest: Manifest, state_and_root: tuple[dict[str, Any], Path]
34
+ ) -> set[str]:
35
+ state, root = state_and_root
36
+ svcs = state["services"]
37
+ missing = [n for n in order if n not in svcs or not is_service_verifiable_alive(svcs[n], root)]
38
+ affected, queue, seen = set(), list(missing), set(missing)
39
+ while queue:
40
+ curr = queue.pop(0)
41
+ for dep in _consumers_of(curr, svcs, manifest):
42
+ _track_consumer(dep, svcs, (affected, queue, seen))
43
+ return affected
44
+
45
+
46
+ def _stop_affected(
47
+ affected: set[str], manifest: Manifest, ctx: tuple[dict[str, Any], Path, Path]
48
+ ) -> set[str]:
49
+ state, root, state_path = ctx
50
+ svcs = state["services"]
51
+ dep_dict = {
52
+ name: {"depends_on": sorted(_merged_depends_on(name, svcs[name], manifest))}
53
+ for name in affected
54
+ }
55
+ stop_order = reverse_dependency_order(dep_dict)
56
+ failed_stops: set[str] = set()
57
+ for dep_name in stop_order:
58
+ if any(c in failed_stops for c in _consumers_of(dep_name, svcs, manifest)):
59
+ failed_stops.add(dep_name)
60
+ continue
61
+ outcome = _stop_record(svcs[dep_name], root)
62
+ if outcome not in ("terminated", "killed", "stale"):
63
+ failed_stops.add(dep_name)
64
+ else:
65
+ svcs.pop(dep_name, None)
66
+ write_state(state_path, state)
67
+ return failed_stops
68
+
69
+
70
+ def _relink_affected(
71
+ order: list[str], manifest: Manifest, ctx: tuple[dict[str, Any], Path, Path, bool]
72
+ ) -> list[str] | int:
73
+ state, root, state_path, as_json = ctx
74
+ affected = _find_affected(order, manifest, (state, root))
75
+ if not affected:
76
+ return order
77
+ failed_stops = _stop_affected(affected, manifest, (state, root, state_path))
78
+ if failed_stops:
79
+ err = RigError(
80
+ f"cleanup failed for dependent services: {', '.join(failed_stops)}",
81
+ code="E_CLEANUP_FAILED",
82
+ exit_code=EXIT_OP_FAILED,
83
+ )
84
+ if as_json:
85
+ raise err
86
+ return err.exit_code
87
+ extra = [name for name in affected if name in manifest.services]
88
+ return manifest.resolve_services(order + extra)