aisc-cli 0.1.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.
- aisc/VERSION +1 -0
- aisc/__init__.py +49 -0
- aisc/__main__.py +6 -0
- aisc/adapters/__init__.py +1 -0
- aisc/adapters/config_reader.py +215 -0
- aisc/adapters/container_registry.py +718 -0
- aisc/adapters/data_root_store.py +296 -0
- aisc/adapters/docker_.py +1183 -0
- aisc/adapters/docker_gateway.py +1244 -0
- aisc/adapters/docker_sdk_backed.py +274 -0
- aisc/adapters/maintenance_lock.py +97 -0
- aisc/adapters/state_file.py +184 -0
- aisc/adapters/system.py +101 -0
- aisc/adapters/windows_config_reader.py +249 -0
- aisc/adapters/workspace_lease_store.py +189 -0
- aisc/application/__init__.py +1 -0
- aisc/application/artifact.py +265 -0
- aisc/application/bundle_fetch.py +836 -0
- aisc/application/cc_switch_provider.py +245 -0
- aisc/application/cc_switch_resolver.py +363 -0
- aisc/application/config_service.py +452 -0
- aisc/application/conversation.py +516 -0
- aisc/application/data_migration.py +440 -0
- aisc/application/data_root.py +275 -0
- aisc/application/docker_lifecycle.py +659 -0
- aisc/application/doctor.py +821 -0
- aisc/application/legacy_scan.py +252 -0
- aisc/application/network_subscription.py +642 -0
- aisc/application/profile_service.py +105 -0
- aisc/application/provider.py +92 -0
- aisc/application/repair.py +387 -0
- aisc/application/resources.py +270 -0
- aisc/application/runtime.py +1826 -0
- aisc/application/session.py +320 -0
- aisc/application/tool_versions.py +210 -0
- aisc/application/toolchain.py +91 -0
- aisc/application/update.py +286 -0
- aisc/application/usage.py +305 -0
- aisc/application/version.py +133 -0
- aisc/application/web_gateway.py +459 -0
- aisc/application/workspace_reconcile.py +318 -0
- aisc/applog.py +103 -0
- aisc/cli/__init__.py +1 -0
- aisc/cli/build_progress_parser.py +217 -0
- aisc/cli/commands/__init__.py +1 -0
- aisc/cli/commands/agents.py +117 -0
- aisc/cli/commands/artifact.py +210 -0
- aisc/cli/commands/build.py +566 -0
- aisc/cli/commands/bundle.py +107 -0
- aisc/cli/commands/cc_switch.py +158 -0
- aisc/cli/commands/config.py +104 -0
- aisc/cli/commands/container.py +787 -0
- aisc/cli/commands/conversation.py +72 -0
- aisc/cli/commands/data_root.py +84 -0
- aisc/cli/commands/logs.py +89 -0
- aisc/cli/commands/network.py +132 -0
- aisc/cli/commands/profile.py +52 -0
- aisc/cli/commands/provider.py +49 -0
- aisc/cli/commands/run.py +494 -0
- aisc/cli/commands/runs.py +125 -0
- aisc/cli/commands/runtime.py +450 -0
- aisc/cli/commands/serve.py +550 -0
- aisc/cli/commands/serve_fs.py +369 -0
- aisc/cli/commands/session.py +167 -0
- aisc/cli/commands/update.py +145 -0
- aisc/cli/commands/usage.py +74 -0
- aisc/cli/commands/wizard.py +130 -0
- aisc/cli/commands/workspaces.py +162 -0
- aisc/cli/help_i18n.py +385 -0
- aisc/cli/main.py +2926 -0
- aisc/cli/output.py +239 -0
- aisc/cli/tutorial.py +109 -0
- aisc/domain/__init__.py +1 -0
- aisc/domain/artifacts.py +279 -0
- aisc/domain/cc_switch_release.py +303 -0
- aisc/domain/config.py +64 -0
- aisc/domain/data_migration.py +259 -0
- aisc/domain/data_root.py +124 -0
- aisc/domain/docker_ownership.py +148 -0
- aisc/domain/gateway.py +168 -0
- aisc/domain/models.py +674 -0
- aisc/domain/toolchain.py +72 -0
- aisc/domain/web_services.py +355 -0
- aisc/domain/workspace_lease.py +139 -0
- aisc/schemas/__init__.py +1 -0
- aisc/schemas/config_schema.py +145 -0
- aisc_cli-0.1.0.dist-info/METADATA +420 -0
- aisc_cli-0.1.0.dist-info/RECORD +92 -0
- aisc_cli-0.1.0.dist-info/WHEEL +5 -0
- aisc_cli-0.1.0.dist-info/entry_points.txt +2 -0
- aisc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- aisc_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Version information gathering.
|
|
2
|
+
|
|
3
|
+
Reads ``VERSION`` and ``config/versions.env`` when a root is available;
|
|
4
|
+
returns ``None`` for fields that depend on the repo when no root is found.
|
|
5
|
+
|
|
6
|
+
RFCDirection: always output 6 fixed keys:
|
|
7
|
+
cli_version, bundle_version, contract_version, image_version,
|
|
8
|
+
claude_version, python_version
|
|
9
|
+
|
|
10
|
+
Unknown values are ``None`` (serialized as ``null`` in JSON).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from aisc import __version__
|
|
20
|
+
from aisc.domain.models import VersionInfo
|
|
21
|
+
from aisc.application.resources import locate_aisc_root, _RootSourceError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _read_version_file(root: Path) -> Optional[str]:
|
|
25
|
+
"""Read the first non-empty line of the root's VERSION file.
|
|
26
|
+
|
|
27
|
+
A2 dual-shape: repo checkouts carry it at ``src/aisc/VERSION``; staged
|
|
28
|
+
bundles and frozen roots keep ``VERSION`` at their root."""
|
|
29
|
+
for rel in ("VERSION", "src/aisc/VERSION"):
|
|
30
|
+
version_file = root / rel
|
|
31
|
+
if not version_file.is_file():
|
|
32
|
+
continue
|
|
33
|
+
text = version_file.read_text(encoding="utf-8").strip()
|
|
34
|
+
if text:
|
|
35
|
+
return text
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _parse_versions_env(root: Path) -> dict:
|
|
40
|
+
"""Parse ``config/versions.env`` returning a dict of KEY->value.
|
|
41
|
+
|
|
42
|
+
Strips inline comments (``# ...``).
|
|
43
|
+
"""
|
|
44
|
+
env_file = root / "config" / "versions.env"
|
|
45
|
+
if not env_file.is_file():
|
|
46
|
+
return {}
|
|
47
|
+
result: dict = {}
|
|
48
|
+
for line in env_file.read_text(encoding="utf-8").splitlines():
|
|
49
|
+
line = line.strip()
|
|
50
|
+
if not line or line.startswith("#"):
|
|
51
|
+
continue
|
|
52
|
+
if "#" in line:
|
|
53
|
+
line = line.split("#", 1)[0].rstrip()
|
|
54
|
+
if "=" not in line:
|
|
55
|
+
continue
|
|
56
|
+
key, _, val = line.partition("=")
|
|
57
|
+
result[key.strip()] = val.strip()
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def gather_version_info(
|
|
62
|
+
explicit_root: Optional[str] = None,
|
|
63
|
+
cwd: Optional[Path] = None,
|
|
64
|
+
) -> VersionInfo:
|
|
65
|
+
"""Assemble ``VersionInfo`` from the environment.
|
|
66
|
+
|
|
67
|
+
Parameters
|
|
68
|
+
----------
|
|
69
|
+
explicit_root:
|
|
70
|
+
Explicit path via ``--aisc-root``.
|
|
71
|
+
cwd:
|
|
72
|
+
Working directory for repo discovery.
|
|
73
|
+
|
|
74
|
+
Returns
|
|
75
|
+
-------
|
|
76
|
+
VersionInfo
|
|
77
|
+
Always contains all fields; root-dependent fields are ``None``
|
|
78
|
+
when no root is found.
|
|
79
|
+
|
|
80
|
+
Raises
|
|
81
|
+
------
|
|
82
|
+
_RootSourceError
|
|
83
|
+
When *explicit_root* is invalid.
|
|
84
|
+
"""
|
|
85
|
+
python_version = (sys.version.split()[0] if hasattr(sys, "version")
|
|
86
|
+
else sys.version)
|
|
87
|
+
|
|
88
|
+
root = None
|
|
89
|
+
bundle_version = None
|
|
90
|
+
declared_claude_version = None
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
found = locate_aisc_root(explicit_root=explicit_root, cwd=cwd)
|
|
94
|
+
except _RootSourceError:
|
|
95
|
+
raise
|
|
96
|
+
except Exception:
|
|
97
|
+
raise
|
|
98
|
+
else:
|
|
99
|
+
root = found
|
|
100
|
+
|
|
101
|
+
if root is not None:
|
|
102
|
+
bundle_version = _read_version_file(root)
|
|
103
|
+
env = _parse_versions_env(root)
|
|
104
|
+
declared_claude_version = env.get("CLAUDE_CODE_VERSION")
|
|
105
|
+
|
|
106
|
+
# A8 (guide 3.5.5): text-mode provenance line; JSON contract unchanged.
|
|
107
|
+
channel = _install_channel(root)
|
|
108
|
+
# Always return 6 fixed keys per RFC; unknown → None
|
|
109
|
+
return VersionInfo(
|
|
110
|
+
cli_version=__version__,
|
|
111
|
+
python_version=python_version,
|
|
112
|
+
bundle_version=bundle_version,
|
|
113
|
+
declared_claude_version=declared_claude_version,
|
|
114
|
+
image_version=None,
|
|
115
|
+
contract_version=None,
|
|
116
|
+
install_channel=channel,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _install_channel(root: Optional[Path]) -> str:
|
|
121
|
+
"""Human-readable install provenance for the text `aisc version`."""
|
|
122
|
+
import sys as _sys
|
|
123
|
+
|
|
124
|
+
frozen = getattr(_sys, "frozen", False)
|
|
125
|
+
if frozen:
|
|
126
|
+
return f"frozen ({_sys.executable})"
|
|
127
|
+
if "site-packages" in str(Path(__file__).resolve()):
|
|
128
|
+
return "pip/pipx (site-packages)"
|
|
129
|
+
if root is not None:
|
|
130
|
+
if root.name == "aisc-bundle" and "bundles" in root.parts:
|
|
131
|
+
return f"bundle fetch ({root})"
|
|
132
|
+
return f"source ({root})"
|
|
133
|
+
return "unknown"
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"""Host-side web-gateway lifecycle (svc-2, docs/plans/container-service-access.md).
|
|
2
|
+
|
|
3
|
+
Wires the container-side gateway (svc-1) into the runtime lifecycle:
|
|
4
|
+
|
|
5
|
+
- host loopback port allocation (bind-probe, conflict retry);
|
|
6
|
+
- the ``--publish 127.0.0.1:<host>:45871/tcp`` argv for ``docker run``;
|
|
7
|
+
- mapping reads from ``docker inspect`` (connectivity source of truth);
|
|
8
|
+
- the ``web_access`` snapshot attached to ``RuntimeSnapshot``;
|
|
9
|
+
- the ``aisc runtime services`` data plane (list/expose/unexpose via
|
|
10
|
+
``docker exec`` of the container helpers).
|
|
11
|
+
|
|
12
|
+
Secret-free: payloads carry ports/labels/state only. Events never log
|
|
13
|
+
paths, queries, headers or bodies.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import socket
|
|
20
|
+
from typing import Any, Dict, List, Optional, Set
|
|
21
|
+
|
|
22
|
+
from aisc.domain.models import CliError, RuntimeErrorCode, RuntimeExitCode
|
|
23
|
+
from aisc.domain.web_services import (
|
|
24
|
+
RUNTIME_SERVICES_SCHEMA_V1,
|
|
25
|
+
WEB_GATEWAY_CONTAINER_PORT,
|
|
26
|
+
WEB_GATEWAY_HOST_BIND,
|
|
27
|
+
WEB_GATEWAY_HOST_PORT_MAX,
|
|
28
|
+
WEB_GATEWAY_HOST_PORT_MIN,
|
|
29
|
+
WebGatewayInfo,
|
|
30
|
+
WebServiceInfo,
|
|
31
|
+
build_service_url,
|
|
32
|
+
parse_expose_port,
|
|
33
|
+
sanitize_service_name,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_ALLOCATION_ATTEMPTS = 5 # bind-conflict retries before giving up
|
|
37
|
+
|
|
38
|
+
# docker daemon port-bind failure signatures (case-insensitive substrings).
|
|
39
|
+
_BIND_CONFLICT_MARKERS = (
|
|
40
|
+
"port is already allocated",
|
|
41
|
+
"address already in use",
|
|
42
|
+
"bind for 0.0.0.0",
|
|
43
|
+
"bind for 127.0.0.1",
|
|
44
|
+
"bind for 172.",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class GatewayPortError(CliError):
|
|
49
|
+
"""Host port allocation exhausted / persistently conflicting."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, message: str, data: Optional[Dict[str, Any]] = None):
|
|
52
|
+
super().__init__(
|
|
53
|
+
message=message,
|
|
54
|
+
exit_code=RuntimeExitCode.RUNTIME_OPERATION_FAILED,
|
|
55
|
+
error_code=RuntimeErrorCode.RUNTIME_OPERATION_FAILED,
|
|
56
|
+
data=data,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def is_bind_conflict(stderr: str) -> bool:
|
|
61
|
+
lowered = (stderr or "").lower()
|
|
62
|
+
return any(marker in lowered for marker in _BIND_CONFLICT_MARKERS)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Host port allocation (decisions.md §7)
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def _connect_reachable(port: int, timeout: float = 0.15) -> bool:
|
|
70
|
+
"""True when something ANSWERS on the port (so it is genuinely in use)."""
|
|
71
|
+
try:
|
|
72
|
+
with socket.create_connection((WEB_GATEWAY_HOST_BIND, port), timeout=timeout):
|
|
73
|
+
return True
|
|
74
|
+
except OSError:
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def allocate_gateway_host_port(exclude: Optional[Set[int]] = None,
|
|
79
|
+
start_hint: Optional[int] = None) -> int:
|
|
80
|
+
"""Pick one loopback port in the frozen 47000..47999 range.
|
|
81
|
+
|
|
82
|
+
*exclude* carries ports reserved by other runtimes' registry records —
|
|
83
|
+
skipped without probing so two starts cannot pick the same candidate.
|
|
84
|
+
|
|
85
|
+
Two-stage probe (2026-09-09, HNS field evidence):
|
|
86
|
+
|
|
87
|
+
1. Bind-probe ``127.0.0.1:<port>`` — a port we can bind is definitely
|
|
88
|
+
publishable (the fast, normal path).
|
|
89
|
+
2. If the WHOLE range fails binds, fall through to a connect-probe:
|
|
90
|
+
Docker-Desktop/WSL2 hosts can carry an invisible HNS reservation
|
|
91
|
+
that blocks plain socket binds on the entire range while docker
|
|
92
|
+
publishes on the same ports keep working (measured: bind 10048,
|
|
93
|
+
publish HTTP 200). A port with nothing answering and no registry
|
|
94
|
+
entry is safe to publish; Docker's own allocator is the conflict
|
|
95
|
+
authority (a genuinely taken port fails `docker run -p` loudly,
|
|
96
|
+
absorbed by the create-retry loop in :func:`runtime.start_runtime`).
|
|
97
|
+
|
|
98
|
+
TOCTOU between probe and publish is absorbed by the same retry loop.
|
|
99
|
+
"""
|
|
100
|
+
taken = set(exclude or ())
|
|
101
|
+
hint = start_hint or WEB_GATEWAY_HOST_PORT_MIN
|
|
102
|
+
candidates = [p for p in range(hint, WEB_GATEWAY_HOST_PORT_MAX + 1)
|
|
103
|
+
if p not in taken]
|
|
104
|
+
candidates += [p for p in range(WEB_GATEWAY_HOST_PORT_MIN, hint)
|
|
105
|
+
if p not in taken]
|
|
106
|
+
|
|
107
|
+
bind_failed = False
|
|
108
|
+
for port in candidates:
|
|
109
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
110
|
+
try:
|
|
111
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0)
|
|
112
|
+
sock.bind((WEB_GATEWAY_HOST_BIND, port))
|
|
113
|
+
return port
|
|
114
|
+
except OSError:
|
|
115
|
+
bind_failed = True
|
|
116
|
+
continue
|
|
117
|
+
finally:
|
|
118
|
+
sock.close()
|
|
119
|
+
|
|
120
|
+
if bind_failed:
|
|
121
|
+
# Phantom HNS hold (binds blocked range-wide, publishes alive) —
|
|
122
|
+
# trust reachability instead of bindability.
|
|
123
|
+
for port in candidates:
|
|
124
|
+
if not _connect_reachable(port):
|
|
125
|
+
return port
|
|
126
|
+
|
|
127
|
+
# 2026-09-10 field evidence (recurring): a transient host state can
|
|
128
|
+
# leave EVERY candidate bind-blocked AND connect-answering at once (a
|
|
129
|
+
# loopback-interception variant of the phantom hold — measured:
|
|
130
|
+
# connects to untouched ports get SWALLOWED, not RST'd, and in the
|
|
131
|
+
# worse state they get ACKed). Both probes lie in that state and a
|
|
132
|
+
# hard GatewayPortError blocks the whole activation for nothing.
|
|
133
|
+
# Docker's own allocator is the conflict authority: hand out the first
|
|
134
|
+
# non-excluded candidate; a genuine clash fails `docker run -p`
|
|
135
|
+
# loudly and the publish-retry loops (run_container /
|
|
136
|
+
# start_runtime) rotate the port with a fresh start_hint.
|
|
137
|
+
if candidates:
|
|
138
|
+
return candidates[0]
|
|
139
|
+
|
|
140
|
+
raise GatewayPortError(
|
|
141
|
+
"No free host port in "
|
|
142
|
+
f"{WEB_GATEWAY_HOST_PORT_MIN}..{WEB_GATEWAY_HOST_PORT_MAX} for the web gateway: "
|
|
143
|
+
"every port in the range is reserved by other registered runtimes. "
|
|
144
|
+
"(Bind-blocked and connect-answering states no longer fail here — "
|
|
145
|
+
"the first non-excluded candidate is handed out and Docker's own "
|
|
146
|
+
"allocator adjudicates real conflicts; `wsl --shutdown` + restart "
|
|
147
|
+
"Docker Desktop clears phantom HNS holds.)"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def docker_publish_argv(host_port: int) -> List[str]:
|
|
152
|
+
"""The loopback publish argv for ``docker run``."""
|
|
153
|
+
return [
|
|
154
|
+
"--publish",
|
|
155
|
+
f"{WEB_GATEWAY_HOST_BIND}:{host_port}:{WEB_GATEWAY_CONTAINER_PORT}/tcp",
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def registry_host_ports(entries: Dict[str, Dict[str, Any]]) -> Set[int]:
|
|
160
|
+
"""Ports reserved by other runtimes' registry entries (0/missing ignored)."""
|
|
161
|
+
out: Set[int] = set()
|
|
162
|
+
for meta in (entries or {}).values():
|
|
163
|
+
if not isinstance(meta, dict):
|
|
164
|
+
continue
|
|
165
|
+
try:
|
|
166
|
+
port = int(meta.get("web_gateway_host_port") or 0)
|
|
167
|
+
except (TypeError, ValueError):
|
|
168
|
+
continue
|
|
169
|
+
if WEB_GATEWAY_HOST_PORT_MIN <= port <= WEB_GATEWAY_HOST_PORT_MAX:
|
|
170
|
+
out.add(port)
|
|
171
|
+
return out
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# Docker inspect mapping read
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def read_gateway_mapping(executor: Any, container_name: str) -> Optional[Dict[str, Any]]:
|
|
179
|
+
"""Read the gateway publish mapping from ``docker inspect``.
|
|
180
|
+
|
|
181
|
+
Returns ``{"host_ip", "host_port", "active"}`` or ``None`` when the
|
|
182
|
+
container is absent/unknown. ``active=True`` means the binding lives in
|
|
183
|
+
``NetworkSettings.Ports`` (established, running); a binding visible only
|
|
184
|
+
in ``HostConfig.PortBindings`` is configured but not established
|
|
185
|
+
(stopped container / failed publish).
|
|
186
|
+
"""
|
|
187
|
+
key = f"{WEB_GATEWAY_CONTAINER_PORT}/tcp"
|
|
188
|
+
try:
|
|
189
|
+
result = executor.inspect_container(container_name)
|
|
190
|
+
except Exception:
|
|
191
|
+
return None
|
|
192
|
+
if result.exit_code != 0:
|
|
193
|
+
return None
|
|
194
|
+
try:
|
|
195
|
+
data = json.loads(result.stdout)
|
|
196
|
+
except (ValueError, TypeError):
|
|
197
|
+
return None
|
|
198
|
+
if isinstance(data, list):
|
|
199
|
+
data = data[0] if data else {}
|
|
200
|
+
if not isinstance(data, dict):
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
def _first_binding(raw: Any) -> Optional[Dict[str, Any]]:
|
|
204
|
+
if isinstance(raw, list) and raw and isinstance(raw[0], dict):
|
|
205
|
+
return raw[0]
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
active = _first_binding(
|
|
209
|
+
(data.get("NetworkSettings") or {}).get("Ports", {}).get(key))
|
|
210
|
+
if active is not None:
|
|
211
|
+
return {"host_ip": str(active.get("HostIp") or WEB_GATEWAY_HOST_BIND),
|
|
212
|
+
"host_port": int(active.get("HostPort") or 0),
|
|
213
|
+
"active": True}
|
|
214
|
+
configured = _first_binding(
|
|
215
|
+
(data.get("HostConfig") or {}).get("PortBindings", {}).get(key))
|
|
216
|
+
if configured is not None:
|
|
217
|
+
try:
|
|
218
|
+
port = int(configured.get("HostPort") or 0)
|
|
219
|
+
except (TypeError, ValueError):
|
|
220
|
+
return None
|
|
221
|
+
if port:
|
|
222
|
+
return {"host_ip": str(configured.get("HostIp") or WEB_GATEWAY_HOST_BIND),
|
|
223
|
+
"host_port": port, "active": False}
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def probe_gateway(host_port: int, timeout: float = 0.5) -> bool:
|
|
228
|
+
"""TCP-connect probe of the published gateway on loopback."""
|
|
229
|
+
try:
|
|
230
|
+
with socket.create_connection((WEB_GATEWAY_HOST_BIND, host_port),
|
|
231
|
+
timeout=timeout):
|
|
232
|
+
return True
|
|
233
|
+
except OSError:
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def snapshot_web_access(executor: Any,
|
|
238
|
+
container_name: str,
|
|
239
|
+
docker_state: Optional[str],
|
|
240
|
+
registry_has_gateway: bool,
|
|
241
|
+
probe: bool = True) -> WebGatewayInfo:
|
|
242
|
+
"""Compose the ``web_access`` view (decisions.md §3.2 reason rules).
|
|
243
|
+
|
|
244
|
+
Non-running containers return immediately — the 5s inspect poll calls
|
|
245
|
+
this for every workspace, so a stopped/not-found runtime must not pay
|
|
246
|
+
the extra mapping docker-inspect (host_port stays 0; the registry
|
|
247
|
+
still knows the reserved port).
|
|
248
|
+
"""
|
|
249
|
+
if docker_state is None or docker_state == "unknown":
|
|
250
|
+
return WebGatewayInfo(state="unavailable", reason="docker_unavailable")
|
|
251
|
+
if docker_state != "running":
|
|
252
|
+
return WebGatewayInfo(state="unavailable", reason="runtime_not_running")
|
|
253
|
+
|
|
254
|
+
mapping = read_gateway_mapping(executor, container_name) if container_name else None
|
|
255
|
+
if mapping is None:
|
|
256
|
+
reason = "no_mapping" if registry_has_gateway else "legacy_runtime"
|
|
257
|
+
return WebGatewayInfo(state="unavailable", reason=reason)
|
|
258
|
+
if not mapping["active"]:
|
|
259
|
+
return WebGatewayInfo(state="unavailable", reason="no_mapping",
|
|
260
|
+
host_port=mapping["host_port"])
|
|
261
|
+
if probe and not probe_gateway(mapping["host_port"]):
|
|
262
|
+
return WebGatewayInfo(state="unavailable", reason="gateway_unreachable",
|
|
263
|
+
host_port=mapping["host_port"])
|
|
264
|
+
return WebGatewayInfo(state="ready", host_port=mapping["host_port"])
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
# `aisc runtime services` data plane
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
_HELPER_LIST = "/usr/local/bin/aisc-web-list"
|
|
272
|
+
_HELPER_EXPOSE = "/usr/local/bin/aisc-web-expose"
|
|
273
|
+
_HELPER_UNEXPOSE = "/usr/local/bin/aisc-web-unexpose"
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _resolve_runtime(runtime_id: str, executor: Any, registry_root: Any):
|
|
277
|
+
"""Validate the id and resolve (container_name, docker_state, meta)."""
|
|
278
|
+
from aisc.application.runtime import (
|
|
279
|
+
_find_docker_container_by_runtime_id,
|
|
280
|
+
validate_uuid_v4,
|
|
281
|
+
)
|
|
282
|
+
from aisc.adapters.container_registry import find_by_runtime_id
|
|
283
|
+
|
|
284
|
+
if not validate_uuid_v4(runtime_id):
|
|
285
|
+
raise CliError(
|
|
286
|
+
message=f"Invalid runtime ID (must be UUID v4): {runtime_id}",
|
|
287
|
+
exit_code=RuntimeExitCode.INVALID_RUNTIME_ID,
|
|
288
|
+
error_code=RuntimeErrorCode.INVALID_RUNTIME_ID,
|
|
289
|
+
)
|
|
290
|
+
meta: Dict[str, Any] = {}
|
|
291
|
+
container_name = ""
|
|
292
|
+
found = find_by_runtime_id(registry_root, runtime_id)
|
|
293
|
+
if found is not None:
|
|
294
|
+
container_name, meta = found
|
|
295
|
+
else:
|
|
296
|
+
dc = _find_docker_container_by_runtime_id(runtime_id, executor)
|
|
297
|
+
if dc is not None:
|
|
298
|
+
container_name = dc["container_name"]
|
|
299
|
+
return container_name, meta
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _exec_container(executor: Any, container_name: str, argv: List[str],
|
|
303
|
+
timeout: float = 15.0):
|
|
304
|
+
return executor.run_captured(["exec", container_name] + argv, timeout=timeout)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def list_container_services(executor: Any, container_name: str) -> List[Dict[str, Any]]:
|
|
308
|
+
"""Read the manifest list via the container helper; degraded to []."""
|
|
309
|
+
result = _exec_container(executor, container_name, [_HELPER_LIST, "--json"])
|
|
310
|
+
if result.exit_code != 0:
|
|
311
|
+
return [] # old image without the helper — degrade, never crash
|
|
312
|
+
try:
|
|
313
|
+
records = json.loads(result.stdout.strip() or "[]")
|
|
314
|
+
except (ValueError, TypeError):
|
|
315
|
+
return []
|
|
316
|
+
if not isinstance(records, list):
|
|
317
|
+
return []
|
|
318
|
+
return [r for r in records if isinstance(r, dict)]
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def runtime_services(runtime_id: str,
|
|
322
|
+
executor: Any,
|
|
323
|
+
registry_root: Any,
|
|
324
|
+
probe: bool = True):
|
|
325
|
+
"""``aisc runtime services`` — gateway info + registered services."""
|
|
326
|
+
from aisc.application.runtime import _get_container_state, iso_now
|
|
327
|
+
from aisc.domain.web_services import RuntimeServicesResult
|
|
328
|
+
|
|
329
|
+
container_name, meta = _resolve_runtime(runtime_id, executor, registry_root)
|
|
330
|
+
docker_state = _get_container_state(container_name, executor) if container_name else "not_found"
|
|
331
|
+
gateway = snapshot_web_access(
|
|
332
|
+
executor, container_name, docker_state,
|
|
333
|
+
registry_has_gateway=bool(meta.get("web_gateway_host_port")),
|
|
334
|
+
probe=probe,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
services: List[WebServiceInfo] = []
|
|
338
|
+
if gateway.state == "ready" and gateway.host_port:
|
|
339
|
+
for record in list_container_services(executor, container_name):
|
|
340
|
+
try:
|
|
341
|
+
port = int(record.get("port") or 0)
|
|
342
|
+
if not 1024 <= port <= 65535:
|
|
343
|
+
continue
|
|
344
|
+
services.append(WebServiceInfo(
|
|
345
|
+
port=port,
|
|
346
|
+
protocol=str(record.get("protocol") or "http"),
|
|
347
|
+
name=str(record.get("name") or ""),
|
|
348
|
+
state=str(record.get("state") or "registered"),
|
|
349
|
+
url=build_service_url(port, gateway.host_port),
|
|
350
|
+
))
|
|
351
|
+
except (ValueError, TypeError):
|
|
352
|
+
continue # malformed record — skip, never fail the listing
|
|
353
|
+
|
|
354
|
+
return RuntimeServicesResult(
|
|
355
|
+
runtime_id=runtime_id, gateway=gateway, services=services,
|
|
356
|
+
observed_at=iso_now(),
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _require_running_gateway(executor: Any, registry_root: Any, runtime_id: str):
|
|
361
|
+
"""Shared gate for expose/unexpose: a running, gateway-ready container."""
|
|
362
|
+
container_name, meta = _resolve_runtime(runtime_id, executor, registry_root)
|
|
363
|
+
if not container_name:
|
|
364
|
+
raise CliError(
|
|
365
|
+
message=f"Runtime not found: {runtime_id}",
|
|
366
|
+
exit_code=RuntimeExitCode.GENERAL_ERROR,
|
|
367
|
+
error_code=RuntimeErrorCode.RUNTIME_NOT_FOUND,
|
|
368
|
+
)
|
|
369
|
+
from aisc.application.runtime import _get_container_state
|
|
370
|
+
|
|
371
|
+
state = _get_container_state(container_name, executor)
|
|
372
|
+
gateway = snapshot_web_access(
|
|
373
|
+
executor, container_name, state,
|
|
374
|
+
registry_has_gateway=bool(meta.get("web_gateway_host_port")),
|
|
375
|
+
)
|
|
376
|
+
if state != "running" or gateway.state != "ready":
|
|
377
|
+
reason = gateway.reason or "runtime_not_running"
|
|
378
|
+
raise CliError(
|
|
379
|
+
message=(
|
|
380
|
+
f"Web gateway not ready ({reason}). "
|
|
381
|
+
f"Runtime state: {state or 'not_found'}."
|
|
382
|
+
),
|
|
383
|
+
exit_code=RuntimeExitCode.RUNTIME_NOT_RUNNING
|
|
384
|
+
if state != "running" else RuntimeExitCode.RUNTIME_OPERATION_FAILED,
|
|
385
|
+
error_code=RuntimeErrorCode.RUNTIME_NOT_RUNNING
|
|
386
|
+
if state != "running" else RuntimeErrorCode.RUNTIME_OPERATION_FAILED,
|
|
387
|
+
data={"runtime_id": runtime_id, "web_access": gateway.to_dict()},
|
|
388
|
+
)
|
|
389
|
+
return container_name
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _parse_port_or_usage_error(port_text: str) -> int:
|
|
393
|
+
try:
|
|
394
|
+
return parse_expose_port(port_text)
|
|
395
|
+
except ValueError as exc:
|
|
396
|
+
raise CliError(
|
|
397
|
+
message=str(exc),
|
|
398
|
+
exit_code=RuntimeExitCode.USAGE_ERROR,
|
|
399
|
+
error_code="AISC_ERR_USAGE",
|
|
400
|
+
) from exc
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _parse_name_or_usage_error(name: str) -> str:
|
|
404
|
+
try:
|
|
405
|
+
return sanitize_service_name(name)
|
|
406
|
+
except ValueError as exc:
|
|
407
|
+
raise CliError(
|
|
408
|
+
message=str(exc),
|
|
409
|
+
exit_code=RuntimeExitCode.USAGE_ERROR,
|
|
410
|
+
error_code="AISC_ERR_USAGE",
|
|
411
|
+
) from exc
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def expose_runtime_service(runtime_id: str, port_text: str, name: str,
|
|
415
|
+
executor: Any, registry_root: Any):
|
|
416
|
+
"""``aisc runtime services expose`` — exec the container helper."""
|
|
417
|
+
from aisc.applog import append_event
|
|
418
|
+
|
|
419
|
+
port = _parse_port_or_usage_error(port_text)
|
|
420
|
+
label = _parse_name_or_usage_error(name)
|
|
421
|
+
container_name = _require_running_gateway(executor, registry_root, runtime_id)
|
|
422
|
+
|
|
423
|
+
argv = [_HELPER_EXPOSE, str(port)] + (["--name", label] if label else [])
|
|
424
|
+
result = _exec_container(executor, container_name, argv)
|
|
425
|
+
if result.exit_code != 0:
|
|
426
|
+
raise CliError(
|
|
427
|
+
message=f"Failed to register service port {port}: "
|
|
428
|
+
f"{(result.stderr or '').strip()[:200]}",
|
|
429
|
+
exit_code=RuntimeExitCode.RUNTIME_OPERATION_FAILED,
|
|
430
|
+
error_code=RuntimeErrorCode.RUNTIME_OPERATION_FAILED,
|
|
431
|
+
data={"runtime_id": runtime_id, "port": port},
|
|
432
|
+
)
|
|
433
|
+
append_event("web_service_registered", source="cli",
|
|
434
|
+
container=container_name, runtime_id=runtime_id,
|
|
435
|
+
container_port=port, protocol="http")
|
|
436
|
+
return runtime_services(runtime_id, executor, registry_root)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def unexpose_runtime_service(runtime_id: str, port_text: str,
|
|
440
|
+
executor: Any, registry_root: Any):
|
|
441
|
+
"""``aisc runtime services unexpose`` — exec the container helper."""
|
|
442
|
+
from aisc.applog import append_event
|
|
443
|
+
|
|
444
|
+
port = _parse_port_or_usage_error(port_text)
|
|
445
|
+
container_name = _require_running_gateway(executor, registry_root, runtime_id)
|
|
446
|
+
|
|
447
|
+
result = _exec_container(executor, container_name, [_HELPER_UNEXPOSE, str(port)])
|
|
448
|
+
if result.exit_code != 0:
|
|
449
|
+
raise CliError(
|
|
450
|
+
message=f"Failed to unregister service port {port}: "
|
|
451
|
+
f"{(result.stderr or '').strip()[:200]}",
|
|
452
|
+
exit_code=RuntimeExitCode.RUNTIME_OPERATION_FAILED,
|
|
453
|
+
error_code=RuntimeErrorCode.RUNTIME_OPERATION_FAILED,
|
|
454
|
+
data={"runtime_id": runtime_id, "port": port},
|
|
455
|
+
)
|
|
456
|
+
append_event("web_service_unregistered", source="cli",
|
|
457
|
+
container=container_name, runtime_id=runtime_id,
|
|
458
|
+
container_port=port)
|
|
459
|
+
return runtime_services(runtime_id, executor, registry_root)
|