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/core/identity.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Instance identity and filesystem path resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fcntl
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import stat
|
|
11
|
+
import subprocess
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from rig.core.constants import DIR_MODE_PRIVATE, FILE_MODE_PRIVATE
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def instance_id(project: str, root: Path | str) -> str:
|
|
18
|
+
"""Return a stable identifier unique to this project *and* this checkout path."""
|
|
19
|
+
canonical = str(Path(root).resolve())
|
|
20
|
+
digest = hashlib.sha256(canonical.encode()).hexdigest()[:8]
|
|
21
|
+
slug = re.sub(r"[^a-z0-9_-]+", "-", project.lower()).strip("-_") or "stack"
|
|
22
|
+
if not slug[0].isalnum():
|
|
23
|
+
slug = f"s{slug}"
|
|
24
|
+
return f"{slug}-{digest}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_state_home() -> Path:
|
|
28
|
+
"""Return root directory for rig global state."""
|
|
29
|
+
override = os.environ.get("RIG_STATE_HOME")
|
|
30
|
+
if override:
|
|
31
|
+
return Path(override).expanduser().resolve()
|
|
32
|
+
xdg = os.environ.get("XDG_STATE_HOME")
|
|
33
|
+
if xdg:
|
|
34
|
+
return (Path(xdg).expanduser() / "rig").resolve()
|
|
35
|
+
return (Path.home() / ".local" / "state" / "rig").resolve()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_instances_dir() -> Path:
|
|
39
|
+
"""Return directory containing all machine-wide instance registries."""
|
|
40
|
+
return get_state_home() / "instances"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_instance_dir(ident: str) -> Path:
|
|
44
|
+
"""Return state directory path for a specific instance."""
|
|
45
|
+
return get_instances_dir() / ident
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def ensure_instance_dir(ident: str) -> Path:
|
|
49
|
+
"""Create and secure owner-only state directory for an instance."""
|
|
50
|
+
inst_dir = get_instance_dir(ident)
|
|
51
|
+
inst_dir.mkdir(parents=True, mode=DIR_MODE_PRIVATE, exist_ok=True)
|
|
52
|
+
if stat.S_IMODE(inst_dir.stat().st_mode) != DIR_MODE_PRIVATE:
|
|
53
|
+
os.chmod(inst_dir, DIR_MODE_PRIVATE)
|
|
54
|
+
return inst_dir
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_boot_id() -> str:
|
|
58
|
+
"""Return OS boot identifier to detect system reboots."""
|
|
59
|
+
linux_boot = Path("/proc/sys/kernel/random/boot_id")
|
|
60
|
+
if linux_boot.exists():
|
|
61
|
+
try:
|
|
62
|
+
return linux_boot.read_text().strip()
|
|
63
|
+
except OSError:
|
|
64
|
+
pass
|
|
65
|
+
try:
|
|
66
|
+
res = subprocess.run(
|
|
67
|
+
["sysctl", "-n", "kern.boottime"],
|
|
68
|
+
capture_output=True,
|
|
69
|
+
text=True,
|
|
70
|
+
timeout=1.0,
|
|
71
|
+
check=False,
|
|
72
|
+
)
|
|
73
|
+
if res.returncode == 0 and res.stdout.strip():
|
|
74
|
+
return res.stdout.strip()
|
|
75
|
+
except (subprocess.SubprocessError, OSError):
|
|
76
|
+
pass
|
|
77
|
+
return "unknown"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def is_locked(path: Path) -> bool:
|
|
81
|
+
"""Return True if path is currently held under exclusive advisory lock."""
|
|
82
|
+
if not (target := Path(path)).exists():
|
|
83
|
+
return False
|
|
84
|
+
try:
|
|
85
|
+
fd = os.open(target, os.O_RDWR | os.O_CLOEXEC | os.O_NOFOLLOW, FILE_MODE_PRIVATE)
|
|
86
|
+
except OSError:
|
|
87
|
+
return False
|
|
88
|
+
try:
|
|
89
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
90
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
91
|
+
except (BlockingIOError, InterruptedError):
|
|
92
|
+
return True
|
|
93
|
+
else:
|
|
94
|
+
return False
|
|
95
|
+
finally:
|
|
96
|
+
os.close(fd)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_PROJECT_CANDIDATES = (
|
|
100
|
+
"rig.json",
|
|
101
|
+
"scripts/rig.json",
|
|
102
|
+
".config/rig.json",
|
|
103
|
+
"stack.json",
|
|
104
|
+
"scripts/stack.json",
|
|
105
|
+
".config/stack.json",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _has_root_marker(directory: Path) -> bool:
|
|
110
|
+
return any((directory / c).exists() for c in (*_PROJECT_CANDIDATES, ".git"))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def find_project_root(start: Path | None = None) -> Path:
|
|
114
|
+
"""Find project root by walking upward from current working directory."""
|
|
115
|
+
current = (start or Path.cwd()).resolve()
|
|
116
|
+
search_path = [current, *current.parents]
|
|
117
|
+
return next((p for p in search_path if _has_root_marker(p)), current)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def find_default_manifest(root: Path) -> Path:
|
|
121
|
+
"""Resolve default manifest path, checking rig.json then stack.json candidates."""
|
|
122
|
+
candidates = (root / rel for rel in _PROJECT_CANDIDATES)
|
|
123
|
+
return next((c for c in candidates if c.is_file()), root / _PROJECT_CANDIDATES[0])
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _read_project_from_manifest(manifest_path: Path) -> str | None:
|
|
127
|
+
if not manifest_path.is_file():
|
|
128
|
+
return None
|
|
129
|
+
try:
|
|
130
|
+
data = json.loads(manifest_path.read_text())
|
|
131
|
+
except (OSError, json.JSONDecodeError):
|
|
132
|
+
return None
|
|
133
|
+
if isinstance(data, dict) and data.get("project"):
|
|
134
|
+
return str(data["project"])
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _get_project_name(root: Path) -> str:
|
|
139
|
+
root = Path(root).resolve()
|
|
140
|
+
names = (_read_project_from_manifest(root / rel) for rel in _PROJECT_CANDIDATES)
|
|
141
|
+
return next((name for name in names if name), root.name)
|
rig/core/locks.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Lifecycle mutex and runtime directory management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import errno
|
|
7
|
+
import fcntl
|
|
8
|
+
import os
|
|
9
|
+
import stat
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from rig.core.constants import (
|
|
14
|
+
DIR_MODE_PRIVATE,
|
|
15
|
+
FILE_MODE_PRIVATE,
|
|
16
|
+
LOCK_FILE_NAME,
|
|
17
|
+
LOCK_TIMEOUT_SECS,
|
|
18
|
+
LOG_DIR_NAME,
|
|
19
|
+
RUNTIME_DIR_NAME,
|
|
20
|
+
)
|
|
21
|
+
from rig.core.errors import RigError
|
|
22
|
+
from rig.core.identity import _get_project_name, ensure_instance_dir, instance_id
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def ensure_runtime_dir(root: Path) -> Path:
|
|
26
|
+
"""Ensure ``.local-run`` exists with private permissions."""
|
|
27
|
+
runtime = Path(root) / RUNTIME_DIR_NAME
|
|
28
|
+
if runtime.is_symlink():
|
|
29
|
+
raise RigError(f"{runtime} is a symlink; refusing to use it as a runtime directory")
|
|
30
|
+
runtime.mkdir(mode=DIR_MODE_PRIVATE, exist_ok=True)
|
|
31
|
+
info = runtime.lstat()
|
|
32
|
+
if not stat.S_ISDIR(info.st_mode):
|
|
33
|
+
raise RigError(f"{runtime} is not a directory")
|
|
34
|
+
if info.st_uid != os.getuid():
|
|
35
|
+
raise RigError(f"{runtime} is owned by another user")
|
|
36
|
+
if stat.S_IMODE(info.st_mode) != DIR_MODE_PRIVATE:
|
|
37
|
+
os.chmod(runtime, DIR_MODE_PRIVATE)
|
|
38
|
+
(runtime / LOG_DIR_NAME).mkdir(mode=DIR_MODE_PRIVATE, exist_ok=True)
|
|
39
|
+
(Path(root) / "data").mkdir(parents=True, exist_ok=True)
|
|
40
|
+
return runtime
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _lock_path(target: Path | str, instance: str | None = None) -> Path:
|
|
44
|
+
if isinstance(target, str) and "/" not in target and "\\" not in target:
|
|
45
|
+
return ensure_instance_dir(target) / LOCK_FILE_NAME
|
|
46
|
+
if instance is not None:
|
|
47
|
+
return ensure_instance_dir(instance) / LOCK_FILE_NAME
|
|
48
|
+
root_path = Path(target).resolve()
|
|
49
|
+
proj = _get_project_name(root_path)
|
|
50
|
+
inst = instance_id(proj, root_path)
|
|
51
|
+
return ensure_instance_dir(inst) / LOCK_FILE_NAME
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _validate_lock_fd(fd: int, path_obj: Path) -> None:
|
|
55
|
+
info = os.fstat(fd)
|
|
56
|
+
if not stat.S_ISREG(info.st_mode):
|
|
57
|
+
raise RigError(f"{path_obj} is not a regular file")
|
|
58
|
+
if info.st_uid != os.getuid():
|
|
59
|
+
raise RigError(f"{path_obj} is owned by another user")
|
|
60
|
+
if info.st_nlink != 1:
|
|
61
|
+
raise RigError(f"{path_obj} has {info.st_nlink} links; refusing to lock it")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _acquire_blocking(fd: int, path_obj: Path, timeout: float) -> None:
|
|
65
|
+
deadline = time.monotonic() + timeout
|
|
66
|
+
while True:
|
|
67
|
+
try:
|
|
68
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
69
|
+
except (BlockingIOError, InterruptedError):
|
|
70
|
+
pass
|
|
71
|
+
else:
|
|
72
|
+
return
|
|
73
|
+
remaining = deadline - time.monotonic()
|
|
74
|
+
if remaining <= 0:
|
|
75
|
+
msg = f"another stack command holds {path_obj}; timed out after {timeout:g}s"
|
|
76
|
+
raise TimeoutError(msg)
|
|
77
|
+
time.sleep(min(0.05, remaining))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _acquire_lock(fd: int, path_obj: Path, timeout: float) -> None:
|
|
81
|
+
if timeout <= 0:
|
|
82
|
+
try:
|
|
83
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
84
|
+
except (BlockingIOError, InterruptedError):
|
|
85
|
+
raise BlockingIOError(f"{path_obj} is currently locked by another process") from None
|
|
86
|
+
else:
|
|
87
|
+
_acquire_blocking(fd, path_obj, timeout)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _open_lock_fd(path_obj: Path) -> int:
|
|
91
|
+
path_obj.parent.mkdir(parents=True, mode=DIR_MODE_PRIVATE, exist_ok=True)
|
|
92
|
+
try:
|
|
93
|
+
return os.open(
|
|
94
|
+
path_obj, os.O_CREAT | os.O_RDWR | os.O_CLOEXEC | os.O_NOFOLLOW, FILE_MODE_PRIVATE
|
|
95
|
+
)
|
|
96
|
+
except OSError as exc:
|
|
97
|
+
if exc.errno in (errno.ELOOP, errno.EMLINK):
|
|
98
|
+
raise RigError(f"{path_obj} is a symlink; refusing to lock it") from None
|
|
99
|
+
raise RigError(f"cannot open lock file {path_obj}: {exc}") from None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@contextlib.contextmanager
|
|
103
|
+
def exclusive_lock(path: Path, timeout: float = LOCK_TIMEOUT_SECS, blocking: bool = True):
|
|
104
|
+
"""Hold an exclusive advisory lock on ``path`` or raise ``TimeoutError``."""
|
|
105
|
+
path_obj = Path(path)
|
|
106
|
+
fd = _open_lock_fd(path_obj)
|
|
107
|
+
try:
|
|
108
|
+
_validate_lock_fd(fd, path_obj)
|
|
109
|
+
_acquire_lock(fd, path_obj, timeout if blocking else 0.0)
|
|
110
|
+
yield fd
|
|
111
|
+
finally:
|
|
112
|
+
os.close(fd)
|
rig/core/state.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""State file reading, atomic persistence, and redaction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import tempfile
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from rig.core.constants import (
|
|
15
|
+
REDACTED,
|
|
16
|
+
RUNTIME_DIR_NAME,
|
|
17
|
+
SECRET_NAME_PATTERN,
|
|
18
|
+
STATE_FILE_NAME,
|
|
19
|
+
)
|
|
20
|
+
from rig.core.identity import (
|
|
21
|
+
_get_project_name,
|
|
22
|
+
ensure_instance_dir,
|
|
23
|
+
get_instances_dir,
|
|
24
|
+
instance_id,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def empty_state() -> dict[str, Any]:
|
|
29
|
+
return {"generation": 0, "services": {}}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def read_state(path: Path) -> dict[str, Any]:
|
|
33
|
+
"""Return persisted state, or an empty stack when it is absent or unreadable."""
|
|
34
|
+
try:
|
|
35
|
+
state = json.loads(Path(path).read_text())
|
|
36
|
+
except (OSError, json.JSONDecodeError):
|
|
37
|
+
return empty_state()
|
|
38
|
+
if not isinstance(state, dict):
|
|
39
|
+
return empty_state()
|
|
40
|
+
state.setdefault("generation", 0)
|
|
41
|
+
services = state.get("services")
|
|
42
|
+
state["services"] = services if isinstance(services, dict) else {}
|
|
43
|
+
if "ports" in state and not isinstance(state["ports"], dict):
|
|
44
|
+
state["ports"] = {}
|
|
45
|
+
return state
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_state_file(path: Path) -> Path:
|
|
49
|
+
p = Path(path)
|
|
50
|
+
if p.is_symlink():
|
|
51
|
+
with contextlib.suppress(OSError):
|
|
52
|
+
return p.resolve()
|
|
53
|
+
return p
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _write_temp_state(target_path: Path, state: Mapping[str, Any]) -> str:
|
|
57
|
+
target_path.parent.mkdir(parents=True, mode=0o700, exist_ok=True)
|
|
58
|
+
with tempfile.NamedTemporaryFile(
|
|
59
|
+
mode="w",
|
|
60
|
+
dir=target_path.parent,
|
|
61
|
+
delete=False,
|
|
62
|
+
prefix=f".{target_path.name}.tmp.",
|
|
63
|
+
) as handle:
|
|
64
|
+
json.dump(state, handle, indent=2, sort_keys=True)
|
|
65
|
+
handle.write("\n")
|
|
66
|
+
handle.flush()
|
|
67
|
+
os.fsync(handle.fileno())
|
|
68
|
+
return handle.name
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def write_state(path: Path, state: Mapping[str, Any]) -> None:
|
|
72
|
+
"""Publish state atomically so no reader observes a partial generation."""
|
|
73
|
+
target_path = resolve_state_file(path)
|
|
74
|
+
tmp: str | None = None
|
|
75
|
+
try:
|
|
76
|
+
tmp = _write_temp_state(target_path, state)
|
|
77
|
+
os.chmod(tmp, 0o600)
|
|
78
|
+
os.replace(tmp, target_path)
|
|
79
|
+
except BaseException:
|
|
80
|
+
if tmp is not None:
|
|
81
|
+
with contextlib.suppress(OSError):
|
|
82
|
+
os.unlink(tmp)
|
|
83
|
+
raise
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def redact(value: Any) -> Any:
|
|
87
|
+
"""Return ``value`` with secret-looking mapping entries masked."""
|
|
88
|
+
if isinstance(value, Mapping):
|
|
89
|
+
return {
|
|
90
|
+
k: REDACTED if isinstance(k, str) and SECRET_NAME_PATTERN.search(k) else redact(v)
|
|
91
|
+
for k, v in value.items()
|
|
92
|
+
}
|
|
93
|
+
if isinstance(value, list):
|
|
94
|
+
return [redact(item) for item in value]
|
|
95
|
+
return value
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _relink_symlink(local_state: Path, target: Path) -> None:
|
|
99
|
+
with contextlib.suppress(OSError):
|
|
100
|
+
if local_state.resolve() != target.resolve():
|
|
101
|
+
local_state.unlink()
|
|
102
|
+
local_state.symlink_to(target)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _relink_file(local_state: Path, target: Path) -> None:
|
|
106
|
+
with contextlib.suppress(OSError):
|
|
107
|
+
if not target.exists():
|
|
108
|
+
shutil.copy2(local_state, target)
|
|
109
|
+
local_state.unlink()
|
|
110
|
+
local_state.symlink_to(target)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _sync_state_symlink(local_state: Path, authoritative_state: Path) -> None:
|
|
114
|
+
if local_state.is_symlink():
|
|
115
|
+
_relink_symlink(local_state, authoritative_state)
|
|
116
|
+
elif local_state.is_file():
|
|
117
|
+
_relink_file(local_state, authoritative_state)
|
|
118
|
+
elif not local_state.exists():
|
|
119
|
+
with contextlib.suppress(OSError):
|
|
120
|
+
local_state.symlink_to(authoritative_state)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _resolve_instance(target: str) -> Path | None:
|
|
124
|
+
instances_dir = get_instances_dir()
|
|
125
|
+
if not instances_dir.is_dir():
|
|
126
|
+
return None
|
|
127
|
+
direct = instances_dir / target
|
|
128
|
+
if direct.is_dir():
|
|
129
|
+
return direct
|
|
130
|
+
for item in instances_dir.iterdir():
|
|
131
|
+
sf = item / STATE_FILE_NAME
|
|
132
|
+
if item.is_dir() and sf.is_file() and read_state(sf).get("project") == target:
|
|
133
|
+
return item
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _state_path(root: Path | str, instance: str | None = None) -> Path:
|
|
138
|
+
if isinstance(root, str) and "/" not in root and "\\" not in root and instance is None:
|
|
139
|
+
inst_dir = _resolve_instance(root) or ensure_instance_dir(root)
|
|
140
|
+
return inst_dir / STATE_FILE_NAME
|
|
141
|
+
root_path = Path(root).resolve()
|
|
142
|
+
if instance is None:
|
|
143
|
+
instance = instance_id(_get_project_name(root_path), root_path)
|
|
144
|
+
inst_dir = ensure_instance_dir(instance)
|
|
145
|
+
authoritative_state = inst_dir / STATE_FILE_NAME
|
|
146
|
+
|
|
147
|
+
runtime = root_path / RUNTIME_DIR_NAME
|
|
148
|
+
if runtime.is_dir():
|
|
149
|
+
_sync_state_symlink(runtime / STATE_FILE_NAME, authoritative_state)
|
|
150
|
+
return authoritative_state
|
rig/core/terminal.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Terminal formatting, ANSI color support, and monospace table alignment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
import unicodedata
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
ANSI_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def supports_color() -> bool:
|
|
16
|
+
"""Return True if stdout supports ANSI escape sequences."""
|
|
17
|
+
if not sys.stdout.isatty():
|
|
18
|
+
return False
|
|
19
|
+
if os.environ.get("NO_COLOR"):
|
|
20
|
+
return False
|
|
21
|
+
return os.environ.get("TERM") != "dumb"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Theme:
|
|
26
|
+
"""ANSI styling token container with automatic color suppression."""
|
|
27
|
+
|
|
28
|
+
r: str = ""
|
|
29
|
+
b: str = ""
|
|
30
|
+
d: str = ""
|
|
31
|
+
green: str = ""
|
|
32
|
+
red: str = ""
|
|
33
|
+
yellow: str = ""
|
|
34
|
+
cyan: str = ""
|
|
35
|
+
magenta: str = ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_theme(color: bool | None = None) -> Theme:
|
|
39
|
+
"""Return active Theme instance with ANSI codes or empty strings."""
|
|
40
|
+
enabled = supports_color() if color is None else color
|
|
41
|
+
if not enabled:
|
|
42
|
+
return Theme()
|
|
43
|
+
return Theme(
|
|
44
|
+
r="\033[0m",
|
|
45
|
+
b="\033[1m",
|
|
46
|
+
d="\033[2m",
|
|
47
|
+
green="\033[32m",
|
|
48
|
+
red="\033[31m",
|
|
49
|
+
yellow="\033[33m",
|
|
50
|
+
cyan="\033[36m",
|
|
51
|
+
magenta="\033[35m",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def strip_ansi(text: str) -> str:
|
|
56
|
+
"""Remove ANSI escape sequences from text."""
|
|
57
|
+
return ANSI_RE.sub("", text)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def visible_width(text: str) -> int:
|
|
61
|
+
"""Return visual monospace column width of text, ignoring ANSI escape codes."""
|
|
62
|
+
clean = strip_ansi(text)
|
|
63
|
+
return sum(
|
|
64
|
+
2 if unicodedata.east_asian_width(c) in ("W", "F") else 0 if unicodedata.combining(c) else 1
|
|
65
|
+
for c in clean
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def pad_cell(text: str, width: int, align: str = "left") -> str:
|
|
70
|
+
"""Pad text to visual width, preserving embedded ANSI escape sequences."""
|
|
71
|
+
pad = " " * max(0, width - visible_width(text))
|
|
72
|
+
return pad + text if align == "right" else text + pad
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def contract_path(path_str: str) -> str:
|
|
76
|
+
"""Contract home directory path to ~ prefix."""
|
|
77
|
+
if not path_str or path_str == "n/a":
|
|
78
|
+
return path_str
|
|
79
|
+
home = os.path.expanduser("~")
|
|
80
|
+
return f"~{path_str[len(home) :]}" if path_str.startswith(home) else path_str
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
MIN_ELLIPSIS_LEN = 4
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def middle_truncate(text: str, max_len: int) -> str:
|
|
87
|
+
"""Truncate middle of text with ellipsis if exceeding max_len."""
|
|
88
|
+
if len(text) <= max_len:
|
|
89
|
+
return text
|
|
90
|
+
if max_len < MIN_ELLIPSIS_LEN:
|
|
91
|
+
return text[:max_len]
|
|
92
|
+
left = (max_len - 1) // 2
|
|
93
|
+
return f"{text[:left]}…{text[-(max_len - 1 - left) :]}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def format_table(
|
|
97
|
+
headers: Sequence[str],
|
|
98
|
+
rows: Sequence[Sequence[str]],
|
|
99
|
+
gutter: int = 4,
|
|
100
|
+
) -> list[str]:
|
|
101
|
+
"""Format tabular data with dynamically calculated column widths and alignment."""
|
|
102
|
+
th = get_theme()
|
|
103
|
+
cols = len(headers)
|
|
104
|
+
all_rows = [headers, *rows]
|
|
105
|
+
widths = [max(visible_width(r[i]) for r in all_rows) for i in range(cols)]
|
|
106
|
+
space = " " * gutter
|
|
107
|
+
hdr_line = space.join(pad_cell(f"{th.d}{h}{th.r}", widths[i]) for i, h in enumerate(headers))
|
|
108
|
+
div_line = f"{th.d}{'─' * (sum(widths) + gutter * (cols - 1))}{th.r}"
|
|
109
|
+
row_lines = [space.join(pad_cell(r[i], widths[i]) for i in range(cols)) for r in rows]
|
|
110
|
+
return [hdr_line, div_line, *row_lines]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def highlight_log_line(line: str, th: Theme) -> str:
|
|
114
|
+
"""Highlight log levels and dim timestamps in log lines without altering content."""
|
|
115
|
+
if not th.r:
|
|
116
|
+
return line
|
|
117
|
+
line = re.sub(
|
|
118
|
+
r"(\[(?:error|fatal|fail)\]|\b(?:ERROR|FATAL|CRITICAL)\b)",
|
|
119
|
+
f"{th.red}\\1{th.r}",
|
|
120
|
+
line,
|
|
121
|
+
flags=re.I,
|
|
122
|
+
)
|
|
123
|
+
line = re.sub(
|
|
124
|
+
r"(\[(?:warn|warning)\]|\b(?:WARN|WARNING)\b)",
|
|
125
|
+
f"{th.yellow}\\1{th.r}",
|
|
126
|
+
line,
|
|
127
|
+
flags=re.I,
|
|
128
|
+
)
|
|
129
|
+
line = re.sub(
|
|
130
|
+
r"(\[(?:info|notice)\]|\b(?:INFO|NOTICE)\b)",
|
|
131
|
+
f"{th.green}\\1{th.r}",
|
|
132
|
+
line,
|
|
133
|
+
flags=re.I,
|
|
134
|
+
)
|
|
135
|
+
line = re.sub(
|
|
136
|
+
r"(\[(?:debug|trace)\]|\b(?:DEBUG|TRACE)\b)",
|
|
137
|
+
f"{th.d}\\1{th.r}",
|
|
138
|
+
line,
|
|
139
|
+
flags=re.I,
|
|
140
|
+
)
|
|
141
|
+
return re.sub(
|
|
142
|
+
r"^(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?)",
|
|
143
|
+
f"{th.d}\\1{th.r}",
|
|
144
|
+
line,
|
|
145
|
+
)
|
rig/manifest/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Manifest loading, models, and detection heuristics for rig."""
|
rig/manifest/detector.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Compose service image detection and datastore classification heuristics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
POSTGRES_IMAGES = ("postgres", "postgresql", "postgis", "timescaledb")
|
|
11
|
+
POSTGRES_NAMES = ("db", "database", "postgres", "postgresql")
|
|
12
|
+
REDIS_IMAGES = ("redis", "valkey")
|
|
13
|
+
REDIS_NAMES = ("redis", "valkey", "cache")
|
|
14
|
+
|
|
15
|
+
_IMAGE_KINDS = {
|
|
16
|
+
**dict.fromkeys(POSTGRES_IMAGES, "postgres"),
|
|
17
|
+
**dict.fromkeys(REDIS_IMAGES, "redis"),
|
|
18
|
+
}
|
|
19
|
+
_NAME_KINDS = {
|
|
20
|
+
**dict.fromkeys(POSTGRES_NAMES, "postgres"),
|
|
21
|
+
**dict.fromkeys(REDIS_NAMES, "redis"),
|
|
22
|
+
}
|
|
23
|
+
_PACKAGE_MANAGERS = (
|
|
24
|
+
("pnpm-lock.yaml", "pnpm"),
|
|
25
|
+
("yarn.lock", "yarn"),
|
|
26
|
+
("bun.lockb", "bun"),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _is_top_level_section(line: str) -> bool:
|
|
31
|
+
return bool(re.match(r"^[a-zA-Z0-9_-]+\s*:\s*$", line) and not line.startswith(" "))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parse_service_header(line: str) -> str | None:
|
|
35
|
+
m = re.match(r"^ {2}([a-zA-Z0-9_-]+)\s*:\s*$", line)
|
|
36
|
+
return m.group(1) if m else None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _append_service_line(
|
|
40
|
+
services: dict[str, list[str]], current_svc: str | None, line: str
|
|
41
|
+
) -> str | None:
|
|
42
|
+
svc_name = _parse_service_header(line)
|
|
43
|
+
if svc_name:
|
|
44
|
+
services[svc_name] = []
|
|
45
|
+
return svc_name
|
|
46
|
+
if current_svc and line.startswith((" ", "\t")):
|
|
47
|
+
services[current_svc].append(line)
|
|
48
|
+
return current_svc
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _extract_compose_services(content: str) -> dict[str, str]:
|
|
52
|
+
"""Return each top-level Compose service name mapped to its own block."""
|
|
53
|
+
services: dict[str, list[str]] = {}
|
|
54
|
+
in_services = False
|
|
55
|
+
current_svc = None
|
|
56
|
+
|
|
57
|
+
for line in content.splitlines():
|
|
58
|
+
trimmed = line.strip()
|
|
59
|
+
if not trimmed or trimmed.startswith("#"):
|
|
60
|
+
continue
|
|
61
|
+
if re.match(r"^services\s*:\s*$", line):
|
|
62
|
+
in_services, current_svc = True, None
|
|
63
|
+
continue
|
|
64
|
+
if in_services and _is_top_level_section(line):
|
|
65
|
+
in_services, current_svc = False, None
|
|
66
|
+
continue
|
|
67
|
+
if in_services:
|
|
68
|
+
current_svc = _append_service_line(services, current_svc, line)
|
|
69
|
+
|
|
70
|
+
return {k: "\n".join(v).lower() for k, v in services.items()}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _compose_service_image(block: str) -> str | None:
|
|
74
|
+
"""Return the image name declared in one Compose service block."""
|
|
75
|
+
match = re.search(r"^\s{2,}image\s*:\s*[\"']?([^\"'\s#]+)", block, re.MULTILINE)
|
|
76
|
+
if not match:
|
|
77
|
+
return None
|
|
78
|
+
reference = match.group(1)
|
|
79
|
+
return reference.rsplit("/", 1)[-1].split(":", 1)[0]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def classify_compose_service(name: str, block: str) -> str | None:
|
|
83
|
+
"""Return 'postgres', 'redis' or None for one Compose service."""
|
|
84
|
+
image = _compose_service_image(block)
|
|
85
|
+
return _IMAGE_KINDS.get(image) if image is not None else _NAME_KINDS.get(name.lower())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _find_fastapi_app(root: Path) -> str:
|
|
89
|
+
if (root / "app" / "main.py").is_file():
|
|
90
|
+
return "app.main:app"
|
|
91
|
+
return "src.main:app" if (root / "src" / "main.py").is_file() else "main:app"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def detect_backend(
|
|
95
|
+
root: Path, native_services: dict[str, Any], base_services: dict[str, Any]
|
|
96
|
+
) -> bool:
|
|
97
|
+
if (root / "manage.py").is_file():
|
|
98
|
+
native_services["backend"] = {
|
|
99
|
+
"type": "port",
|
|
100
|
+
"cwd": ".",
|
|
101
|
+
"command": ["python", "manage.py", "runserver", "127.0.0.1:{port}"],
|
|
102
|
+
"healthcheck_path": "/",
|
|
103
|
+
"description": "Django web application",
|
|
104
|
+
}
|
|
105
|
+
return True
|
|
106
|
+
if (root / "pyproject.toml").is_file() or (root / "requirements.txt").is_file():
|
|
107
|
+
spec: dict[str, Any] = {
|
|
108
|
+
"type": "fd",
|
|
109
|
+
"cwd": ".",
|
|
110
|
+
"python": sys.executable,
|
|
111
|
+
"app": _find_fastapi_app(root),
|
|
112
|
+
"healthcheck_path": "/healthz",
|
|
113
|
+
"description": "FastAPI / ASGI backend application",
|
|
114
|
+
}
|
|
115
|
+
if "postgres" in base_services:
|
|
116
|
+
spec["depends_on"] = ["postgres"]
|
|
117
|
+
native_services["backend"] = spec
|
|
118
|
+
return True
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def detect_frontend(root: Path, native_services: dict[str, Any], has_backend: bool) -> None:
|
|
123
|
+
if not (root / "package.json").is_file():
|
|
124
|
+
return
|
|
125
|
+
pm = next(
|
|
126
|
+
(manager for lock, manager in _PACKAGE_MANAGERS if (root / lock).is_file()),
|
|
127
|
+
"npm",
|
|
128
|
+
)
|
|
129
|
+
spec: dict[str, Any] = {
|
|
130
|
+
"type": "port",
|
|
131
|
+
"cwd": ".",
|
|
132
|
+
"command": [pm, "run", "dev", "--", "--port", "{port}"],
|
|
133
|
+
"healthcheck_path": "/",
|
|
134
|
+
"description": "Frontend development server",
|
|
135
|
+
}
|
|
136
|
+
if has_backend:
|
|
137
|
+
spec["depends_on"] = ["backend"]
|
|
138
|
+
native_services["frontend"] = spec
|