wyrmctl 0.4.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 (75) hide show
  1. wyrmctl/__init__.py +3 -0
  2. wyrmctl/__main__.py +5 -0
  3. wyrmctl/adoption.py +5 -0
  4. wyrmctl/apply.py +280 -0
  5. wyrmctl/artifacts/__init__.py +24 -0
  6. wyrmctl/artifacts/codec.py +54 -0
  7. wyrmctl/artifacts/execution.py +105 -0
  8. wyrmctl/artifacts/models.py +126 -0
  9. wyrmctl/artifacts/plan.py +85 -0
  10. wyrmctl/artifacts/redaction.py +22 -0
  11. wyrmctl/artifacts/retention.py +28 -0
  12. wyrmctl/artifacts/signing.py +33 -0
  13. wyrmctl/cli.py +798 -0
  14. wyrmctl/client/__init__.py +5 -0
  15. wyrmctl/client/access_lists.py +10 -0
  16. wyrmctl/client/auth.py +5 -0
  17. wyrmctl/client/base.py +308 -0
  18. wyrmctl/client/certificates.py +10 -0
  19. wyrmctl/client/contracts.py +30 -0
  20. wyrmctl/client/proxy_hosts.py +10 -0
  21. wyrmctl/commands/__init__.py +15 -0
  22. wyrmctl/commands/artifacts.py +25 -0
  23. wyrmctl/commands/contracts.py +23 -0
  24. wyrmctl/commands/import_live.py +25 -0
  25. wyrmctl/commands/lock.py +33 -0
  26. wyrmctl/commands/repository.py +35 -0
  27. wyrmctl/config.py +45 -0
  28. wyrmctl/contracts/__init__.py +20 -0
  29. wyrmctl/contracts/builtin.py +33 -0
  30. wyrmctl/contracts/canonical.py +43 -0
  31. wyrmctl/contracts/compatibility.py +24 -0
  32. wyrmctl/contracts/registry.py +63 -0
  33. wyrmctl/contracts/types.py +62 -0
  34. wyrmctl/diagnostics.py +41 -0
  35. wyrmctl/dns.py +347 -0
  36. wyrmctl/errors.py +95 -0
  37. wyrmctl/issuance.py +138 -0
  38. wyrmctl/loader.py +340 -0
  39. wyrmctl/lockfile.py +73 -0
  40. wyrmctl/logging.py +13 -0
  41. wyrmctl/metadata.py +79 -0
  42. wyrmctl/migrations/__init__.py +22 -0
  43. wyrmctl/migrations/base.py +18 -0
  44. wyrmctl/migrations/builtin/__init__.py +41 -0
  45. wyrmctl/migrations/builtin/desired_state_v1_v2.py +29 -0
  46. wyrmctl/migrations/builtin/desired_state_v2_v3.py +61 -0
  47. wyrmctl/migrations/builtin/repository_v0_v1.py +18 -0
  48. wyrmctl/migrations/executor.py +47 -0
  49. wyrmctl/migrations/graph.py +71 -0
  50. wyrmctl/migrations/import_live.py +43 -0
  51. wyrmctl/migrations/leases.py +77 -0
  52. wyrmctl/migrations/ledger.py +71 -0
  53. wyrmctl/migrations/manifest.py +70 -0
  54. wyrmctl/migrations/planner.py +59 -0
  55. wyrmctl/migrations/recovery.py +31 -0
  56. wyrmctl/migrations/registry.py +88 -0
  57. wyrmctl/migrations/transaction.py +64 -0
  58. wyrmctl/migrations/v1.py +10 -0
  59. wyrmctl/migrations/v2.py +11 -0
  60. wyrmctl/models.py +869 -0
  61. wyrmctl/operational.py +256 -0
  62. wyrmctl/output.py +75 -0
  63. wyrmctl/planner.py +563 -0
  64. wyrmctl/plugins.py +173 -0
  65. wyrmctl/profile.py +64 -0
  66. wyrmctl/providers.py +77 -0
  67. wyrmctl/py.typed +0 -0
  68. wyrmctl/repository.py +130 -0
  69. wyrmctl/schema.py +178 -0
  70. wyrmctl/validation.py +5 -0
  71. wyrmctl-0.4.0.dist-info/METADATA +247 -0
  72. wyrmctl-0.4.0.dist-info/RECORD +75 -0
  73. wyrmctl-0.4.0.dist-info/WHEEL +4 -0
  74. wyrmctl-0.4.0.dist-info/entry_points.txt +3 -0
  75. wyrmctl-0.4.0.dist-info/licenses/LICENSE +183 -0
wyrmctl/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """wyrmctl: owner-scoped controller for Nginx Proxy Manager."""
2
+
3
+ __version__ = "0.4.0"
wyrmctl/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """python -m wyrmctl entry point."""
2
+
3
+ from wyrmctl.cli import main
4
+
5
+ raise SystemExit(main())
wyrmctl/adoption.py ADDED
@@ -0,0 +1,5 @@
1
+ """Adoption helpers."""
2
+
3
+ from wyrmctl.planner import PlannerOptions, compute_plan
4
+
5
+ __all__ = ["PlannerOptions", "compute_plan"]
wyrmctl/apply.py ADDED
@@ -0,0 +1,280 @@
1
+ """Apply owner-scoped plans to NPM."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ from wyrmctl.client import NpmClient
9
+ from wyrmctl.errors import ApiError, CertificateApiError, ConflictError, ValidationError
10
+ from wyrmctl.issuance import CertificateIssuanceGuard
11
+ from wyrmctl.metadata import merge_managed_meta
12
+ from wyrmctl.models import (
13
+ DesiredAccessList,
14
+ DesiredCertificate,
15
+ DesiredGenericResource,
16
+ DesiredProxyHost,
17
+ ExistingResource,
18
+ ExistingState,
19
+ PlanAction,
20
+ ResourceKind,
21
+ )
22
+ from wyrmctl.planner import Plan, PlanOperation
23
+ from wyrmctl.schema import Capabilities
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class ApplyResult:
28
+ """Result of applying a plan."""
29
+
30
+ applied: bool
31
+ mutations: list[dict[str, Any]] = field(default_factory=list)
32
+
33
+ def to_dict(self) -> dict[str, Any]:
34
+ return {"applied": self.applied, "mutations": list(self.mutations)}
35
+
36
+
37
+ class ApplyEngine:
38
+ """Executes a validated plan in dependency order."""
39
+
40
+ def __init__(
41
+ self,
42
+ *,
43
+ client: NpmClient,
44
+ capabilities: Capabilities,
45
+ existing_state: ExistingState | None = None,
46
+ issuance_guard: CertificateIssuanceGuard | None = None,
47
+ ) -> None:
48
+ self.client = client
49
+ self.capabilities = capabilities
50
+ self.created_by_resource_id: dict[str, ExistingResource] = {}
51
+ self.issuance_guard = issuance_guard or CertificateIssuanceGuard()
52
+ if existing_state is not None:
53
+ for resource in existing_state.resources():
54
+ if resource.identity is not None:
55
+ self.created_by_resource_id.setdefault(resource.identity.resource_id, resource)
56
+
57
+ def apply(self, plan: Plan) -> ApplyResult:
58
+ """Apply the plan. Conflicts prevent all mutations."""
59
+
60
+ if plan.conflicts:
61
+ raise ConflictError("refusing to apply plan with conflicts")
62
+ for operation in plan.operations:
63
+ if operation.desired is not None and operation.existing is not None:
64
+ self.created_by_resource_id.setdefault(operation.desired.identity.resource_id, operation.existing)
65
+ result = ApplyResult(applied=True)
66
+ for operation in _ordered_operations(plan.operations):
67
+ if operation.action == PlanAction.NOOP:
68
+ continue
69
+ mutation = self._apply_operation(operation)
70
+ result.mutations.append(mutation)
71
+ return result
72
+
73
+ def _apply_operation(self, operation: PlanOperation) -> dict[str, Any]:
74
+ if operation.action == PlanAction.CREATE:
75
+ return self._create(operation)
76
+ if operation.action == PlanAction.UPDATE:
77
+ return self._update(operation)
78
+ if operation.action == PlanAction.ADOPT:
79
+ return self._adopt(operation)
80
+ if operation.action == PlanAction.DELETE:
81
+ return self._delete(operation)
82
+ raise ValidationError(f"unsupported apply operation {operation.action}")
83
+
84
+ def _create(self, operation: PlanOperation) -> dict[str, Any]:
85
+ desired = _require_desired(operation)
86
+ payload = self._payload_for(desired)
87
+ issuance_key: str | None = None
88
+ try:
89
+ if isinstance(desired, DesiredCertificate):
90
+ issuance_key = self.issuance_guard.begin(desired)
91
+ created = self.client.create_resource(desired.kind, payload)
92
+ except CertificateApiError:
93
+ if issuance_key is not None:
94
+ self.issuance_guard.fail(issuance_key, error_code="certificate_api_error")
95
+ raise
96
+ except Exception:
97
+ if issuance_key is not None:
98
+ self.issuance_guard.fail(issuance_key, error_code="certificate_create_failed")
99
+ raise
100
+ if issuance_key is not None:
101
+ self.issuance_guard.succeed(issuance_key)
102
+ self.created_by_resource_id[desired.identity.resource_id] = created
103
+ return {
104
+ "action": "create",
105
+ "kind": desired.kind.value,
106
+ "resource_id": desired.identity.resource_id,
107
+ "id": created.id,
108
+ }
109
+
110
+ def _update(self, operation: PlanOperation) -> dict[str, Any]:
111
+ desired = _require_desired(operation)
112
+ existing = _require_existing(operation)
113
+ payload = self._merge_existing_with_desired(existing, desired)
114
+ cap = self.capabilities.for_kind(desired.kind)
115
+ issuance_key: str | None = None
116
+ try:
117
+ if isinstance(desired, DesiredCertificate):
118
+ issuance_key = self.issuance_guard.begin(desired)
119
+ updated = self.client.update_resource(desired.kind, existing.id, payload, method=cap.update_method or "put")
120
+ except CertificateApiError:
121
+ if issuance_key is not None:
122
+ self.issuance_guard.fail(issuance_key, error_code="certificate_api_error")
123
+ raise
124
+ except Exception:
125
+ if issuance_key is not None:
126
+ self.issuance_guard.fail(issuance_key, error_code="certificate_update_failed")
127
+ raise
128
+ if issuance_key is not None:
129
+ self.issuance_guard.succeed(issuance_key)
130
+ return {
131
+ "action": "update",
132
+ "kind": desired.kind.value,
133
+ "resource_id": desired.identity.resource_id,
134
+ "id": updated.id,
135
+ }
136
+
137
+ def _adopt(self, operation: PlanOperation) -> dict[str, Any]:
138
+ desired = _require_desired(operation)
139
+ existing = _require_existing(operation)
140
+ payload = _updateable_existing_payload(existing)
141
+ payload["meta"] = merge_managed_meta(payload.get("meta"), desired.meta)
142
+ cap = self.capabilities.for_kind(desired.kind)
143
+ updated = self.client.update_resource(desired.kind, existing.id, payload, method=cap.update_method or "put")
144
+ return {
145
+ "action": "adopt",
146
+ "kind": desired.kind.value,
147
+ "resource_id": desired.identity.resource_id,
148
+ "id": updated.id,
149
+ }
150
+
151
+ def _delete(self, operation: PlanOperation) -> dict[str, Any]:
152
+ existing = _require_existing(operation)
153
+ deleted = self.client.delete_resource(existing.kind, existing.id)
154
+ if not deleted:
155
+ raise ApiError(f"delete failed for {existing.kind.value} id={existing.id}")
156
+ resource_id = existing.identity.resource_id if existing.identity else None
157
+ return {"action": "delete", "kind": existing.kind.value, "resource_id": resource_id, "id": existing.id}
158
+
159
+ def _merge_existing_with_desired(
160
+ self,
161
+ existing: ExistingResource,
162
+ desired: DesiredProxyHost | DesiredCertificate | DesiredAccessList | DesiredGenericResource,
163
+ ) -> dict[str, Any]:
164
+ payload = self._payload_for(desired)
165
+ payload["meta"] = merge_managed_meta(existing.raw.get("meta"), desired.meta)
166
+ return payload
167
+
168
+ def _payload_for(
169
+ self, desired: DesiredProxyHost | DesiredCertificate | DesiredAccessList | DesiredGenericResource
170
+ ) -> dict[str, Any]:
171
+ if isinstance(desired, DesiredProxyHost):
172
+ certificate_id = self._resolve_reference(desired.certificate_ref, ResourceKind.CERTIFICATE)
173
+ access_list_id = self._resolve_reference(desired.access_list_ref, ResourceKind.ACCESS_LIST)
174
+ return desired.to_payload(certificate_id=certificate_id, access_list_id=access_list_id)
175
+ return desired.to_payload()
176
+
177
+ def _resolve_reference(self, ref: str | None, kind: ResourceKind) -> int | None:
178
+ if ref is None:
179
+ return None
180
+ created = self.created_by_resource_id.get(ref)
181
+ if created is not None:
182
+ if created.kind != kind:
183
+ raise ValidationError(f"reference {ref!r} resolved to {created.kind.value}, expected {kind.value}")
184
+ return created.id
185
+ raise ValidationError(f"unresolved {kind.value} reference: {ref}")
186
+
187
+
188
+ def _ordered_operations(operations: tuple[PlanOperation, ...]) -> list[PlanOperation]:
189
+ creates_updates_adopts = [
190
+ op for op in operations if op.action in {PlanAction.CREATE, PlanAction.UPDATE, PlanAction.ADOPT}
191
+ ]
192
+ deletes = [op for op in operations if op.action == PlanAction.DELETE]
193
+ order = {
194
+ ResourceKind.CERTIFICATE: 0,
195
+ ResourceKind.ACCESS_LIST: 1,
196
+ ResourceKind.REDIRECTION_HOST: 2,
197
+ ResourceKind.DEAD_HOST: 2,
198
+ ResourceKind.STREAM: 2,
199
+ ResourceKind.USER: 2,
200
+ ResourceKind.SETTING: 2,
201
+ ResourceKind.PROXY_HOST: 3,
202
+ }
203
+ delete_order = {
204
+ ResourceKind.PROXY_HOST: 0,
205
+ ResourceKind.REDIRECTION_HOST: 1,
206
+ ResourceKind.DEAD_HOST: 1,
207
+ ResourceKind.STREAM: 1,
208
+ ResourceKind.USER: 1,
209
+ ResourceKind.SETTING: 1,
210
+ ResourceKind.ACCESS_LIST: 2,
211
+ ResourceKind.CERTIFICATE: 3,
212
+ }
213
+ return sorted(creates_updates_adopts, key=lambda op: order[op.kind]) + sorted(
214
+ deletes, key=lambda op: delete_order[op.kind]
215
+ )
216
+
217
+
218
+ def _require_desired(
219
+ operation: PlanOperation,
220
+ ) -> DesiredProxyHost | DesiredCertificate | DesiredAccessList | DesiredGenericResource:
221
+ if operation.desired is None:
222
+ raise ValidationError(f"operation {operation.action} requires desired resource")
223
+ return operation.desired
224
+
225
+
226
+ def _require_existing(operation: PlanOperation) -> ExistingResource:
227
+ if operation.existing is None:
228
+ raise ValidationError(f"operation {operation.action} requires existing resource")
229
+ return operation.existing
230
+
231
+
232
+ def _updateable_existing_payload(existing: ExistingResource) -> dict[str, Any]:
233
+ fields = {
234
+ ResourceKind.PROXY_HOST: (
235
+ "domain_names",
236
+ "forward_scheme",
237
+ "forward_host",
238
+ "forward_port",
239
+ "certificate_id",
240
+ "ssl_forced",
241
+ "hsts_enabled",
242
+ "hsts_subdomains",
243
+ "http2_support",
244
+ "block_exploits",
245
+ "caching_enabled",
246
+ "allow_websocket_upgrade",
247
+ "access_list_id",
248
+ "advanced_config",
249
+ "enabled",
250
+ "locations",
251
+ "meta",
252
+ ),
253
+ ResourceKind.ACCESS_LIST: ("name", "satisfy_any", "pass_auth", "items", "clients", "meta"),
254
+ ResourceKind.CERTIFICATE: ("provider", "nice_name", "domain_names", "meta"),
255
+ ResourceKind.REDIRECTION_HOST: ("domain_names", "forward_domain_name", "meta"),
256
+ ResourceKind.DEAD_HOST: ("domain_names", "meta"),
257
+ ResourceKind.STREAM: ("incoming_port", "forward_host", "forward_port", "protocol", "meta"),
258
+ ResourceKind.USER: ("name", "email", "roles", "is_disabled", "meta"),
259
+ ResourceKind.SETTING: ("name", "value", "meta"),
260
+ }[existing.kind]
261
+ payload = {field: existing.raw[field] for field in fields if field in existing.raw}
262
+ if existing.kind == ResourceKind.PROXY_HOST:
263
+ defaults = {
264
+ "access_list_id": 0,
265
+ "certificate_id": 0,
266
+ "ssl_forced": 0,
267
+ "hsts_enabled": 0,
268
+ "hsts_subdomains": 0,
269
+ "http2_support": 0,
270
+ "block_exploits": 0,
271
+ "caching_enabled": 0,
272
+ "allow_websocket_upgrade": 0,
273
+ "advanced_config": "",
274
+ "enabled": 1,
275
+ "locations": [],
276
+ }
277
+ for field, default in defaults.items():
278
+ if payload.get(field) is None:
279
+ payload[field] = default
280
+ return payload
@@ -0,0 +1,24 @@
1
+ """Public immutable artifact API."""
2
+
3
+ from wyrmctl.artifacts.codec import artifact_digest, read_artifact, write_artifact
4
+ from wyrmctl.artifacts.execution import plan_from_artifact
5
+ from wyrmctl.artifacts.models import ApplyReport, ArtifactSignature, LiveStateSnapshot, PlanArtifact
6
+ from wyrmctl.artifacts.plan import build_plan_artifact, validate_plan_binding
7
+ from wyrmctl.artifacts.redaction import redact_artifact
8
+ from wyrmctl.artifacts.signing import sign_plan, verify_plan
9
+
10
+ __all__ = [
11
+ "ApplyReport",
12
+ "ArtifactSignature",
13
+ "LiveStateSnapshot",
14
+ "PlanArtifact",
15
+ "artifact_digest",
16
+ "build_plan_artifact",
17
+ "plan_from_artifact",
18
+ "read_artifact",
19
+ "redact_artifact",
20
+ "sign_plan",
21
+ "validate_plan_binding",
22
+ "verify_plan",
23
+ "write_artifact",
24
+ ]
@@ -0,0 +1,54 @@
1
+ """Versioned artifact encoding with atomic writes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import yaml
12
+
13
+ from wyrmctl.contracts import BUILTIN_CONTRACTS, check_document, semantic_digest
14
+ from wyrmctl.errors import ArtifactError
15
+
16
+
17
+ def read_artifact(path: str | Path, *, strict: bool = False) -> dict[str, Any]:
18
+ source = Path(path)
19
+ try:
20
+ text = source.read_text(encoding="utf-8")
21
+ value = json.loads(text) if source.suffix.lower() == ".json" else yaml.safe_load(text)
22
+ except (OSError, json.JSONDecodeError, yaml.YAMLError) as exc:
23
+ raise ArtifactError("ARTIFACT_READ_FAILED", f"failed to read artifact {source}: {exc}") from exc
24
+ if not isinstance(value, dict):
25
+ raise ArtifactError("INVALID_ARTIFACT", "artifact must contain an object")
26
+ check_document(value, BUILTIN_CONTRACTS, strict=strict)
27
+ return value
28
+
29
+
30
+ def write_artifact(path: str | Path, value: dict[str, Any]) -> Path:
31
+ check_document(value, BUILTIN_CONTRACTS)
32
+ target = Path(path)
33
+ target.parent.mkdir(parents=True, exist_ok=True)
34
+ text = (
35
+ json.dumps(value, indent=2, sort_keys=True) + "\n"
36
+ if target.suffix.lower() == ".json"
37
+ else yaml.safe_dump(value, sort_keys=False)
38
+ )
39
+ handle, temp_name = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent)
40
+ try:
41
+ with os.fdopen(handle, "w", encoding="utf-8", newline="\n") as stream:
42
+ stream.write(text)
43
+ stream.flush()
44
+ os.fsync(stream.fileno())
45
+ os.replace(temp_name, target)
46
+ except BaseException:
47
+ Path(temp_name).unlink(missing_ok=True)
48
+ raise
49
+ return target
50
+
51
+
52
+ def artifact_digest(value: dict[str, Any]) -> str:
53
+ unsigned = {key: item for key, item in value.items() if key != "signature"}
54
+ return semantic_digest(unsigned)
@@ -0,0 +1,105 @@
1
+ """Rehydrate an exact reviewed PlanArtifact for execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from wyrmctl.errors import ArtifactError
8
+ from wyrmctl.models import (
9
+ DesiredAccessList,
10
+ DesiredCertificate,
11
+ DesiredGenericResource,
12
+ DesiredProxyHost,
13
+ ExistingResource,
14
+ PlanAction,
15
+ ResourceKind,
16
+ )
17
+ from wyrmctl.planner import Plan, PlanConflict, PlanOperation
18
+
19
+
20
+ def plan_from_artifact(document: dict[str, Any]) -> Plan:
21
+ if document.get("kind") != "PlanArtifact" or document.get("schemaVersion") != 1:
22
+ raise ArtifactError("INVALID_PLAN_ARTIFACT", "apply requires a PlanArtifact schema 1 document")
23
+ spec = document.get("spec")
24
+ if not isinstance(spec, dict):
25
+ raise ArtifactError("INVALID_PLAN_ARTIFACT", "plan artifact spec must be an object")
26
+ operations = tuple(_operation(item, index) for index, item in enumerate(_items(spec, "operations")))
27
+ conflicts = tuple(_conflict(item, index) for index, item in enumerate(_items(spec, "conflicts")))
28
+ return Plan(operations, conflicts, sum(operation.existing is not None for operation in operations))
29
+
30
+
31
+ def _operation(value: Any, index: int) -> PlanOperation:
32
+ item = _mapping(value, f"operations[{index}]")
33
+ try:
34
+ action = PlanAction(str(item["action"]))
35
+ kind = ResourceKind(str(item["kind"]))
36
+ except (KeyError, ValueError) as exc:
37
+ raise ArtifactError("INVALID_PLAN_OPERATION", f"invalid operation at index {index}") from exc
38
+ if action in {PlanAction.ADOPT, PlanAction.DELETE}:
39
+ raise ArtifactError(
40
+ "MIGRATION_REQUIRED",
41
+ f"{action.value} requires an NpmctlMigration artifact, not an ordinary PlanArtifact",
42
+ )
43
+ return PlanOperation(
44
+ action=action,
45
+ kind=kind,
46
+ desired=_desired(kind, item.get("desired"), index),
47
+ existing=_existing(kind, item.get("existing"), index),
48
+ reason=str(item.get("reason", "")),
49
+ diff=_mapping(item.get("diff", {}), f"operations[{index}].diff"),
50
+ )
51
+
52
+
53
+ def _desired(kind: ResourceKind, value: Any, index: int) -> Any:
54
+ if value is None:
55
+ return None
56
+ item = _mapping(value, f"operations[{index}].desired")
57
+ path = f"artifact.operations[{index}].desired"
58
+ if kind == ResourceKind.PROXY_HOST:
59
+ return DesiredProxyHost.from_mapping(item, path=path)
60
+ if kind == ResourceKind.CERTIFICATE:
61
+ return DesiredCertificate.from_mapping(item, path=path)
62
+ if kind == ResourceKind.ACCESS_LIST:
63
+ return DesiredAccessList.from_mapping(item, path=path)
64
+ return DesiredGenericResource.from_mapping(kind, item, path=path)
65
+
66
+
67
+ def _existing(kind: ResourceKind, value: Any, index: int) -> ExistingResource | None:
68
+ if value is None:
69
+ return None
70
+ item = _mapping(value, f"operations[{index}].existing")
71
+ raw = _mapping(item.get("raw"), f"operations[{index}].existing.raw")
72
+ factories = {
73
+ ResourceKind.PROXY_HOST: ExistingResource.from_proxy_host,
74
+ ResourceKind.CERTIFICATE: ExistingResource.from_certificate,
75
+ ResourceKind.ACCESS_LIST: ExistingResource.from_access_list,
76
+ }
77
+ factory = factories.get(kind)
78
+ return factory(raw) if factory else ExistingResource.from_generic(kind, raw)
79
+
80
+
81
+ def _conflict(value: Any, index: int) -> PlanConflict:
82
+ item = _mapping(value, f"conflicts[{index}]")
83
+ raw_kind = item.get("kind")
84
+ return PlanConflict(
85
+ code=str(item.get("code", "artifact_conflict")),
86
+ message=str(item.get("message", "artifact contains a conflict")),
87
+ kind=ResourceKind(str(raw_kind)) if raw_kind else None,
88
+ owner=item.get("owner"),
89
+ resource_id=item.get("resource_id"),
90
+ existing_id=item.get("existing_id"),
91
+ domain=item.get("domain"),
92
+ )
93
+
94
+
95
+ def _items(spec: dict[str, Any], key: str) -> list[Any]:
96
+ value = spec.get(key, [])
97
+ if not isinstance(value, list):
98
+ raise ArtifactError("INVALID_PLAN_ARTIFACT", f"plan artifact spec.{key} must be an array")
99
+ return value
100
+
101
+
102
+ def _mapping(value: Any, path: str) -> dict[str, Any]:
103
+ if not isinstance(value, dict):
104
+ raise ArtifactError("INVALID_PLAN_ARTIFACT", f"{path} must be an object")
105
+ return dict(value)
@@ -0,0 +1,126 @@
1
+ """Immutable execution artifact models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime, timezone
7
+ from typing import Any
8
+
9
+ from wyrmctl.contracts import semantic_digest
10
+ from wyrmctl.profile import current_profile
11
+
12
+
13
+ def utc_now() -> str:
14
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class ArtifactSignature:
19
+ algorithm: str
20
+ key_id: str
21
+ value: str
22
+ signed_at: str
23
+
24
+ def to_dict(self) -> dict[str, str]:
25
+ return {"algorithm": self.algorithm, "keyId": self.key_id, "value": self.value, "signedAt": self.signed_at}
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class PlanArtifact:
30
+ artifact_id: str
31
+ repository: str
32
+ environment: str
33
+ commit: str
34
+ desired_state_digest: str
35
+ live_state_fingerprint: str
36
+ operations: tuple[dict[str, Any], ...]
37
+ conflicts: tuple[dict[str, Any], ...] = ()
38
+ provider_capabilities: dict[str, int] = field(default_factory=dict)
39
+ api_profiles: dict[str, str] = field(default_factory=dict)
40
+ created_at: str = field(default_factory=utc_now)
41
+ expires_at: str | None = None
42
+ signature: ArtifactSignature | None = None
43
+
44
+ def unsigned_dict(self) -> dict[str, Any]:
45
+ return {
46
+ "apiVersion": current_profile().api_version,
47
+ "kind": "PlanArtifact",
48
+ "schemaVersion": 1,
49
+ "metadata": {
50
+ "id": self.artifact_id,
51
+ "repository": self.repository,
52
+ "environment": self.environment,
53
+ "createdAt": self.created_at,
54
+ "expiresAt": self.expires_at,
55
+ },
56
+ "spec": {
57
+ "source": {"commit": self.commit},
58
+ "inputs": {
59
+ "desiredStateDigest": self.desired_state_digest,
60
+ "liveStateFingerprint": self.live_state_fingerprint,
61
+ },
62
+ "providerCapabilities": self.provider_capabilities,
63
+ "apiProfiles": self.api_profiles,
64
+ "operations": list(self.operations),
65
+ "conflicts": list(self.conflicts),
66
+ },
67
+ }
68
+
69
+ @property
70
+ def digest(self) -> str:
71
+ return semantic_digest(self.unsigned_dict())
72
+
73
+ def to_dict(self) -> dict[str, Any]:
74
+ payload = self.unsigned_dict()
75
+ if self.signature is not None:
76
+ payload["signature"] = self.signature.to_dict()
77
+ return payload
78
+
79
+
80
+ @dataclass(frozen=True, slots=True)
81
+ class LiveStateSnapshot:
82
+ repository: str
83
+ environment: str
84
+ resources: tuple[dict[str, Any], ...]
85
+ observed_at: str = field(default_factory=utc_now)
86
+
87
+ def to_dict(self) -> dict[str, Any]:
88
+ return {
89
+ "apiVersion": current_profile().api_version,
90
+ "kind": "LiveStateSnapshot",
91
+ "schemaVersion": 1,
92
+ "metadata": {
93
+ "repository": self.repository,
94
+ "environment": self.environment,
95
+ "observedAt": self.observed_at,
96
+ },
97
+ "spec": {"resources": list(self.resources)},
98
+ }
99
+
100
+ @property
101
+ def fingerprint(self) -> str:
102
+ return semantic_digest(self.to_dict()["spec"])
103
+
104
+
105
+ @dataclass(frozen=True, slots=True)
106
+ class ApplyReport:
107
+ plan_id: str
108
+ plan_digest: str
109
+ status: str
110
+ results: tuple[dict[str, Any], ...]
111
+ verification: dict[str, Any]
112
+ completed_at: str = field(default_factory=utc_now)
113
+
114
+ def to_dict(self) -> dict[str, Any]:
115
+ return {
116
+ "apiVersion": current_profile().api_version,
117
+ "kind": "ApplyReport",
118
+ "schemaVersion": 1,
119
+ "metadata": {"planId": self.plan_id, "completedAt": self.completed_at},
120
+ "spec": {
121
+ "planArtifactDigest": self.plan_digest,
122
+ "status": self.status,
123
+ "results": list(self.results),
124
+ "verification": self.verification,
125
+ },
126
+ }