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/manifest/inspect.py
ADDED
|
@@ -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)
|