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
aisc/adapters/docker_.py
ADDED
|
@@ -0,0 +1,1183 @@
|
|
|
1
|
+
"""Docker adapter — unified ``DockerExecutor`` protocol with preflight,
|
|
2
|
+
image inspection, and build/run execution.
|
|
3
|
+
|
|
4
|
+
**All** docker operations go through the executor. Application / command
|
|
5
|
+
code never calls ``subprocess.run`` directly.
|
|
6
|
+
|
|
7
|
+
Protocol methods
|
|
8
|
+
----------------
|
|
9
|
+
- ``preflight()`` → ``DockerPreflightResult``
|
|
10
|
+
- ``inspect_image()`` → ``ImageInspectResult`` (structured, never swallows)
|
|
11
|
+
- ``run_captured()`` → ``ProcessResult`` (capture stdout/stderr)
|
|
12
|
+
- ``run_streaming()`` → ``int`` (inherit streams, return exit code)
|
|
13
|
+
|
|
14
|
+
``shell=True`` is **never** used.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import queue
|
|
21
|
+
import select
|
|
22
|
+
import socket
|
|
23
|
+
import time
|
|
24
|
+
import shlex
|
|
25
|
+
import signal
|
|
26
|
+
import shutil
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
import threading
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Callable, Dict, List, Optional, Protocol, runtime_checkable
|
|
33
|
+
|
|
34
|
+
from aisc.domain.models import (
|
|
35
|
+
BuildPlan,
|
|
36
|
+
DockerPreflightResult,
|
|
37
|
+
ImageInspectResult,
|
|
38
|
+
ImageInspectStatus,
|
|
39
|
+
ProcessResult,
|
|
40
|
+
RunPlan,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Executor protocol — single injectable abstraction
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def _poll_resize_step(resize_file, last_size, apply_resize):
|
|
49
|
+
"""One resize-file poll step (G-02): read ``"<cols> <rows>"``; when it
|
|
50
|
+
changed, call *apply_resize* with ``(cols, rows)`` and return the new
|
|
51
|
+
size, otherwise return *last_size* unchanged. A missing/garbage file is
|
|
52
|
+
tolerated (returns *last_size*).
|
|
53
|
+
|
|
54
|
+
Hoisted out of the interactive watch threads so the update semantics are
|
|
55
|
+
unit-testable. The previous INLINE version assigned the closed-over
|
|
56
|
+
``last_size`` without ``nonlocal`` -- Python made it a thread-local, so
|
|
57
|
+
every iteration raised UnboundLocalError (silently swallowed by the
|
|
58
|
+
broad except) and NO resize after the sidecar's initial read ever
|
|
59
|
+
reached the container (B-05: terminals stuck at their startup size).
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
content = open(resize_file).read().strip().split()
|
|
63
|
+
if len(content) == 2:
|
|
64
|
+
cur = (int(content[0]), int(content[1]))
|
|
65
|
+
if cur != last_size:
|
|
66
|
+
apply_resize(cur)
|
|
67
|
+
return cur
|
|
68
|
+
except Exception: # noqa: BLE001
|
|
69
|
+
pass
|
|
70
|
+
return last_size
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@runtime_checkable
|
|
74
|
+
class DockerExecutor(Protocol):
|
|
75
|
+
"""Narrow interface for all Docker operations.
|
|
76
|
+
|
|
77
|
+
Every method maps to one or more ``docker`` CLI invocations.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def preflight(self) -> DockerPreflightResult:
|
|
81
|
+
"""Check Docker CLI availability + daemon reachability via
|
|
82
|
+
``docker info`` (8 s timeout)."""
|
|
83
|
+
|
|
84
|
+
def inspect_image(self, image_name: str) -> ImageInspectResult:
|
|
85
|
+
"""Check whether *image_name* exists locally via
|
|
86
|
+
``docker image inspect`` (10 s timeout). Structured result —
|
|
87
|
+
never returns bare ``bool``."""
|
|
88
|
+
|
|
89
|
+
def run_captured(self, docker_argv: List[str],
|
|
90
|
+
*, timeout: Optional[float] = None,
|
|
91
|
+
input_text: Optional[str] = None) -> ProcessResult:
|
|
92
|
+
"""Execute ``docker <argv>`` with captured stdout / stderr.
|
|
93
|
+
|
|
94
|
+
``input_text`` (Stage 8d) pipes a string to the child's stdin — the
|
|
95
|
+
cc-switch provider data plane's secret channel (never argv)."""
|
|
96
|
+
|
|
97
|
+
def run_streaming(self, docker_argv: List[str],
|
|
98
|
+
*, timeout: Optional[float] = None) -> ProcessResult:
|
|
99
|
+
"""Execute ``docker <argv>`` with inherited stdin / stdout / stderr.
|
|
100
|
+
Returns a ``ProcessResult`` with exit code captured, stderr empty."""
|
|
101
|
+
|
|
102
|
+
def open_interactive(
|
|
103
|
+
self,
|
|
104
|
+
container: str,
|
|
105
|
+
argv: List[str],
|
|
106
|
+
env: Optional[Dict[str, str]] = None,
|
|
107
|
+
) -> ProcessResult:
|
|
108
|
+
"""Open an interactive TTY session via the Docker SDK so the exec pty
|
|
109
|
+
can be resized with ``exec_resize`` (G-02: the docker CLI's exec pty is
|
|
110
|
+
frozen at the spawn size). Raw tty stream: stdout forwarded to fd 1,
|
|
111
|
+
stdin forwarded to the socket, terminal-size watcher forwards changes.
|
|
112
|
+
Returns the agent's exit code from exec_inspect."""
|
|
113
|
+
|
|
114
|
+
def run_non_interactive(self, docker_argv: List[str],
|
|
115
|
+
*, timeout: Optional[float] = None) -> ProcessResult:
|
|
116
|
+
"""Execute ``docker <argv>`` with DEVNULL stdin, inherited stdout / stderr.
|
|
117
|
+
For ``--non-interactive`` mode."""
|
|
118
|
+
|
|
119
|
+
def run_streaming_captured(self, docker_argv: List[str],
|
|
120
|
+
on_chunk: "Callable[[str, str], None]",
|
|
121
|
+
*, timeout: Optional[float] = None) -> ProcessResult:
|
|
122
|
+
"""Execute ``docker <argv>`` streaming stdout/stderr chunks to
|
|
123
|
+
*on_chunk(stream, chunk)*. Used by ``build --events`` for real-time
|
|
124
|
+
``build.output`` events (not end-of-build replay). The child runs in its
|
|
125
|
+
own process group so a cancel can kill it without signaling the CLI."""
|
|
126
|
+
|
|
127
|
+
# Container operations
|
|
128
|
+
def list_containers(self, all: bool = False) -> ProcessResult:
|
|
129
|
+
"""List containers via ``docker ps`` (or ``docker ps -a`` if all=True)."""
|
|
130
|
+
|
|
131
|
+
def stop_container(self, container_name: str, timeout: int = 10) -> ProcessResult:
|
|
132
|
+
"""Stop a container via ``docker stop``."""
|
|
133
|
+
|
|
134
|
+
def remove_container(self, container_name: str, force: bool = False) -> ProcessResult:
|
|
135
|
+
"""Remove a container via ``docker rm`` (with -f if force=True)."""
|
|
136
|
+
|
|
137
|
+
def inspect_container(self, container_name: str) -> ProcessResult:
|
|
138
|
+
"""Inspect a container via ``docker inspect``."""
|
|
139
|
+
|
|
140
|
+
# Image operations
|
|
141
|
+
def list_images(self) -> ProcessResult:
|
|
142
|
+
"""List images via ``docker images``."""
|
|
143
|
+
|
|
144
|
+
def remove_image(self, image_name: str, force: bool = False) -> ProcessResult:
|
|
145
|
+
"""Remove an image via ``docker rmi`` (with -f if force=True)."""
|
|
146
|
+
|
|
147
|
+
def pull_image(self, image_name: str) -> ProcessResult:
|
|
148
|
+
"""Pull an image via ``docker pull``."""
|
|
149
|
+
|
|
150
|
+
def tag_image(self, source: str, target: str) -> ProcessResult:
|
|
151
|
+
"""Tag an image via ``docker tag``."""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# Factory functions are below; Protocol methods don't have bodies.
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# Process-tree kill helper (cross-platform)
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def _kill_child(proc: subprocess.Popen) -> None:
|
|
161
|
+
"""Kill *proc* and its whole child tree, never raising.
|
|
162
|
+
|
|
163
|
+
POSIX: SIGKILL the child's process group (docker build subprocesses must
|
|
164
|
+
not outlive the CLI). Windows: ``taskkill /T /F`` tree kill with
|
|
165
|
+
``proc.kill()`` as fallback. Safe to call from exception handlers —
|
|
166
|
+
neither ``os.killpg`` nor ``signal.SIGKILL`` exist on Windows, so the
|
|
167
|
+
fallbacks keep the cleanup path from crashing and masking the original
|
|
168
|
+
exception.
|
|
169
|
+
"""
|
|
170
|
+
if os.name == "posix":
|
|
171
|
+
try:
|
|
172
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
173
|
+
return
|
|
174
|
+
except OSError:
|
|
175
|
+
pass
|
|
176
|
+
else:
|
|
177
|
+
try:
|
|
178
|
+
subprocess.run(
|
|
179
|
+
["taskkill", "/PID", str(proc.pid), "/T", "/F"],
|
|
180
|
+
capture_output=True, timeout=5,
|
|
181
|
+
)
|
|
182
|
+
except Exception:
|
|
183
|
+
pass
|
|
184
|
+
try:
|
|
185
|
+
proc.kill()
|
|
186
|
+
except Exception:
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# Real Docker executor (production)
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
class RealDockerExecutor:
|
|
195
|
+
"""Real ``docker`` subprocess executor — all methods use ``subprocess``."""
|
|
196
|
+
|
|
197
|
+
_PREFLIGHT_TIMEOUT = 8.0
|
|
198
|
+
_INSPECT_TIMEOUT = 10.0
|
|
199
|
+
|
|
200
|
+
# Windows install locations to try when the CLI is not on PATH. A fresh
|
|
201
|
+
# winget install only lands in the user PATH after Explorer re-reads the
|
|
202
|
+
# environment, so a Workbench launched straight from the installer inherits
|
|
203
|
+
# a stale PATH and ``shutil.which`` misses it (TODO 20260806 line 76).
|
|
204
|
+
# The per-user "Install for me" layout (Programs\DockerDesktop) leads —
|
|
205
|
+
# KI-6: its absence made direct terminal CLI runs see no running engine.
|
|
206
|
+
_WINDOWS_FALLBACK_PATHS = (
|
|
207
|
+
r"%LOCALAPPDATA%\Programs\DockerDesktop\resources\bin\docker.exe",
|
|
208
|
+
r"C:\Program Files\Docker\Docker\resources\bin\docker.exe",
|
|
209
|
+
r"%LOCALAPPDATA%\Docker\Docker\resources\bin\docker.exe",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def __init__(self, docker_path: Optional[str] = None):
|
|
213
|
+
self._docker_path: Optional[str] = docker_path
|
|
214
|
+
|
|
215
|
+
def _resolve_path(self) -> Optional[str]:
|
|
216
|
+
if self._docker_path is not None:
|
|
217
|
+
return self._docker_path
|
|
218
|
+
self._docker_path = shutil.which("docker")
|
|
219
|
+
if self._docker_path is None and os.name == "nt":
|
|
220
|
+
for candidate in self._WINDOWS_FALLBACK_PATHS:
|
|
221
|
+
expanded = os.path.expandvars(candidate)
|
|
222
|
+
if os.path.isfile(expanded):
|
|
223
|
+
self._docker_path = expanded
|
|
224
|
+
break
|
|
225
|
+
return self._docker_path
|
|
226
|
+
|
|
227
|
+
def _subprocess_env(self) -> dict:
|
|
228
|
+
"""Env for docker subprocesses: the resolved docker dir prepended to
|
|
229
|
+
PATH so credential helpers next to the docker CLI (e.g.
|
|
230
|
+
``docker-credential-desktop.exe``) resolve even when the parent
|
|
231
|
+
process inherited a stale PATH (Workbench launched straight from the
|
|
232
|
+
installer; S4.1.b)."""
|
|
233
|
+
env = os.environ.copy()
|
|
234
|
+
dp = self._resolve_path()
|
|
235
|
+
if dp:
|
|
236
|
+
env["PATH"] = os.path.dirname(dp) + os.pathsep + env.get("PATH", "")
|
|
237
|
+
return env
|
|
238
|
+
|
|
239
|
+
# ------------------------------------------------------------------
|
|
240
|
+
# preflight
|
|
241
|
+
# ------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
def preflight(self) -> DockerPreflightResult:
|
|
244
|
+
docker_path = self._resolve_path()
|
|
245
|
+
if docker_path is None:
|
|
246
|
+
return DockerPreflightResult(
|
|
247
|
+
docker_path="", available=False, reason="cli_not_found",
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
proc = subprocess.run(
|
|
252
|
+
[docker_path, "info"],
|
|
253
|
+
capture_output=True, text=True,
|
|
254
|
+
encoding="utf-8", errors="replace",
|
|
255
|
+
timeout=self._PREFLIGHT_TIMEOUT,
|
|
256
|
+
env=self._subprocess_env(),
|
|
257
|
+
)
|
|
258
|
+
except FileNotFoundError:
|
|
259
|
+
return DockerPreflightResult(
|
|
260
|
+
docker_path=docker_path, available=False, reason="cli_not_found",
|
|
261
|
+
)
|
|
262
|
+
except subprocess.TimeoutExpired:
|
|
263
|
+
return DockerPreflightResult(
|
|
264
|
+
docker_path=docker_path, available=False, reason="daemon_unreachable",
|
|
265
|
+
)
|
|
266
|
+
except PermissionError:
|
|
267
|
+
return DockerPreflightResult(
|
|
268
|
+
docker_path=docker_path, available=False, reason="permission_denied",
|
|
269
|
+
)
|
|
270
|
+
except OSError as exc:
|
|
271
|
+
return DockerPreflightResult(
|
|
272
|
+
docker_path=docker_path, available=False,
|
|
273
|
+
reason="daemon_unreachable",
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
if proc.returncode != 0:
|
|
277
|
+
stderr_lower = (proc.stderr or "").lower()
|
|
278
|
+
if "permission denied" in stderr_lower:
|
|
279
|
+
return DockerPreflightResult(
|
|
280
|
+
docker_path=docker_path, available=False,
|
|
281
|
+
reason="permission_denied",
|
|
282
|
+
)
|
|
283
|
+
return DockerPreflightResult(
|
|
284
|
+
docker_path=docker_path, available=False,
|
|
285
|
+
reason="daemon_unreachable",
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
return DockerPreflightResult(
|
|
289
|
+
docker_path=docker_path, available=True, reason="ok",
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# ------------------------------------------------------------------
|
|
293
|
+
# inspect_image
|
|
294
|
+
# ------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
def inspect_image(self, image_name: str) -> ImageInspectResult:
|
|
297
|
+
docker_path = self._resolve_path()
|
|
298
|
+
if docker_path is None:
|
|
299
|
+
return ImageInspectResult(
|
|
300
|
+
status=ImageInspectStatus.DOCKER_UNAVAILABLE,
|
|
301
|
+
image=image_name,
|
|
302
|
+
message="Docker CLI not found",
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
proc = subprocess.run(
|
|
307
|
+
[docker_path, "image", "inspect", image_name],
|
|
308
|
+
capture_output=True, text=True,
|
|
309
|
+
timeout=self._INSPECT_TIMEOUT,
|
|
310
|
+
encoding="utf-8", errors="replace",
|
|
311
|
+
env=self._subprocess_env(),
|
|
312
|
+
)
|
|
313
|
+
except FileNotFoundError:
|
|
314
|
+
return ImageInspectResult(
|
|
315
|
+
status=ImageInspectStatus.DOCKER_UNAVAILABLE,
|
|
316
|
+
image=image_name, message="Docker CLI not found",
|
|
317
|
+
)
|
|
318
|
+
except subprocess.TimeoutExpired:
|
|
319
|
+
return ImageInspectResult(
|
|
320
|
+
status=ImageInspectStatus.TIMEOUT,
|
|
321
|
+
image=image_name, message="docker image inspect timed out",
|
|
322
|
+
)
|
|
323
|
+
except PermissionError:
|
|
324
|
+
return ImageInspectResult(
|
|
325
|
+
status=ImageInspectStatus.PERMISSION_DENIED,
|
|
326
|
+
image=image_name, message="Permission denied accessing Docker daemon",
|
|
327
|
+
)
|
|
328
|
+
except OSError as exc:
|
|
329
|
+
return ImageInspectResult(
|
|
330
|
+
status=ImageInspectStatus.ERROR,
|
|
331
|
+
image=image_name, message=f"Command error: {exc}",
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
if proc.returncode == 0:
|
|
335
|
+
# 容器随镜像同步更新 (KI-4 挂账): harvest the content-addressed
|
|
336
|
+
# .Id for the image-sync conflict check. Existence stays the
|
|
337
|
+
# primary answer — an unparseable body degrades to "" (unknown),
|
|
338
|
+
# never to a failure.
|
|
339
|
+
image_id = ""
|
|
340
|
+
try:
|
|
341
|
+
import json as _json
|
|
342
|
+
docs = _json.loads(proc.stdout or "[]")
|
|
343
|
+
if isinstance(docs, list) and docs and isinstance(docs[0], dict):
|
|
344
|
+
image_id = str(docs[0].get("Id") or "")
|
|
345
|
+
except (ValueError, TypeError, IndexError):
|
|
346
|
+
image_id = ""
|
|
347
|
+
return ImageInspectResult(
|
|
348
|
+
status=ImageInspectStatus.EXISTS,
|
|
349
|
+
image=image_name, message="",
|
|
350
|
+
image_id=image_id,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
stderr_text = proc.stderr or ""
|
|
354
|
+
stderr_lower = stderr_text.lower()
|
|
355
|
+
|
|
356
|
+
# Permission denied patterns
|
|
357
|
+
if "permission denied" in stderr_lower:
|
|
358
|
+
return ImageInspectResult(
|
|
359
|
+
status=ImageInspectStatus.PERMISSION_DENIED,
|
|
360
|
+
image=image_name, message="Permission denied accessing Docker daemon",
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# Explicit "not found" — Docker standard messages
|
|
364
|
+
# Must check BEFORE daemon keyword because Docker error response
|
|
365
|
+
# format "Error response from daemon: No such image: ..." contains
|
|
366
|
+
# the word "daemon" even for missing images
|
|
367
|
+
if any(kw in stderr_lower for kw in (
|
|
368
|
+
"no such image", "no such object",
|
|
369
|
+
"image not found", "not found",
|
|
370
|
+
)):
|
|
371
|
+
return ImageInspectResult(
|
|
372
|
+
status=ImageInspectStatus.MISSING,
|
|
373
|
+
image=image_name, message=f"Image '{image_name}' not found",
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
# Docker daemon / connection errors (AFTER not-found check)
|
|
377
|
+
if any(kw in stderr_lower for kw in (
|
|
378
|
+
"cannot connect", "is the docker daemon running",
|
|
379
|
+
"connection refused", "error during connect",
|
|
380
|
+
)):
|
|
381
|
+
return ImageInspectResult(
|
|
382
|
+
status=ImageInspectStatus.DOCKER_UNAVAILABLE,
|
|
383
|
+
image=image_name, message=f"Docker daemon unreachable: {stderr_text.strip()[:200]}",
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
# Fallback: unknown error
|
|
387
|
+
return ImageInspectResult(
|
|
388
|
+
status=ImageInspectStatus.ERROR,
|
|
389
|
+
image=image_name,
|
|
390
|
+
message=f"Image inspect failed (exit {proc.returncode}): {stderr_text.strip()[:200]}",
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
# ------------------------------------------------------------------
|
|
394
|
+
# run_captured
|
|
395
|
+
# ------------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
def run_captured(self, docker_argv: List[str],
|
|
398
|
+
*, timeout: Optional[float] = None,
|
|
399
|
+
input_text: Optional[str] = None) -> ProcessResult:
|
|
400
|
+
dp = self._resolve_path() or "docker"
|
|
401
|
+
try:
|
|
402
|
+
proc = subprocess.run(
|
|
403
|
+
[dp] + list(docker_argv),
|
|
404
|
+
capture_output=True, text=True,
|
|
405
|
+
timeout=timeout,
|
|
406
|
+
encoding="utf-8", errors="replace",
|
|
407
|
+
env=self._subprocess_env(),
|
|
408
|
+
input=input_text,
|
|
409
|
+
)
|
|
410
|
+
return ProcessResult(
|
|
411
|
+
stdout=proc.stdout or "",
|
|
412
|
+
stderr=proc.stderr or "",
|
|
413
|
+
exit_code=proc.returncode,
|
|
414
|
+
)
|
|
415
|
+
except FileNotFoundError:
|
|
416
|
+
return ProcessResult(
|
|
417
|
+
stdout="", stderr="command not found: docker",
|
|
418
|
+
exit_code=-1, command_not_found=True,
|
|
419
|
+
)
|
|
420
|
+
except subprocess.TimeoutExpired:
|
|
421
|
+
return ProcessResult(
|
|
422
|
+
stdout="", stderr="command timed out",
|
|
423
|
+
exit_code=-1, timed_out=True,
|
|
424
|
+
)
|
|
425
|
+
except OSError as exc:
|
|
426
|
+
return ProcessResult(
|
|
427
|
+
stdout="", stderr=f"command error: {exc}",
|
|
428
|
+
exit_code=-1, command_not_found=True,
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
# ------------------------------------------------------------------
|
|
432
|
+
# run_streaming
|
|
433
|
+
# ------------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
def run_streaming(self, docker_argv: List[str],
|
|
436
|
+
*, timeout: Optional[float] = None) -> ProcessResult:
|
|
437
|
+
dp = self._resolve_path() or "docker"
|
|
438
|
+
try:
|
|
439
|
+
proc = subprocess.run(
|
|
440
|
+
[dp] + list(docker_argv), timeout=timeout, env=self._subprocess_env(),
|
|
441
|
+
)
|
|
442
|
+
return ProcessResult(
|
|
443
|
+
stdout="", stderr="", exit_code=proc.returncode,
|
|
444
|
+
)
|
|
445
|
+
except FileNotFoundError:
|
|
446
|
+
return ProcessResult(
|
|
447
|
+
stdout="", stderr="command not found: docker",
|
|
448
|
+
exit_code=-1, command_not_found=True,
|
|
449
|
+
)
|
|
450
|
+
except subprocess.TimeoutExpired:
|
|
451
|
+
return ProcessResult(
|
|
452
|
+
stdout="", stderr="command timed out",
|
|
453
|
+
exit_code=-1, timed_out=True,
|
|
454
|
+
)
|
|
455
|
+
except OSError as exc:
|
|
456
|
+
return ProcessResult(
|
|
457
|
+
stdout="", stderr=f"command error: {exc}",
|
|
458
|
+
exit_code=-1, command_not_found=True,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
# ------------------------------------------------------------------
|
|
462
|
+
# open_interactive (G-02 resize chain)
|
|
463
|
+
# ------------------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
@staticmethod
|
|
466
|
+
def _client_from_env_safe():
|
|
467
|
+
"""2.1.9 hotfix r3 (nairong #61, user VM repro): ``docker.from_env()``
|
|
468
|
+
with DOCKER_HOST unset loads the docker CLI context metadata, and
|
|
469
|
+
docker-py 7.x opens meta.json WITHOUT an encoding — on zh-CN Windows
|
|
470
|
+
that decodes as GBK while Docker Desktop (Chinese-language installs)
|
|
471
|
+
writes UTF-8 bytes into it, raising a bare UnicodeDecodeError wrapped
|
|
472
|
+
in a plain Exception ("corrupted meta file"). Not a DockerException,
|
|
473
|
+
so it escaped as a traceback → exit 1, zero output. Fall back to the
|
|
474
|
+
platform DEFAULT endpoint: an explicit base_url never reads
|
|
475
|
+
meta.json, skipping context resolution entirely."""
|
|
476
|
+
import docker # lazy: every other path stays dependency-free
|
|
477
|
+
|
|
478
|
+
try:
|
|
479
|
+
return docker.from_env()
|
|
480
|
+
except Exception: # noqa: BLE001 — context meta unreadable
|
|
481
|
+
if os.name == "nt":
|
|
482
|
+
return docker.DockerClient(base_url="npipe:////./pipe/docker_engine")
|
|
483
|
+
return docker.DockerClient(base_url="unix:///var/run/docker.sock")
|
|
484
|
+
|
|
485
|
+
def open_interactive(
|
|
486
|
+
self,
|
|
487
|
+
container: str,
|
|
488
|
+
argv: List[str],
|
|
489
|
+
env: Optional[Dict[str, str]] = None,
|
|
490
|
+
) -> ProcessResult:
|
|
491
|
+
"""Interactive TTY session via the Docker SDK (see protocol doc).
|
|
492
|
+
|
|
493
|
+
The sidecar is spawned with **pipes** (not a ConPTY) by the Rust
|
|
494
|
+
pty supervisor. stdin/stdout are raw byte streams - no console
|
|
495
|
+
processing, no codepage, no Unicode width tables. The container's
|
|
496
|
+
UTF-8 / VT sequences pass through directly to xterm.js.
|
|
497
|
+
|
|
498
|
+
Terminal size is passed via the ``AISC_RESIZE_FILE`` env var: Rust
|
|
499
|
+
writes ``"<cols> <rows>\\n"`` on resize, this sidecar polls it
|
|
500
|
+
(100ms) and calls ``exec_resize``. (G-02, 2026-08-10.)
|
|
501
|
+
"""
|
|
502
|
+
import docker # lazy: every other path stays dependency-free
|
|
503
|
+
import requests # docker SDK dependency: transport-layer exceptions
|
|
504
|
+
|
|
505
|
+
# 2.1.9 hotfix (nairong #61): transport-layer failures (npipe
|
|
506
|
+
# ReadTimeout / ConnectionError) are NOT DockerException subclasses —
|
|
507
|
+
# they used to escape as a bare traceback with the sidecar's stderr
|
|
508
|
+
# discarded by the pty supervisor (Stdio::null), surfacing as
|
|
509
|
+
# "exit 1 with zero terminal output". Catch them here so every
|
|
510
|
+
# failure becomes an orderly ProcessResult whose message reaches
|
|
511
|
+
# the terminal. Same class of bug as #59 (resolver IncompleteRead).
|
|
512
|
+
def _transport_failure(stage: str, exc: Exception) -> ProcessResult:
|
|
513
|
+
return ProcessResult(
|
|
514
|
+
stdout="", stderr=f"{stage} failed: {exc}",
|
|
515
|
+
exit_code=-1, command_not_found=True,
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
try:
|
|
519
|
+
client = self._client_from_env_safe()
|
|
520
|
+
except (docker.errors.DockerException, requests.RequestException, OSError) as exc:
|
|
521
|
+
return _transport_failure("docker daemon unreachable", exc)
|
|
522
|
+
try:
|
|
523
|
+
exec_kwargs: Dict[str, Any] = {"tty": True, "stdin": True}
|
|
524
|
+
if env:
|
|
525
|
+
# v2.1.7 S6: session-scoped environment (the bash tutorial
|
|
526
|
+
# `help` function enters here) — never the image or a profile.
|
|
527
|
+
exec_kwargs["environment"] = dict(env)
|
|
528
|
+
exec_id = client.api.exec_create(container, list(argv), **exec_kwargs)["Id"]
|
|
529
|
+
except docker.errors.NotFound:
|
|
530
|
+
return ProcessResult(
|
|
531
|
+
stdout="", stderr="container not found",
|
|
532
|
+
exit_code=-1, command_not_found=True,
|
|
533
|
+
)
|
|
534
|
+
except (docker.errors.DockerException, requests.RequestException, OSError) as exc:
|
|
535
|
+
return _transport_failure("exec create", exc)
|
|
536
|
+
try:
|
|
537
|
+
sock = client.api.exec_start(exec_id, socket=True, tty=True)
|
|
538
|
+
except (docker.errors.DockerException, requests.RequestException, OSError) as exc:
|
|
539
|
+
return _transport_failure("exec start", exc)
|
|
540
|
+
|
|
541
|
+
stop = threading.Event()
|
|
542
|
+
errors: List[Exception] = []
|
|
543
|
+
|
|
544
|
+
# Initial resize from the resize file (set by Rust before spawn).
|
|
545
|
+
resize_file = os.environ.get("AISC_RESIZE_FILE")
|
|
546
|
+
last_size: Optional[tuple] = None
|
|
547
|
+
if resize_file:
|
|
548
|
+
try:
|
|
549
|
+
content = open(resize_file).read().strip().split()
|
|
550
|
+
if len(content) == 2:
|
|
551
|
+
last_size = (int(content[0]), int(content[1]))
|
|
552
|
+
client.api.exec_resize(
|
|
553
|
+
exec_id, height=last_size[1], width=last_size[0]
|
|
554
|
+
)
|
|
555
|
+
except Exception: # noqa: BLE001
|
|
556
|
+
pass
|
|
557
|
+
|
|
558
|
+
def read_sock(size: int) -> bytes:
|
|
559
|
+
"""Read raw bytes from the docker-py exec socket.
|
|
560
|
+
|
|
561
|
+
Transport shape differs by platform: native sockets expose
|
|
562
|
+
``recv``, while Unix-socket transports return ``socket.SocketIO``
|
|
563
|
+
which only exposes ``read`` (docker-py 7.x). Cover both so Linux
|
|
564
|
+
and WSL sessions receive PTY output.
|
|
565
|
+
"""
|
|
566
|
+
if hasattr(sock, "recv"):
|
|
567
|
+
return sock.recv(size)
|
|
568
|
+
if hasattr(sock, "read"):
|
|
569
|
+
return sock.read(size)
|
|
570
|
+
return os.read(sock.fileno(), size)
|
|
571
|
+
|
|
572
|
+
def send_all(data: bytes) -> None:
|
|
573
|
+
"""Send every byte; transports expose either ``sendall`` or ``write``."""
|
|
574
|
+
if hasattr(sock, "sendall"):
|
|
575
|
+
sock.sendall(data)
|
|
576
|
+
return
|
|
577
|
+
raw = getattr(sock, "_sock", None)
|
|
578
|
+
if raw is not None and hasattr(raw, "sendall"):
|
|
579
|
+
raw.sendall(data)
|
|
580
|
+
return
|
|
581
|
+
view = memoryview(data)
|
|
582
|
+
while view:
|
|
583
|
+
if hasattr(sock, "write") and getattr(sock, "writable", lambda: False)():
|
|
584
|
+
sent = sock.write(view)
|
|
585
|
+
else:
|
|
586
|
+
sent = os.write(sock.fileno(), view)
|
|
587
|
+
if sent is None:
|
|
588
|
+
raise OSError("socket write would block")
|
|
589
|
+
if sent <= 0:
|
|
590
|
+
raise OSError("socket write failed")
|
|
591
|
+
view = view[sent:]
|
|
592
|
+
|
|
593
|
+
def shutdown_write() -> None:
|
|
594
|
+
"""Half-close the write side when stdin reaches EOF.
|
|
595
|
+
|
|
596
|
+
Raw sockets expose ``shutdown``; ``socket.SocketIO`` hides the raw
|
|
597
|
+
socket in ``_sock``. Close fully only as a last resort.
|
|
598
|
+
"""
|
|
599
|
+
raw = getattr(sock, "_sock", None)
|
|
600
|
+
targets = [raw, sock] if raw is not None else [sock]
|
|
601
|
+
for target in targets:
|
|
602
|
+
if hasattr(target, "shutdown"):
|
|
603
|
+
try:
|
|
604
|
+
target.shutdown(socket.SHUT_WR)
|
|
605
|
+
return
|
|
606
|
+
except OSError:
|
|
607
|
+
pass
|
|
608
|
+
try:
|
|
609
|
+
sock.close()
|
|
610
|
+
except OSError:
|
|
611
|
+
pass
|
|
612
|
+
|
|
613
|
+
def drain() -> None:
|
|
614
|
+
"""Socket -> stdout (raw bytes, no processing)."""
|
|
615
|
+
try:
|
|
616
|
+
while True:
|
|
617
|
+
chunk = read_sock(65536)
|
|
618
|
+
if not chunk:
|
|
619
|
+
break
|
|
620
|
+
os.write(sys.stdout.fileno(), chunk)
|
|
621
|
+
except Exception as exc: # noqa: BLE001
|
|
622
|
+
errors.append(exc)
|
|
623
|
+
|
|
624
|
+
def forward() -> None:
|
|
625
|
+
"""stdin -> socket; on EOF, close the write side."""
|
|
626
|
+
try:
|
|
627
|
+
while True:
|
|
628
|
+
chunk = os.read(sys.stdin.fileno(), 4096)
|
|
629
|
+
if not chunk:
|
|
630
|
+
break
|
|
631
|
+
send_all(chunk)
|
|
632
|
+
except Exception as exc: # noqa: BLE001
|
|
633
|
+
errors.append(exc)
|
|
634
|
+
finally:
|
|
635
|
+
shutdown_write()
|
|
636
|
+
|
|
637
|
+
def watch_resize() -> None:
|
|
638
|
+
"""Poll the resize file; forward size changes to exec_resize."""
|
|
639
|
+
if not resize_file:
|
|
640
|
+
return
|
|
641
|
+
# Local copy + module-level step helper: immune to the closure
|
|
642
|
+
# trap that previously dropped every post-initial resize (B-05).
|
|
643
|
+
last = last_size
|
|
644
|
+
while not stop.is_set():
|
|
645
|
+
last = _poll_resize_step(
|
|
646
|
+
resize_file,
|
|
647
|
+
last,
|
|
648
|
+
lambda size: client.api.exec_resize(
|
|
649
|
+
exec_id, height=size[1], width=size[0]
|
|
650
|
+
),
|
|
651
|
+
)
|
|
652
|
+
stop.wait(0.1)
|
|
653
|
+
|
|
654
|
+
t_drain = threading.Thread(target=drain, daemon=True)
|
|
655
|
+
t_fwd = threading.Thread(target=forward, daemon=True)
|
|
656
|
+
t_resize = threading.Thread(target=watch_resize, daemon=True)
|
|
657
|
+
t_drain.start()
|
|
658
|
+
t_fwd.start()
|
|
659
|
+
t_resize.start()
|
|
660
|
+
|
|
661
|
+
exit_code = -1
|
|
662
|
+
# 2.1.9 hotfix (nairong #61): exec_inspect polls every 200ms over the
|
|
663
|
+
# same npipe; a single transient transport hiccup used to escape as a
|
|
664
|
+
# bare traceback (only APIError was caught), killing a HEALTHY session.
|
|
665
|
+
# Tolerate a few consecutive failures before giving up orderly.
|
|
666
|
+
inspect_failures = 0
|
|
667
|
+
try:
|
|
668
|
+
while True:
|
|
669
|
+
try:
|
|
670
|
+
info = client.api.exec_inspect(exec_id)
|
|
671
|
+
inspect_failures = 0
|
|
672
|
+
except (docker.errors.APIError, requests.RequestException, OSError) as exc:
|
|
673
|
+
inspect_failures += 1
|
|
674
|
+
if inspect_failures >= 3:
|
|
675
|
+
raise
|
|
676
|
+
time.sleep(0.5)
|
|
677
|
+
continue
|
|
678
|
+
if not info.get("Running"):
|
|
679
|
+
exit_code = int(info.get("ExitCode", 0))
|
|
680
|
+
break
|
|
681
|
+
time.sleep(0.2)
|
|
682
|
+
except (docker.errors.APIError, requests.RequestException, OSError) as exc:
|
|
683
|
+
errors.append(exc)
|
|
684
|
+
finally:
|
|
685
|
+
stop.set()
|
|
686
|
+
t_drain.join(timeout=5)
|
|
687
|
+
t_fwd.join(timeout=5)
|
|
688
|
+
|
|
689
|
+
if errors:
|
|
690
|
+
try:
|
|
691
|
+
os.write(2, ("[open_interactive] thread error: %r\n" % (errors[0],)).encode())
|
|
692
|
+
except OSError:
|
|
693
|
+
pass
|
|
694
|
+
return ProcessResult(
|
|
695
|
+
stdout="", stderr=f"exec stream error: {errors[0]}",
|
|
696
|
+
exit_code=-1,
|
|
697
|
+
)
|
|
698
|
+
return ProcessResult(stdout="", stderr="", exit_code=exit_code)
|
|
699
|
+
|
|
700
|
+
# ------------------------------------------------------------------
|
|
701
|
+
# run_non_interactive
|
|
702
|
+
# ------------------------------------------------------------------
|
|
703
|
+
|
|
704
|
+
def run_non_interactive(self, docker_argv: List[str],
|
|
705
|
+
*, timeout: Optional[float] = None) -> ProcessResult:
|
|
706
|
+
"""Execute ``docker <argv>`` with DEVNULL stdin, inherited stdout/stderr."""
|
|
707
|
+
dp = self._resolve_path() or "docker"
|
|
708
|
+
try:
|
|
709
|
+
proc = subprocess.run(
|
|
710
|
+
[dp] + list(docker_argv),
|
|
711
|
+
stdin=subprocess.DEVNULL,
|
|
712
|
+
timeout=timeout,
|
|
713
|
+
env=self._subprocess_env(),
|
|
714
|
+
)
|
|
715
|
+
return ProcessResult(
|
|
716
|
+
stdout="", stderr="", exit_code=proc.returncode,
|
|
717
|
+
)
|
|
718
|
+
except FileNotFoundError:
|
|
719
|
+
return ProcessResult(
|
|
720
|
+
stdout="", stderr="command not found: docker",
|
|
721
|
+
exit_code=-1, command_not_found=True,
|
|
722
|
+
)
|
|
723
|
+
except subprocess.TimeoutExpired:
|
|
724
|
+
return ProcessResult(
|
|
725
|
+
stdout="", stderr="command timed out",
|
|
726
|
+
exit_code=-1, timed_out=True,
|
|
727
|
+
)
|
|
728
|
+
except OSError as exc:
|
|
729
|
+
return ProcessResult(
|
|
730
|
+
stdout="", stderr=f"command error: {exc}",
|
|
731
|
+
exit_code=-1, command_not_found=True,
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
# ------------------------------------------------------------------
|
|
735
|
+
# run_streaming_captured (build --events: real-time build.output)
|
|
736
|
+
# ------------------------------------------------------------------
|
|
737
|
+
|
|
738
|
+
def run_streaming_captured(self, docker_argv: List[str],
|
|
739
|
+
on_chunk: "Callable[[str, str], None]",
|
|
740
|
+
*, timeout: Optional[float] = None) -> ProcessResult:
|
|
741
|
+
"""Run ``docker <argv>`` in its own process group, streaming each
|
|
742
|
+
stdout/stderr chunk to *on_chunk(stream, chunk)*. On any interruption
|
|
743
|
+
(cancel/error) the child's whole process tree is killed so Docker
|
|
744
|
+
build subprocesses do not outlive the CLI.
|
|
745
|
+
|
|
746
|
+
Drain strategy is platform-specific: ``select`` is POSIX-only
|
|
747
|
+
(Windows supports sockets only), so Windows uses reader threads +
|
|
748
|
+
a queue instead. Chunk ordering and the timeout contract
|
|
749
|
+
(applied at the final ``proc.wait``) are identical on both."""
|
|
750
|
+
dp = self._resolve_path() or "docker"
|
|
751
|
+
try:
|
|
752
|
+
proc = subprocess.Popen(
|
|
753
|
+
[dp] + list(docker_argv),
|
|
754
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
755
|
+
start_new_session=True, # own process group -> cancel can kill tree
|
|
756
|
+
env=self._subprocess_env(),
|
|
757
|
+
)
|
|
758
|
+
except FileNotFoundError:
|
|
759
|
+
return ProcessResult(
|
|
760
|
+
stdout="", stderr="command not found: docker",
|
|
761
|
+
exit_code=-1, command_not_found=True,
|
|
762
|
+
)
|
|
763
|
+
except OSError as exc:
|
|
764
|
+
return ProcessResult(
|
|
765
|
+
stdout="", stderr=f"command error: {exc}",
|
|
766
|
+
exit_code=-1, command_not_found=True,
|
|
767
|
+
)
|
|
768
|
+
try:
|
|
769
|
+
if os.name == "posix":
|
|
770
|
+
result = self._drain_select(proc, on_chunk, timeout=timeout)
|
|
771
|
+
else:
|
|
772
|
+
result = self._drain_threads(proc, on_chunk, timeout=timeout)
|
|
773
|
+
except BaseException:
|
|
774
|
+
# Cancel or error: kill the Docker child's whole process tree.
|
|
775
|
+
_kill_child(proc)
|
|
776
|
+
try:
|
|
777
|
+
proc.wait(timeout=5)
|
|
778
|
+
except Exception:
|
|
779
|
+
pass
|
|
780
|
+
raise
|
|
781
|
+
finally:
|
|
782
|
+
for stream in (proc.stdout, proc.stderr):
|
|
783
|
+
try:
|
|
784
|
+
stream.close()
|
|
785
|
+
except Exception:
|
|
786
|
+
pass
|
|
787
|
+
return result
|
|
788
|
+
|
|
789
|
+
def _drain_select(self, proc: subprocess.Popen,
|
|
790
|
+
on_chunk: "Callable[[str, str], None]",
|
|
791
|
+
*, timeout: Optional[float]) -> ProcessResult:
|
|
792
|
+
"""POSIX: incremental read of both pipes via ``select`` (original
|
|
793
|
+
implementation, kept byte-for-byte equivalent)."""
|
|
794
|
+
streams = {proc.stdout: "stdout", proc.stderr: "stderr"}
|
|
795
|
+
open_fds = list(streams.keys())
|
|
796
|
+
while open_fds:
|
|
797
|
+
ready, _, _ = select.select(open_fds, [], [], 0.5)
|
|
798
|
+
for f in ready:
|
|
799
|
+
data = f.read1(4096)
|
|
800
|
+
if data:
|
|
801
|
+
on_chunk(streams[f], data.decode("utf-8", "replace"))
|
|
802
|
+
else:
|
|
803
|
+
open_fds.remove(f)
|
|
804
|
+
proc.wait(timeout=timeout)
|
|
805
|
+
return ProcessResult(stdout="", stderr="", exit_code=proc.returncode)
|
|
806
|
+
|
|
807
|
+
def _drain_threads(self, proc: subprocess.Popen,
|
|
808
|
+
on_chunk: "Callable[[str, str], None]",
|
|
809
|
+
*, timeout: Optional[float]) -> ProcessResult:
|
|
810
|
+
"""Windows: two daemon reader threads feed a queue; the main thread
|
|
811
|
+
drains it and invokes *on_chunk* so emission stays single-threaded
|
|
812
|
+
(``JsonlEmitter`` is not lock-protected)."""
|
|
813
|
+
q: "queue.Queue[Optional[tuple[str, bytes]]]" = queue.Queue()
|
|
814
|
+
|
|
815
|
+
def _reader(stream: "object", name: str) -> None:
|
|
816
|
+
try:
|
|
817
|
+
while True:
|
|
818
|
+
chunk = stream.read1(4096)
|
|
819
|
+
if not chunk:
|
|
820
|
+
break
|
|
821
|
+
q.put((name, chunk))
|
|
822
|
+
finally:
|
|
823
|
+
q.put(None) # EOF sentinel for this stream
|
|
824
|
+
|
|
825
|
+
threads = [
|
|
826
|
+
threading.Thread(target=_reader, args=(proc.stdout, "stdout"), daemon=True),
|
|
827
|
+
threading.Thread(target=_reader, args=(proc.stderr, "stderr"), daemon=True),
|
|
828
|
+
]
|
|
829
|
+
for t in threads:
|
|
830
|
+
t.start()
|
|
831
|
+
|
|
832
|
+
remaining = 2
|
|
833
|
+
while remaining:
|
|
834
|
+
try:
|
|
835
|
+
item = q.get(timeout=0.2)
|
|
836
|
+
except queue.Empty:
|
|
837
|
+
continue
|
|
838
|
+
if item is None:
|
|
839
|
+
remaining -= 1
|
|
840
|
+
continue
|
|
841
|
+
stream, chunk = item
|
|
842
|
+
on_chunk(stream, chunk.decode("utf-8", "replace"))
|
|
843
|
+
|
|
844
|
+
proc.wait(timeout=timeout)
|
|
845
|
+
return ProcessResult(stdout="", stderr="", exit_code=proc.returncode)
|
|
846
|
+
|
|
847
|
+
@property
|
|
848
|
+
def docker_path(self) -> str:
|
|
849
|
+
return self._resolve_path() or "docker"
|
|
850
|
+
|
|
851
|
+
# ------------------------------------------------------------------
|
|
852
|
+
# Container operations
|
|
853
|
+
# ------------------------------------------------------------------
|
|
854
|
+
|
|
855
|
+
def list_containers(self, all: bool = False) -> ProcessResult:
|
|
856
|
+
"""List containers via ``docker ps`` (or ``docker ps -a`` if all=True)."""
|
|
857
|
+
argv = ["ps", "--format", "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}"]
|
|
858
|
+
if all:
|
|
859
|
+
argv.insert(1, "-a")
|
|
860
|
+
return self.run_captured(argv, timeout=10.0)
|
|
861
|
+
|
|
862
|
+
def stop_container(self, container_name: str, timeout: int = 10) -> ProcessResult:
|
|
863
|
+
"""Stop a container via ``docker stop``."""
|
|
864
|
+
argv = ["stop", "-t", str(timeout), container_name]
|
|
865
|
+
return self.run_captured(argv, timeout=float(timeout + 5))
|
|
866
|
+
|
|
867
|
+
def remove_container(self, container_name: str, force: bool = False) -> ProcessResult:
|
|
868
|
+
"""Remove a container via ``docker rm`` (with -f if force=True)."""
|
|
869
|
+
argv = ["rm"]
|
|
870
|
+
if force:
|
|
871
|
+
argv.append("-f")
|
|
872
|
+
argv.append(container_name)
|
|
873
|
+
return self.run_captured(argv, timeout=10.0)
|
|
874
|
+
|
|
875
|
+
def inspect_container(self, container_name: str) -> ProcessResult:
|
|
876
|
+
"""Inspect a container via ``docker inspect``."""
|
|
877
|
+
argv = ["inspect", container_name]
|
|
878
|
+
return self.run_captured(argv, timeout=10.0)
|
|
879
|
+
|
|
880
|
+
# ------------------------------------------------------------------
|
|
881
|
+
# Image operations
|
|
882
|
+
# ------------------------------------------------------------------
|
|
883
|
+
|
|
884
|
+
def list_images(self) -> ProcessResult:
|
|
885
|
+
"""List images via ``docker images``."""
|
|
886
|
+
argv = ["images", "--format", "{{.Repository}}:{{.Tag}}\t{{.ID}}\t{{.Size}}"]
|
|
887
|
+
return self.run_captured(argv, timeout=10.0)
|
|
888
|
+
|
|
889
|
+
def remove_image(self, image_name: str, force: bool = False) -> ProcessResult:
|
|
890
|
+
"""Remove an image via ``docker rmi`` (with -f if force=True)."""
|
|
891
|
+
argv = ["rmi"]
|
|
892
|
+
if force:
|
|
893
|
+
argv.append("-f")
|
|
894
|
+
argv.append(image_name)
|
|
895
|
+
return self.run_captured(argv, timeout=30.0)
|
|
896
|
+
|
|
897
|
+
def pull_image(self, image_name: str) -> ProcessResult:
|
|
898
|
+
"""Pull an image via ``docker pull``."""
|
|
899
|
+
argv = ["pull", image_name]
|
|
900
|
+
return self.run_captured(argv, timeout=300.0)
|
|
901
|
+
|
|
902
|
+
def tag_image(self, source: str, target: str) -> ProcessResult:
|
|
903
|
+
"""Tag an image via ``docker tag``."""
|
|
904
|
+
argv = ["tag", source, target]
|
|
905
|
+
return self.run_captured(argv, timeout=10.0)
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
# ---------------------------------------------------------------------------
|
|
909
|
+
# Fake Docker executor (testing — zero process calls)
|
|
910
|
+
# ---------------------------------------------------------------------------
|
|
911
|
+
|
|
912
|
+
class FakeDockerExecutor:
|
|
913
|
+
"""Fully controllable fake executor.
|
|
914
|
+
|
|
915
|
+
Configure responses via attributes / dicts. Tracks all calls so tests
|
|
916
|
+
can assert call counts and exact argv.
|
|
917
|
+
"""
|
|
918
|
+
|
|
919
|
+
def __init__(self):
|
|
920
|
+
self._preflight_result: DockerPreflightResult = DockerPreflightResult(
|
|
921
|
+
docker_path="docker", available=True, reason="ok",
|
|
922
|
+
)
|
|
923
|
+
self._inspect_results: dict = {} # image_name → ImageInspectResult
|
|
924
|
+
self._default_inspect: ImageInspectResult = ImageInspectResult(
|
|
925
|
+
status=ImageInspectStatus.MISSING, image="",
|
|
926
|
+
)
|
|
927
|
+
self._captured_results: dict = {} # key substr → ProcessResult
|
|
928
|
+
self._default_captured: ProcessResult = ProcessResult(
|
|
929
|
+
stdout="", stderr="", exit_code=0,
|
|
930
|
+
)
|
|
931
|
+
self._streaming_exit_code: int = 0
|
|
932
|
+
self._streaming_chunks: List = [] # [(stream, chunk), ...] for run_streaming_captured
|
|
933
|
+
|
|
934
|
+
# Call tracking
|
|
935
|
+
self.calls: List[List[str]] = [] # run_captured argv
|
|
936
|
+
self.streaming_calls: List[List[str]] = [] # run_streaming argv
|
|
937
|
+
self.interactive_calls: List[tuple] = [] # (container, argv) for open_interactive
|
|
938
|
+
self.preflight_calls: int = 0
|
|
939
|
+
self.inspect_calls: List[str] = [] # image names inspected
|
|
940
|
+
|
|
941
|
+
# ------------------------------------------------------------------
|
|
942
|
+
# preflight
|
|
943
|
+
# ------------------------------------------------------------------
|
|
944
|
+
|
|
945
|
+
def preflight(self) -> DockerPreflightResult:
|
|
946
|
+
self.preflight_calls += 1
|
|
947
|
+
return self._preflight_result
|
|
948
|
+
|
|
949
|
+
def set_preflight(self, result: DockerPreflightResult) -> None:
|
|
950
|
+
self._preflight_result = result
|
|
951
|
+
|
|
952
|
+
# ------------------------------------------------------------------
|
|
953
|
+
# inspect_image
|
|
954
|
+
# ------------------------------------------------------------------
|
|
955
|
+
|
|
956
|
+
def inspect_image(self, image_name: str) -> ImageInspectResult:
|
|
957
|
+
self.inspect_calls.append(image_name)
|
|
958
|
+
return self._inspect_results.get(image_name, self._default_inspect)
|
|
959
|
+
|
|
960
|
+
def set_inspect(self, image_name: str, result: ImageInspectResult) -> None:
|
|
961
|
+
self._inspect_results[image_name] = result
|
|
962
|
+
|
|
963
|
+
def set_default_inspect(self, result: ImageInspectResult) -> None:
|
|
964
|
+
self._default_inspect = result
|
|
965
|
+
|
|
966
|
+
# ------------------------------------------------------------------
|
|
967
|
+
# run_captured
|
|
968
|
+
# ------------------------------------------------------------------
|
|
969
|
+
|
|
970
|
+
def run_captured(self, docker_argv: List[str],
|
|
971
|
+
*, timeout: Optional[float] = None) -> ProcessResult:
|
|
972
|
+
self.calls.append(list(docker_argv))
|
|
973
|
+
# Match by first subcommand or keyword
|
|
974
|
+
key = docker_argv[0] if docker_argv else ""
|
|
975
|
+
if key in self._captured_results:
|
|
976
|
+
return self._captured_results[key]
|
|
977
|
+
for k, v in self._captured_results.items():
|
|
978
|
+
if k in docker_argv:
|
|
979
|
+
return v
|
|
980
|
+
# fallback: scan all keys against joined arg string
|
|
981
|
+
joined = " ".join(docker_argv)
|
|
982
|
+
for k, v in self._captured_results.items():
|
|
983
|
+
if k in joined:
|
|
984
|
+
return v
|
|
985
|
+
return ProcessResult(
|
|
986
|
+
stdout=self._default_captured.stdout,
|
|
987
|
+
stderr=self._default_captured.stderr,
|
|
988
|
+
exit_code=self._default_captured.exit_code,
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
def set_captured(self, key: str, result: ProcessResult) -> None:
|
|
992
|
+
self._captured_results[key] = result
|
|
993
|
+
|
|
994
|
+
def set_default_captured(self, result: ProcessResult) -> None:
|
|
995
|
+
self._default_captured = result
|
|
996
|
+
|
|
997
|
+
# ------------------------------------------------------------------
|
|
998
|
+
# run_streaming
|
|
999
|
+
# ------------------------------------------------------------------
|
|
1000
|
+
|
|
1001
|
+
def run_streaming(self, docker_argv: List[str],
|
|
1002
|
+
*, timeout: Optional[float] = None) -> ProcessResult:
|
|
1003
|
+
self.streaming_calls.append(list(docker_argv))
|
|
1004
|
+
return ProcessResult(
|
|
1005
|
+
stdout="", stderr="",
|
|
1006
|
+
exit_code=self._streaming_exit_code if self._streaming_exit_code >= 0 else -1,
|
|
1007
|
+
command_not_found=(self._streaming_exit_code < 0),
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
# ------------------------------------------------------------------
|
|
1011
|
+
# open_interactive (G-02 resize chain)
|
|
1012
|
+
# ------------------------------------------------------------------
|
|
1013
|
+
|
|
1014
|
+
def set_streaming_exit(self, code: int) -> None:
|
|
1015
|
+
"""Configure the exit code returned by run_streaming / open_interactive."""
|
|
1016
|
+
self._streaming_exit_code = code
|
|
1017
|
+
|
|
1018
|
+
def run_streaming_captured(self, docker_argv: List[str],
|
|
1019
|
+
on_chunk: "Callable[[str, str], None]",
|
|
1020
|
+
*, timeout: Optional[float] = None) -> ProcessResult:
|
|
1021
|
+
"""Replay configured chunks to *on_chunk*, then return the preset exit
|
|
1022
|
+
code. Set chunks via :meth:`set_streaming_chunks`."""
|
|
1023
|
+
self.streaming_calls.append(list(docker_argv))
|
|
1024
|
+
for stream, chunk in self._streaming_chunks:
|
|
1025
|
+
on_chunk(stream, chunk)
|
|
1026
|
+
return ProcessResult(
|
|
1027
|
+
stdout="", stderr="",
|
|
1028
|
+
exit_code=self._streaming_exit_code if self._streaming_exit_code >= 0 else -1,
|
|
1029
|
+
command_not_found=(self._streaming_exit_code < 0),
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
def set_streaming_chunks(self, chunks) -> None:
|
|
1033
|
+
"""Configure ``[(stream, chunk), ...]`` replayed by run_streaming_captured."""
|
|
1034
|
+
self._streaming_chunks = list(chunks)
|
|
1035
|
+
|
|
1036
|
+
def open_interactive(
|
|
1037
|
+
self,
|
|
1038
|
+
container: str,
|
|
1039
|
+
argv: List[str],
|
|
1040
|
+
env: Optional[Dict[str, str]] = None,
|
|
1041
|
+
) -> ProcessResult:
|
|
1042
|
+
"""Fake interactive session: record (container, argv, env), return the
|
|
1043
|
+
configured streaming exit code."""
|
|
1044
|
+
self.interactive_calls.append((container, list(argv), dict(env or {})))
|
|
1045
|
+
return ProcessResult(
|
|
1046
|
+
stdout="", stderr="",
|
|
1047
|
+
exit_code=self._streaming_exit_code if self._streaming_exit_code >= 0 else -1,
|
|
1048
|
+
command_not_found=(self._streaming_exit_code < 0),
|
|
1049
|
+
)
|
|
1050
|
+
|
|
1051
|
+
# ------------------------------------------------------------------
|
|
1052
|
+
# Zero-call assertion
|
|
1053
|
+
# ------------------------------------------------------------------
|
|
1054
|
+
|
|
1055
|
+
@property
|
|
1056
|
+
def total_calls(self) -> int:
|
|
1057
|
+
return len(self.calls) + len(self.streaming_calls) + len(self.interactive_calls)
|
|
1058
|
+
|
|
1059
|
+
def assert_zero_docker_calls(self, msg: str = "") -> None:
|
|
1060
|
+
"""Fail if any docker subprocess call was made."""
|
|
1061
|
+
if self.total_calls > 0:
|
|
1062
|
+
raise AssertionError(
|
|
1063
|
+
f"Expected zero docker calls, got {self.total_calls}. {msg}"
|
|
1064
|
+
)
|
|
1065
|
+
|
|
1066
|
+
# ------------------------------------------------------------------
|
|
1067
|
+
# Container operations (fake implementations)
|
|
1068
|
+
# ------------------------------------------------------------------
|
|
1069
|
+
|
|
1070
|
+
def list_containers(self, all: bool = False) -> ProcessResult:
|
|
1071
|
+
"""Fake container list."""
|
|
1072
|
+
self.calls.append(("list_containers", {"all": all}))
|
|
1073
|
+
return self._default_captured or ProcessResult(
|
|
1074
|
+
stdout="", stderr="", exit_code=0
|
|
1075
|
+
)
|
|
1076
|
+
|
|
1077
|
+
def stop_container(self, container_name: str, timeout: int = 10) -> ProcessResult:
|
|
1078
|
+
"""Fake container stop."""
|
|
1079
|
+
self.calls.append(("stop_container", {"name": container_name, "timeout": timeout}))
|
|
1080
|
+
return self._default_captured or ProcessResult(
|
|
1081
|
+
stdout="", stderr="", exit_code=0
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
def remove_container(self, container_name: str, force: bool = False) -> ProcessResult:
|
|
1085
|
+
"""Fake container removal."""
|
|
1086
|
+
self.calls.append(("remove_container", {"name": container_name, "force": force}))
|
|
1087
|
+
return self._default_captured or ProcessResult(
|
|
1088
|
+
stdout="", stderr="", exit_code=0
|
|
1089
|
+
)
|
|
1090
|
+
|
|
1091
|
+
def inspect_container(self, container_name: str) -> ProcessResult:
|
|
1092
|
+
"""Fake container inspect."""
|
|
1093
|
+
self.calls.append(("inspect_container", {"name": container_name}))
|
|
1094
|
+
return self._default_captured or ProcessResult(
|
|
1095
|
+
stdout="[]", stderr="", exit_code=0
|
|
1096
|
+
)
|
|
1097
|
+
|
|
1098
|
+
# ------------------------------------------------------------------
|
|
1099
|
+
# Image operations (fake implementations)
|
|
1100
|
+
# ------------------------------------------------------------------
|
|
1101
|
+
|
|
1102
|
+
def list_images(self) -> ProcessResult:
|
|
1103
|
+
"""Fake image list."""
|
|
1104
|
+
self.calls.append(("list_images", {}))
|
|
1105
|
+
return self._default_captured or ProcessResult(
|
|
1106
|
+
stdout="", stderr="", exit_code=0
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
def remove_image(self, image_name: str, force: bool = False) -> ProcessResult:
|
|
1110
|
+
"""Fake image removal."""
|
|
1111
|
+
self.calls.append(("remove_image", {"name": image_name, "force": force}))
|
|
1112
|
+
return self._default_captured or ProcessResult(
|
|
1113
|
+
stdout="", stderr="", exit_code=0
|
|
1114
|
+
)
|
|
1115
|
+
|
|
1116
|
+
def pull_image(self, image_name: str) -> ProcessResult:
|
|
1117
|
+
"""Fake image pull."""
|
|
1118
|
+
self.calls.append(("pull_image", {"name": image_name}))
|
|
1119
|
+
return self._default_captured or ProcessResult(
|
|
1120
|
+
stdout="", stderr="", exit_code=0
|
|
1121
|
+
)
|
|
1122
|
+
|
|
1123
|
+
def tag_image(self, source: str, target: str) -> ProcessResult:
|
|
1124
|
+
"""Fake image tag."""
|
|
1125
|
+
self.calls.append(("tag_image", {"source": source, "target": target}))
|
|
1126
|
+
return self._default_captured or ProcessResult(
|
|
1127
|
+
stdout="", stderr="", exit_code=0
|
|
1128
|
+
)
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
# ---------------------------------------------------------------------------
|
|
1132
|
+
# Resource validation helpers (no subprocess)
|
|
1133
|
+
# ---------------------------------------------------------------------------
|
|
1134
|
+
|
|
1135
|
+
def validate_build_resources(root: Path) -> None:
|
|
1136
|
+
"""Validate that required build resources exist.
|
|
1137
|
+
|
|
1138
|
+
Raises FileNotFoundError with descriptive message.
|
|
1139
|
+
"""
|
|
1140
|
+
dockerfile = root / "container" / "Dockerfile"
|
|
1141
|
+
if not dockerfile.is_file():
|
|
1142
|
+
raise FileNotFoundError(f"Dockerfile not found: {dockerfile}")
|
|
1143
|
+
versions_env = root / "config" / "versions.env"
|
|
1144
|
+
if not versions_env.is_file():
|
|
1145
|
+
raise FileNotFoundError(f"versions.env not found: {versions_env}")
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def validate_run_resources(workspace: Path) -> None:
|
|
1149
|
+
"""Validate that *workspace* exists and is readable.
|
|
1150
|
+
|
|
1151
|
+
Raises FileNotFoundError, PermissionError, or NotADirectoryError.
|
|
1152
|
+
"""
|
|
1153
|
+
if not workspace.exists():
|
|
1154
|
+
raise FileNotFoundError(f"工作区目录不存在: {workspace}")
|
|
1155
|
+
if not workspace.is_dir():
|
|
1156
|
+
raise NotADirectoryError(f"Workspace is not a directory: {workspace}")
|
|
1157
|
+
if not os.access(str(workspace), os.R_OK):
|
|
1158
|
+
raise PermissionError(f"Workspace is not readable: {workspace}")
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def validate_proxy_config(proxy_config: Path) -> None:
|
|
1162
|
+
"""Validate that proxy config exists and is readable."""
|
|
1163
|
+
if not proxy_config.is_file():
|
|
1164
|
+
raise FileNotFoundError(
|
|
1165
|
+
f"Proxy network mode requires {proxy_config}. "
|
|
1166
|
+
"Please create the proxy configuration first."
|
|
1167
|
+
)
|
|
1168
|
+
if not os.access(str(proxy_config), os.R_OK):
|
|
1169
|
+
raise PermissionError(f"Proxy config is not readable: {proxy_config}")
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
# ---------------------------------------------------------------------------
|
|
1173
|
+
# Cross-platform argv formatter for dry-run display
|
|
1174
|
+
# ---------------------------------------------------------------------------
|
|
1175
|
+
|
|
1176
|
+
def format_argv_display(argv: List[str]) -> str:
|
|
1177
|
+
"""Format an argv list for human-readable display.
|
|
1178
|
+
|
|
1179
|
+
Uses ``shlex.join`` on POSIX, ``subprocess.list2cmdline`` on Windows.
|
|
1180
|
+
"""
|
|
1181
|
+
if sys.platform == "win32":
|
|
1182
|
+
return subprocess.list2cmdline(argv)
|
|
1183
|
+
return shlex.join(argv)
|