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,659 @@
|
|
|
1
|
+
"""Docker resource lifecycle service (docker-resource-lifecycle A1/B).
|
|
2
|
+
|
|
3
|
+
Centralized scan / classification / cleanup / rebuild over AISC-owned
|
|
4
|
+
containers and workstation images. Every installer calls THIS through the
|
|
5
|
+
``aisc maintenance`` CLI — platform scripts never reimplement the filter
|
|
6
|
+
rules (02 §1/§2/§3/§4).
|
|
7
|
+
|
|
8
|
+
Ownership tiers (frozen):
|
|
9
|
+
- ``owned`` — labels prove it (io.aisc.managed / org.aisc.managed);
|
|
10
|
+
- ``legacy_owned`` — no labels but legacy evidence: registry record, the
|
|
11
|
+
``aisc-wb-`` name pattern, or the historical
|
|
12
|
+
``super-claude-station-`` name + super-claude repo
|
|
13
|
+
(containers); the exact default tag
|
|
14
|
+
``super-claude:latest`` in an upgrade/uninstall
|
|
15
|
+
context (images);
|
|
16
|
+
- ``unverified`` — merely looks AISC-ish (name/repository similar).
|
|
17
|
+
Reported, NEVER deleted.
|
|
18
|
+
|
|
19
|
+
Safety invariants (02 §5): no global prune; never delete by repository
|
|
20
|
+
name alone; containers before images; volumes/networks untouched; Docker
|
|
21
|
+
unavailable never concludes "not_found"; re-scan before deleting; argv
|
|
22
|
+
and logs never carry secrets.
|
|
23
|
+
|
|
24
|
+
All Docker I/O rides machine formats (ps/images templates, inspect
|
|
25
|
+
``{{.Id}}``) — never human-text parsing. Cleanup/rebuild hold the shared
|
|
26
|
+
docker-maintenance lock (cross-plan order: maintenance -> workspace ->
|
|
27
|
+
registry). The runtime-create critical section (02 §4.1) attaches when
|
|
28
|
+
Stage C wires the installers' rebuild flow.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
36
|
+
|
|
37
|
+
from aisc.domain.models import CliError, RuntimeErrorCode, RuntimeExitCode
|
|
38
|
+
|
|
39
|
+
#: Exact default workstation tag (legacy evidence in upgrade/uninstall).
|
|
40
|
+
DEFAULT_IMAGE_TAG = "super-claude:latest"
|
|
41
|
+
|
|
42
|
+
SCAN_SCHEMA = "aisc.docker-scan/v1"
|
|
43
|
+
CLEANUP_SCHEMA = "aisc.docker-cleanup/v1"
|
|
44
|
+
REBUILD_SCHEMA = "aisc.docker-rebuild/v1"
|
|
45
|
+
|
|
46
|
+
#: docker ps template: id, name, image ref, status + the AISC labels.
|
|
47
|
+
_PS_FORMAT = (
|
|
48
|
+
"{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t"
|
|
49
|
+
'{{.Label "io.aisc.managed"}}\t{{.Label "io.aisc.kind"}}\t'
|
|
50
|
+
'{{.Label "io.aisc.owner"}}'
|
|
51
|
+
)
|
|
52
|
+
#: docker images template (no .Labels placeholder exists on `docker
|
|
53
|
+
#: images` — ownership comes from the server-side label filter below).
|
|
54
|
+
_IMAGES_FORMAT = "{{.Repository}} {{.Tag}} {{.ID}}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _ps_rows(executor: Any) -> List[Dict[str, str]]:
|
|
58
|
+
try:
|
|
59
|
+
result = executor.run_captured(
|
|
60
|
+
["ps", "-a", "--format", _PS_FORMAT], timeout=15.0
|
|
61
|
+
)
|
|
62
|
+
except Exception:
|
|
63
|
+
return []
|
|
64
|
+
if getattr(result, "exit_code", 1) != 0:
|
|
65
|
+
return []
|
|
66
|
+
rows: List[Dict[str, str]] = []
|
|
67
|
+
for line in (result.stdout or "").splitlines():
|
|
68
|
+
parts = [p.strip() for p in line.split("\t")]
|
|
69
|
+
if len(parts) < 4:
|
|
70
|
+
continue
|
|
71
|
+
rows.append({
|
|
72
|
+
"id": parts[0], "name": parts[1], "image": parts[2],
|
|
73
|
+
"status": parts[3],
|
|
74
|
+
"managed": parts[4] if len(parts) > 4 else "",
|
|
75
|
+
"kind": parts[5] if len(parts) > 5 else "",
|
|
76
|
+
"owner": parts[6] if len(parts) > 6 else "",
|
|
77
|
+
})
|
|
78
|
+
return rows
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _image_universe(executor: Any) -> List[Dict[str, Any]]:
|
|
82
|
+
"""All images as {repository, tag, id, ref} rows (no labels)."""
|
|
83
|
+
try:
|
|
84
|
+
result = executor.run_captured(
|
|
85
|
+
["images", "--format", _IMAGES_FORMAT], timeout=15.0
|
|
86
|
+
)
|
|
87
|
+
except Exception:
|
|
88
|
+
return []
|
|
89
|
+
if getattr(result, "exit_code", 1) != 0:
|
|
90
|
+
return []
|
|
91
|
+
rows: List[Dict[str, Any]] = []
|
|
92
|
+
for line in (result.stdout or "").splitlines():
|
|
93
|
+
parts = [p.strip() for p in line.split(" ")]
|
|
94
|
+
if len(parts) < 3:
|
|
95
|
+
continue
|
|
96
|
+
rows.append({
|
|
97
|
+
"repository": parts[0], "tag": parts[1], "id": parts[2],
|
|
98
|
+
"ref": f"{parts[0]}:{parts[1]}" if parts[1] != "<none>" else "",
|
|
99
|
+
})
|
|
100
|
+
return rows
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _filtered_image_ids(executor: Any, flag: str) -> List[str]:
|
|
104
|
+
"""Image IDs passing a server-side ``docker images --filter``."""
|
|
105
|
+
try:
|
|
106
|
+
result = executor.run_captured(
|
|
107
|
+
["images", "--filter", flag, "--format", "{{.ID}}"], timeout=15.0
|
|
108
|
+
)
|
|
109
|
+
except Exception:
|
|
110
|
+
return []
|
|
111
|
+
if getattr(result, "exit_code", 1) != 0:
|
|
112
|
+
return []
|
|
113
|
+
return [ln.strip() for ln in (result.stdout or "").splitlines() if ln.strip()]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _registry_evidence(data_root: Optional[Path]) -> Dict[str, Dict[str, Any]]:
|
|
117
|
+
"""All registry records across workspaces (legacy container evidence)."""
|
|
118
|
+
from aisc.adapters.container_registry import list_containers_readonly
|
|
119
|
+
from aisc.application.data_root import shared_root
|
|
120
|
+
|
|
121
|
+
root = Path(data_root) if data_root else shared_root()
|
|
122
|
+
evidence: Dict[str, Dict[str, Any]] = {}
|
|
123
|
+
ws_root = root / "workspaces"
|
|
124
|
+
if not ws_root.is_dir():
|
|
125
|
+
return evidence
|
|
126
|
+
for ws_dir in ws_root.iterdir():
|
|
127
|
+
reg = ws_dir / "runtime"
|
|
128
|
+
if not reg.is_dir():
|
|
129
|
+
continue
|
|
130
|
+
try:
|
|
131
|
+
for name, meta in list_containers_readonly(reg).items():
|
|
132
|
+
if isinstance(meta, dict):
|
|
133
|
+
evidence[name] = meta
|
|
134
|
+
except Exception:
|
|
135
|
+
continue # one unreadable workspace registry never breaks the scan
|
|
136
|
+
return evidence
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# Classification
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
def classify_containers(
|
|
144
|
+
rows: Iterable[Dict[str, str]], registry: Dict[str, Dict[str, Any]]
|
|
145
|
+
) -> Dict[str, List[Dict[str, Any]]]:
|
|
146
|
+
buckets: Dict[str, List[Dict[str, Any]]] = {
|
|
147
|
+
"owned": [], "legacy_owned": [], "unverified": [],
|
|
148
|
+
}
|
|
149
|
+
for row in rows:
|
|
150
|
+
name = row["name"]
|
|
151
|
+
image_repo = row["image"].split(":")[0]
|
|
152
|
+
if row["managed"] == "true":
|
|
153
|
+
ownership, reason = "owned", "label"
|
|
154
|
+
elif name in registry:
|
|
155
|
+
ownership, reason = "legacy_owned", "registry"
|
|
156
|
+
elif name.startswith("aisc-wb-"):
|
|
157
|
+
ownership, reason = "legacy_owned", "legacy-name"
|
|
158
|
+
elif name.startswith("super-claude-station-") and image_repo == "super-claude":
|
|
159
|
+
ownership, reason = "legacy_owned", "legacy-name"
|
|
160
|
+
elif image_repo == "super-claude" or "aisc" in name.lower():
|
|
161
|
+
ownership, reason = "unverified", "repository-only"
|
|
162
|
+
else:
|
|
163
|
+
continue # not AISC-adjacent — none of our business
|
|
164
|
+
buckets[ownership].append({
|
|
165
|
+
"id": row["id"], "name": name, "image": row["image"],
|
|
166
|
+
"ownership": ownership,
|
|
167
|
+
"state": "running" if row["status"].startswith("Up") else "stopped",
|
|
168
|
+
"reason": reason,
|
|
169
|
+
})
|
|
170
|
+
return buckets
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def classify_images(
|
|
174
|
+
rows: Iterable[Dict[str, Any]],
|
|
175
|
+
*,
|
|
176
|
+
context: str,
|
|
177
|
+
owned_ids: Iterable[str] = (),
|
|
178
|
+
dangling_ids: Iterable[str] = (),
|
|
179
|
+
old_image_ids: Iterable[str] = (),
|
|
180
|
+
) -> Tuple[Dict[str, List[Dict[str, Any]]], List[Dict[str, Any]]]:
|
|
181
|
+
"""Three-tier image classification + evidence-backed dangling set.
|
|
182
|
+
|
|
183
|
+
``owned_ids`` come from the server-side ``label=org.aisc.managed=true``
|
|
184
|
+
filter (docker images has no label template — the filter is the only
|
|
185
|
+
reliable source); ``dangling_ids`` from ``dangling=true``.
|
|
186
|
+
"""
|
|
187
|
+
buckets: Dict[str, List[Dict[str, Any]]] = {
|
|
188
|
+
"owned": [], "legacy_owned": [], "unverified": [],
|
|
189
|
+
}
|
|
190
|
+
dangling: List[Dict[str, Any]] = []
|
|
191
|
+
owned = {i for i in owned_ids if i}
|
|
192
|
+
dangling_set = {i for i in dangling_ids if i}
|
|
193
|
+
old_ids = {i for i in old_image_ids if i}
|
|
194
|
+
for row in rows:
|
|
195
|
+
entry = {
|
|
196
|
+
"id": row["id"], "name": row["ref"] or row["id"],
|
|
197
|
+
"image": row["ref"] or row["id"],
|
|
198
|
+
}
|
|
199
|
+
if row["repository"] == "<none>":
|
|
200
|
+
# Dangling FIRST (no ref to untag — deletable by ID only).
|
|
201
|
+
# Evidence: org.aisc label (survives on the image config) or the
|
|
202
|
+
# upgrade's captured old_image_id (temporary proof).
|
|
203
|
+
if row["id"] in owned or row["id"] in old_ids:
|
|
204
|
+
dangling.append({
|
|
205
|
+
"id": row["id"], "name": row["id"], "image": row["id"],
|
|
206
|
+
"ownership": "owned", "state": "dangling",
|
|
207
|
+
"reason": "label" if row["id"] in owned else "upgrade-old-id",
|
|
208
|
+
})
|
|
209
|
+
elif row["id"] in owned:
|
|
210
|
+
entry.update({"ownership": "owned", "state": "present", "reason": "label"})
|
|
211
|
+
buckets["owned"].append(entry)
|
|
212
|
+
elif row["ref"] == DEFAULT_IMAGE_TAG and context != "first_install":
|
|
213
|
+
entry.update({"ownership": "legacy_owned", "state": "present",
|
|
214
|
+
"reason": "default-tag"})
|
|
215
|
+
buckets["legacy_owned"].append(entry)
|
|
216
|
+
elif row["repository"] == "super-claude":
|
|
217
|
+
entry.update({"ownership": "unverified", "state": "present",
|
|
218
|
+
"reason": "repository-only"})
|
|
219
|
+
buckets["unverified"].append(entry)
|
|
220
|
+
else:
|
|
221
|
+
continue # not AISC-adjacent — none of our business
|
|
222
|
+
return buckets, dangling
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ---------------------------------------------------------------------------
|
|
226
|
+
# Scan
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
def docker_scan(
|
|
230
|
+
executor: Any,
|
|
231
|
+
*,
|
|
232
|
+
context: str = "upgrade",
|
|
233
|
+
old_image_ids: Iterable[str] = (),
|
|
234
|
+
data_root: Optional[Path] = None,
|
|
235
|
+
) -> Dict[str, Any]:
|
|
236
|
+
"""One read-only classification pass (02 §2 envelope)."""
|
|
237
|
+
from aisc.application.runtime import _check_docker
|
|
238
|
+
|
|
239
|
+
available = _check_docker(executor)
|
|
240
|
+
payload: Dict[str, Any] = {
|
|
241
|
+
"schema_version": SCAN_SCHEMA,
|
|
242
|
+
"docker": {"available": available, "reason": "ok" if available else "unavailable"},
|
|
243
|
+
"containers": {"owned": [], "legacy_owned": [], "unverified": []},
|
|
244
|
+
"images": {"owned": [], "legacy_owned": [], "unverified": []},
|
|
245
|
+
"dangling_owned": [],
|
|
246
|
+
"warnings": [],
|
|
247
|
+
}
|
|
248
|
+
if not available:
|
|
249
|
+
return payload
|
|
250
|
+
registry = _registry_evidence(data_root)
|
|
251
|
+
payload["containers"] = classify_containers(_ps_rows(executor), registry)
|
|
252
|
+
payload["images"], payload["dangling_owned"] = classify_images(
|
|
253
|
+
_image_universe(executor),
|
|
254
|
+
context=context,
|
|
255
|
+
owned_ids=_filtered_image_ids(executor, "label=org.aisc.managed=true"),
|
|
256
|
+
dangling_ids=_filtered_image_ids(executor, "dangling=true"),
|
|
257
|
+
old_image_ids=old_image_ids,
|
|
258
|
+
)
|
|
259
|
+
return payload
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def render_scan_text(payload: Dict[str, Any]) -> str:
|
|
263
|
+
"""Space-separated one-line-per-resource text for installers and shell
|
|
264
|
+
scripts (NSIS/Inno/POSIX sh cannot parse JSON cheaply; this format is
|
|
265
|
+
stable: ``<kind> <ownership> <id> <name>``).
|
|
266
|
+
|
|
267
|
+
Example lines::
|
|
268
|
+
|
|
269
|
+
docker available
|
|
270
|
+
container owned cid123 aisc-wb-1
|
|
271
|
+
image owned sha256:abc super-claude:latest
|
|
272
|
+
"""
|
|
273
|
+
lines = [
|
|
274
|
+
"docker available" if payload.get("docker", {}).get("available")
|
|
275
|
+
else "docker unavailable"
|
|
276
|
+
]
|
|
277
|
+
for kind in ("containers", "images"):
|
|
278
|
+
buckets = payload.get(kind, {})
|
|
279
|
+
for ownership in ("owned", "legacy_owned", "unverified"):
|
|
280
|
+
for entry in buckets.get(ownership, []):
|
|
281
|
+
lines.append(
|
|
282
|
+
f"{kind.rstrip('s')} {ownership} {entry.get('id', '')} "
|
|
283
|
+
f"{entry.get('name', '')}"
|
|
284
|
+
)
|
|
285
|
+
for entry in payload.get("dangling_owned", []):
|
|
286
|
+
lines.append(f"image dangling-owned {entry.get('id', '')} {entry.get('id', '')}")
|
|
287
|
+
return "\n".join(lines)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# ---------------------------------------------------------------------------
|
|
291
|
+
# Cleanup (02 §3 order; per-resource failure never aborts the rest)
|
|
292
|
+
# ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
def _is_no_such(stderr: str) -> bool:
|
|
295
|
+
low = (stderr or "").lower()
|
|
296
|
+
return "no such" in low or "not found" in low
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def docker_cleanup(
|
|
300
|
+
executor: Any,
|
|
301
|
+
*,
|
|
302
|
+
context: str = "upgrade",
|
|
303
|
+
old_image_ids: Iterable[str] = (),
|
|
304
|
+
data_root: Optional[Path] = None,
|
|
305
|
+
) -> Dict[str, Any]:
|
|
306
|
+
"""Upgrade context cleans CONTAINERS ONLY (01 §3 upgrade ordering):
|
|
307
|
+
the tagged image must survive until docker-rebuild succeeds, or a
|
|
308
|
+
failed rebuild would leave the user with no workstation image at all;
|
|
309
|
+
the old image is then removed by ID via rebuild's old-ID handoff."""
|
|
310
|
+
result: Dict[str, Any] = {
|
|
311
|
+
"schema_version": CLEANUP_SCHEMA,
|
|
312
|
+
"action": "cleanup",
|
|
313
|
+
"containers": {"removed": [], "not_found": [], "failed": []},
|
|
314
|
+
"images": {"removed": [], "not_found": [], "failed": []},
|
|
315
|
+
"skipped_unverified": [],
|
|
316
|
+
"warnings": [],
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
def _run(argv: List[str], timeout: float = 60.0):
|
|
320
|
+
return executor.run_captured(argv, timeout=timeout)
|
|
321
|
+
|
|
322
|
+
# Re-scan + re-verify (invariant 6: never trust a stale list).
|
|
323
|
+
scan = docker_scan(executor, context=context, old_image_ids=old_image_ids,
|
|
324
|
+
data_root=data_root)
|
|
325
|
+
if not scan["docker"]["available"]:
|
|
326
|
+
raise CliError(
|
|
327
|
+
message="Docker unavailable; cleanup refused (nothing concluded)",
|
|
328
|
+
exit_code=RuntimeExitCode.DOCKER_UNAVAILABLE,
|
|
329
|
+
error_code=RuntimeErrorCode.DOCKER_UNAVAILABLE,
|
|
330
|
+
)
|
|
331
|
+
for entry in scan["containers"]["unverified"] + scan["images"]["unverified"]:
|
|
332
|
+
result["skipped_unverified"].append(entry["name"])
|
|
333
|
+
|
|
334
|
+
from aisc.adapters.maintenance_lock import docker_maintenance_lock_at_root
|
|
335
|
+
from aisc.application.data_root import shared_root
|
|
336
|
+
|
|
337
|
+
root = Path(data_root) if data_root else shared_root()
|
|
338
|
+
|
|
339
|
+
with docker_maintenance_lock_at_root(root):
|
|
340
|
+
# 1-3. containers first (order: stop orderly, then force remove).
|
|
341
|
+
for entry in scan["containers"]["owned"] + scan["containers"]["legacy_owned"]:
|
|
342
|
+
name = entry["name"]
|
|
343
|
+
if entry["state"] == "running":
|
|
344
|
+
stop = _run(["stop", "-t", "10", name], timeout=30.0)
|
|
345
|
+
if stop.exit_code != 0 and not _is_no_such(stop.stderr):
|
|
346
|
+
result["containers"]["failed"].append(name)
|
|
347
|
+
continue
|
|
348
|
+
rm = _run(["rm", "-f", name], timeout=60.0)
|
|
349
|
+
if rm.exit_code == 0 or _is_no_such(rm.stderr):
|
|
350
|
+
bucket = "removed" if rm.exit_code == 0 else "not_found"
|
|
351
|
+
result["containers"][bucket].append(name)
|
|
352
|
+
else:
|
|
353
|
+
result["containers"]["failed"].append(name)
|
|
354
|
+
|
|
355
|
+
# 4-6. re-scan image references, then untag/delete by evidence.
|
|
356
|
+
# Upgrade: images ride the rebuild handoff — skip entirely.
|
|
357
|
+
removed_ids = set()
|
|
358
|
+
if context == "upgrade":
|
|
359
|
+
scan2 = {"images": {"owned": [], "legacy_owned": [], "unverified": []},
|
|
360
|
+
"dangling_owned": []}
|
|
361
|
+
else:
|
|
362
|
+
scan2 = docker_scan(executor, context=context, old_image_ids=old_image_ids,
|
|
363
|
+
data_root=data_root)
|
|
364
|
+
for entry in scan2["images"]["owned"] + scan2["images"]["legacy_owned"]:
|
|
365
|
+
ref = entry["image"]
|
|
366
|
+
if entry["id"] in removed_ids:
|
|
367
|
+
continue
|
|
368
|
+
rm = _run(["rmi", ref], timeout=120.0)
|
|
369
|
+
if rm.exit_code == 0:
|
|
370
|
+
result["images"]["removed"].append(ref)
|
|
371
|
+
removed_ids.add(entry["id"])
|
|
372
|
+
elif _is_no_such(rm.stderr):
|
|
373
|
+
result["images"]["not_found"].append(ref)
|
|
374
|
+
else:
|
|
375
|
+
# referenced by another tag/container or refused — kept.
|
|
376
|
+
result["images"]["failed"].append(ref)
|
|
377
|
+
result["warnings"].append(
|
|
378
|
+
f"image kept (still referenced or refused): {ref}"
|
|
379
|
+
)
|
|
380
|
+
# 7. dangling with evidence.
|
|
381
|
+
for entry in scan2["dangling_owned"]:
|
|
382
|
+
if entry["id"] in removed_ids:
|
|
383
|
+
continue
|
|
384
|
+
rm = _run(["rmi", entry["id"]], timeout=120.0)
|
|
385
|
+
if rm.exit_code == 0:
|
|
386
|
+
result["images"]["removed"].append(entry["id"])
|
|
387
|
+
removed_ids.add(entry["id"])
|
|
388
|
+
elif not _is_no_such(rm.stderr):
|
|
389
|
+
result["images"]["failed"].append(entry["id"])
|
|
390
|
+
# Volumes/networks: deliberately nothing (invariant 4).
|
|
391
|
+
return result
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# ---------------------------------------------------------------------------
|
|
395
|
+
# Rebuild (02 §4)
|
|
396
|
+
# ---------------------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
def _cc_switch_for_rebuild() -> Any:
|
|
399
|
+
"""Deterministic-offline cc-switch pin for installer rebuilds.
|
|
400
|
+
|
|
401
|
+
Priority: explicit cached version (any age — a pinned digest is stable
|
|
402
|
+
content, and installers must not depend on reaching the GitHub API) →
|
|
403
|
+
live ``latest`` → None (Dockerfile ARG fallback, documented
|
|
404
|
+
non-reproducible; the result carries a warning).
|
|
405
|
+
"""
|
|
406
|
+
import json as _json
|
|
407
|
+
from aisc.application.cc_switch_resolver import CcSwitchResolver
|
|
408
|
+
from aisc.application.data_root import shared_root
|
|
409
|
+
|
|
410
|
+
resolver = CcSwitchResolver()
|
|
411
|
+
cache = shared_root() / "cache" / "cc-switch" / "last-resolved.json"
|
|
412
|
+
version = ""
|
|
413
|
+
try:
|
|
414
|
+
version = str(_json.loads(cache.read_text(encoding="utf-8")).get("version") or "")
|
|
415
|
+
except (OSError, ValueError):
|
|
416
|
+
version = ""
|
|
417
|
+
for candidate in ([version] if version else []) + ["latest"]:
|
|
418
|
+
try:
|
|
419
|
+
return resolver.resolve(version=candidate)
|
|
420
|
+
except Exception:
|
|
421
|
+
continue
|
|
422
|
+
return None
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
_CC_UNSET = object()
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def docker_rebuild(
|
|
429
|
+
executor: Any,
|
|
430
|
+
*,
|
|
431
|
+
root: str,
|
|
432
|
+
tag: str = DEFAULT_IMAGE_TAG,
|
|
433
|
+
old_image_id: str = "",
|
|
434
|
+
no_cache: bool = True,
|
|
435
|
+
pull: bool = False,
|
|
436
|
+
cc_switch: Any = _CC_UNSET,
|
|
437
|
+
) -> Dict[str, Any]:
|
|
438
|
+
"""No-cache rebuild of the workstation image with old-ID handoff.
|
|
439
|
+
|
|
440
|
+
``root`` is the BUNDLE ROOT (aisc-root shaped: ``container/Dockerfile``
|
|
441
|
+
+ ``config/versions.env`` — validated like ``aisc build``), matching
|
|
442
|
+
the installed ``$INSTDIR/aisc-bundle``. cc-switch is pinned from the
|
|
443
|
+
resolver cache when available (offline-deterministic; installers must
|
|
444
|
+
not require the GitHub API), falling back to the Dockerfile ARG path
|
|
445
|
+
with a warning.
|
|
446
|
+
|
|
447
|
+
Returns the 02 §4 result: old/new image ids, image_changed,
|
|
448
|
+
old_image_action (removed | untagged | kept_referenced | not_found),
|
|
449
|
+
reconcile_hint. Build failure PRESERVES the old image and reports
|
|
450
|
+
failed=true (the caller decides the UX; nothing is deleted on failure).
|
|
451
|
+
"""
|
|
452
|
+
from aisc.adapters.maintenance_lock import docker_maintenance_lock_at_root
|
|
453
|
+
from aisc.application.data_root import shared_root
|
|
454
|
+
from aisc.cli.commands.build import plan_build
|
|
455
|
+
|
|
456
|
+
if cc_switch is _CC_UNSET:
|
|
457
|
+
cc_switch = _cc_switch_for_rebuild()
|
|
458
|
+
warnings: List[str] = []
|
|
459
|
+
if cc_switch is None:
|
|
460
|
+
warnings.append(
|
|
461
|
+
"cc-switch not pinned (no resolver cache, live resolve failed); "
|
|
462
|
+
"built via Dockerfile ARG fallback"
|
|
463
|
+
)
|
|
464
|
+
try:
|
|
465
|
+
plan = plan_build(
|
|
466
|
+
Path(root), tag=tag, no_cache=no_cache, pull=pull,
|
|
467
|
+
dry_run=False, cc_switch=cc_switch,
|
|
468
|
+
)
|
|
469
|
+
except CliError as exc:
|
|
470
|
+
raise CliError(
|
|
471
|
+
message=f"bundle validation failed: {exc.message}",
|
|
472
|
+
exit_code=RuntimeExitCode.USAGE_ERROR,
|
|
473
|
+
error_code=RuntimeErrorCode.WORKSPACE_INVALID,
|
|
474
|
+
) from exc
|
|
475
|
+
|
|
476
|
+
with docker_maintenance_lock_at_root(shared_root()):
|
|
477
|
+
build = executor.run_captured(plan.docker_argv, timeout=1800.0)
|
|
478
|
+
new_id = _image_id_by_ref(executor, tag)
|
|
479
|
+
if build.exit_code != 0 or not new_id:
|
|
480
|
+
return {
|
|
481
|
+
"schema_version": REBUILD_SCHEMA,
|
|
482
|
+
"tag": tag,
|
|
483
|
+
"old_image_id": old_image_id,
|
|
484
|
+
"new_image_id": "",
|
|
485
|
+
"image_changed": False,
|
|
486
|
+
"old_image_action": "not_found" if not old_image_id else "kept_referenced",
|
|
487
|
+
"reconcile_hint": "unchanged",
|
|
488
|
+
"failed": True,
|
|
489
|
+
"warnings": warnings,
|
|
490
|
+
"build_log_tail": _log_tail(build.stdout or ""),
|
|
491
|
+
}
|
|
492
|
+
changed = bool(old_image_id) and old_image_id != new_id
|
|
493
|
+
action = "not_found"
|
|
494
|
+
if old_image_id and changed:
|
|
495
|
+
rm = executor.run_captured(["rmi", old_image_id], timeout=120.0)
|
|
496
|
+
if rm.exit_code == 0:
|
|
497
|
+
action = "removed"
|
|
498
|
+
elif _is_no_such(rm.stderr):
|
|
499
|
+
action = "not_found"
|
|
500
|
+
else:
|
|
501
|
+
action = "kept_referenced" # still tagged elsewhere / in use
|
|
502
|
+
return {
|
|
503
|
+
"schema_version": REBUILD_SCHEMA,
|
|
504
|
+
"tag": tag,
|
|
505
|
+
"old_image_id": old_image_id,
|
|
506
|
+
"new_image_id": new_id,
|
|
507
|
+
"image_changed": changed,
|
|
508
|
+
"old_image_action": action,
|
|
509
|
+
"reconcile_hint": "image_changed" if changed else "unchanged",
|
|
510
|
+
"failed": False,
|
|
511
|
+
"warnings": warnings,
|
|
512
|
+
"build_log_tail": _log_tail(build.stdout or ""),
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _image_id_by_ref(executor: Any, ref: str) -> str:
|
|
517
|
+
try:
|
|
518
|
+
result = executor.run_captured(
|
|
519
|
+
["image", "inspect", ref, "--format", "{{.Id}}"], timeout=15.0
|
|
520
|
+
)
|
|
521
|
+
except Exception:
|
|
522
|
+
return ""
|
|
523
|
+
if getattr(result, "exit_code", 1) != 0:
|
|
524
|
+
return ""
|
|
525
|
+
return (result.stdout or "").strip()
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _log_tail(stdout: str, lines: int = 20) -> str:
|
|
529
|
+
tail = "\n".join(stdout.strip().splitlines()[-lines:])
|
|
530
|
+
# Redaction-by-construction: build output contains no secrets; cap the
|
|
531
|
+
# size so the envelope stays bounded either way.
|
|
532
|
+
return tail[-4000:]
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
# ---------------------------------------------------------------------------
|
|
536
|
+
# O7 (opt-batch, D-11): build-cache cleanup — builder prune + dangling images
|
|
537
|
+
# with filters. Extends the module's safety invariants: NEVER a global
|
|
538
|
+
# `docker system prune`, NEVER `-a` (non-dangling images stay), and the
|
|
539
|
+
# `until=` filter keeps caches/entries younger than the window untouched.
|
|
540
|
+
# ---------------------------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
CACHE_USAGE_SCHEMA = "aisc.docker-cache-usage/v1"
|
|
543
|
+
CACHE_CLEANUP_SCHEMA = "aisc.docker-cache-cleanup/v1"
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _cache_cleanup_argv(kind: str, min_age_hours: int) -> List[str]:
|
|
547
|
+
"""Pure argv builder for the two prune kinds (test-pinned invariants).
|
|
548
|
+
|
|
549
|
+
builder → `docker builder prune --force --filter until=<h>h`
|
|
550
|
+
dangling → `docker image prune --force --filter until=<h>h` (NO -a)
|
|
551
|
+
"""
|
|
552
|
+
if kind == "builder":
|
|
553
|
+
return ["builder", "prune", "--force",
|
|
554
|
+
"--filter", f"until={min_age_hours}h"]
|
|
555
|
+
if kind == "dangling":
|
|
556
|
+
# image prune WITHOUT -a removes DANGLING (<none>) images only.
|
|
557
|
+
return ["image", "prune", "--force",
|
|
558
|
+
"--filter", f"until={min_age_hours}h"]
|
|
559
|
+
raise ValueError(f"unknown cache-cleanup kind: {kind}")
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _system_df(executor: Any) -> Dict[str, Dict[str, str]]:
|
|
563
|
+
"""`docker system df` rows keyed by type (Images/Containers/Local Volumes/
|
|
564
|
+
Build Cache). Unavailable docker → empty dict (caller surfaces it)."""
|
|
565
|
+
result = executor.run_captured(
|
|
566
|
+
["system", "df", "--format", "{{json .}}"], timeout=30.0
|
|
567
|
+
)
|
|
568
|
+
import json as _json
|
|
569
|
+
|
|
570
|
+
rows: Dict[str, Dict[str, str]] = {}
|
|
571
|
+
if getattr(result, "exit_code", 1) != 0:
|
|
572
|
+
return rows
|
|
573
|
+
for line in (result.stdout or "").splitlines():
|
|
574
|
+
line = line.strip()
|
|
575
|
+
if not line.startswith("{"):
|
|
576
|
+
continue
|
|
577
|
+
try:
|
|
578
|
+
row = _json.loads(line)
|
|
579
|
+
except ValueError:
|
|
580
|
+
continue
|
|
581
|
+
kind = str(row.get("Type") or "")
|
|
582
|
+
if kind:
|
|
583
|
+
rows[kind] = {
|
|
584
|
+
"total_count": str(row.get("TotalCount") or ""),
|
|
585
|
+
"active": str(row.get("Active") or ""),
|
|
586
|
+
"size": str(row.get("Size") or ""),
|
|
587
|
+
"reclaimable": str(row.get("Reclaimable") or ""),
|
|
588
|
+
}
|
|
589
|
+
return rows
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def cache_usage(executor: Any) -> Dict[str, Any]:
|
|
593
|
+
"""Read-only df summary for the settings card."""
|
|
594
|
+
df = _system_df(executor)
|
|
595
|
+
return {
|
|
596
|
+
"schema_version": CACHE_USAGE_SCHEMA,
|
|
597
|
+
"docker_available": bool(df),
|
|
598
|
+
"df": df,
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def docker_cache_cleanup(
|
|
603
|
+
executor: Any,
|
|
604
|
+
*,
|
|
605
|
+
min_age_hours: int = 24,
|
|
606
|
+
data_root: Optional[Path] = None,
|
|
607
|
+
) -> Dict[str, Any]:
|
|
608
|
+
"""Prune the build cache and dangling images (both until-filtered).
|
|
609
|
+
|
|
610
|
+
Never a global prune; never `-a`; running containers' layers are never
|
|
611
|
+
candidates (docker enforces this for both prune kinds). Reports the df
|
|
612
|
+
before/after deltas plus each prune's own reclaimed line.
|
|
613
|
+
"""
|
|
614
|
+
if min_age_hours < 1:
|
|
615
|
+
raise CliError(
|
|
616
|
+
message="min_age_hours must be >= 1 (refusing unfiltered prune)",
|
|
617
|
+
exit_code=RuntimeExitCode.USAGE_ERROR,
|
|
618
|
+
error_code="AISC_ERR_USAGE",
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
from aisc.adapters.maintenance_lock import docker_maintenance_lock_at_root
|
|
622
|
+
from aisc.application.data_root import shared_root
|
|
623
|
+
|
|
624
|
+
root = Path(data_root) if data_root else shared_root()
|
|
625
|
+
before = _system_df(executor)
|
|
626
|
+
if not before:
|
|
627
|
+
raise CliError(
|
|
628
|
+
message="Docker unavailable; cache cleanup refused",
|
|
629
|
+
exit_code=RuntimeExitCode.DOCKER_UNAVAILABLE,
|
|
630
|
+
error_code=RuntimeErrorCode.DOCKER_UNAVAILABLE,
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
result: Dict[str, Any] = {
|
|
634
|
+
"schema_version": CACHE_CLEANUP_SCHEMA,
|
|
635
|
+
"action": "cache-cleanup",
|
|
636
|
+
"min_age_hours": min_age_hours,
|
|
637
|
+
"prunes": [],
|
|
638
|
+
"df_before": before,
|
|
639
|
+
"df_after": {},
|
|
640
|
+
"warnings": [],
|
|
641
|
+
}
|
|
642
|
+
with docker_maintenance_lock_at_root(root):
|
|
643
|
+
for kind in ("builder", "dangling"):
|
|
644
|
+
argv = _cache_cleanup_argv(kind, min_age_hours)
|
|
645
|
+
pr = executor.run_captured(argv, timeout=300.0)
|
|
646
|
+
reclaimed = ""
|
|
647
|
+
for line in (pr.stdout or "").splitlines():
|
|
648
|
+
if "reclaimed" in line.lower():
|
|
649
|
+
reclaimed = line.strip()
|
|
650
|
+
break
|
|
651
|
+
entry = {"kind": kind, "argv": argv,
|
|
652
|
+
"exit_code": pr.exit_code, "reclaimed": reclaimed}
|
|
653
|
+
if pr.exit_code != 0:
|
|
654
|
+
entry["error"] = (pr.stderr or pr.stdout or "").strip()[:200]
|
|
655
|
+
result["warnings"].append(f"{kind} prune failed")
|
|
656
|
+
result["prunes"].append(entry)
|
|
657
|
+
|
|
658
|
+
result["df_after"] = _system_df(executor)
|
|
659
|
+
return result
|