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/proc/spawn.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Process spawning with socket inheritance and strict port contracts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
import socket
|
|
9
|
+
import subprocess
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from rig.core.constants import DIR_MODE_PRIVATE
|
|
15
|
+
from rig.core.env import render
|
|
16
|
+
from rig.core.errors import RigError
|
|
17
|
+
from rig.net.ports import _safe_allocate_listener
|
|
18
|
+
from rig.proc.process import _OWN_CHILDREN
|
|
19
|
+
from rig.proc.record import _record
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def uvicorn_argv(python: str, app: str, factory: bool = False) -> list[str]:
|
|
23
|
+
"""Return an argv that runs uvicorn on an inherited socket descriptor."""
|
|
24
|
+
return [python, "-m", "uvicorn", app, *(["--factory"] if factory else []), "--fd", "{fd}"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _open_log(log_path: Path):
|
|
28
|
+
Path(log_path).parent.mkdir(mode=DIR_MODE_PRIVATE, parents=True, exist_ok=True)
|
|
29
|
+
return open(log_path, "ab", buffering=0)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _popen_service(
|
|
33
|
+
cmd: tuple[str, list[str]],
|
|
34
|
+
ctx: tuple[Path, Mapping[str, str], Any],
|
|
35
|
+
pass_fds: tuple[int, ...] = (),
|
|
36
|
+
) -> subprocess.Popen:
|
|
37
|
+
name, resolved = cmd
|
|
38
|
+
cwd, env, log = ctx
|
|
39
|
+
proc = None
|
|
40
|
+
try:
|
|
41
|
+
proc = subprocess.Popen(
|
|
42
|
+
resolved,
|
|
43
|
+
cwd=str(cwd),
|
|
44
|
+
env=dict(env),
|
|
45
|
+
pass_fds=pass_fds,
|
|
46
|
+
start_new_session=True,
|
|
47
|
+
stdin=subprocess.DEVNULL,
|
|
48
|
+
stdout=log,
|
|
49
|
+
stderr=subprocess.STDOUT,
|
|
50
|
+
)
|
|
51
|
+
_OWN_CHILDREN[proc.pid] = proc
|
|
52
|
+
except BaseException as exc:
|
|
53
|
+
if proc is not None:
|
|
54
|
+
with contextlib.suppress(OSError):
|
|
55
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
56
|
+
if isinstance(exc, OSError):
|
|
57
|
+
raise RigError(f"cannot start service {name}: {exc}") from None
|
|
58
|
+
raise
|
|
59
|
+
else:
|
|
60
|
+
return proc
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _unpack_spawn(
|
|
64
|
+
args: Sequence[Any], kwargs: Mapping[str, Any]
|
|
65
|
+
) -> tuple[Path, dict[str, str], Path]:
|
|
66
|
+
p = list(args)
|
|
67
|
+
cwd = Path(p.pop(0) if p else kwargs.get("cwd", Path.cwd()))
|
|
68
|
+
env = dict(p.pop(0) if p else kwargs.get("env", {}))
|
|
69
|
+
log_path = Path(p.pop(0) if p else kwargs["log_path"])
|
|
70
|
+
return cwd, env, log_path
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _exec_fd_service(
|
|
74
|
+
spec: tuple[str, Sequence[str], Any],
|
|
75
|
+
ctx: tuple[Path, Mapping[str, str], Any],
|
|
76
|
+
net: tuple[socket.socket, int],
|
|
77
|
+
) -> tuple[subprocess.Popen, list[str], int]:
|
|
78
|
+
name, argv, values = spec
|
|
79
|
+
listener, port = net
|
|
80
|
+
fd = listener.fileno()
|
|
81
|
+
os.set_inheritable(fd, True)
|
|
82
|
+
render_vals = {**(values or {}), "fd": fd, "port": port}
|
|
83
|
+
resolved = [str(render(item, render_vals)) for item in argv]
|
|
84
|
+
proc = _popen_service((name, resolved), ctx, pass_fds=(fd,))
|
|
85
|
+
return proc, resolved, fd
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _unpack_spawn_fd_args(
|
|
89
|
+
args: Sequence[Any], kwargs: Mapping[str, Any]
|
|
90
|
+
) -> tuple[tuple[Path, dict[str, str], Path], Any, Any]:
|
|
91
|
+
cwd, env, log_path = _unpack_spawn(args, kwargs)
|
|
92
|
+
p = list(args)[3:]
|
|
93
|
+
values = p.pop(0) if p else kwargs.get("values", {})
|
|
94
|
+
cands = p.pop(0) if p else kwargs.get("candidate_ports")
|
|
95
|
+
return (cwd, env, log_path), values, cands
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def spawn_fd_service(name: str, argv: Sequence[str], *args: Any, **kwargs: Any) -> dict[str, Any]:
|
|
99
|
+
"""Start a service on a listening socket this process binds and hands over."""
|
|
100
|
+
ctx, values, cands = _unpack_spawn_fd_args(args, kwargs)
|
|
101
|
+
log = _open_log(ctx[2])
|
|
102
|
+
listener: socket.socket | None = None
|
|
103
|
+
try:
|
|
104
|
+
listener, port = _safe_allocate_listener(cands)
|
|
105
|
+
proc, res, fd = _exec_fd_service(
|
|
106
|
+
(name, argv, values), (ctx[0], ctx[1], log), (listener, port)
|
|
107
|
+
)
|
|
108
|
+
listener.close()
|
|
109
|
+
listener = None
|
|
110
|
+
return _record(name, "fd", proc, res, port, {"fd": fd})
|
|
111
|
+
finally:
|
|
112
|
+
if listener is not None:
|
|
113
|
+
listener.close()
|
|
114
|
+
log.close()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def spawn_port_service(name: str, argv: Sequence[str], *args: Any, **kwargs: Any) -> dict[str, Any]:
|
|
118
|
+
"""Start a service that binds a port itself, such as a Vite dev server."""
|
|
119
|
+
cwd, env, log_path = _unpack_spawn(args, kwargs)
|
|
120
|
+
p = list(args)[3:]
|
|
121
|
+
port = int(p.pop(0) if p else kwargs["port"])
|
|
122
|
+
values = p.pop(0) if p else kwargs.get("values", {})
|
|
123
|
+
log = _open_log(log_path)
|
|
124
|
+
try:
|
|
125
|
+
render_vals = {**(values or {}), "port": port}
|
|
126
|
+
resolved = [str(render(item, render_vals)) for item in argv]
|
|
127
|
+
proc = _popen_service((name, resolved), (cwd, env, log))
|
|
128
|
+
return _record(name, "port", proc, resolved, port)
|
|
129
|
+
finally:
|
|
130
|
+
log.close()
|
rig/proc/teardown.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Process and process-group termination and signal escalation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from rig.core.constants import TEARDOWN_TIMEOUT_SECS
|
|
14
|
+
from rig.net.probe import run_query_command
|
|
15
|
+
from rig.proc.process import (
|
|
16
|
+
_collect,
|
|
17
|
+
_is_zombie,
|
|
18
|
+
identity_matches,
|
|
19
|
+
pid_alive,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _has_active_pg_members(pgid: int) -> bool:
|
|
24
|
+
"""Return ``True`` if any non-zombie process belongs to process group ``pgid``."""
|
|
25
|
+
output = run_query_command(["ps", "-o", "stat=", "-g", str(pgid)])
|
|
26
|
+
if not output:
|
|
27
|
+
return False
|
|
28
|
+
lines = [line.strip() for line in output.splitlines() if line.strip()]
|
|
29
|
+
return any(not line.startswith("Z") for line in lines)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def pgid_alive(pgid: int) -> bool:
|
|
33
|
+
"""Return ``True`` when at least one process in process group ``pgid`` is alive."""
|
|
34
|
+
_collect(pgid)
|
|
35
|
+
try:
|
|
36
|
+
os.killpg(pgid, 0)
|
|
37
|
+
except ProcessLookupError:
|
|
38
|
+
return False
|
|
39
|
+
except PermissionError:
|
|
40
|
+
_collect(pgid)
|
|
41
|
+
return _has_active_pg_members(pgid)
|
|
42
|
+
else:
|
|
43
|
+
if _is_zombie(pgid):
|
|
44
|
+
_collect(pgid)
|
|
45
|
+
return _has_active_pg_members(pgid)
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _await_pg_exit(pgid: int, timeout: float) -> bool:
|
|
50
|
+
deadline = time.monotonic() + max(timeout, 0.1)
|
|
51
|
+
while True:
|
|
52
|
+
if not pgid_alive(pgid):
|
|
53
|
+
return True
|
|
54
|
+
if time.monotonic() >= deadline:
|
|
55
|
+
return False
|
|
56
|
+
time.sleep(0.05)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _validate_record_target(record: Mapping[str, Any]) -> tuple[int, int] | None:
|
|
60
|
+
pid = record.get("pid")
|
|
61
|
+
pgid = record.get("pgid")
|
|
62
|
+
if not isinstance(pid, int) or not isinstance(pgid, int) or pid <= 0 or pgid <= 0:
|
|
63
|
+
return None
|
|
64
|
+
if pid == os.getpid() or pgid == os.getpgid(0):
|
|
65
|
+
return None
|
|
66
|
+
return pid, pgid
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _is_our_pgid(pid: int, pgid: int) -> bool:
|
|
70
|
+
try:
|
|
71
|
+
return os.getpgid(pid) == pgid
|
|
72
|
+
except (ProcessLookupError, PermissionError):
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _verify_process_ownership(record: Mapping[str, Any], pid: int, pgid: int) -> str | None:
|
|
77
|
+
if not pid_alive(pid):
|
|
78
|
+
return "stale" if not pgid_alive(pgid) else "refused"
|
|
79
|
+
if not identity_matches(record) or not _is_our_pgid(pid, pgid):
|
|
80
|
+
return "refused"
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _kill_permission_fallback(pgid: int, pid: int) -> str:
|
|
85
|
+
if not pgid_alive(pgid):
|
|
86
|
+
_collect(pid)
|
|
87
|
+
return "terminated"
|
|
88
|
+
with contextlib.suppress(OSError, subprocess.SubprocessError):
|
|
89
|
+
subprocess.run(["pkill", "-9", "-g", str(pgid)], check=False)
|
|
90
|
+
if not pgid_alive(pgid):
|
|
91
|
+
_collect(pid)
|
|
92
|
+
return "killed"
|
|
93
|
+
return "refused"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _kill_process_group(pgid: int, pid: int, timeout: float) -> str:
|
|
97
|
+
try:
|
|
98
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
99
|
+
except ProcessLookupError:
|
|
100
|
+
_collect(pid)
|
|
101
|
+
return "terminated"
|
|
102
|
+
except PermissionError:
|
|
103
|
+
return _kill_permission_fallback(pgid, pid)
|
|
104
|
+
|
|
105
|
+
if _await_pg_exit(pgid, timeout):
|
|
106
|
+
_collect(pid)
|
|
107
|
+
return "killed"
|
|
108
|
+
return "failed"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _signal_term_process_group(pgid: int, pid: int, timeout: float) -> str | None:
|
|
112
|
+
try:
|
|
113
|
+
os.killpg(pgid, signal.SIGTERM)
|
|
114
|
+
except ProcessLookupError:
|
|
115
|
+
return "stale"
|
|
116
|
+
except PermissionError:
|
|
117
|
+
return "stale" if not pgid_alive(pgid) else "refused"
|
|
118
|
+
if _await_pg_exit(pgid, timeout):
|
|
119
|
+
_collect(pid)
|
|
120
|
+
return "terminated"
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def terminate_record(record: Mapping[str, Any], timeout: float = TEARDOWN_TIMEOUT_SECS) -> str:
|
|
125
|
+
"""Stop the recorded process group."""
|
|
126
|
+
target = _validate_record_target(record)
|
|
127
|
+
if target is None:
|
|
128
|
+
return "refused"
|
|
129
|
+
pid, pgid = target
|
|
130
|
+
|
|
131
|
+
recheck = _verify_process_ownership(record, pid, pgid)
|
|
132
|
+
if recheck is not None:
|
|
133
|
+
return recheck
|
|
134
|
+
|
|
135
|
+
term_result = _signal_term_process_group(pgid, pid, timeout)
|
|
136
|
+
if term_result is not None:
|
|
137
|
+
return term_result
|
|
138
|
+
|
|
139
|
+
return _kill_process_group(pgid, pid, timeout)
|