seqevi 0.2.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.
- seqevi/__init__.py +7 -0
- seqevi/__main__.py +6 -0
- seqevi/adapters/__init__.py +41 -0
- seqevi/adapters/base.py +131 -0
- seqevi/adapters/dbcan_cazyme.py +588 -0
- seqevi/adapters/eggnog.py +674 -0
- seqevi/adapters/interpro_pfam.py +757 -0
- seqevi/adapters/registry.py +68 -0
- seqevi/annotate.py +413 -0
- seqevi/api.py +390 -0
- seqevi/cli.py +610 -0
- seqevi/distribution/__init__.py +13 -0
- seqevi/distribution/manifest.py +199 -0
- seqevi/distribution/oci.py +490 -0
- seqevi/distribution/setup.py +752 -0
- seqevi/errors.py +73 -0
- seqevi/evidence.py +295 -0
- seqevi/execution_profile.py +526 -0
- seqevi/hashing.py +13 -0
- seqevi/kits/__init__.py +1 -0
- seqevi/kits/dbcan-cazyme.toml +35 -0
- seqevi/resource_lock.py +438 -0
- seqevi/result.py +682 -0
- seqevi/runner.py +163 -0
- seqevi/runtime_identity.py +104 -0
- seqevi/sequence.py +383 -0
- seqevi/service/__init__.py +11 -0
- seqevi/service/app.py +213 -0
- seqevi/service/config.py +38 -0
- seqevi/service/persistence.py +360 -0
- seqevi/store/__init__.py +14 -0
- seqevi/store/artifact.py +225 -0
- seqevi/store/client.py +311 -0
- seqevi/store/contract.py +33 -0
- seqevi/store/factory.py +38 -0
- seqevi/store/local.py +479 -0
- seqevi/store/migration.py +62 -0
- seqevi/store/migrations/__init__.py +1 -0
- seqevi/store/migrations/env.py +30 -0
- seqevi/store/migrations/versions/0001_initial_store.py +103 -0
- seqevi/store/migrations/versions/0002_artifact_byte_size_bigint.py +40 -0
- seqevi/store/migrations/versions/__init__.py +1 -0
- seqevi/store/schema.py +86 -0
- seqevi/store/transport.py +224 -0
- seqevi-0.2.0.dist-info/METADATA +333 -0
- seqevi-0.2.0.dist-info/RECORD +49 -0
- seqevi-0.2.0.dist-info/WHEEL +4 -0
- seqevi-0.2.0.dist-info/entry_points.txt +5 -0
- seqevi-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
"""Read-only managed setup planning for the first-party dbCAN kit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import uuid
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass, replace
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from seqevi.errors import ProfileConfigurationError, ResourceLockError, SetupError
|
|
18
|
+
from seqevi.execution_profile import (
|
|
19
|
+
ExecutionProfile,
|
|
20
|
+
load_execution_profile,
|
|
21
|
+
named_profile_path,
|
|
22
|
+
)
|
|
23
|
+
from seqevi.resource_lock import (
|
|
24
|
+
ResourceComponent,
|
|
25
|
+
ResourceLock,
|
|
26
|
+
read_resource_lock,
|
|
27
|
+
resolve_resource_lock,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
from .manifest import KitManifest, load_kit_manifest
|
|
31
|
+
|
|
32
|
+
_SMOKE_RESOURCE_ROOT = "/mnt/seqevi-dbcan-resource"
|
|
33
|
+
_SMOKE_TIMEOUT_SECONDS = 120
|
|
34
|
+
_PULL_TIMEOUT_SECONDS = 900
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class SetupComponentPlan:
|
|
39
|
+
"""Read-only inspection of one caller-owned resource component."""
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
path: str
|
|
43
|
+
expected_size: int
|
|
44
|
+
actual_size: int | None
|
|
45
|
+
expected_sha256: str
|
|
46
|
+
actual_sha256: str | None
|
|
47
|
+
status: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class SetupResourcePlan:
|
|
52
|
+
"""Resource inspection included in a setup plan."""
|
|
53
|
+
|
|
54
|
+
path: Path | None
|
|
55
|
+
status: str
|
|
56
|
+
lock_path: Path | None
|
|
57
|
+
components: tuple[SetupComponentPlan, ...]
|
|
58
|
+
error: str | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class SetupRuntimePlan:
|
|
63
|
+
"""Runtime image inspection included in a setup plan."""
|
|
64
|
+
|
|
65
|
+
platform: str
|
|
66
|
+
engine: str
|
|
67
|
+
image: str
|
|
68
|
+
kit_id: str
|
|
69
|
+
dbcan_version: str
|
|
70
|
+
diamond_version: str
|
|
71
|
+
image_status: str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True)
|
|
75
|
+
class SetupProfilePlan:
|
|
76
|
+
"""Named profile destination and conflict state."""
|
|
77
|
+
|
|
78
|
+
name: str
|
|
79
|
+
path: Path
|
|
80
|
+
status: str
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class SetupPlan:
|
|
85
|
+
"""Complete typed, read-only plan shared by human and JSON presenters."""
|
|
86
|
+
|
|
87
|
+
schema_version: int
|
|
88
|
+
adapter: str
|
|
89
|
+
kit_id: str
|
|
90
|
+
status: str
|
|
91
|
+
runtime: SetupRuntimePlan
|
|
92
|
+
resource: SetupResourcePlan
|
|
93
|
+
profile: SetupProfilePlan
|
|
94
|
+
actions: tuple[str, ...]
|
|
95
|
+
smoke_status: str
|
|
96
|
+
smoke_reason: str
|
|
97
|
+
next_command: str | None
|
|
98
|
+
issues: tuple[str, ...] = ()
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def ready_for_apply(self) -> bool:
|
|
102
|
+
"""Whether the plan has no known blocker for the later apply slice."""
|
|
103
|
+
|
|
104
|
+
return self.status == "planned" and not self.issues
|
|
105
|
+
|
|
106
|
+
def as_dict(self) -> dict[str, Any]:
|
|
107
|
+
"""Render the plan as one stable, secret-free JSON-compatible object."""
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
"schema_version": self.schema_version,
|
|
111
|
+
"adapter": self.adapter,
|
|
112
|
+
"kit_id": self.kit_id,
|
|
113
|
+
"status": self.status,
|
|
114
|
+
"runtime": {
|
|
115
|
+
"platform": self.runtime.platform,
|
|
116
|
+
"engine": self.runtime.engine,
|
|
117
|
+
"image": self.runtime.image,
|
|
118
|
+
"kit_id": self.runtime.kit_id,
|
|
119
|
+
"dbcan_version": self.runtime.dbcan_version,
|
|
120
|
+
"diamond_version": self.runtime.diamond_version,
|
|
121
|
+
"image_status": self.runtime.image_status,
|
|
122
|
+
},
|
|
123
|
+
"resource": {
|
|
124
|
+
"path": str(self.resource.path)
|
|
125
|
+
if self.resource.path is not None
|
|
126
|
+
else None,
|
|
127
|
+
"status": self.resource.status,
|
|
128
|
+
"lock_path": (
|
|
129
|
+
str(self.resource.lock_path)
|
|
130
|
+
if self.resource.lock_path is not None
|
|
131
|
+
else None
|
|
132
|
+
),
|
|
133
|
+
"error": self.resource.error,
|
|
134
|
+
"components": [
|
|
135
|
+
{
|
|
136
|
+
"name": component.name,
|
|
137
|
+
"path": component.path,
|
|
138
|
+
"expected_size": component.expected_size,
|
|
139
|
+
"actual_size": component.actual_size,
|
|
140
|
+
"expected_sha256": component.expected_sha256,
|
|
141
|
+
"actual_sha256": component.actual_sha256,
|
|
142
|
+
"status": component.status,
|
|
143
|
+
}
|
|
144
|
+
for component in self.resource.components
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
"profile": {
|
|
148
|
+
"name": self.profile.name,
|
|
149
|
+
"path": str(self.profile.path),
|
|
150
|
+
"status": self.profile.status,
|
|
151
|
+
},
|
|
152
|
+
"actions": list(self.actions),
|
|
153
|
+
"smoke": {"status": self.smoke_status, "reason": self.smoke_reason},
|
|
154
|
+
"next_command": self.next_command,
|
|
155
|
+
"issues": list(self.issues),
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def build_setup_plan(
|
|
160
|
+
kit_name: str,
|
|
161
|
+
*,
|
|
162
|
+
resource: str | Path | None = None,
|
|
163
|
+
profile_name: str | None = None,
|
|
164
|
+
environment: Mapping[str, str] | None = None,
|
|
165
|
+
stdin_isatty: bool | None = None,
|
|
166
|
+
) -> SetupPlan:
|
|
167
|
+
"""Build a setup plan without pulling, hashing, writing or launching."""
|
|
168
|
+
|
|
169
|
+
manifest = load_kit_manifest(kit_name)
|
|
170
|
+
values = os.environ if environment is None else environment
|
|
171
|
+
selected_name = kit_name if profile_name is None else profile_name
|
|
172
|
+
profile_path = named_profile_path(selected_name, environment=values)
|
|
173
|
+
issues: list[str] = []
|
|
174
|
+
existing: ExecutionProfile | None = None
|
|
175
|
+
profile_status = "absent"
|
|
176
|
+
if profile_path.exists():
|
|
177
|
+
try:
|
|
178
|
+
existing = load_execution_profile(profile_path)
|
|
179
|
+
except ProfileConfigurationError as error:
|
|
180
|
+
profile_status = "invalid"
|
|
181
|
+
issues.append(f"existing profile cannot be read: {error}")
|
|
182
|
+
else:
|
|
183
|
+
profile_status = "existing"
|
|
184
|
+
if not _profile_matches_manifest(existing, manifest):
|
|
185
|
+
profile_status = "conflict"
|
|
186
|
+
issues.append(
|
|
187
|
+
"existing profile conflicts with the selected kit; use "
|
|
188
|
+
"--profile-name for a new managed profile"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
selected_resource = _resolve_resource_input(
|
|
192
|
+
resource=resource,
|
|
193
|
+
existing=existing,
|
|
194
|
+
profile_status=profile_status,
|
|
195
|
+
stdin_isatty=(sys.stdin.isatty() if stdin_isatty is None else stdin_isatty),
|
|
196
|
+
)
|
|
197
|
+
if selected_resource is None:
|
|
198
|
+
issues.append(
|
|
199
|
+
"a first-run managed setup requires --resource in non-interactive mode"
|
|
200
|
+
)
|
|
201
|
+
elif existing is not None and profile_status == "existing":
|
|
202
|
+
if existing.resource != selected_resource:
|
|
203
|
+
profile_status = "conflict"
|
|
204
|
+
issues.append(
|
|
205
|
+
"the supplied resource differs from the existing profile; use "
|
|
206
|
+
"--profile-name for a different resource"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
resource_plan = _inspect_resource(selected_resource, manifest)
|
|
210
|
+
if resource_plan.status in {"missing", "invalid"}:
|
|
211
|
+
if resource_plan.error is not None:
|
|
212
|
+
issues.append(resource_plan.error)
|
|
213
|
+
else:
|
|
214
|
+
issues.append(f"resource is not ready: {resource_plan.status}")
|
|
215
|
+
|
|
216
|
+
runtime = SetupRuntimePlan(
|
|
217
|
+
platform=manifest.platform,
|
|
218
|
+
engine="docker",
|
|
219
|
+
image=manifest.image,
|
|
220
|
+
kit_id=manifest.kit_id,
|
|
221
|
+
dbcan_version=manifest.dbcan_version,
|
|
222
|
+
diamond_version=manifest.diamond_version,
|
|
223
|
+
image_status=(
|
|
224
|
+
_inspect_image(manifest.image)
|
|
225
|
+
if selected_resource is not None
|
|
226
|
+
else "not-checked"
|
|
227
|
+
),
|
|
228
|
+
)
|
|
229
|
+
profile = SetupProfilePlan(
|
|
230
|
+
name=selected_name,
|
|
231
|
+
path=profile_path,
|
|
232
|
+
status=(
|
|
233
|
+
"equal" if profile_status == "existing" and not issues else profile_status
|
|
234
|
+
),
|
|
235
|
+
)
|
|
236
|
+
actions = _actions(runtime, resource_plan, profile)
|
|
237
|
+
status = "blocked" if issues else "planned"
|
|
238
|
+
return SetupPlan(
|
|
239
|
+
schema_version=1,
|
|
240
|
+
adapter=manifest.adapter.value,
|
|
241
|
+
kit_id=manifest.kit_id,
|
|
242
|
+
status=status,
|
|
243
|
+
runtime=runtime,
|
|
244
|
+
resource=resource_plan,
|
|
245
|
+
profile=profile,
|
|
246
|
+
actions=actions,
|
|
247
|
+
smoke_status="deferred",
|
|
248
|
+
smoke_reason="setup apply will run the ephemeral runtime smoke",
|
|
249
|
+
next_command=None,
|
|
250
|
+
issues=tuple(issues),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def apply_setup(plan: SetupPlan) -> SetupPlan:
|
|
255
|
+
"""Apply one previously inspected plan and publish its profile atomically."""
|
|
256
|
+
|
|
257
|
+
if not plan.ready_for_apply:
|
|
258
|
+
detail = "; ".join(plan.issues) or f"setup plan is {plan.status}"
|
|
259
|
+
raise SetupError(f"setup plan is not ready to apply: {detail}")
|
|
260
|
+
manifest = load_kit_manifest(plan.adapter)
|
|
261
|
+
if manifest.kit_id != plan.kit_id or manifest.image != plan.runtime.image:
|
|
262
|
+
raise SetupError("setup plan no longer matches the bundled managed kit")
|
|
263
|
+
resource = plan.resource.path
|
|
264
|
+
if resource is None:
|
|
265
|
+
raise SetupError("setup plan has no caller-owned resource path")
|
|
266
|
+
|
|
267
|
+
docker = shutil.which("docker")
|
|
268
|
+
if docker is None:
|
|
269
|
+
raise SetupError("Docker executable is not available on PATH")
|
|
270
|
+
_ensure_image(docker, manifest, image=plan.runtime.image)
|
|
271
|
+
lock = _verify_resource(resource, manifest, full=plan.resource.status != "ready")
|
|
272
|
+
_run_smoke(docker, manifest, resource)
|
|
273
|
+
profile_published = _publish_profile(plan, manifest)
|
|
274
|
+
|
|
275
|
+
locked_by_path = {
|
|
276
|
+
component.relative_path: component for component in lock.components
|
|
277
|
+
}
|
|
278
|
+
components = tuple(
|
|
279
|
+
replace(
|
|
280
|
+
component,
|
|
281
|
+
actual_sha256=locked_by_path[component.path].sha256,
|
|
282
|
+
status="ready",
|
|
283
|
+
)
|
|
284
|
+
for component in plan.resource.components
|
|
285
|
+
)
|
|
286
|
+
resource_plan = replace(plan.resource, status="ready", components=components)
|
|
287
|
+
profile_plan = replace(
|
|
288
|
+
plan.profile,
|
|
289
|
+
status="published" if profile_published else "equal",
|
|
290
|
+
)
|
|
291
|
+
return replace(
|
|
292
|
+
plan,
|
|
293
|
+
status="applied",
|
|
294
|
+
resource=resource_plan,
|
|
295
|
+
profile=profile_plan,
|
|
296
|
+
smoke_status="passed",
|
|
297
|
+
smoke_reason="dbCAN runtime and caller-mounted read-only resource passed",
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _resolve_resource_input(
|
|
302
|
+
*,
|
|
303
|
+
resource: str | Path | None,
|
|
304
|
+
existing: ExecutionProfile | None,
|
|
305
|
+
profile_status: str,
|
|
306
|
+
stdin_isatty: bool,
|
|
307
|
+
) -> Path | None:
|
|
308
|
+
if resource is not None:
|
|
309
|
+
return Path(resource).expanduser().resolve()
|
|
310
|
+
if existing is not None and profile_status == "existing" and existing.version == 2:
|
|
311
|
+
return existing.resource
|
|
312
|
+
# Prompting belongs to the CLI boundary. The pure plan builder remains
|
|
313
|
+
# deterministic and reports the missing first-run input instead.
|
|
314
|
+
del stdin_isatty
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _profile_matches_manifest(profile: ExecutionProfile, manifest: KitManifest) -> bool:
|
|
319
|
+
runtime = profile.runtime
|
|
320
|
+
return (
|
|
321
|
+
profile.version == 2
|
|
322
|
+
and profile.adapter is manifest.adapter
|
|
323
|
+
and runtime is not None
|
|
324
|
+
and runtime.kind == "oci"
|
|
325
|
+
and runtime.engine == "docker"
|
|
326
|
+
and runtime.kit_id == manifest.kit_id
|
|
327
|
+
and runtime.image == manifest.image
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _inspect_resource(path: Path | None, manifest: KitManifest) -> SetupResourcePlan:
|
|
332
|
+
if path is None:
|
|
333
|
+
return SetupResourcePlan(None, "unresolved", None, ())
|
|
334
|
+
lock_path = path / "seqevi.lock"
|
|
335
|
+
if not path.exists():
|
|
336
|
+
return SetupResourcePlan(
|
|
337
|
+
path, "missing", lock_path, (), f"resource directory does not exist: {path}"
|
|
338
|
+
)
|
|
339
|
+
if not path.is_dir() or not os.access(path, os.R_OK):
|
|
340
|
+
return SetupResourcePlan(
|
|
341
|
+
path,
|
|
342
|
+
"invalid",
|
|
343
|
+
lock_path,
|
|
344
|
+
(),
|
|
345
|
+
f"resource is not a readable directory: {path}",
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
components: list[SetupComponentPlan] = []
|
|
349
|
+
for declaration in manifest.components:
|
|
350
|
+
component_path = path / declaration.path
|
|
351
|
+
if not component_path.is_file():
|
|
352
|
+
components.append(
|
|
353
|
+
SetupComponentPlan(
|
|
354
|
+
declaration.name,
|
|
355
|
+
declaration.path,
|
|
356
|
+
declaration.size,
|
|
357
|
+
None,
|
|
358
|
+
declaration.sha256,
|
|
359
|
+
None,
|
|
360
|
+
"missing",
|
|
361
|
+
)
|
|
362
|
+
)
|
|
363
|
+
continue
|
|
364
|
+
actual_size = component_path.stat().st_size
|
|
365
|
+
components.append(
|
|
366
|
+
SetupComponentPlan(
|
|
367
|
+
declaration.name,
|
|
368
|
+
declaration.path,
|
|
369
|
+
declaration.size,
|
|
370
|
+
actual_size,
|
|
371
|
+
declaration.sha256,
|
|
372
|
+
None,
|
|
373
|
+
"size-ok" if actual_size == declaration.size else "size-mismatch",
|
|
374
|
+
)
|
|
375
|
+
)
|
|
376
|
+
if any(component.status != "size-ok" for component in components):
|
|
377
|
+
return SetupResourcePlan(
|
|
378
|
+
path,
|
|
379
|
+
"invalid",
|
|
380
|
+
lock_path,
|
|
381
|
+
tuple(components),
|
|
382
|
+
f"resource components do not match the dbCAN kit manifest: {path}",
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
try:
|
|
386
|
+
lock = read_resource_lock(path)
|
|
387
|
+
except ResourceLockError as error:
|
|
388
|
+
return SetupResourcePlan(
|
|
389
|
+
path, "invalid", lock_path, tuple(components), str(error)
|
|
390
|
+
)
|
|
391
|
+
if lock is None:
|
|
392
|
+
return SetupResourcePlan(path, "needs-lock", lock_path, tuple(components))
|
|
393
|
+
lock_error = _lock_mismatch(lock, manifest)
|
|
394
|
+
if lock_error is not None:
|
|
395
|
+
return SetupResourcePlan(
|
|
396
|
+
path, "invalid", lock_path, tuple(components), lock_error
|
|
397
|
+
)
|
|
398
|
+
locked_by_path = {
|
|
399
|
+
component.relative_path: component for component in lock.components
|
|
400
|
+
}
|
|
401
|
+
enriched = tuple(
|
|
402
|
+
SetupComponentPlan(
|
|
403
|
+
component.name,
|
|
404
|
+
component.path,
|
|
405
|
+
component.expected_size,
|
|
406
|
+
component.actual_size,
|
|
407
|
+
component.expected_sha256,
|
|
408
|
+
locked_by_path[component.path].sha256,
|
|
409
|
+
"ready",
|
|
410
|
+
)
|
|
411
|
+
for component in components
|
|
412
|
+
)
|
|
413
|
+
return SetupResourcePlan(path, "ready", lock_path, enriched)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _lock_mismatch(lock: ResourceLock, manifest: KitManifest) -> str | None:
|
|
417
|
+
if (lock.resource_name, lock.resource_version) != (
|
|
418
|
+
manifest.resource_name,
|
|
419
|
+
manifest.resource_version,
|
|
420
|
+
):
|
|
421
|
+
return "seqevi.lock resource identity conflicts with the selected kit"
|
|
422
|
+
expected = {
|
|
423
|
+
component.path: (component.name, component.size, component.sha256)
|
|
424
|
+
for component in manifest.components
|
|
425
|
+
}
|
|
426
|
+
actual = {
|
|
427
|
+
component.relative_path: (component.name, component.size, component.sha256)
|
|
428
|
+
for component in lock.components
|
|
429
|
+
}
|
|
430
|
+
if actual != expected:
|
|
431
|
+
return "seqevi.lock components conflict with the selected kit manifest"
|
|
432
|
+
return None
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _inspect_image(image: str) -> str:
|
|
436
|
+
docker = shutil.which("docker")
|
|
437
|
+
if docker is None:
|
|
438
|
+
return "docker-unavailable"
|
|
439
|
+
try:
|
|
440
|
+
result = subprocess.run(
|
|
441
|
+
[docker, "image", "inspect", image],
|
|
442
|
+
check=False,
|
|
443
|
+
capture_output=True,
|
|
444
|
+
text=True,
|
|
445
|
+
timeout=5,
|
|
446
|
+
)
|
|
447
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
448
|
+
return "docker-unavailable"
|
|
449
|
+
return "present" if result.returncode == 0 else "missing"
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _ensure_image(docker: str, manifest: KitManifest, *, image: str) -> None:
|
|
453
|
+
inspected = _docker_run(
|
|
454
|
+
docker,
|
|
455
|
+
("image", "inspect", image),
|
|
456
|
+
timeout_seconds=30,
|
|
457
|
+
action="inspect the managed runtime image",
|
|
458
|
+
)
|
|
459
|
+
if inspected.returncode != 0:
|
|
460
|
+
pulled = _docker_run(
|
|
461
|
+
docker,
|
|
462
|
+
("pull", "--platform", manifest.platform, image),
|
|
463
|
+
timeout_seconds=_PULL_TIMEOUT_SECONDS,
|
|
464
|
+
action="pull the managed runtime image",
|
|
465
|
+
)
|
|
466
|
+
if pulled.returncode != 0:
|
|
467
|
+
raise SetupError(
|
|
468
|
+
"Docker could not pull the exact managed runtime image: "
|
|
469
|
+
+ _process_detail(pulled)
|
|
470
|
+
)
|
|
471
|
+
verified = _docker_run(
|
|
472
|
+
docker,
|
|
473
|
+
("image", "inspect", image),
|
|
474
|
+
timeout_seconds=30,
|
|
475
|
+
action="verify the managed runtime image",
|
|
476
|
+
)
|
|
477
|
+
if verified.returncode != 0:
|
|
478
|
+
raise SetupError(
|
|
479
|
+
"Docker did not retain the exact digest-pinned managed runtime image: "
|
|
480
|
+
+ _process_detail(verified)
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _verify_resource(
|
|
485
|
+
resource: Path, manifest: KitManifest, *, full: bool
|
|
486
|
+
) -> ResourceLock:
|
|
487
|
+
declarations = tuple(
|
|
488
|
+
ResourceComponent(component.name, component.path)
|
|
489
|
+
for component in manifest.components
|
|
490
|
+
)
|
|
491
|
+
try:
|
|
492
|
+
return resolve_resource_lock(
|
|
493
|
+
database=resource,
|
|
494
|
+
resource_name=manifest.resource_name,
|
|
495
|
+
resource_version=manifest.resource_version,
|
|
496
|
+
components=declarations,
|
|
497
|
+
verify=full,
|
|
498
|
+
)
|
|
499
|
+
except ResourceLockError as error:
|
|
500
|
+
raise SetupError(
|
|
501
|
+
f"caller-owned dbCAN resource verification failed: {error}"
|
|
502
|
+
) from error
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _run_smoke(docker: str, manifest: KitManifest, resource: Path) -> None:
|
|
506
|
+
expected = {component.path: component.size for component in manifest.components}
|
|
507
|
+
script = "\n".join(
|
|
508
|
+
(
|
|
509
|
+
"from pathlib import Path",
|
|
510
|
+
"import subprocess",
|
|
511
|
+
f"root = Path({_SMOKE_RESOURCE_ROOT!r})",
|
|
512
|
+
f"expected = {expected!r}",
|
|
513
|
+
"version = subprocess.run(",
|
|
514
|
+
" ['/opt/dbcan-venv/bin/run_dbcan', 'version'], check=False",
|
|
515
|
+
")",
|
|
516
|
+
"if version.returncode != 0:",
|
|
517
|
+
" raise SystemExit('dbCAN version probe failed')",
|
|
518
|
+
"for name, size in expected.items():",
|
|
519
|
+
" path = root / name",
|
|
520
|
+
" if not path.is_file() or path.stat().st_size != size:",
|
|
521
|
+
" raise SystemExit(f'resource component failed: {name}')",
|
|
522
|
+
"probe = root / '.seqevi-smoke-write-test'",
|
|
523
|
+
"try:",
|
|
524
|
+
" probe.write_text('unexpected', encoding='utf-8')",
|
|
525
|
+
"except OSError:",
|
|
526
|
+
" pass",
|
|
527
|
+
"else:",
|
|
528
|
+
" probe.unlink(missing_ok=True)",
|
|
529
|
+
" raise SystemExit('resource mount is writable')",
|
|
530
|
+
"print('seqevi dbCAN setup smoke passed')",
|
|
531
|
+
)
|
|
532
|
+
)
|
|
533
|
+
_run_ephemeral_container(
|
|
534
|
+
docker,
|
|
535
|
+
image=manifest.image,
|
|
536
|
+
resource=resource,
|
|
537
|
+
command=("-c", script),
|
|
538
|
+
entrypoint="/opt/venv/bin/python",
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _run_ephemeral_container(
|
|
543
|
+
docker: str,
|
|
544
|
+
*,
|
|
545
|
+
image: str,
|
|
546
|
+
resource: Path,
|
|
547
|
+
command: tuple[str, ...],
|
|
548
|
+
entrypoint: str,
|
|
549
|
+
) -> None:
|
|
550
|
+
container_name = f"seqevi-setup-smoke-{uuid.uuid4().hex[:16]}"
|
|
551
|
+
uid = getattr(os, "getuid", lambda: 0)()
|
|
552
|
+
gid = getattr(os, "getgid", lambda: 0)()
|
|
553
|
+
mount = f"type=bind,source={resource},target={_SMOKE_RESOURCE_ROOT},readonly"
|
|
554
|
+
created = _docker_run(
|
|
555
|
+
docker,
|
|
556
|
+
(
|
|
557
|
+
"create",
|
|
558
|
+
"--name",
|
|
559
|
+
container_name,
|
|
560
|
+
"--user",
|
|
561
|
+
f"{uid}:{gid}",
|
|
562
|
+
"--network",
|
|
563
|
+
"none",
|
|
564
|
+
"--mount",
|
|
565
|
+
mount,
|
|
566
|
+
"--entrypoint",
|
|
567
|
+
entrypoint,
|
|
568
|
+
image,
|
|
569
|
+
*command,
|
|
570
|
+
),
|
|
571
|
+
timeout_seconds=30,
|
|
572
|
+
action="create the setup smoke container",
|
|
573
|
+
)
|
|
574
|
+
if created.returncode != 0:
|
|
575
|
+
raise SetupError(
|
|
576
|
+
"Docker could not create the setup smoke container: "
|
|
577
|
+
+ _process_detail(created)
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
failure: SetupError | None = None
|
|
581
|
+
try:
|
|
582
|
+
started = _docker_run(
|
|
583
|
+
docker,
|
|
584
|
+
("start", "--attach", container_name),
|
|
585
|
+
timeout_seconds=_SMOKE_TIMEOUT_SECONDS,
|
|
586
|
+
action="run the setup smoke container",
|
|
587
|
+
)
|
|
588
|
+
if started.returncode != 0:
|
|
589
|
+
failure = SetupError(
|
|
590
|
+
"managed dbCAN setup smoke failed: " + _process_detail(started)
|
|
591
|
+
)
|
|
592
|
+
except SetupError as error:
|
|
593
|
+
failure = error
|
|
594
|
+
finally:
|
|
595
|
+
try:
|
|
596
|
+
removed = _docker_run(
|
|
597
|
+
docker,
|
|
598
|
+
("rm", "--force", container_name),
|
|
599
|
+
timeout_seconds=30,
|
|
600
|
+
action="remove the setup smoke container",
|
|
601
|
+
)
|
|
602
|
+
except SetupError as error:
|
|
603
|
+
removed = None
|
|
604
|
+
if failure is None:
|
|
605
|
+
failure = error
|
|
606
|
+
if removed is not None and removed.returncode != 0 and failure is None:
|
|
607
|
+
failure = SetupError(
|
|
608
|
+
"Docker could not remove the setup smoke container: "
|
|
609
|
+
+ _process_detail(removed)
|
|
610
|
+
)
|
|
611
|
+
if failure is not None:
|
|
612
|
+
raise failure
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _publish_profile(plan: SetupPlan, manifest: KitManifest) -> bool:
|
|
616
|
+
destination = plan.profile.path
|
|
617
|
+
resource = plan.resource.path
|
|
618
|
+
if resource is None:
|
|
619
|
+
raise SetupError("cannot publish a managed profile without a resource")
|
|
620
|
+
if destination.exists():
|
|
621
|
+
try:
|
|
622
|
+
existing = load_execution_profile(destination)
|
|
623
|
+
except ProfileConfigurationError as error:
|
|
624
|
+
raise SetupError(f"existing profile cannot be read: {error}") from error
|
|
625
|
+
if (
|
|
626
|
+
not _profile_matches_manifest(existing, manifest)
|
|
627
|
+
or existing.resource != resource
|
|
628
|
+
):
|
|
629
|
+
raise SetupError(
|
|
630
|
+
"existing profile conflicts with the selected managed kit; "
|
|
631
|
+
"choose another --profile-name"
|
|
632
|
+
)
|
|
633
|
+
return False
|
|
634
|
+
|
|
635
|
+
try:
|
|
636
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
637
|
+
except OSError as error:
|
|
638
|
+
raise SetupError(f"cannot create profile directory: {error}") from error
|
|
639
|
+
temporary: Path | None = None
|
|
640
|
+
try:
|
|
641
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
642
|
+
prefix=f".{destination.stem}.",
|
|
643
|
+
suffix=".tmp",
|
|
644
|
+
dir=destination.parent,
|
|
645
|
+
text=True,
|
|
646
|
+
)
|
|
647
|
+
temporary = Path(temporary_name)
|
|
648
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
649
|
+
stream.write(_profile_document(manifest, resource))
|
|
650
|
+
stream.flush()
|
|
651
|
+
os.fsync(stream.fileno())
|
|
652
|
+
os.chmod(temporary, 0o600)
|
|
653
|
+
try:
|
|
654
|
+
os.link(temporary, destination)
|
|
655
|
+
except FileExistsError as error:
|
|
656
|
+
raise SetupError(
|
|
657
|
+
"managed profile appeared during setup; rerun to inspect the conflict"
|
|
658
|
+
) from error
|
|
659
|
+
_fsync_directory(destination.parent)
|
|
660
|
+
return True
|
|
661
|
+
except OSError as error:
|
|
662
|
+
raise SetupError(
|
|
663
|
+
f"cannot publish managed profile {destination}: {error}"
|
|
664
|
+
) from error
|
|
665
|
+
finally:
|
|
666
|
+
if temporary is not None:
|
|
667
|
+
try:
|
|
668
|
+
temporary.unlink()
|
|
669
|
+
except FileNotFoundError:
|
|
670
|
+
pass
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _profile_document(manifest: KitManifest, resource: Path) -> str:
|
|
674
|
+
return (
|
|
675
|
+
"version = 2\n"
|
|
676
|
+
f"adapter = {_toml_string(manifest.adapter.value)}\n"
|
|
677
|
+
f"resource = {_toml_string(str(resource))}\n\n"
|
|
678
|
+
"[runtime]\n"
|
|
679
|
+
'kind = "oci"\n'
|
|
680
|
+
f"kit_id = {_toml_string(manifest.kit_id)}\n"
|
|
681
|
+
'engine = "docker"\n'
|
|
682
|
+
f"image = {_toml_string(manifest.image)}\n"
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _toml_string(value: str) -> str:
|
|
687
|
+
return json.dumps(value, ensure_ascii=False)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _fsync_directory(directory: Path) -> None:
|
|
691
|
+
try:
|
|
692
|
+
descriptor = os.open(directory, os.O_RDONLY)
|
|
693
|
+
except OSError:
|
|
694
|
+
return
|
|
695
|
+
try:
|
|
696
|
+
os.fsync(descriptor)
|
|
697
|
+
finally:
|
|
698
|
+
os.close(descriptor)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _docker_run(
|
|
702
|
+
docker: str,
|
|
703
|
+
arguments: tuple[str, ...],
|
|
704
|
+
*,
|
|
705
|
+
timeout_seconds: float,
|
|
706
|
+
action: str,
|
|
707
|
+
) -> subprocess.CompletedProcess[str]:
|
|
708
|
+
try:
|
|
709
|
+
return subprocess.run(
|
|
710
|
+
[docker, *arguments],
|
|
711
|
+
check=False,
|
|
712
|
+
capture_output=True,
|
|
713
|
+
text=True,
|
|
714
|
+
timeout=timeout_seconds,
|
|
715
|
+
)
|
|
716
|
+
except subprocess.TimeoutExpired as error:
|
|
717
|
+
raise SetupError(f"timed out while trying to {action}") from error
|
|
718
|
+
except OSError as error:
|
|
719
|
+
raise SetupError(f"could not {action}: {error}") from error
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _process_detail(result: subprocess.CompletedProcess[str]) -> str:
|
|
723
|
+
detail = (result.stderr or result.stdout or "no diagnostic output").strip()
|
|
724
|
+
return detail[-2000:]
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _actions(
|
|
728
|
+
runtime: SetupRuntimePlan,
|
|
729
|
+
resource: SetupResourcePlan,
|
|
730
|
+
profile: SetupProfilePlan,
|
|
731
|
+
) -> tuple[str, ...]:
|
|
732
|
+
actions = []
|
|
733
|
+
if runtime.image_status == "present":
|
|
734
|
+
actions.append("reuse the locally cached digest-pinned runtime image")
|
|
735
|
+
else:
|
|
736
|
+
actions.append("pull the exact digest-pinned runtime image during setup apply")
|
|
737
|
+
if resource.status == "needs-lock":
|
|
738
|
+
actions.append(
|
|
739
|
+
"verify all four resource files and create seqevi.lock during setup apply"
|
|
740
|
+
)
|
|
741
|
+
elif resource.status == "ready":
|
|
742
|
+
actions.append("reuse the matching read-only resource lock")
|
|
743
|
+
else:
|
|
744
|
+
actions.append("resolve and validate the caller-owned four-file resource")
|
|
745
|
+
if profile.status == "absent":
|
|
746
|
+
actions.append("atomically publish the complete v2 profile during setup apply")
|
|
747
|
+
elif profile.status == "equal":
|
|
748
|
+
actions.append("keep the existing equal profile unchanged")
|
|
749
|
+
else:
|
|
750
|
+
actions.append("repair or select a non-conflicting profile name")
|
|
751
|
+
actions.append("run an ephemeral caller-mounted smoke during setup apply")
|
|
752
|
+
return tuple(actions)
|