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.
Files changed (92) hide show
  1. aisc/VERSION +1 -0
  2. aisc/__init__.py +49 -0
  3. aisc/__main__.py +6 -0
  4. aisc/adapters/__init__.py +1 -0
  5. aisc/adapters/config_reader.py +215 -0
  6. aisc/adapters/container_registry.py +718 -0
  7. aisc/adapters/data_root_store.py +296 -0
  8. aisc/adapters/docker_.py +1183 -0
  9. aisc/adapters/docker_gateway.py +1244 -0
  10. aisc/adapters/docker_sdk_backed.py +274 -0
  11. aisc/adapters/maintenance_lock.py +97 -0
  12. aisc/adapters/state_file.py +184 -0
  13. aisc/adapters/system.py +101 -0
  14. aisc/adapters/windows_config_reader.py +249 -0
  15. aisc/adapters/workspace_lease_store.py +189 -0
  16. aisc/application/__init__.py +1 -0
  17. aisc/application/artifact.py +265 -0
  18. aisc/application/bundle_fetch.py +836 -0
  19. aisc/application/cc_switch_provider.py +245 -0
  20. aisc/application/cc_switch_resolver.py +363 -0
  21. aisc/application/config_service.py +452 -0
  22. aisc/application/conversation.py +516 -0
  23. aisc/application/data_migration.py +440 -0
  24. aisc/application/data_root.py +275 -0
  25. aisc/application/docker_lifecycle.py +659 -0
  26. aisc/application/doctor.py +821 -0
  27. aisc/application/legacy_scan.py +252 -0
  28. aisc/application/network_subscription.py +642 -0
  29. aisc/application/profile_service.py +105 -0
  30. aisc/application/provider.py +92 -0
  31. aisc/application/repair.py +387 -0
  32. aisc/application/resources.py +270 -0
  33. aisc/application/runtime.py +1826 -0
  34. aisc/application/session.py +320 -0
  35. aisc/application/tool_versions.py +210 -0
  36. aisc/application/toolchain.py +91 -0
  37. aisc/application/update.py +286 -0
  38. aisc/application/usage.py +305 -0
  39. aisc/application/version.py +133 -0
  40. aisc/application/web_gateway.py +459 -0
  41. aisc/application/workspace_reconcile.py +318 -0
  42. aisc/applog.py +103 -0
  43. aisc/cli/__init__.py +1 -0
  44. aisc/cli/build_progress_parser.py +217 -0
  45. aisc/cli/commands/__init__.py +1 -0
  46. aisc/cli/commands/agents.py +117 -0
  47. aisc/cli/commands/artifact.py +210 -0
  48. aisc/cli/commands/build.py +566 -0
  49. aisc/cli/commands/bundle.py +107 -0
  50. aisc/cli/commands/cc_switch.py +158 -0
  51. aisc/cli/commands/config.py +104 -0
  52. aisc/cli/commands/container.py +787 -0
  53. aisc/cli/commands/conversation.py +72 -0
  54. aisc/cli/commands/data_root.py +84 -0
  55. aisc/cli/commands/logs.py +89 -0
  56. aisc/cli/commands/network.py +132 -0
  57. aisc/cli/commands/profile.py +52 -0
  58. aisc/cli/commands/provider.py +49 -0
  59. aisc/cli/commands/run.py +494 -0
  60. aisc/cli/commands/runs.py +125 -0
  61. aisc/cli/commands/runtime.py +450 -0
  62. aisc/cli/commands/serve.py +550 -0
  63. aisc/cli/commands/serve_fs.py +369 -0
  64. aisc/cli/commands/session.py +167 -0
  65. aisc/cli/commands/update.py +145 -0
  66. aisc/cli/commands/usage.py +74 -0
  67. aisc/cli/commands/wizard.py +130 -0
  68. aisc/cli/commands/workspaces.py +162 -0
  69. aisc/cli/help_i18n.py +385 -0
  70. aisc/cli/main.py +2926 -0
  71. aisc/cli/output.py +239 -0
  72. aisc/cli/tutorial.py +109 -0
  73. aisc/domain/__init__.py +1 -0
  74. aisc/domain/artifacts.py +279 -0
  75. aisc/domain/cc_switch_release.py +303 -0
  76. aisc/domain/config.py +64 -0
  77. aisc/domain/data_migration.py +259 -0
  78. aisc/domain/data_root.py +124 -0
  79. aisc/domain/docker_ownership.py +148 -0
  80. aisc/domain/gateway.py +168 -0
  81. aisc/domain/models.py +674 -0
  82. aisc/domain/toolchain.py +72 -0
  83. aisc/domain/web_services.py +355 -0
  84. aisc/domain/workspace_lease.py +139 -0
  85. aisc/schemas/__init__.py +1 -0
  86. aisc/schemas/config_schema.py +145 -0
  87. aisc_cli-0.1.0.dist-info/METADATA +420 -0
  88. aisc_cli-0.1.0.dist-info/RECORD +92 -0
  89. aisc_cli-0.1.0.dist-info/WHEEL +5 -0
  90. aisc_cli-0.1.0.dist-info/entry_points.txt +2 -0
  91. aisc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  92. aisc_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,296 @@
1
+ """Unified data-root storage adapter (Stage 7, 7b).
2
+
3
+ One directory/lock/atomic API for every writer that moves under the data
4
+ root (state, config, artifact metadata, diagnostics; wiring happens in 7e).
5
+ Semantics come from the proven predecessors and are unified here:
6
+
7
+ - directories: idempotent ``prepare`` creates the contract skeleton exactly
8
+ (no invented siblings), every write re-checks the target chain for
9
+ reparse points (TOCTOU defense on top of the resolver's resolve-time
10
+ validation);
11
+ - locks: cross-process fail-closed byte lock (``msvcrt``/``fcntl``,
12
+ ``container_registry._registry_lock`` semantics) parked under
13
+ ``state/locks/`` per the domain contract;
14
+ - atomic writes: UTF-8, temp file in the target directory, flush + fsync,
15
+ ``os.replace`` (``_write_registry_unlocked`` semantics) — readers never
16
+ see half-written files;
17
+ - corruption: a failing JSON read is isolated to ``*.corrupt`` (Rust
18
+ ``storage.rs``/``settings.rs`` precedent), never truncated in place.
19
+
20
+ The store never decides WHAT to store — schema/version handling stays with
21
+ each feature module.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+ import stat
29
+ import sys
30
+ import tempfile
31
+ from contextlib import contextmanager
32
+ from pathlib import Path
33
+ from typing import Any, Iterator, Optional
34
+
35
+ from aisc.domain.artifacts import validate_relative_path
36
+ from aisc.domain.data_root import (
37
+ ERR_LOCK_TIMEOUT,
38
+ ERR_REPARSE_POINT,
39
+ LOCKS_SUBDIR,
40
+ SHARED_SUBDIRS,
41
+ WORKSPACE_SUBDIRS,
42
+ ResolvedDataRoot,
43
+ )
44
+ from aisc.domain.models import CliError
45
+
46
+ SCOPE_SHARED = "shared"
47
+ SCOPE_WORKSPACE = "workspace"
48
+
49
+ _FILE_ATTRIBUTE_REPARSE_POINT = 0x0400 # windows.h
50
+
51
+
52
+ def _is_reparse_point(path: Path) -> bool:
53
+ try:
54
+ st = os.lstat(path)
55
+ except OSError:
56
+ return False
57
+ if stat.S_ISLNK(st.st_mode):
58
+ return True
59
+ return bool(getattr(st, "st_file_attributes", 0) & _FILE_ATTRIBUTE_REPARSE_POINT)
60
+
61
+
62
+ def _check_reparse_chain(root: Path, target: Path) -> None:
63
+ """Reject reparse points on any EXISTING segment between *root* and
64
+ *target* (defense-in-depth: the resolver validated at resolve time)."""
65
+ cur = target
66
+ stop = root
67
+ while True:
68
+ if _is_reparse_point(cur):
69
+ raise CliError(
70
+ f"data root path component is a reparse point/symlink: {cur}",
71
+ exit_code=1,
72
+ error_code=ERR_REPARSE_POINT,
73
+ )
74
+ if cur == stop:
75
+ return
76
+ parent = cur.parent
77
+ if parent == cur:
78
+ return # target outside root: reject via containment first
79
+ cur = parent
80
+
81
+
82
+ @contextmanager
83
+ def file_lock(
84
+ lock_path: "Path | str",
85
+ timeout: float = 10.0,
86
+ *,
87
+ error_code: str = ERR_LOCK_TIMEOUT,
88
+ ) -> Iterator[None]:
89
+ """Hold a cross-process lock across a complete read-modify-write cycle.
90
+
91
+ Fail-closed (bounded retry on Windows ``msvcrt.locking``; ``fcntl.flock``
92
+ with SIGALRM timeout on POSIX). Raises CliError(*error_code*) on timeout
93
+ — callers with legacy code contracts (e.g. STATE_LOCK_TIMEOUT at 7e
94
+ wiring time) pass their own ``error_code``.
95
+ """
96
+ import time as time_module
97
+
98
+ lock_path = Path(lock_path)
99
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
100
+ lock_fd = None
101
+ locked = False
102
+
103
+ try:
104
+ lock_fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR, 0o644)
105
+
106
+ if sys.platform == "win32":
107
+ import msvcrt
108
+
109
+ start_time = time_module.time()
110
+ while True:
111
+ try:
112
+ msvcrt.locking(lock_fd, msvcrt.LK_NBLCK, 1)
113
+ locked = True
114
+ break
115
+ except OSError as exc:
116
+ if time_module.time() - start_time >= timeout:
117
+ raise CliError(
118
+ f"lock timeout after {timeout}s: {lock_path}",
119
+ exit_code=1,
120
+ error_code=error_code,
121
+ ) from exc
122
+ time_module.sleep(0.1)
123
+ else:
124
+ import fcntl
125
+ import signal
126
+
127
+ def _timeout(signum: Any, frame: Any) -> None:
128
+ raise CliError(
129
+ f"lock timeout after {timeout}s: {lock_path}",
130
+ exit_code=1,
131
+ error_code=error_code,
132
+ )
133
+
134
+ alarm_set = False
135
+ old_handler = None
136
+ try:
137
+ old_handler = signal.signal(signal.SIGALRM, _timeout)
138
+ signal.alarm(int(timeout) + 1)
139
+ alarm_set = True
140
+ except ValueError:
141
+ pass # not in main thread: fall back to blocking lock
142
+
143
+ try:
144
+ fcntl.flock(lock_fd, fcntl.LOCK_EX)
145
+ locked = True
146
+ finally:
147
+ if alarm_set:
148
+ signal.alarm(0)
149
+ signal.signal(signal.SIGALRM, old_handler)
150
+
151
+ yield
152
+ finally:
153
+ if locked and lock_fd is not None:
154
+ try:
155
+ if sys.platform == "win32":
156
+ import msvcrt
157
+
158
+ msvcrt.locking(lock_fd, msvcrt.LK_UNLCK, 1)
159
+ else:
160
+ import fcntl
161
+
162
+ fcntl.flock(lock_fd, fcntl.LOCK_UN)
163
+ except OSError:
164
+ pass
165
+ if lock_fd is not None:
166
+ try:
167
+ os.close(lock_fd)
168
+ except OSError:
169
+ pass
170
+
171
+
172
+ class DataRootStore:
173
+ """Directory + lock + atomic-write API over one resolved data root."""
174
+
175
+ def __init__(self, resolved: ResolvedDataRoot) -> None:
176
+ self._resolved = resolved
177
+
178
+ # -- paths ------------------------------------------------------------
179
+
180
+ def _scope_base(self, scope: str) -> Path:
181
+ if scope == SCOPE_SHARED:
182
+ return self._resolved.root
183
+ if scope == SCOPE_WORKSPACE:
184
+ return self._resolved.workspace_dir
185
+ raise ValueError(f"unknown scope: {scope!r}")
186
+
187
+ def path_for(self, scope: str, rel: str) -> Path:
188
+ """Validated absolute path: *rel* is a forward-slash relative path
189
+ (no ``..``/absolute/backslash — ``domain.artifacts`` validator)."""
190
+ normalized = validate_relative_path(rel)
191
+ base = self._scope_base(scope)
192
+ target = base / Path(*normalized.split("/"))
193
+ if not (target == base or base in target.parents):
194
+ raise ValueError(f"path escapes the {scope} tree: {rel!r}")
195
+ return target
196
+
197
+ def locks_dir(self) -> Path:
198
+ return self._resolved.root.joinpath(*LOCKS_SUBDIR.split("/"))
199
+
200
+ def lock_path_for(self, name: str, *, scope: str = SCOPE_SHARED) -> Path:
201
+ """Lock file under ``state/locks/``; workspace-scoped locks get the
202
+ workspace-hash prefix so both live in the contract's locks dir."""
203
+ normalized = validate_relative_path(name)
204
+ if scope == SCOPE_WORKSPACE:
205
+ hash_dir = self._resolved.workspace_hash.replace(":", "-", 1)
206
+ normalized = f"{hash_dir}-{normalized}"
207
+ elif scope != SCOPE_SHARED:
208
+ raise ValueError(f"unknown scope: {scope!r}")
209
+ return self.locks_dir() / (normalized + ".lock")
210
+
211
+ # -- lifecycle ----------------------------------------------------------
212
+
213
+ def prepare(self) -> None:
214
+ """Idempotently create the contract skeleton (shared + workspace
215
+ subtrees + ``state/locks``); the resolver stays read-only, this is
216
+ the lifecycle 'prepare' step."""
217
+ targets = list(self._resolved.shared_dirs.values())
218
+ targets.append(self._resolved.workspace_dir)
219
+ targets.extend(self._resolved.workspace_dirs.values())
220
+ targets.append(self.locks_dir())
221
+ for target in targets:
222
+ _check_reparse_chain(self._resolved.root, target)
223
+ target.mkdir(parents=True, exist_ok=True)
224
+
225
+ # -- locks ---------------------------------------------------------------
226
+
227
+ @contextmanager
228
+ def lock(
229
+ self,
230
+ name: str,
231
+ timeout: float = 10.0,
232
+ *,
233
+ scope: str = SCOPE_SHARED,
234
+ error_code: str = ERR_LOCK_TIMEOUT,
235
+ ) -> Iterator[None]:
236
+ with file_lock(self.lock_path_for(name, scope=scope), timeout, error_code=error_code):
237
+ yield
238
+
239
+ # -- atomic writes ---------------------------------------------------------
240
+
241
+ def write_text(self, scope: str, rel: str, text: str, *, lock: Optional[str] = None) -> None:
242
+ def _do_write() -> None:
243
+ path = self.path_for(scope, rel)
244
+ _check_reparse_chain(self._resolved.root, path.parent)
245
+ path.parent.mkdir(parents=True, exist_ok=True)
246
+ fd, tmp = tempfile.mkstemp(prefix=f".{path.name}_", dir=str(path.parent), text=True)
247
+ try:
248
+ with os.fdopen(fd, "w", encoding="utf-8", newline="") as f:
249
+ f.write(text)
250
+ f.flush()
251
+ os.fsync(f.fileno())
252
+ os.replace(tmp, str(path))
253
+ except Exception:
254
+ try:
255
+ os.unlink(tmp)
256
+ except OSError:
257
+ pass
258
+ raise
259
+
260
+ if lock is not None:
261
+ with self.lock(lock):
262
+ _do_write()
263
+ else:
264
+ _do_write()
265
+
266
+ def write_json(
267
+ self, scope: str, rel: str, data: Any, *, lock: Optional[str] = None
268
+ ) -> None:
269
+ payload = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
270
+ self.write_text(scope, rel, payload, lock=lock)
271
+
272
+ # -- reads (missing → None; corrupt → isolate, never truncate) -------------
273
+
274
+ def read_text(self, scope: str, rel: str) -> Optional[str]:
275
+ path = self.path_for(scope, rel)
276
+ try:
277
+ return path.read_text(encoding="utf-8")
278
+ except FileNotFoundError:
279
+ return None
280
+
281
+ def read_json(self, scope: str, rel: str) -> Optional[Any]:
282
+ path = self.path_for(scope, rel)
283
+ text = self.read_text(scope, rel)
284
+ if text is None:
285
+ return None
286
+ try:
287
+ return json.loads(text)
288
+ except json.JSONDecodeError:
289
+ # Isolate (rename preserves bytes, nothing truncated) and fail
290
+ # closed: the caller sees "absent", never a guessed value.
291
+ corrupt = path.with_name(path.name + ".corrupt")
292
+ try:
293
+ os.replace(path, corrupt)
294
+ except OSError:
295
+ pass
296
+ return None