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,718 @@
|
|
|
1
|
+
"""Container registry — multi-container index at ``.aisc/containers.json``.
|
|
2
|
+
|
|
3
|
+
Replaces the single-container ``CONTAINER_NAME`` pointer in ``state.env``.
|
|
4
|
+
Each ``aisc run`` registers a container; ``status``/``stop``/``restart``/
|
|
5
|
+
``shell``/``switch`` discover the target via :func:`resolve_target`.
|
|
6
|
+
|
|
7
|
+
The registry is a JSON file with a ``default`` pointer and a ``containers``
|
|
8
|
+
map keyed by container name. Writes are atomic (temp-file + rename) and
|
|
9
|
+
serialized with ``fcntl.flock`` to make concurrent ``run`` from multiple
|
|
10
|
+
terminals safe.
|
|
11
|
+
|
|
12
|
+
``resolve_target`` runs a lazy GC (``docker inspect`` each entry) before
|
|
13
|
+
addressing; entries whose container no longer exists are pruned so stale
|
|
14
|
+
records from deleted/crashed containers do not accumulate.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import tempfile
|
|
22
|
+
import time
|
|
23
|
+
from contextlib import contextmanager
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Dict, Iterator, List, Optional
|
|
26
|
+
|
|
27
|
+
from aisc.domain.models import CliError, RuntimeErrorCode, RuntimeExitCode
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_REGISTRY_FILE = "containers.json"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Path resolution
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
def _registry_path(root: Path) -> Path:
|
|
38
|
+
"""Return path to containers.json inside the STATE DIRECTORY *root*.
|
|
39
|
+
|
|
40
|
+
Stage 7 wiring: *root* is the registry's state directory itself (the
|
|
41
|
+
data-root ``workspaces/<hash>/runtime`` dir, or the legacy
|
|
42
|
+
``<workspace>/.aisc`` during transition) — callers resolve it via
|
|
43
|
+
``application.data_root.workspace_state_dir``; this module never
|
|
44
|
+
concatenates workspace paths (01-cross-stage-contracts §1).
|
|
45
|
+
"""
|
|
46
|
+
return root / _REGISTRY_FILE
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _state_dir(root: Path) -> Path:
|
|
50
|
+
"""Return the state directory for locks/temp files (= *root* itself)."""
|
|
51
|
+
return root
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _resolve_root(root: Optional[Path], explicit_root: Optional[str] = None) -> Optional[Path]:
|
|
55
|
+
"""Resolve the registry STATE DIR for the active workspace (Stage 7).
|
|
56
|
+
|
|
57
|
+
*root*/*explicit_root* may be a workspace path, a pre-resolved state
|
|
58
|
+
dir (legacy ``.aisc`` or data-root ``runtime``), or None (→ current
|
|
59
|
+
directory). State is per-workspace under the data root — the old
|
|
60
|
+
install-root fallback (repo/.aisc split-brain with run's workspace
|
|
61
|
+
registry) is gone; legacy state is adopted on first use.
|
|
62
|
+
Resolver failures propagate (fail closed, never write the workspace).
|
|
63
|
+
"""
|
|
64
|
+
if root is not None:
|
|
65
|
+
base = Path(root)
|
|
66
|
+
elif explicit_root is not None:
|
|
67
|
+
base = Path(explicit_root).resolve()
|
|
68
|
+
if not base.is_dir():
|
|
69
|
+
return None
|
|
70
|
+
else:
|
|
71
|
+
base = Path.cwd()
|
|
72
|
+
if base.name == ".aisc" or (base / _REGISTRY_FILE).is_file():
|
|
73
|
+
return base # already a state dir
|
|
74
|
+
from aisc.application.data_root import workspace_state_dir
|
|
75
|
+
|
|
76
|
+
return workspace_state_dir(base)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Read / write
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def _read_registry(root: Path) -> Dict[str, Any]:
|
|
84
|
+
"""Load the registry. Returns empty structure if file absent or corrupt."""
|
|
85
|
+
path = _registry_path(root)
|
|
86
|
+
if not path.is_file():
|
|
87
|
+
return {"default": "", "containers": {}}
|
|
88
|
+
try:
|
|
89
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
90
|
+
data = json.load(f)
|
|
91
|
+
except (json.JSONDecodeError, OSError):
|
|
92
|
+
return {"default": "", "containers": {}}
|
|
93
|
+
if not isinstance(data, dict):
|
|
94
|
+
return {"default": "", "containers": {}}
|
|
95
|
+
data.setdefault("default", "")
|
|
96
|
+
data.setdefault("containers", {})
|
|
97
|
+
if not isinstance(data["containers"], dict):
|
|
98
|
+
data["containers"] = {}
|
|
99
|
+
return data
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@contextmanager
|
|
103
|
+
def _registry_lock(root: Path, timeout: float = 10.0) -> Iterator[None]:
|
|
104
|
+
"""Hold the registry lock across a complete read-modify-write cycle.
|
|
105
|
+
|
|
106
|
+
Uses fcntl.flock (POSIX) or msvcrt.locking (Windows).
|
|
107
|
+
Fail-closed: raises on lock acquisition failure.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
root: AISC root directory
|
|
111
|
+
timeout: Lock timeout in seconds (default 10.0)
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
CliError (STATE_LOCK_TIMEOUT): If lock cannot be acquired within timeout
|
|
115
|
+
OSError: On other lock-related errors
|
|
116
|
+
|
|
117
|
+
Yields:
|
|
118
|
+
None while lock is held
|
|
119
|
+
"""
|
|
120
|
+
import sys
|
|
121
|
+
import time as time_module
|
|
122
|
+
|
|
123
|
+
state_dir = _state_dir(root)
|
|
124
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
lock_path = state_dir / ".containers.lock"
|
|
126
|
+
lock_fd = None
|
|
127
|
+
locked = False
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
# Open lock file
|
|
131
|
+
lock_fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR, 0o644)
|
|
132
|
+
|
|
133
|
+
# Platform-specific locking with timeout
|
|
134
|
+
if sys.platform == "win32":
|
|
135
|
+
# Windows: msvcrt.locking with bounded retry
|
|
136
|
+
import msvcrt
|
|
137
|
+
|
|
138
|
+
start_time = time_module.time()
|
|
139
|
+
while True:
|
|
140
|
+
try:
|
|
141
|
+
# Try to lock 1 byte at offset 0
|
|
142
|
+
msvcrt.locking(lock_fd, msvcrt.LK_NBLCK, 1)
|
|
143
|
+
locked = True
|
|
144
|
+
break
|
|
145
|
+
except OSError as e:
|
|
146
|
+
elapsed = time_module.time() - start_time
|
|
147
|
+
if elapsed >= timeout:
|
|
148
|
+
raise CliError(
|
|
149
|
+
message=f"Failed to acquire registry lock within {timeout}s",
|
|
150
|
+
exit_code=RuntimeExitCode.STATE_LOCK_TIMEOUT,
|
|
151
|
+
error_code=RuntimeErrorCode.STATE_LOCK_TIMEOUT,
|
|
152
|
+
) from e
|
|
153
|
+
# Retry after short sleep
|
|
154
|
+
time_module.sleep(0.1)
|
|
155
|
+
else:
|
|
156
|
+
# POSIX: fcntl.flock with alarm-based timeout
|
|
157
|
+
import fcntl
|
|
158
|
+
import signal
|
|
159
|
+
|
|
160
|
+
def timeout_handler(signum, frame):
|
|
161
|
+
raise CliError(
|
|
162
|
+
message=f"Failed to acquire registry lock within {timeout}s",
|
|
163
|
+
exit_code=RuntimeExitCode.STATE_LOCK_TIMEOUT,
|
|
164
|
+
error_code=RuntimeErrorCode.STATE_LOCK_TIMEOUT,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Only set alarm if we're in the main thread
|
|
168
|
+
# (signal.alarm only works in main thread)
|
|
169
|
+
try:
|
|
170
|
+
old_handler = signal.signal(signal.SIGALRM, timeout_handler)
|
|
171
|
+
# Use ceil to ensure timeout >= 1 second works
|
|
172
|
+
signal.alarm(int(timeout) + 1)
|
|
173
|
+
alarm_set = True
|
|
174
|
+
except ValueError:
|
|
175
|
+
# Not in main thread, fall back to blocking lock
|
|
176
|
+
alarm_set = False
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
|
180
|
+
locked = True
|
|
181
|
+
finally:
|
|
182
|
+
if alarm_set:
|
|
183
|
+
signal.alarm(0)
|
|
184
|
+
signal.signal(signal.SIGALRM, old_handler)
|
|
185
|
+
|
|
186
|
+
yield
|
|
187
|
+
|
|
188
|
+
finally:
|
|
189
|
+
# Release lock
|
|
190
|
+
if locked and lock_fd is not None:
|
|
191
|
+
try:
|
|
192
|
+
if sys.platform == "win32":
|
|
193
|
+
import msvcrt
|
|
194
|
+
msvcrt.locking(lock_fd, msvcrt.LK_UNLCK, 1)
|
|
195
|
+
else:
|
|
196
|
+
import fcntl
|
|
197
|
+
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
|
198
|
+
except OSError:
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
# Close file descriptor
|
|
202
|
+
if lock_fd is not None:
|
|
203
|
+
try:
|
|
204
|
+
os.close(lock_fd)
|
|
205
|
+
except OSError:
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _write_registry_unlocked(root: Path, data: Dict[str, Any]) -> None:
|
|
210
|
+
"""Atomically replace the registry; caller is responsible for locking.
|
|
211
|
+
|
|
212
|
+
Atomicity still prevents readers from observing a partially written JSON
|
|
213
|
+
document while the separate lock serializes writers.
|
|
214
|
+
"""
|
|
215
|
+
state_dir = _state_dir(root)
|
|
216
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
217
|
+
path = _registry_path(root)
|
|
218
|
+
|
|
219
|
+
fd, tmp_path = tempfile.mkstemp(prefix=".containers_", dir=str(state_dir), text=True)
|
|
220
|
+
try:
|
|
221
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
222
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
223
|
+
f.flush()
|
|
224
|
+
os.fsync(f.fileno())
|
|
225
|
+
os.replace(tmp_path, str(path))
|
|
226
|
+
except Exception:
|
|
227
|
+
try:
|
|
228
|
+
os.unlink(tmp_path)
|
|
229
|
+
except OSError:
|
|
230
|
+
pass
|
|
231
|
+
raise
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _write_registry(root: Path, data: Dict[str, Any]) -> None:
|
|
235
|
+
"""Atomically write a complete registry under the registry lock."""
|
|
236
|
+
with _registry_lock(root):
|
|
237
|
+
_write_registry_unlocked(root, data)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
# Public API
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
def register(root: Path, name: str, meta: Dict[str, Any],
|
|
245
|
+
*, set_default: bool = True) -> None:
|
|
246
|
+
"""Register a container and mark it as the default target.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
root: AISC root directory.
|
|
250
|
+
name: Container name (unique per run).
|
|
251
|
+
meta: Metadata dict with keys:
|
|
252
|
+
- image: Docker image name
|
|
253
|
+
- workspace: Workspace path
|
|
254
|
+
- network: Network mode (direct/proxy)
|
|
255
|
+
- label: Optional user label
|
|
256
|
+
- runtime_id: Runtime ID (UUID v4 for Workbench runtimes)
|
|
257
|
+
- owner: Username who created this runtime
|
|
258
|
+
- scope: Scope mode (project/temporary)
|
|
259
|
+
- config_fingerprint: Config hash for idempotent retry
|
|
260
|
+
- container_id: Docker container ID (Workbench runtimes)
|
|
261
|
+
- workspace_key: sha256 of canonical workspace (Workbench runtimes)
|
|
262
|
+
- image_id: Content-addressed image ID at create time
|
|
263
|
+
(容器随镜像同步更新, KI-4 挂账 — empty on legacy records)
|
|
264
|
+
- lifecycle/retention/dependency_policy (runtime-lifecycle-ux
|
|
265
|
+
02 §1): "" on legacy records — absent lifecycle is the legacy
|
|
266
|
+
marker reconcile treats as recyclable-after-verification;
|
|
267
|
+
never part of the config fingerprint.
|
|
268
|
+
- workbench_instance_id: creating Workbench instance (Stage 2+
|
|
269
|
+
callers); "" for CLI-created runtimes.
|
|
270
|
+
set_default: also mark the container as the default target. The
|
|
271
|
+
image_id heal path (start_runtime reusing a legacy record)
|
|
272
|
+
re-registers with False so an in-place metadata fix never
|
|
273
|
+
steals the default from another container.
|
|
274
|
+
|
|
275
|
+
Backward compatible: if old fields are missing, stores empty strings.
|
|
276
|
+
"""
|
|
277
|
+
entry = {
|
|
278
|
+
"image": meta.get("image", ""),
|
|
279
|
+
"workspace": meta.get("workspace", ""),
|
|
280
|
+
"network": meta.get("network", ""),
|
|
281
|
+
"label": meta.get("label", ""),
|
|
282
|
+
"created_at": meta.get("created_at") or time.time(),
|
|
283
|
+
# New fields (v2.2.0+) - backward compatible
|
|
284
|
+
"runtime_id": meta.get("runtime_id", ""),
|
|
285
|
+
"owner": meta.get("owner", ""),
|
|
286
|
+
"scope": meta.get("scope", ""),
|
|
287
|
+
"config_fingerprint": meta.get("config_fingerprint", ""),
|
|
288
|
+
"container_id": meta.get("container_id", ""),
|
|
289
|
+
"workspace_key": meta.get("workspace_key", ""),
|
|
290
|
+
"image_id": meta.get("image_id", ""),
|
|
291
|
+
# svc-2 (web gateway): loopback host port of this runtime's gateway
|
|
292
|
+
# publish; 0/absent on legacy records (web access reports
|
|
293
|
+
# legacy_runtime). Runtime metadata only — never in the fingerprint.
|
|
294
|
+
"web_gateway_host_port": meta.get("web_gateway_host_port", 0),
|
|
295
|
+
# runtime-lifecycle-ux Stage 1 (02 §1): lifecycle metadata. Epoch
|
|
296
|
+
# float to match created_at (contract shows RFC3339; the registry's
|
|
297
|
+
# existing timestamp convention wins for on-disk consistency).
|
|
298
|
+
"lifecycle": meta.get("lifecycle", ""),
|
|
299
|
+
"retention": meta.get("retention", ""),
|
|
300
|
+
"dependency_policy": meta.get("dependency_policy", ""),
|
|
301
|
+
"workbench_instance_id": meta.get("workbench_instance_id", ""),
|
|
302
|
+
# runtime-lifecycle-ux 3a: which storage backend this runtime's
|
|
303
|
+
# persistent toolchain uses ("" on legacy/non-project records).
|
|
304
|
+
"toolchain_storage": meta.get("toolchain_storage", ""),
|
|
305
|
+
"last_state_change_at": meta.get("last_state_change_at") or time.time(),
|
|
306
|
+
}
|
|
307
|
+
with _registry_lock(root):
|
|
308
|
+
data = _read_registry(root)
|
|
309
|
+
data["containers"][name] = entry
|
|
310
|
+
if set_default:
|
|
311
|
+
data["default"] = name
|
|
312
|
+
_write_registry_unlocked(root, data)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def unregister(root: Path, name: str) -> None:
|
|
316
|
+
"""Remove a container from the registry.
|
|
317
|
+
|
|
318
|
+
If the removed name was the default, repoint default to the most recently
|
|
319
|
+
created remaining entry (max created_at). Clears default when empty.
|
|
320
|
+
"""
|
|
321
|
+
with _registry_lock(root):
|
|
322
|
+
data = _read_registry(root)
|
|
323
|
+
data["containers"].pop(name, None)
|
|
324
|
+
if data["default"] == name:
|
|
325
|
+
data["default"] = _pick_newest(data["containers"])
|
|
326
|
+
_write_registry_unlocked(root, data)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@contextmanager
|
|
330
|
+
def workspace_lock(root: Path, workspace_key: str, timeout: float = 10.0) -> Iterator[None]:
|
|
331
|
+
"""Hold a per-workspace lock for project Runtime start.
|
|
332
|
+
|
|
333
|
+
Serializes ``registry/labels conflict check -> Docker create/ready ->
|
|
334
|
+
registry commit`` for one canonical workspace so two concurrent
|
|
335
|
+
``project`` starts on the same workspace cannot both succeed.
|
|
336
|
+
|
|
337
|
+
Lock file: ``<state_dir>/workspace-locks/<workspace_key>.lock``.
|
|
338
|
+
Cross-platform and fail-closed, identical semantics to
|
|
339
|
+
:func:`_registry_lock`. Lock order is ``workspace lock -> registry lock``;
|
|
340
|
+
callers must not acquire the registry lock first.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
root: Registry root (workspace root or ``.aisc`` dir).
|
|
344
|
+
workspace_key: ``sha256`` hex of the canonical workspace path.
|
|
345
|
+
timeout: Lock acquisition timeout in seconds.
|
|
346
|
+
|
|
347
|
+
Raises:
|
|
348
|
+
CliError (STATE_LOCK_TIMEOUT): if the lock cannot be acquired within *timeout*.
|
|
349
|
+
"""
|
|
350
|
+
import sys
|
|
351
|
+
import time as time_module
|
|
352
|
+
|
|
353
|
+
state_dir = _state_dir(root)
|
|
354
|
+
locks_dir = state_dir / "workspace-locks"
|
|
355
|
+
locks_dir.mkdir(parents=True, exist_ok=True)
|
|
356
|
+
lock_path = locks_dir / f"{workspace_key}.lock"
|
|
357
|
+
lock_fd = None
|
|
358
|
+
locked = False
|
|
359
|
+
|
|
360
|
+
try:
|
|
361
|
+
lock_fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR, 0o644)
|
|
362
|
+
|
|
363
|
+
if sys.platform == "win32":
|
|
364
|
+
import msvcrt
|
|
365
|
+
|
|
366
|
+
start_time = time_module.time()
|
|
367
|
+
while True:
|
|
368
|
+
try:
|
|
369
|
+
msvcrt.locking(lock_fd, msvcrt.LK_NBLCK, 1)
|
|
370
|
+
locked = True
|
|
371
|
+
break
|
|
372
|
+
except OSError as e:
|
|
373
|
+
if time_module.time() - start_time >= timeout:
|
|
374
|
+
raise CliError(
|
|
375
|
+
message=f"Failed to acquire workspace lock within {timeout}s",
|
|
376
|
+
exit_code=RuntimeExitCode.STATE_LOCK_TIMEOUT,
|
|
377
|
+
error_code=RuntimeErrorCode.STATE_LOCK_TIMEOUT,
|
|
378
|
+
) from e
|
|
379
|
+
time_module.sleep(0.1)
|
|
380
|
+
else:
|
|
381
|
+
import fcntl
|
|
382
|
+
import signal
|
|
383
|
+
|
|
384
|
+
def timeout_handler(signum, frame):
|
|
385
|
+
raise CliError(
|
|
386
|
+
message=f"Failed to acquire workspace lock within {timeout}s",
|
|
387
|
+
exit_code=RuntimeExitCode.STATE_LOCK_TIMEOUT,
|
|
388
|
+
error_code=RuntimeErrorCode.STATE_LOCK_TIMEOUT,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
# SIGALRM-based timeout only works in the main thread; a non-main
|
|
392
|
+
# caller (signal.signal raises ValueError) falls back to an
|
|
393
|
+
# unbounded blocking flock. Fine for the single-threaded aisc CLI;
|
|
394
|
+
# a future threaded caller would need a different timeout strategy.
|
|
395
|
+
alarm_set = False
|
|
396
|
+
try:
|
|
397
|
+
old_handler = signal.signal(signal.SIGALRM, timeout_handler)
|
|
398
|
+
signal.alarm(int(timeout) + 1)
|
|
399
|
+
alarm_set = True
|
|
400
|
+
except ValueError:
|
|
401
|
+
alarm_set = False
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
|
405
|
+
locked = True
|
|
406
|
+
finally:
|
|
407
|
+
if alarm_set:
|
|
408
|
+
signal.alarm(0)
|
|
409
|
+
signal.signal(signal.SIGALRM, old_handler)
|
|
410
|
+
|
|
411
|
+
yield
|
|
412
|
+
|
|
413
|
+
finally:
|
|
414
|
+
if locked and lock_fd is not None:
|
|
415
|
+
try:
|
|
416
|
+
if sys.platform == "win32":
|
|
417
|
+
import msvcrt
|
|
418
|
+
msvcrt.locking(lock_fd, msvcrt.LK_UNLCK, 1)
|
|
419
|
+
else:
|
|
420
|
+
import fcntl
|
|
421
|
+
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
|
422
|
+
except OSError:
|
|
423
|
+
pass
|
|
424
|
+
if lock_fd is not None:
|
|
425
|
+
try:
|
|
426
|
+
os.close(lock_fd)
|
|
427
|
+
except OSError:
|
|
428
|
+
pass
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def find_by_runtime_id(root: Path, runtime_id: str) -> Optional[tuple]:
|
|
432
|
+
"""Return ``(container_name, meta)`` for the registry entry matching
|
|
433
|
+
*runtime_id*, or ``None`` if not found. Reads under the registry lock.
|
|
434
|
+
"""
|
|
435
|
+
with _registry_lock(root):
|
|
436
|
+
data = _read_registry(root)
|
|
437
|
+
for name, meta in data["containers"].items():
|
|
438
|
+
if isinstance(meta, dict) and meta.get("runtime_id", "") == runtime_id:
|
|
439
|
+
import copy
|
|
440
|
+
return name, copy.deepcopy(meta)
|
|
441
|
+
return None
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def unregister_by_runtime_id(root: Path, runtime_id: str) -> Optional[str]:
|
|
445
|
+
"""Remove the registry entry matching *runtime_id*.
|
|
446
|
+
|
|
447
|
+
Returns the removed container name, or ``None`` if no entry matched.
|
|
448
|
+
Repoints ``default`` when the removed entry was the default.
|
|
449
|
+
"""
|
|
450
|
+
with _registry_lock(root):
|
|
451
|
+
data = _read_registry(root)
|
|
452
|
+
removed: Optional[str] = None
|
|
453
|
+
for name, meta in data["containers"].items():
|
|
454
|
+
if isinstance(meta, dict) and meta.get("runtime_id", "") == runtime_id:
|
|
455
|
+
removed = name
|
|
456
|
+
break
|
|
457
|
+
if removed is None:
|
|
458
|
+
return None
|
|
459
|
+
data["containers"].pop(removed, None)
|
|
460
|
+
if data["default"] == removed:
|
|
461
|
+
data["default"] = _pick_newest(data["containers"])
|
|
462
|
+
_write_registry_unlocked(root, data)
|
|
463
|
+
return removed
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def list_containers(root: Path) -> Dict[str, Dict[str, Any]]:
|
|
467
|
+
"""Return all registered container entries (name → meta).
|
|
468
|
+
|
|
469
|
+
Reads registry snapshot under lock to ensure consistency.
|
|
470
|
+
Returns a copy to prevent external mutation.
|
|
471
|
+
"""
|
|
472
|
+
with _registry_lock(root):
|
|
473
|
+
data = _read_registry(root)
|
|
474
|
+
# Return a deep copy to prevent external mutation
|
|
475
|
+
import copy
|
|
476
|
+
return copy.deepcopy(data["containers"])
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def list_containers_readonly(root: Path) -> Dict[str, Dict[str, Any]]:
|
|
480
|
+
"""Return all registered container entries (name → meta) without side effects.
|
|
481
|
+
|
|
482
|
+
Read-only: does not acquire lock or create directories/files.
|
|
483
|
+
For preflight and other observational operations.
|
|
484
|
+
Returns empty dict if registry file doesn't exist.
|
|
485
|
+
Raises exception if registry exists but is corrupted/unreadable.
|
|
486
|
+
"""
|
|
487
|
+
import copy
|
|
488
|
+
import json
|
|
489
|
+
|
|
490
|
+
registry_file = _registry_path(root)
|
|
491
|
+
if not registry_file.exists():
|
|
492
|
+
return {}
|
|
493
|
+
|
|
494
|
+
# File exists, so corruption/read errors must be raised
|
|
495
|
+
with open(registry_file, "r", encoding="utf-8") as f:
|
|
496
|
+
data = json.load(f)
|
|
497
|
+
if not isinstance(data, dict):
|
|
498
|
+
raise ValueError("Registry root is not a dict")
|
|
499
|
+
containers = data.get("containers", {})
|
|
500
|
+
if not isinstance(containers, dict):
|
|
501
|
+
raise ValueError("Registry containers field is not a dict")
|
|
502
|
+
return copy.deepcopy(containers)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def get_default(root: Path) -> str:
|
|
506
|
+
"""Return the default container name, or empty string if none."""
|
|
507
|
+
with _registry_lock(root):
|
|
508
|
+
data = _read_registry(root)
|
|
509
|
+
return data.get("default", "")
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _pick_newest(containers: Dict[str, Any]) -> str:
|
|
513
|
+
"""Return the name of the entry with the greatest created_at, or ''."""
|
|
514
|
+
if not containers:
|
|
515
|
+
return ""
|
|
516
|
+
best_name = ""
|
|
517
|
+
best_ts = -1.0
|
|
518
|
+
for nm, meta in containers.items():
|
|
519
|
+
ts = meta.get("created_at", 0) if isinstance(meta, dict) else 0
|
|
520
|
+
if ts >= best_ts:
|
|
521
|
+
best_ts = ts
|
|
522
|
+
best_name = nm
|
|
523
|
+
return best_name
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
# ---------------------------------------------------------------------------
|
|
527
|
+
# GC — prune entries whose docker container no longer exists
|
|
528
|
+
# ---------------------------------------------------------------------------
|
|
529
|
+
|
|
530
|
+
def _container_exists(executor, name: str) -> Optional[bool]:
|
|
531
|
+
"""Return True/False for container existence, or None if docker is
|
|
532
|
+
unreachable (GC skipped, do not block addressing)."""
|
|
533
|
+
fmt = '{{.State.Running}}'
|
|
534
|
+
proc = executor.run_captured(["inspect", "--format", fmt, name], timeout=10.0)
|
|
535
|
+
if proc.command_not_found or proc.timed_out:
|
|
536
|
+
return None
|
|
537
|
+
stderr_lower = (proc.stderr or "").lower()
|
|
538
|
+
if proc.exit_code != 0:
|
|
539
|
+
if any(kw in stderr_lower for kw in (
|
|
540
|
+
"no such object", "no such container", "not found",
|
|
541
|
+
)):
|
|
542
|
+
return False
|
|
543
|
+
# Daemon down / permission — skip GC
|
|
544
|
+
if any(kw in stderr_lower for kw in (
|
|
545
|
+
"cannot connect", "is the docker daemon running",
|
|
546
|
+
"connection refused", "error during connect", "permission denied",
|
|
547
|
+
)):
|
|
548
|
+
return None
|
|
549
|
+
return None
|
|
550
|
+
return True
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def gc(root: Path, executor) -> List[str]:
|
|
554
|
+
"""Prune registry entries whose container is gone. Returns pruned names.
|
|
555
|
+
|
|
556
|
+
Best-effort: if docker is unreachable, prunes nothing and returns [].
|
|
557
|
+
|
|
558
|
+
Pattern: lock→snapshot→unlock→inspect→relock→compare→prune per contract.
|
|
559
|
+
"""
|
|
560
|
+
# Phase 1: lock → snapshot → unlock
|
|
561
|
+
with _registry_lock(root):
|
|
562
|
+
snapshot = _read_registry(root)
|
|
563
|
+
containers = snapshot.get("containers", {})
|
|
564
|
+
if not containers:
|
|
565
|
+
return []
|
|
566
|
+
|
|
567
|
+
# Phase 2: Docker inspect outside lock
|
|
568
|
+
missing: List[str] = []
|
|
569
|
+
for nm in list(containers):
|
|
570
|
+
exists = _container_exists(executor, nm)
|
|
571
|
+
if exists is False:
|
|
572
|
+
missing.append(nm)
|
|
573
|
+
|
|
574
|
+
if not missing:
|
|
575
|
+
return []
|
|
576
|
+
|
|
577
|
+
# Phase 3: relock → compare current entry → conditional prune
|
|
578
|
+
with _registry_lock(root):
|
|
579
|
+
current_data = _read_registry(root)
|
|
580
|
+
current_containers = current_data.get("containers", {})
|
|
581
|
+
|
|
582
|
+
pruned: List[str] = []
|
|
583
|
+
for nm in missing:
|
|
584
|
+
# Do not delete a same-name container re-registered while Docker
|
|
585
|
+
# checks were running.
|
|
586
|
+
if current_containers.get(nm) == containers.get(nm):
|
|
587
|
+
current_containers.pop(nm, None)
|
|
588
|
+
pruned.append(nm)
|
|
589
|
+
|
|
590
|
+
if pruned:
|
|
591
|
+
if current_data["default"] in pruned:
|
|
592
|
+
current_data["default"] = _pick_newest(current_containers)
|
|
593
|
+
_write_registry_unlocked(root, current_data)
|
|
594
|
+
return pruned
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
# ---------------------------------------------------------------------------
|
|
598
|
+
# Target resolution — replaces discover_container
|
|
599
|
+
# ---------------------------------------------------------------------------
|
|
600
|
+
|
|
601
|
+
def _format_candidates(containers: Dict[str, Dict[str, Any]]) -> str:
|
|
602
|
+
lines = []
|
|
603
|
+
for nm, meta in containers.items():
|
|
604
|
+
label = meta.get("label", "") or "-"
|
|
605
|
+
img = meta.get("image", "")
|
|
606
|
+
ws = meta.get("workspace", "")
|
|
607
|
+
lines.append(f" {nm} [{label}] {img} {ws}")
|
|
608
|
+
return "\n".join(lines)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def resolve_target(
|
|
612
|
+
root: Optional[Path] = None,
|
|
613
|
+
*,
|
|
614
|
+
name_override: Optional[str] = None,
|
|
615
|
+
label_override: Optional[str] = None,
|
|
616
|
+
executor=None,
|
|
617
|
+
explicit_root: Optional[str] = None,
|
|
618
|
+
) -> str:
|
|
619
|
+
"""Resolve which container to operate on.
|
|
620
|
+
|
|
621
|
+
Priority:
|
|
622
|
+
1. ``name_override`` — validate existence via GC, return (or error+list)
|
|
623
|
+
2. ``label_override`` — match by label; unique → return
|
|
624
|
+
3. ``default`` pointer (after GC) — return if still present
|
|
625
|
+
4. single registered container — return it
|
|
626
|
+
5. multiple + no override — ``CliError`` listing candidates
|
|
627
|
+
|
|
628
|
+
Raises:
|
|
629
|
+
CliError: when no target can be resolved.
|
|
630
|
+
"""
|
|
631
|
+
# r2 #2: --name needs no workspace at all — the name IS the address.
|
|
632
|
+
# Resolving a workspace from cwd misfires outside workspaces (from
|
|
633
|
+
# $HOME the data-root guard fires outright), blocking the very
|
|
634
|
+
# scenario --name exists for. Registry lookups degrade to "not
|
|
635
|
+
# registered" (the name is still accepted; cmd_status verifies it
|
|
636
|
+
# against docker).
|
|
637
|
+
resolved_root: Optional[Path] = None
|
|
638
|
+
try:
|
|
639
|
+
resolved_root = _resolve_root(root, explicit_root)
|
|
640
|
+
except CliError:
|
|
641
|
+
if not name_override:
|
|
642
|
+
raise
|
|
643
|
+
if resolved_root is None and not name_override:
|
|
644
|
+
raise CliError(
|
|
645
|
+
message=(
|
|
646
|
+
"No AISC root found and no container name/label given.\n"
|
|
647
|
+
" Pass --name NAME or --label LABEL, or run inside an AISC workspace."
|
|
648
|
+
),
|
|
649
|
+
exit_code=1, error_code="AISC_ERR_CONTAINER_NOT_FOUND",
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
# Lazy GC before addressing (best-effort, executor may be None in dry paths)
|
|
653
|
+
if executor is not None and resolved_root is not None:
|
|
654
|
+
try:
|
|
655
|
+
gc(resolved_root, executor)
|
|
656
|
+
except Exception:
|
|
657
|
+
pass
|
|
658
|
+
|
|
659
|
+
containers = list_containers(resolved_root) if resolved_root is not None else {}
|
|
660
|
+
|
|
661
|
+
# 1. explicit name
|
|
662
|
+
if name_override:
|
|
663
|
+
if name_override in containers:
|
|
664
|
+
return name_override
|
|
665
|
+
# Not in registry — may still exist in docker (e.g. --name passed to a
|
|
666
|
+
# container run outside aisc, or registry was wiped). Accept it; caller's
|
|
667
|
+
# cmd_status will verify real existence via docker inspect.
|
|
668
|
+
return name_override
|
|
669
|
+
|
|
670
|
+
# 2. explicit label
|
|
671
|
+
if label_override:
|
|
672
|
+
matches = [nm for nm, m in containers.items()
|
|
673
|
+
if m.get("label", "") == label_override]
|
|
674
|
+
if len(matches) == 1:
|
|
675
|
+
return matches[0]
|
|
676
|
+
if not matches:
|
|
677
|
+
raise CliError(
|
|
678
|
+
message=(
|
|
679
|
+
f"No container with label '{label_override}'. Registered:\n"
|
|
680
|
+
f"{_format_candidates(containers) or ' (none)'}"
|
|
681
|
+
),
|
|
682
|
+
exit_code=1, error_code="AISC_ERR_CONTAINER_NOT_FOUND",
|
|
683
|
+
)
|
|
684
|
+
raise CliError(
|
|
685
|
+
message=(
|
|
686
|
+
f"Multiple containers share label '{label_override}':\n"
|
|
687
|
+
+ _format_candidates({nm: containers[nm] for nm in matches})
|
|
688
|
+
+ "\nSpecify --name NAME to disambiguate."
|
|
689
|
+
),
|
|
690
|
+
exit_code=1, error_code="AISC_ERR_MULTIPLE_CONTAINERS",
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
# 3. default pointer
|
|
694
|
+
default = get_default(resolved_root)
|
|
695
|
+
if default and default in containers:
|
|
696
|
+
return default
|
|
697
|
+
|
|
698
|
+
# 4. single container
|
|
699
|
+
if len(containers) == 1:
|
|
700
|
+
return next(iter(containers))
|
|
701
|
+
|
|
702
|
+
# 5. none / multiple
|
|
703
|
+
if not containers:
|
|
704
|
+
raise CliError(
|
|
705
|
+
message=(
|
|
706
|
+
"当前没有已注册容器——先用 aisc run <路径> 激活,"
|
|
707
|
+
"或用 --name <名> / --label <标签> 指定目标。"
|
|
708
|
+
),
|
|
709
|
+
exit_code=1, error_code="AISC_ERR_CONTAINER_NOT_FOUND",
|
|
710
|
+
)
|
|
711
|
+
raise CliError(
|
|
712
|
+
message=(
|
|
713
|
+
f"注册了多个容器({len(containers)} 个),请用 --name <名> "
|
|
714
|
+
f"或 --label <标签> 指定:\n"
|
|
715
|
+
+ _format_candidates(containers)
|
|
716
|
+
),
|
|
717
|
+
exit_code=1, error_code="AISC_ERR_MULTIPLE_CONTAINERS",
|
|
718
|
+
)
|