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/compose/docker.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Plain Docker container queries and lifecycle supervision."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from rig.compose.client import run_docker
|
|
10
|
+
from rig.compose.context import INHERIT_DOCKER_HOST, record_compose_env, record_docker_endpoint
|
|
11
|
+
from rig.core.errors import RigError
|
|
12
|
+
|
|
13
|
+
DOCKER_ABSENT_MARKERS = ("no such object", "no such container")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def docker_label_container_ids(record: Mapping[str, Any]) -> list[str] | None:
|
|
17
|
+
"""Return the container IDs Compose labelled with this record's project and service."""
|
|
18
|
+
context, docker_host = record_docker_endpoint(record)
|
|
19
|
+
filters = [
|
|
20
|
+
"--filter",
|
|
21
|
+
f"label=com.docker.compose.project={record.get('instance')}",
|
|
22
|
+
"--filter",
|
|
23
|
+
f"label=com.docker.compose.service={record.get('compose_service')}",
|
|
24
|
+
]
|
|
25
|
+
try:
|
|
26
|
+
res = run_docker(
|
|
27
|
+
["ps", "-q", "-a", *filters],
|
|
28
|
+
context,
|
|
29
|
+
docker_host=docker_host,
|
|
30
|
+
env=record_compose_env(record),
|
|
31
|
+
)
|
|
32
|
+
except RigError:
|
|
33
|
+
return None
|
|
34
|
+
if res.returncode != 0:
|
|
35
|
+
return None
|
|
36
|
+
return [line.strip() for line in res.stdout.splitlines() if line.strip()]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def docker_reports_no_such_object(probe: subprocess.CompletedProcess) -> bool:
|
|
40
|
+
answer = f"{probe.stderr or ''}\n{probe.stdout or ''}".lower()
|
|
41
|
+
return any(m in answer for m in DOCKER_ABSENT_MARKERS)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def docker_container_status(
|
|
45
|
+
container: str,
|
|
46
|
+
context: Any = None,
|
|
47
|
+
*args: Any,
|
|
48
|
+
**kwargs: Any,
|
|
49
|
+
) -> str:
|
|
50
|
+
p = list(args)
|
|
51
|
+
docker_host = p.pop(0) if p else kwargs.get("docker_host", INHERIT_DOCKER_HOST)
|
|
52
|
+
env = p.pop(0) if p else kwargs.get("env")
|
|
53
|
+
try:
|
|
54
|
+
cmd = ["inspect", "--format", "{{.State.Status}}", str(container)]
|
|
55
|
+
probe = run_docker(cmd, context, docker_host=docker_host, env=env)
|
|
56
|
+
except RigError:
|
|
57
|
+
return "error"
|
|
58
|
+
if probe.returncode != 0:
|
|
59
|
+
return "absent" if docker_reports_no_such_object(probe) else "error"
|
|
60
|
+
return "alive" if probe.stdout.strip().lower() in ("running", "restarting") else "stopped"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def docker_record_targets(record: Mapping[str, Any]) -> tuple[list[str], bool]:
|
|
64
|
+
ids = docker_label_container_ids(record)
|
|
65
|
+
targets = list(ids or [])
|
|
66
|
+
recorded = str(record.get("container") or "")
|
|
67
|
+
if recorded and not any(recorded.startswith(f) or f.startswith(recorded) for f in targets):
|
|
68
|
+
targets.insert(0, recorded)
|
|
69
|
+
return targets, ids is not None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def docker_record_status(record: Mapping[str, Any]) -> str:
|
|
73
|
+
targets, answered = docker_record_targets(record)
|
|
74
|
+
if not targets:
|
|
75
|
+
return "absent" if answered else "error"
|
|
76
|
+
ctx, host = record_docker_endpoint(record)
|
|
77
|
+
cenv = record_compose_env(record)
|
|
78
|
+
states = [docker_container_status(t, ctx, host, cenv) for t in targets]
|
|
79
|
+
return next(
|
|
80
|
+
(s for s in ("alive", "stopped", "error") if s in states),
|
|
81
|
+
"absent" if answered else "error",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _stop_target(
|
|
86
|
+
target: str,
|
|
87
|
+
endpoint: tuple[Any, Any, Any],
|
|
88
|
+
remove: bool,
|
|
89
|
+
) -> bool:
|
|
90
|
+
ctx, host, cenv = endpoint
|
|
91
|
+
cmds = [["stop", target]]
|
|
92
|
+
if remove:
|
|
93
|
+
cmds.append(["rm", "-f", target])
|
|
94
|
+
for args in cmds:
|
|
95
|
+
try:
|
|
96
|
+
res = run_docker(args, ctx, docker_host=host, env=cenv)
|
|
97
|
+
except RigError:
|
|
98
|
+
return False
|
|
99
|
+
if res.returncode == 0:
|
|
100
|
+
continue
|
|
101
|
+
if docker_reports_no_such_object(res):
|
|
102
|
+
break
|
|
103
|
+
return False
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def docker_record_stop(record: Mapping[str, Any], remove: bool) -> str:
|
|
108
|
+
targets, answered = docker_record_targets(record)
|
|
109
|
+
if not targets:
|
|
110
|
+
return "stale" if answered else "failed"
|
|
111
|
+
ctx, host = record_docker_endpoint(record)
|
|
112
|
+
cenv = record_compose_env(record)
|
|
113
|
+
endpoint = (ctx, host, cenv)
|
|
114
|
+
for t in targets:
|
|
115
|
+
if not _stop_target(t, endpoint, remove):
|
|
116
|
+
return "failed"
|
|
117
|
+
return "failed" if not answered else "terminated"
|
rig/compose/starter.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Compose service startup and container tracking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from rig.compose.client import run_compose
|
|
11
|
+
from rig.compose.context import resolve_current_docker_context
|
|
12
|
+
from rig.compose.discovery import (
|
|
13
|
+
INTERRUPTION_EXCEPTIONS,
|
|
14
|
+
discover_container_and_port,
|
|
15
|
+
handle_compose_interruption,
|
|
16
|
+
init_compose_record,
|
|
17
|
+
stranded_error,
|
|
18
|
+
)
|
|
19
|
+
from rig.core.constants import DOCKER_CLIENT_ENV_PASSTHROUGH, EXIT_OP_FAILED
|
|
20
|
+
from rig.core.errors import RigError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _inherit_docker_client_env(cmd_env: dict[str, str]) -> None:
|
|
24
|
+
cmd_env.update(
|
|
25
|
+
(name, os.environ[name])
|
|
26
|
+
for name in DOCKER_CLIENT_ENV_PASSTHROUGH
|
|
27
|
+
if name not in cmd_env and name in os.environ
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _resolve_compose_endpoints(
|
|
32
|
+
service: Any, env: Mapping[str, str] | None
|
|
33
|
+
) -> tuple[dict[str, str] | None, str | None, str | None]:
|
|
34
|
+
docker_host = os.environ.get("DOCKER_HOST")
|
|
35
|
+
cmd_env = dict(env) if env is not None else None
|
|
36
|
+
if cmd_env is not None:
|
|
37
|
+
_inherit_docker_client_env(cmd_env)
|
|
38
|
+
docker_context = service.docker_context or os.environ.get("DOCKER_CONTEXT") or None
|
|
39
|
+
if docker_context is None and not docker_host:
|
|
40
|
+
docker_context = resolve_current_docker_context(cmd_env)
|
|
41
|
+
if docker_context:
|
|
42
|
+
docker_host = None
|
|
43
|
+
return cmd_env, docker_context, docker_host
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _run_cleanup_step(
|
|
47
|
+
target: tuple[str, Path, Path, str],
|
|
48
|
+
endpoint: tuple[str | None, dict[str, str] | None, str | None],
|
|
49
|
+
action: list[str],
|
|
50
|
+
) -> bool | None:
|
|
51
|
+
instance, root, cfile, _ = target
|
|
52
|
+
ctx, env, host = endpoint
|
|
53
|
+
try:
|
|
54
|
+
res = run_compose(
|
|
55
|
+
instance, root, cfile, action, ctx, timeout=30.0, env=env, docker_host=host
|
|
56
|
+
)
|
|
57
|
+
except INTERRUPTION_EXCEPTIONS:
|
|
58
|
+
return None
|
|
59
|
+
else:
|
|
60
|
+
return res.returncode == 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _cleanup_compose(
|
|
64
|
+
target: tuple[str, Path, Path, str],
|
|
65
|
+
endpoint: tuple[str | None, dict[str, str] | None, str | None],
|
|
66
|
+
) -> bool:
|
|
67
|
+
removed = True
|
|
68
|
+
for args in (["stop", target[3]], ["rm", "-f", target[3]]):
|
|
69
|
+
ok = _run_cleanup_step(target, endpoint, args)
|
|
70
|
+
if ok is None:
|
|
71
|
+
return False
|
|
72
|
+
if not ok:
|
|
73
|
+
removed = False
|
|
74
|
+
return removed
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _run_compose_up(
|
|
78
|
+
info: tuple[Any, dict[str, Any]],
|
|
79
|
+
compose_fn: Any,
|
|
80
|
+
handlers: tuple[Any, Any],
|
|
81
|
+
) -> None:
|
|
82
|
+
service, record = info
|
|
83
|
+
fail_fn, cleanup_fn = handlers
|
|
84
|
+
try:
|
|
85
|
+
res = compose_fn(["up", "-d", "--no-deps", "--wait", str(service.compose_service)])
|
|
86
|
+
except (RigError, OSError) as exc:
|
|
87
|
+
if not cleanup_fn():
|
|
88
|
+
msg = f"compose could not start {service.name!r}: {exc}"
|
|
89
|
+
raise stranded_error(msg, record) from exc
|
|
90
|
+
raise
|
|
91
|
+
except INTERRUPTION_EXCEPTIONS as exc:
|
|
92
|
+
handle_compose_interruption(exc, info, cleanup_fn, "the start of")
|
|
93
|
+
if res.returncode != 0:
|
|
94
|
+
err = res.stderr.strip() or res.stdout.strip()
|
|
95
|
+
raise fail_fn(f"compose could not start {service.name!r}: {err}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _run_discovery_phase(
|
|
99
|
+
service: Any,
|
|
100
|
+
record: dict[str, Any],
|
|
101
|
+
helpers: tuple[Any, Any, Any],
|
|
102
|
+
) -> None:
|
|
103
|
+
compose_fn, fail_fn, cleanup_fn = helpers
|
|
104
|
+
try:
|
|
105
|
+
discover_container_and_port(service, record, (compose_fn, fail_fn))
|
|
106
|
+
except RigError:
|
|
107
|
+
raise
|
|
108
|
+
except INTERRUPTION_EXCEPTIONS as exc:
|
|
109
|
+
handle_compose_interruption(exc, (service, record), cleanup_fn, "discovery for")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _start_compose_service(
|
|
113
|
+
service: Any, root: Path, instance: str, *args: Any, **kwargs: Any
|
|
114
|
+
) -> dict[str, Any]:
|
|
115
|
+
env = args[0] if args else kwargs.get("env")
|
|
116
|
+
cfile = Path(root) / str(service.compose_file)
|
|
117
|
+
cmd_env, ctx, host = _resolve_compose_endpoints(service, env)
|
|
118
|
+
record = init_compose_record(service, instance, (cfile, ctx, host, cmd_env))
|
|
119
|
+
|
|
120
|
+
target = (instance, Path(root), cfile, str(service.compose_service))
|
|
121
|
+
endpoint = (ctx, cmd_env, host)
|
|
122
|
+
|
|
123
|
+
def _compose(compose_args: list[str], timeout: float = 180.0):
|
|
124
|
+
return run_compose(
|
|
125
|
+
instance,
|
|
126
|
+
Path(root),
|
|
127
|
+
cfile,
|
|
128
|
+
compose_args,
|
|
129
|
+
ctx,
|
|
130
|
+
timeout=timeout,
|
|
131
|
+
env=cmd_env,
|
|
132
|
+
docker_host=host,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def _cleanup() -> bool:
|
|
136
|
+
return _cleanup_compose(target, endpoint)
|
|
137
|
+
|
|
138
|
+
def _fail(msg: str) -> RigError:
|
|
139
|
+
if _cleanup():
|
|
140
|
+
return RigError(msg, code="E_COMPOSE_FAILED", exit_code=EXIT_OP_FAILED)
|
|
141
|
+
return stranded_error(msg, record)
|
|
142
|
+
|
|
143
|
+
_run_compose_up((service, record), _compose, (_fail, _cleanup))
|
|
144
|
+
_run_discovery_phase(service, record, (_compose, _fail, _cleanup))
|
|
145
|
+
return record
|
rig/compose/stopper.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Compose service stop and teardown."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from rig.compose import client
|
|
10
|
+
from rig.compose.context import record_compose_env, record_docker_endpoint
|
|
11
|
+
from rig.compose.docker import docker_record_stop
|
|
12
|
+
from rig.compose.supervisor import compose_record_status
|
|
13
|
+
from rig.core.errors import RigError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _run_compose_teardown(
|
|
17
|
+
target: tuple[str, Path, Path],
|
|
18
|
+
args: list[str],
|
|
19
|
+
endpoint: tuple[str | None, Any, Any],
|
|
20
|
+
) -> bool:
|
|
21
|
+
instance, root, compose_file = target
|
|
22
|
+
ctx_name, docker_host, compose_env = endpoint
|
|
23
|
+
try:
|
|
24
|
+
res = client.run_compose(
|
|
25
|
+
instance,
|
|
26
|
+
root,
|
|
27
|
+
compose_file,
|
|
28
|
+
args,
|
|
29
|
+
ctx_name,
|
|
30
|
+
env=compose_env,
|
|
31
|
+
docker_host=docker_host,
|
|
32
|
+
)
|
|
33
|
+
except RigError:
|
|
34
|
+
return False
|
|
35
|
+
else:
|
|
36
|
+
return res.returncode == 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _teardown_services(
|
|
40
|
+
target: tuple[str, Path, Path, str],
|
|
41
|
+
endpoint: tuple[str | None, Any, Any],
|
|
42
|
+
remove: bool,
|
|
43
|
+
) -> bool:
|
|
44
|
+
instance, root, compose_file, compose_service = target
|
|
45
|
+
cmds = [["stop", compose_service]]
|
|
46
|
+
if remove:
|
|
47
|
+
cmds.append(["rm", "-f", compose_service])
|
|
48
|
+
tup = (instance, root, compose_file)
|
|
49
|
+
return all(_run_compose_teardown(tup, cmd, endpoint) for cmd in cmds)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def compose_stop_record(record: Mapping[str, Any], root: Path, remove: bool = True) -> str:
|
|
53
|
+
status = compose_record_status(record, root)
|
|
54
|
+
if status in ("absent", "error"):
|
|
55
|
+
return "stale" if status == "absent" else "failed"
|
|
56
|
+
if not client.compose_file_present(record, root):
|
|
57
|
+
return docker_record_stop(record, remove)
|
|
58
|
+
|
|
59
|
+
target = (
|
|
60
|
+
str(record["instance"]),
|
|
61
|
+
Path(root),
|
|
62
|
+
Path(str(record["compose_file"])),
|
|
63
|
+
str(record["compose_service"]),
|
|
64
|
+
)
|
|
65
|
+
docker_context, docker_host = record_docker_endpoint(record)
|
|
66
|
+
endpoint = (docker_context, docker_host, record_compose_env(record))
|
|
67
|
+
should_remove = remove or not record.get("container")
|
|
68
|
+
|
|
69
|
+
if _teardown_services(target, endpoint, should_remove):
|
|
70
|
+
return "terminated"
|
|
71
|
+
return docker_record_stop(record, remove)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Docker container lifecycle supervision, status queries, and reclamation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from rig.compose.client import compose_file_present, run_compose
|
|
10
|
+
from rig.compose.context import record_compose_env, record_docker_endpoint
|
|
11
|
+
from rig.compose.docker import (
|
|
12
|
+
docker_container_status,
|
|
13
|
+
docker_record_status,
|
|
14
|
+
)
|
|
15
|
+
from rig.core.errors import RigError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _check_compose_ps(record: Mapping[str, Any], root: Path) -> list[str] | None:
|
|
19
|
+
context, docker_host = record_docker_endpoint(record)
|
|
20
|
+
compose_env = record_compose_env(record)
|
|
21
|
+
try:
|
|
22
|
+
res = run_compose(
|
|
23
|
+
str(record["instance"]),
|
|
24
|
+
Path(root),
|
|
25
|
+
Path(record["compose_file"]),
|
|
26
|
+
["ps", "-q", "-a", str(record["compose_service"])],
|
|
27
|
+
context,
|
|
28
|
+
timeout=60.0,
|
|
29
|
+
env=compose_env,
|
|
30
|
+
docker_host=docker_host,
|
|
31
|
+
)
|
|
32
|
+
if res.returncode == 0:
|
|
33
|
+
return [line.strip() for line in res.stdout.splitlines() if line.strip()]
|
|
34
|
+
except RigError:
|
|
35
|
+
pass
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _has_compose_metadata(record: Mapping[str, Any]) -> bool:
|
|
40
|
+
return bool(
|
|
41
|
+
record.get("instance") and record.get("compose_file") and record.get("compose_service")
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _evaluate_container_states(
|
|
46
|
+
ids: list[str], recorded: str, endpoint: tuple[Any, Any, Any]
|
|
47
|
+
) -> str:
|
|
48
|
+
ctx, host, cenv = endpoint
|
|
49
|
+
states = [docker_container_status(found, ctx, host, cenv) for found in ids]
|
|
50
|
+
if recorded and not any(recorded.startswith(f) or f.startswith(recorded) for f in ids):
|
|
51
|
+
states.append(docker_container_status(recorded, ctx, host, cenv))
|
|
52
|
+
return next((state for state in ("alive", "stopped") if state in states), "error")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def compose_record_status(record: Mapping[str, Any], root: Path) -> str:
|
|
56
|
+
"""Return 'alive', 'stopped', 'absent', or 'error' for one compose record."""
|
|
57
|
+
if not _has_compose_metadata(record):
|
|
58
|
+
return "absent"
|
|
59
|
+
ids = _check_compose_ps(record, root) if compose_file_present(record, root) else None
|
|
60
|
+
if ids is None:
|
|
61
|
+
return docker_record_status(record)
|
|
62
|
+
|
|
63
|
+
context, docker_host = record_docker_endpoint(record)
|
|
64
|
+
compose_env = record_compose_env(record)
|
|
65
|
+
recorded = str(record.get("container") or "")
|
|
66
|
+
if not ids:
|
|
67
|
+
return (
|
|
68
|
+
docker_container_status(recorded, context, docker_host, compose_env)
|
|
69
|
+
if recorded
|
|
70
|
+
else "absent"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return _evaluate_container_states(ids, recorded, (context, docker_host, compose_env))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def compose_record_alive(record: Mapping[str, Any], root: Path) -> bool:
|
|
77
|
+
"""Return ``True`` when the recorded container is still running under this instance."""
|
|
78
|
+
return compose_record_status(record, root) == "alive"
|
rig/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core primitives, constants, locks, and state for rig."""
|
rig/core/constants.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Constants and exit codes for rig."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
RUNTIME_DIR_NAME = ".local-run"
|
|
8
|
+
__version__ = "1.0.0"
|
|
9
|
+
LOCK_FILE_NAME = "checkout.lock"
|
|
10
|
+
STATE_FILE_NAME = "state.json"
|
|
11
|
+
LOG_DIR_NAME = "logs"
|
|
12
|
+
DIR_MODE_PRIVATE = 0o700
|
|
13
|
+
FILE_MODE_PRIVATE = 0o600
|
|
14
|
+
|
|
15
|
+
LOCK_TIMEOUT_SECS = 10.0
|
|
16
|
+
HEALTH_TIMEOUT_SECS = 45.0
|
|
17
|
+
TEARDOWN_TIMEOUT_SECS = 5.0
|
|
18
|
+
PORT_RELEASE_TIMEOUT_SECS = 5.0
|
|
19
|
+
PORT_RETRY_ATTEMPTS = 3
|
|
20
|
+
PORT_MIN = 1
|
|
21
|
+
PORT_MAX = 65535
|
|
22
|
+
COMPOSE_UP_TIMEOUT_SECS = 180.0
|
|
23
|
+
COMPOSE_DISCOVERY_TIMEOUT_SECS = 60.0
|
|
24
|
+
COMPOSE_TEARDOWN_TIMEOUT_SECS = 30.0
|
|
25
|
+
DEFAULT_STARTUP_WAIT_SECS = 0.3
|
|
26
|
+
|
|
27
|
+
SERVICE_TYPES = ("fd", "port", "compose")
|
|
28
|
+
SCOPES = ("full", "local", "backend", "ui")
|
|
29
|
+
|
|
30
|
+
BASE_ENV_ALLOWLIST = (
|
|
31
|
+
"PATH",
|
|
32
|
+
"HOME",
|
|
33
|
+
"USER",
|
|
34
|
+
"LOGNAME",
|
|
35
|
+
"SHELL",
|
|
36
|
+
"TERM",
|
|
37
|
+
"TMPDIR",
|
|
38
|
+
"TZ",
|
|
39
|
+
"LANG",
|
|
40
|
+
"LC_ALL",
|
|
41
|
+
"LC_CTYPE",
|
|
42
|
+
"SSL_CERT_FILE",
|
|
43
|
+
"SSL_CERT_DIR",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
DOCKER_CLIENT_ENV_PASSTHROUGH = (
|
|
47
|
+
"DOCKER_CONFIG",
|
|
48
|
+
"DOCKER_CERT_PATH",
|
|
49
|
+
"DOCKER_TLS_VERIFY",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
SECRET_NAME_PATTERN = re.compile(
|
|
53
|
+
r"TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|_KEY$|^KEY$|APIKEY|PRIVATE",
|
|
54
|
+
re.IGNORECASE,
|
|
55
|
+
)
|
|
56
|
+
REDACTED = "***"
|
|
57
|
+
|
|
58
|
+
EXIT_OK = 0
|
|
59
|
+
EXIT_OP_FAILED = 1
|
|
60
|
+
EXIT_USAGE = 2
|
|
61
|
+
EXIT_MUTEX_CONFLICT = 3
|
|
62
|
+
EXIT_NOT_FOUND = 4
|
|
63
|
+
EXIT_REFUSED = 5
|
|
64
|
+
EXIT_EXTERNAL_TOOL = 6
|
|
65
|
+
EXIT_INTERRUPTED = 130
|
rig/core/env.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Environment variable allowlisting and placeholder formatting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import string
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from rig.core.constants import BASE_ENV_ALLOWLIST
|
|
12
|
+
from rig.core.errors import RigError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _StrictValues(dict):
|
|
16
|
+
def __missing__(self, key):
|
|
17
|
+
raise RigError(f"unknown placeholder {{{key}}}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_FORMATTER = string.Formatter()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def render(value: Any, values: Mapping[str, Any]) -> Any:
|
|
24
|
+
"""Substitute ``{name}`` placeholders in strings, lists and mappings."""
|
|
25
|
+
strict = _StrictValues(values)
|
|
26
|
+
if isinstance(value, str):
|
|
27
|
+
try:
|
|
28
|
+
return _FORMATTER.vformat(value, (), strict)
|
|
29
|
+
except (IndexError, KeyError) as exc:
|
|
30
|
+
raise RigError(f"cannot render {value!r}: {exc}") from None
|
|
31
|
+
if isinstance(value, list):
|
|
32
|
+
return [render(item, values) for item in value]
|
|
33
|
+
if isinstance(value, Mapping):
|
|
34
|
+
return {key: render(item, values) for key, item in value.items()}
|
|
35
|
+
return value
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_MIN_QUOTED_LEN = 2
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _strip_env_quotes(item: str) -> str:
|
|
42
|
+
if len(item) >= _MIN_QUOTED_LEN and item[0] == item[-1] and item[0] in "\"'":
|
|
43
|
+
return item[1:-1]
|
|
44
|
+
return item
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_env_file(path: Path) -> dict[str, str]:
|
|
48
|
+
"""Return ``KEY=VALUE`` pairs from a dotenv-style file, ignoring comments."""
|
|
49
|
+
values: dict[str, str] = {}
|
|
50
|
+
try:
|
|
51
|
+
text = Path(path).read_text()
|
|
52
|
+
except OSError:
|
|
53
|
+
return values
|
|
54
|
+
for line in text.splitlines():
|
|
55
|
+
stripped = line.strip()
|
|
56
|
+
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
|
57
|
+
continue
|
|
58
|
+
key, _, raw = stripped.partition("=")
|
|
59
|
+
key = key.strip()
|
|
60
|
+
if key.startswith("export "):
|
|
61
|
+
key = key[len("export ") :].strip()
|
|
62
|
+
values[key] = _strip_env_quotes(raw.strip())
|
|
63
|
+
return values
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def build_service_env(
|
|
67
|
+
declared: Mapping[str, Any],
|
|
68
|
+
*args: Any,
|
|
69
|
+
**kwargs: Any,
|
|
70
|
+
) -> dict[str, str]:
|
|
71
|
+
"""Compose a service environment from an allowlist, env files and manifest values."""
|
|
72
|
+
params = list(args)
|
|
73
|
+
inherit: Sequence[str] = params.pop(0) if params else kwargs.get("inherit", ())
|
|
74
|
+
root: Path = params.pop(0) if params else kwargs.get("root", Path.cwd())
|
|
75
|
+
values: Mapping[str, Any] = params.pop(0) if params else kwargs.get("values", {})
|
|
76
|
+
env_files: Sequence[str] = params.pop(0) if params else kwargs.get("env_files", ())
|
|
77
|
+
|
|
78
|
+
env = {name: os.environ[name] for name in (*BASE_ENV_ALLOWLIST, *inherit) if name in os.environ}
|
|
79
|
+
for relative in env_files:
|
|
80
|
+
env.update(parse_env_file(Path(root) / relative))
|
|
81
|
+
merged = {**values, "root": str(root)}
|
|
82
|
+
env.update((str(key), str(render(raw, merged))) for key, raw in declared.items())
|
|
83
|
+
return env
|
rig/core/errors.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Exceptions and JSON error formatting for rig."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from rig.core.constants import EXIT_OP_FAILED, EXIT_USAGE
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RigError(RuntimeError):
|
|
12
|
+
"""A rig operation cannot proceed safely."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, message: str, **kwargs: Any) -> None:
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.message = message
|
|
17
|
+
self.code: str = str(kwargs.get("code", "E_GENERIC"))
|
|
18
|
+
self.exit_code: int = int(kwargs.get("exit_code", EXIT_OP_FAILED))
|
|
19
|
+
self.hint: str | None = kwargs.get("hint")
|
|
20
|
+
self.headline: str | None = kwargs.get("headline")
|
|
21
|
+
self.context: str | None = kwargs.get("context")
|
|
22
|
+
self.details: dict[str, Any] = kwargs.get("details") or {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
StackError = RigError
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def format_human_error(exc: RigError, th: Any) -> str:
|
|
29
|
+
"""Format a RigError into a high-signal human structured error card."""
|
|
30
|
+
headline = exc.headline or exc.message
|
|
31
|
+
lines = [f" {th.red}✖ {headline}{th.r}"]
|
|
32
|
+
if exc.context:
|
|
33
|
+
for cl in exc.context.splitlines():
|
|
34
|
+
lines.append(f" {th.d}{cl}{th.r}")
|
|
35
|
+
elif exc.headline and exc.headline != exc.message:
|
|
36
|
+
lines.append(f" {th.d}{exc.message}{th.r}")
|
|
37
|
+
if exc.hint:
|
|
38
|
+
lines.append(f" {th.cyan}Hint:{th.r} {exc.hint} {th.d}[{exc.code}]{th.r}")
|
|
39
|
+
else:
|
|
40
|
+
lines.append(f" {th.d}[{exc.code}]{th.r}")
|
|
41
|
+
return "\n".join(lines) + "\n"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def manifest_error(message: str, *, hint: str | None = None) -> RigError:
|
|
45
|
+
"""Return the error for a manifest that cannot be used as written."""
|
|
46
|
+
return RigError(message, code="E_USAGE", exit_code=EXIT_USAGE, hint=hint)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def print_json_envelope(command: str, data: Any, ok: bool | None = None) -> None:
|
|
50
|
+
"""Output envelope for structured JSON responses."""
|
|
51
|
+
if ok is None:
|
|
52
|
+
ok = bool(data["ok"]) if isinstance(data, dict) and "ok" in data else True
|
|
53
|
+
envelope = {
|
|
54
|
+
"schema": f"rig.{command}/1",
|
|
55
|
+
"ok": ok,
|
|
56
|
+
"data": data,
|
|
57
|
+
}
|
|
58
|
+
print(json.dumps(envelope, indent=2))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def print_json_error(exc: RigError, command: str = "error") -> None:
|
|
62
|
+
"""Output envelope for structured JSON errors."""
|
|
63
|
+
envelope = {
|
|
64
|
+
"schema": "rig.error/1",
|
|
65
|
+
"ok": False,
|
|
66
|
+
"error": {
|
|
67
|
+
"code": exc.code,
|
|
68
|
+
"exit_code": exc.exit_code,
|
|
69
|
+
"message": exc.message,
|
|
70
|
+
"hint": exc.hint,
|
|
71
|
+
"details": exc.details,
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
print(json.dumps(envelope, indent=2))
|