f-layer 2.0.1__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.
- f_layer-2.0.1.dist-info/METADATA +232 -0
- f_layer-2.0.1.dist-info/RECORD +45 -0
- f_layer-2.0.1.dist-info/WHEEL +4 -0
- f_layer-2.0.1.dist-info/entry_points.txt +2 -0
- f_layer-2.0.1.dist-info/licenses/LICENSE +201 -0
- flayer/__init__.py +5 -0
- flayer/__main__.py +247 -0
- flayer/_vpn_guest.py +130 -0
- flayer/core/__init__.py +1 -0
- flayer/core/config.py +198 -0
- flayer/core/contracts.py +114 -0
- flayer/core/lifecycle.py +767 -0
- flayer/core/state.py +351 -0
- flayer/diagnostics/__init__.py +20 -0
- flayer/diagnostics/benchmark.py +272 -0
- flayer/diagnostics/contracts.py +167 -0
- flayer/diagnostics/health.py +154 -0
- flayer/diagnostics/reporting.py +24 -0
- flayer/extensions/__init__.py +25 -0
- flayer/extensions/builtins.py +165 -0
- flayer/extensions/contracts.py +196 -0
- flayer/extensions/discovery.py +152 -0
- flayer/extensions/registry.py +347 -0
- flayer/guest.py +270 -0
- flayer/integrations/__init__.py +23 -0
- flayer/integrations/adapters.py +92 -0
- flayer/integrations/contracts.py +241 -0
- flayer/integrations/wire.py +273 -0
- flayer/profiles/__init__.py +33 -0
- flayer/profiles/_amnezia_export.py +176 -0
- flayer/profiles/_vless_guest.py +432 -0
- flayer/profiles/amneziawg.py +555 -0
- flayer/profiles/artifacts.py +609 -0
- flayer/profiles/gateway.py +729 -0
- flayer/profiles/static_site.py +596 -0
- flayer/profiles/vless.py +422 -0
- flayer/profiles/vpn.py +524 -0
- flayer/providers/__init__.py +1 -0
- flayer/providers/contracts.py +207 -0
- flayer/providers/lifecycle.py +177 -0
- flayer/providers/yandex.py +387 -0
- flayer/providers/yandex_lifecycle.py +781 -0
- flayer/py.typed +0 -0
- flayer/vpn_export.py +72 -0
- flayer/vpn_project.py +828 -0
flayer/core/lifecycle.py
ADDED
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
"""Owned deployment orchestration with durable progress and fail-closed recovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import tempfile
|
|
10
|
+
import tomllib
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from dataclasses import asdict, dataclass, replace
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import cast
|
|
16
|
+
from uuid import uuid4
|
|
17
|
+
|
|
18
|
+
from flayer.providers.contracts import (
|
|
19
|
+
ProviderError,
|
|
20
|
+
ProviderErrorCode,
|
|
21
|
+
ProviderResource,
|
|
22
|
+
ResourceKind,
|
|
23
|
+
ResourceReference,
|
|
24
|
+
)
|
|
25
|
+
from flayer.providers.lifecycle import JsonValue, LifecycleProvider, ResourceSpec
|
|
26
|
+
|
|
27
|
+
from .contracts import (
|
|
28
|
+
ContractError,
|
|
29
|
+
ParseIdentity,
|
|
30
|
+
RequireTable,
|
|
31
|
+
StackIdentity,
|
|
32
|
+
ValidateFields,
|
|
33
|
+
ValidateName,
|
|
34
|
+
ValidateSchemaVersion,
|
|
35
|
+
)
|
|
36
|
+
from .state import (
|
|
37
|
+
LoadState,
|
|
38
|
+
ParseState,
|
|
39
|
+
ResourceState,
|
|
40
|
+
SaveState,
|
|
41
|
+
StackState,
|
|
42
|
+
_OpenTextStream,
|
|
43
|
+
_ReadOwnedJson,
|
|
44
|
+
_SyncDirectory,
|
|
45
|
+
_ValidatePath,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
MAX_PLAN_RESOURCES = 128
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class LifecycleError(ContractError):
|
|
52
|
+
"""An operation is blocked or requires explicit interrupted-operation recovery."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def CoreKind(kind: ResourceKind) -> str:
|
|
56
|
+
"""Bridge provider enum spelling to the portable hyphenated state contract."""
|
|
57
|
+
|
|
58
|
+
return kind.value.replace("_", "-")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def ProviderKind(kind: str) -> ResourceKind:
|
|
62
|
+
"""Accept only an explicitly recognized portable lifecycle resource kind."""
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
return ResourceKind(kind.replace("-", "_"))
|
|
66
|
+
|
|
67
|
+
except ValueError:
|
|
68
|
+
raise LifecycleError("Lifecycle resource kind is unsupported") from None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class DeploymentPlan:
|
|
73
|
+
"""Immutable desired resources with a deterministic dependency order."""
|
|
74
|
+
|
|
75
|
+
identity: StackIdentity
|
|
76
|
+
resources: tuple[ResourceSpec, ...]
|
|
77
|
+
|
|
78
|
+
def __post_init__(self) -> None:
|
|
79
|
+
"""Reject malformed plans, duplicates, unknown dependencies, and cycles offline."""
|
|
80
|
+
|
|
81
|
+
if not isinstance(self.identity, StackIdentity) or not isinstance(self.resources, tuple):
|
|
82
|
+
raise LifecycleError("Plan requires an immutable identity and resource tuple")
|
|
83
|
+
|
|
84
|
+
if len(self.resources) > MAX_PLAN_RESOURCES:
|
|
85
|
+
raise LifecycleError("Plan resource count exceeds the supported bound")
|
|
86
|
+
|
|
87
|
+
if any(not isinstance(resource, ResourceSpec) for resource in self.resources):
|
|
88
|
+
raise LifecycleError("Plan resources must be validated ResourceSpec values")
|
|
89
|
+
|
|
90
|
+
identifiers = {resource.logical_id for resource in self.resources}
|
|
91
|
+
|
|
92
|
+
if len(identifiers) != len(self.resources):
|
|
93
|
+
raise LifecycleError("Plan logical identifiers must be unique")
|
|
94
|
+
|
|
95
|
+
for resource in self.resources:
|
|
96
|
+
if set(resource.dependencies) - identifiers:
|
|
97
|
+
raise LifecycleError("Plan contains an unknown dependency")
|
|
98
|
+
|
|
99
|
+
self.OrderedResources()
|
|
100
|
+
|
|
101
|
+
def OrderedResources(self) -> tuple[ResourceSpec, ...]:
|
|
102
|
+
"""Return a stable topological ordering or reject cyclic dependencies."""
|
|
103
|
+
|
|
104
|
+
remaining = list(self.resources)
|
|
105
|
+
result: list[ResourceSpec] = []
|
|
106
|
+
complete: set[str] = set()
|
|
107
|
+
|
|
108
|
+
while remaining:
|
|
109
|
+
ready = [item for item in remaining if set(item.dependencies) <= complete]
|
|
110
|
+
|
|
111
|
+
if not ready:
|
|
112
|
+
raise LifecycleError("Plan contains a dependency cycle")
|
|
113
|
+
|
|
114
|
+
for resource in ready:
|
|
115
|
+
result.append(resource)
|
|
116
|
+
complete.add(resource.logical_id)
|
|
117
|
+
remaining.remove(resource)
|
|
118
|
+
|
|
119
|
+
return tuple(result)
|
|
120
|
+
|
|
121
|
+
def Fingerprint(self) -> str:
|
|
122
|
+
"""Bind recovery to exact intent without persisting desired parameters in state."""
|
|
123
|
+
|
|
124
|
+
payload = json.dumps(asdict(self), sort_keys=True, separators=(",", ":"))
|
|
125
|
+
|
|
126
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _FreezeValue(value: object) -> JsonValue:
|
|
130
|
+
"""Convert TOML collections into immutable provider parameters without coercion."""
|
|
131
|
+
|
|
132
|
+
if isinstance(value, dict):
|
|
133
|
+
return tuple((key, _FreezeValue(item)) for key, item in sorted(value.items()))
|
|
134
|
+
|
|
135
|
+
if isinstance(value, list):
|
|
136
|
+
return tuple(_FreezeValue(item) for item in value)
|
|
137
|
+
|
|
138
|
+
if value is not None and type(value) not in {str, int, bool}:
|
|
139
|
+
raise LifecycleError("Plan parameters require immutable JSON-compatible values")
|
|
140
|
+
|
|
141
|
+
return cast(JsonValue, value)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def LoadDeploymentPlan(path: str | Path) -> DeploymentPlan:
|
|
145
|
+
"""Load explicit version-one lifecycle TOML without resolving credential material."""
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
with Path(path).open("rb") as stream:
|
|
149
|
+
data = tomllib.load(stream)
|
|
150
|
+
|
|
151
|
+
ValidateFields(
|
|
152
|
+
data,
|
|
153
|
+
frozenset({"schema_version", "identity", "resources"}),
|
|
154
|
+
frozenset({"profile"}),
|
|
155
|
+
"deployment plan",
|
|
156
|
+
)
|
|
157
|
+
ValidateSchemaVersion(data["schema_version"])
|
|
158
|
+
|
|
159
|
+
if "profile" in data:
|
|
160
|
+
ValidateName(data["profile"], "profile")
|
|
161
|
+
|
|
162
|
+
raw_resources = data["resources"]
|
|
163
|
+
|
|
164
|
+
if not isinstance(raw_resources, list):
|
|
165
|
+
raise LifecycleError("Plan resources must be an array of tables")
|
|
166
|
+
|
|
167
|
+
resources: list[ResourceSpec] = []
|
|
168
|
+
|
|
169
|
+
for item in raw_resources:
|
|
170
|
+
table = RequireTable(item, "resources")
|
|
171
|
+
ValidateFields(
|
|
172
|
+
table,
|
|
173
|
+
frozenset({"logical_id", "kind", "name"}),
|
|
174
|
+
frozenset({"parameters", "dependencies"}),
|
|
175
|
+
"resources",
|
|
176
|
+
)
|
|
177
|
+
parameters = RequireTable(table.get("parameters", {}), "parameters")
|
|
178
|
+
dependencies = table.get("dependencies", [])
|
|
179
|
+
|
|
180
|
+
if not isinstance(dependencies, list) or any(
|
|
181
|
+
not isinstance(value, str) for value in dependencies
|
|
182
|
+
):
|
|
183
|
+
raise LifecycleError("Dependencies must be an array of logical identifiers")
|
|
184
|
+
|
|
185
|
+
kind = ValidateName(table["kind"], "resources.kind")
|
|
186
|
+
resources.append(ResourceSpec(
|
|
187
|
+
logical_id=ValidateName(table["logical_id"], "resources.logical_id"),
|
|
188
|
+
kind=ProviderKind(kind),
|
|
189
|
+
name=ValidateName(table["name"], "resources.name"),
|
|
190
|
+
parameters=tuple((key, _FreezeValue(value)) for key, value in parameters.items()),
|
|
191
|
+
dependencies=tuple(dependencies),
|
|
192
|
+
))
|
|
193
|
+
|
|
194
|
+
return DeploymentPlan(ParseIdentity(data["identity"]), tuple(resources))
|
|
195
|
+
|
|
196
|
+
except (OSError, ValueError, RecursionError):
|
|
197
|
+
raise LifecycleError("Unable to load a valid lifecycle plan") from None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass(frozen=True)
|
|
201
|
+
class LifecycleObservation:
|
|
202
|
+
"""Sanitized local ownership and remote existence summary for one logical resource."""
|
|
203
|
+
|
|
204
|
+
logical_id: str
|
|
205
|
+
status: str
|
|
206
|
+
resource_id: str | None = None
|
|
207
|
+
provider_status: str | None = None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclass(frozen=True)
|
|
211
|
+
class LifecycleReport:
|
|
212
|
+
"""Truthful operation outcome without raw provider commands, errors, or parameters."""
|
|
213
|
+
|
|
214
|
+
action: str
|
|
215
|
+
status: str
|
|
216
|
+
resources: tuple[LifecycleObservation, ...] = ()
|
|
217
|
+
recovery_required: bool = False
|
|
218
|
+
|
|
219
|
+
def ExitCode(self) -> int:
|
|
220
|
+
"""Report nonzero whenever an operation failed or is awaiting recovery."""
|
|
221
|
+
|
|
222
|
+
return 0 if self.status == "complete" else 1
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@dataclass(frozen=True)
|
|
226
|
+
class _Journal:
|
|
227
|
+
"""Write-ahead mutation intent and the resource set eligible for safe rollback."""
|
|
228
|
+
|
|
229
|
+
identity: StackIdentity
|
|
230
|
+
fingerprint: str
|
|
231
|
+
action: str
|
|
232
|
+
original: tuple[ResourceState, ...]
|
|
233
|
+
created: tuple[ResourceState, ...] = ()
|
|
234
|
+
pending: str | None = None
|
|
235
|
+
operation_id: str = ""
|
|
236
|
+
schema_version: int = 1
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _JournalPath(path: Path) -> Path:
|
|
240
|
+
"""Keep recovery progress beside its caller-selected owned snapshot."""
|
|
241
|
+
|
|
242
|
+
return path.with_name(f".{path.name}.operation.json")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _LoadJournal(path: Path, plan: DeploymentPlan) -> _Journal | None:
|
|
246
|
+
"""Read a strict journal and block recovery for foreign or changed intent."""
|
|
247
|
+
|
|
248
|
+
journal_path = _JournalPath(path)
|
|
249
|
+
_ValidatePath(journal_path)
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
data = RequireTable(_ReadOwnedJson(journal_path), "journal")
|
|
253
|
+
|
|
254
|
+
ValidateFields(data, frozenset({
|
|
255
|
+
"schema_version", "identity", "fingerprint", "action", "original", "created", "pending",
|
|
256
|
+
"operation_id",
|
|
257
|
+
}), frozenset(), "journal")
|
|
258
|
+
ValidateSchemaVersion(data["schema_version"])
|
|
259
|
+
identity = ParseIdentity(data["identity"])
|
|
260
|
+
|
|
261
|
+
if identity != plan.identity or data["fingerprint"] != plan.Fingerprint():
|
|
262
|
+
raise LifecycleError("Journal ownership or desired plan does not match")
|
|
263
|
+
|
|
264
|
+
if not isinstance(data["action"], str) or data["action"] not in {"create", "rollback", "destroy"}:
|
|
265
|
+
raise LifecycleError("Journal action is unsupported")
|
|
266
|
+
|
|
267
|
+
operation_id = data["operation_id"]
|
|
268
|
+
|
|
269
|
+
if not isinstance(operation_id, str) or re.fullmatch(r"[0-9a-f]{32}", operation_id) is None:
|
|
270
|
+
raise LifecycleError("Journal operation identity is invalid")
|
|
271
|
+
|
|
272
|
+
pending = data["pending"]
|
|
273
|
+
logical_ids = {resource.logical_id for resource in plan.resources}
|
|
274
|
+
|
|
275
|
+
if pending is not None and (not isinstance(pending, str) or pending not in logical_ids):
|
|
276
|
+
raise LifecycleError("Journal pending resource is invalid")
|
|
277
|
+
|
|
278
|
+
snapshots = [ParseState({
|
|
279
|
+
"schema_version": 1, "identity": asdict(identity), "resources": data[key],
|
|
280
|
+
}, identity).resources for key in ("original", "created")]
|
|
281
|
+
|
|
282
|
+
specs = {resource.logical_id: resource for resource in plan.resources}
|
|
283
|
+
|
|
284
|
+
if any(
|
|
285
|
+
resource.logical_id not in logical_ids
|
|
286
|
+
or resource.kind != CoreKind(specs[resource.logical_id].kind)
|
|
287
|
+
for items in snapshots for resource in items
|
|
288
|
+
):
|
|
289
|
+
raise LifecycleError("Journal includes resources outside the desired plan")
|
|
290
|
+
|
|
291
|
+
if {item.logical_id for item in snapshots[0]} & {item.logical_id for item in snapshots[1]}:
|
|
292
|
+
raise LifecycleError("Journal rollback resources overlap preexisting resources")
|
|
293
|
+
|
|
294
|
+
StackState(identity, (*snapshots[0], *snapshots[1]))
|
|
295
|
+
|
|
296
|
+
if data["action"] == "create" and pending in {
|
|
297
|
+
item.logical_id for items in snapshots for item in items
|
|
298
|
+
}:
|
|
299
|
+
raise LifecycleError("Pending creation must not overlap recorded operation resources")
|
|
300
|
+
|
|
301
|
+
if pending is not None and data["action"] in {"rollback", "destroy"}:
|
|
302
|
+
eligible = snapshots[1] if data["action"] == "rollback" else snapshots[0]
|
|
303
|
+
|
|
304
|
+
if pending not in {item.logical_id for item in eligible}:
|
|
305
|
+
raise LifecycleError("Journal pending removal is not eligible")
|
|
306
|
+
|
|
307
|
+
return _Journal(
|
|
308
|
+
identity, plan.Fingerprint(), data["action"], snapshots[0], snapshots[1],
|
|
309
|
+
pending, operation_id=operation_id,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
except FileNotFoundError:
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
except (OSError, ValueError, RecursionError):
|
|
316
|
+
raise LifecycleError("Unable to load a valid operation journal") from None
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _WriteJournal(path: Path, journal: _Journal) -> None:
|
|
320
|
+
"""Atomically persist write-ahead intent before an external mutation is permitted."""
|
|
321
|
+
|
|
322
|
+
journal_path = _JournalPath(path)
|
|
323
|
+
_ValidatePath(journal_path)
|
|
324
|
+
temporary_path: Path | None = None
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
328
|
+
prefix=f".{path.name}.journal.", suffix=".tmp", dir=path.parent
|
|
329
|
+
)
|
|
330
|
+
temporary_path = Path(temporary_name)
|
|
331
|
+
|
|
332
|
+
with _OpenTextStream(descriptor, "w") as stream:
|
|
333
|
+
json.dump(asdict(journal), stream, indent=2, sort_keys=True)
|
|
334
|
+
stream.write("\n")
|
|
335
|
+
stream.flush()
|
|
336
|
+
os.fsync(stream.fileno())
|
|
337
|
+
|
|
338
|
+
os.replace(temporary_path, journal_path)
|
|
339
|
+
temporary_path = None
|
|
340
|
+
_SyncDirectory(path.parent)
|
|
341
|
+
|
|
342
|
+
except OSError:
|
|
343
|
+
raise LifecycleError("Unable to persist operation intent; recovery may be required") from None
|
|
344
|
+
|
|
345
|
+
finally:
|
|
346
|
+
if temporary_path is not None:
|
|
347
|
+
try:
|
|
348
|
+
temporary_path.unlink(missing_ok=True)
|
|
349
|
+
|
|
350
|
+
except OSError:
|
|
351
|
+
raise LifecycleError("Unable to clean temporary operation intent") from None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _ClearJournal(path: Path) -> None:
|
|
355
|
+
"""Clear durable intent only after the resulting snapshot has been persisted."""
|
|
356
|
+
|
|
357
|
+
try:
|
|
358
|
+
_JournalPath(path).unlink(missing_ok=True)
|
|
359
|
+
_SyncDirectory(path.parent)
|
|
360
|
+
|
|
361
|
+
except OSError:
|
|
362
|
+
raise LifecycleError("Operation completed but its journal requires local recovery") from None
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@contextmanager
|
|
366
|
+
def _OperationLock(path: Path) -> Iterator[None]:
|
|
367
|
+
"""Serialize complete operations; hard-crash locks require explicit operator inspection."""
|
|
368
|
+
|
|
369
|
+
_ValidatePath(path)
|
|
370
|
+
lock_path = path.with_name(f".{path.name}.operation.lock")
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
374
|
+
descriptor = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
375
|
+
os.close(descriptor)
|
|
376
|
+
|
|
377
|
+
except OSError:
|
|
378
|
+
raise LifecycleError("Another operation holds the stack lock or storage is unavailable") from None
|
|
379
|
+
|
|
380
|
+
try:
|
|
381
|
+
yield
|
|
382
|
+
|
|
383
|
+
finally:
|
|
384
|
+
try:
|
|
385
|
+
lock_path.unlink()
|
|
386
|
+
|
|
387
|
+
except OSError:
|
|
388
|
+
raise LifecycleError("Unable to release the operation lock") from None
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class LifecycleEngine:
|
|
392
|
+
"""Compose a scoped provider with durable owned-state lifecycle semantics."""
|
|
393
|
+
|
|
394
|
+
def __init__(
|
|
395
|
+
self, plan: DeploymentPlan, provider: LifecycleProvider, state_path: str | Path
|
|
396
|
+
) -> None:
|
|
397
|
+
"""Reject a provider or path that cannot represent the exact desired scope."""
|
|
398
|
+
|
|
399
|
+
if not isinstance(plan, DeploymentPlan):
|
|
400
|
+
raise LifecycleError("Lifecycle requires a validated DeploymentPlan")
|
|
401
|
+
|
|
402
|
+
if (
|
|
403
|
+
provider.Identity.provider_id != plan.identity.provider
|
|
404
|
+
or provider.Identity.scope_id != plan.identity.scope_id
|
|
405
|
+
):
|
|
406
|
+
raise LifecycleError("Provider identity must match the deployment plan")
|
|
407
|
+
|
|
408
|
+
for spec in plan.resources:
|
|
409
|
+
provider.ValidateSpec(spec)
|
|
410
|
+
|
|
411
|
+
self.plan = plan
|
|
412
|
+
self.provider = provider
|
|
413
|
+
self.state_path = Path(state_path)
|
|
414
|
+
_ValidatePath(self.state_path)
|
|
415
|
+
self._specs = {resource.logical_id: resource for resource in plan.resources}
|
|
416
|
+
|
|
417
|
+
def _State(self) -> StackState:
|
|
418
|
+
"""Require exact ownership and reject state incompatible with the current plan."""
|
|
419
|
+
|
|
420
|
+
state = LoadState(self.state_path, self.plan.identity) or StackState(self.plan.identity)
|
|
421
|
+
|
|
422
|
+
for resource in state.resources:
|
|
423
|
+
spec = self._specs.get(resource.logical_id)
|
|
424
|
+
|
|
425
|
+
if spec is None or CoreKind(spec.kind) != resource.kind:
|
|
426
|
+
raise LifecycleError("State contains resources outside the deployment plan")
|
|
427
|
+
|
|
428
|
+
return state
|
|
429
|
+
|
|
430
|
+
def _Observe(self, spec: ResourceSpec) -> ProviderResource | None:
|
|
431
|
+
"""Independently enforce ownership, kind, and scope after adapter discovery."""
|
|
432
|
+
|
|
433
|
+
resource = self.provider.FindResource(spec, self.plan.identity)
|
|
434
|
+
|
|
435
|
+
if resource is None:
|
|
436
|
+
return None
|
|
437
|
+
|
|
438
|
+
expected = spec.OwnershipLabels(self.plan.identity)
|
|
439
|
+
reference = resource.reference
|
|
440
|
+
|
|
441
|
+
if (
|
|
442
|
+
reference.provider_id != self.plan.identity.provider
|
|
443
|
+
or reference.scope_id != self.plan.identity.scope_id
|
|
444
|
+
or reference.kind != spec.kind
|
|
445
|
+
or not resource.HasLabels(expected)
|
|
446
|
+
):
|
|
447
|
+
raise LifecycleError("Provider observation violates the ownership boundary")
|
|
448
|
+
|
|
449
|
+
return resource
|
|
450
|
+
|
|
451
|
+
def _Locator(self, spec: ResourceSpec, resource: ProviderResource) -> ResourceState:
|
|
452
|
+
"""Extract only identifiers from a verified scoped provider resource."""
|
|
453
|
+
|
|
454
|
+
return ResourceState(spec.logical_id, CoreKind(spec.kind), resource.reference.resource_id)
|
|
455
|
+
|
|
456
|
+
def _RequireLocator(self, locator: ResourceState) -> ProviderResource | None:
|
|
457
|
+
"""Block deletion if the owned logical resource resolves to a different identifier."""
|
|
458
|
+
|
|
459
|
+
reference = ResourceReference(
|
|
460
|
+
self.plan.identity.provider, self.plan.identity.scope_id,
|
|
461
|
+
ProviderKind(locator.kind), locator.resource_id,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
try:
|
|
465
|
+
resource = self.provider.GetResource(reference)
|
|
466
|
+
|
|
467
|
+
except ProviderError as error:
|
|
468
|
+
if error.code == ProviderErrorCode.NOT_FOUND:
|
|
469
|
+
return None
|
|
470
|
+
|
|
471
|
+
raise
|
|
472
|
+
|
|
473
|
+
expected = tuple(sorted({
|
|
474
|
+
**self.plan.identity.OwnershipLabels(), "flayer-resource": locator.logical_id,
|
|
475
|
+
}.items()))
|
|
476
|
+
|
|
477
|
+
if resource.reference != reference or not resource.HasLabels(expected):
|
|
478
|
+
raise LifecycleError("Exact resource lookup violates its persisted ownership boundary")
|
|
479
|
+
|
|
480
|
+
return resource
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _Save(self, resources: tuple[ResourceState, ...]) -> None:
|
|
484
|
+
"""Persist minimum owned state after every confirmed external resource transition."""
|
|
485
|
+
|
|
486
|
+
SaveState(self.state_path, StackState(self.plan.identity, resources), self.plan.identity)
|
|
487
|
+
|
|
488
|
+
def _Report(self, action: str, status: str, recovery: bool = False) -> LifecycleReport:
|
|
489
|
+
"""Return persisted progress even when the provider could not finish an operation."""
|
|
490
|
+
|
|
491
|
+
resources = tuple(LifecycleObservation(item.logical_id, "recorded", item.resource_id)
|
|
492
|
+
for item in self._State().resources)
|
|
493
|
+
|
|
494
|
+
return LifecycleReport(action, status, resources, recovery)
|
|
495
|
+
|
|
496
|
+
def Status(self) -> LifecycleReport:
|
|
497
|
+
"""Read exact owned remote existence without changing state or creating resources."""
|
|
498
|
+
|
|
499
|
+
with _OperationLock(self.state_path):
|
|
500
|
+
state = self._State()
|
|
501
|
+
journal = _LoadJournal(self.state_path, self.plan)
|
|
502
|
+
resources: list[LifecycleObservation] = []
|
|
503
|
+
|
|
504
|
+
for locator in state.resources:
|
|
505
|
+
observed = self._RequireLocator(locator)
|
|
506
|
+
resources.append(LifecycleObservation(
|
|
507
|
+
locator.logical_id, "present" if observed is not None else "missing",
|
|
508
|
+
locator.resource_id, observed.status if observed is not None else None,
|
|
509
|
+
))
|
|
510
|
+
|
|
511
|
+
missing = any(resource.status == "missing" for resource in resources)
|
|
512
|
+
resources.extend(LifecycleObservation(spec.logical_id, "not-recorded")
|
|
513
|
+
for spec in self.plan.resources
|
|
514
|
+
if spec.logical_id not in {item.logical_id for item in state.resources})
|
|
515
|
+
complete = not missing and len(state.resources) == len(self.plan.resources)
|
|
516
|
+
|
|
517
|
+
return LifecycleReport(
|
|
518
|
+
"status", "complete" if complete and journal is None else "incomplete",
|
|
519
|
+
tuple(resources), journal is not None,
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
def Create(self) -> LifecycleReport:
|
|
523
|
+
"""Create missing owned resources and roll back only this operation's creations."""
|
|
524
|
+
|
|
525
|
+
with _OperationLock(self.state_path):
|
|
526
|
+
state = self._State()
|
|
527
|
+
|
|
528
|
+
if _LoadJournal(self.state_path, self.plan) is not None:
|
|
529
|
+
raise LifecycleError("An interrupted operation requires explicit recovery")
|
|
530
|
+
|
|
531
|
+
preexisting: list[ResourceState] = []
|
|
532
|
+
observations: dict[str, ProviderResource] = {}
|
|
533
|
+
recorded = {resource.logical_id: resource for resource in state.resources}
|
|
534
|
+
|
|
535
|
+
for spec in self.plan.OrderedResources():
|
|
536
|
+
observed = self._Observe(spec)
|
|
537
|
+
|
|
538
|
+
if spec.logical_id in recorded and (
|
|
539
|
+
observed is None or observed.reference.resource_id != recorded[spec.logical_id].resource_id
|
|
540
|
+
):
|
|
541
|
+
raise LifecycleError("Recorded resource is missing or replaced")
|
|
542
|
+
|
|
543
|
+
if observed is not None:
|
|
544
|
+
observations[spec.logical_id] = observed
|
|
545
|
+
preexisting.append(self._Locator(spec, observed))
|
|
546
|
+
|
|
547
|
+
for spec in self.plan.resources:
|
|
548
|
+
if spec.logical_id in observations and not set(spec.dependencies) <= observations.keys():
|
|
549
|
+
raise LifecycleError("Preexisting resource depends on a missing resource")
|
|
550
|
+
|
|
551
|
+
journal = _Journal(
|
|
552
|
+
self.plan.identity, self.plan.Fingerprint(), "create", tuple(preexisting),
|
|
553
|
+
operation_id=uuid4().hex,
|
|
554
|
+
)
|
|
555
|
+
_WriteJournal(self.state_path, journal)
|
|
556
|
+
self._Save(tuple(preexisting))
|
|
557
|
+
|
|
558
|
+
return self._Create(journal)
|
|
559
|
+
|
|
560
|
+
def _Create(self, journal: _Journal) -> LifecycleReport:
|
|
561
|
+
"""Persist adoption and each successful creation in dependency order."""
|
|
562
|
+
|
|
563
|
+
state = self._State()
|
|
564
|
+
locators = {resource.logical_id: resource for resource in state.resources}
|
|
565
|
+
observed: dict[str, ProviderResource] = {}
|
|
566
|
+
|
|
567
|
+
for spec in self.plan.OrderedResources():
|
|
568
|
+
resource = self._Observe(spec)
|
|
569
|
+
|
|
570
|
+
if spec.logical_id in locators:
|
|
571
|
+
if resource is None or resource.reference.resource_id != locators[spec.logical_id].resource_id:
|
|
572
|
+
raise LifecycleError("Recorded resource is missing or replaced; explicit recovery is required")
|
|
573
|
+
|
|
574
|
+
observed[spec.logical_id] = resource
|
|
575
|
+
|
|
576
|
+
continue
|
|
577
|
+
|
|
578
|
+
if resource is not None:
|
|
579
|
+
if set(spec.dependencies) & {item.logical_id for item in journal.created}:
|
|
580
|
+
raise LifecycleError("A concurrent preexisting resource depends on a new creation")
|
|
581
|
+
|
|
582
|
+
locator = self._Locator(spec, resource)
|
|
583
|
+
locators[spec.logical_id] = locator
|
|
584
|
+
journal = replace(journal, original=(*journal.original, locator), pending=None)
|
|
585
|
+
_WriteJournal(self.state_path, journal)
|
|
586
|
+
self._Save(tuple(locators.values()))
|
|
587
|
+
observed[spec.logical_id] = resource
|
|
588
|
+
|
|
589
|
+
continue
|
|
590
|
+
|
|
591
|
+
journal = replace(journal, action="create", pending=spec.logical_id)
|
|
592
|
+
_WriteJournal(self.state_path, journal)
|
|
593
|
+
|
|
594
|
+
try:
|
|
595
|
+
self.provider.CreateResource(
|
|
596
|
+
spec, self.plan.identity,
|
|
597
|
+
{key: observed[key] for key in spec.dependencies},
|
|
598
|
+
journal.operation_id,
|
|
599
|
+
)
|
|
600
|
+
resource = self._Observe(spec)
|
|
601
|
+
|
|
602
|
+
if resource is None or dict(resource.labels).get("flayer-operation") != journal.operation_id:
|
|
603
|
+
return self._Report("create", "uncertain", True)
|
|
604
|
+
|
|
605
|
+
except ProviderError as error:
|
|
606
|
+
if getattr(error, "outcome_unknown", True):
|
|
607
|
+
return self._Report("create", "uncertain", True)
|
|
608
|
+
|
|
609
|
+
journal = replace(journal, action="rollback", pending=None)
|
|
610
|
+
_WriteJournal(self.state_path, journal)
|
|
611
|
+
|
|
612
|
+
return self._Rollback(journal)
|
|
613
|
+
|
|
614
|
+
locator = self._Locator(spec, resource)
|
|
615
|
+
journal = replace(journal, action="create", created=(*journal.created, locator), pending=None)
|
|
616
|
+
_WriteJournal(self.state_path, journal)
|
|
617
|
+
locators[spec.logical_id] = locator
|
|
618
|
+
self._Save(tuple(locators.values()))
|
|
619
|
+
observed[spec.logical_id] = resource
|
|
620
|
+
|
|
621
|
+
_ClearJournal(self.state_path)
|
|
622
|
+
|
|
623
|
+
return self._Report("create", "complete")
|
|
624
|
+
|
|
625
|
+
def _Delete(self, locator: ResourceState, operation_id: str | None = None) -> None:
|
|
626
|
+
"""Delete an exact verified owned locator, treating confirmed absence as success."""
|
|
627
|
+
|
|
628
|
+
observed = self._RequireLocator(locator)
|
|
629
|
+
|
|
630
|
+
if observed is None:
|
|
631
|
+
return
|
|
632
|
+
|
|
633
|
+
if operation_id is not None and dict(observed.labels).get("flayer-operation") != operation_id:
|
|
634
|
+
raise LifecycleError("Rollback resource belongs to a different creation operation")
|
|
635
|
+
|
|
636
|
+
reference = ResourceReference(
|
|
637
|
+
self.plan.identity.provider, self.plan.identity.scope_id,
|
|
638
|
+
ProviderKind(locator.kind), locator.resource_id,
|
|
639
|
+
)
|
|
640
|
+
self.provider.DeleteResource(reference, self.plan.identity, locator.logical_id, operation_id)
|
|
641
|
+
|
|
642
|
+
if self._RequireLocator(locator) is not None:
|
|
643
|
+
raise ProviderError(ProviderErrorCode.CONFLICT, "delete_confirmation")
|
|
644
|
+
|
|
645
|
+
def _Rollback(self, journal: _Journal) -> LifecycleReport:
|
|
646
|
+
"""Remove only confirmed new resources and retain every preexisting locator."""
|
|
647
|
+
|
|
648
|
+
created_ids = {resource.logical_id for resource in journal.created}
|
|
649
|
+
|
|
650
|
+
for spec in self.plan.resources:
|
|
651
|
+
if spec.logical_id not in created_ids and set(spec.dependencies) & created_ids:
|
|
652
|
+
if self._Observe(spec) is not None:
|
|
653
|
+
raise LifecycleError("A preexisting resource depends on rollback resources")
|
|
654
|
+
|
|
655
|
+
pending_created = list(journal.created)
|
|
656
|
+
|
|
657
|
+
while pending_created:
|
|
658
|
+
locator = pending_created[-1]
|
|
659
|
+
current = replace(journal, action="rollback", created=tuple(pending_created),
|
|
660
|
+
pending=locator.logical_id)
|
|
661
|
+
_WriteJournal(self.state_path, current)
|
|
662
|
+
|
|
663
|
+
try:
|
|
664
|
+
self._Delete(locator, journal.operation_id)
|
|
665
|
+
|
|
666
|
+
except ProviderError:
|
|
667
|
+
return self._Report("create", "rollback-incomplete", True)
|
|
668
|
+
|
|
669
|
+
pending_created.pop()
|
|
670
|
+
self._Save((*journal.original, *pending_created))
|
|
671
|
+
journal = replace(journal, action="rollback", created=tuple(pending_created), pending=None)
|
|
672
|
+
_WriteJournal(self.state_path, journal)
|
|
673
|
+
|
|
674
|
+
self._Save(journal.original)
|
|
675
|
+
_ClearJournal(self.state_path)
|
|
676
|
+
|
|
677
|
+
return self._Report("create", "rolled-back")
|
|
678
|
+
|
|
679
|
+
def Destroy(self) -> LifecycleReport:
|
|
680
|
+
"""Remove only recorded exact-owned resources in reverse dependency order."""
|
|
681
|
+
|
|
682
|
+
with _OperationLock(self.state_path):
|
|
683
|
+
state = self._State()
|
|
684
|
+
|
|
685
|
+
if _LoadJournal(self.state_path, self.plan) is not None:
|
|
686
|
+
raise LifecycleError("An interrupted operation requires explicit recovery")
|
|
687
|
+
|
|
688
|
+
journal = _Journal(
|
|
689
|
+
self.plan.identity, self.plan.Fingerprint(), "destroy", state.resources,
|
|
690
|
+
operation_id=uuid4().hex,
|
|
691
|
+
)
|
|
692
|
+
_WriteJournal(self.state_path, journal)
|
|
693
|
+
|
|
694
|
+
return self._Destroy(journal)
|
|
695
|
+
|
|
696
|
+
def _Destroy(self, journal: _Journal) -> LifecycleReport:
|
|
697
|
+
"""Persist every confirmed removal so retry cannot forget partial cleanup."""
|
|
698
|
+
|
|
699
|
+
locators = {item.logical_id: item for item in self._State().resources}
|
|
700
|
+
|
|
701
|
+
for spec in reversed(self.plan.OrderedResources()):
|
|
702
|
+
locator = locators.get(spec.logical_id)
|
|
703
|
+
|
|
704
|
+
if locator is None:
|
|
705
|
+
continue
|
|
706
|
+
|
|
707
|
+
current = replace(journal, action="destroy", pending=locator.logical_id)
|
|
708
|
+
_WriteJournal(self.state_path, current)
|
|
709
|
+
|
|
710
|
+
try:
|
|
711
|
+
self._Delete(locator)
|
|
712
|
+
|
|
713
|
+
except ProviderError:
|
|
714
|
+
return self._Report("destroy", "incomplete", True)
|
|
715
|
+
|
|
716
|
+
del locators[spec.logical_id]
|
|
717
|
+
self._Save(tuple(locators.values()))
|
|
718
|
+
_WriteJournal(self.state_path, journal)
|
|
719
|
+
|
|
720
|
+
_ClearJournal(self.state_path)
|
|
721
|
+
|
|
722
|
+
return self._Report("destroy", "complete")
|
|
723
|
+
|
|
724
|
+
def Recover(self, rollback: bool = False) -> LifecycleReport:
|
|
725
|
+
"""Explicitly resume durable intent, never recreate an unresolved ambiguous create."""
|
|
726
|
+
|
|
727
|
+
with _OperationLock(self.state_path):
|
|
728
|
+
self._State()
|
|
729
|
+
journal = _LoadJournal(self.state_path, self.plan)
|
|
730
|
+
|
|
731
|
+
if journal is None:
|
|
732
|
+
raise LifecycleError("No interrupted operation is recorded")
|
|
733
|
+
|
|
734
|
+
if journal.action == "destroy":
|
|
735
|
+
if rollback:
|
|
736
|
+
raise LifecycleError("A partial destroy cannot be rolled back")
|
|
737
|
+
|
|
738
|
+
return self._Destroy(journal)
|
|
739
|
+
|
|
740
|
+
if journal.action == "rollback":
|
|
741
|
+
return self._Rollback(journal)
|
|
742
|
+
|
|
743
|
+
if journal.pending is not None:
|
|
744
|
+
if journal.pending in {item.logical_id for item in (*journal.original, *journal.created)}:
|
|
745
|
+
raise LifecycleError("Pending creation cannot become rollback eligible twice")
|
|
746
|
+
|
|
747
|
+
spec = self._specs[journal.pending]
|
|
748
|
+
resource = self._Observe(spec)
|
|
749
|
+
|
|
750
|
+
if resource is None or dict(resource.labels).get("flayer-operation") != journal.operation_id:
|
|
751
|
+
return self._Report("recover", "uncertain", True)
|
|
752
|
+
|
|
753
|
+
locator = self._Locator(spec, resource)
|
|
754
|
+
journal = replace(journal, action="create", created=(*journal.created, locator), pending=None)
|
|
755
|
+
_WriteJournal(self.state_path, journal)
|
|
756
|
+
|
|
757
|
+
locators = {item.logical_id: item for item in self._State().resources}
|
|
758
|
+
locators.update((item.logical_id, item) for item in (*journal.original, *journal.created))
|
|
759
|
+
self._Save(tuple(locators.values()))
|
|
760
|
+
|
|
761
|
+
if rollback:
|
|
762
|
+
journal = replace(journal, action="rollback", pending=None)
|
|
763
|
+
_WriteJournal(self.state_path, journal)
|
|
764
|
+
|
|
765
|
+
return self._Rollback(journal)
|
|
766
|
+
|
|
767
|
+
return self._Create(journal)
|