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/net/ports.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Loopback socket allocation, port reservation, and sticky lease computation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import socket
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from rig.core.constants import PORT_MAX, PORT_MIN, PORT_RELEASE_TIMEOUT_SECS
|
|
12
|
+
from rig.net.registry import (
|
|
13
|
+
get_allocated_ports_for_others,
|
|
14
|
+
get_or_allocate_port,
|
|
15
|
+
port_is_free,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
DEFAULT_PORT_WINDOW = 50
|
|
19
|
+
|
|
20
|
+
_BASE_PORT_RULES = (
|
|
21
|
+
(("front", "web", "ui", "client", "next", "vite"), 3000),
|
|
22
|
+
(("back", "api", "server", "app", "worker"), 8000),
|
|
23
|
+
(("doc", "storybook", "admin"), 4000),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _bind_candidate_port(port: int) -> tuple[socket.socket, int] | None:
|
|
28
|
+
if port < PORT_MIN or port > PORT_MAX:
|
|
29
|
+
return None
|
|
30
|
+
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
31
|
+
try:
|
|
32
|
+
listener.bind(("127.0.0.1", port))
|
|
33
|
+
listener.listen(socket.SOMAXCONN)
|
|
34
|
+
except OSError:
|
|
35
|
+
listener.close()
|
|
36
|
+
return None
|
|
37
|
+
else:
|
|
38
|
+
return listener, port
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _find_candidate_listener(
|
|
42
|
+
candidate_ports: Sequence[int],
|
|
43
|
+
) -> tuple[socket.socket, int] | None:
|
|
44
|
+
return next((r for p in candidate_ports if (r := _bind_candidate_port(p)) is not None), None)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def allocate_listener(candidate_ports: Sequence[int] | None = None) -> tuple[socket.socket, int]:
|
|
48
|
+
"""Bind and listen on a loopback port, keeping ownership of it."""
|
|
49
|
+
if candidate_ports and (candidate := _find_candidate_listener(candidate_ports)) is not None:
|
|
50
|
+
return candidate
|
|
51
|
+
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
52
|
+
try:
|
|
53
|
+
listener.bind(("127.0.0.1", 0))
|
|
54
|
+
listener.listen(socket.SOMAXCONN)
|
|
55
|
+
except BaseException:
|
|
56
|
+
listener.close()
|
|
57
|
+
raise
|
|
58
|
+
return listener, listener.getsockname()[1]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _safe_allocate_listener(
|
|
62
|
+
candidate_ports: Sequence[int] | None = None,
|
|
63
|
+
) -> tuple[socket.socket, int]:
|
|
64
|
+
try:
|
|
65
|
+
return allocate_listener(candidate_ports) if candidate_ports else allocate_listener()
|
|
66
|
+
except TypeError:
|
|
67
|
+
return allocate_listener()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def reserve_port(candidate_ports: Sequence[int] | None = None) -> int:
|
|
71
|
+
"""Return a currently free loopback port for a service that cannot inherit a socket."""
|
|
72
|
+
listener, port = _safe_allocate_listener(candidate_ports)
|
|
73
|
+
listener.close()
|
|
74
|
+
return port
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _safe_reserve_port(candidate_ports: Sequence[int] | None = None) -> int:
|
|
78
|
+
try:
|
|
79
|
+
return reserve_port(candidate_ports) if candidate_ports else reserve_port()
|
|
80
|
+
except TypeError:
|
|
81
|
+
return reserve_port()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def wait_for_port_release(port: int, timeout: float = PORT_RELEASE_TIMEOUT_SECS) -> bool:
|
|
85
|
+
deadline = time.monotonic() + timeout
|
|
86
|
+
while True:
|
|
87
|
+
if port_is_free(port):
|
|
88
|
+
return True
|
|
89
|
+
if time.monotonic() >= deadline:
|
|
90
|
+
return False
|
|
91
|
+
time.sleep(0.05)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def default_base_port_for_service(name: str) -> int:
|
|
95
|
+
"""Return a human-friendly default base port based on service name conventions."""
|
|
96
|
+
slug = name.lower()
|
|
97
|
+
return next((port for keys, port in _BASE_PORT_RULES if any(k in slug for k in keys)), 5000)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _collect_excluded_ports(
|
|
101
|
+
state: Mapping[str, Any],
|
|
102
|
+
avoid: set[int] | None = None,
|
|
103
|
+
target: tuple[str, str] | None = None,
|
|
104
|
+
) -> set[int]:
|
|
105
|
+
excluded = set(avoid or ())
|
|
106
|
+
for srec in state.get("services", {}).values():
|
|
107
|
+
if isinstance(srec, dict) and srec.get("port"):
|
|
108
|
+
with contextlib.suppress(ValueError, TypeError):
|
|
109
|
+
excluded.add(int(srec["port"]))
|
|
110
|
+
if target is not None:
|
|
111
|
+
excluded.update(get_allocated_ports_for_others(target))
|
|
112
|
+
return excluded
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _resolve_base_port(service: Any, state: Mapping[str, Any]) -> int:
|
|
116
|
+
pref = getattr(service, "preferred_port", None)
|
|
117
|
+
if pref is not None and PORT_MIN <= pref <= PORT_MAX:
|
|
118
|
+
return pref
|
|
119
|
+
project = state.get("project") or getattr(service, "project", None)
|
|
120
|
+
if project:
|
|
121
|
+
base = default_base_port_for_service(service.name)
|
|
122
|
+
return get_or_allocate_port((str(project), service.name), base)
|
|
123
|
+
leased = state.get("ports", {}).get(service.name)
|
|
124
|
+
if leased is not None and isinstance(leased, int) and PORT_MIN <= leased <= PORT_MAX:
|
|
125
|
+
return leased
|
|
126
|
+
return default_base_port_for_service(service.name)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def compute_candidate_ports(
|
|
130
|
+
service: Any,
|
|
131
|
+
state: Mapping[str, Any],
|
|
132
|
+
avoid: set[int] | None = None,
|
|
133
|
+
) -> list[int]:
|
|
134
|
+
"""Compute an ordered list of candidate ports for a service."""
|
|
135
|
+
project = state.get("project") or getattr(service, "project", None)
|
|
136
|
+
sname = getattr(service, "name", None)
|
|
137
|
+
target = (str(project), sname) if project and sname else None
|
|
138
|
+
excluded = _collect_excluded_ports(state, avoid, target)
|
|
139
|
+
base_port = _resolve_base_port(service, state)
|
|
140
|
+
limit = min(base_port + DEFAULT_PORT_WINDOW, PORT_MAX + 1)
|
|
141
|
+
return [port for port in range(base_port, limit) if port not in excluded]
|
rig/net/probe.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Port listener inspection and process group querying."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run_query_command(cmd: Sequence[str], timeout: float = 1.0) -> str | None:
|
|
12
|
+
"""Run a query subprocess safely, returning stdout if successful."""
|
|
13
|
+
try:
|
|
14
|
+
proc = subprocess.run(
|
|
15
|
+
cmd,
|
|
16
|
+
capture_output=True,
|
|
17
|
+
text=True,
|
|
18
|
+
check=False,
|
|
19
|
+
timeout=timeout,
|
|
20
|
+
)
|
|
21
|
+
except (subprocess.SubprocessError, OSError):
|
|
22
|
+
return None
|
|
23
|
+
if proc.returncode != 0 or not proc.stdout.strip():
|
|
24
|
+
return None
|
|
25
|
+
return proc.stdout.strip()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _matches_pgid(p: int, pgid: int) -> bool:
|
|
29
|
+
try:
|
|
30
|
+
return os.getpgid(p) == pgid
|
|
31
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_pid_owned(p: int, pid: int | None, pgid: int | None) -> bool:
|
|
36
|
+
if pid is not None and p == pid:
|
|
37
|
+
return True
|
|
38
|
+
return bool(pgid is not None and _matches_pgid(p, pgid))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _get_listener_pids(port: int) -> list[int]:
|
|
42
|
+
lsof_bin = shutil.which("lsof")
|
|
43
|
+
if not lsof_bin:
|
|
44
|
+
return []
|
|
45
|
+
output = run_query_command([lsof_bin, "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"])
|
|
46
|
+
if not output:
|
|
47
|
+
return []
|
|
48
|
+
return [int(line.strip()) for line in output.splitlines() if line.strip().isdigit()]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def port_listener_matches(port: int, pgid: int | None = None, pid: int | None = None) -> bool:
|
|
52
|
+
"""Return ``True`` only when a listener on ``port`` belongs to ``pid`` or ``pgid``."""
|
|
53
|
+
if pid is None and pgid is None:
|
|
54
|
+
return False
|
|
55
|
+
pids = _get_listener_pids(port)
|
|
56
|
+
return bool(pids) and all(_is_pid_owned(p, pid, pgid) for p in pids)
|
rig/net/registry.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Centralized machine-wide port registry and reservation management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
import tempfile
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from rig.core.constants import DIR_MODE_PRIVATE, FILE_MODE_PRIVATE, PORT_MAX, PORT_MIN
|
|
14
|
+
from rig.core.identity import get_state_home
|
|
15
|
+
from rig.core.locks import exclusive_lock
|
|
16
|
+
|
|
17
|
+
PORT_REGISTRY_FILE = "ports.json"
|
|
18
|
+
PORT_LOCK_FILE = "ports.lock"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def port_is_free(port: int) -> bool:
|
|
22
|
+
"""Return True when nothing is listening on port on loopback."""
|
|
23
|
+
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
24
|
+
with probe:
|
|
25
|
+
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
26
|
+
try:
|
|
27
|
+
probe.bind(("127.0.0.1", port))
|
|
28
|
+
except OSError:
|
|
29
|
+
return False
|
|
30
|
+
return True
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _ensure_dir(filename: str) -> Path:
|
|
34
|
+
home = get_state_home()
|
|
35
|
+
home.mkdir(parents=True, mode=DIR_MODE_PRIVATE, exist_ok=True)
|
|
36
|
+
return home / filename
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def read_port_registry(path: Path | None = None) -> dict[str, Any]:
|
|
40
|
+
"""Read central port registry, returning empty schema on error."""
|
|
41
|
+
target = path or _ensure_dir(PORT_REGISTRY_FILE)
|
|
42
|
+
try:
|
|
43
|
+
data = json.loads(target.read_text())
|
|
44
|
+
if isinstance(data, dict):
|
|
45
|
+
allocs = data.get("allocations")
|
|
46
|
+
data["allocations"] = allocs if isinstance(allocs, dict) else {}
|
|
47
|
+
return data
|
|
48
|
+
except (OSError, json.JSONDecodeError):
|
|
49
|
+
pass
|
|
50
|
+
return {"version": 1, "allocations": {}}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _write_port_registry(target: Path, data: Mapping[str, Any]) -> None:
|
|
54
|
+
target.parent.mkdir(parents=True, mode=DIR_MODE_PRIVATE, exist_ok=True)
|
|
55
|
+
with tempfile.NamedTemporaryFile(
|
|
56
|
+
mode="w", dir=target.parent, delete=False, prefix=".ports.tmp."
|
|
57
|
+
) as handle:
|
|
58
|
+
json.dump(data, handle, indent=2, sort_keys=True)
|
|
59
|
+
handle.write("\n")
|
|
60
|
+
handle.flush()
|
|
61
|
+
os.fsync(handle.fileno())
|
|
62
|
+
tmp = handle.name
|
|
63
|
+
os.chmod(tmp, FILE_MODE_PRIVATE)
|
|
64
|
+
os.replace(tmp, target)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def allocation_key(project: str, service_name: str) -> str:
|
|
68
|
+
return f"{project}:{service_name}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _find_free_port(base_port: int, window: int, used_ports: set[int]) -> int:
|
|
72
|
+
limit = min(base_port + window, PORT_MAX + 1)
|
|
73
|
+
for cand in range(base_port, limit):
|
|
74
|
+
if cand not in used_ports and port_is_free(cand):
|
|
75
|
+
return cand
|
|
76
|
+
for cand in range(limit, PORT_MAX + 1):
|
|
77
|
+
if cand not in used_ports and port_is_free(cand):
|
|
78
|
+
return cand
|
|
79
|
+
return base_port
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_or_allocate_port(target: tuple[str, str], base_port: int, window: int = 50) -> int:
|
|
83
|
+
"""Return assigned port from central registry, allocating next free if absent."""
|
|
84
|
+
project, service_name = target
|
|
85
|
+
key = allocation_key(project, service_name)
|
|
86
|
+
reg_path, lock_path = _ensure_dir(PORT_REGISTRY_FILE), _ensure_dir(PORT_LOCK_FILE)
|
|
87
|
+
with exclusive_lock(lock_path):
|
|
88
|
+
data = read_port_registry(reg_path)
|
|
89
|
+
allocs = data["allocations"]
|
|
90
|
+
if key in allocs:
|
|
91
|
+
assigned = allocs[key]
|
|
92
|
+
if isinstance(assigned, int) and PORT_MIN <= assigned <= PORT_MAX:
|
|
93
|
+
return assigned
|
|
94
|
+
used = {int(p) for p in allocs.values() if isinstance(p, int) and PORT_MIN <= p <= PORT_MAX}
|
|
95
|
+
port = _find_free_port(base_port, window, used)
|
|
96
|
+
allocs[key] = port
|
|
97
|
+
_write_port_registry(reg_path, data)
|
|
98
|
+
return port
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_allocated_ports_for_others(target: tuple[str, str]) -> set[int]:
|
|
102
|
+
"""Return set of ports allocated to other projects or services."""
|
|
103
|
+
project, service_name = target
|
|
104
|
+
key = allocation_key(project, service_name)
|
|
105
|
+
data = read_port_registry(_ensure_dir(PORT_REGISTRY_FILE))
|
|
106
|
+
allocs = data.get("allocations", {})
|
|
107
|
+
return {
|
|
108
|
+
int(p)
|
|
109
|
+
for k, p in allocs.items()
|
|
110
|
+
if k != key and isinstance(p, int) and PORT_MIN <= p <= PORT_MAX
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def release_port_allocation(project: str, service_name: str | None = None) -> list[int]:
|
|
115
|
+
"""Release port allocation for a project or specific service."""
|
|
116
|
+
reg_path, lock_path = _ensure_dir(PORT_REGISTRY_FILE), _ensure_dir(PORT_LOCK_FILE)
|
|
117
|
+
with exclusive_lock(lock_path):
|
|
118
|
+
data = read_port_registry(reg_path)
|
|
119
|
+
allocs = data.get("allocations", {})
|
|
120
|
+
prefix = f"{project}:" if service_name is None else f"{project}:{service_name}"
|
|
121
|
+
released = [
|
|
122
|
+
allocs.pop(k)
|
|
123
|
+
for k in list(allocs.keys())
|
|
124
|
+
if k == prefix or (service_name is None and k.startswith(prefix))
|
|
125
|
+
]
|
|
126
|
+
if released:
|
|
127
|
+
_write_port_registry(reg_path, data)
|
|
128
|
+
return released
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def list_port_allocations() -> dict[str, int]:
|
|
132
|
+
return dict(read_port_registry(_ensure_dir(PORT_REGISTRY_FILE)).get("allocations", {}))
|
rig/parser.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""CLI argument parser setup and error handling for rig."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from rig.core.constants import EXIT_USAGE, __version__
|
|
10
|
+
from rig.core.errors import RigError, print_json_error
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RigArgumentParser(argparse.ArgumentParser):
|
|
14
|
+
"""ArgumentParser that outputs structured JSON errors when requested."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, *args: Any, as_json: bool = False, **kwargs: Any) -> None:
|
|
17
|
+
super().__init__(*args, **kwargs)
|
|
18
|
+
self.as_json = as_json
|
|
19
|
+
|
|
20
|
+
def error(self, message: str) -> None:
|
|
21
|
+
if self.as_json:
|
|
22
|
+
err = RigError(message, code="E_USAGE", exit_code=EXIT_USAGE)
|
|
23
|
+
print_json_error(err, command="cli")
|
|
24
|
+
sys.exit(EXIT_USAGE)
|
|
25
|
+
super().error(message)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _add_service_parsers(sub: Any) -> None:
|
|
29
|
+
p_up = sub.add_parser("up", help="start services")
|
|
30
|
+
p_up.add_argument("--scope", default="full")
|
|
31
|
+
p_up.add_argument("--mode", default=None)
|
|
32
|
+
p_up.add_argument("--switch", action="store_true")
|
|
33
|
+
|
|
34
|
+
p_down = sub.add_parser("down", help="stop services")
|
|
35
|
+
p_down.add_argument("target", nargs="?", default=None)
|
|
36
|
+
p_down.add_argument("--all", action="store_true", dest="all_instances")
|
|
37
|
+
p_down.add_argument("--scope", default="full")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _add_admin_parsers(sub: Any) -> None:
|
|
41
|
+
sub.add_parser("status", help="status")
|
|
42
|
+
p_ps = sub.add_parser("ps", aliases=["ls", "list"], help="ps")
|
|
43
|
+
p_ps.add_argument("--health", action="store_true")
|
|
44
|
+
p_ps.add_argument("-w", "--wide", action="store_true")
|
|
45
|
+
sub.add_parser("prune", help="prune").add_argument("--force", action="store_true")
|
|
46
|
+
sub.add_parser("check", help="check").add_argument("--mode", default=None)
|
|
47
|
+
p_init = sub.add_parser("init", help="init")
|
|
48
|
+
p_init.add_argument("--dry-run", action="store_true")
|
|
49
|
+
p_init.add_argument("--force", action="store_true")
|
|
50
|
+
p_init.add_argument("--up", action="store_true")
|
|
51
|
+
sub.add_parser("schema", help="schema")
|
|
52
|
+
p_logs = sub.add_parser("logs", help="logs")
|
|
53
|
+
p_logs.add_argument("service", nargs="?", default=None, help="service name")
|
|
54
|
+
p_logs.add_argument("-n", "--tail", type=int, default=50, help="number of lines")
|
|
55
|
+
p_logs.add_argument("--mode", default=None, help="mode overlay")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_parser(as_json: bool = False) -> argparse.ArgumentParser:
|
|
59
|
+
"""Construct the command line parser for rig."""
|
|
60
|
+
parser = RigArgumentParser(prog="rig", as_json=as_json)
|
|
61
|
+
parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}")
|
|
62
|
+
parser.add_argument("--root", default=None)
|
|
63
|
+
parser.add_argument("--manifest", default=None)
|
|
64
|
+
parser.add_argument("--json", action="store_true")
|
|
65
|
+
sub = parser.add_subparsers(
|
|
66
|
+
dest="command",
|
|
67
|
+
required=True,
|
|
68
|
+
parser_class=lambda **kw: RigArgumentParser(as_json=as_json, **kw),
|
|
69
|
+
)
|
|
70
|
+
_add_service_parsers(sub)
|
|
71
|
+
_add_admin_parsers(sub)
|
|
72
|
+
return parser
|
rig/proc/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Process lifecycle, spawning, inspection, and teardown for rig."""
|
rig/proc/process.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Process identity inspection and liveness verification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
_OWN_CHILDREN: dict[int, subprocess.Popen] = {}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _ps(pid: int, fields: str) -> str | None:
|
|
16
|
+
try:
|
|
17
|
+
completed = subprocess.run(
|
|
18
|
+
["ps", "-ww", "-p", str(pid), "-o", fields],
|
|
19
|
+
capture_output=True,
|
|
20
|
+
text=True,
|
|
21
|
+
timeout=10,
|
|
22
|
+
)
|
|
23
|
+
except (OSError, subprocess.SubprocessError):
|
|
24
|
+
return None
|
|
25
|
+
if completed.returncode != 0:
|
|
26
|
+
return None
|
|
27
|
+
line = completed.stdout.strip()
|
|
28
|
+
return line or None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def process_start_time(pid: int) -> str | None:
|
|
32
|
+
"""Return the kernel-reported start time of ``pid``."""
|
|
33
|
+
value = _ps(pid, "lstart=")
|
|
34
|
+
return " ".join(value.split()) if value else None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def process_args(pid: int) -> str | None:
|
|
38
|
+
value = _ps(pid, "args=")
|
|
39
|
+
return " ".join(value.split()) if value else None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def stable_process_args(pid: int, settle: float = 1.0, interval: float = 0.06) -> str | None:
|
|
43
|
+
"""Return the command line once two consecutive reads agree."""
|
|
44
|
+
previous = process_args(pid)
|
|
45
|
+
deadline = time.monotonic() + max(settle, 0.0)
|
|
46
|
+
while time.monotonic() < deadline:
|
|
47
|
+
time.sleep(interval)
|
|
48
|
+
current = process_args(pid)
|
|
49
|
+
if current is None or current == previous:
|
|
50
|
+
return current if current is not None else previous
|
|
51
|
+
previous = current
|
|
52
|
+
return previous
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_zombie(pid: int) -> bool:
|
|
56
|
+
state = _ps(pid, "state=")
|
|
57
|
+
return bool(state) and state.lstrip().upper().startswith("Z")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _collect(pid: int) -> bool:
|
|
61
|
+
"""Reap a child this process started. Returns ``True`` when it has exited."""
|
|
62
|
+
child = _OWN_CHILDREN.get(pid)
|
|
63
|
+
if child is None or child.poll() is None:
|
|
64
|
+
return False
|
|
65
|
+
_OWN_CHILDREN.pop(pid, None)
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _is_live_signal(pid: int) -> bool:
|
|
70
|
+
try:
|
|
71
|
+
os.kill(pid, 0)
|
|
72
|
+
except ProcessLookupError:
|
|
73
|
+
return False
|
|
74
|
+
except PermissionError:
|
|
75
|
+
return True
|
|
76
|
+
else:
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def pid_alive(pid: int) -> bool:
|
|
81
|
+
"""Return ``True`` when ``pid`` names a live, non-zombie process."""
|
|
82
|
+
if _collect(pid) or not _is_live_signal(pid):
|
|
83
|
+
return False
|
|
84
|
+
if _is_zombie(pid):
|
|
85
|
+
_collect(pid)
|
|
86
|
+
return False
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def resolve_binary(argv0: str) -> str:
|
|
91
|
+
"""Return the canonical executable path for ``argv0``."""
|
|
92
|
+
if os.sep in argv0:
|
|
93
|
+
return os.path.realpath(argv0)
|
|
94
|
+
found = shutil.which(argv0)
|
|
95
|
+
return os.path.realpath(found) if found else argv0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _binary_matches(recorded: str, observed_argv0: str) -> bool:
|
|
99
|
+
if os.sep in observed_argv0:
|
|
100
|
+
return os.path.realpath(observed_argv0) == recorded
|
|
101
|
+
return os.path.basename(recorded).startswith(observed_argv0)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def identity_baseline(record: Mapping[str, Any]) -> str:
|
|
105
|
+
"""Return the command line that ``pid`` must still report to be considered ours."""
|
|
106
|
+
observed = record.get("identity")
|
|
107
|
+
if observed:
|
|
108
|
+
return str(observed)
|
|
109
|
+
argv = record.get("argv") or []
|
|
110
|
+
return " ".join(str(item) for item in argv)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _record_fields_valid(record: Mapping[str, Any], baseline: str) -> bool:
|
|
114
|
+
pid = record.get("pid")
|
|
115
|
+
return (
|
|
116
|
+
isinstance(pid, int)
|
|
117
|
+
and pid > 0
|
|
118
|
+
and bool(record.get("start_time") and baseline and record.get("binary"))
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def identity_matches(record: Mapping[str, Any]) -> bool:
|
|
123
|
+
"""Return ``True`` only when ``pid`` still runs exactly the recorded program."""
|
|
124
|
+
baseline = identity_baseline(record)
|
|
125
|
+
if not _record_fields_valid(record, baseline):
|
|
126
|
+
return False
|
|
127
|
+
pid = int(record["pid"])
|
|
128
|
+
if not pid_alive(pid) or process_start_time(pid) != record.get("start_time"):
|
|
129
|
+
return False
|
|
130
|
+
observed = process_args(pid)
|
|
131
|
+
if not observed or observed != baseline:
|
|
132
|
+
return False
|
|
133
|
+
return _binary_matches(str(record.get("binary")), observed.split()[0])
|
rig/proc/record.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Process record construction and metadata formatting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from rig.proc.process import process_start_time, resolve_binary, stable_process_args
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_record(
|
|
15
|
+
proc: subprocess.Popen,
|
|
16
|
+
name: str,
|
|
17
|
+
meta: tuple[str, list[str], int | None, Mapping[str, Any] | None],
|
|
18
|
+
) -> dict[str, Any]:
|
|
19
|
+
"""Construct structured process record dictionary."""
|
|
20
|
+
kind, resolved, port, extra = meta
|
|
21
|
+
observed = stable_process_args(proc.pid)
|
|
22
|
+
argv0 = observed.split()[0] if observed else str(resolved[0])
|
|
23
|
+
try:
|
|
24
|
+
pgid = os.getpgid(proc.pid)
|
|
25
|
+
except ProcessLookupError:
|
|
26
|
+
pgid = proc.pid
|
|
27
|
+
rec = {
|
|
28
|
+
"name": name,
|
|
29
|
+
"type": kind,
|
|
30
|
+
"pid": proc.pid,
|
|
31
|
+
"pgid": pgid,
|
|
32
|
+
"binary": resolve_binary(argv0),
|
|
33
|
+
"argv": list(resolved),
|
|
34
|
+
"identity": observed,
|
|
35
|
+
"start_time": process_start_time(proc.pid),
|
|
36
|
+
"port": port,
|
|
37
|
+
"url": f"http://127.0.0.1:{port}" if port else None,
|
|
38
|
+
"started_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
39
|
+
}
|
|
40
|
+
return {**rec, **(extra or {})}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _record(
|
|
44
|
+
name: str,
|
|
45
|
+
kind: str,
|
|
46
|
+
proc: subprocess.Popen,
|
|
47
|
+
*args: Any,
|
|
48
|
+
**kwargs: Any,
|
|
49
|
+
) -> dict[str, Any]:
|
|
50
|
+
argv = list(args)
|
|
51
|
+
resolved = kwargs.get("argv") or (argv.pop(0) if argv else [])
|
|
52
|
+
port = kwargs.get("port") if "port" in kwargs else (argv.pop(0) if argv else None)
|
|
53
|
+
extra = kwargs.get("extra") if "extra" in kwargs else (argv.pop(0) if argv else None)
|
|
54
|
+
return build_record(proc, name, (kind, resolved, port, extra))
|