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.
- wyrmctl/__init__.py +3 -0
- wyrmctl/__main__.py +5 -0
- wyrmctl/adoption.py +5 -0
- wyrmctl/apply.py +280 -0
- wyrmctl/artifacts/__init__.py +24 -0
- wyrmctl/artifacts/codec.py +54 -0
- wyrmctl/artifacts/execution.py +105 -0
- wyrmctl/artifacts/models.py +126 -0
- wyrmctl/artifacts/plan.py +85 -0
- wyrmctl/artifacts/redaction.py +22 -0
- wyrmctl/artifacts/retention.py +28 -0
- wyrmctl/artifacts/signing.py +33 -0
- wyrmctl/cli.py +798 -0
- wyrmctl/client/__init__.py +5 -0
- wyrmctl/client/access_lists.py +10 -0
- wyrmctl/client/auth.py +5 -0
- wyrmctl/client/base.py +308 -0
- wyrmctl/client/certificates.py +10 -0
- wyrmctl/client/contracts.py +30 -0
- wyrmctl/client/proxy_hosts.py +10 -0
- wyrmctl/commands/__init__.py +15 -0
- wyrmctl/commands/artifacts.py +25 -0
- wyrmctl/commands/contracts.py +23 -0
- wyrmctl/commands/import_live.py +25 -0
- wyrmctl/commands/lock.py +33 -0
- wyrmctl/commands/repository.py +35 -0
- wyrmctl/config.py +45 -0
- wyrmctl/contracts/__init__.py +20 -0
- wyrmctl/contracts/builtin.py +33 -0
- wyrmctl/contracts/canonical.py +43 -0
- wyrmctl/contracts/compatibility.py +24 -0
- wyrmctl/contracts/registry.py +63 -0
- wyrmctl/contracts/types.py +62 -0
- wyrmctl/diagnostics.py +41 -0
- wyrmctl/dns.py +347 -0
- wyrmctl/errors.py +95 -0
- wyrmctl/issuance.py +138 -0
- wyrmctl/loader.py +340 -0
- wyrmctl/lockfile.py +73 -0
- wyrmctl/logging.py +13 -0
- wyrmctl/metadata.py +79 -0
- wyrmctl/migrations/__init__.py +22 -0
- wyrmctl/migrations/base.py +18 -0
- wyrmctl/migrations/builtin/__init__.py +41 -0
- wyrmctl/migrations/builtin/desired_state_v1_v2.py +29 -0
- wyrmctl/migrations/builtin/desired_state_v2_v3.py +61 -0
- wyrmctl/migrations/builtin/repository_v0_v1.py +18 -0
- wyrmctl/migrations/executor.py +47 -0
- wyrmctl/migrations/graph.py +71 -0
- wyrmctl/migrations/import_live.py +43 -0
- wyrmctl/migrations/leases.py +77 -0
- wyrmctl/migrations/ledger.py +71 -0
- wyrmctl/migrations/manifest.py +70 -0
- wyrmctl/migrations/planner.py +59 -0
- wyrmctl/migrations/recovery.py +31 -0
- wyrmctl/migrations/registry.py +88 -0
- wyrmctl/migrations/transaction.py +64 -0
- wyrmctl/migrations/v1.py +10 -0
- wyrmctl/migrations/v2.py +11 -0
- wyrmctl/models.py +869 -0
- wyrmctl/operational.py +256 -0
- wyrmctl/output.py +75 -0
- wyrmctl/planner.py +563 -0
- wyrmctl/plugins.py +173 -0
- wyrmctl/profile.py +64 -0
- wyrmctl/providers.py +77 -0
- wyrmctl/py.typed +0 -0
- wyrmctl/repository.py +130 -0
- wyrmctl/schema.py +178 -0
- wyrmctl/validation.py +5 -0
- wyrmctl-0.4.0.dist-info/METADATA +247 -0
- wyrmctl-0.4.0.dist-info/RECORD +75 -0
- wyrmctl-0.4.0.dist-info/WHEEL +4 -0
- wyrmctl-0.4.0.dist-info/entry_points.txt +3 -0
- wyrmctl-0.4.0.dist-info/licenses/LICENSE +183 -0
wyrmctl/__init__.py
ADDED
wyrmctl/__main__.py
ADDED
wyrmctl/adoption.py
ADDED
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
|
+
}
|