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.
Files changed (92) hide show
  1. aisc/VERSION +1 -0
  2. aisc/__init__.py +49 -0
  3. aisc/__main__.py +6 -0
  4. aisc/adapters/__init__.py +1 -0
  5. aisc/adapters/config_reader.py +215 -0
  6. aisc/adapters/container_registry.py +718 -0
  7. aisc/adapters/data_root_store.py +296 -0
  8. aisc/adapters/docker_.py +1183 -0
  9. aisc/adapters/docker_gateway.py +1244 -0
  10. aisc/adapters/docker_sdk_backed.py +274 -0
  11. aisc/adapters/maintenance_lock.py +97 -0
  12. aisc/adapters/state_file.py +184 -0
  13. aisc/adapters/system.py +101 -0
  14. aisc/adapters/windows_config_reader.py +249 -0
  15. aisc/adapters/workspace_lease_store.py +189 -0
  16. aisc/application/__init__.py +1 -0
  17. aisc/application/artifact.py +265 -0
  18. aisc/application/bundle_fetch.py +836 -0
  19. aisc/application/cc_switch_provider.py +245 -0
  20. aisc/application/cc_switch_resolver.py +363 -0
  21. aisc/application/config_service.py +452 -0
  22. aisc/application/conversation.py +516 -0
  23. aisc/application/data_migration.py +440 -0
  24. aisc/application/data_root.py +275 -0
  25. aisc/application/docker_lifecycle.py +659 -0
  26. aisc/application/doctor.py +821 -0
  27. aisc/application/legacy_scan.py +252 -0
  28. aisc/application/network_subscription.py +642 -0
  29. aisc/application/profile_service.py +105 -0
  30. aisc/application/provider.py +92 -0
  31. aisc/application/repair.py +387 -0
  32. aisc/application/resources.py +270 -0
  33. aisc/application/runtime.py +1826 -0
  34. aisc/application/session.py +320 -0
  35. aisc/application/tool_versions.py +210 -0
  36. aisc/application/toolchain.py +91 -0
  37. aisc/application/update.py +286 -0
  38. aisc/application/usage.py +305 -0
  39. aisc/application/version.py +133 -0
  40. aisc/application/web_gateway.py +459 -0
  41. aisc/application/workspace_reconcile.py +318 -0
  42. aisc/applog.py +103 -0
  43. aisc/cli/__init__.py +1 -0
  44. aisc/cli/build_progress_parser.py +217 -0
  45. aisc/cli/commands/__init__.py +1 -0
  46. aisc/cli/commands/agents.py +117 -0
  47. aisc/cli/commands/artifact.py +210 -0
  48. aisc/cli/commands/build.py +566 -0
  49. aisc/cli/commands/bundle.py +107 -0
  50. aisc/cli/commands/cc_switch.py +158 -0
  51. aisc/cli/commands/config.py +104 -0
  52. aisc/cli/commands/container.py +787 -0
  53. aisc/cli/commands/conversation.py +72 -0
  54. aisc/cli/commands/data_root.py +84 -0
  55. aisc/cli/commands/logs.py +89 -0
  56. aisc/cli/commands/network.py +132 -0
  57. aisc/cli/commands/profile.py +52 -0
  58. aisc/cli/commands/provider.py +49 -0
  59. aisc/cli/commands/run.py +494 -0
  60. aisc/cli/commands/runs.py +125 -0
  61. aisc/cli/commands/runtime.py +450 -0
  62. aisc/cli/commands/serve.py +550 -0
  63. aisc/cli/commands/serve_fs.py +369 -0
  64. aisc/cli/commands/session.py +167 -0
  65. aisc/cli/commands/update.py +145 -0
  66. aisc/cli/commands/usage.py +74 -0
  67. aisc/cli/commands/wizard.py +130 -0
  68. aisc/cli/commands/workspaces.py +162 -0
  69. aisc/cli/help_i18n.py +385 -0
  70. aisc/cli/main.py +2926 -0
  71. aisc/cli/output.py +239 -0
  72. aisc/cli/tutorial.py +109 -0
  73. aisc/domain/__init__.py +1 -0
  74. aisc/domain/artifacts.py +279 -0
  75. aisc/domain/cc_switch_release.py +303 -0
  76. aisc/domain/config.py +64 -0
  77. aisc/domain/data_migration.py +259 -0
  78. aisc/domain/data_root.py +124 -0
  79. aisc/domain/docker_ownership.py +148 -0
  80. aisc/domain/gateway.py +168 -0
  81. aisc/domain/models.py +674 -0
  82. aisc/domain/toolchain.py +72 -0
  83. aisc/domain/web_services.py +355 -0
  84. aisc/domain/workspace_lease.py +139 -0
  85. aisc/schemas/__init__.py +1 -0
  86. aisc/schemas/config_schema.py +145 -0
  87. aisc_cli-0.1.0.dist-info/METADATA +420 -0
  88. aisc_cli-0.1.0.dist-info/RECORD +92 -0
  89. aisc_cli-0.1.0.dist-info/WHEEL +5 -0
  90. aisc_cli-0.1.0.dist-info/entry_points.txt +2 -0
  91. aisc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  92. 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)