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,18 @@
1
+ """Binary executable resolution utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ from pathlib import Path
8
+
9
+
10
+ def _resolve_executable(spec: str, cwd: Path) -> Path | None:
11
+ """Return the file a spawn would execute for ``spec``, or None when absent."""
12
+ if os.sep in spec:
13
+ candidate = Path(spec)
14
+ if not candidate.is_absolute():
15
+ candidate = cwd / candidate
16
+ return candidate if candidate.is_file() else None
17
+ found = shutil.which(spec)
18
+ return Path(found) if found else None
rig/manifest/loader.py ADDED
@@ -0,0 +1,146 @@
1
+ """Manifest parsing and loading for rig."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from rig.core.constants import EXIT_USAGE
10
+ from rig.core.errors import RigError, manifest_error
11
+ from rig.manifest.models import Manifest, Service
12
+ from rig.manifest.parser import _parse_service, _validate_service_integrity
13
+
14
+
15
+ def _load_raw_json(path: Path) -> dict[str, Any]:
16
+ try:
17
+ raw = json.loads(Path(path).read_text())
18
+ except OSError:
19
+ raise RigError(
20
+ f"manifest not found: {path}",
21
+ code="E_USAGE",
22
+ exit_code=EXIT_USAGE,
23
+ headline="No manifest found in this directory",
24
+ context=f"rig looked for 'rig.json' or 'stack.json' at:\n{path}",
25
+ hint="Run 'rig init' to scaffold a new manifest, or pass '--manifest <path>'",
26
+ ) from None
27
+ except json.JSONDecodeError as exc:
28
+ raise manifest_error(f"manifest {path} is not valid JSON: {exc}") from None
29
+ if not isinstance(raw, dict):
30
+ raise manifest_error(f"manifest {path} must be a JSON object")
31
+ if not isinstance(raw.get("project"), str) or not raw.get("project"):
32
+ raise manifest_error(f"manifest {path} must declare a non-empty 'project'")
33
+ return raw
34
+
35
+
36
+ def _build_mode_services(raw_modes: Any) -> dict[str, dict[str, Service]]:
37
+ if raw_modes is None:
38
+ return {}
39
+ if not isinstance(raw_modes, dict):
40
+ raise manifest_error("'modes' must be a JSON object")
41
+ modes: dict[str, dict[str, Service]] = {}
42
+ for mode_name, mode_obj in raw_modes.items():
43
+ if not isinstance(mode_obj, dict) or not isinstance(mode_obj.get("services"), dict):
44
+ raise manifest_error(f"mode {mode_name!r} must declare a 'services' object")
45
+ modes[mode_name] = {
46
+ sname: _parse_service(sname, spec) for sname, spec in mode_obj["services"].items()
47
+ }
48
+ return modes
49
+
50
+
51
+ def _register_service_aliases(
52
+ item: tuple[str, Service], services: dict[str, Service], derived: dict[str, list[str]]
53
+ ) -> None:
54
+ sname, service = item
55
+ derived[sname] = [sname]
56
+ for alias in service.aliases:
57
+ if not isinstance(alias, str) or not alias:
58
+ raise manifest_error(f"service {sname!r} has invalid alias {alias!r}")
59
+ if alias in services and alias != sname:
60
+ raise manifest_error(f"alias {alias!r} for service {sname!r} conflicts with another")
61
+ derived[alias] = [sname]
62
+
63
+
64
+ def _validate_scope_members(scope: str, members: list[str], known: set[str]) -> None:
65
+ for m in members:
66
+ if m not in known:
67
+ raise manifest_error(f"scope {scope!r} names unknown service {m!r}")
68
+
69
+
70
+ def _parse_explicit_scopes(
71
+ scopes_raw: Any, initial_services: dict[str, Service]
72
+ ) -> dict[str, list[str]]:
73
+ if scopes_raw is None:
74
+ return {}
75
+ if not isinstance(scopes_raw, dict):
76
+ raise manifest_error("'scopes' must be a JSON object")
77
+ explicit: dict[str, list[str]] = {}
78
+ known = set(initial_services)
79
+ for scope, members in scopes_raw.items():
80
+ if not isinstance(members, list) or not all(isinstance(m, str) for m in members):
81
+ raise manifest_error(f"scope {scope!r} must be a list of string service names")
82
+ _validate_scope_members(scope, members, known)
83
+ explicit[scope] = list(members)
84
+ return explicit
85
+
86
+
87
+ def _derive_scopes(
88
+ services: dict[str, Service], raw: Any
89
+ ) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
90
+ derived: dict[str, list[str]] = {"full": list(services), "local": list(services)}
91
+ for item in services.items():
92
+ _register_service_aliases(item, services, derived)
93
+ explicit = _parse_explicit_scopes(raw, services)
94
+ derived.update(explicit)
95
+ return derived, explicit
96
+
97
+
98
+ def _validate_raw_services(path: Path, declared: Any, raw_modes: Any) -> None:
99
+ if declared is not None and not isinstance(declared, dict):
100
+ raise manifest_error(f"manifest {path} 'services' must be a JSON object")
101
+ if not declared and not raw_modes:
102
+ raise manifest_error(f"manifest {path} must declare at least one service")
103
+
104
+
105
+ def _resolve_initial_mode(
106
+ modes: dict[str, dict[str, Service]], default_mode: str | None
107
+ ) -> tuple[str | None, str | None]:
108
+ if default_mode and default_mode not in modes:
109
+ known = list(modes.keys())
110
+ raise manifest_error(f"default_mode {default_mode!r} not declared in modes: {known}")
111
+ init_mode = default_mode or next(iter(modes), None)
112
+ return default_mode, init_mode
113
+
114
+
115
+ def load_manifest(path: Path) -> Manifest:
116
+ """Read and validate a stack manifest."""
117
+ path = Path(path)
118
+ raw = _load_raw_json(path)
119
+ declared, raw_modes = raw.get("services"), raw.get("modes")
120
+ _validate_raw_services(path, declared, raw_modes)
121
+
122
+ base = {name: _parse_service(name, spec) for name, spec in (declared or {}).items()}
123
+ modes = _build_mode_services(raw_modes)
124
+ default_mode, init_mode = _resolve_initial_mode(modes, raw.get("default_mode"))
125
+
126
+ services = {**base, **(modes[init_mode] if init_mode else {})}
127
+ _validate_service_integrity(services)
128
+ scopes, explicit_scopes = _derive_scopes(services, raw.get("scopes"))
129
+
130
+ manifest = Manifest(
131
+ project=raw["project"],
132
+ services=services,
133
+ scopes=scopes,
134
+ path=path,
135
+ base_services=base,
136
+ modes=modes,
137
+ default_mode=default_mode,
138
+ active_mode=init_mode,
139
+ explicit_scopes=explicit_scopes,
140
+ )
141
+ for m_name in modes:
142
+ manifest.for_mode(m_name)
143
+ if not modes:
144
+ for scope in scopes:
145
+ manifest.resolve_scope(scope)
146
+ return manifest
rig/manifest/models.py ADDED
@@ -0,0 +1,129 @@
1
+ """Data models for rig service and manifest declarations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from rig.core.constants import HEALTH_TIMEOUT_SECS
11
+ from rig.core.errors import manifest_error
12
+
13
+
14
+ @dataclass
15
+ class Service:
16
+ name: str
17
+ type: str
18
+ cwd: str = "."
19
+ command: list[str] = field(default_factory=list)
20
+ aliases: list[str] = field(default_factory=list)
21
+ app: str | None = None
22
+ factory: bool = False
23
+ python: str | None = None
24
+ env: dict[str, Any] = field(default_factory=dict)
25
+ inherit: list[str] = field(default_factory=list)
26
+ env_files: list[str] = field(default_factory=list)
27
+ healthcheck_path: str | None = None
28
+ healthcheck_timeout: float = HEALTH_TIMEOUT_SECS
29
+ depends_on: list[str] = field(default_factory=list)
30
+ compose_file: str | None = None
31
+ compose_service: str | None = None
32
+ compose_port: int | None = None
33
+ docker_context: str | None = None
34
+ description: str = ""
35
+ preferred_port: int | None = None
36
+
37
+
38
+ @dataclass
39
+ class Manifest:
40
+ project: str
41
+ services: dict[str, Service]
42
+ scopes: dict[str, list[str]]
43
+ path: Path
44
+ base_services: dict[str, Service] = field(default_factory=dict)
45
+ modes: dict[str, dict[str, Service]] = field(default_factory=dict)
46
+ default_mode: str | None = None
47
+ active_mode: str | None = None
48
+ explicit_scopes: dict[str, list[str]] = field(default_factory=dict)
49
+
50
+ def resolve_services(self, names: Iterable[str]) -> list[str]:
51
+ """Return the given services plus their transitive dependencies, in start order."""
52
+ ordered: list[str] = []
53
+ for name in names:
54
+ self._visit(name, ordered, set())
55
+ return ordered
56
+
57
+ def resolve_scope(self, scope: str) -> list[str]:
58
+ """Return the scope's services plus their transitive dependencies, in start order."""
59
+ return self.resolve_services(self._members(scope))
60
+
61
+ def teardown_scope(self, scope: str) -> list[str]:
62
+ """Return only the scope's declared services, in reverse start order."""
63
+ declared = set(self._members(scope))
64
+ return [name for name in reversed(self.resolve_scope(scope)) if name in declared]
65
+
66
+ def dependents(self, name: str) -> list[str]:
67
+ return [other for other, service in self.services.items() if name in service.depends_on]
68
+
69
+ def _members(self, scope: str) -> list[str]:
70
+ if scope not in self.scopes:
71
+ known = ", ".join(sorted(self.scopes))
72
+ raise manifest_error(f"unknown scope {scope!r}; manifest declares {known}")
73
+ return list(self.scopes[scope])
74
+
75
+ def _visit(self, name: str, ordered: list[str], seen: set[str]) -> None:
76
+ if name in ordered:
77
+ return
78
+ if name in seen:
79
+ raise manifest_error(f"dependency cycle through service {name!r}")
80
+ seen.add(name)
81
+ for dependency in self.services[name].depends_on:
82
+ self._visit(dependency, ordered, seen)
83
+ ordered.append(name)
84
+
85
+ def _build_derived_scopes(self, mode_services: dict[str, Service]) -> dict[str, list[str]]:
86
+ derived: dict[str, list[str]] = {
87
+ "full": list(mode_services.keys()),
88
+ "local": list(mode_services.keys()),
89
+ }
90
+ derived.update(
91
+ {
92
+ alias: [name]
93
+ for name, service in mode_services.items()
94
+ for alias in (name, *service.aliases)
95
+ }
96
+ )
97
+ derived.update(
98
+ {
99
+ scope: selected
100
+ for scope, members in self.explicit_scopes.items()
101
+ if (selected := [member for member in members if member in mode_services])
102
+ }
103
+ )
104
+ return derived
105
+
106
+ def for_mode(self, mode_name: str | None = None) -> Manifest:
107
+ if not self.modes:
108
+ return self
109
+ target_mode = mode_name or self.default_mode or next(iter(self.modes.keys()))
110
+ if target_mode not in self.modes:
111
+ known = ", ".join(sorted(self.modes.keys()))
112
+ raise manifest_error(f"unknown mode {target_mode!r}; manifest declares modes: {known}")
113
+ mode_services = {**self.base_services, **self.modes[target_mode]}
114
+ derived_scopes = self._build_derived_scopes(mode_services)
115
+
116
+ m = Manifest(
117
+ project=self.project,
118
+ services=mode_services,
119
+ scopes=derived_scopes,
120
+ path=self.path,
121
+ base_services=self.base_services,
122
+ modes=self.modes,
123
+ default_mode=self.default_mode,
124
+ active_mode=target_mode,
125
+ explicit_scopes=self.explicit_scopes,
126
+ )
127
+ for scope in derived_scopes:
128
+ m.resolve_scope(scope)
129
+ return m
rig/manifest/parser.py ADDED
@@ -0,0 +1,123 @@
1
+ """Service specification parsing and integrity validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import shlex
7
+ from collections.abc import Mapping
8
+ from typing import Any
9
+
10
+ from rig.core.constants import PORT_MAX, PORT_MIN, SERVICE_TYPES
11
+ from rig.core.errors import manifest_error
12
+ from rig.manifest.models import Service
13
+
14
+
15
+ def _validate_spec_types(name: str, spec: dict[str, Any]) -> None:
16
+ kind = spec.get("type")
17
+ if kind not in SERVICE_TYPES:
18
+ raise manifest_error(
19
+ f"service {name!r} has unknown type {kind!r}; expected one of {SERVICE_TYPES}"
20
+ )
21
+ for key in ("env_files", "depends_on", "aliases", "inherit"):
22
+ if key in spec and (
23
+ not isinstance(spec[key], list) or not all(isinstance(i, str) for i in spec[key])
24
+ ):
25
+ raise manifest_error(f"service {name!r} {key!r} must be a list of strings")
26
+ if "env" in spec and (
27
+ not isinstance(spec["env"], dict) or not all(isinstance(k, str) for k in spec["env"])
28
+ ):
29
+ raise manifest_error(
30
+ f"service {name!r} 'env' must be a JSON object mapping strings to values"
31
+ )
32
+ if "cwd" in spec and not isinstance(spec["cwd"], str):
33
+ raise manifest_error(f"service {name!r} 'cwd' must be a string")
34
+
35
+
36
+ def _validate_spec_health(name: str, spec: dict[str, Any]) -> None:
37
+ if "health" in spec:
38
+ if "healthcheck_path" in spec and spec["health"] != spec["healthcheck_path"]:
39
+ raise manifest_error(
40
+ f"service {name!r} defines conflicting 'health' and 'healthcheck_path'"
41
+ )
42
+ spec["healthcheck_path"] = spec.pop("health")
43
+ t = spec.get("healthcheck_timeout")
44
+ if "healthcheck_timeout" in spec and (
45
+ isinstance(t, bool) or not isinstance(t, (int, float)) or not math.isfinite(t) or t <= 0
46
+ ):
47
+ raise manifest_error(
48
+ f"service {name!r} 'healthcheck_timeout' must be a positive finite number"
49
+ )
50
+ if "healthcheck_path" in spec and spec["healthcheck_path"] is not None:
51
+ hp = spec["healthcheck_path"]
52
+ if not isinstance(hp, str) or not hp or not hp.startswith("/"):
53
+ raise manifest_error(
54
+ f"service {name!r} 'healthcheck_path' must be a non-empty string starting with '/'"
55
+ )
56
+
57
+
58
+ def _parse_command_string(name: str, cmd_str: str) -> list[str]:
59
+ clean = cmd_str.strip()
60
+ if not clean or "\0" in clean:
61
+ raise manifest_error(f"service {name!r} 'command' cannot be empty or contain NUL")
62
+ try:
63
+ tokens = shlex.split(clean, comments=False, posix=True)
64
+ except ValueError as exc:
65
+ raise manifest_error(f"service {name!r} invalid command syntax: {exc}") from None
66
+ if not tokens:
67
+ raise manifest_error(f"service {name!r} 'command' cannot be empty")
68
+ return tokens
69
+
70
+
71
+ def _validate_spec_command(name: str, spec: dict[str, Any]) -> None:
72
+ raw_cmd = spec.get("command")
73
+ if isinstance(raw_cmd, str):
74
+ spec["command"] = _parse_command_string(name, raw_cmd)
75
+ elif isinstance(raw_cmd, list):
76
+ if not all(isinstance(t, str) for t in raw_cmd):
77
+ raise manifest_error(f"service {name!r} 'command' must be a list of strings")
78
+ elif raw_cmd is not None:
79
+ raise manifest_error(f"service {name!r} 'command' must be a string or list of strings")
80
+
81
+
82
+ def _validate_spec_port(name: str, spec: dict[str, Any]) -> None:
83
+ if "port" in spec and "preferred_port" not in spec:
84
+ spec["preferred_port"] = spec.pop("port")
85
+ if "preferred_port" in spec and spec["preferred_port"] is not None:
86
+ p = spec["preferred_port"]
87
+ if isinstance(p, bool) or not isinstance(p, int) or not (PORT_MIN <= p <= PORT_MAX):
88
+ raise manifest_error(
89
+ f"service {name!r} 'preferred_port' must be an integer between 1 and {PORT_MAX}"
90
+ )
91
+
92
+
93
+ def _parse_service(name: str, raw_spec: Any) -> Service:
94
+ if not isinstance(raw_spec, dict):
95
+ raise manifest_error(f"service {name!r} must be a JSON object")
96
+ spec = dict(raw_spec)
97
+ _validate_spec_types(name, spec)
98
+ _validate_spec_health(name, spec)
99
+ _validate_spec_command(name, spec)
100
+ _validate_spec_port(name, spec)
101
+
102
+ known = {f.name for f in Service.__dataclass_fields__.values()} - {"name"}
103
+ unknown = set(spec) - known
104
+ if unknown:
105
+ raise manifest_error(f"service {name!r} has unknown keys: {sorted(unknown)}")
106
+ return Service(name=name, **spec)
107
+
108
+
109
+ def _check_dependencies(name: str, service: Service, services: Mapping[str, Service]) -> None:
110
+ for dependency in service.depends_on:
111
+ if dependency not in services:
112
+ raise manifest_error(f"service {name!r} depends on unknown service {dependency!r}")
113
+
114
+
115
+ def _validate_service_integrity(services: Mapping[str, Service]) -> None:
116
+ for name, service in services.items():
117
+ _check_dependencies(name, service, services)
118
+ if service.type == "fd" and not (service.command or service.app):
119
+ raise manifest_error(f"service {name!r} needs a 'command' or an 'app'")
120
+ if service.type == "port" and not service.command:
121
+ raise manifest_error(f"service {name!r} needs a 'command'")
122
+ if service.type == "compose" and not (service.compose_file and service.compose_service):
123
+ raise manifest_error(f"service {name!r} needs 'compose_file' and 'compose_service'")
rig/manifest/schema.py ADDED
@@ -0,0 +1,89 @@
1
+ """JSON schema generation and CLI handler for rig manifests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from rig.core.constants import EXIT_OK
9
+ from rig.core.errors import print_json_envelope
10
+
11
+
12
+ def get_rig_schema() -> dict[str, Any]:
13
+ return {
14
+ "$schema": "http://json-schema.org/draft-07/schema#",
15
+ "title": "RigManifest",
16
+ "description": "Schema for rig.json (v2) developer environment supervisor manifests.",
17
+ "type": "object",
18
+ "required": ["project"],
19
+ "properties": {
20
+ "$schema": {"type": "string"},
21
+ "project": {"type": "string", "pattern": "^[a-zA-Z0-9_-]+$"},
22
+ "default_mode": {"type": "string"},
23
+ "services": {
24
+ "type": "object",
25
+ "additionalProperties": {"$ref": "#/definitions/Service"},
26
+ },
27
+ "modes": {
28
+ "type": "object",
29
+ "additionalProperties": {
30
+ "type": "object",
31
+ "required": ["services"],
32
+ "properties": {
33
+ "services": {
34
+ "type": "object",
35
+ "additionalProperties": {"$ref": "#/definitions/Service"},
36
+ }
37
+ },
38
+ },
39
+ },
40
+ "scopes": {
41
+ "type": "object",
42
+ "additionalProperties": {
43
+ "type": "array",
44
+ "items": {"type": "string"},
45
+ },
46
+ },
47
+ },
48
+ "definitions": {
49
+ "Service": {
50
+ "type": "object",
51
+ "required": ["type"],
52
+ "properties": {
53
+ "type": {"type": "string", "enum": ["fd", "port", "compose"]},
54
+ "cwd": {"type": "string", "default": "."},
55
+ "command": {
56
+ "oneOf": [
57
+ {"type": "string"},
58
+ {"type": "array", "items": {"type": "string"}},
59
+ ]
60
+ },
61
+ "aliases": {"type": "array", "items": {"type": "string"}},
62
+ "app": {"type": "string"},
63
+ "factory": {"type": "boolean", "default": False},
64
+ "python": {"type": "string"},
65
+ "env": {"type": "object"},
66
+ "inherit": {"type": "array", "items": {"type": "string"}},
67
+ "env_files": {"type": "array", "items": {"type": "string"}},
68
+ "healthcheck_path": {"type": "string"},
69
+ "healthcheck_timeout": {"type": "number", "default": 45.0},
70
+ "depends_on": {"type": "array", "items": {"type": "string"}},
71
+ "compose_file": {"type": "string"},
72
+ "compose_service": {"type": "string"},
73
+ "compose_port": {"type": "integer"},
74
+ "docker_context": {"type": "string"},
75
+ "description": {"type": "string"},
76
+ "preferred_port": {"type": "integer"},
77
+ },
78
+ }
79
+ },
80
+ }
81
+
82
+
83
+ def cmd_schema(as_json: bool = False) -> int:
84
+ schema = get_rig_schema()
85
+ if as_json:
86
+ print_json_envelope("schema", schema)
87
+ else:
88
+ print(json.dumps(schema, indent=2))
89
+ return EXIT_OK
rig/net/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Networking, port management, and health checks for rig."""
rig/net/health.py ADDED
@@ -0,0 +1,76 @@
1
+ """Health check HTTP polling without external proxies or redirects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ import urllib.error
8
+ import urllib.request
9
+
10
+ from rig.core.constants import HEALTH_TIMEOUT_SECS
11
+ from rig.net.probe import port_listener_matches
12
+
13
+ HTTP_STATUS_OK = 200
14
+ HTTP_STATUS_REDIRECT = 300
15
+
16
+
17
+ class _RefuseRedirects(urllib.request.HTTPRedirectHandler):
18
+ def redirect_request(self, *args, **kwargs):
19
+ return None
20
+
21
+
22
+ def _health_opener() -> urllib.request.OpenerDirector:
23
+ return urllib.request.build_opener(urllib.request.ProxyHandler({}), _RefuseRedirects())
24
+
25
+
26
+ def _pid_is_running(pid: int | None) -> bool:
27
+ if pid is None:
28
+ return True
29
+ try:
30
+ os.kill(pid, 0)
31
+ except (ProcessLookupError, PermissionError):
32
+ return False
33
+ else:
34
+ return True
35
+
36
+
37
+ def _check_http_response(
38
+ opener: urllib.request.OpenerDirector,
39
+ url: str,
40
+ target: tuple[int, int | None, int | None, float],
41
+ ) -> bool:
42
+ port, pid, pgid, timeout = target
43
+ try:
44
+ with opener.open(url, timeout=min(2.0, max(0.2, timeout))) as response:
45
+ if HTTP_STATUS_OK <= response.status < HTTP_STATUS_REDIRECT:
46
+ if pid is None and pgid is None:
47
+ return True
48
+ if _pid_is_running(pid) and port_listener_matches(port, pgid=pgid, pid=pid):
49
+ return True
50
+ except (urllib.error.URLError, OSError, ValueError):
51
+ pass
52
+ return False
53
+
54
+
55
+ def wait_for_http(
56
+ port: int,
57
+ path: str,
58
+ timeout: float | tuple[float, int | None, int | None] = HEALTH_TIMEOUT_SECS,
59
+ ) -> bool:
60
+ """Poll ``http://127.0.0.1:<port><path>`` until it answers 2xx or deadline passes."""
61
+ t_val = timeout[0] if isinstance(timeout, tuple) else float(timeout)
62
+ pid = timeout[1] if isinstance(timeout, tuple) else None
63
+ pgid = timeout[2] if isinstance(timeout, tuple) else None
64
+ opener = _health_opener()
65
+ clean_path = path if path.startswith("/") else f"/{path}"
66
+ url = f"http://127.0.0.1:{port}{clean_path}"
67
+ target = (port, pid, pgid, t_val)
68
+ deadline = time.monotonic() + t_val
69
+ while True:
70
+ if pid is not None and not _pid_is_running(pid):
71
+ return False
72
+ if _check_http_response(opener, url, target):
73
+ return True
74
+ if time.monotonic() >= deadline:
75
+ return False
76
+ time.sleep(0.15)