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,274 @@
|
|
|
1
|
+
"""PERF P4 (D-13): SDK-backed Docker executor for the command layer.
|
|
2
|
+
|
|
3
|
+
The runtime/provider/services command layer used to hardcode
|
|
4
|
+
``RealDockerExecutor`` — every Docker call inside each aisc.exe invocation
|
|
5
|
+
was a ``docker.exe`` subprocess chain (info → ps → inspect → exec). On weak
|
|
6
|
+
machines each docker.exe cold start costs 100-300ms × 5-6 calls per tick.
|
|
7
|
+
|
|
8
|
+
``SdkBackedDockerExecutor`` implements the same ``DockerExecutor`` protocol
|
|
9
|
+
with the HOT-READ face served over the docker SDK's named-pipe/unix-socket
|
|
10
|
+
HTTP client (in-process, zero subprocesses):
|
|
11
|
+
|
|
12
|
+
- ``preflight()`` — SDK ping first, CLI fallback (reason vocabulary kept)
|
|
13
|
+
- ``inspect_container(name)`` — SDK inspect; NotFound maps to the CLI's
|
|
14
|
+
"No such object" stderr semantics the callers pattern-match
|
|
15
|
+
- ``run_captured`` for the three hot argv shapes:
|
|
16
|
+
* ``ps -a --filter label=... --format <template>`` — SDK containers.list
|
|
17
|
+
+ a mini renderer for the templates we ship (``ID``/``Names``/``Image``/
|
|
18
|
+
``Status``/``Label "k"`` tokens only — anything else falls back to CLI)
|
|
19
|
+
* ``exec <name> <cmd...>`` (no flags) — exec_create/start/inspect
|
|
20
|
+
* ``inspect <name>``
|
|
21
|
+
- everything else (run/build/start/stop/rm/pull/... and all streaming /
|
|
22
|
+
interactive methods) delegates verbatim to an inner ``RealDockerExecutor``
|
|
23
|
+
— low-frequency control operations where the CLI's behavior is battle-
|
|
24
|
+
tested (buildx flags, process groups, etc.).
|
|
25
|
+
|
|
26
|
+
Failure semantics: ANY SDK exception on a mapped path falls back to the CLI
|
|
27
|
+
invocation — worst case = today's behavior, never worse.
|
|
28
|
+
``AISC_DOCKER_EXECUTOR=cli`` restores the pure-CLI executor globally.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
from typing import Any, Dict, List, Optional
|
|
36
|
+
|
|
37
|
+
from aisc.domain.models import DockerPreflightResult, ProcessResult
|
|
38
|
+
|
|
39
|
+
# Templates the ps renderer understands — every token must be one of
|
|
40
|
+
# ID / Names / Image / Status / Label "k". Anything else → CLI fallback.
|
|
41
|
+
_PS_TOKEN = re.compile(r'\{\{\.(ID|Names|Image|Status|Label "([^"]+)")\}\}')
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _render_ps_line(template: str, c: Any) -> Optional[str]:
|
|
45
|
+
"""Render one docker-ps line for a SDK container object.
|
|
46
|
+
|
|
47
|
+
``Status`` synthesizes the CLI's human form ("Up"/"Exited") — the only
|
|
48
|
+
property consumers rely on is ``startswith("Up")`` (runtime.py
|
|
49
|
+
``_docker_status_to_state``)."""
|
|
50
|
+
out: List[str] = []
|
|
51
|
+
pos = 0
|
|
52
|
+
for m in _PS_TOKEN.finditer(template):
|
|
53
|
+
out.append(template[pos : m.start()])
|
|
54
|
+
label_key = m.group(2)
|
|
55
|
+
try:
|
|
56
|
+
if label_key is not None:
|
|
57
|
+
labels = c.attrs.get("Labels")
|
|
58
|
+
if labels is None:
|
|
59
|
+
labels = c.attrs.get("Config", {}).get("Labels", {})
|
|
60
|
+
out.append(str(labels.get(label_key, "")))
|
|
61
|
+
elif m.group(1) == "Status":
|
|
62
|
+
# containers.list() is backed by /containers/json, where State
|
|
63
|
+
# is a string ("running"/"paused"/"exited"), unlike inspect.
|
|
64
|
+
state = c.attrs.get("State", "")
|
|
65
|
+
running = (
|
|
66
|
+
bool(state.get("Running"))
|
|
67
|
+
if isinstance(state, dict)
|
|
68
|
+
else str(state).lower() in {"running", "paused"}
|
|
69
|
+
)
|
|
70
|
+
out.append("Up" if running else "Exited")
|
|
71
|
+
elif m.group(1) == "ID":
|
|
72
|
+
out.append(str(c.id)[:12])
|
|
73
|
+
elif m.group(1) == "Names":
|
|
74
|
+
names = list(c.attrs.get("Names") or [])
|
|
75
|
+
if names:
|
|
76
|
+
rendered = ",".join(name.lstrip("/") for name in names)
|
|
77
|
+
else:
|
|
78
|
+
rendered = str(c.name).lstrip("/")
|
|
79
|
+
out.append(rendered)
|
|
80
|
+
elif m.group(1) == "Image":
|
|
81
|
+
# Avoid `c.image`: it triggers an extra image inspect. Sparse
|
|
82
|
+
# list payloads expose Image directly; inspect payloads expose
|
|
83
|
+
# Config.Image, so support both shapes without extra calls.
|
|
84
|
+
image = c.attrs.get("Config", {}).get("Image")
|
|
85
|
+
if image is None:
|
|
86
|
+
image = c.attrs.get("Image", "")
|
|
87
|
+
out.append(str(image))
|
|
88
|
+
else:
|
|
89
|
+
return None # unreachable; keeps the checker honest
|
|
90
|
+
except Exception: # noqa: BLE001 — unexpected shape: bail to CLI
|
|
91
|
+
return None
|
|
92
|
+
pos = m.end()
|
|
93
|
+
out.append(template[pos:])
|
|
94
|
+
return "".join(out)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class SdkBackedDockerExecutor:
|
|
98
|
+
"""DockerExecutor protocol with the hot-read face on the SDK."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, client: Any = None):
|
|
101
|
+
self._client = client
|
|
102
|
+
from aisc.adapters.docker_ import RealDockerExecutor
|
|
103
|
+
|
|
104
|
+
self._cli = RealDockerExecutor()
|
|
105
|
+
|
|
106
|
+
# -- SDK client ------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def _sdk(self) -> Any:
|
|
109
|
+
if self._client is None:
|
|
110
|
+
from aisc.adapters.docker_gateway import _default_client
|
|
111
|
+
|
|
112
|
+
self._client = _default_client()
|
|
113
|
+
return self._client
|
|
114
|
+
|
|
115
|
+
# -- structured hot ops ----------------------------------------------
|
|
116
|
+
|
|
117
|
+
def preflight(self) -> DockerPreflightResult:
|
|
118
|
+
try:
|
|
119
|
+
self._sdk().ping()
|
|
120
|
+
return DockerPreflightResult(
|
|
121
|
+
docker_path="(sdk)", available=True, reason="ok"
|
|
122
|
+
)
|
|
123
|
+
except Exception: # noqa: BLE001 — SDK unusable: CLI may still be
|
|
124
|
+
return self._cli.preflight()
|
|
125
|
+
|
|
126
|
+
def inspect_container(self, container_name: str) -> ProcessResult:
|
|
127
|
+
try:
|
|
128
|
+
data = self._sdk().api.inspect_container(container_name)
|
|
129
|
+
return ProcessResult(
|
|
130
|
+
stdout=json.dumps([data]), stderr="", exit_code=0
|
|
131
|
+
)
|
|
132
|
+
except Exception as exc: # noqa: BLE001
|
|
133
|
+
import docker.errors
|
|
134
|
+
|
|
135
|
+
if isinstance(exc, docker.errors.NotFound):
|
|
136
|
+
return ProcessResult(
|
|
137
|
+
stdout="",
|
|
138
|
+
stderr=f"Error: No such object: {container_name}",
|
|
139
|
+
exit_code=1,
|
|
140
|
+
)
|
|
141
|
+
# Transient/unknown: CLI fallback preserves today's semantics.
|
|
142
|
+
return self._cli.inspect_container(container_name)
|
|
143
|
+
|
|
144
|
+
# -- free argv: map the three hot shapes, CLI for the rest ------------
|
|
145
|
+
|
|
146
|
+
def run_captured(
|
|
147
|
+
self,
|
|
148
|
+
docker_argv: List[str],
|
|
149
|
+
*,
|
|
150
|
+
timeout: Optional[float] = None,
|
|
151
|
+
input_text: Optional[str] = None,
|
|
152
|
+
) -> ProcessResult:
|
|
153
|
+
try:
|
|
154
|
+
if docker_argv and docker_argv[0] == "ps":
|
|
155
|
+
# ps is read-only and does not consume stdin; SDK execution can
|
|
156
|
+
# preserve this shape even when the caller sets a CLI timeout.
|
|
157
|
+
mapped = self._run_ps(docker_argv)
|
|
158
|
+
if mapped is not None:
|
|
159
|
+
return mapped
|
|
160
|
+
elif (
|
|
161
|
+
docker_argv
|
|
162
|
+
and docker_argv[0] == "exec"
|
|
163
|
+
# exec is the only hot shape where the CLI timeout/input
|
|
164
|
+
# semantics differ materially from the mapped SDK call. Never
|
|
165
|
+
# silently drop either argument.
|
|
166
|
+
and timeout is None
|
|
167
|
+
and input_text is None
|
|
168
|
+
):
|
|
169
|
+
mapped = self._run_exec(docker_argv)
|
|
170
|
+
if mapped is not None:
|
|
171
|
+
return mapped
|
|
172
|
+
elif docker_argv and docker_argv[0] == "inspect" and len(docker_argv) == 2:
|
|
173
|
+
return self.inspect_container(docker_argv[1])
|
|
174
|
+
except Exception: # noqa: BLE001 — any mapping trouble: CLI path
|
|
175
|
+
pass
|
|
176
|
+
return self._cli.run_captured(
|
|
177
|
+
docker_argv, timeout=timeout, input_text=input_text
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def _run_ps(self, argv: List[str]) -> Optional[ProcessResult]:
|
|
181
|
+
filters: Dict[str, List[str]] = {}
|
|
182
|
+
template: Optional[str] = None
|
|
183
|
+
all_flag = False
|
|
184
|
+
i = 1
|
|
185
|
+
while i < len(argv):
|
|
186
|
+
a = argv[i]
|
|
187
|
+
if a in ("-a", "--all"):
|
|
188
|
+
all_flag = True
|
|
189
|
+
elif a == "--filter" and i + 1 < len(argv):
|
|
190
|
+
f = argv[i + 1]
|
|
191
|
+
if not f.startswith("label="):
|
|
192
|
+
return None # non-label filters unmapped → CLI
|
|
193
|
+
filters.setdefault("label", []).append(f[len("label=") :])
|
|
194
|
+
i += 1
|
|
195
|
+
elif a == "--format" and i + 1 < len(argv):
|
|
196
|
+
template = argv[i + 1]
|
|
197
|
+
i += 1
|
|
198
|
+
elif a.startswith("-"):
|
|
199
|
+
return None # unknown flag → CLI
|
|
200
|
+
i += 1
|
|
201
|
+
if template is None:
|
|
202
|
+
return None
|
|
203
|
+
# Docker's format language is larger than the field set mapped here.
|
|
204
|
+
# Reject any unknown field token before fetching so a new caller shape
|
|
205
|
+
# falls back to CLI instead of rendering the template literally.
|
|
206
|
+
if "{{" in _PS_TOKEN.sub("", template):
|
|
207
|
+
return None
|
|
208
|
+
# sparse=True keeps this as one /containers/json request. The default
|
|
209
|
+
# list() call performs an additional container inspect per row.
|
|
210
|
+
containers = self._sdk().containers.list(
|
|
211
|
+
all=all_flag, filters=filters or None, sparse=True
|
|
212
|
+
)
|
|
213
|
+
lines = []
|
|
214
|
+
for c in containers:
|
|
215
|
+
line = _render_ps_line(template, c)
|
|
216
|
+
if line is None:
|
|
217
|
+
return None
|
|
218
|
+
lines.append(line)
|
|
219
|
+
return ProcessResult(stdout="\n".join(lines), stderr="", exit_code=0)
|
|
220
|
+
|
|
221
|
+
def _run_exec(self, argv: List[str]) -> Optional[ProcessResult]:
|
|
222
|
+
# Plain `exec <name> <cmd...>` only — flags (-e/-u/-w/...) between
|
|
223
|
+
# "exec" and the container name are unmapped (the command TAIL may
|
|
224
|
+
# contain anything, including dash-prefixed tool args like --json).
|
|
225
|
+
if len(argv) < 3 or argv[1].startswith("-"):
|
|
226
|
+
return None
|
|
227
|
+
container_name, cmd = argv[1], argv[2:]
|
|
228
|
+
api = self._sdk().api
|
|
229
|
+
exec_id = api.exec_create(container_name, cmd, stdout=True, stderr=True)
|
|
230
|
+
# demux=True is required for CLI parity: without it docker-py merges the
|
|
231
|
+
# multiplexed stdout/stderr stream and JSON consumers can silently see
|
|
232
|
+
# warnings/error frames mixed into stdout.
|
|
233
|
+
result = api.exec_start(exec_id, socket=False, tty=False, demux=True)
|
|
234
|
+
if not (isinstance(result, tuple) and len(result) == 2):
|
|
235
|
+
return None
|
|
236
|
+
raw_stdout, raw_stderr = result
|
|
237
|
+
|
|
238
|
+
def _decode(value: Any) -> str:
|
|
239
|
+
if value is None:
|
|
240
|
+
return ""
|
|
241
|
+
if isinstance(value, bytes):
|
|
242
|
+
return value.decode("utf-8", "replace")
|
|
243
|
+
if isinstance(value, str):
|
|
244
|
+
return value
|
|
245
|
+
return None # type: ignore[return-value]
|
|
246
|
+
|
|
247
|
+
stdout = _decode(raw_stdout)
|
|
248
|
+
stderr = _decode(raw_stderr)
|
|
249
|
+
if stdout is None or stderr is None:
|
|
250
|
+
return None
|
|
251
|
+
info = api.exec_inspect(exec_id)
|
|
252
|
+
return ProcessResult(
|
|
253
|
+
stdout=stdout,
|
|
254
|
+
stderr=stderr,
|
|
255
|
+
exit_code=int(info.get("ExitCode", 0) or 0),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
# -- everything else: verbatim CLI delegation --------------------------
|
|
259
|
+
|
|
260
|
+
def __getattr__(self, name: str) -> Any:
|
|
261
|
+
return getattr(self.__dict__.get("_cli"), name)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def default_executor() -> Any:
|
|
265
|
+
"""Command-layer executor factory (PERF P4).
|
|
266
|
+
|
|
267
|
+
``AISC_DOCKER_EXECUTOR=cli`` restores the pure-CLI executor; tests pass
|
|
268
|
+
their own ``executor`` as before (the factory only feeds the ``or``
|
|
269
|
+
default)."""
|
|
270
|
+
if os.environ.get("AISC_DOCKER_EXECUTOR", "").lower() == "cli":
|
|
271
|
+
from aisc.adapters.docker_ import RealDockerExecutor
|
|
272
|
+
|
|
273
|
+
return RealDockerExecutor()
|
|
274
|
+
return SdkBackedDockerExecutor()
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Docker maintenance lock (A0 / docker-ownership-foundation).
|
|
2
|
+
|
|
3
|
+
Cross-process lock serializing host-level Docker resource mutations:
|
|
4
|
+
installer scan/cleanup/rebuild, ``aisc build`` writes to the default
|
|
5
|
+
workstation tag, runtime create/start resolving the default tag to an
|
|
6
|
+
image ID, and reconcile's removal critical sections
|
|
7
|
+
(docker-resource-lifecycle 02-domain-contract.md §4.1,
|
|
8
|
+
05-cross-plan-coordination.md §4).
|
|
9
|
+
|
|
10
|
+
FROZEN LOCK ORDER (violating it deadlocks — enforced by convention and
|
|
11
|
+
reviewed in tests/docs, not by the runtime):
|
|
12
|
+
|
|
13
|
+
docker-maintenance.lock -> workspace lock -> registry transaction
|
|
14
|
+
|
|
15
|
+
The lock file lives at ``<data-root>/state/locks/docker-maintenance.lock``
|
|
16
|
+
via the frozen DataRootStore layout (domain/data_root.py LOCKS_SUBDIR —
|
|
17
|
+
the plans' ``<data-root>/locks/`` notation resolves to this contract
|
|
18
|
+
path; consumers never concatenate paths themselves).
|
|
19
|
+
|
|
20
|
+
The acquisition timeout must outlast the longest legitimate holder (a
|
|
21
|
+
no-cache workstation rebuild takes minutes); callers may lower it when
|
|
22
|
+
they know the contention window is short.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from contextlib import contextmanager
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Iterator
|
|
30
|
+
|
|
31
|
+
from aisc.adapters.data_root_store import SCOPE_SHARED, DataRootStore
|
|
32
|
+
from aisc.domain.data_root import ERR_LOCK_TIMEOUT
|
|
33
|
+
|
|
34
|
+
#: Lock name; ``DataRootStore.lock_path_for`` turns this into
|
|
35
|
+
#: ``state/locks/docker-maintenance.lock`` under the shared scope.
|
|
36
|
+
MAINTENANCE_LOCK_NAME = "docker-maintenance"
|
|
37
|
+
|
|
38
|
+
#: Stable error code surfaced when acquisition times out.
|
|
39
|
+
ERR_MAINTENANCE_LOCK_TIMEOUT = "AISC_ERR_MAINTENANCE_LOCK_TIMEOUT"
|
|
40
|
+
|
|
41
|
+
#: Default acquisition timeout (seconds) — sized to survive a no-cache
|
|
42
|
+
#: rebuild holding the lock, not just metadata mutations.
|
|
43
|
+
MAINTENANCE_LOCK_TIMEOUT = 300.0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@contextmanager
|
|
47
|
+
def docker_maintenance_lock(
|
|
48
|
+
store: DataRootStore,
|
|
49
|
+
timeout: float = MAINTENANCE_LOCK_TIMEOUT,
|
|
50
|
+
) -> Iterator[None]:
|
|
51
|
+
"""Hold the Docker maintenance lock.
|
|
52
|
+
|
|
53
|
+
Raises ``CliError(ERR_MAINTENANCE_LOCK_TIMEOUT)`` on timeout — the
|
|
54
|
+
caller must NOT proceed to mutate Docker resources without the lock
|
|
55
|
+
(fail-closed). Acquire this BEFORE any workspace/registry lock.
|
|
56
|
+
"""
|
|
57
|
+
with store.lock(
|
|
58
|
+
MAINTENANCE_LOCK_NAME,
|
|
59
|
+
timeout,
|
|
60
|
+
scope=SCOPE_SHARED,
|
|
61
|
+
error_code=ERR_MAINTENANCE_LOCK_TIMEOUT,
|
|
62
|
+
):
|
|
63
|
+
yield
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def maintenance_lock_path(store: DataRootStore):
|
|
67
|
+
"""Canonical lock file path (contract: ``state/locks/<name>.lock``)."""
|
|
68
|
+
return store.lock_path_for(MAINTENANCE_LOCK_NAME, scope=SCOPE_SHARED)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@contextmanager
|
|
72
|
+
def docker_maintenance_lock_at_root(
|
|
73
|
+
data_root,
|
|
74
|
+
timeout: float = MAINTENANCE_LOCK_TIMEOUT,
|
|
75
|
+
) -> Iterator[None]:
|
|
76
|
+
"""Same lock, addressed by the data ROOT directly (installer-side
|
|
77
|
+
callers have no workspace to resolve a store with). Identical file —
|
|
78
|
+
both entries serialize against each other."""
|
|
79
|
+
from aisc.adapters.data_root_store import file_lock
|
|
80
|
+
from aisc.domain.data_root import LOCKS_SUBDIR
|
|
81
|
+
|
|
82
|
+
lock_path = Path(str(data_root)).joinpath(*LOCKS_SUBDIR.split("/")) / (
|
|
83
|
+
MAINTENANCE_LOCK_NAME + ".lock"
|
|
84
|
+
)
|
|
85
|
+
with file_lock(lock_path, timeout, error_code=ERR_MAINTENANCE_LOCK_TIMEOUT):
|
|
86
|
+
yield
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# Re-exported for callers that construct their own timeout errors.
|
|
90
|
+
__all__ = [
|
|
91
|
+
"MAINTENANCE_LOCK_NAME",
|
|
92
|
+
"MAINTENANCE_LOCK_TIMEOUT",
|
|
93
|
+
"ERR_MAINTENANCE_LOCK_TIMEOUT",
|
|
94
|
+
"ERR_LOCK_TIMEOUT",
|
|
95
|
+
"docker_maintenance_lock",
|
|
96
|
+
"maintenance_lock_path",
|
|
97
|
+
]
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""State file adapter — read/write ``.aisc/state.env`` for container discovery.
|
|
2
|
+
|
|
3
|
+
Provides atomic writes via temp-file + rename. Only allows known safe keys.
|
|
4
|
+
Preserves existing keys, comments, and blank lines where practical.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Dict, List, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
# Known safe keys — only these may be written to state.env.
|
|
16
|
+
# Container-name keys (CONTAINER_NAME, IMAGE) live in containers.json now;
|
|
17
|
+
# state.env retains only boolean flag keys.
|
|
18
|
+
_KNOWN_KEYS = frozenset({
|
|
19
|
+
"DO_RUN",
|
|
20
|
+
"PROXY_ENABLED",
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
# Regex for valid key names: uppercase alphanumeric + underscore, starts with letter
|
|
24
|
+
_VALID_KEY_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
|
25
|
+
|
|
26
|
+
# Shell metacharacters / injection characters rejected from all values
|
|
27
|
+
_SHELL_META_RE = re.compile(r'[\r\n\0`$\'";&|<>]')
|
|
28
|
+
|
|
29
|
+
# Boolean flags
|
|
30
|
+
_BOOL_VALUES = frozenset({"0", "1"})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _validate_value(key: str, value: str) -> None:
|
|
34
|
+
"""Validate *value* against field-specific rules plus shell-metacharacter ban.
|
|
35
|
+
|
|
36
|
+
Raises ValueError with a descriptive message on any violation.
|
|
37
|
+
"""
|
|
38
|
+
if not value:
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
# --- shell metacharacter / whitespace / control check (all keys) ---
|
|
42
|
+
if _SHELL_META_RE.search(value) or " " in value or "\t" in value:
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"State value for '{key}' contains prohibited characters "
|
|
45
|
+
f"(shell metacharacters, whitespace, or control chars)"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# --- field-specific checks ---
|
|
49
|
+
if key in ("DO_RUN", "PROXY_ENABLED"):
|
|
50
|
+
if value not in _BOOL_VALUES:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"{key} must be 0 or 1, got: {value!r}"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _parse_state_file(path: Path) -> List[Tuple[str, Optional[str], Optional[str]]]:
|
|
57
|
+
"""Parse state.env into (key, value, raw_comment) triples.
|
|
58
|
+
|
|
59
|
+
Returns a list of (key, value, comment) where:
|
|
60
|
+
- key: the KEY name (empty string for comment/blank lines)
|
|
61
|
+
- value: the value after '=' (None for comments)
|
|
62
|
+
- comment: full raw line text for comments/blanks (None for key=value lines)
|
|
63
|
+
|
|
64
|
+
Duplicate keys use deterministic **last-wins** semantics (consistent with
|
|
65
|
+
shell ``source`` / ``set -a`` behaviour).
|
|
66
|
+
"""
|
|
67
|
+
entries: List[Tuple[str, Optional[str], Optional[str]]] = []
|
|
68
|
+
if not path.is_file():
|
|
69
|
+
return entries
|
|
70
|
+
|
|
71
|
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
72
|
+
for line in f:
|
|
73
|
+
stripped = line.rstrip("\n\r")
|
|
74
|
+
# Comments and blank lines preserved as-is
|
|
75
|
+
if not stripped or stripped.startswith("#"):
|
|
76
|
+
entries.append(("", None, stripped))
|
|
77
|
+
continue
|
|
78
|
+
# Parse KEY=VALUE
|
|
79
|
+
if "=" in stripped:
|
|
80
|
+
key, _, value = stripped.partition("=")
|
|
81
|
+
key = key.strip()
|
|
82
|
+
if _VALID_KEY_RE.match(key):
|
|
83
|
+
# Last-wins: replace previous entry for same key
|
|
84
|
+
for i in range(len(entries) - 1, -1, -1):
|
|
85
|
+
if entries[i][0] == key:
|
|
86
|
+
entries[i] = (key, value, None)
|
|
87
|
+
break
|
|
88
|
+
else:
|
|
89
|
+
entries.append((key, value, None))
|
|
90
|
+
else:
|
|
91
|
+
# Malformed key — preserve as comment to avoid corruption
|
|
92
|
+
entries.append(("", None, stripped))
|
|
93
|
+
else:
|
|
94
|
+
# Line without '=' — treat as comment
|
|
95
|
+
entries.append(("", None, stripped))
|
|
96
|
+
return entries
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def read_state_key(root: Path, key: str) -> Optional[str]:
|
|
100
|
+
"""Read a single key from ``<root>/state.env`` (Stage 7: *root* is the
|
|
101
|
+
state directory itself — data-root ``workspaces/<hash>/runtime`` or the
|
|
102
|
+
legacy ``<workspace>/.aisc``; resolved by callers, never appended here).
|
|
103
|
+
|
|
104
|
+
Returns the value string, or ``None`` if the key or file is absent.
|
|
105
|
+
Duplicate keys resolve to last occurrence (shell-consistent).
|
|
106
|
+
"""
|
|
107
|
+
state_path = root / "state.env"
|
|
108
|
+
entries = _parse_state_file(state_path)
|
|
109
|
+
# Walk backwards to find last occurrence (last-wins)
|
|
110
|
+
for entry_key, value, _comment in reversed(entries):
|
|
111
|
+
if entry_key == key:
|
|
112
|
+
return value
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def write_state_keys(root: Path, updates: Dict[str, str]) -> None:
|
|
117
|
+
"""Atomically write key=value pairs into ``<root>/.aisc/state.env``.
|
|
118
|
+
|
|
119
|
+
Preserves existing keys and comments. Only writes keys that are in
|
|
120
|
+
``_KNOWN_KEYS``. Raises ``ValueError`` for unknown keys or values
|
|
121
|
+
containing ``\\r``, ``\\n``, or NUL. Silently skips keys with empty values.
|
|
122
|
+
|
|
123
|
+
Uses temp-file + rename for atomicity.
|
|
124
|
+
Errors during write do not corrupt the existing file.
|
|
125
|
+
"""
|
|
126
|
+
for k in updates:
|
|
127
|
+
if k not in _KNOWN_KEYS:
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"Unknown state key '{k}'. Allowed: {sorted(_KNOWN_KEYS)}"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Validate values before touching filesystem
|
|
133
|
+
for k, v in updates.items():
|
|
134
|
+
_validate_value(k, v)
|
|
135
|
+
|
|
136
|
+
state_dir = root # Stage 7: root IS the state directory
|
|
137
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
|
|
139
|
+
state_path = state_dir / "state.env"
|
|
140
|
+
|
|
141
|
+
# Read existing entries
|
|
142
|
+
existing = _parse_state_file(state_path) if state_path.is_file() else []
|
|
143
|
+
|
|
144
|
+
# Build merged: last-wins for duplicate keys, update existing, keep others
|
|
145
|
+
new_entries: List[Tuple[str, Optional[str], Optional[str]]] = []
|
|
146
|
+
seen_keys: set = set()
|
|
147
|
+
|
|
148
|
+
for entry_key, value, comment in existing:
|
|
149
|
+
if entry_key and entry_key in updates:
|
|
150
|
+
new_val = updates[entry_key]
|
|
151
|
+
if new_val:
|
|
152
|
+
new_entries.append((entry_key, new_val, None))
|
|
153
|
+
else:
|
|
154
|
+
new_entries.append((entry_key, value, None))
|
|
155
|
+
seen_keys.add(entry_key)
|
|
156
|
+
else:
|
|
157
|
+
new_entries.append((entry_key, value, comment))
|
|
158
|
+
|
|
159
|
+
# Append any new keys not already present
|
|
160
|
+
for k, v in updates.items():
|
|
161
|
+
if k not in seen_keys and v:
|
|
162
|
+
new_entries.append((k, v, None))
|
|
163
|
+
|
|
164
|
+
# Write atomically via temp file
|
|
165
|
+
fd, tmp_path = tempfile.mkstemp(
|
|
166
|
+
prefix=".state_", dir=str(state_dir), text=True
|
|
167
|
+
)
|
|
168
|
+
try:
|
|
169
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
170
|
+
for entry_key, value, comment in new_entries:
|
|
171
|
+
if comment is not None:
|
|
172
|
+
f.write(comment + "\n")
|
|
173
|
+
elif entry_key:
|
|
174
|
+
f.write(f"{entry_key}={value}\n")
|
|
175
|
+
f.flush()
|
|
176
|
+
os.fsync(f.fileno())
|
|
177
|
+
os.replace(tmp_path, str(state_path))
|
|
178
|
+
except Exception:
|
|
179
|
+
# Clean up temp file on failure
|
|
180
|
+
try:
|
|
181
|
+
os.unlink(tmp_path)
|
|
182
|
+
except OSError:
|
|
183
|
+
pass
|
|
184
|
+
raise
|
aisc/adapters/system.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""System / process adapter — narrow interface for command execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from typing import Protocol, List, Optional, runtime_checkable
|
|
7
|
+
|
|
8
|
+
from aisc.domain.models import ProcessResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@runtime_checkable
|
|
12
|
+
class ProcessRunner(Protocol):
|
|
13
|
+
"""Protocol for running subprocess commands (testable via injection)."""
|
|
14
|
+
|
|
15
|
+
def run(
|
|
16
|
+
self,
|
|
17
|
+
argv: List[str],
|
|
18
|
+
timeout: Optional[float] = None,
|
|
19
|
+
) -> ProcessResult:
|
|
20
|
+
"""Execute *argv* and return a ``ProcessResult``."""
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RealProcessRunner:
|
|
25
|
+
"""Real subprocess runner — shell=False, text capture, timeout support.
|
|
26
|
+
|
|
27
|
+
Uses ``encoding='utf-8'``, ``errors='replace'``.
|
|
28
|
+
Catches ``FileNotFoundError`` → ``command_not_found``.
|
|
29
|
+
Catches generic ``OSError`` → ``command_not_found`` (e.g. permission,
|
|
30
|
+
broken symlink).
|
|
31
|
+
``subprocess.TimeoutExpired`` → ``timed_out``.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def run(
|
|
35
|
+
self,
|
|
36
|
+
argv: List[str],
|
|
37
|
+
timeout: Optional[float] = None,
|
|
38
|
+
) -> ProcessResult:
|
|
39
|
+
"""Execute *argv* in a subprocess."""
|
|
40
|
+
try:
|
|
41
|
+
proc = subprocess.run(
|
|
42
|
+
argv,
|
|
43
|
+
capture_output=True,
|
|
44
|
+
encoding="utf-8",
|
|
45
|
+
errors="replace",
|
|
46
|
+
timeout=timeout,
|
|
47
|
+
)
|
|
48
|
+
return ProcessResult(
|
|
49
|
+
stdout=proc.stdout or "",
|
|
50
|
+
stderr=proc.stderr or "",
|
|
51
|
+
exit_code=proc.returncode,
|
|
52
|
+
)
|
|
53
|
+
except FileNotFoundError:
|
|
54
|
+
return ProcessResult(
|
|
55
|
+
stdout="",
|
|
56
|
+
stderr=f"command not found: {argv[0] if argv else ''}",
|
|
57
|
+
exit_code=-1,
|
|
58
|
+
command_not_found=True,
|
|
59
|
+
)
|
|
60
|
+
except subprocess.TimeoutExpired:
|
|
61
|
+
return ProcessResult(
|
|
62
|
+
stdout="",
|
|
63
|
+
stderr="command timed out",
|
|
64
|
+
exit_code=-1,
|
|
65
|
+
timed_out=True,
|
|
66
|
+
)
|
|
67
|
+
except OSError as exc:
|
|
68
|
+
return ProcessResult(
|
|
69
|
+
stdout="",
|
|
70
|
+
stderr=f"command error: {exc}",
|
|
71
|
+
exit_code=-1,
|
|
72
|
+
command_not_found=True,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def run_streaming(
|
|
76
|
+
self,
|
|
77
|
+
argv: List[str],
|
|
78
|
+
timeout: Optional[float] = None,
|
|
79
|
+
) -> ProcessResult:
|
|
80
|
+
"""Execute an interactive command with inherited terminal streams."""
|
|
81
|
+
try:
|
|
82
|
+
proc = subprocess.run(argv, timeout=timeout)
|
|
83
|
+
return ProcessResult(exit_code=proc.returncode)
|
|
84
|
+
except FileNotFoundError:
|
|
85
|
+
return ProcessResult(
|
|
86
|
+
stderr=f"command not found: {argv[0] if argv else ''}",
|
|
87
|
+
exit_code=-1,
|
|
88
|
+
command_not_found=True,
|
|
89
|
+
)
|
|
90
|
+
except subprocess.TimeoutExpired:
|
|
91
|
+
return ProcessResult(
|
|
92
|
+
stderr="command timed out",
|
|
93
|
+
exit_code=-1,
|
|
94
|
+
timed_out=True,
|
|
95
|
+
)
|
|
96
|
+
except OSError as exc:
|
|
97
|
+
return ProcessResult(
|
|
98
|
+
stderr=f"command error: {exc}",
|
|
99
|
+
exit_code=-1,
|
|
100
|
+
command_not_found=True,
|
|
101
|
+
)
|