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.
Files changed (49) hide show
  1. seqevi/__init__.py +7 -0
  2. seqevi/__main__.py +6 -0
  3. seqevi/adapters/__init__.py +41 -0
  4. seqevi/adapters/base.py +131 -0
  5. seqevi/adapters/dbcan_cazyme.py +588 -0
  6. seqevi/adapters/eggnog.py +674 -0
  7. seqevi/adapters/interpro_pfam.py +757 -0
  8. seqevi/adapters/registry.py +68 -0
  9. seqevi/annotate.py +413 -0
  10. seqevi/api.py +390 -0
  11. seqevi/cli.py +610 -0
  12. seqevi/distribution/__init__.py +13 -0
  13. seqevi/distribution/manifest.py +199 -0
  14. seqevi/distribution/oci.py +490 -0
  15. seqevi/distribution/setup.py +752 -0
  16. seqevi/errors.py +73 -0
  17. seqevi/evidence.py +295 -0
  18. seqevi/execution_profile.py +526 -0
  19. seqevi/hashing.py +13 -0
  20. seqevi/kits/__init__.py +1 -0
  21. seqevi/kits/dbcan-cazyme.toml +35 -0
  22. seqevi/resource_lock.py +438 -0
  23. seqevi/result.py +682 -0
  24. seqevi/runner.py +163 -0
  25. seqevi/runtime_identity.py +104 -0
  26. seqevi/sequence.py +383 -0
  27. seqevi/service/__init__.py +11 -0
  28. seqevi/service/app.py +213 -0
  29. seqevi/service/config.py +38 -0
  30. seqevi/service/persistence.py +360 -0
  31. seqevi/store/__init__.py +14 -0
  32. seqevi/store/artifact.py +225 -0
  33. seqevi/store/client.py +311 -0
  34. seqevi/store/contract.py +33 -0
  35. seqevi/store/factory.py +38 -0
  36. seqevi/store/local.py +479 -0
  37. seqevi/store/migration.py +62 -0
  38. seqevi/store/migrations/__init__.py +1 -0
  39. seqevi/store/migrations/env.py +30 -0
  40. seqevi/store/migrations/versions/0001_initial_store.py +103 -0
  41. seqevi/store/migrations/versions/0002_artifact_byte_size_bigint.py +40 -0
  42. seqevi/store/migrations/versions/__init__.py +1 -0
  43. seqevi/store/schema.py +86 -0
  44. seqevi/store/transport.py +224 -0
  45. seqevi-0.2.0.dist-info/METADATA +333 -0
  46. seqevi-0.2.0.dist-info/RECORD +49 -0
  47. seqevi-0.2.0.dist-info/WHEEL +4 -0
  48. seqevi-0.2.0.dist-info/entry_points.txt +5 -0
  49. 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)