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,1244 @@
|
|
|
1
|
+
"""DockerGateway adapter (Stage 4, DG-01..DG-08).
|
|
2
|
+
|
|
3
|
+
Evolves the CLI-only ``RealDockerExecutor`` into a backend-selecting gateway
|
|
4
|
+
while keeping ``DockerExecutor`` as a compatibility alias for at least one
|
|
5
|
+
release (D4-02). Backend choice (``auto|sdk|cli``) lives entirely inside this
|
|
6
|
+
module — application/domain never branches on it (D4-06).
|
|
7
|
+
|
|
8
|
+
Layer diagram (per ``stage-4-docker-gateway/02-domain-contract.md``):
|
|
9
|
+
|
|
10
|
+
application/domain
|
|
11
|
+
↓ DockerGateway (Protocol)
|
|
12
|
+
AutoGateway → SdkGateway | CliGateway
|
|
13
|
+
↓
|
|
14
|
+
Docker Engine
|
|
15
|
+
|
|
16
|
+
Backends
|
|
17
|
+
--------
|
|
18
|
+
- ``SdkGateway``: docker-py for query (preflight/inspect/list) and lifecycle
|
|
19
|
+
(start/stop/remove/wait). Interactive exec stays SDK-first (D4-03, reuses the
|
|
20
|
+
transport helpers from ``docker_.RealDockerExecutor.open_interactive``).
|
|
21
|
+
- ``CliGateway``: wraps ``RealDockerExecutor`` (argv-only, no ``shell=True``);
|
|
22
|
+
kept as the fallback until the equivalence matrix is complete (D4-08).
|
|
23
|
+
- ``AutoGateway``: selects by capability/feature flag; never leaks the choice.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import socket
|
|
31
|
+
import sys
|
|
32
|
+
import threading
|
|
33
|
+
import time
|
|
34
|
+
import uuid
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from typing import Any, Callable, List, Optional, Protocol, runtime_checkable
|
|
37
|
+
|
|
38
|
+
from aisc.adapters.docker_ import _poll_resize_step
|
|
39
|
+
from aisc.domain.gateway import (
|
|
40
|
+
BuildResult,
|
|
41
|
+
ContainerInspectResult,
|
|
42
|
+
ContainerListResult,
|
|
43
|
+
ContainerSummary,
|
|
44
|
+
GatewayOperation,
|
|
45
|
+
GatewayResult,
|
|
46
|
+
ImageInspectGatewayResult,
|
|
47
|
+
InteractiveResult,
|
|
48
|
+
LifecycleResult,
|
|
49
|
+
PreflightResult,
|
|
50
|
+
)
|
|
51
|
+
from aisc.domain.models import (
|
|
52
|
+
BuildPlan,
|
|
53
|
+
DockerPreflightResult,
|
|
54
|
+
ImageInspectResult,
|
|
55
|
+
ImageInspectStatus,
|
|
56
|
+
ProcessResult,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _poll_env(name: str, default: float, lo: float, hi: float) -> float:
|
|
61
|
+
"""PERF P2 (D-13) tunable poll cadence — env override clamped to a sane
|
|
62
|
+
range (also keeps tests fast without sleeping production intervals)."""
|
|
63
|
+
try:
|
|
64
|
+
v = float(os.environ.get(name, "") or default)
|
|
65
|
+
except ValueError:
|
|
66
|
+
return default
|
|
67
|
+
return min(hi, max(lo, v))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _resize_poll_interval() -> float:
|
|
71
|
+
"""Resize-file poll cadence: default 0.25s (was 0.1s)."""
|
|
72
|
+
return _poll_env("AISC_RESIZE_POLL", 0.25, 0.05, 1.0)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _exec_floor_interval() -> float:
|
|
76
|
+
"""While-stream-open re-inspect floor: default 5s."""
|
|
77
|
+
return _poll_env("AISC_EXEC_FLOOR", 5.0, 0.05, 60.0)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _exec_settle_interval() -> float:
|
|
81
|
+
"""EOF-but-Running corner settle poll: default 1s."""
|
|
82
|
+
return _poll_env("AISC_EXEC_SETTLE", 1.0, 0.05, 10.0)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# Backend identity (adapter-internal; never leaks to application code)
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
BackendName = str # "auto" | "sdk" | "cli"
|
|
90
|
+
|
|
91
|
+
# Stable Docker error codes (D4-07). Prefixed to avoid colliding with AISC_*.
|
|
92
|
+
class DockerErrorCode:
|
|
93
|
+
DAEMON_UNREACHABLE = "DOCKER_ERR_DAEMON_UNREACHABLE"
|
|
94
|
+
CLI_NOT_FOUND = "DOCKER_ERR_CLI_NOT_FOUND"
|
|
95
|
+
PERMISSION_DENIED = "DOCKER_ERR_PERMISSION_DENIED"
|
|
96
|
+
NOT_FOUND = "DOCKER_ERR_NOT_FOUND"
|
|
97
|
+
TIMEOUT = "DOCKER_ERR_TIMEOUT"
|
|
98
|
+
CONFLICT = "DOCKER_ERR_CONFLICT"
|
|
99
|
+
UNKNOWN = "DOCKER_ERR_UNKNOWN"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# Gateway protocol — the single injectable abstraction (DG-01)
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
@runtime_checkable
|
|
107
|
+
class DockerGateway(Protocol):
|
|
108
|
+
"""Structured gateway over the Docker Engine.
|
|
109
|
+
|
|
110
|
+
Every method returns a :class:`GatewayResult` subclass carrying
|
|
111
|
+
``operation_id``, ``backend``, exit code, duration, stable error and
|
|
112
|
+
cleanup status. stdout/stderr are only populated on explicit capture
|
|
113
|
+
request and are bounded.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def backend(self) -> str:
|
|
118
|
+
"""Which backend actually runs (sdk | cli | auto)."""
|
|
119
|
+
...
|
|
120
|
+
|
|
121
|
+
def preflight(self) -> PreflightResult:
|
|
122
|
+
"""Daemon reachability + docker availability."""
|
|
123
|
+
...
|
|
124
|
+
|
|
125
|
+
def inspect_image(self, image_name: str) -> ImageInspectGatewayResult:
|
|
126
|
+
"""Structured local-image inspection."""
|
|
127
|
+
...
|
|
128
|
+
|
|
129
|
+
def list_containers(self, all: bool = False) -> ContainerListResult:
|
|
130
|
+
"""List containers with stable per-row summaries."""
|
|
131
|
+
...
|
|
132
|
+
|
|
133
|
+
def inspect_container(self, container: str) -> ContainerInspectResult:
|
|
134
|
+
"""Inspect one container."""
|
|
135
|
+
...
|
|
136
|
+
|
|
137
|
+
def start_container(self, container: str) -> LifecycleResult:
|
|
138
|
+
"""Start a container."""
|
|
139
|
+
...
|
|
140
|
+
|
|
141
|
+
def stop_container(self, container: str, timeout: int = 10) -> LifecycleResult:
|
|
142
|
+
"""Stop a container with a grace timeout."""
|
|
143
|
+
...
|
|
144
|
+
|
|
145
|
+
def remove_container(self, container: str, force: bool = False) -> LifecycleResult:
|
|
146
|
+
"""Remove a container."""
|
|
147
|
+
...
|
|
148
|
+
|
|
149
|
+
def wait_container(self, container: str, timeout: Optional[float] = None) -> LifecycleResult:
|
|
150
|
+
"""Wait for a container to exit; returns its final state."""
|
|
151
|
+
...
|
|
152
|
+
|
|
153
|
+
def open_interactive(
|
|
154
|
+
self,
|
|
155
|
+
container: str,
|
|
156
|
+
argv: List[str],
|
|
157
|
+
env: Optional[Dict[str, str]] = None,
|
|
158
|
+
) -> InteractiveResult:
|
|
159
|
+
"""Open an interactive exec session (SDK-first, resizable)."""
|
|
160
|
+
...
|
|
161
|
+
|
|
162
|
+
def build_image(
|
|
163
|
+
self,
|
|
164
|
+
plan: BuildPlan,
|
|
165
|
+
on_event: Optional[Callable[[str, str], None]] = None,
|
|
166
|
+
) -> BuildResult:
|
|
167
|
+
"""Build an image; events stream to *on_event* when provided."""
|
|
168
|
+
...
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
# Operation envelope helper
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
def _new_operation(backend: str, **kw) -> GatewayOperation:
|
|
176
|
+
# Allow callers to override duration_ms without a duplicate-kwarg clash.
|
|
177
|
+
return GatewayOperation(
|
|
178
|
+
operation_id=uuid.uuid4().hex[:16],
|
|
179
|
+
backend=backend,
|
|
180
|
+
**kw,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _elapsed(start: float) -> int:
|
|
185
|
+
return int((time.monotonic() - start) * 1000)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _default_client():
|
|
189
|
+
"""2.1.9 hotfix r3 (#61): docker.from_env() with DOCKER_HOST unset reads
|
|
190
|
+
the docker CLI context meta.json WITHOUT an encoding — on zh-CN Windows
|
|
191
|
+
that GBK-decodes Docker Desktop's UTF-8 context file and raises a plain
|
|
192
|
+
Exception ("corrupted meta file"), not a DockerException. Fall back to
|
|
193
|
+
the platform DEFAULT endpoint (explicit base_url never reads meta.json).
|
|
194
|
+
Mirror of RealDockerExecutor._client_from_env_safe."""
|
|
195
|
+
import docker
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
return docker.from_env()
|
|
199
|
+
except Exception: # noqa: BLE001 — context meta unreadable
|
|
200
|
+
import os as _os
|
|
201
|
+
|
|
202
|
+
if _os.name == "nt":
|
|
203
|
+
return docker.DockerClient(base_url="npipe:////./pipe/docker_engine")
|
|
204
|
+
return docker.DockerClient(base_url="unix:///var/run/docker.sock")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# 2.1.10 R2 (D-8): live exec TTY as a stream handle — serve PTY transport
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _sock_recv(sock: Any, size: int) -> bytes:
|
|
213
|
+
"""Raw read from a docker-py exec socket (recv | read | os.read)."""
|
|
214
|
+
if hasattr(sock, "recv"):
|
|
215
|
+
return sock.recv(size)
|
|
216
|
+
if hasattr(sock, "read"):
|
|
217
|
+
return sock.read(size)
|
|
218
|
+
return os.read(sock.fileno(), size)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _sock_send_all(sock: Any, data: bytes) -> None:
|
|
222
|
+
"""Send every byte (sendall | _sock.sendall | write | os.write)."""
|
|
223
|
+
if hasattr(sock, "sendall"):
|
|
224
|
+
sock.sendall(data)
|
|
225
|
+
return
|
|
226
|
+
raw = getattr(sock, "_sock", None)
|
|
227
|
+
if raw is not None and hasattr(raw, "sendall"):
|
|
228
|
+
raw.sendall(data)
|
|
229
|
+
return
|
|
230
|
+
view = memoryview(data)
|
|
231
|
+
while view:
|
|
232
|
+
if hasattr(sock, "write") and getattr(sock, "writable", lambda: False)():
|
|
233
|
+
sent = sock.write(view)
|
|
234
|
+
else:
|
|
235
|
+
sent = os.write(sock.fileno(), view)
|
|
236
|
+
if sent is None:
|
|
237
|
+
raise OSError("socket write would block")
|
|
238
|
+
if sent <= 0:
|
|
239
|
+
raise OSError("socket write failed")
|
|
240
|
+
view = view[sent:]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _sock_shutdown_write(sock: Any) -> None:
|
|
244
|
+
"""Half-close the write side on stdin EOF (shutdown | _sock.shutdown)."""
|
|
245
|
+
raw = getattr(sock, "_sock", None)
|
|
246
|
+
targets = [raw, sock] if raw is not None else [sock]
|
|
247
|
+
for target in targets:
|
|
248
|
+
if hasattr(target, "shutdown"):
|
|
249
|
+
try:
|
|
250
|
+
target.shutdown(socket.SHUT_WR)
|
|
251
|
+
return
|
|
252
|
+
except OSError:
|
|
253
|
+
pass
|
|
254
|
+
try:
|
|
255
|
+
sock.close()
|
|
256
|
+
except OSError:
|
|
257
|
+
pass
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class InteractiveStreamHandle:
|
|
261
|
+
"""One live docker exec TTY: raw byte bidirectional plane + resize.
|
|
262
|
+
|
|
263
|
+
Produced by ``_open_sdk_pty_stream`` (SDK only — the CLI backend cannot
|
|
264
|
+
resize an exec pty, which is why G-02 exists). The pump thread invokes
|
|
265
|
+
``on_output`` from its own thread; everything else is called from the
|
|
266
|
+
owning thread. Exit detection is the P2 EOF-driven scheme verbatim.
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
def __init__(self, api: Any, exec_id: str, sock: Any,
|
|
270
|
+
on_output: Optional[Callable[[bytes], None]]) -> None:
|
|
271
|
+
import threading as _threading
|
|
272
|
+
|
|
273
|
+
self._api = api
|
|
274
|
+
self._exec_id = exec_id
|
|
275
|
+
self._sock = sock
|
|
276
|
+
self._on_output = on_output or (lambda chunk: None)
|
|
277
|
+
self._eof = _threading.Event()
|
|
278
|
+
self._stopped = _threading.Event()
|
|
279
|
+
self._error: Optional[Exception] = None
|
|
280
|
+
self._exit_code: Optional[int] = None
|
|
281
|
+
self._pump = _threading.Thread(target=self._pump_loop, daemon=True)
|
|
282
|
+
self._pump.start()
|
|
283
|
+
|
|
284
|
+
# -- byte plane ----------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
def _pump_loop(self) -> None:
|
|
287
|
+
try:
|
|
288
|
+
while not self._stopped.is_set():
|
|
289
|
+
chunk = _sock_recv(self._sock, 65536)
|
|
290
|
+
if not chunk:
|
|
291
|
+
break
|
|
292
|
+
self._on_output(chunk)
|
|
293
|
+
except Exception as exc: # noqa: BLE001 — surfaced via .error
|
|
294
|
+
self._error = exc
|
|
295
|
+
finally:
|
|
296
|
+
self._eof.set()
|
|
297
|
+
|
|
298
|
+
def write(self, data: bytes) -> None:
|
|
299
|
+
"""stdin → socket. Raises OSError-family on a dead stream."""
|
|
300
|
+
_sock_send_all(self._sock, data)
|
|
301
|
+
|
|
302
|
+
def resize(self, size: tuple) -> None:
|
|
303
|
+
"""(cols, rows) → exec_resize."""
|
|
304
|
+
self._api.exec_resize(self._exec_id, height=size[1], width=size[0])
|
|
305
|
+
|
|
306
|
+
def close_stdin(self) -> None:
|
|
307
|
+
_sock_shutdown_write(self._sock)
|
|
308
|
+
|
|
309
|
+
def kill(self) -> None:
|
|
310
|
+
"""Detach: close the socket (pump hits EOF and settles) — the exec
|
|
311
|
+
process itself keeps running in the container, the same semantics as
|
|
312
|
+
killing the pipe-mode sidecar process. ``wait_exit`` observes the
|
|
313
|
+
detach and returns instead of polling a Running exec forever."""
|
|
314
|
+
self._stopped.set()
|
|
315
|
+
try:
|
|
316
|
+
self._sock.close()
|
|
317
|
+
except OSError:
|
|
318
|
+
pass
|
|
319
|
+
|
|
320
|
+
# -- exit ---------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
@property
|
|
323
|
+
def error(self) -> Optional[Exception]:
|
|
324
|
+
return self._error
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def exit_code(self) -> Optional[int]:
|
|
328
|
+
return self._exit_code
|
|
329
|
+
|
|
330
|
+
def wait_exit(self) -> int:
|
|
331
|
+
"""Block until the exec settles; returns its exit code. The P2
|
|
332
|
+
EOF-driven scheme verbatim: EOF → ONE inspect; no EOF → floor
|
|
333
|
+
re-inspect; EOF-but-Running → settle loop. Unbounded, like the
|
|
334
|
+
open_interactive contract (call from a dedicated thread)."""
|
|
335
|
+
import docker
|
|
336
|
+
import requests
|
|
337
|
+
|
|
338
|
+
failures = 0
|
|
339
|
+
|
|
340
|
+
def inspect_info() -> dict:
|
|
341
|
+
"""exec_inspect with the #61 transient-failure tolerance."""
|
|
342
|
+
nonlocal failures
|
|
343
|
+
while True:
|
|
344
|
+
try:
|
|
345
|
+
info = self._api.exec_inspect(self._exec_id)
|
|
346
|
+
failures = 0
|
|
347
|
+
return info
|
|
348
|
+
except (docker.errors.APIError, requests.RequestException, OSError):
|
|
349
|
+
failures += 1
|
|
350
|
+
if failures >= 3:
|
|
351
|
+
raise
|
|
352
|
+
time.sleep(0.5)
|
|
353
|
+
|
|
354
|
+
floor = _exec_floor_interval()
|
|
355
|
+
settle = _exec_settle_interval()
|
|
356
|
+
info: Optional[dict] = None
|
|
357
|
+
while info is None:
|
|
358
|
+
if self._stopped.is_set():
|
|
359
|
+
# Client-detached (kill): the exec keeps running in the
|
|
360
|
+
# container; report the detach as an unknown exit instead of
|
|
361
|
+
# settling forever on a Running inspect.
|
|
362
|
+
self._exit_code = -1
|
|
363
|
+
return self._exit_code
|
|
364
|
+
if not self._eof.is_set():
|
|
365
|
+
info = inspect_info()
|
|
366
|
+
if info.get("Running"):
|
|
367
|
+
info = None
|
|
368
|
+
self._eof.wait(timeout=floor)
|
|
369
|
+
else:
|
|
370
|
+
info = inspect_info()
|
|
371
|
+
if info.get("Running"):
|
|
372
|
+
info = None
|
|
373
|
+
time.sleep(settle)
|
|
374
|
+
self._exit_code = int(info.get("ExitCode", 0))
|
|
375
|
+
return self._exit_code
|
|
376
|
+
|
|
377
|
+
def join_pump(self, timeout: float = 5.0) -> None:
|
|
378
|
+
self._pump.join(timeout=timeout)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _open_sdk_pty_stream(
|
|
382
|
+
client: Any,
|
|
383
|
+
container: str,
|
|
384
|
+
argv: List[str],
|
|
385
|
+
env: Optional[Dict[str, str]],
|
|
386
|
+
on_output: Optional[Callable[[bytes], None]],
|
|
387
|
+
initial_size: Optional[tuple],
|
|
388
|
+
) -> InteractiveStreamHandle:
|
|
389
|
+
"""exec_create → exec_start(socket) → InteractiveStreamHandle. Raises
|
|
390
|
+
the same DockerException family open_interactive's create/start legs
|
|
391
|
+
map (the caller decides the error shape)."""
|
|
392
|
+
exec_kwargs: Dict[str, Any] = {"tty": True, "stdin": True}
|
|
393
|
+
if env:
|
|
394
|
+
exec_kwargs["environment"] = dict(env)
|
|
395
|
+
exec_id = client.api.exec_create(container, list(argv), **exec_kwargs)["Id"]
|
|
396
|
+
sock = client.api.exec_start(exec_id, socket=True, tty=True)
|
|
397
|
+
handle = InteractiveStreamHandle(client.api, exec_id, sock, on_output)
|
|
398
|
+
if initial_size:
|
|
399
|
+
try:
|
|
400
|
+
handle.resize(initial_size)
|
|
401
|
+
except Exception: # noqa: BLE001 — best-effort initial size, as ever
|
|
402
|
+
pass
|
|
403
|
+
return handle
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ---------------------------------------------------------------------------
|
|
407
|
+
# SDK backend — docker-py (query + lifecycle)
|
|
408
|
+
# ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
@dataclass
|
|
411
|
+
class SdkGateway:
|
|
412
|
+
"""docker-py backed gateway (D4-03/D4-04, query+lifecycle first).
|
|
413
|
+
|
|
414
|
+
``client`` may be injected for tests (Fake/recording); when ``None`` the
|
|
415
|
+
gateway lazily creates ``docker.from_env()``.
|
|
416
|
+
"""
|
|
417
|
+
|
|
418
|
+
client: object = None # docker.DockerClient (duck-typed to keep SDK optional)
|
|
419
|
+
timeout: float = 10.0
|
|
420
|
+
# 2.1.9 hotfix r3: the default_factory CALLS _default_client() at
|
|
421
|
+
# construction — eager docker.from_env() (a zh-CN GBK landmine) and a
|
|
422
|
+
# client instance where _client() expects a callable. A plain function
|
|
423
|
+
# default keeps the field lazy AND callable.
|
|
424
|
+
_client_factory: Callable[[], object] = _default_client
|
|
425
|
+
|
|
426
|
+
@property
|
|
427
|
+
def backend(self) -> str:
|
|
428
|
+
return "sdk"
|
|
429
|
+
|
|
430
|
+
def _client(self):
|
|
431
|
+
if self.client is None:
|
|
432
|
+
self.client = self._client_factory()
|
|
433
|
+
return self.client
|
|
434
|
+
|
|
435
|
+
# -- helpers ----------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
def _op(self, **kw) -> GatewayOperation:
|
|
438
|
+
return _new_operation("sdk", **kw)
|
|
439
|
+
|
|
440
|
+
# -- query ------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
def preflight(self) -> PreflightResult:
|
|
443
|
+
import docker # lazy: keep the module importable without the SDK
|
|
444
|
+
start = time.monotonic()
|
|
445
|
+
try:
|
|
446
|
+
client = self._client()
|
|
447
|
+
version = client.version() # raises if daemon unreachable
|
|
448
|
+
return PreflightResult(
|
|
449
|
+
operation=_new_operation(
|
|
450
|
+
"sdk", exit_code=0, duration_ms=_elapsed(start), cleanup_status="ok",
|
|
451
|
+
),
|
|
452
|
+
available=True,
|
|
453
|
+
reason="ok",
|
|
454
|
+
docker_version=str(version.get("Version", "")),
|
|
455
|
+
engine_ok=True,
|
|
456
|
+
)
|
|
457
|
+
except docker.errors.DockerException as exc:
|
|
458
|
+
return PreflightResult(
|
|
459
|
+
operation=_new_operation(
|
|
460
|
+
"sdk", exit_code=3, duration_ms=_elapsed(start),
|
|
461
|
+
error_code=DockerErrorCode.DAEMON_UNREACHABLE,
|
|
462
|
+
error_message=str(exc), cleanup_status="ok",
|
|
463
|
+
),
|
|
464
|
+
available=False,
|
|
465
|
+
reason="daemon_unreachable",
|
|
466
|
+
)
|
|
467
|
+
except Exception as exc: # noqa: BLE001
|
|
468
|
+
return PreflightResult(
|
|
469
|
+
operation=_new_operation(
|
|
470
|
+
"sdk", exit_code=3, duration_ms=_elapsed(start),
|
|
471
|
+
error_code=DockerErrorCode.UNKNOWN,
|
|
472
|
+
error_message=str(exc), cleanup_status="ok",
|
|
473
|
+
),
|
|
474
|
+
available=False,
|
|
475
|
+
reason="daemon_unreachable",
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
def inspect_image(self, image_name: str) -> ImageInspectGatewayResult:
|
|
479
|
+
import docker
|
|
480
|
+
start = time.monotonic()
|
|
481
|
+
try:
|
|
482
|
+
client = self._client()
|
|
483
|
+
client.images.get(image_name)
|
|
484
|
+
return ImageInspectGatewayResult(
|
|
485
|
+
operation=_new_operation("sdk", exit_code=0, duration_ms=_elapsed(start)),
|
|
486
|
+
status=ImageInspectStatus.EXISTS,
|
|
487
|
+
image=image_name,
|
|
488
|
+
message="",
|
|
489
|
+
)
|
|
490
|
+
except docker.errors.ImageNotFound:
|
|
491
|
+
return ImageInspectGatewayResult(
|
|
492
|
+
operation=_new_operation("sdk", exit_code=5, duration_ms=_elapsed(start)),
|
|
493
|
+
status=ImageInspectStatus.MISSING,
|
|
494
|
+
image=image_name,
|
|
495
|
+
message="image not found locally",
|
|
496
|
+
)
|
|
497
|
+
except docker.errors.DockerException as exc:
|
|
498
|
+
return ImageInspectGatewayResult(
|
|
499
|
+
operation=_new_operation(
|
|
500
|
+
"sdk", exit_code=3, duration_ms=_elapsed(start),
|
|
501
|
+
error_code=DockerErrorCode.DAEMON_UNREACHABLE,
|
|
502
|
+
error_message=str(exc),
|
|
503
|
+
),
|
|
504
|
+
status=ImageInspectStatus.DOCKER_UNAVAILABLE,
|
|
505
|
+
image=image_name,
|
|
506
|
+
message=str(exc),
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
def list_containers(self, all: bool = False) -> ContainerListResult:
|
|
510
|
+
import docker
|
|
511
|
+
start = time.monotonic()
|
|
512
|
+
try:
|
|
513
|
+
client = self._client()
|
|
514
|
+
rows = client.containers.list(all=all)
|
|
515
|
+
summaries = [
|
|
516
|
+
ContainerSummary(
|
|
517
|
+
id=c.id[:12],
|
|
518
|
+
name=(c.name or ""),
|
|
519
|
+
image=(c.image.tags[0] if getattr(c, "image", None) and getattr(c.image, "tags", None) else ""),
|
|
520
|
+
state=(c.status or ""),
|
|
521
|
+
status=(c.attrs.get("State", {}).get("Status", "") or (c.status or "")),
|
|
522
|
+
labels=(c.attrs.get("Config", {}).get("Labels", {}) or {}),
|
|
523
|
+
# Full sha256 image ID ships with the list attrs — no
|
|
524
|
+
# extra API round-trip (A0: structured image ID).
|
|
525
|
+
image_id=(c.attrs.get("Image") or ""),
|
|
526
|
+
)
|
|
527
|
+
for c in rows
|
|
528
|
+
]
|
|
529
|
+
return ContainerListResult(
|
|
530
|
+
operation=_new_operation("sdk", exit_code=0, duration_ms=_elapsed(start)),
|
|
531
|
+
containers=summaries,
|
|
532
|
+
)
|
|
533
|
+
except docker.errors.DockerException as exc:
|
|
534
|
+
return ContainerListResult(
|
|
535
|
+
operation=_new_operation(
|
|
536
|
+
"sdk", exit_code=3, duration_ms=_elapsed(start),
|
|
537
|
+
error_code=DockerErrorCode.DAEMON_UNREACHABLE,
|
|
538
|
+
error_message=str(exc),
|
|
539
|
+
),
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
def inspect_container(self, container: str) -> ContainerInspectResult:
|
|
543
|
+
import docker
|
|
544
|
+
start = time.monotonic()
|
|
545
|
+
try:
|
|
546
|
+
client = self._client()
|
|
547
|
+
c = client.containers.get(container)
|
|
548
|
+
attrs = c.attrs
|
|
549
|
+
state = attrs.get("State", {}) or {}
|
|
550
|
+
config = attrs.get("Config", {}) or {}
|
|
551
|
+
return ContainerInspectResult(
|
|
552
|
+
operation=_new_operation("sdk", exit_code=0, duration_ms=_elapsed(start)),
|
|
553
|
+
container_id=c.id,
|
|
554
|
+
name=(c.name or ""),
|
|
555
|
+
state=state.get("Status", ""),
|
|
556
|
+
image=attrs.get("Config", {}).get("Image", "") or "",
|
|
557
|
+
labels=config.get("Labels", {}) or {},
|
|
558
|
+
config=config,
|
|
559
|
+
# `.Image` in inspect attrs = content-addressed ID, distinct
|
|
560
|
+
# from the Config.Image REF above (A0: structured image ID).
|
|
561
|
+
image_id=(attrs.get("Image") or ""),
|
|
562
|
+
)
|
|
563
|
+
except docker.errors.NotFound:
|
|
564
|
+
return ContainerInspectResult(
|
|
565
|
+
operation=_new_operation(
|
|
566
|
+
"sdk", exit_code=1, duration_ms=_elapsed(start),
|
|
567
|
+
error_code=DockerErrorCode.NOT_FOUND,
|
|
568
|
+
error_message=f"container {container} not found",
|
|
569
|
+
),
|
|
570
|
+
)
|
|
571
|
+
except docker.errors.DockerException as exc:
|
|
572
|
+
return ContainerInspectResult(
|
|
573
|
+
operation=_new_operation(
|
|
574
|
+
"sdk", exit_code=3, duration_ms=_elapsed(start),
|
|
575
|
+
error_code=DockerErrorCode.DAEMON_UNREACHABLE,
|
|
576
|
+
error_message=str(exc),
|
|
577
|
+
),
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
# -- lifecycle --------------------------------------------------------
|
|
581
|
+
|
|
582
|
+
def start_container(self, container: str) -> LifecycleResult:
|
|
583
|
+
import docker
|
|
584
|
+
start = time.monotonic()
|
|
585
|
+
try:
|
|
586
|
+
client = self._client()
|
|
587
|
+
c = client.containers.get(container)
|
|
588
|
+
c.start()
|
|
589
|
+
return LifecycleResult(
|
|
590
|
+
operation=_new_operation("sdk", exit_code=0, duration_ms=_elapsed(start)),
|
|
591
|
+
target=container,
|
|
592
|
+
observed_state="running",
|
|
593
|
+
container_id=c.id,
|
|
594
|
+
)
|
|
595
|
+
except docker.errors.NotFound:
|
|
596
|
+
return LifecycleResult(
|
|
597
|
+
operation=_new_operation(
|
|
598
|
+
"sdk", exit_code=1, duration_ms=_elapsed(start),
|
|
599
|
+
error_code=DockerErrorCode.NOT_FOUND,
|
|
600
|
+
error_message=f"container {container} not found",
|
|
601
|
+
),
|
|
602
|
+
target=container,
|
|
603
|
+
)
|
|
604
|
+
except docker.errors.DockerException as exc:
|
|
605
|
+
return LifecycleResult(
|
|
606
|
+
operation=_new_operation(
|
|
607
|
+
"sdk", exit_code=3, duration_ms=_elapsed(start),
|
|
608
|
+
error_code=DockerErrorCode.DAEMON_UNREACHABLE,
|
|
609
|
+
error_message=str(exc),
|
|
610
|
+
),
|
|
611
|
+
target=container,
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
def stop_container(self, container: str, timeout: int = 10) -> LifecycleResult:
|
|
615
|
+
import docker
|
|
616
|
+
start = time.monotonic()
|
|
617
|
+
try:
|
|
618
|
+
client = self._client()
|
|
619
|
+
c = client.containers.get(container)
|
|
620
|
+
c.stop(timeout=timeout)
|
|
621
|
+
return LifecycleResult(
|
|
622
|
+
operation=_new_operation("sdk", exit_code=0, duration_ms=_elapsed(start)),
|
|
623
|
+
target=container,
|
|
624
|
+
observed_state="stopped",
|
|
625
|
+
container_id=c.id,
|
|
626
|
+
)
|
|
627
|
+
except docker.errors.NotFound:
|
|
628
|
+
return LifecycleResult(
|
|
629
|
+
operation=_new_operation(
|
|
630
|
+
"sdk", exit_code=1, duration_ms=_elapsed(start),
|
|
631
|
+
error_code=DockerErrorCode.NOT_FOUND,
|
|
632
|
+
error_message=f"container {container} not found",
|
|
633
|
+
),
|
|
634
|
+
target=container,
|
|
635
|
+
)
|
|
636
|
+
except docker.errors.DockerException as exc:
|
|
637
|
+
return LifecycleResult(
|
|
638
|
+
operation=_new_operation(
|
|
639
|
+
"sdk", exit_code=3, duration_ms=_elapsed(start),
|
|
640
|
+
error_code=DockerErrorCode.DAEMON_UNREACHABLE,
|
|
641
|
+
error_message=str(exc),
|
|
642
|
+
),
|
|
643
|
+
target=container,
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
def remove_container(self, container: str, force: bool = False) -> LifecycleResult:
|
|
647
|
+
import docker
|
|
648
|
+
start = time.monotonic()
|
|
649
|
+
try:
|
|
650
|
+
client = self._client()
|
|
651
|
+
c = client.containers.get(container)
|
|
652
|
+
c.remove(force=force)
|
|
653
|
+
return LifecycleResult(
|
|
654
|
+
operation=_new_operation("sdk", exit_code=0, duration_ms=_elapsed(start)),
|
|
655
|
+
target=container,
|
|
656
|
+
observed_state="removed",
|
|
657
|
+
container_id=c.id,
|
|
658
|
+
)
|
|
659
|
+
except docker.errors.NotFound:
|
|
660
|
+
# Already absent is a successful remove (idempotent).
|
|
661
|
+
return LifecycleResult(
|
|
662
|
+
operation=_new_operation("sdk", exit_code=0, duration_ms=_elapsed(start)),
|
|
663
|
+
target=container,
|
|
664
|
+
observed_state="removed",
|
|
665
|
+
)
|
|
666
|
+
except docker.errors.DockerException as exc:
|
|
667
|
+
return LifecycleResult(
|
|
668
|
+
operation=_new_operation(
|
|
669
|
+
"sdk", exit_code=3, duration_ms=_elapsed(start),
|
|
670
|
+
error_code=DockerErrorCode.DAEMON_UNREACHABLE,
|
|
671
|
+
error_message=str(exc),
|
|
672
|
+
),
|
|
673
|
+
target=container,
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
def wait_container(self, container: str, timeout: Optional[float] = None) -> LifecycleResult:
|
|
677
|
+
import docker
|
|
678
|
+
start = time.monotonic()
|
|
679
|
+
try:
|
|
680
|
+
client = self._client()
|
|
681
|
+
c = client.containers.get(container)
|
|
682
|
+
try:
|
|
683
|
+
c.wait(timeout=timeout)
|
|
684
|
+
except Exception as exc: # noqa: BLE001
|
|
685
|
+
# docker-py surfaces wait timeout as requests.ReadTimeout (not a
|
|
686
|
+
# DockerException); classify by class name so the deadline path
|
|
687
|
+
# returns a stable TIMEOUT result instead of escaping.
|
|
688
|
+
if "ReadTimeout" in type(exc).__name__ or "Timeout" in type(exc).__name__:
|
|
689
|
+
return LifecycleResult(
|
|
690
|
+
operation=_new_operation(
|
|
691
|
+
"sdk", exit_code=1, duration_ms=_elapsed(start),
|
|
692
|
+
error_code=DockerErrorCode.TIMEOUT,
|
|
693
|
+
error_message=f"wait timed out after {timeout}s",
|
|
694
|
+
timed_out=True,
|
|
695
|
+
),
|
|
696
|
+
target=container,
|
|
697
|
+
)
|
|
698
|
+
if isinstance(exc, docker.errors.DockerException):
|
|
699
|
+
raise
|
|
700
|
+
return LifecycleResult(
|
|
701
|
+
operation=_new_operation(
|
|
702
|
+
"sdk", exit_code=3, duration_ms=_elapsed(start),
|
|
703
|
+
error_code=DockerErrorCode.UNKNOWN,
|
|
704
|
+
error_message=str(exc),
|
|
705
|
+
),
|
|
706
|
+
target=container,
|
|
707
|
+
)
|
|
708
|
+
try:
|
|
709
|
+
c.reload()
|
|
710
|
+
except docker.errors.NotFound:
|
|
711
|
+
# Container removed between wait and reload: report exited.
|
|
712
|
+
return LifecycleResult(
|
|
713
|
+
operation=_new_operation("sdk", exit_code=0, duration_ms=_elapsed(start)),
|
|
714
|
+
target=container,
|
|
715
|
+
observed_state="exited",
|
|
716
|
+
)
|
|
717
|
+
return LifecycleResult(
|
|
718
|
+
operation=_new_operation("sdk", exit_code=0, duration_ms=_elapsed(start)),
|
|
719
|
+
target=container,
|
|
720
|
+
observed_state=((c.attrs.get("State", {}) or {}).get("Status", "exited")),
|
|
721
|
+
container_id=c.id,
|
|
722
|
+
)
|
|
723
|
+
except docker.errors.NotFound:
|
|
724
|
+
return LifecycleResult(
|
|
725
|
+
operation=_new_operation(
|
|
726
|
+
"sdk", exit_code=1, duration_ms=_elapsed(start),
|
|
727
|
+
error_code=DockerErrorCode.NOT_FOUND,
|
|
728
|
+
error_message=f"container {container} not found",
|
|
729
|
+
),
|
|
730
|
+
target=container,
|
|
731
|
+
)
|
|
732
|
+
except docker.errors.DockerException as exc:
|
|
733
|
+
return LifecycleResult(
|
|
734
|
+
operation=_new_operation(
|
|
735
|
+
"sdk", exit_code=3, duration_ms=_elapsed(start),
|
|
736
|
+
error_code=DockerErrorCode.DAEMON_UNREACHABLE,
|
|
737
|
+
error_message=str(exc),
|
|
738
|
+
),
|
|
739
|
+
target=container,
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
# -- interactive (SDK-first; D4-03 owns create/start/resize/stream/reap) --
|
|
743
|
+
|
|
744
|
+
def open_pty_stream(
|
|
745
|
+
self,
|
|
746
|
+
container: str,
|
|
747
|
+
argv: List[str],
|
|
748
|
+
env: Optional[Dict[str, str]] = None,
|
|
749
|
+
on_output: Optional[Callable[[bytes], None]] = None,
|
|
750
|
+
initial_size: Optional[tuple] = None,
|
|
751
|
+
) -> "InteractiveStreamHandle":
|
|
752
|
+
"""2.1.10 R2 (D-8): a LIVE exec TTY as a stream handle — the serve
|
|
753
|
+
PTY transport. Same lifecycle as ``open_interactive`` (create →
|
|
754
|
+
socket → resize via API) but the byte plane is a callback instead of
|
|
755
|
+
fd 0/1, resize is a method call instead of the resize file, and the
|
|
756
|
+
caller decides when to stop. ``open_interactive`` is now assembled
|
|
757
|
+
from this handle (zero behavior change there).
|
|
758
|
+
|
|
759
|
+
``on_output`` is invoked from the pump thread; it must be cheap and
|
|
760
|
+
thread-safe. ``initial_size`` (cols, rows) replaces the resize file's
|
|
761
|
+
spawn-time read.
|
|
762
|
+
"""
|
|
763
|
+
return _open_sdk_pty_stream(
|
|
764
|
+
self._client(), container, argv, env, on_output, initial_size
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
def open_interactive(
|
|
768
|
+
self,
|
|
769
|
+
container: str,
|
|
770
|
+
argv: List[str],
|
|
771
|
+
env: Optional[Dict[str, str]] = None,
|
|
772
|
+
) -> InteractiveResult:
|
|
773
|
+
"""Interactive TTY session via the Docker SDK, using the injected client.
|
|
774
|
+
|
|
775
|
+
Lifecycle (D4-03/A-DG04-1): ``exec_create`` → ``exec_start`` (socket) →
|
|
776
|
+
terminal-size watcher forwards ``exec_resize`` → raw stdout/stdin stream →
|
|
777
|
+
``exec_inspect`` poll for exit → join/reap all threads. No resource is
|
|
778
|
+
left behind on error: the stop event is always set and threads joined.
|
|
779
|
+
|
|
780
|
+
v2.1.7 S6: *env* rides the exec environment only (never the image or a
|
|
781
|
+
profile) — the bash tutorial ``help`` function enters sessions this way.
|
|
782
|
+
"""
|
|
783
|
+
import docker
|
|
784
|
+
import requests # 2.1.9 hotfix (#61): transport failures are not DockerExceptions
|
|
785
|
+
|
|
786
|
+
start = time.monotonic()
|
|
787
|
+
try:
|
|
788
|
+
client = self._client()
|
|
789
|
+
exec_kwargs: Dict[str, Any] = {"tty": True, "stdin": True}
|
|
790
|
+
if env:
|
|
791
|
+
exec_kwargs["environment"] = dict(env)
|
|
792
|
+
exec_id = client.api.exec_create(container, list(argv), **exec_kwargs)["Id"]
|
|
793
|
+
except docker.errors.NotFound:
|
|
794
|
+
return InteractiveResult(
|
|
795
|
+
operation=_new_operation(
|
|
796
|
+
"sdk", exit_code=1, duration_ms=_elapsed(start),
|
|
797
|
+
error_code=DockerErrorCode.NOT_FOUND,
|
|
798
|
+
error_message=f"container {container} not found",
|
|
799
|
+
),
|
|
800
|
+
session_id="",
|
|
801
|
+
)
|
|
802
|
+
except docker.errors.DockerException as exc:
|
|
803
|
+
return InteractiveResult(
|
|
804
|
+
operation=_new_operation(
|
|
805
|
+
"sdk", exit_code=3, duration_ms=_elapsed(start),
|
|
806
|
+
error_code=DockerErrorCode.DAEMON_UNREACHABLE,
|
|
807
|
+
error_message=str(exc),
|
|
808
|
+
),
|
|
809
|
+
session_id="",
|
|
810
|
+
)
|
|
811
|
+
except (requests.RequestException, OSError) as exc:
|
|
812
|
+
return InteractiveResult(
|
|
813
|
+
operation=_new_operation(
|
|
814
|
+
"sdk", exit_code=3, duration_ms=_elapsed(start),
|
|
815
|
+
error_code=DockerErrorCode.DAEMON_UNREACHABLE,
|
|
816
|
+
error_message=f"exec create transport failure: {exc}",
|
|
817
|
+
),
|
|
818
|
+
session_id="",
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
try:
|
|
822
|
+
sock = client.api.exec_start(exec_id, socket=True, tty=True)
|
|
823
|
+
except (docker.errors.DockerException, requests.RequestException, OSError) as exc:
|
|
824
|
+
return InteractiveResult(
|
|
825
|
+
operation=_new_operation(
|
|
826
|
+
"sdk", exit_code=3, duration_ms=_elapsed(start),
|
|
827
|
+
error_code=DockerErrorCode.UNKNOWN,
|
|
828
|
+
error_message=f"exec start failed: {exc}",
|
|
829
|
+
),
|
|
830
|
+
session_id=exec_id,
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
stop = threading.Event()
|
|
834
|
+
errors: List[Exception] = []
|
|
835
|
+
|
|
836
|
+
# Initial resize from the resize file (set by the pty supervisor before
|
|
837
|
+
# spawn; AISC_RESIZE_FILE = "<cols> <rows>\n").
|
|
838
|
+
resize_file = os.environ.get("AISC_RESIZE_FILE")
|
|
839
|
+
initial_size: Optional[tuple] = None
|
|
840
|
+
if resize_file:
|
|
841
|
+
try:
|
|
842
|
+
content = open(resize_file).read().strip().split()
|
|
843
|
+
if len(content) == 2:
|
|
844
|
+
initial_size = (int(content[0]), int(content[1]))
|
|
845
|
+
except Exception: # noqa: BLE001
|
|
846
|
+
pass
|
|
847
|
+
|
|
848
|
+
# 2.1.10 R2 (D-8): the live stream is ONE handle now — the raw byte
|
|
849
|
+
# plane, the EOF-driven exit scheme (PERF P2) and the #61 inspect
|
|
850
|
+
# tolerance all live in InteractiveStreamHandle; this method only
|
|
851
|
+
# adapts fd 0/1 and the resize file around it. (The AISC_EXEC_POLL=
|
|
852
|
+
# legacy escape hatch retired here — P2 said "kept one version" and
|
|
853
|
+
# two have passed.)
|
|
854
|
+
def on_output(chunk: bytes) -> None:
|
|
855
|
+
os.write(sys.stdout.fileno(), chunk)
|
|
856
|
+
|
|
857
|
+
handle = InteractiveStreamHandle(client.api, exec_id, sock, on_output)
|
|
858
|
+
if initial_size:
|
|
859
|
+
try:
|
|
860
|
+
handle.resize(initial_size)
|
|
861
|
+
except Exception: # noqa: BLE001 — best-effort initial size, as ever
|
|
862
|
+
pass
|
|
863
|
+
|
|
864
|
+
def forward() -> None:
|
|
865
|
+
"""stdin → socket; on EOF close the write side."""
|
|
866
|
+
try:
|
|
867
|
+
while True:
|
|
868
|
+
chunk = os.read(sys.stdin.fileno(), 4096)
|
|
869
|
+
if not chunk:
|
|
870
|
+
break
|
|
871
|
+
handle.write(chunk)
|
|
872
|
+
except Exception as exc: # noqa: BLE001
|
|
873
|
+
errors.append(exc)
|
|
874
|
+
finally:
|
|
875
|
+
handle.close_stdin()
|
|
876
|
+
|
|
877
|
+
def watch_resize() -> None:
|
|
878
|
+
"""Poll the resize file; forward changes to exec_resize."""
|
|
879
|
+
if not resize_file:
|
|
880
|
+
return
|
|
881
|
+
# Local copy + module-level step helper: immune to the closure
|
|
882
|
+
# trap that previously dropped every post-initial resize (B-05).
|
|
883
|
+
# PERF P2: 0.25s cadence (was 0.1s) — inside the 200-300ms resize
|
|
884
|
+
# perception threshold, pane dragging stays smooth; ~10 file
|
|
885
|
+
# reads/sec per session was idle-CPU tax on weak machines.
|
|
886
|
+
interval = _resize_poll_interval()
|
|
887
|
+
last = initial_size
|
|
888
|
+
while not stop.is_set():
|
|
889
|
+
last = _poll_resize_step(
|
|
890
|
+
resize_file,
|
|
891
|
+
last,
|
|
892
|
+
handle.resize,
|
|
893
|
+
)
|
|
894
|
+
stop.wait(interval)
|
|
895
|
+
|
|
896
|
+
t_fwd = threading.Thread(target=forward, daemon=True)
|
|
897
|
+
t_resize = threading.Thread(target=watch_resize, daemon=True)
|
|
898
|
+
t_fwd.start()
|
|
899
|
+
t_resize.start()
|
|
900
|
+
|
|
901
|
+
exit_code = -1
|
|
902
|
+
waited = False
|
|
903
|
+
try:
|
|
904
|
+
exit_code = handle.wait_exit()
|
|
905
|
+
waited = True
|
|
906
|
+
except (docker.errors.APIError, requests.RequestException, OSError) as exc:
|
|
907
|
+
errors.append(exc)
|
|
908
|
+
finally:
|
|
909
|
+
stop.set()
|
|
910
|
+
handle.join_pump(timeout=5)
|
|
911
|
+
t_fwd.join(timeout=5)
|
|
912
|
+
t_resize.join(timeout=5)
|
|
913
|
+
|
|
914
|
+
if handle.error is not None:
|
|
915
|
+
errors.append(handle.error)
|
|
916
|
+
if errors:
|
|
917
|
+
return InteractiveResult(
|
|
918
|
+
operation=_new_operation(
|
|
919
|
+
"sdk", exit_code=-1, duration_ms=_elapsed(start),
|
|
920
|
+
error_code=DockerErrorCode.UNKNOWN,
|
|
921
|
+
error_message=f"exec stream error: {errors[0]}",
|
|
922
|
+
),
|
|
923
|
+
session_id=exec_id,
|
|
924
|
+
)
|
|
925
|
+
return InteractiveResult(
|
|
926
|
+
operation=_new_operation("sdk", exit_code=exit_code, duration_ms=_elapsed(start)),
|
|
927
|
+
session_id=exec_id,
|
|
928
|
+
waited=waited,
|
|
929
|
+
)
|
|
930
|
+
|
|
931
|
+
def build_image(
|
|
932
|
+
self,
|
|
933
|
+
plan: BuildPlan,
|
|
934
|
+
on_event: Optional[Callable[[str, str], None]] = None,
|
|
935
|
+
) -> BuildResult:
|
|
936
|
+
from aisc.adapters.docker_ import RealDockerExecutor
|
|
937
|
+
executor = RealDockerExecutor()
|
|
938
|
+
if on_event is not None:
|
|
939
|
+
result = executor.run_streaming_captured(
|
|
940
|
+
plan.docker_argv,
|
|
941
|
+
on_chunk=on_event,
|
|
942
|
+
)
|
|
943
|
+
else:
|
|
944
|
+
result = executor.run_captured(plan.docker_argv)
|
|
945
|
+
return BuildResult(
|
|
946
|
+
operation=_new_operation("sdk", exit_code=result.exit_code, duration_ms=0),
|
|
947
|
+
image_ref=plan.tag,
|
|
948
|
+
stdout=result.stdout,
|
|
949
|
+
stderr=result.stderr,
|
|
950
|
+
)
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
# ---------------------------------------------------------------------------
|
|
954
|
+
# CLI backend — wraps RealDockerExecutor (argv-only fallback, D4-04/D4-08)
|
|
955
|
+
# ---------------------------------------------------------------------------
|
|
956
|
+
|
|
957
|
+
@dataclass
|
|
958
|
+
class CliGateway:
|
|
959
|
+
"""CLI-backed gateway: delegates to the existing ``RealDockerExecutor``.
|
|
960
|
+
|
|
961
|
+
Kept for the equivalence/regression window; never removed until the SDK/CLI
|
|
962
|
+
matrix and cross-platform evidence are complete (D4-08).
|
|
963
|
+
"""
|
|
964
|
+
|
|
965
|
+
executor: object = None # RealDockerExecutor (duck-typed)
|
|
966
|
+
|
|
967
|
+
def _exec(self):
|
|
968
|
+
if self.executor is None:
|
|
969
|
+
from aisc.adapters.docker_ import RealDockerExecutor
|
|
970
|
+
self.executor = RealDockerExecutor()
|
|
971
|
+
return self.executor
|
|
972
|
+
|
|
973
|
+
@property
|
|
974
|
+
def backend(self) -> str:
|
|
975
|
+
return "cli"
|
|
976
|
+
|
|
977
|
+
def _op(self, result: ProcessResult, **kw) -> GatewayOperation:
|
|
978
|
+
return _new_operation(
|
|
979
|
+
"cli",
|
|
980
|
+
exit_code=result.exit_code,
|
|
981
|
+
timed_out=result.timed_out,
|
|
982
|
+
**kw,
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
def preflight(self) -> PreflightResult:
|
|
986
|
+
start = time.monotonic()
|
|
987
|
+
r: DockerPreflightResult = self._exec().preflight()
|
|
988
|
+
return PreflightResult(
|
|
989
|
+
operation=_new_operation(
|
|
990
|
+
"cli", exit_code=r.exit_code, duration_ms=_elapsed(start),
|
|
991
|
+
error_code=r.error_code or "",
|
|
992
|
+
),
|
|
993
|
+
available=r.available,
|
|
994
|
+
reason=r.reason,
|
|
995
|
+
docker_path=r.docker_path,
|
|
996
|
+
)
|
|
997
|
+
|
|
998
|
+
def inspect_image(self, image_name: str) -> ImageInspectGatewayResult:
|
|
999
|
+
start = time.monotonic()
|
|
1000
|
+
r: ImageInspectResult = self._exec().inspect_image(image_name)
|
|
1001
|
+
return ImageInspectGatewayResult(
|
|
1002
|
+
operation=_new_operation("cli", exit_code=_map_inspect_exit(r.status), duration_ms=_elapsed(start)),
|
|
1003
|
+
status=r.status,
|
|
1004
|
+
image=r.image,
|
|
1005
|
+
message=r.message,
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
def list_containers(self, all: bool = False) -> ContainerListResult:
|
|
1009
|
+
start = time.monotonic()
|
|
1010
|
+
r: ProcessResult = self._exec().list_containers(all=all)
|
|
1011
|
+
result = ContainerListResult(
|
|
1012
|
+
operation=self._op(r, duration_ms=_elapsed(start)),
|
|
1013
|
+
stdout=r.stdout,
|
|
1014
|
+
stderr=r.stderr,
|
|
1015
|
+
)
|
|
1016
|
+
if r.exit_code != 0:
|
|
1017
|
+
return result
|
|
1018
|
+
# RealDockerExecutor lists with
|
|
1019
|
+
# `--format "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}"` — machine
|
|
1020
|
+
# format, stable fields. `docker ps` cannot carry labels or the
|
|
1021
|
+
# content-addressed image ID: rows leave labels={} / image_id="" and
|
|
1022
|
+
# callers inspect the interesting candidates individually (A0).
|
|
1023
|
+
for line in (r.stdout or "").splitlines():
|
|
1024
|
+
parts = line.split("\t")
|
|
1025
|
+
if len(parts) < 4:
|
|
1026
|
+
continue
|
|
1027
|
+
result.containers.append(
|
|
1028
|
+
ContainerSummary(
|
|
1029
|
+
id=parts[0].strip(),
|
|
1030
|
+
name=parts[1].strip(),
|
|
1031
|
+
image=parts[2].strip(),
|
|
1032
|
+
status=parts[3].strip(),
|
|
1033
|
+
)
|
|
1034
|
+
)
|
|
1035
|
+
return result
|
|
1036
|
+
|
|
1037
|
+
def inspect_container(self, container: str) -> ContainerInspectResult:
|
|
1038
|
+
start = time.monotonic()
|
|
1039
|
+
r: ProcessResult = self._exec().inspect_container(container)
|
|
1040
|
+
if r.exit_code != 0:
|
|
1041
|
+
return ContainerInspectResult(
|
|
1042
|
+
operation=self._op(r, duration_ms=_elapsed(start)),
|
|
1043
|
+
stdout=r.stdout,
|
|
1044
|
+
stderr=r.stderr,
|
|
1045
|
+
)
|
|
1046
|
+
# `docker inspect` emits stable machine JSON (never human text).
|
|
1047
|
+
try:
|
|
1048
|
+
attrs = json.loads(r.stdout or "[]")
|
|
1049
|
+
attrs = attrs[0] if isinstance(attrs, list) and attrs else {}
|
|
1050
|
+
except ValueError:
|
|
1051
|
+
attrs = {}
|
|
1052
|
+
if not isinstance(attrs, dict):
|
|
1053
|
+
attrs = {}
|
|
1054
|
+
config = attrs.get("Config", {}) or {}
|
|
1055
|
+
return ContainerInspectResult(
|
|
1056
|
+
operation=self._op(r, duration_ms=_elapsed(start)),
|
|
1057
|
+
stdout=r.stdout,
|
|
1058
|
+
stderr=r.stderr,
|
|
1059
|
+
container_id=attrs.get("Id", "") or "",
|
|
1060
|
+
name=(attrs.get("Name", "") or "").lstrip("/"),
|
|
1061
|
+
state=(attrs.get("State", {}) or {}).get("Status", "") or "",
|
|
1062
|
+
image=config.get("Image", "") or "",
|
|
1063
|
+
labels=config.get("Labels", {}) or {},
|
|
1064
|
+
config=config,
|
|
1065
|
+
image_id=attrs.get("Image", "") or "",
|
|
1066
|
+
)
|
|
1067
|
+
|
|
1068
|
+
def start_container(self, container: str) -> LifecycleResult:
|
|
1069
|
+
# CLI executor exposes run_captured; start = `docker start <name>`.
|
|
1070
|
+
return self._lifecycle_via_argv(["start", container], target=container, state="running")
|
|
1071
|
+
|
|
1072
|
+
def stop_container(self, container: str, timeout: int = 10) -> LifecycleResult:
|
|
1073
|
+
return self._lifecycle_via_argv(
|
|
1074
|
+
["stop", "-t", str(timeout), container], target=container, state="stopped",
|
|
1075
|
+
)
|
|
1076
|
+
|
|
1077
|
+
def remove_container(self, container: str, force: bool = False) -> LifecycleResult:
|
|
1078
|
+
argv = ["rm"] + (["-f"] if force else []) + [container]
|
|
1079
|
+
return self._lifecycle_via_argv(argv, target=container, state="removed")
|
|
1080
|
+
|
|
1081
|
+
def wait_container(self, container: str, timeout: Optional[float] = None) -> LifecycleResult:
|
|
1082
|
+
return self._lifecycle_via_argv(["wait", container], target=container, state="exited")
|
|
1083
|
+
|
|
1084
|
+
def _lifecycle_via_argv(
|
|
1085
|
+
self, argv: List[str], *, target: str, state: str,
|
|
1086
|
+
) -> LifecycleResult:
|
|
1087
|
+
start = time.monotonic()
|
|
1088
|
+
r: ProcessResult = self._exec().run_captured(argv)
|
|
1089
|
+
return LifecycleResult(
|
|
1090
|
+
operation=_new_operation(
|
|
1091
|
+
"cli", exit_code=r.exit_code, duration_ms=_elapsed(start),
|
|
1092
|
+
timed_out=r.timed_out,
|
|
1093
|
+
),
|
|
1094
|
+
target=target,
|
|
1095
|
+
observed_state=state if r.exit_code == 0 else "",
|
|
1096
|
+
)
|
|
1097
|
+
|
|
1098
|
+
def open_interactive(self, container: str, argv: List[str]) -> InteractiveResult:
|
|
1099
|
+
start = time.monotonic()
|
|
1100
|
+
r: ProcessResult = self._exec().open_interactive(container, argv)
|
|
1101
|
+
return InteractiveResult(
|
|
1102
|
+
operation=_new_operation("cli", exit_code=r.exit_code, duration_ms=_elapsed(start)),
|
|
1103
|
+
)
|
|
1104
|
+
|
|
1105
|
+
def build_image(
|
|
1106
|
+
self,
|
|
1107
|
+
plan: BuildPlan,
|
|
1108
|
+
on_event: Optional[Callable[[str, str], None]] = None,
|
|
1109
|
+
) -> BuildResult:
|
|
1110
|
+
start = time.monotonic()
|
|
1111
|
+
executor = self._exec()
|
|
1112
|
+
if on_event is not None:
|
|
1113
|
+
r: ProcessResult = executor.run_streaming_captured(plan.docker_argv, on_chunk=on_event)
|
|
1114
|
+
else:
|
|
1115
|
+
r: ProcessResult = executor.run_captured(plan.docker_argv)
|
|
1116
|
+
return BuildResult(
|
|
1117
|
+
operation=_new_operation("cli", exit_code=r.exit_code, duration_ms=_elapsed(start)),
|
|
1118
|
+
image_ref=plan.tag,
|
|
1119
|
+
stdout=r.stdout,
|
|
1120
|
+
stderr=r.stderr,
|
|
1121
|
+
)
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def _map_inspect_exit(status: str) -> int:
|
|
1125
|
+
if status == ImageInspectStatus.EXISTS:
|
|
1126
|
+
return 0
|
|
1127
|
+
if status == ImageInspectStatus.MISSING:
|
|
1128
|
+
return 5
|
|
1129
|
+
if status == ImageInspectStatus.PERMISSION_DENIED:
|
|
1130
|
+
return 9
|
|
1131
|
+
if status == ImageInspectStatus.TIMEOUT:
|
|
1132
|
+
return 1
|
|
1133
|
+
return 3 # docker_unavailable / error
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
# ---------------------------------------------------------------------------
|
|
1137
|
+
# Auto backend — capability-based selection (D4-06)
|
|
1138
|
+
# ---------------------------------------------------------------------------
|
|
1139
|
+
|
|
1140
|
+
@dataclass
|
|
1141
|
+
class AutoGateway:
|
|
1142
|
+
"""Selects sdk vs cli by capability.
|
|
1143
|
+
|
|
1144
|
+
The choice is a pure function of environment capability (SDK importable,
|
|
1145
|
+
daemon reachable); it never reflects a caller-supplied preference. The
|
|
1146
|
+
resolved backend is exposed on every operation result for diagnostics.
|
|
1147
|
+
"""
|
|
1148
|
+
|
|
1149
|
+
_sdk: Optional[SdkGateway] = None
|
|
1150
|
+
_cli: Optional[CliGateway] = None
|
|
1151
|
+
prefer_sdk: bool = True # SDK is the default when available (D4-03/D4-04)
|
|
1152
|
+
|
|
1153
|
+
@property
|
|
1154
|
+
def backend(self) -> str:
|
|
1155
|
+
return "auto"
|
|
1156
|
+
|
|
1157
|
+
def _resolve(self) -> DockerGateway:
|
|
1158
|
+
if self.prefer_sdk:
|
|
1159
|
+
try:
|
|
1160
|
+
import docker # noqa: F401
|
|
1161
|
+
if self._sdk is None:
|
|
1162
|
+
self._sdk = SdkGateway()
|
|
1163
|
+
return self._sdk
|
|
1164
|
+
except ImportError:
|
|
1165
|
+
pass
|
|
1166
|
+
if self._cli is None:
|
|
1167
|
+
self._cli = CliGateway()
|
|
1168
|
+
return self._cli
|
|
1169
|
+
|
|
1170
|
+
def preflight(self) -> PreflightResult:
|
|
1171
|
+
return self._resolve().preflight()
|
|
1172
|
+
|
|
1173
|
+
def inspect_image(self, image_name: str) -> ImageInspectGatewayResult:
|
|
1174
|
+
return self._resolve().inspect_image(image_name)
|
|
1175
|
+
|
|
1176
|
+
def list_containers(self, all: bool = False) -> ContainerListResult:
|
|
1177
|
+
return self._resolve().list_containers(all=all)
|
|
1178
|
+
|
|
1179
|
+
def inspect_container(self, container: str) -> ContainerInspectResult:
|
|
1180
|
+
return self._resolve().inspect_container(container)
|
|
1181
|
+
|
|
1182
|
+
def start_container(self, container: str) -> LifecycleResult:
|
|
1183
|
+
return self._resolve().start_container(container)
|
|
1184
|
+
|
|
1185
|
+
def stop_container(self, container: str, timeout: int = 10) -> LifecycleResult:
|
|
1186
|
+
return self._resolve().stop_container(container, timeout=timeout)
|
|
1187
|
+
|
|
1188
|
+
def remove_container(self, container: str, force: bool = False) -> LifecycleResult:
|
|
1189
|
+
return self._resolve().remove_container(container, force=force)
|
|
1190
|
+
|
|
1191
|
+
def wait_container(self, container: str, timeout: Optional[float] = None) -> LifecycleResult:
|
|
1192
|
+
return self._resolve().wait_container(container, timeout=timeout)
|
|
1193
|
+
|
|
1194
|
+
def open_interactive(self, container: str, argv: List[str]) -> InteractiveResult:
|
|
1195
|
+
return self._resolve().open_interactive(container, argv)
|
|
1196
|
+
|
|
1197
|
+
def open_pty_stream(self, container, argv, env=None, on_output=None,
|
|
1198
|
+
initial_size=None) -> InteractiveStreamHandle:
|
|
1199
|
+
"""2.1.10 R2: PTY streams are SDK-only (exec_resize needs the API —
|
|
1200
|
+
the reason G-02 exists). Resolve straight to the SDK backend; a
|
|
1201
|
+
missing SDK is a hard error for this transport, never a CLI
|
|
1202
|
+
fallback."""
|
|
1203
|
+
try:
|
|
1204
|
+
import docker # noqa: F401
|
|
1205
|
+
except ImportError as exc:
|
|
1206
|
+
raise NotImplementedError(
|
|
1207
|
+
"pty streams require the Docker SDK backend"
|
|
1208
|
+
) from exc
|
|
1209
|
+
backend = self._resolve()
|
|
1210
|
+
return backend.open_pty_stream(container, argv, env=env,
|
|
1211
|
+
on_output=on_output,
|
|
1212
|
+
initial_size=initial_size)
|
|
1213
|
+
|
|
1214
|
+
def build_image(
|
|
1215
|
+
self,
|
|
1216
|
+
plan: BuildPlan,
|
|
1217
|
+
on_event: Optional[Callable[[str, str], None]] = None,
|
|
1218
|
+
) -> BuildResult:
|
|
1219
|
+
return self._resolve().build_image(plan, on_event=on_event)
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
# ---------------------------------------------------------------------------
|
|
1223
|
+
# Factory + compatibility alias
|
|
1224
|
+
# ---------------------------------------------------------------------------
|
|
1225
|
+
|
|
1226
|
+
def create_docker_gateway(
|
|
1227
|
+
backend: BackendName = "auto",
|
|
1228
|
+
client: object = None,
|
|
1229
|
+
) -> DockerGateway:
|
|
1230
|
+
"""Return a gateway for *backend* (auto | sdk | cli).
|
|
1231
|
+
|
|
1232
|
+
``client`` injects a docker-py client (tests/recording); CLI backend accepts
|
|
1233
|
+
an executor for the same purpose. The backend string is never interpreted
|
|
1234
|
+
outside this module.
|
|
1235
|
+
"""
|
|
1236
|
+
if backend == "sdk":
|
|
1237
|
+
return SdkGateway(client=client)
|
|
1238
|
+
if backend == "cli":
|
|
1239
|
+
return CliGateway(executor=client)
|
|
1240
|
+
return AutoGateway(_sdk=(SdkGateway(client=client) if client is not None else None))
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
# D4-02: compatibility alias — existing callers of `DockerExecutor` keep working.
|
|
1244
|
+
DockerExecutor = DockerGateway
|