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,821 @@
|
|
|
1
|
+
"""Doctor checks — host-only diagnosis.
|
|
2
|
+
|
|
3
|
+
Each check is a simple callable that returns a ``CheckResult``.
|
|
4
|
+
Checks are ordered and the report determines the final exit code.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import stat
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Callable, List, Optional
|
|
14
|
+
|
|
15
|
+
from aisc.domain.models import CheckResult, CheckStatus, DoctorReport
|
|
16
|
+
from aisc.adapters.system import ProcessRunner, RealProcessRunner
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Timeout constants (seconds)
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
DOCKER_TIMEOUT = 8.0
|
|
24
|
+
GIT_TIMEOUT = 5.0
|
|
25
|
+
COMPOSE_TIMEOUT = 5.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Check implementations
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
def _check_docker_cli(
|
|
33
|
+
docker_path: str,
|
|
34
|
+
runner: ProcessRunner,
|
|
35
|
+
) -> CheckResult:
|
|
36
|
+
"""Check 1: Docker CLI discovery / version."""
|
|
37
|
+
result = runner.run([docker_path, "--version"], timeout=DOCKER_TIMEOUT)
|
|
38
|
+
if result.command_not_found:
|
|
39
|
+
return CheckResult(
|
|
40
|
+
name="docker-cli",
|
|
41
|
+
status=CheckStatus.FAIL,
|
|
42
|
+
message="Docker CLI not found",
|
|
43
|
+
detail="docker command not available on PATH",
|
|
44
|
+
hint="Install Docker: https://docs.docker.com/get-docker/",
|
|
45
|
+
)
|
|
46
|
+
if result.timed_out:
|
|
47
|
+
return CheckResult(
|
|
48
|
+
name="docker-cli",
|
|
49
|
+
status=CheckStatus.FAIL,
|
|
50
|
+
message="Docker CLI timed out",
|
|
51
|
+
detail="docker --version did not respond",
|
|
52
|
+
)
|
|
53
|
+
if result.exit_code != 0:
|
|
54
|
+
return CheckResult(
|
|
55
|
+
name="docker-cli",
|
|
56
|
+
status=CheckStatus.FAIL,
|
|
57
|
+
message="Docker CLI returned an error",
|
|
58
|
+
detail=result.stderr.strip() or result.stdout.strip(),
|
|
59
|
+
)
|
|
60
|
+
version_line = (
|
|
61
|
+
result.stdout.strip().splitlines()[0]
|
|
62
|
+
if result.stdout.strip()
|
|
63
|
+
else "(unknown)"
|
|
64
|
+
)
|
|
65
|
+
return CheckResult(
|
|
66
|
+
name="docker-cli",
|
|
67
|
+
status=CheckStatus.PASS,
|
|
68
|
+
message=version_line,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _check_docker_daemon(
|
|
73
|
+
docker_path: str,
|
|
74
|
+
runner: ProcessRunner,
|
|
75
|
+
docker_cli_available: bool,
|
|
76
|
+
) -> CheckResult:
|
|
77
|
+
"""Check 2: Docker daemon (``docker info``)."""
|
|
78
|
+
if not docker_cli_available:
|
|
79
|
+
return CheckResult(
|
|
80
|
+
name="docker-daemon",
|
|
81
|
+
status=CheckStatus.SKIP,
|
|
82
|
+
message="Docker CLI not available",
|
|
83
|
+
)
|
|
84
|
+
result = runner.run([docker_path, "info"], timeout=DOCKER_TIMEOUT)
|
|
85
|
+
if result.timed_out:
|
|
86
|
+
return CheckResult(
|
|
87
|
+
name="docker-daemon",
|
|
88
|
+
status=CheckStatus.FAIL,
|
|
89
|
+
message="Docker daemon check timed out",
|
|
90
|
+
hint="Ensure the Docker daemon is running",
|
|
91
|
+
)
|
|
92
|
+
if result.exit_code != 0:
|
|
93
|
+
return CheckResult(
|
|
94
|
+
name="docker-daemon",
|
|
95
|
+
status=CheckStatus.FAIL,
|
|
96
|
+
message="Docker daemon not running or unreachable",
|
|
97
|
+
detail=result.stderr.strip() or result.stdout.strip(),
|
|
98
|
+
hint="Ensure the Docker daemon is running",
|
|
99
|
+
)
|
|
100
|
+
return CheckResult(
|
|
101
|
+
name="docker-daemon",
|
|
102
|
+
status=CheckStatus.PASS,
|
|
103
|
+
message="Docker daemon is running",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _check_docker_permission(
|
|
108
|
+
docker_path: str,
|
|
109
|
+
runner: ProcessRunner,
|
|
110
|
+
docker_cli_available: bool,
|
|
111
|
+
docker_daemon_failed: bool,
|
|
112
|
+
) -> CheckResult:
|
|
113
|
+
"""Check 3: Docker permission (``docker ps``)."""
|
|
114
|
+
if not docker_cli_available:
|
|
115
|
+
return CheckResult(
|
|
116
|
+
name="docker-permission",
|
|
117
|
+
status=CheckStatus.SKIP,
|
|
118
|
+
message="Docker CLI not available",
|
|
119
|
+
)
|
|
120
|
+
if docker_daemon_failed:
|
|
121
|
+
return CheckResult(
|
|
122
|
+
name="docker-permission",
|
|
123
|
+
status=CheckStatus.SKIP,
|
|
124
|
+
message="Docker daemon not available",
|
|
125
|
+
)
|
|
126
|
+
result = runner.run([docker_path, "ps"], timeout=DOCKER_TIMEOUT)
|
|
127
|
+
if result.timed_out:
|
|
128
|
+
return CheckResult(
|
|
129
|
+
name="docker-permission",
|
|
130
|
+
status=CheckStatus.FAIL,
|
|
131
|
+
message="Docker permission check timed out",
|
|
132
|
+
)
|
|
133
|
+
if result.exit_code != 0:
|
|
134
|
+
return CheckResult(
|
|
135
|
+
name="docker-permission",
|
|
136
|
+
status=CheckStatus.FAIL,
|
|
137
|
+
message="Permission denied — cannot access Docker daemon",
|
|
138
|
+
detail=result.stderr.strip() or result.stdout.strip(),
|
|
139
|
+
hint="Ensure your user has access to Docker (e.g. docker group membership)",
|
|
140
|
+
)
|
|
141
|
+
return CheckResult(
|
|
142
|
+
name="docker-permission",
|
|
143
|
+
status=CheckStatus.PASS,
|
|
144
|
+
message="Docker permission OK",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _check_docker_buildx(
|
|
149
|
+
docker_path: str,
|
|
150
|
+
runner: ProcessRunner,
|
|
151
|
+
docker_cli_available: bool,
|
|
152
|
+
docker_daemon_failed: bool,
|
|
153
|
+
) -> CheckResult:
|
|
154
|
+
"""Check 4: Docker Buildx (``docker buildx version``)."""
|
|
155
|
+
if not docker_cli_available:
|
|
156
|
+
return CheckResult(
|
|
157
|
+
name="docker-buildx",
|
|
158
|
+
status=CheckStatus.SKIP,
|
|
159
|
+
message="Docker CLI not available",
|
|
160
|
+
)
|
|
161
|
+
if docker_daemon_failed:
|
|
162
|
+
return CheckResult(
|
|
163
|
+
name="docker-buildx",
|
|
164
|
+
status=CheckStatus.SKIP,
|
|
165
|
+
message="Docker daemon not available",
|
|
166
|
+
)
|
|
167
|
+
result = runner.run([docker_path, "buildx", "version"], timeout=DOCKER_TIMEOUT)
|
|
168
|
+
if result.timed_out:
|
|
169
|
+
return CheckResult(
|
|
170
|
+
name="docker-buildx",
|
|
171
|
+
status=CheckStatus.WARN,
|
|
172
|
+
message="Docker Buildx check timed out",
|
|
173
|
+
)
|
|
174
|
+
if result.exit_code != 0:
|
|
175
|
+
return CheckResult(
|
|
176
|
+
name="docker-buildx",
|
|
177
|
+
status=CheckStatus.WARN,
|
|
178
|
+
message="Docker Buildx not available",
|
|
179
|
+
detail=result.stderr.strip() or result.stdout.strip(),
|
|
180
|
+
hint="Install buildx: https://docs.docker.com/buildx/",
|
|
181
|
+
)
|
|
182
|
+
version_line = (
|
|
183
|
+
result.stdout.strip().splitlines()[0]
|
|
184
|
+
if result.stdout.strip()
|
|
185
|
+
else "(unknown)"
|
|
186
|
+
)
|
|
187
|
+
return CheckResult(
|
|
188
|
+
name="docker-buildx",
|
|
189
|
+
status=CheckStatus.PASS,
|
|
190
|
+
message=version_line,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _check_tun_device() -> CheckResult:
|
|
195
|
+
"""Check 5: Linux ``/dev/net/tun`` (character device)."""
|
|
196
|
+
if sys.platform != "linux":
|
|
197
|
+
return CheckResult(
|
|
198
|
+
name="tun-device",
|
|
199
|
+
status=CheckStatus.SKIP,
|
|
200
|
+
message="Not Linux — /dev/net/tun check skipped",
|
|
201
|
+
)
|
|
202
|
+
tun = Path("/dev/net/tun")
|
|
203
|
+
try:
|
|
204
|
+
st = tun.stat()
|
|
205
|
+
if stat.S_ISCHR(st.st_mode):
|
|
206
|
+
return CheckResult(
|
|
207
|
+
name="tun-device",
|
|
208
|
+
status=CheckStatus.PASS,
|
|
209
|
+
message="/dev/net/tun exists and is a character device",
|
|
210
|
+
)
|
|
211
|
+
return CheckResult(
|
|
212
|
+
name="tun-device",
|
|
213
|
+
status=CheckStatus.WARN,
|
|
214
|
+
message="/dev/net/tun exists but is not a character device",
|
|
215
|
+
)
|
|
216
|
+
except FileNotFoundError:
|
|
217
|
+
return CheckResult(
|
|
218
|
+
name="tun-device",
|
|
219
|
+
status=CheckStatus.WARN,
|
|
220
|
+
message="/dev/net/tun not found",
|
|
221
|
+
detail="TUN device required for transparent proxy mode",
|
|
222
|
+
)
|
|
223
|
+
except OSError:
|
|
224
|
+
return CheckResult(
|
|
225
|
+
name="tun-device",
|
|
226
|
+
status=CheckStatus.WARN,
|
|
227
|
+
message="/dev/net/tun inaccessible",
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _check_aisc_root(
|
|
232
|
+
root: Optional[Path],
|
|
233
|
+
root_error: Optional[str] = None,
|
|
234
|
+
) -> CheckResult:
|
|
235
|
+
"""Check 6: AISC root / bundle discovery.
|
|
236
|
+
|
|
237
|
+
*root_error* is the original error message when an explicit or env
|
|
238
|
+
source was invalid. Passed through verbatim.
|
|
239
|
+
"""
|
|
240
|
+
if root_error is not None:
|
|
241
|
+
return CheckResult(
|
|
242
|
+
name="aisc-root",
|
|
243
|
+
status=CheckStatus.FAIL,
|
|
244
|
+
message=root_error,
|
|
245
|
+
)
|
|
246
|
+
if root is not None:
|
|
247
|
+
return CheckResult(
|
|
248
|
+
name="aisc-root",
|
|
249
|
+
status=CheckStatus.PASS,
|
|
250
|
+
message=f"Found at {root}",
|
|
251
|
+
)
|
|
252
|
+
return CheckResult(
|
|
253
|
+
name="aisc-root",
|
|
254
|
+
status=CheckStatus.WARN,
|
|
255
|
+
message="AISC root not found (no repo in parent directories)",
|
|
256
|
+
detail="Some checks require a repo root to read VERSION / container files",
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _check_bundle_store(root: Optional[Path]) -> CheckResult:
|
|
261
|
+
"""Check 6b: the fetched-bundle store (pip installs' build resources).
|
|
262
|
+
|
|
263
|
+
PASS when the ACTIVE root is a repo/frozen bundle (the store is
|
|
264
|
+
irrelevant); PASS when it resolves from ``bundles/<ver>/``; WARN when a
|
|
265
|
+
store exists but nothing is compatible; WARN on pip forms with no store
|
|
266
|
+
at all (build needs ``aisc bundle fetch``)."""
|
|
267
|
+
import sys as _sys
|
|
268
|
+
|
|
269
|
+
from aisc import __version__
|
|
270
|
+
|
|
271
|
+
pip_form = (
|
|
272
|
+
not getattr(_sys, "frozen", False)
|
|
273
|
+
and __file__ is not None
|
|
274
|
+
and "site-packages" in str(Path(__file__).resolve())
|
|
275
|
+
)
|
|
276
|
+
try:
|
|
277
|
+
from aisc.application.data_root import shared_root
|
|
278
|
+
|
|
279
|
+
dr = shared_root()
|
|
280
|
+
except Exception:
|
|
281
|
+
dr = None
|
|
282
|
+
if dr is not None:
|
|
283
|
+
from aisc.application.resources import find_data_root_bundles
|
|
284
|
+
|
|
285
|
+
candidates, skipped = find_data_root_bundles(dr)
|
|
286
|
+
if candidates:
|
|
287
|
+
return CheckResult(
|
|
288
|
+
name="aisc-bundle",
|
|
289
|
+
status=CheckStatus.PASS,
|
|
290
|
+
message=f"bundle store: {candidates[0]}",
|
|
291
|
+
)
|
|
292
|
+
if skipped:
|
|
293
|
+
return CheckResult(
|
|
294
|
+
name="aisc-bundle",
|
|
295
|
+
status=CheckStatus.WARN,
|
|
296
|
+
message="bundle store present but no compatible bundle",
|
|
297
|
+
detail="; ".join(skipped[:3]),
|
|
298
|
+
)
|
|
299
|
+
if root is not None:
|
|
300
|
+
return CheckResult(
|
|
301
|
+
name="aisc-bundle",
|
|
302
|
+
status=CheckStatus.PASS,
|
|
303
|
+
message=f"root resolves without the bundle store ({root})",
|
|
304
|
+
)
|
|
305
|
+
if pip_form:
|
|
306
|
+
return CheckResult(
|
|
307
|
+
name="aisc-bundle",
|
|
308
|
+
status=CheckStatus.WARN,
|
|
309
|
+
message="pip install without build resources",
|
|
310
|
+
detail="run `aisc bundle fetch` to download this version's bundle "
|
|
311
|
+
"(build/docker-rebuild need it)",
|
|
312
|
+
)
|
|
313
|
+
return CheckResult(
|
|
314
|
+
name="aisc-bundle",
|
|
315
|
+
status=CheckStatus.WARN,
|
|
316
|
+
message="no bundle store and no repo root",
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _check_channel_confusion() -> CheckResult:
|
|
321
|
+
"""Check 6c: multiple `aisc` hits on PATH -> WARN with the list.
|
|
322
|
+
|
|
323
|
+
Only lists paths + file info by default — running every hit's
|
|
324
|
+
`--version` is `--verbose` territory (never execute unknown binaries
|
|
325
|
+
unprompted)."""
|
|
326
|
+
import shutil as _shutil
|
|
327
|
+
|
|
328
|
+
hits: list = []
|
|
329
|
+
for d in os.environ.get("PATH", "").split(os.pathsep):
|
|
330
|
+
if not d:
|
|
331
|
+
continue
|
|
332
|
+
for cand in (Path(d) / "aisc", Path(d) / "aisc.exe"):
|
|
333
|
+
try:
|
|
334
|
+
if cand.is_file() and cand.stat().st_size > 0:
|
|
335
|
+
hits.append(str(cand))
|
|
336
|
+
except OSError:
|
|
337
|
+
continue
|
|
338
|
+
if len(hits) > 1:
|
|
339
|
+
return CheckResult(
|
|
340
|
+
name="channel-confusion",
|
|
341
|
+
status=CheckStatus.WARN,
|
|
342
|
+
message=f"{len(hits)} aisc executables on PATH",
|
|
343
|
+
detail="; ".join(hits[:4]) + (" …" if len(hits) > 4 else ""),
|
|
344
|
+
)
|
|
345
|
+
if len(hits) == 1:
|
|
346
|
+
return CheckResult(name="channel-confusion", status=CheckStatus.PASS,
|
|
347
|
+
message=hits[0])
|
|
348
|
+
return CheckResult(name="channel-confusion", status=CheckStatus.WARN,
|
|
349
|
+
message="no aisc executable found on PATH")
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _check_platform_support() -> CheckResult:
|
|
353
|
+
"""Check 6d: py3-none-any wheel installs anywhere; payloads are
|
|
354
|
+
linux-amd64. Unsupported combos get a WARN (no install-time hook
|
|
355
|
+
exists for pure wheels — guide 3.5.5)."""
|
|
356
|
+
import platform as _platform
|
|
357
|
+
|
|
358
|
+
combo = f"{_platform.system().lower()}-{_platform.machine().lower()}"
|
|
359
|
+
supported = {"windows-amd64", "linux-x86_64", "darwin-arm64"}
|
|
360
|
+
if combo in supported:
|
|
361
|
+
return CheckResult(name="platform-support", status=CheckStatus.PASS,
|
|
362
|
+
message=combo)
|
|
363
|
+
return CheckResult(
|
|
364
|
+
name="platform-support",
|
|
365
|
+
status=CheckStatus.WARN,
|
|
366
|
+
message=f"unsupported combination {combo}",
|
|
367
|
+
detail="official artifacts are windows-x86_64 / linux-x86_64 / "
|
|
368
|
+
"macos-arm64; builds may be extremely slow or unstable here",
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _check_root_files(root: Optional[Path]) -> List[CheckResult]:
|
|
373
|
+
"""Check 7: Verify key root files exist."""
|
|
374
|
+
if root is None:
|
|
375
|
+
return [
|
|
376
|
+
CheckResult(
|
|
377
|
+
name="aisc-root-files",
|
|
378
|
+
status=CheckStatus.SKIP,
|
|
379
|
+
message="No AISC root found — file checks skipped",
|
|
380
|
+
)
|
|
381
|
+
]
|
|
382
|
+
|
|
383
|
+
required = {
|
|
384
|
+
# A2 dual-shape: repo checkouts carry it at src/aisc/VERSION.
|
|
385
|
+
"VERSION": ("VERSION", "src/aisc/VERSION"),
|
|
386
|
+
"container/Dockerfile": ("container/Dockerfile",),
|
|
387
|
+
"config/versions.env": ("config/versions.env",),
|
|
388
|
+
}
|
|
389
|
+
results: List[CheckResult] = []
|
|
390
|
+
for label, rels in required.items():
|
|
391
|
+
p = next((root / rel for rel in rels if (root / rel).is_file()), None)
|
|
392
|
+
if p is not None:
|
|
393
|
+
results.append(
|
|
394
|
+
CheckResult(
|
|
395
|
+
name=f"root-file:{label}",
|
|
396
|
+
status=CheckStatus.PASS,
|
|
397
|
+
message=f"{rels[0]} exists",
|
|
398
|
+
)
|
|
399
|
+
)
|
|
400
|
+
else:
|
|
401
|
+
results.append(
|
|
402
|
+
CheckResult(
|
|
403
|
+
name=f"root-file:{label}",
|
|
404
|
+
status=CheckStatus.FAIL,
|
|
405
|
+
message=f"{rels[0]} not found",
|
|
406
|
+
)
|
|
407
|
+
)
|
|
408
|
+
return results
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _check_git(
|
|
412
|
+
git_path: str,
|
|
413
|
+
runner: ProcessRunner,
|
|
414
|
+
) -> CheckResult:
|
|
415
|
+
"""Check 8: Git discovery / version."""
|
|
416
|
+
result = runner.run([git_path, "--version"], timeout=GIT_TIMEOUT)
|
|
417
|
+
if result.command_not_found or result.exit_code != 0:
|
|
418
|
+
return CheckResult(
|
|
419
|
+
name="git",
|
|
420
|
+
status=CheckStatus.WARN,
|
|
421
|
+
message="Git not available",
|
|
422
|
+
)
|
|
423
|
+
version_line = (
|
|
424
|
+
result.stdout.strip().splitlines()[0]
|
|
425
|
+
if result.stdout.strip()
|
|
426
|
+
else "(unknown)"
|
|
427
|
+
)
|
|
428
|
+
return CheckResult(
|
|
429
|
+
name="git",
|
|
430
|
+
status=CheckStatus.PASS,
|
|
431
|
+
message=version_line,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _check_docker_compose(
|
|
436
|
+
docker_path: Optional[str],
|
|
437
|
+
runner: ProcessRunner,
|
|
438
|
+
docker_cli_available: bool,
|
|
439
|
+
) -> CheckResult:
|
|
440
|
+
"""Check 9: Docker Compose (``docker compose version``)."""
|
|
441
|
+
if not docker_cli_available or docker_path is None:
|
|
442
|
+
return CheckResult(
|
|
443
|
+
name="docker-compose",
|
|
444
|
+
status=CheckStatus.SKIP,
|
|
445
|
+
message="Docker CLI not available",
|
|
446
|
+
)
|
|
447
|
+
result = runner.run(
|
|
448
|
+
[docker_path, "compose", "version"], timeout=COMPOSE_TIMEOUT,
|
|
449
|
+
)
|
|
450
|
+
if result.command_not_found:
|
|
451
|
+
return CheckResult(
|
|
452
|
+
name="docker-compose",
|
|
453
|
+
status=CheckStatus.WARN,
|
|
454
|
+
message="Docker Compose subcommand not available",
|
|
455
|
+
detail="docker command exists but 'compose' subcommand not found",
|
|
456
|
+
hint="Ensure Docker Compose plugin is installed: https://docs.docker.com/compose/install/",
|
|
457
|
+
)
|
|
458
|
+
if result.timed_out:
|
|
459
|
+
return CheckResult(
|
|
460
|
+
name="docker-compose",
|
|
461
|
+
status=CheckStatus.WARN,
|
|
462
|
+
message="Docker Compose check timed out",
|
|
463
|
+
)
|
|
464
|
+
if result.exit_code != 0:
|
|
465
|
+
detail = result.stderr.strip() or result.stdout.strip()
|
|
466
|
+
lowered = detail.lower()
|
|
467
|
+
if "unknown command" in lowered or "not a docker command" in lowered:
|
|
468
|
+
return CheckResult(
|
|
469
|
+
name="docker-compose",
|
|
470
|
+
status=CheckStatus.WARN,
|
|
471
|
+
message="Docker Compose subcommand not available",
|
|
472
|
+
detail=detail,
|
|
473
|
+
hint="Install the Docker Compose plugin: https://docs.docker.com/compose/install/",
|
|
474
|
+
)
|
|
475
|
+
return CheckResult(
|
|
476
|
+
name="docker-compose",
|
|
477
|
+
status=CheckStatus.WARN,
|
|
478
|
+
message="Docker Compose returned an error",
|
|
479
|
+
detail=detail,
|
|
480
|
+
hint="Run 'docker compose version' manually and check the Docker Compose plugin installation.",
|
|
481
|
+
)
|
|
482
|
+
version_line = (
|
|
483
|
+
result.stdout.strip().splitlines()[0]
|
|
484
|
+
if result.stdout.strip()
|
|
485
|
+
else "(unknown)"
|
|
486
|
+
)
|
|
487
|
+
return CheckResult(
|
|
488
|
+
name="docker-compose",
|
|
489
|
+
status=CheckStatus.PASS,
|
|
490
|
+
message=version_line,
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _check_wsl_memory(
|
|
495
|
+
home: Optional[Path] = None,
|
|
496
|
+
total_ram_gb: Optional[Callable[[], Optional[float]]] = None,
|
|
497
|
+
platform: Optional[str] = None,
|
|
498
|
+
) -> CheckResult:
|
|
499
|
+
"""O6 (opt-batch, D-11): WSL2 memory guidance on low-RAM Windows hosts.
|
|
500
|
+
|
|
501
|
+
Docker Desktop's WSL2 backend defaults to ~50%/8GB of host RAM (whichever
|
|
502
|
+
is less) for the utility VM — on an 8GB machine the VM, Vmmem, and the
|
|
503
|
+
host then fight for memory and the container side (cc-switch daemon OOM,
|
|
504
|
+
see O5) suffers first. When the host has ≤8GB and `%USERPROFILE%\\.wslconfig`
|
|
505
|
+
does not cap `memory` under [wsl2], surface a WARN with the recommended
|
|
506
|
+
snippet. Advisory ONLY — never edits system config. Non-Windows hosts SKIP.
|
|
507
|
+
"""
|
|
508
|
+
if (platform or sys.platform) != "win32":
|
|
509
|
+
return CheckResult(name="wsl-memory", status=CheckStatus.SKIP,
|
|
510
|
+
message="Windows-only check")
|
|
511
|
+
|
|
512
|
+
home = home or Path.home()
|
|
513
|
+
cfg = home / ".wslconfig"
|
|
514
|
+
|
|
515
|
+
def _default_total_ram_gb() -> Optional[float]:
|
|
516
|
+
import ctypes
|
|
517
|
+
|
|
518
|
+
class MEMORYSTATUSEX(ctypes.Structure):
|
|
519
|
+
_fields_ = [
|
|
520
|
+
("dwLength", ctypes.c_ulong),
|
|
521
|
+
("dwMemoryLoad", ctypes.c_ulong),
|
|
522
|
+
("ullTotalPhys", ctypes.c_ulonglong),
|
|
523
|
+
("ullAvailPhys", ctypes.c_ulonglong),
|
|
524
|
+
("ullTotalPageFile", ctypes.c_ulonglong),
|
|
525
|
+
("ullAvailPageFile", ctypes.c_ulonglong),
|
|
526
|
+
("ullTotalVirtual", ctypes.c_ulonglong),
|
|
527
|
+
("ullAvailVirtual", ctypes.c_ulonglong),
|
|
528
|
+
("ullAvailExtendedVirtual", ctypes.c_ulonglong),
|
|
529
|
+
]
|
|
530
|
+
|
|
531
|
+
stat = MEMORYSTATUSEX()
|
|
532
|
+
stat.dwLength = ctypes.sizeof(MEMORYSTATUSEX)
|
|
533
|
+
if not ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(stat)):
|
|
534
|
+
return None
|
|
535
|
+
return stat.ullTotalPhys / (1024 ** 3)
|
|
536
|
+
|
|
537
|
+
ram = (total_ram_gb or _default_total_ram_gb)()
|
|
538
|
+
if ram is None:
|
|
539
|
+
return CheckResult(name="wsl-memory", status=CheckStatus.SKIP,
|
|
540
|
+
message="Could not read physical memory")
|
|
541
|
+
|
|
542
|
+
capped = False
|
|
543
|
+
try:
|
|
544
|
+
if cfg.is_file():
|
|
545
|
+
in_wsl2 = False
|
|
546
|
+
for line in cfg.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
547
|
+
stripped = line.strip()
|
|
548
|
+
if stripped.startswith("["):
|
|
549
|
+
in_wsl2 = stripped.lower() == "[wsl2]"
|
|
550
|
+
elif in_wsl2 and stripped.lower().startswith("memory"):
|
|
551
|
+
capped = True # any memory= cap counts, value not judged
|
|
552
|
+
break
|
|
553
|
+
except OSError:
|
|
554
|
+
pass
|
|
555
|
+
|
|
556
|
+
if capped or ram > 8.5:
|
|
557
|
+
return CheckResult(
|
|
558
|
+
name="wsl-memory",
|
|
559
|
+
status=CheckStatus.PASS,
|
|
560
|
+
message=(
|
|
561
|
+
f"物理内存 {ram:.0f}GB"
|
|
562
|
+
+ (",.wslconfig 已设 memory 上限" if capped else ",无需 WSL 内存上限")
|
|
563
|
+
),
|
|
564
|
+
)
|
|
565
|
+
return CheckResult(
|
|
566
|
+
name="wsl-memory",
|
|
567
|
+
status=CheckStatus.WARN,
|
|
568
|
+
message=(
|
|
569
|
+
f"物理内存 {ram:.0f}GB 且 .wslconfig 未限制 WSL2 内存——"
|
|
570
|
+
"Docker 的 WSL2 虚拟机默认可占约 50% 内存,低配机上易与宿主争抢"
|
|
571
|
+
"(容器侧 cc-switch daemon 可能被 OOM)"
|
|
572
|
+
),
|
|
573
|
+
hint=(
|
|
574
|
+
"建议在 %USERPROFILE%\\.wslconfig 添加:\n"
|
|
575
|
+
"[wsl2]\n"
|
|
576
|
+
f"memory={'4GB' if ram <= 4.5 else '5GB'}\n"
|
|
577
|
+
"保存后运行 `wsl --shutdown` 生效(仅建议,AISC 不会自动修改)"
|
|
578
|
+
),
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _check_root_writable(root: Optional[Path]) -> CheckResult:
|
|
583
|
+
"""Check 10: Project root directory writability (read-only diagnostic)."""
|
|
584
|
+
if root is None:
|
|
585
|
+
return CheckResult(
|
|
586
|
+
name="root-writable",
|
|
587
|
+
status=CheckStatus.SKIP,
|
|
588
|
+
message="No AISC root found — writability check skipped",
|
|
589
|
+
)
|
|
590
|
+
try:
|
|
591
|
+
if not root.exists():
|
|
592
|
+
return CheckResult(
|
|
593
|
+
name="root-writable",
|
|
594
|
+
status=CheckStatus.WARN,
|
|
595
|
+
message=f"Root directory does not exist: {root}",
|
|
596
|
+
hint="Verify the project root path is correct",
|
|
597
|
+
)
|
|
598
|
+
if not root.is_dir():
|
|
599
|
+
return CheckResult(
|
|
600
|
+
name="root-writable",
|
|
601
|
+
status=CheckStatus.WARN,
|
|
602
|
+
message=f"Root path is not a directory: {root}",
|
|
603
|
+
hint="The project root must be a directory",
|
|
604
|
+
)
|
|
605
|
+
if os.access(str(root), os.W_OK):
|
|
606
|
+
return CheckResult(
|
|
607
|
+
name="root-writable",
|
|
608
|
+
status=CheckStatus.PASS,
|
|
609
|
+
message=f"Root directory is writable: {root}",
|
|
610
|
+
)
|
|
611
|
+
else:
|
|
612
|
+
return CheckResult(
|
|
613
|
+
name="root-writable",
|
|
614
|
+
status=CheckStatus.WARN,
|
|
615
|
+
message=f"Root directory may not be writable: {root}",
|
|
616
|
+
detail="Permission pre-check only — not a write guarantee",
|
|
617
|
+
hint="Check directory permissions (e.g. ls -ld on the directory)",
|
|
618
|
+
)
|
|
619
|
+
except OSError as exc:
|
|
620
|
+
return CheckResult(
|
|
621
|
+
name="root-writable",
|
|
622
|
+
status=CheckStatus.WARN,
|
|
623
|
+
message=f"Cannot check root writability: {root}",
|
|
624
|
+
detail=str(exc),
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
# ---------------------------------------------------------------------------
|
|
629
|
+
# Exit code priority logic
|
|
630
|
+
# ---------------------------------------------------------------------------
|
|
631
|
+
|
|
632
|
+
EXIT_OK = 0
|
|
633
|
+
EXIT_GENERAL = 1
|
|
634
|
+
EXIT_USAGE = 2
|
|
635
|
+
EXIT_DOCKER_UNAVAILABLE = 3
|
|
636
|
+
EXIT_PERMISSION_DENIED = 9
|
|
637
|
+
|
|
638
|
+
ERR_GENERAL = "AISC_ERR_GENERAL"
|
|
639
|
+
ERR_DOCKER_UNAVAILABLE = "AISC_ERR_DOCKER_UNAVAILABLE"
|
|
640
|
+
ERR_PERMISSION_DENIED = "AISC_ERR_PERMISSION_DENIED"
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _compute_exit_code(checks: List[CheckResult]) -> tuple:
|
|
644
|
+
"""Compute final exit code and error details from a completed check list."""
|
|
645
|
+
docker_cli_fail = any(
|
|
646
|
+
c.name == "docker-cli" and c.status == CheckStatus.FAIL for c in checks
|
|
647
|
+
)
|
|
648
|
+
docker_daemon_fail = any(
|
|
649
|
+
c.name == "docker-daemon" and c.status == CheckStatus.FAIL for c in checks
|
|
650
|
+
)
|
|
651
|
+
docker_permission_fail = any(
|
|
652
|
+
c.name == "docker-permission" and c.status == CheckStatus.FAIL for c in checks
|
|
653
|
+
)
|
|
654
|
+
explicit_root_invalid = any(
|
|
655
|
+
c.name == "aisc-root" and c.status == CheckStatus.FAIL for c in checks
|
|
656
|
+
)
|
|
657
|
+
any_other_fail = any(
|
|
658
|
+
c.status == CheckStatus.FAIL
|
|
659
|
+
and c.name
|
|
660
|
+
not in ("docker-cli", "docker-daemon", "docker-permission", "aisc-root")
|
|
661
|
+
for c in checks
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
if docker_cli_fail or docker_daemon_fail:
|
|
665
|
+
return EXIT_DOCKER_UNAVAILABLE, ERR_DOCKER_UNAVAILABLE, "Docker is not available"
|
|
666
|
+
|
|
667
|
+
if docker_permission_fail:
|
|
668
|
+
return EXIT_PERMISSION_DENIED, ERR_PERMISSION_DENIED, "Docker permission denied"
|
|
669
|
+
|
|
670
|
+
if explicit_root_invalid or any_other_fail:
|
|
671
|
+
return EXIT_GENERAL, ERR_GENERAL, "One or more checks failed"
|
|
672
|
+
|
|
673
|
+
return EXIT_OK, None, None
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
# ---------------------------------------------------------------------------
|
|
677
|
+
# Public API
|
|
678
|
+
# ---------------------------------------------------------------------------
|
|
679
|
+
|
|
680
|
+
CheckFunc = Callable[[], CheckResult]
|
|
681
|
+
CheckListFunc = Callable[[], List[CheckResult]]
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def run_doctor(
|
|
685
|
+
runner: Optional[ProcessRunner] = None,
|
|
686
|
+
root: Optional[Path] = None,
|
|
687
|
+
root_error: Optional[str] = None,
|
|
688
|
+
which: Optional[Callable[[str], Optional[str]]] = None,
|
|
689
|
+
) -> DoctorReport:
|
|
690
|
+
"""Run all host doctor checks and return a ``DoctorReport``.
|
|
691
|
+
|
|
692
|
+
Parameters
|
|
693
|
+
----------
|
|
694
|
+
runner:
|
|
695
|
+
Process runner. Defaults to ``RealProcessRunner``.
|
|
696
|
+
root:
|
|
697
|
+
Pre-located AISC root (may be ``None``).
|
|
698
|
+
root_error:
|
|
699
|
+
Original error message when an explicit or env root source was
|
|
700
|
+
invalid. Passed through verbatim to the aisc-root check.
|
|
701
|
+
which:
|
|
702
|
+
Command-discovery callable. Defaults to ``shutil.which``.
|
|
703
|
+
When ``which('docker')`` returns ``None``, docker-cli is **FAIL**
|
|
704
|
+
(exit 3 / AISC_ERR_DOCKER_UNAVAILABLE) and subsequent docker
|
|
705
|
+
sub-command checks are SKIP with zero subprocess calls.
|
|
706
|
+
"""
|
|
707
|
+
import shutil as _shutil
|
|
708
|
+
|
|
709
|
+
r = runner or RealProcessRunner()
|
|
710
|
+
w: Callable[[str], Optional[str]] = which if which is not None else _shutil.which
|
|
711
|
+
|
|
712
|
+
docker_raw = w("docker") # may be None when not on PATH / injected None-return
|
|
713
|
+
git_raw = w("git")
|
|
714
|
+
|
|
715
|
+
checks: List[CheckResult] = []
|
|
716
|
+
|
|
717
|
+
# 1. docker-cli
|
|
718
|
+
# which('docker') is None → FAIL (Docker CLI not found, exit 3)
|
|
719
|
+
# which('docker') returns a path → run --version check
|
|
720
|
+
if docker_raw is None:
|
|
721
|
+
docker_cli = CheckResult(
|
|
722
|
+
name="docker-cli",
|
|
723
|
+
status=CheckStatus.FAIL,
|
|
724
|
+
message="Docker CLI not found",
|
|
725
|
+
detail="docker command not available on PATH",
|
|
726
|
+
hint="Install Docker: https://docs.docker.com/get-docker/",
|
|
727
|
+
)
|
|
728
|
+
docker_cli_available = False
|
|
729
|
+
docker_path = None
|
|
730
|
+
else:
|
|
731
|
+
docker_path = docker_raw
|
|
732
|
+
docker_cli = _check_docker_cli(docker_path, r)
|
|
733
|
+
docker_cli_available = docker_cli.status != CheckStatus.FAIL
|
|
734
|
+
checks.append(docker_cli)
|
|
735
|
+
|
|
736
|
+
# 2. docker-daemon (SKIP when CLI unavailable — no subprocess call)
|
|
737
|
+
if docker_path and docker_cli_available:
|
|
738
|
+
docker_daemon = _check_docker_daemon(docker_path, r, docker_cli_available)
|
|
739
|
+
else:
|
|
740
|
+
docker_daemon = CheckResult(
|
|
741
|
+
name="docker-daemon",
|
|
742
|
+
status=CheckStatus.SKIP,
|
|
743
|
+
message="Docker CLI not available",
|
|
744
|
+
)
|
|
745
|
+
checks.append(docker_daemon)
|
|
746
|
+
docker_daemon_failed = docker_daemon.status == CheckStatus.FAIL
|
|
747
|
+
|
|
748
|
+
# 3. docker-permission (SKIP when CLI/daemon unavailable)
|
|
749
|
+
if docker_path and docker_cli_available:
|
|
750
|
+
docker_perm = _check_docker_permission(
|
|
751
|
+
docker_path, r, docker_cli_available, docker_daemon_failed
|
|
752
|
+
)
|
|
753
|
+
else:
|
|
754
|
+
docker_perm = CheckResult(
|
|
755
|
+
name="docker-permission",
|
|
756
|
+
status=CheckStatus.SKIP,
|
|
757
|
+
message="Docker CLI not available",
|
|
758
|
+
)
|
|
759
|
+
checks.append(docker_perm)
|
|
760
|
+
|
|
761
|
+
# 4. buildx (SKIP when CLI/daemon unavailable)
|
|
762
|
+
if docker_path and docker_cli_available:
|
|
763
|
+
buildx = _check_docker_buildx(
|
|
764
|
+
docker_path, r, docker_cli_available, docker_daemon_failed
|
|
765
|
+
)
|
|
766
|
+
else:
|
|
767
|
+
buildx = CheckResult(
|
|
768
|
+
name="docker-buildx",
|
|
769
|
+
status=CheckStatus.SKIP,
|
|
770
|
+
message="Docker CLI not available",
|
|
771
|
+
)
|
|
772
|
+
checks.append(buildx)
|
|
773
|
+
|
|
774
|
+
# 5. TUN
|
|
775
|
+
checks.append(_check_tun_device())
|
|
776
|
+
|
|
777
|
+
# 6. aisc root — pass through root_error verbatim
|
|
778
|
+
checks.append(_check_aisc_root(root, root_error=root_error))
|
|
779
|
+
|
|
780
|
+
# 6b. aisc-bundle store (0.1.0 A3, guide 3.3.4): append-only check —
|
|
781
|
+
# the pip install form has no repo, so the aisc-root WARN above is
|
|
782
|
+
# misleading there. This check names the fetch path explicitly.
|
|
783
|
+
checks.append(_check_bundle_store(root))
|
|
784
|
+
|
|
785
|
+
# 6c. A8 (guide 3.5.5): channel + platform-support — multi-channel
|
|
786
|
+
# installs are the #1 "upgraded but still old version" confusion.
|
|
787
|
+
checks.append(_check_channel_confusion())
|
|
788
|
+
checks.append(_check_platform_support())
|
|
789
|
+
|
|
790
|
+
# 7. root files
|
|
791
|
+
checks.extend(_check_root_files(root))
|
|
792
|
+
|
|
793
|
+
# 8. git (WARN when missing, not SKIP)
|
|
794
|
+
if git_raw:
|
|
795
|
+
checks.append(_check_git(git_raw, r))
|
|
796
|
+
else:
|
|
797
|
+
checks.append(
|
|
798
|
+
CheckResult(
|
|
799
|
+
name="git",
|
|
800
|
+
status=CheckStatus.WARN,
|
|
801
|
+
message="Git not available",
|
|
802
|
+
)
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
# 9. docker-compose
|
|
806
|
+
checks.append(_check_docker_compose(docker_path, r, docker_cli_available))
|
|
807
|
+
|
|
808
|
+
# 10. root writability
|
|
809
|
+
checks.append(_check_root_writable(root))
|
|
810
|
+
|
|
811
|
+
# 11. WSL2 memory guidance (O6: WARN-only advisory, never a gate)
|
|
812
|
+
checks.append(_check_wsl_memory())
|
|
813
|
+
|
|
814
|
+
exit_code, error_code, error_message = _compute_exit_code(checks)
|
|
815
|
+
|
|
816
|
+
return DoctorReport(
|
|
817
|
+
checks=checks,
|
|
818
|
+
exit_code=exit_code,
|
|
819
|
+
error_code=error_code,
|
|
820
|
+
error_message=error_message,
|
|
821
|
+
)
|