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