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.
- rig/__init__.py +6 -0
- rig/__main__.py +8 -0
- rig/cli.py +107 -0
- rig/commands/__init__.py +23 -0
- rig/commands/check.py +125 -0
- rig/commands/common.py +149 -0
- rig/commands/dispatch.py +87 -0
- rig/commands/down/__init__.py +139 -0
- rig/commands/down/runner.py +136 -0
- rig/commands/init.py +148 -0
- rig/commands/logs.py +146 -0
- rig/commands/prune.py +142 -0
- rig/commands/ps.py +149 -0
- rig/commands/status.py +149 -0
- rig/commands/up/__init__.py +111 -0
- rig/commands/up/context.py +36 -0
- rig/commands/up/loop.py +150 -0
- rig/commands/up/relink.py +88 -0
- rig/commands/up/rollback.py +49 -0
- rig/commands/up/runner.py +118 -0
- rig/commands/up/service.py +136 -0
- rig/compose/__init__.py +1 -0
- rig/compose/client.py +144 -0
- rig/compose/context.py +56 -0
- rig/compose/discovery.py +121 -0
- rig/compose/docker.py +117 -0
- rig/compose/starter.py +145 -0
- rig/compose/stopper.py +71 -0
- rig/compose/supervisor.py +78 -0
- rig/core/__init__.py +1 -0
- rig/core/constants.py +65 -0
- rig/core/env.py +83 -0
- rig/core/errors.py +74 -0
- rig/core/identity.py +141 -0
- rig/core/locks.py +112 -0
- rig/core/state.py +150 -0
- rig/core/terminal.py +145 -0
- rig/manifest/__init__.py +1 -0
- rig/manifest/detector.py +138 -0
- rig/manifest/inspect.py +18 -0
- rig/manifest/loader.py +146 -0
- rig/manifest/models.py +129 -0
- rig/manifest/parser.py +123 -0
- rig/manifest/schema.py +89 -0
- rig/net/__init__.py +1 -0
- rig/net/health.py +76 -0
- rig/net/ports.py +141 -0
- rig/net/probe.py +56 -0
- rig/net/registry.py +132 -0
- rig/parser.py +72 -0
- rig/proc/__init__.py +1 -0
- rig/proc/process.py +133 -0
- rig/proc/record.py +54 -0
- rig/proc/spawn.py +130 -0
- rig/proc/teardown.py +139 -0
- rig_cli-1.0.0.dist-info/METADATA +503 -0
- rig_cli-1.0.0.dist-info/RECORD +59 -0
- rig_cli-1.0.0.dist-info/WHEEL +4 -0
- 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
|
rig/commands/up/loop.py
ADDED
|
@@ -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)
|