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
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Rollback lifecycle operations for failed service launches."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from rig.commands.common import _stop_record
|
|
10
|
+
from rig.commands.up.context import RollbackContext
|
|
11
|
+
from rig.core.state import write_state
|
|
12
|
+
from rig.manifest.models import Manifest
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _rollback_step(
|
|
16
|
+
name: str,
|
|
17
|
+
ctx: tuple[dict[str, Any], Path, Path, Manifest | None],
|
|
18
|
+
failed_services: set[str],
|
|
19
|
+
) -> None:
|
|
20
|
+
state, state_path, root, manifest = ctx
|
|
21
|
+
rec = state["services"].get(name)
|
|
22
|
+
if rec is None:
|
|
23
|
+
return
|
|
24
|
+
if manifest and any(
|
|
25
|
+
d in failed_services or d in state["services"] for d in manifest.dependents(name)
|
|
26
|
+
):
|
|
27
|
+
return
|
|
28
|
+
outcome = _stop_record(rec, root)
|
|
29
|
+
if outcome in ("terminated", "killed", "stale"):
|
|
30
|
+
state["services"].pop(name, None)
|
|
31
|
+
else:
|
|
32
|
+
failed_services.add(name)
|
|
33
|
+
write_state(state_path, state)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def rollback_started(started: Sequence[str], ctx: RollbackContext) -> None:
|
|
37
|
+
failed_services: set[str] = set()
|
|
38
|
+
for name in reversed(list(started)):
|
|
39
|
+
_rollback_step(name, (ctx.state, ctx.state_path, ctx.root, ctx.manifest), failed_services)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _rollback(
|
|
43
|
+
state: dict[str, Any], state_path: Path, started: Sequence[str], *args: Any, **kwargs: Any
|
|
44
|
+
) -> None:
|
|
45
|
+
"""External test adapter delegating to rollback_started."""
|
|
46
|
+
argv = list(args)
|
|
47
|
+
root = kwargs.get("root") or (argv.pop(0) if argv else Path("."))
|
|
48
|
+
man = kwargs.get("manifest") or (argv.pop(0) if argv else None)
|
|
49
|
+
rollback_started(started, RollbackContext(state, state_path, root, man))
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Retry loop, rollback, and process supervisor for rig up."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from rig.commands.common import _stop_record, _values_for
|
|
10
|
+
from rig.commands.up.context import RetryContext
|
|
11
|
+
from rig.commands.up.rollback import _rollback, rollback_started
|
|
12
|
+
from rig.commands.up.service import _await_ready, _start_service
|
|
13
|
+
from rig.core.constants import PORT_RETRY_ATTEMPTS
|
|
14
|
+
from rig.core.errors import RigError
|
|
15
|
+
from rig.core.state import write_state
|
|
16
|
+
from rig.manifest.models import Service
|
|
17
|
+
from rig.net.ports import compute_candidate_ports
|
|
18
|
+
|
|
19
|
+
_LAUNCH_EXCEPTIONS = (
|
|
20
|
+
KeyboardInterrupt,
|
|
21
|
+
SystemExit,
|
|
22
|
+
RigError,
|
|
23
|
+
OSError,
|
|
24
|
+
RuntimeError,
|
|
25
|
+
ValueError,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _spawn_candidate(
|
|
30
|
+
service: Service,
|
|
31
|
+
ctx: tuple[Path, Path, str, Mapping[str, Any]],
|
|
32
|
+
cands: Sequence[int],
|
|
33
|
+
) -> dict[str, Any]:
|
|
34
|
+
root, runtime, instance, values = ctx
|
|
35
|
+
return _start_service(service, root, runtime, instance, values, cands)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _handle_launch_failure(
|
|
39
|
+
exc: Exception | KeyboardInterrupt | SystemExit,
|
|
40
|
+
service: Service,
|
|
41
|
+
ctx: tuple[dict[str, Any], Path],
|
|
42
|
+
) -> None:
|
|
43
|
+
state, state_path = ctx
|
|
44
|
+
partial = (
|
|
45
|
+
exc.details.get("partial_record") if isinstance(exc, RigError) and exc.details else None
|
|
46
|
+
)
|
|
47
|
+
if isinstance(partial, dict):
|
|
48
|
+
state["services"][service.name] = partial
|
|
49
|
+
write_state(state_path, state)
|
|
50
|
+
raise exc
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _handle_unready(
|
|
54
|
+
record: dict[str, Any],
|
|
55
|
+
ctx: tuple[Path, Service, dict[str, Any], Path],
|
|
56
|
+
collided: set[int],
|
|
57
|
+
) -> bool:
|
|
58
|
+
root, service, state, state_path = ctx
|
|
59
|
+
outcome = _stop_record(record, root)
|
|
60
|
+
if record.get("port"):
|
|
61
|
+
collided.add(int(record["port"]))
|
|
62
|
+
if outcome in ("terminated", "killed", "stale"):
|
|
63
|
+
state["services"].pop(service.name, None)
|
|
64
|
+
write_state(state_path, state)
|
|
65
|
+
return True
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _start_attempt(
|
|
70
|
+
service: Service,
|
|
71
|
+
paths: tuple[Path, Path, str],
|
|
72
|
+
ctx: tuple[dict[str, Any], Path, set[int]],
|
|
73
|
+
) -> dict[str, Any] | None:
|
|
74
|
+
root, runtime, instance = paths
|
|
75
|
+
state, state_path, collided = ctx
|
|
76
|
+
values = _values_for(state, root, instance)
|
|
77
|
+
cands = compute_candidate_ports(service, state, avoid=collided)
|
|
78
|
+
try:
|
|
79
|
+
record = _spawn_candidate(service, (root, runtime, instance, values), cands)
|
|
80
|
+
except _LAUNCH_EXCEPTIONS as exc:
|
|
81
|
+
_handle_launch_failure(exc, service, (state, state_path))
|
|
82
|
+
state["services"][service.name] = record
|
|
83
|
+
write_state(state_path, state)
|
|
84
|
+
if _await_ready(service, record, root):
|
|
85
|
+
if record.get("port"):
|
|
86
|
+
state.setdefault("ports", {})[service.name] = record["port"]
|
|
87
|
+
write_state(state_path, state)
|
|
88
|
+
return record
|
|
89
|
+
_handle_unready(record, (root, service, state, state_path), collided)
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def start_with_retry(service: Service, retry: RetryContext) -> dict[str, Any] | None:
|
|
94
|
+
attempts = PORT_RETRY_ATTEMPTS if service.type == "port" else 1
|
|
95
|
+
collided: set[int] = set()
|
|
96
|
+
for _ in range(attempts):
|
|
97
|
+
record = _start_attempt(
|
|
98
|
+
service,
|
|
99
|
+
(retry.root, retry.runtime, retry.instance),
|
|
100
|
+
(retry.state, retry.state_path, collided),
|
|
101
|
+
)
|
|
102
|
+
if record is not None:
|
|
103
|
+
return record
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _start_with_retry(
|
|
108
|
+
service: Service, root: Path, runtime: Path, *args: Any, **kwargs: Any
|
|
109
|
+
) -> dict[str, Any] | None:
|
|
110
|
+
"""External test adapter delegating to start_with_retry."""
|
|
111
|
+
argv = list(args)
|
|
112
|
+
inst = kwargs.get("instance") or (argv.pop(0) if argv else "")
|
|
113
|
+
st = kwargs.get("state") or (argv.pop(0) if argv else {})
|
|
114
|
+
sp = kwargs.get("state_path") or (argv.pop(0) if argv else Path("."))
|
|
115
|
+
return start_with_retry(service, RetryContext(root, runtime, inst, st, sp))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
__all__ = ["_rollback", "_start_with_retry", "rollback_started", "start_with_retry"]
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Service execution and health checking logic for rig up."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from rig.commands.up.context import ServiceContext
|
|
13
|
+
from rig.compose.starter import _start_compose_service
|
|
14
|
+
from rig.compose.supervisor import compose_record_alive
|
|
15
|
+
from rig.core.constants import EXIT_EXTERNAL_TOOL
|
|
16
|
+
from rig.core.env import build_service_env, render
|
|
17
|
+
from rig.core.errors import RigError
|
|
18
|
+
from rig.core.state import redact
|
|
19
|
+
from rig.manifest.models import Service
|
|
20
|
+
from rig.net.health import wait_for_http
|
|
21
|
+
from rig.net.ports import _safe_reserve_port, compute_candidate_ports
|
|
22
|
+
from rig.net.probe import port_listener_matches
|
|
23
|
+
from rig.proc.process import identity_matches, pid_alive
|
|
24
|
+
from rig.proc.spawn import spawn_fd_service, spawn_port_service, uvicorn_argv
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _validate_service_env(
|
|
28
|
+
service: Service, root: Path, svc_vals: Mapping[str, Any]
|
|
29
|
+
) -> tuple[Path, dict[str, str]]:
|
|
30
|
+
cwd = (Path(root) / service.cwd).resolve()
|
|
31
|
+
if not cwd.is_dir():
|
|
32
|
+
raise RigError(f"service {service.name!r} working directory {cwd} does not exist")
|
|
33
|
+
if service.type in ("port", "fd") and not shutil.which("lsof"):
|
|
34
|
+
raise RigError(
|
|
35
|
+
"'lsof' required but not on PATH", code="E_EXTERNAL_TOOL", exit_code=EXIT_EXTERNAL_TOOL
|
|
36
|
+
)
|
|
37
|
+
env = build_service_env(service.env, service.inherit, Path(root), svc_vals, service.env_files)
|
|
38
|
+
return cwd, env
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _spawn_local_service(
|
|
42
|
+
service: Service,
|
|
43
|
+
paths: tuple[Path, Path],
|
|
44
|
+
ctx: tuple[dict[str, str], Mapping[str, Any], Sequence[int]],
|
|
45
|
+
) -> dict[str, Any]:
|
|
46
|
+
cwd, log_path = paths
|
|
47
|
+
env, svc_vals, cands = ctx
|
|
48
|
+
if service.type == "fd":
|
|
49
|
+
py_bin = str(render(service.python or sys.executable, svc_vals))
|
|
50
|
+
cmd = service.command or uvicorn_argv(py_bin, service.app or "", service.factory)
|
|
51
|
+
return spawn_fd_service(service.name, cmd, cwd, env, log_path, svc_vals, cands)
|
|
52
|
+
p = _safe_reserve_port(cands)
|
|
53
|
+
return spawn_port_service(service.name, service.command, cwd, env, log_path, p, svc_vals)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def start_service(service: Service, ctx: ServiceContext) -> dict[str, Any]:
|
|
57
|
+
cwd = (Path(ctx.root) / service.cwd).resolve()
|
|
58
|
+
log_path = ctx.runtime / "logs" / f"{service.name}.log"
|
|
59
|
+
cwd, env = _validate_service_env(service, ctx.root, {**ctx.values, "cwd": str(cwd)})
|
|
60
|
+
if service.type == "compose":
|
|
61
|
+
return _start_compose_service(service, ctx.root, ctx.instance, env=env)
|
|
62
|
+
ports = (
|
|
63
|
+
ctx.candidate_ports
|
|
64
|
+
if ctx.candidate_ports is not None
|
|
65
|
+
else compute_candidate_ports(service, {})
|
|
66
|
+
)
|
|
67
|
+
rec = _spawn_local_service(
|
|
68
|
+
service, (cwd, log_path), (env, {**ctx.values, "cwd": str(cwd)}, ports)
|
|
69
|
+
)
|
|
70
|
+
rec.update(
|
|
71
|
+
{
|
|
72
|
+
"log": str(log_path),
|
|
73
|
+
"env": redact(env),
|
|
74
|
+
"depends_on": list(service.depends_on),
|
|
75
|
+
"health": service.healthcheck_path,
|
|
76
|
+
"healthcheck_path": service.healthcheck_path,
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
return rec
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _start_service(
|
|
83
|
+
service: Service, root: Path, runtime: Path, *args: Any, **kwargs: Any
|
|
84
|
+
) -> dict[str, Any]:
|
|
85
|
+
"""External test adapter delegating to start_service."""
|
|
86
|
+
argv = list(args)
|
|
87
|
+
inst = kwargs.get("instance") or (argv.pop(0) if argv else "")
|
|
88
|
+
vals = kwargs.get("values") or (argv.pop(0) if argv else {})
|
|
89
|
+
cands = kwargs.get("candidate_ports") or (argv.pop(0) if argv else None)
|
|
90
|
+
return start_service(service, ServiceContext(root, runtime, inst, vals, cands))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _check_port_listener(port: int, ids: tuple[Any, Any], record: Mapping[str, Any]) -> bool:
|
|
94
|
+
pid, pgid = ids
|
|
95
|
+
p_id = pid if isinstance(pid, int) else None
|
|
96
|
+
pg_id = pgid if isinstance(pgid, int) else None
|
|
97
|
+
if not port_listener_matches(port, pgid=pg_id, pid=p_id):
|
|
98
|
+
return False
|
|
99
|
+
return pid_alive(p_id) and identity_matches(record) if isinstance(p_id, int) else True
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _await_process_service(
|
|
103
|
+
service: Service, record: Mapping[str, Any], target: tuple[Any, Any, int | None]
|
|
104
|
+
) -> bool:
|
|
105
|
+
pid, pgid, port = target
|
|
106
|
+
p_id = pid if isinstance(pid, int) else None
|
|
107
|
+
pg_id = pgid if isinstance(pgid, int) else None
|
|
108
|
+
if service.healthcheck_path and port is not None:
|
|
109
|
+
wait_target = (service.healthcheck_timeout, p_id, pg_id)
|
|
110
|
+
ok = wait_for_http(port, service.healthcheck_path, wait_target)
|
|
111
|
+
return ok and _check_port_listener(port, (pid, pgid), record)
|
|
112
|
+
if isinstance(pid, int):
|
|
113
|
+
time.sleep(0.3)
|
|
114
|
+
matched = port is None or port_listener_matches(port, pgid=pg_id, pid=p_id)
|
|
115
|
+
return matched and pid_alive(pid) and identity_matches(record)
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _await_ready(service: Service, record: Mapping[str, Any], root: Path = Path(".")) -> bool:
|
|
120
|
+
pid, pgid = record.get("pid"), record.get("pgid")
|
|
121
|
+
if isinstance(pid, int) and not pid_alive(pid):
|
|
122
|
+
return False
|
|
123
|
+
port = int(record["port"]) if record.get("port") and str(record["port"]).isdigit() else None
|
|
124
|
+
if service.type == "compose":
|
|
125
|
+
p_id = pid if isinstance(pid, int) else None
|
|
126
|
+
pg_id = pgid if isinstance(pgid, int) else None
|
|
127
|
+
if (
|
|
128
|
+
service.healthcheck_path
|
|
129
|
+
and port is not None
|
|
130
|
+
and not wait_for_http(
|
|
131
|
+
port, service.healthcheck_path, (service.healthcheck_timeout, p_id, pg_id)
|
|
132
|
+
)
|
|
133
|
+
):
|
|
134
|
+
return False
|
|
135
|
+
return compose_record_alive(record, root)
|
|
136
|
+
return _await_process_service(service, record, (pid, pgid, port))
|
rig/compose/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Docker and Docker Compose integration for rig."""
|
rig/compose/client.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Docker and Docker Compose CLI client invocation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from rig.compose.context import (
|
|
12
|
+
INHERIT_DOCKER_HOST,
|
|
13
|
+
_pin_docker_endpoint,
|
|
14
|
+
)
|
|
15
|
+
from rig.core.constants import (
|
|
16
|
+
COMPOSE_DISCOVERY_TIMEOUT_SECS,
|
|
17
|
+
COMPOSE_UP_TIMEOUT_SECS,
|
|
18
|
+
DOCKER_CLIENT_ENV_PASSTHROUGH,
|
|
19
|
+
EXIT_EXTERNAL_TOOL,
|
|
20
|
+
)
|
|
21
|
+
from rig.core.errors import RigError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def compose_argv(
|
|
25
|
+
instance: str,
|
|
26
|
+
root: Path,
|
|
27
|
+
*extra_args: Any,
|
|
28
|
+
**kwargs: Any,
|
|
29
|
+
) -> list[str]:
|
|
30
|
+
"""Build a Compose command scoped to this checkout."""
|
|
31
|
+
p = list(extra_args)
|
|
32
|
+
compose_file = p.pop(0) if p else kwargs["compose_file"]
|
|
33
|
+
args = p.pop(0) if p else kwargs.get("args", ())
|
|
34
|
+
context = p.pop(0) if p else kwargs.get("context")
|
|
35
|
+
|
|
36
|
+
return [
|
|
37
|
+
"docker",
|
|
38
|
+
*(["--context", str(context)] if context else []),
|
|
39
|
+
"compose",
|
|
40
|
+
"--project-directory",
|
|
41
|
+
str(root),
|
|
42
|
+
"-p",
|
|
43
|
+
instance,
|
|
44
|
+
"-f",
|
|
45
|
+
str(compose_file),
|
|
46
|
+
*args,
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_compose_port(output: str) -> int:
|
|
51
|
+
"""Extract the host port from ``docker compose port`` output."""
|
|
52
|
+
line = output.strip().splitlines()[-1].strip() if output.strip() else ""
|
|
53
|
+
_, sep, port = line.rpartition(":")
|
|
54
|
+
if not sep or not port.isdigit():
|
|
55
|
+
raise RigError(f"no published host port in compose output {output!r}")
|
|
56
|
+
return int(port)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def compose_file_present(record: Mapping[str, Any], root: Path) -> bool:
|
|
60
|
+
"""Return ``True`` when this record's compose file is still on disk."""
|
|
61
|
+
compose_file = record.get("compose_file")
|
|
62
|
+
if not compose_file:
|
|
63
|
+
return False
|
|
64
|
+
return (Path(root) / str(compose_file)).is_file()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _exec_docker_cmd(
|
|
68
|
+
argv: list[str],
|
|
69
|
+
env: Mapping[str, str],
|
|
70
|
+
timeout_spec: tuple[float, str],
|
|
71
|
+
) -> subprocess.CompletedProcess:
|
|
72
|
+
timeout, timeout_msg = timeout_spec
|
|
73
|
+
try:
|
|
74
|
+
return subprocess.run(argv, capture_output=True, text=True, timeout=timeout, env=env)
|
|
75
|
+
except FileNotFoundError:
|
|
76
|
+
msg = "docker is not installed or not on PATH"
|
|
77
|
+
raise RigError(msg, code="E_EXTERNAL_TOOL", exit_code=EXIT_EXTERNAL_TOOL) from None
|
|
78
|
+
except subprocess.TimeoutExpired:
|
|
79
|
+
raise RigError(timeout_msg) from None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _unpack_compose_call(
|
|
83
|
+
args: Sequence[Any], kwargs: Mapping[str, Any]
|
|
84
|
+
) -> tuple[Path, Sequence[str], str | None, float, Mapping[str, str] | None, Any]:
|
|
85
|
+
p = list(args)
|
|
86
|
+
cfile = Path(p.pop(0) if p else kwargs["compose_file"])
|
|
87
|
+
cmd_args = p.pop(0) if p else kwargs.get("args", ())
|
|
88
|
+
context = p.pop(0) if p else kwargs.get("context")
|
|
89
|
+
timeout = float(p.pop(0) if p else kwargs.get("timeout", COMPOSE_UP_TIMEOUT_SECS))
|
|
90
|
+
env = p.pop(0) if p else kwargs.get("env")
|
|
91
|
+
host = p.pop(0) if p else kwargs.get("docker_host", INHERIT_DOCKER_HOST)
|
|
92
|
+
return cfile, cmd_args, context, timeout, env, host
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def run_compose(
|
|
96
|
+
instance: str,
|
|
97
|
+
root: Path,
|
|
98
|
+
*args: Any,
|
|
99
|
+
**kwargs: Any,
|
|
100
|
+
) -> subprocess.CompletedProcess:
|
|
101
|
+
cfile, cmd_args, context, timeout, env, host = _unpack_compose_call(args, kwargs)
|
|
102
|
+
argv = compose_argv(instance, root, cfile, cmd_args, context)
|
|
103
|
+
cmd_env = dict(os.environ) if env is None else dict(env)
|
|
104
|
+
_pin_docker_endpoint(cmd_env, context, host)
|
|
105
|
+
msg = f"compose command timed out: {' '.join(cmd_args)}"
|
|
106
|
+
return _exec_docker_cmd(argv, cmd_env, (timeout, msg))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _apply_env_passthrough(cmd_env: dict[str, str], env: Mapping[str, str]) -> None:
|
|
110
|
+
for name in DOCKER_CLIENT_ENV_PASSTHROUGH:
|
|
111
|
+
value = env.get(name)
|
|
112
|
+
if value is None:
|
|
113
|
+
cmd_env.pop(name, None)
|
|
114
|
+
else:
|
|
115
|
+
cmd_env[name] = str(value)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _prepare_docker_env(
|
|
119
|
+
env: Mapping[str, str] | None, context: str | None, docker_host: Any
|
|
120
|
+
) -> dict[str, str]:
|
|
121
|
+
cmd_env = dict(os.environ)
|
|
122
|
+
if env is not None:
|
|
123
|
+
_apply_env_passthrough(cmd_env, env)
|
|
124
|
+
_pin_docker_endpoint(cmd_env, context, docker_host)
|
|
125
|
+
return cmd_env
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def run_docker(
|
|
129
|
+
args: Sequence[str],
|
|
130
|
+
*extra: Any,
|
|
131
|
+
**kwargs: Any,
|
|
132
|
+
) -> subprocess.CompletedProcess:
|
|
133
|
+
"""Run one plain ``docker`` command against one explicit Docker endpoint."""
|
|
134
|
+
p = list(extra)
|
|
135
|
+
context = p.pop(0) if p else kwargs.get("context")
|
|
136
|
+
timeout = float(p.pop(0) if p else kwargs.get("timeout", COMPOSE_DISCOVERY_TIMEOUT_SECS))
|
|
137
|
+
docker_host = p.pop(0) if p else kwargs.get("docker_host", INHERIT_DOCKER_HOST)
|
|
138
|
+
env = p.pop(0) if p else kwargs.get("env")
|
|
139
|
+
|
|
140
|
+
argv = ["docker", "--context", str(context)] if context else ["docker"]
|
|
141
|
+
argv.extend(args)
|
|
142
|
+
cmd_env = _prepare_docker_env(env, context, docker_host)
|
|
143
|
+
msg = f"docker command timed out: {' '.join(args)}"
|
|
144
|
+
return _exec_docker_cmd(argv, cmd_env, (timeout, msg))
|
rig/compose/context.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Docker context and endpoint resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
INHERIT_DOCKER_HOST: Any = object()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def record_docker_endpoint(record: Mapping[str, Any]) -> tuple[Any, Any]:
|
|
14
|
+
"""Return the Docker context and host one record was started against."""
|
|
15
|
+
return record.get("docker_context"), record.get("docker_host", INHERIT_DOCKER_HOST)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def resolve_current_docker_context(env: Mapping[str, str] | None = None) -> str | None:
|
|
19
|
+
"""Return the name of the Docker context that is active right now."""
|
|
20
|
+
cmd_env = dict(os.environ) if env is None else dict(env)
|
|
21
|
+
named = cmd_env.get("DOCKER_CONTEXT")
|
|
22
|
+
if named:
|
|
23
|
+
return named
|
|
24
|
+
try:
|
|
25
|
+
probe = subprocess.run(
|
|
26
|
+
["docker", "context", "show"],
|
|
27
|
+
capture_output=True,
|
|
28
|
+
text=True,
|
|
29
|
+
timeout=5.0,
|
|
30
|
+
env=cmd_env,
|
|
31
|
+
)
|
|
32
|
+
except (OSError, subprocess.SubprocessError):
|
|
33
|
+
return None
|
|
34
|
+
if probe.returncode != 0:
|
|
35
|
+
return None
|
|
36
|
+
return probe.stdout.strip() or None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _pin_docker_endpoint(cmd_env: dict[str, str], context: Any, docker_host: Any) -> dict[str, str]:
|
|
40
|
+
"""Point one Docker command at the endpoint its record was started against."""
|
|
41
|
+
cmd_env.pop("DOCKER_CONTEXT", None)
|
|
42
|
+
if docker_host is INHERIT_DOCKER_HOST:
|
|
43
|
+
return cmd_env
|
|
44
|
+
if docker_host:
|
|
45
|
+
cmd_env["DOCKER_HOST"] = str(docker_host)
|
|
46
|
+
else:
|
|
47
|
+
cmd_env.pop("DOCKER_HOST", None)
|
|
48
|
+
return cmd_env
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def record_compose_env(record: Mapping[str, Any]) -> dict[str, str] | None:
|
|
52
|
+
"""Return the environment one compose record was started with, if it was recorded."""
|
|
53
|
+
env = record.get("compose_env")
|
|
54
|
+
if not isinstance(env, Mapping):
|
|
55
|
+
return None
|
|
56
|
+
return {str(key): str(value) for key, value in env.items()}
|
rig/compose/discovery.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Compose container discovery, initialization, and port resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from rig.compose.client import parse_compose_port
|
|
11
|
+
from rig.core.constants import EXIT_OP_FAILED
|
|
12
|
+
from rig.core.errors import RigError
|
|
13
|
+
from rig.core.state import redact
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def init_compose_record(
|
|
17
|
+
service: Any,
|
|
18
|
+
instance: str,
|
|
19
|
+
target: tuple[Path, str | None, str | None, dict[str, str] | None],
|
|
20
|
+
) -> dict[str, Any]:
|
|
21
|
+
"""Build the initial state record for a starting compose service."""
|
|
22
|
+
cfile, ctx, host, env = target
|
|
23
|
+
rec: dict[str, Any] = {
|
|
24
|
+
"name": service.name,
|
|
25
|
+
"type": "compose",
|
|
26
|
+
"pid": None,
|
|
27
|
+
"pgid": None,
|
|
28
|
+
"instance": instance,
|
|
29
|
+
"compose_file": str(cfile),
|
|
30
|
+
"compose_service": service.compose_service,
|
|
31
|
+
"docker_context": ctx,
|
|
32
|
+
"docker_host": host,
|
|
33
|
+
"container": "",
|
|
34
|
+
"port": None,
|
|
35
|
+
"url": None,
|
|
36
|
+
"started_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
37
|
+
"depends_on": list(service.depends_on),
|
|
38
|
+
"health": service.healthcheck_path,
|
|
39
|
+
"healthcheck_path": service.healthcheck_path,
|
|
40
|
+
}
|
|
41
|
+
return {**rec, **({"compose_env": redact(env)} if env is not None else {})}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
INTERRUPTION_EXCEPTIONS = (
|
|
45
|
+
KeyboardInterrupt,
|
|
46
|
+
SystemExit,
|
|
47
|
+
OSError,
|
|
48
|
+
RigError,
|
|
49
|
+
subprocess.SubprocessError,
|
|
50
|
+
RuntimeError,
|
|
51
|
+
ValueError,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def stranded_error(msg: str, record: dict[str, Any]) -> RigError:
|
|
56
|
+
"""Format RigError when a container failed to start and could not be cleaned up."""
|
|
57
|
+
return RigError(
|
|
58
|
+
f"{msg}; the partial container could not be removed and stays recorded",
|
|
59
|
+
code="E_COMPOSE_FAILED",
|
|
60
|
+
exit_code=EXIT_OP_FAILED,
|
|
61
|
+
hint="run 'rig down' or 'rig prune --force' to reclaim it",
|
|
62
|
+
details={"partial_record": record},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def handle_compose_interruption(
|
|
67
|
+
exc: BaseException,
|
|
68
|
+
info: tuple[Any, dict[str, Any]],
|
|
69
|
+
cleanup_fn: Any,
|
|
70
|
+
*args: Any,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Handle interruption during compose start or discovery."""
|
|
73
|
+
action = args[0] if args else "the start of"
|
|
74
|
+
service, record = info
|
|
75
|
+
if not cleanup_fn():
|
|
76
|
+
name = type(exc).__name__
|
|
77
|
+
msg = f"{action} {service.name!r} was interrupted by {name}"
|
|
78
|
+
raise stranded_error(msg, record) from exc
|
|
79
|
+
raise exc
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _discover_container_id(service: Any, compose_fn: Any, fail_fn: Any) -> str:
|
|
83
|
+
try:
|
|
84
|
+
ids = compose_fn(["ps", "-q", str(service.compose_service)], timeout=60.0)
|
|
85
|
+
except (RigError, OSError, subprocess.SubprocessError, RuntimeError) as exc:
|
|
86
|
+
raise fail_fn(f"compose failed to list containers for {service.name!r}: {exc}") from exc
|
|
87
|
+
if ids.returncode != 0:
|
|
88
|
+
err = ids.stderr.strip() or ids.stdout.strip()
|
|
89
|
+
raise fail_fn(f"compose failed to list containers for {service.name!r}: {err}")
|
|
90
|
+
container = ids.stdout.strip().splitlines()[0].strip() if ids.stdout.strip() else ""
|
|
91
|
+
if not container:
|
|
92
|
+
raise fail_fn(f"compose reported no container for {service.name!r}")
|
|
93
|
+
return container
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _discover_service_port(service: Any, compose_fn: Any, fail_fn: Any) -> int:
|
|
97
|
+
try:
|
|
98
|
+
pub = compose_fn(
|
|
99
|
+
["port", str(service.compose_service), str(service.compose_port)],
|
|
100
|
+
timeout=60.0,
|
|
101
|
+
)
|
|
102
|
+
except (RigError, OSError, subprocess.SubprocessError, RuntimeError) as exc:
|
|
103
|
+
raise fail_fn(f"compose failed to resolve port for {service.name!r}: {exc}") from exc
|
|
104
|
+
if pub.returncode != 0:
|
|
105
|
+
err = pub.stderr.strip() or pub.stdout.strip() or "no output"
|
|
106
|
+
raise fail_fn(f"compose failed to resolve port for {service.name!r}: {err}")
|
|
107
|
+
return parse_compose_port(pub.stdout)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def discover_container_and_port(
|
|
111
|
+
service: Any,
|
|
112
|
+
record: dict[str, Any],
|
|
113
|
+
helpers: tuple[Any, Any],
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Populate record with running container ID and resolved loopback port."""
|
|
116
|
+
compose_fn, fail_fn = helpers
|
|
117
|
+
record["container"] = _discover_container_id(service, compose_fn, fail_fn)
|
|
118
|
+
if service.compose_port:
|
|
119
|
+
port = _discover_service_port(service, compose_fn, fail_fn)
|
|
120
|
+
record["port"] = port
|
|
121
|
+
record["url"] = f"http://127.0.0.1:{port}"
|