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/__init__.py
ADDED
rig/__main__.py
ADDED
rig/cli.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Generic local stack orchestrator and command-line entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from types import ModuleType
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from rig.commands.dispatch import _dispatch_command, _handle_exception, _prepare_args
|
|
12
|
+
from rig.core import constants
|
|
13
|
+
from rig.core.errors import RigError
|
|
14
|
+
from rig.parser import build_parser
|
|
15
|
+
|
|
16
|
+
_SUBMODULES = (
|
|
17
|
+
"rig.core.constants",
|
|
18
|
+
"rig.core.errors",
|
|
19
|
+
"rig.core.identity",
|
|
20
|
+
"rig.core.locks",
|
|
21
|
+
"rig.core.state",
|
|
22
|
+
"rig.core.env",
|
|
23
|
+
"rig.core.terminal",
|
|
24
|
+
"rig.net.ports",
|
|
25
|
+
"rig.net.probe",
|
|
26
|
+
"rig.net.health",
|
|
27
|
+
"rig.proc.process",
|
|
28
|
+
"rig.proc.teardown",
|
|
29
|
+
"rig.proc.spawn",
|
|
30
|
+
"rig.proc.record",
|
|
31
|
+
"rig.compose.client",
|
|
32
|
+
"rig.compose.context",
|
|
33
|
+
"rig.compose.docker",
|
|
34
|
+
"rig.compose.discovery",
|
|
35
|
+
"rig.compose.supervisor",
|
|
36
|
+
"rig.compose.starter",
|
|
37
|
+
"rig.compose.stopper",
|
|
38
|
+
"rig.manifest.models",
|
|
39
|
+
"rig.manifest.parser",
|
|
40
|
+
"rig.manifest.detector",
|
|
41
|
+
"rig.manifest.inspect",
|
|
42
|
+
"rig.manifest.loader",
|
|
43
|
+
"rig.manifest.schema",
|
|
44
|
+
"rig.commands.common",
|
|
45
|
+
"rig.commands.dispatch",
|
|
46
|
+
"rig.commands.up",
|
|
47
|
+
"rig.commands.up.context",
|
|
48
|
+
"rig.commands.up.service",
|
|
49
|
+
"rig.commands.up.runner",
|
|
50
|
+
"rig.commands.up.rollback",
|
|
51
|
+
"rig.commands.up.relink",
|
|
52
|
+
"rig.commands.up.loop",
|
|
53
|
+
"rig.commands.down",
|
|
54
|
+
"rig.commands.down.runner",
|
|
55
|
+
"rig.commands.status",
|
|
56
|
+
"rig.commands.ps",
|
|
57
|
+
"rig.commands.prune",
|
|
58
|
+
"rig.commands.check",
|
|
59
|
+
"rig.commands.init",
|
|
60
|
+
"rig.commands.logs",
|
|
61
|
+
"rig.parser",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
for _mod in _SUBMODULES:
|
|
65
|
+
_m = importlib.import_module(_mod)
|
|
66
|
+
for _k, _v in _m.__dict__.items():
|
|
67
|
+
if not _k.startswith("__"):
|
|
68
|
+
globals()[_k] = _v
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _CliModule(ModuleType):
|
|
72
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
73
|
+
super().__setattr__(name, value)
|
|
74
|
+
for mod_name, mod in list(sys.modules.items()):
|
|
75
|
+
if mod_name.startswith("rig.") and mod is not self and name in mod.__dict__:
|
|
76
|
+
setattr(mod, name, value)
|
|
77
|
+
|
|
78
|
+
def __delattr__(self, name: str) -> None:
|
|
79
|
+
super().__delattr__(name)
|
|
80
|
+
for mod_name, mod in list(sys.modules.items()):
|
|
81
|
+
if mod_name.startswith("rig.") and mod is not self and name in mod.__dict__:
|
|
82
|
+
mod.__dict__.pop(name, None)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
sys.modules[__name__].__class__ = _CliModule
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
89
|
+
raw_argv = list(sys.argv[1:] if argv is None else argv)
|
|
90
|
+
as_json = "--json" in raw_argv
|
|
91
|
+
if as_json:
|
|
92
|
+
raw_argv = [a for a in raw_argv if a != "--json"]
|
|
93
|
+
parser = build_parser(as_json=as_json)
|
|
94
|
+
try:
|
|
95
|
+
args = parser.parse_args(raw_argv)
|
|
96
|
+
except SystemExit as exc:
|
|
97
|
+
return exc.code if isinstance(exc.code, int) else constants.EXIT_USAGE
|
|
98
|
+
|
|
99
|
+
_prepare_args(args, as_json)
|
|
100
|
+
try:
|
|
101
|
+
return _dispatch_command(args.command, args)
|
|
102
|
+
except (RigError, TimeoutError, KeyboardInterrupt, OSError, RuntimeError, ValueError) as exc:
|
|
103
|
+
return _handle_exception(exc, args.command, as_json)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
sys.exit(main())
|
rig/commands/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Command handlers for the rig CLI."""
|
|
2
|
+
|
|
3
|
+
from rig.commands.check import cmd_check
|
|
4
|
+
from rig.commands.down import cmd_down
|
|
5
|
+
from rig.commands.init import cmd_init
|
|
6
|
+
from rig.commands.logs import cmd_logs
|
|
7
|
+
from rig.commands.prune import cmd_prune
|
|
8
|
+
from rig.commands.ps import cmd_ps
|
|
9
|
+
from rig.commands.status import cmd_status
|
|
10
|
+
from rig.commands.up import cmd_up
|
|
11
|
+
from rig.manifest.schema import cmd_schema
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"cmd_check",
|
|
15
|
+
"cmd_down",
|
|
16
|
+
"cmd_init",
|
|
17
|
+
"cmd_logs",
|
|
18
|
+
"cmd_prune",
|
|
19
|
+
"cmd_ps",
|
|
20
|
+
"cmd_schema",
|
|
21
|
+
"cmd_status",
|
|
22
|
+
"cmd_up",
|
|
23
|
+
]
|
rig/commands/check.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Static project and manifest verification command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from rig.core.constants import EXIT_OK, EXIT_USAGE
|
|
12
|
+
from rig.core.env import render
|
|
13
|
+
from rig.core.errors import RigError, print_json_envelope
|
|
14
|
+
from rig.core.terminal import get_theme
|
|
15
|
+
from rig.manifest.inspect import _resolve_executable
|
|
16
|
+
from rig.manifest.loader import load_manifest
|
|
17
|
+
|
|
18
|
+
MANIFEST_EXCEPTIONS = (RigError, OSError, ValueError, KeyError)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _check_binary(
|
|
22
|
+
spec: str, cwd: Path, ctx: tuple[dict[str, Any], str, list[dict[str, Any]]]
|
|
23
|
+
) -> None:
|
|
24
|
+
render_vals, tag, issues = ctx
|
|
25
|
+
bin_str = str(render(spec, render_vals))
|
|
26
|
+
actual = _resolve_executable(bin_str, cwd)
|
|
27
|
+
if actual is None:
|
|
28
|
+
msg = f"executable '{spec}' not found"
|
|
29
|
+
issues.append({"level": "error", "check": f"{tag}:binary", "message": msg})
|
|
30
|
+
elif not os.access(actual, os.X_OK):
|
|
31
|
+
msg = f"file '{spec}' is not executable"
|
|
32
|
+
issues.append({"level": "error", "check": f"{tag}:binary", "message": msg})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _check_proc_service(
|
|
36
|
+
svc: Any, info: tuple[Path, Path, str], issues: list[dict[str, Any]]
|
|
37
|
+
) -> None:
|
|
38
|
+
root, cwd, tag = info
|
|
39
|
+
if not shutil.which("lsof"):
|
|
40
|
+
msg = "'lsof' not found on PATH"
|
|
41
|
+
issues.append({"level": "error", "check": f"{tag}:lsof", "message": msg})
|
|
42
|
+
vals = {"root": str(root), "cwd": str(cwd), "python": sys.executable}
|
|
43
|
+
bin_ctx = (vals, tag, issues)
|
|
44
|
+
if svc.command:
|
|
45
|
+
_check_binary(svc.command[0], cwd, bin_ctx)
|
|
46
|
+
elif svc.type == "fd" and svc.python:
|
|
47
|
+
_check_binary(svc.python, cwd, bin_ctx)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _check_compose_service(svc: Any, info: tuple[Path, str], issues: list[dict[str, Any]]) -> None:
|
|
51
|
+
root, tag = info
|
|
52
|
+
if not shutil.which("docker"):
|
|
53
|
+
msg = "'docker' not found on PATH"
|
|
54
|
+
issues.append({"level": "error", "check": f"{tag}:docker", "message": msg})
|
|
55
|
+
if svc.compose_file and not (p := (root / svc.compose_file).resolve()).is_file():
|
|
56
|
+
msg = f"compose file '{p}' does not exist"
|
|
57
|
+
issues.append({"level": "error", "check": f"{tag}:compose_file", "message": msg})
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _check_service(svc: Any, root: Path, ctx: tuple[str, str, list[dict[str, Any]]]) -> None:
|
|
61
|
+
sname, mode_tag, issues = ctx
|
|
62
|
+
cwd = (root / svc.cwd).resolve()
|
|
63
|
+
tag = f"{mode_tag} service:{sname}".strip()
|
|
64
|
+
if not cwd.is_dir():
|
|
65
|
+
msg = f"working directory '{cwd}' does not exist"
|
|
66
|
+
issues.append({"level": "error", "check": f"{tag}:cwd", "message": msg})
|
|
67
|
+
if svc.type in ("fd", "port"):
|
|
68
|
+
_check_proc_service(svc, (root, cwd, tag), issues)
|
|
69
|
+
elif svc.type == "compose":
|
|
70
|
+
_check_compose_service(svc, (root, tag), issues)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _check_modes(
|
|
74
|
+
manifest: Any, modes: list[str | None], ctx: tuple[Path, list[dict[str, Any]]]
|
|
75
|
+
) -> None:
|
|
76
|
+
root, issues = ctx
|
|
77
|
+
for m in modes:
|
|
78
|
+
try:
|
|
79
|
+
m_manifest = manifest.for_mode(m)
|
|
80
|
+
except MANIFEST_EXCEPTIONS as exc:
|
|
81
|
+
issues.append({"level": "error", "check": f"mode:{m}", "message": str(exc)})
|
|
82
|
+
continue
|
|
83
|
+
tag = f"[{m}]" if m else ""
|
|
84
|
+
for sname, svc in m_manifest.services.items():
|
|
85
|
+
_check_service(svc, root, (sname, tag, issues))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _report_check_results(
|
|
89
|
+
manifest: Any, issues: list[dict[str, Any]], opts: tuple[int, bool, Path]
|
|
90
|
+
) -> int:
|
|
91
|
+
mode_count, as_json, manifest_path = opts
|
|
92
|
+
has_errors = any(i["level"] == "error" for i in issues)
|
|
93
|
+
if as_json:
|
|
94
|
+
data = {"ok": not has_errors, "project": manifest.project, "issues": issues}
|
|
95
|
+
print_json_envelope("check", data)
|
|
96
|
+
return EXIT_USAGE if has_errors else EXIT_OK
|
|
97
|
+
th = get_theme()
|
|
98
|
+
for i in issues:
|
|
99
|
+
prefix = f"{th.red}✖ FAIL{th.r}" if i["level"] == "error" else f"{th.yellow}▲ WARN{th.r}"
|
|
100
|
+
print(f"{prefix} {i['check']}: {i['message']}", file=sys.stderr)
|
|
101
|
+
if not issues:
|
|
102
|
+
msg = f"manifest '{manifest_path}' is valid for {mode_count} mode(s)."
|
|
103
|
+
print(f"{th.green}✓ OK{th.r} check passed: {msg}")
|
|
104
|
+
return EXIT_USAGE if has_errors else EXIT_OK
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def cmd_check(root: Path, manifest_path: Path, *args: Any, **kwargs: Any) -> int:
|
|
108
|
+
argv = list(args)
|
|
109
|
+
mode = kwargs.get("mode") or (argv.pop(0) if argv else None)
|
|
110
|
+
as_json = bool(kwargs.get("as_json") or (argv.pop(0) if argv else False))
|
|
111
|
+
issues: list[dict[str, Any]] = []
|
|
112
|
+
resolved_root = Path(root).resolve()
|
|
113
|
+
try:
|
|
114
|
+
manifest = load_manifest(manifest_path)
|
|
115
|
+
except MANIFEST_EXCEPTIONS as exc:
|
|
116
|
+
if as_json:
|
|
117
|
+
issue = {"level": "error", "check": "manifest", "message": str(exc)}
|
|
118
|
+
print_json_envelope("check", {"ok": False, "issues": [issue]})
|
|
119
|
+
else:
|
|
120
|
+
print(f"FAIL manifest: {exc}", file=sys.stderr)
|
|
121
|
+
return EXIT_USAGE
|
|
122
|
+
|
|
123
|
+
modes = [mode] if mode else (list(manifest.modes.keys()) if manifest.modes else [None])
|
|
124
|
+
_check_modes(manifest, modes, (resolved_root, issues))
|
|
125
|
+
return _report_check_results(manifest, issues, (len(modes), as_json, manifest_path))
|
rig/commands/common.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Common command helpers, dependency ordering, and state inspection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from rig.compose.stopper import compose_stop_record
|
|
11
|
+
from rig.compose.supervisor import compose_record_alive, compose_record_status
|
|
12
|
+
from rig.core.constants import TEARDOWN_TIMEOUT_SECS
|
|
13
|
+
from rig.core.identity import instance_id
|
|
14
|
+
from rig.core.locks import _lock_path, ensure_runtime_dir
|
|
15
|
+
from rig.core.state import _state_path
|
|
16
|
+
from rig.manifest.loader import load_manifest
|
|
17
|
+
from rig.manifest.models import Manifest
|
|
18
|
+
from rig.net.ports import port_is_free
|
|
19
|
+
from rig.proc.process import identity_matches, pid_alive
|
|
20
|
+
from rig.proc.teardown import pgid_alive, terminate_record
|
|
21
|
+
|
|
22
|
+
_COMPOSE_STATUS_LABELS: dict[str, str] = {"alive": "running", "error": "error"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def record_alive(record: Mapping[str, Any], root: Path) -> bool:
|
|
26
|
+
if record.get("type") == "compose":
|
|
27
|
+
return compose_record_alive(record, root)
|
|
28
|
+
return identity_matches(record)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def record_status(record: Mapping[str, Any], root: Path) -> str:
|
|
32
|
+
"""Return 'running', 'stopped' or 'error' for one recorded service."""
|
|
33
|
+
if record.get("type") != "compose":
|
|
34
|
+
return "running" if identity_matches(record) else "stopped"
|
|
35
|
+
return _COMPOSE_STATUS_LABELS.get(compose_record_status(record, root), "stopped")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_service_verifiable_alive(record: Mapping[str, Any], root: Path) -> bool:
|
|
39
|
+
if record.get("type") == "compose":
|
|
40
|
+
return compose_record_alive(record, root)
|
|
41
|
+
pid = record.get("pid")
|
|
42
|
+
return isinstance(pid, int) and pid_alive(pid) and identity_matches(record)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_service_active_in_mode(record: Mapping[str, Any], root: Path) -> bool:
|
|
46
|
+
if record.get("type") == "compose":
|
|
47
|
+
return compose_record_status(record, root) != "absent"
|
|
48
|
+
return (
|
|
49
|
+
is_service_verifiable_alive(record, root)
|
|
50
|
+
or (isinstance(record.get("pgid"), int) and pgid_alive(record["pgid"]))
|
|
51
|
+
or (isinstance(record.get("pid"), int) and pid_alive(record["pid"]))
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_dead(rec: Mapping[str, Any], root: Path) -> bool:
|
|
56
|
+
if rec.get("type") == "compose":
|
|
57
|
+
return compose_record_status(rec, root) == "absent"
|
|
58
|
+
pgid_ok = isinstance(rec.get("pgid"), int) and pgid_alive(rec["pgid"])
|
|
59
|
+
return not identity_matches(rec) and not pgid_ok
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def prune_state(state: dict[str, Any], root: Path) -> list[str]:
|
|
63
|
+
dropped = [n for n, r in list(state.get("services", {}).items()) if _is_dead(r, root)]
|
|
64
|
+
for name in dropped:
|
|
65
|
+
rec = state["services"].pop(name, None) or {}
|
|
66
|
+
if (port := rec.get("port")) and not port_is_free(int(port)):
|
|
67
|
+
warn = f" warning: {name} port {port} in use (pid {rec.get('pid')} may be orphaned)"
|
|
68
|
+
print(warn, file=sys.stderr)
|
|
69
|
+
return dropped
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _values_for(state: Mapping[str, Any], root: Path, instance: str) -> dict[str, Any]:
|
|
73
|
+
values: dict[str, Any] = {
|
|
74
|
+
"root": str(root),
|
|
75
|
+
"instance": instance,
|
|
76
|
+
"data_dir": str(Path(root) / "data"),
|
|
77
|
+
"python": sys.executable,
|
|
78
|
+
}
|
|
79
|
+
for name, record in state.get("services", {}).items():
|
|
80
|
+
if record.get("port"):
|
|
81
|
+
values[f"{name}_port"] = record["port"]
|
|
82
|
+
values[f"{name}_url"] = record.get("url") or f"http://127.0.0.1:{record['port']}"
|
|
83
|
+
return values
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def record_depends_on(record: Mapping[str, Any]) -> list[str]:
|
|
87
|
+
raw = record.get("depends_on")
|
|
88
|
+
return [i for i in raw if isinstance(i, str)] if isinstance(raw, (list, tuple, set)) else []
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _merged_depends_on(
|
|
92
|
+
name: str, record: Mapping[str, Any], manifest: Manifest | None = None
|
|
93
|
+
) -> set[str]:
|
|
94
|
+
deps = set(record_depends_on(record))
|
|
95
|
+
if manifest is not None and name in manifest.services:
|
|
96
|
+
deps.update(manifest.services[name].depends_on)
|
|
97
|
+
return deps - {name}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _consumers_of(
|
|
101
|
+
name: str, services: Mapping[str, Mapping[str, Any]], manifest: Manifest | None = None
|
|
102
|
+
) -> set[str]:
|
|
103
|
+
consumers = {
|
|
104
|
+
other for other, rec in services.items() if name in _merged_depends_on(other, rec, manifest)
|
|
105
|
+
}
|
|
106
|
+
if manifest is not None:
|
|
107
|
+
consumers.update(manifest.dependents(name))
|
|
108
|
+
return consumers - {name}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _drain_ready_deps(deps: set[str], in_degree: dict[str, int]) -> list[str]:
|
|
112
|
+
in_degree.update({d: in_degree[d] - 1 for d in deps})
|
|
113
|
+
return [d for d in deps if in_degree[d] == 0]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def reverse_dependency_order(services: Mapping[str, Mapping[str, Any]]) -> list[str]:
|
|
117
|
+
in_degree = {k: 0 for k in services}
|
|
118
|
+
dep_map = {k: set() for k in services}
|
|
119
|
+
for name, rec in services.items():
|
|
120
|
+
for d in {d for d in record_depends_on(rec) if d in services and d != name}:
|
|
121
|
+
dep_map[name].add(d)
|
|
122
|
+
in_degree[d] += 1
|
|
123
|
+
queue = [k for k, deg in in_degree.items() if deg == 0]
|
|
124
|
+
order = []
|
|
125
|
+
while queue:
|
|
126
|
+
curr = queue.pop(0)
|
|
127
|
+
order.append(curr)
|
|
128
|
+
queue.extend(_drain_ready_deps(dep_map[curr], in_degree))
|
|
129
|
+
return order + [k for k in services if k not in order]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _stop_record(record: Mapping[str, Any], root: Path, remove: bool = True) -> str:
|
|
133
|
+
if record.get("type") == "compose":
|
|
134
|
+
return compose_stop_record(record, root, remove=remove)
|
|
135
|
+
return terminate_record(record, TEARDOWN_TIMEOUT_SECS)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _resolve_manifest_context(root: Path, manifest_path: Path):
|
|
139
|
+
raw_manifest = load_manifest(manifest_path)
|
|
140
|
+
resolved_root = Path(root).resolve()
|
|
141
|
+
ensure_runtime_dir(resolved_root)
|
|
142
|
+
instance = instance_id(raw_manifest.project, resolved_root)
|
|
143
|
+
return (
|
|
144
|
+
raw_manifest,
|
|
145
|
+
resolved_root,
|
|
146
|
+
instance,
|
|
147
|
+
_state_path(resolved_root, instance=instance),
|
|
148
|
+
_lock_path(resolved_root, instance=instance),
|
|
149
|
+
)
|
rig/commands/dispatch.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Command dispatching and top-level CLI error handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from rig import commands
|
|
10
|
+
from rig.core import constants
|
|
11
|
+
from rig.core.errors import RigError, format_human_error, print_json_error
|
|
12
|
+
from rig.core.identity import find_default_manifest, find_project_root
|
|
13
|
+
from rig.core.terminal import get_theme
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _dispatch_command(cmd: str, args: argparse.Namespace) -> int:
|
|
17
|
+
root, mf, j = args.root_dir, args.manifest_file, args.as_json
|
|
18
|
+
dispatch = {
|
|
19
|
+
"up": lambda: commands.cmd_up(
|
|
20
|
+
root, mf, scope=args.scope, mode=args.mode, switch=args.switch, as_json=j
|
|
21
|
+
),
|
|
22
|
+
"down": lambda: commands.cmd_down(
|
|
23
|
+
root,
|
|
24
|
+
mf,
|
|
25
|
+
scope=args.scope,
|
|
26
|
+
target=args.target,
|
|
27
|
+
all_instances=args.all_instances,
|
|
28
|
+
as_json=j,
|
|
29
|
+
),
|
|
30
|
+
"status": lambda: commands.cmd_status(root, mf, as_json=j),
|
|
31
|
+
"ps": lambda: commands.cmd_ps(
|
|
32
|
+
health=args.health, as_json=j, wide=getattr(args, "wide", False)
|
|
33
|
+
),
|
|
34
|
+
"ls": lambda: commands.cmd_ps(
|
|
35
|
+
health=args.health, as_json=j, wide=getattr(args, "wide", False)
|
|
36
|
+
),
|
|
37
|
+
"list": lambda: commands.cmd_ps(
|
|
38
|
+
health=args.health, as_json=j, wide=getattr(args, "wide", False)
|
|
39
|
+
),
|
|
40
|
+
"prune": lambda: commands.cmd_prune(force=args.force, as_json=j),
|
|
41
|
+
"check": lambda: commands.cmd_check(root, mf, mode=args.mode, as_json=j),
|
|
42
|
+
"init": lambda: commands.cmd_init(
|
|
43
|
+
root, dry_run=args.dry_run, force=args.force, up=args.up, as_json=j
|
|
44
|
+
),
|
|
45
|
+
"schema": lambda: commands.cmd_schema(as_json=j),
|
|
46
|
+
"logs": lambda: commands.cmd_logs(
|
|
47
|
+
root, mf, service=args.service, tail=args.tail, mode=args.mode, as_json=j
|
|
48
|
+
),
|
|
49
|
+
}
|
|
50
|
+
action = dispatch.get(cmd)
|
|
51
|
+
return action() if action else constants.EXIT_OK
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _print_rig_error(exc: RigError) -> None:
|
|
55
|
+
th = get_theme()
|
|
56
|
+
print(format_human_error(exc, th), end="", file=sys.stderr)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _handle_exception(exc: Exception, cmd: str, as_json: bool) -> int:
|
|
60
|
+
if isinstance(exc, RigError):
|
|
61
|
+
if as_json:
|
|
62
|
+
print_json_error(exc, command=cmd)
|
|
63
|
+
else:
|
|
64
|
+
_print_rig_error(exc)
|
|
65
|
+
return exc.exit_code
|
|
66
|
+
if isinstance(exc, TimeoutError):
|
|
67
|
+
err = RigError(str(exc), code="E_LOCK_TIMEOUT", exit_code=constants.EXIT_MUTEX_CONFLICT)
|
|
68
|
+
return _handle_exception(err, cmd, as_json)
|
|
69
|
+
if isinstance(exc, KeyboardInterrupt):
|
|
70
|
+
err = RigError(
|
|
71
|
+
"operation cancelled by user",
|
|
72
|
+
code="E_INTERRUPTED",
|
|
73
|
+
exit_code=constants.EXIT_INTERRUPTED,
|
|
74
|
+
)
|
|
75
|
+
return _handle_exception(err, cmd, as_json)
|
|
76
|
+
err = RigError(
|
|
77
|
+
f"unexpected error: {exc}", code="E_INTERNAL", exit_code=constants.EXIT_OP_FAILED
|
|
78
|
+
)
|
|
79
|
+
return _handle_exception(err, cmd, as_json)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _prepare_args(args: argparse.Namespace, as_json: bool) -> None:
|
|
83
|
+
args.as_json = as_json
|
|
84
|
+
r_arg = getattr(args, "root", None)
|
|
85
|
+
args.root_dir = Path(r_arg).resolve() if r_arg else find_project_root()
|
|
86
|
+
m_arg = getattr(args, "manifest", None)
|
|
87
|
+
args.manifest_file = Path(m_arg).resolve() if m_arg else find_default_manifest(args.root_dir)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Teardown command for stopping individual checkouts or all machine instances."""
|
|
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
|
+
_merged_depends_on,
|
|
10
|
+
_stop_record,
|
|
11
|
+
reverse_dependency_order,
|
|
12
|
+
)
|
|
13
|
+
from rig.commands.down.runner import down_checkout
|
|
14
|
+
from rig.core.constants import (
|
|
15
|
+
EXIT_NOT_FOUND,
|
|
16
|
+
EXIT_OK,
|
|
17
|
+
EXIT_OP_FAILED,
|
|
18
|
+
LOCK_FILE_NAME,
|
|
19
|
+
STATE_FILE_NAME,
|
|
20
|
+
)
|
|
21
|
+
from rig.core.errors import RigError, print_json_envelope
|
|
22
|
+
from rig.core.identity import get_instances_dir
|
|
23
|
+
from rig.core.locks import exclusive_lock
|
|
24
|
+
from rig.core.state import read_state, write_state
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _stop_instance_records(
|
|
28
|
+
services: dict[str, Any], root: Path, state: dict[str, Any]
|
|
29
|
+
) -> tuple[list[str], list[str]]:
|
|
30
|
+
stopped, failed, failed_svcs = [], [], set()
|
|
31
|
+
for name in reverse_dependency_order(services):
|
|
32
|
+
if deps := [d for d in failed_svcs if name in _merged_depends_on(d, services[d])]:
|
|
33
|
+
failed.append(f"{name}: refused (needed by running dependent {', '.join(deps)})")
|
|
34
|
+
failed_svcs.add(name)
|
|
35
|
+
continue
|
|
36
|
+
outcome = _stop_record(services[name], root)
|
|
37
|
+
if outcome in ("terminated", "killed", "stale"):
|
|
38
|
+
state["services"].pop(name, None)
|
|
39
|
+
stopped.append(name)
|
|
40
|
+
else:
|
|
41
|
+
failed_svcs.add(name)
|
|
42
|
+
failed.append(f"{name}: {outcome}")
|
|
43
|
+
return stopped, failed
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _stop_instance(inst_dir: Path) -> dict[str, Any]:
|
|
47
|
+
state_file = inst_dir / STATE_FILE_NAME
|
|
48
|
+
if not state_file.exists():
|
|
49
|
+
return {"instance": inst_dir.name, "status": "no_state", "stopped": [], "failed": []}
|
|
50
|
+
with exclusive_lock(inst_dir / LOCK_FILE_NAME):
|
|
51
|
+
state = read_state(state_file)
|
|
52
|
+
root = Path(state["root"]).resolve() if state.get("root") else inst_dir
|
|
53
|
+
stopped, failed = _stop_instance_records(dict(state.get("services", {})), root, state)
|
|
54
|
+
state["generation"] = int(state.get("generation", 0)) + 1
|
|
55
|
+
write_state(state_file, state)
|
|
56
|
+
proj = state.get("project", inst_dir.name)
|
|
57
|
+
return {"instance": inst_dir.name, "project": proj, "stopped": stopped, "failed": failed}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _down_all_instances(as_json: bool) -> int:
|
|
61
|
+
instances_dir = get_instances_dir()
|
|
62
|
+
if not instances_dir.is_dir():
|
|
63
|
+
if as_json:
|
|
64
|
+
print_json_envelope("down", {"instances": [], "all": True})
|
|
65
|
+
else:
|
|
66
|
+
print("No active rig instances to stop.")
|
|
67
|
+
return EXIT_OK
|
|
68
|
+
results = [
|
|
69
|
+
_stop_instance(d)
|
|
70
|
+
for d in sorted(instances_dir.iterdir())
|
|
71
|
+
if d.is_dir() and (d / STATE_FILE_NAME).is_file()
|
|
72
|
+
]
|
|
73
|
+
failed_any = any(r.get("failed") for r in results)
|
|
74
|
+
if as_json:
|
|
75
|
+
print_json_envelope("down", {"instances": results, "all": True}, ok=not failed_any)
|
|
76
|
+
return EXIT_OP_FAILED if failed_any else EXIT_OK
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _matches_target(d: Path, target: str) -> bool:
|
|
80
|
+
if not (d.is_dir() and (d / STATE_FILE_NAME).is_file()):
|
|
81
|
+
return False
|
|
82
|
+
proj = read_state(d / STATE_FILE_NAME).get("project") or d.name.rsplit("-", 1)[0]
|
|
83
|
+
return target.lower() in (d.name.lower(), proj.lower()) or d.name == target
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _find_matching_instances(instances_dir: Path, target: str) -> list[Path]:
|
|
87
|
+
if not instances_dir.is_dir():
|
|
88
|
+
return []
|
|
89
|
+
return [d for d in sorted(instances_dir.iterdir()) if _matches_target(d, target)]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _find_target(target: str) -> Path:
|
|
93
|
+
matched = _find_matching_instances(get_instances_dir(), target)
|
|
94
|
+
if not matched:
|
|
95
|
+
msg = f"no instance found matching {target!r}"
|
|
96
|
+
head = f"No active or recorded instance matches {target!r}"
|
|
97
|
+
hint = "Check running instances with 'rig ps'"
|
|
98
|
+
raise RigError(msg, code="E_NOT_FOUND", exit_code=EXIT_NOT_FOUND, headline=head, hint=hint)
|
|
99
|
+
if len(matched) > 1:
|
|
100
|
+
names = ", ".join(d.name for d in matched)
|
|
101
|
+
msg = f"ambiguous target {target!r}; matches: {names}"
|
|
102
|
+
head = f"Multiple instances match {target!r}"
|
|
103
|
+
ctx = f"Matching instances:\n{names}"
|
|
104
|
+
hint = "Specify the full instance identifier (e.g. from 'rig ps')"
|
|
105
|
+
raise RigError(
|
|
106
|
+
msg,
|
|
107
|
+
code="E_AMBIGUOUS",
|
|
108
|
+
exit_code=EXIT_NOT_FOUND,
|
|
109
|
+
headline=head,
|
|
110
|
+
context=ctx,
|
|
111
|
+
hint=hint,
|
|
112
|
+
)
|
|
113
|
+
return matched[0]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def cmd_down(
|
|
117
|
+
root: Path | None = None,
|
|
118
|
+
manifest_path: Path | None = None,
|
|
119
|
+
scope: str = "full",
|
|
120
|
+
*args: Any,
|
|
121
|
+
**kwargs: Any,
|
|
122
|
+
) -> int:
|
|
123
|
+
argv = list(args)
|
|
124
|
+
target = kwargs.get("target") or (argv.pop(0) if argv else None)
|
|
125
|
+
all_inst = bool(kwargs.get("all_instances") or (argv.pop(0) if argv else False))
|
|
126
|
+
as_json = bool(kwargs.get("as_json") or (argv.pop(0) if argv else False))
|
|
127
|
+
if all_inst:
|
|
128
|
+
return _down_all_instances(as_json)
|
|
129
|
+
if target:
|
|
130
|
+
res = _stop_instance(_find_target(target))
|
|
131
|
+
if as_json:
|
|
132
|
+
print_json_envelope("down", res, ok=not res.get("failed"))
|
|
133
|
+
return EXIT_OP_FAILED if res.get("failed") else EXIT_OK
|
|
134
|
+
if root and manifest_path:
|
|
135
|
+
return down_checkout(root, manifest_path, scope, as_json=as_json)
|
|
136
|
+
return EXIT_OK
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
__all__ = ["_down_all_instances", "_find_target", "_stop_instance", "cmd_down", "down_checkout"]
|