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
@@ -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))
@@ -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()}
@@ -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}"