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,566 @@
|
|
|
1
|
+
"""``aisc build`` — Docker image build command.
|
|
2
|
+
|
|
3
|
+
Plans a ``docker build`` invocation based on user args + config/versions.env,
|
|
4
|
+
then optionally executes it. Supports ``--events`` JSONL output.
|
|
5
|
+
|
|
6
|
+
**All** docker operations go through an injected ``DockerExecutor``;
|
|
7
|
+
this module never calls ``subprocess.run`` directly.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import sys as _sys
|
|
15
|
+
import time as _time
|
|
16
|
+
from dataclasses import dataclass, field, asdict
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
from aisc.domain.models import BuildPlan, CliError
|
|
21
|
+
from aisc.adapters.docker_ import (
|
|
22
|
+
DockerExecutor,
|
|
23
|
+
RealDockerExecutor,
|
|
24
|
+
validate_build_resources,
|
|
25
|
+
)
|
|
26
|
+
from aisc.application.version import _parse_versions_env
|
|
27
|
+
from aisc.cli.build_progress_parser import BuildProgressParser, parse_context_bytes
|
|
28
|
+
from aisc.cli.output import JsonlEmitter
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Build service: plan the build
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class _BuildEnv:
|
|
37
|
+
"""Parsed values from config/versions.env used during build."""
|
|
38
|
+
use_cn_mirror: str = "1"
|
|
39
|
+
node_image: str = ""
|
|
40
|
+
node_image_cn: str = ""
|
|
41
|
+
node_image_mirrors: tuple = ()
|
|
42
|
+
# A5 (D-9): tool pins forwarded as build args — factory values layered
|
|
43
|
+
# under the data-root USER pin overlay (tool_versions.py, D-27).
|
|
44
|
+
claude_code_version: str = "latest"
|
|
45
|
+
codex_version: str = "latest"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _parse_build_env(root: Path, data_root: Optional[Path] = None) -> _BuildEnv:
|
|
49
|
+
"""Parse build-relevant values from config/versions.env.
|
|
50
|
+
|
|
51
|
+
A5: tool versions resolve through the D-27 overlay — factory base from
|
|
52
|
+
the root, per-key overrides from the data-root user layer."""
|
|
53
|
+
from aisc.application.tool_versions import effective_tool_versions
|
|
54
|
+
|
|
55
|
+
env = _parse_versions_env(root)
|
|
56
|
+
eff = effective_tool_versions(root, data_root)
|
|
57
|
+
claude = eff.get("CLAUDE_CODE_VERSION", ("latest", "default"))[0]
|
|
58
|
+
codex = eff.get("CODEX_VERSION", ("latest", "default"))[0]
|
|
59
|
+
use_cn = env.get("USE_CN_MIRROR", "1")
|
|
60
|
+
node_image = env.get("NODE_IMAGE", "")
|
|
61
|
+
node_image_cn = env.get("NODE_IMAGE_CN", "")
|
|
62
|
+
# T8a: comma-separated registry prefixes for the pre-pull chain.
|
|
63
|
+
mirrors = tuple(
|
|
64
|
+
m.strip().rstrip("/")
|
|
65
|
+
for m in env.get("NODE_IMAGE_MIRRORS", "").split(",")
|
|
66
|
+
if m.strip()
|
|
67
|
+
)
|
|
68
|
+
return _BuildEnv(
|
|
69
|
+
use_cn_mirror=use_cn, node_image=node_image,
|
|
70
|
+
node_image_cn=node_image_cn, node_image_mirrors=mirrors,
|
|
71
|
+
claude_code_version=claude or "latest",
|
|
72
|
+
codex_version=codex or "latest",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _open_build_log() -> Optional[Path]:
|
|
77
|
+
"""Create a timestamped build log under ``<data-root>/logs`` (Gate-S4 §1:
|
|
78
|
+
the UI keeps only a bounded tail; the complete raw output lives here and
|
|
79
|
+
``build.start`` carries its path). Best-effort — never blocks the build."""
|
|
80
|
+
try:
|
|
81
|
+
from aisc.application.data_root import shared_root
|
|
82
|
+
logs = shared_root() / "logs"
|
|
83
|
+
logs.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
path = logs / f"build-{int(_time.time())}.log"
|
|
85
|
+
path.write_text("", encoding="utf-8")
|
|
86
|
+
return path
|
|
87
|
+
except Exception:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _previous_context_total(skip_log: Optional[Path]) -> Optional[float]:
|
|
92
|
+
"""The FINAL `transferring context: X done` bytes from the most recent
|
|
93
|
+
PREVIOUS build log — the estimated denominator for the prepare-phase
|
|
94
|
+
progress bar (Gate-S4 §1 amendment). Best-effort; absence = the bar
|
|
95
|
+
simply stays indeterminate."""
|
|
96
|
+
try:
|
|
97
|
+
from aisc.application.data_root import shared_root
|
|
98
|
+
logs = shared_root() / "logs"
|
|
99
|
+
candidates = sorted(
|
|
100
|
+
(p for p in logs.glob("build-*.log") if p != skip_log),
|
|
101
|
+
key=lambda p: p.name,
|
|
102
|
+
reverse=True,
|
|
103
|
+
)
|
|
104
|
+
for path in candidates[:3]: # newest few; tolerate partial logs
|
|
105
|
+
try:
|
|
106
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
107
|
+
except OSError:
|
|
108
|
+
continue
|
|
109
|
+
last: Optional[float] = None
|
|
110
|
+
for line in text.splitlines():
|
|
111
|
+
stripped = line.strip()
|
|
112
|
+
if stripped.endswith("done") or stripped.endswith("done)"):
|
|
113
|
+
val = parse_context_bytes(stripped)
|
|
114
|
+
if val:
|
|
115
|
+
last = val
|
|
116
|
+
if last:
|
|
117
|
+
return last
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# Dockerfile instructions that count as build steps (candidate step_total).
|
|
124
|
+
_INSTRUCTION_RE = re.compile(
|
|
125
|
+
r"^\s*(?:FROM|RUN|CMD|LABEL|MAINTAINER|EXPOSE|ENV|ADD|COPY|ENTRYPOINT"
|
|
126
|
+
r"|VOLUME|USER|WORKDIR|ARG|ONBUILD|STOPSIGNAL|HEALTHCHECK|SHELL)\b",
|
|
127
|
+
re.IGNORECASE,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _dockerfile_instruction_count(plan: BuildPlan) -> Optional[int]:
|
|
132
|
+
"""Metadata only (Gate-S4): the Dockerfile's instruction count as a
|
|
133
|
+
candidate ``step_total``. build.progress itself only trusts what the
|
|
134
|
+
docker output actually maps; this feeds the plan event for consumers
|
|
135
|
+
that want an early hint."""
|
|
136
|
+
try:
|
|
137
|
+
if not plan.root or not plan.dockerfile:
|
|
138
|
+
return None
|
|
139
|
+
text = (Path(plan.root) / plan.dockerfile).read_text(encoding="utf-8")
|
|
140
|
+
except OSError:
|
|
141
|
+
return None
|
|
142
|
+
count = 0
|
|
143
|
+
for line in text.splitlines():
|
|
144
|
+
stripped = line.strip()
|
|
145
|
+
if not stripped or stripped.startswith("#"):
|
|
146
|
+
continue
|
|
147
|
+
if _INSTRUCTION_RE.match(stripped):
|
|
148
|
+
count += 1
|
|
149
|
+
return count or None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def plan_build(
|
|
153
|
+
root: Path,
|
|
154
|
+
tag: str = "super-claude:latest",
|
|
155
|
+
no_cache: bool = False,
|
|
156
|
+
pull: bool = False,
|
|
157
|
+
dry_run: bool = False,
|
|
158
|
+
cc_switch: Optional[Any] = None,
|
|
159
|
+
data_root: Optional[Path] = None,
|
|
160
|
+
) -> BuildPlan:
|
|
161
|
+
"""Create an immutable ``BuildPlan`` from user args.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
cc_switch: a ``ResolvedRelease`` (aisc.domain.cc_switch_release) from
|
|
165
|
+
the resolver — injected as docker build args + image labels (Stage
|
|
166
|
+
8b, CS-01/CS-02). ``None`` keeps the legacy ARG-fallback path
|
|
167
|
+
(manual docker builds only; never used by ``aisc build``).
|
|
168
|
+
|
|
169
|
+
Returns a ``BuildPlan`` with the computed ``docker argv``.
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
CliError (exit 1, AISC_ERR_GENERAL): if Dockerfile, versions.env,
|
|
173
|
+
or NODE_IMAGE are missing. These are planning errors,
|
|
174
|
+
NOT build failures (exit 4).
|
|
175
|
+
"""
|
|
176
|
+
# Validate resources — planning phase, exit 1 on missing
|
|
177
|
+
try:
|
|
178
|
+
validate_build_resources(root)
|
|
179
|
+
except FileNotFoundError as exc:
|
|
180
|
+
raise CliError(message=str(exc), exit_code=1,
|
|
181
|
+
error_code="AISC_ERR_GENERAL") from exc
|
|
182
|
+
|
|
183
|
+
dockerfile = str(root / "container" / "Dockerfile")
|
|
184
|
+
build_env = _parse_build_env(root, data_root)
|
|
185
|
+
|
|
186
|
+
if not build_env.node_image:
|
|
187
|
+
raise CliError(
|
|
188
|
+
message="NODE_IMAGE is missing from config/versions.env. "
|
|
189
|
+
"Please add NODE_IMAGE=<base-image> to config/versions.env.",
|
|
190
|
+
exit_code=1,
|
|
191
|
+
error_code="AISC_ERR_GENERAL",
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# If tag is just a name without tag (no ':'), append ':latest'
|
|
195
|
+
if ":" not in tag:
|
|
196
|
+
tag = f"{tag}:latest"
|
|
197
|
+
|
|
198
|
+
# Select node image based on USE_CN_MIRROR
|
|
199
|
+
selected_node_image = build_env.node_image
|
|
200
|
+
if build_env.use_cn_mirror == "1" and build_env.node_image_cn:
|
|
201
|
+
selected_node_image = build_env.node_image_cn
|
|
202
|
+
|
|
203
|
+
cc_kwargs: Dict[str, str] = {}
|
|
204
|
+
if cc_switch is not None:
|
|
205
|
+
manifest = cc_switch.to_manifest()
|
|
206
|
+
# Compact label JSON — keep the OCI label small and stable-shaped.
|
|
207
|
+
label_manifest = json.dumps(
|
|
208
|
+
{
|
|
209
|
+
"schema": manifest["schema"],
|
|
210
|
+
"channel": manifest["channel"],
|
|
211
|
+
"version": manifest["version"],
|
|
212
|
+
"commit": manifest["commit"],
|
|
213
|
+
"asset_sha256": manifest["asset_sha256"],
|
|
214
|
+
"asset_name": manifest["asset_name"],
|
|
215
|
+
"source": manifest["source"],
|
|
216
|
+
},
|
|
217
|
+
separators=(",", ":"),
|
|
218
|
+
)
|
|
219
|
+
cc_kwargs = {
|
|
220
|
+
"cc_switch_version": cc_switch.tag,
|
|
221
|
+
"cc_switch_commit": cc_switch.commit,
|
|
222
|
+
"cc_switch_asset_url": cc_switch.asset_url,
|
|
223
|
+
"cc_switch_asset_sha256": cc_switch.asset_sha256,
|
|
224
|
+
"cc_switch_asset_name": cc_switch.asset_name,
|
|
225
|
+
"cc_switch_manifest": label_manifest,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return BuildPlan(
|
|
229
|
+
tag=tag,
|
|
230
|
+
root=str(root),
|
|
231
|
+
dockerfile=dockerfile,
|
|
232
|
+
no_cache=no_cache,
|
|
233
|
+
pull=pull,
|
|
234
|
+
build_arg_use_cn_mirror=build_env.use_cn_mirror,
|
|
235
|
+
node_image_mirrors=build_env.node_image_mirrors,
|
|
236
|
+
build_arg_node_image=selected_node_image,
|
|
237
|
+
build_arg_claude_code_version=build_env.claude_code_version,
|
|
238
|
+
build_arg_codex_version=build_env.codex_version,
|
|
239
|
+
dry_run=dry_run,
|
|
240
|
+
**cc_kwargs,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
# Build result (structured outcome)
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
@dataclass
|
|
249
|
+
class BuildResult:
|
|
250
|
+
"""Result of a build execution — all fields populated even on failure."""
|
|
251
|
+
image_tag: str = ""
|
|
252
|
+
docker_argv: List[str] = field(default_factory=list)
|
|
253
|
+
docker_exit_code: Optional[int] = None
|
|
254
|
+
dry_run: bool = False
|
|
255
|
+
executed: bool = False
|
|
256
|
+
"""Stage 8b: resolved cc-switch summary (version/sha256/source/...) and
|
|
257
|
+
the manifest file path — the reproducibility receipt for the build."""
|
|
258
|
+
cc_switch: Dict[str, Any] = field(default_factory=dict)
|
|
259
|
+
cc_switch_manifest_path: str = ""
|
|
260
|
+
|
|
261
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
262
|
+
return {
|
|
263
|
+
"image_tag": self.image_tag,
|
|
264
|
+
"dry_run": self.dry_run,
|
|
265
|
+
"executed": self.executed,
|
|
266
|
+
"docker_argv": list(self.docker_argv),
|
|
267
|
+
"docker_exit_code": self.docker_exit_code,
|
|
268
|
+
"cc_switch": dict(self.cc_switch),
|
|
269
|
+
"cc_switch_manifest_path": self.cc_switch_manifest_path,
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ---------------------------------------------------------------------------
|
|
274
|
+
# Run the build (orchestration)
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
# T8a (2.1.9 D-9): host-side base-image pre-pull through a mirror chain.
|
|
278
|
+
# Evidence: nairong's first build crawled docker.1ms.run for ~10 minutes and
|
|
279
|
+
# was abandoned — buildkit's FROM has no mirror fallback, no retry, no
|
|
280
|
+
# guidance. aisc build therefore pre-pulls the base itself: local hit →
|
|
281
|
+
# zero docker calls; otherwise walk the chain (selected image first, then
|
|
282
|
+
# NODE_IMAGE_MIRRORS entries), `docker tag` the first success to the bare
|
|
283
|
+
# local name so buildkit hits the local store, and only fail (with guidance)
|
|
284
|
+
# when every mirror failed.
|
|
285
|
+
_PULL_TIMEOUT_S = 600.0
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _base_image_candidates(plan: "BuildPlan") -> list:
|
|
289
|
+
"""Pull candidates in priority order: the plan's selected image, then
|
|
290
|
+
each mirror prefix joined with the bare image name (deduped)."""
|
|
291
|
+
selected = plan.build_arg_node_image
|
|
292
|
+
bare = selected.rsplit("/", 1)[-1]
|
|
293
|
+
seen = {selected}
|
|
294
|
+
out = [selected]
|
|
295
|
+
for prefix in plan.node_image_mirrors:
|
|
296
|
+
ref = f"{str(prefix).rstrip('/')}/{bare}"
|
|
297
|
+
if ref not in seen:
|
|
298
|
+
seen.add(ref)
|
|
299
|
+
out.append(ref)
|
|
300
|
+
return out
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _ensure_base_image(exec_, plan, emitter, streaming: bool) -> str:
|
|
304
|
+
"""Pre-pull the base image through the mirror chain.
|
|
305
|
+
|
|
306
|
+
Returns the node image reference the build argv should use (the bare
|
|
307
|
+
local name once tagged). Raises CliError (exit 4) when every candidate
|
|
308
|
+
fails — with actionable guidance for mainland-network users.
|
|
309
|
+
"""
|
|
310
|
+
from aisc.domain.models import ImageInspectStatus
|
|
311
|
+
|
|
312
|
+
candidates = _base_image_candidates(plan)
|
|
313
|
+
bare = candidates[0].rsplit("/", 1)[-1]
|
|
314
|
+
|
|
315
|
+
def _say(line: str) -> None:
|
|
316
|
+
if emitter is not None:
|
|
317
|
+
emitter.emit("build.output", data={"stream": "stderr", "chunk": line})
|
|
318
|
+
if streaming and emitter is None:
|
|
319
|
+
_sys.stderr.write(line)
|
|
320
|
+
_sys.stderr.flush()
|
|
321
|
+
|
|
322
|
+
# Local store hit → buildkit needs no network at all.
|
|
323
|
+
if exec_.inspect_image(bare).status == ImageInspectStatus.EXISTS:
|
|
324
|
+
return bare
|
|
325
|
+
|
|
326
|
+
for ref in candidates:
|
|
327
|
+
_say(f"⬇️ 基础镜像 try: {ref}\n")
|
|
328
|
+
proc = exec_.run_captured(["pull", ref], timeout=_PULL_TIMEOUT_S)
|
|
329
|
+
if proc.exit_code == 0:
|
|
330
|
+
if ref == bare:
|
|
331
|
+
return bare
|
|
332
|
+
# Retag to the bare local name; if tagging fails, build directly
|
|
333
|
+
# from the pulled (now local) reference instead of failing.
|
|
334
|
+
if exec_.run_captured(["tag", ref, bare], timeout=30.0).exit_code == 0:
|
|
335
|
+
return bare
|
|
336
|
+
return ref
|
|
337
|
+
|
|
338
|
+
raise CliError(
|
|
339
|
+
message=(
|
|
340
|
+
"基础镜像预拉失败(所有镜像源均不可达):" + ", ".join(candidates) +
|
|
341
|
+
"。可依次尝试:① Docker Desktop 设置里配置 registry-mirrors 后重试;"
|
|
342
|
+
"② 手动 docker pull <任一镜像源>/node:20-slim 成功后重新构建;"
|
|
343
|
+
"③ 切换到可直连 Docker Hub 的网络。"
|
|
344
|
+
),
|
|
345
|
+
exit_code=4, error_code="AISC_ERR_BUILD_FAILED",
|
|
346
|
+
data={"base_image_candidates": candidates},
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _rewrite_node_image(argv: list, new_value: str) -> list:
|
|
351
|
+
"""Replace the NODE_IMAGE build-arg value in a docker build argv copy."""
|
|
352
|
+
out = list(argv)
|
|
353
|
+
for i, tok in enumerate(out):
|
|
354
|
+
if tok == "--build-arg" and i + 1 < len(out) and out[i + 1].startswith("NODE_IMAGE="):
|
|
355
|
+
out[i + 1] = f"NODE_IMAGE={new_value}"
|
|
356
|
+
break
|
|
357
|
+
return out
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def run_build(
|
|
361
|
+
plan: BuildPlan,
|
|
362
|
+
*,
|
|
363
|
+
emitter: Optional[JsonlEmitter] = None,
|
|
364
|
+
executor: Optional[DockerExecutor] = None,
|
|
365
|
+
streaming: bool = False,
|
|
366
|
+
cc_switch_summary: Optional[Dict[str, Any]] = None,
|
|
367
|
+
cc_switch_manifest_path: str = "",
|
|
368
|
+
) -> BuildResult:
|
|
369
|
+
"""Execute or simulate a build, emitting events as needed.
|
|
370
|
+
|
|
371
|
+
All docker operations go through *executor* (RealDockerExecutor by default).
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
plan: The immutable build plan.
|
|
375
|
+
emitter: JsonlEmitter for --events mode, or None.
|
|
376
|
+
executor: DockerExecutor for injection (testing).
|
|
377
|
+
streaming:
|
|
378
|
+
``True`` (text mode) — use ``run_streaming`` with inherited
|
|
379
|
+
stdout/stderr.
|
|
380
|
+
``False`` (JSON/events mode) — use ``run_captured`` and forward
|
|
381
|
+
docker stdout/stderr to stderr so stdout stays pure envelope/JSONL.
|
|
382
|
+
cc_switch_summary / cc_switch_manifest_path: Stage 8b resolver facts
|
|
383
|
+
surfaced in BuildResult (and thus in error payloads too).
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
BuildResult with outcome data.
|
|
387
|
+
|
|
388
|
+
Raises:
|
|
389
|
+
CliError: with ``data`` field carrying full BuildResult.to_dict().
|
|
390
|
+
"""
|
|
391
|
+
exec_ = executor or RealDockerExecutor()
|
|
392
|
+
|
|
393
|
+
if cc_switch_summary is None and plan.cc_switch_version:
|
|
394
|
+
# Derive from the plan's label manifest so error payloads still carry
|
|
395
|
+
# the pin even when the caller passed no explicit summary.
|
|
396
|
+
try:
|
|
397
|
+
cc_switch_summary = json.loads(plan.cc_switch_manifest)
|
|
398
|
+
except json.JSONDecodeError:
|
|
399
|
+
cc_switch_summary = {"version": plan.cc_switch_version}
|
|
400
|
+
|
|
401
|
+
result = BuildResult(
|
|
402
|
+
image_tag=plan.tag,
|
|
403
|
+
docker_argv=list(plan.docker_argv),
|
|
404
|
+
dry_run=plan.dry_run,
|
|
405
|
+
executed=False,
|
|
406
|
+
cc_switch=dict(cc_switch_summary or {}),
|
|
407
|
+
cc_switch_manifest_path=cc_switch_manifest_path,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
# --- emit start event ---
|
|
411
|
+
# v2.1.7 S4 (Gate-S4): build.start carries the full-log path — the raw
|
|
412
|
+
# docker output lands there while the UI keeps only a bounded tail.
|
|
413
|
+
build_log_path = _open_build_log() if emitter is not None else None
|
|
414
|
+
if emitter is not None:
|
|
415
|
+
emitter.emit("build.start", data={
|
|
416
|
+
"image_tag": plan.tag,
|
|
417
|
+
"log_path": str(build_log_path) if build_log_path else None,
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
# --- dry-run: zero docker calls ---
|
|
421
|
+
if plan.dry_run:
|
|
422
|
+
if emitter is not None:
|
|
423
|
+
emitter.emit("build.plan", data={
|
|
424
|
+
"docker_argv": result.docker_argv,
|
|
425
|
+
"dry_run": True,
|
|
426
|
+
"cc_switch": result.cc_switch,
|
|
427
|
+
})
|
|
428
|
+
return result
|
|
429
|
+
|
|
430
|
+
# --- preflight ---
|
|
431
|
+
pf = exec_.preflight()
|
|
432
|
+
if not pf.available:
|
|
433
|
+
raise CliError(
|
|
434
|
+
message=f"Docker unavailable: {pf.reason}",
|
|
435
|
+
exit_code=pf.exit_code,
|
|
436
|
+
error_code=pf.error_code,
|
|
437
|
+
data=result.to_dict(),
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
# --- check if image already exists ---
|
|
441
|
+
from aisc.domain.models import ImageInspectStatus
|
|
442
|
+
inspect_result = exec_.inspect_image(plan.tag)
|
|
443
|
+
image_exists = inspect_result.status == ImageInspectStatus.EXISTS
|
|
444
|
+
if image_exists and streaming and emitter is None:
|
|
445
|
+
# Text mode: warn user about existing image
|
|
446
|
+
_sys.stderr.write(f"\n⚠️ Image already exists: {plan.tag}\n")
|
|
447
|
+
_sys.stderr.write(" Building will replace it (may create dangling <none> images).\n")
|
|
448
|
+
_sys.stderr.write(" Tip: Use 'docker rmi {tag}' before build, or use a different tag.\n\n")
|
|
449
|
+
_sys.stderr.flush()
|
|
450
|
+
|
|
451
|
+
# --- plan event (non-dry-run) ---
|
|
452
|
+
if emitter is not None:
|
|
453
|
+
emitter.emit("build.plan", data={
|
|
454
|
+
"docker_argv": result.docker_argv,
|
|
455
|
+
"image_exists": image_exists,
|
|
456
|
+
"cc_switch": result.cc_switch,
|
|
457
|
+
# Gate-S4: Dockerfile instruction count as candidate step_total
|
|
458
|
+
# metadata; build.progress only trusts what the output maps.
|
|
459
|
+
"step_total": _dockerfile_instruction_count(plan),
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
# --- T8a: pre-pull the base image through the mirror chain (CN 网络兜底;
|
|
463
|
+
# local hit is a zero-IPC no-op) and point the build at the local name ---
|
|
464
|
+
effective_node_image = _ensure_base_image(exec_, plan, emitter, streaming)
|
|
465
|
+
|
|
466
|
+
# --- execute docker build ---
|
|
467
|
+
argv = _rewrite_node_image(plan.docker_argv, effective_node_image)
|
|
468
|
+
result.docker_argv = argv # error payloads carry the argv that actually ran
|
|
469
|
+
|
|
470
|
+
if streaming:
|
|
471
|
+
# Text mode: inherit streams for real-time build log
|
|
472
|
+
proc = exec_.run_streaming(argv)
|
|
473
|
+
result.docker_exit_code = proc.exit_code if proc.exit_code >= 0 else proc.exit_code
|
|
474
|
+
result.executed = True
|
|
475
|
+
|
|
476
|
+
if proc.command_not_found:
|
|
477
|
+
raise CliError(
|
|
478
|
+
message="Docker CLI not found",
|
|
479
|
+
exit_code=3, error_code="AISC_ERR_DOCKER_UNAVAILABLE",
|
|
480
|
+
data=result.to_dict(),
|
|
481
|
+
)
|
|
482
|
+
if proc.timed_out:
|
|
483
|
+
raise CliError(
|
|
484
|
+
message="Docker build timed out",
|
|
485
|
+
exit_code=1, error_code="AISC_ERR_GENERAL",
|
|
486
|
+
data=result.to_dict(),
|
|
487
|
+
)
|
|
488
|
+
if proc.exit_code != 0:
|
|
489
|
+
raise CliError(
|
|
490
|
+
message=f"Docker build failed (exit {proc.exit_code})",
|
|
491
|
+
exit_code=4, error_code="AISC_ERR_BUILD_FAILED",
|
|
492
|
+
data=result.to_dict(),
|
|
493
|
+
)
|
|
494
|
+
elif emitter is not None:
|
|
495
|
+
# --events: stream docker output as real-time build.output events and
|
|
496
|
+
# (v2.1.7 S4, Gate-S4) derive structured build.progress updates from
|
|
497
|
+
# the SAME bytes — the emitter is the only progress fact source; the
|
|
498
|
+
# UI never parses build.output. Raw chunks also append to the
|
|
499
|
+
# build.start log file.
|
|
500
|
+
progress = BuildProgressParser(
|
|
501
|
+
context_total_bytes=_previous_context_total(build_log_path)
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
def _on_chunk(stream: str, chunk: str) -> None:
|
|
505
|
+
if build_log_path is not None:
|
|
506
|
+
try:
|
|
507
|
+
with open(build_log_path, "a", encoding="utf-8", errors="replace") as _lf:
|
|
508
|
+
_lf.write(chunk)
|
|
509
|
+
except OSError:
|
|
510
|
+
pass # logging must never break the build
|
|
511
|
+
emitter.emit("build.output", data={"stream": stream, "chunk": chunk})
|
|
512
|
+
for upd in progress.feed(chunk):
|
|
513
|
+
emitter.emit("build.progress", data=asdict(upd))
|
|
514
|
+
|
|
515
|
+
try:
|
|
516
|
+
proc = exec_.run_streaming_captured(argv, _on_chunk)
|
|
517
|
+
except KeyboardInterrupt:
|
|
518
|
+
# Cancel: the executor already killpg'd the docker child. Emit the
|
|
519
|
+
# terminal build.cancelled with a resource summary and exit 130
|
|
520
|
+
# (bypasses main.py's generic handler so we control the payload).
|
|
521
|
+
emitter.emit_terminal("build.cancelled", 130, extra_data={
|
|
522
|
+
"image_tag": plan.tag,
|
|
523
|
+
"docker_exit_code": None,
|
|
524
|
+
"reason": "cancelled",
|
|
525
|
+
})
|
|
526
|
+
_sys.exit(130)
|
|
527
|
+
result.docker_exit_code = proc.exit_code
|
|
528
|
+
result.executed = True
|
|
529
|
+
if proc.command_not_found:
|
|
530
|
+
raise CliError(
|
|
531
|
+
message="Docker CLI not found",
|
|
532
|
+
exit_code=3, error_code="AISC_ERR_DOCKER_UNAVAILABLE",
|
|
533
|
+
data=result.to_dict(),
|
|
534
|
+
)
|
|
535
|
+
if proc.timed_out:
|
|
536
|
+
raise CliError(
|
|
537
|
+
message="Docker build timed out",
|
|
538
|
+
exit_code=1, error_code="AISC_ERR_GENERAL",
|
|
539
|
+
data=result.to_dict(),
|
|
540
|
+
)
|
|
541
|
+
if proc.exit_code != 0:
|
|
542
|
+
raise CliError(
|
|
543
|
+
message=f"Docker build failed (exit {proc.exit_code})",
|
|
544
|
+
exit_code=4, error_code="AISC_ERR_BUILD_FAILED",
|
|
545
|
+
data=result.to_dict(),
|
|
546
|
+
)
|
|
547
|
+
else:
|
|
548
|
+
# --format json: capture, forward docker output to stderr (stdout pure).
|
|
549
|
+
proc = exec_.run_captured(argv)
|
|
550
|
+
result.docker_exit_code = proc.exit_code
|
|
551
|
+
result.executed = True
|
|
552
|
+
|
|
553
|
+
if proc.stdout:
|
|
554
|
+
_sys.stderr.write(proc.stdout)
|
|
555
|
+
if proc.stderr:
|
|
556
|
+
_sys.stderr.write(proc.stderr)
|
|
557
|
+
|
|
558
|
+
if proc.exit_code != 0:
|
|
559
|
+
raise CliError(
|
|
560
|
+
message=f"Docker build failed (exit {proc.exit_code})",
|
|
561
|
+
exit_code=4, error_code="AISC_ERR_BUILD_FAILED",
|
|
562
|
+
data=result.to_dict(),
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
# --- success: main.py emits the build.complete terminal event ---
|
|
566
|
+
return result
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""``aisc bundle`` command group (0.1.0 A3, guide 3.3.3).
|
|
2
|
+
|
|
3
|
+
fetch / list / remove / path over the data-root bundle store
|
|
4
|
+
(``<data-root>/bundles/<ver>/``). Non-interactive by contract; all output
|
|
5
|
+
is JSON-serializable for the aisc.cli/v1 envelope; text mode prints a
|
|
6
|
+
short summary. Network failures fail CLOSED with the three manual escape
|
|
7
|
+
hatches in the copy (manual URL / --from-file / --aisc-root).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, Optional
|
|
14
|
+
|
|
15
|
+
from aisc import __version__
|
|
16
|
+
from aisc.application.bundle_fetch import (
|
|
17
|
+
AISC_RELEASE_REPO,
|
|
18
|
+
BundleFetchError,
|
|
19
|
+
BundleFetcher,
|
|
20
|
+
)
|
|
21
|
+
from aisc.application.data_root import shared_root
|
|
22
|
+
from aisc.domain.models import CliError
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _map_error(exc: BundleFetchError) -> CliError:
|
|
26
|
+
return CliError(message=exc.message, exit_code=1, error_code="AISC_ERR_GENERAL")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _active_root() -> Optional[Path]:
|
|
30
|
+
from aisc.application.resources import locate_aisc_root
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
return locate_aisc_root()
|
|
34
|
+
except Exception:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cmd_bundle_fetch(
|
|
39
|
+
*,
|
|
40
|
+
version: Optional[str] = None,
|
|
41
|
+
from_file: Optional[str] = None,
|
|
42
|
+
sha256: Optional[str] = None,
|
|
43
|
+
allow_mismatch: bool = False,
|
|
44
|
+
) -> Dict[str, Any]:
|
|
45
|
+
fetcher = BundleFetcher()
|
|
46
|
+
try:
|
|
47
|
+
return fetcher.fetch(
|
|
48
|
+
shared_root(),
|
|
49
|
+
__version__,
|
|
50
|
+
version=version,
|
|
51
|
+
from_file=Path(from_file) if from_file else None,
|
|
52
|
+
sha256=sha256,
|
|
53
|
+
allow_mismatch=allow_mismatch,
|
|
54
|
+
)
|
|
55
|
+
except BundleFetchError as exc:
|
|
56
|
+
raise _map_error(exc) from exc
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def cmd_bundle_list() -> Dict[str, Any]:
|
|
60
|
+
fetcher = BundleFetcher()
|
|
61
|
+
versions = fetcher.installed_versions(shared_root())
|
|
62
|
+
active = _active_root()
|
|
63
|
+
active_version = None
|
|
64
|
+
if active is not None:
|
|
65
|
+
# bundles/<ver>/aisc-bundle -> <ver>
|
|
66
|
+
try:
|
|
67
|
+
active_version = active.resolve().parent.name
|
|
68
|
+
except OSError:
|
|
69
|
+
active_version = None
|
|
70
|
+
return {
|
|
71
|
+
"repo": AISC_RELEASE_REPO,
|
|
72
|
+
"installed": versions,
|
|
73
|
+
"active_version": active_version,
|
|
74
|
+
"cli_version": __version__,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def cmd_bundle_remove(version: str) -> Dict[str, Any]:
|
|
79
|
+
fetcher = BundleFetcher()
|
|
80
|
+
try:
|
|
81
|
+
return fetcher.remove(shared_root(), version, _active_root())
|
|
82
|
+
except BundleFetchError as exc:
|
|
83
|
+
raise _map_error(exc) from exc
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cmd_bundle_path() -> Dict[str, Any]:
|
|
87
|
+
return {"bundles_root": str(BundleFetcher().bundles_root(shared_root()))}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def print_bundle_text(sub: str, data: Any) -> None:
|
|
91
|
+
"""Minimal human-readable output for text mode."""
|
|
92
|
+
if not isinstance(data, dict):
|
|
93
|
+
return
|
|
94
|
+
if sub == "fetch":
|
|
95
|
+
print(f"status : {data.get('status')}")
|
|
96
|
+
print(f"version: {data.get('version')}")
|
|
97
|
+
print(f"path : {data.get('path')}")
|
|
98
|
+
elif sub == "list":
|
|
99
|
+
print(f"repo : {data.get('repo')}")
|
|
100
|
+
print(f"cli : {data.get('cli_version')}")
|
|
101
|
+
for v in data.get("installed", []):
|
|
102
|
+
mark = " (active)" if v == data.get("active_version") else ""
|
|
103
|
+
print(f" {v}{mark}")
|
|
104
|
+
elif sub == "remove":
|
|
105
|
+
print(f"removed: {data.get('version')}")
|
|
106
|
+
elif sub == "path":
|
|
107
|
+
print(data.get("bundles_root", ""))
|