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.
Files changed (59) hide show
  1. rig/__init__.py +6 -0
  2. rig/__main__.py +8 -0
  3. rig/cli.py +107 -0
  4. rig/commands/__init__.py +23 -0
  5. rig/commands/check.py +125 -0
  6. rig/commands/common.py +149 -0
  7. rig/commands/dispatch.py +87 -0
  8. rig/commands/down/__init__.py +139 -0
  9. rig/commands/down/runner.py +136 -0
  10. rig/commands/init.py +148 -0
  11. rig/commands/logs.py +146 -0
  12. rig/commands/prune.py +142 -0
  13. rig/commands/ps.py +149 -0
  14. rig/commands/status.py +149 -0
  15. rig/commands/up/__init__.py +111 -0
  16. rig/commands/up/context.py +36 -0
  17. rig/commands/up/loop.py +150 -0
  18. rig/commands/up/relink.py +88 -0
  19. rig/commands/up/rollback.py +49 -0
  20. rig/commands/up/runner.py +118 -0
  21. rig/commands/up/service.py +136 -0
  22. rig/compose/__init__.py +1 -0
  23. rig/compose/client.py +144 -0
  24. rig/compose/context.py +56 -0
  25. rig/compose/discovery.py +121 -0
  26. rig/compose/docker.py +117 -0
  27. rig/compose/starter.py +145 -0
  28. rig/compose/stopper.py +71 -0
  29. rig/compose/supervisor.py +78 -0
  30. rig/core/__init__.py +1 -0
  31. rig/core/constants.py +65 -0
  32. rig/core/env.py +83 -0
  33. rig/core/errors.py +74 -0
  34. rig/core/identity.py +141 -0
  35. rig/core/locks.py +112 -0
  36. rig/core/state.py +150 -0
  37. rig/core/terminal.py +145 -0
  38. rig/manifest/__init__.py +1 -0
  39. rig/manifest/detector.py +138 -0
  40. rig/manifest/inspect.py +18 -0
  41. rig/manifest/loader.py +146 -0
  42. rig/manifest/models.py +129 -0
  43. rig/manifest/parser.py +123 -0
  44. rig/manifest/schema.py +89 -0
  45. rig/net/__init__.py +1 -0
  46. rig/net/health.py +76 -0
  47. rig/net/ports.py +141 -0
  48. rig/net/probe.py +56 -0
  49. rig/net/registry.py +132 -0
  50. rig/parser.py +72 -0
  51. rig/proc/__init__.py +1 -0
  52. rig/proc/process.py +133 -0
  53. rig/proc/record.py +54 -0
  54. rig/proc/spawn.py +130 -0
  55. rig/proc/teardown.py +139 -0
  56. rig_cli-1.0.0.dist-info/METADATA +503 -0
  57. rig_cli-1.0.0.dist-info/RECORD +59 -0
  58. rig_cli-1.0.0.dist-info/WHEEL +4 -0
  59. 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)