f-layer 2.0.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 (45) hide show
  1. f_layer-2.0.0.dist-info/METADATA +232 -0
  2. f_layer-2.0.0.dist-info/RECORD +45 -0
  3. f_layer-2.0.0.dist-info/WHEEL +4 -0
  4. f_layer-2.0.0.dist-info/entry_points.txt +2 -0
  5. f_layer-2.0.0.dist-info/licenses/LICENSE +201 -0
  6. flayer/__init__.py +5 -0
  7. flayer/__main__.py +247 -0
  8. flayer/_vpn_guest.py +130 -0
  9. flayer/core/__init__.py +1 -0
  10. flayer/core/config.py +198 -0
  11. flayer/core/contracts.py +114 -0
  12. flayer/core/lifecycle.py +767 -0
  13. flayer/core/state.py +351 -0
  14. flayer/diagnostics/__init__.py +20 -0
  15. flayer/diagnostics/benchmark.py +272 -0
  16. flayer/diagnostics/contracts.py +167 -0
  17. flayer/diagnostics/health.py +154 -0
  18. flayer/diagnostics/reporting.py +24 -0
  19. flayer/extensions/__init__.py +25 -0
  20. flayer/extensions/builtins.py +165 -0
  21. flayer/extensions/contracts.py +196 -0
  22. flayer/extensions/discovery.py +152 -0
  23. flayer/extensions/registry.py +347 -0
  24. flayer/guest.py +270 -0
  25. flayer/integrations/__init__.py +23 -0
  26. flayer/integrations/adapters.py +92 -0
  27. flayer/integrations/contracts.py +241 -0
  28. flayer/integrations/wire.py +273 -0
  29. flayer/profiles/__init__.py +33 -0
  30. flayer/profiles/_amnezia_export.py +176 -0
  31. flayer/profiles/_vless_guest.py +432 -0
  32. flayer/profiles/amneziawg.py +555 -0
  33. flayer/profiles/artifacts.py +609 -0
  34. flayer/profiles/gateway.py +729 -0
  35. flayer/profiles/static_site.py +596 -0
  36. flayer/profiles/vless.py +422 -0
  37. flayer/profiles/vpn.py +524 -0
  38. flayer/providers/__init__.py +1 -0
  39. flayer/providers/contracts.py +207 -0
  40. flayer/providers/lifecycle.py +177 -0
  41. flayer/providers/yandex.py +387 -0
  42. flayer/providers/yandex_lifecycle.py +781 -0
  43. flayer/py.typed +0 -0
  44. flayer/vpn_export.py +72 -0
  45. flayer/vpn_project.py +828 -0
@@ -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)