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.
Files changed (45) hide show
  1. f_layer-2.0.1.dist-info/METADATA +232 -0
  2. f_layer-2.0.1.dist-info/RECORD +45 -0
  3. f_layer-2.0.1.dist-info/WHEEL +4 -0
  4. f_layer-2.0.1.dist-info/entry_points.txt +2 -0
  5. f_layer-2.0.1.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
flayer/__main__.py ADDED
@@ -0,0 +1,247 @@
1
+ """Explicit diagnostics and opt-in owned lifecycle CLI without implicit cloud discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from collections.abc import Sequence
8
+ from dataclasses import asdict
9
+ from typing import NoReturn
10
+
11
+ from .diagnostics import (
12
+ BenchmarkLimits,
13
+ CheckResult,
14
+ DiagnosticReport,
15
+ DiagnosticStatus,
16
+ ProbeEndpoint,
17
+ ProviderStatus,
18
+ RenderJson,
19
+ RenderText,
20
+ RunBenchmark,
21
+ RuntimeChecks,
22
+ )
23
+
24
+
25
+ class DiagnosticArgumentParser(argparse.ArgumentParser):
26
+ """Suppress raw invalid argument values that may contain private endpoint material."""
27
+
28
+ def error(self, message: str) -> NoReturn:
29
+ """Preserve argparse's exit contract while keeping untrusted argument text private."""
30
+
31
+ self.exit(2, f"{self.prog}: error: invalid command arguments\n")
32
+
33
+
34
+ def Parser() -> argparse.ArgumentParser:
35
+ """Build an offline help surface and bounded command options."""
36
+
37
+ parser = DiagnosticArgumentParser(prog="python -m flayer", description="F-Layer diagnostics and owned lifecycle")
38
+ commands = parser.add_subparsers(dest="command", required=True)
39
+
40
+ for command in ("status", "check", "health", "benchmark"):
41
+ child = commands.add_parser(command)
42
+ child.add_argument("--format", choices=("text", "json"), default="text")
43
+
44
+ if command in ("health", "benchmark"):
45
+ child.add_argument("--endpoint", help="Explicit HTTP(S) endpoint without credentials")
46
+ child.add_argument("--timeout", type=float, default=3.0, metavar="SECONDS")
47
+
48
+ if command == "benchmark":
49
+ child.add_argument("--count", type=int, default=3)
50
+ child.add_argument("--bytes", type=int, default=64 * 1024, dest="byte_limit")
51
+
52
+ lifecycle = commands.add_parser("lifecycle", help="Explicit owned stack lifecycle")
53
+ actions = lifecycle.add_subparsers(dest="action", required=True)
54
+
55
+ for action in ("create", "status", "destroy", "recover"):
56
+ child = actions.add_parser(action)
57
+ child.add_argument("--config", required=True, help="Explicit lifecycle plan TOML path")
58
+ child.add_argument("--state", required=True, help="Explicit owned snapshot path")
59
+ child.add_argument("--yc-profile", required=True, help="Explicit preauthenticated yc profile")
60
+ child.add_argument("--format", choices=("text", "json"), default="text")
61
+
62
+ if action != "status":
63
+ child.add_argument("--allow-mutation", action="store_true")
64
+ child.add_argument("--scope-confirm", required=True, help="Repeat the exact folder ID")
65
+
66
+ if action == "recover":
67
+ child.add_argument("--rollback", action="store_true")
68
+
69
+ project = commands.add_parser("project", help="Create a private installable VPN project")
70
+ project_actions = project.add_subparsers(dest="action", required=True)
71
+ initialize = project_actions.add_parser("init", help="Create an editable project and administration key")
72
+ initialize.add_argument("directory")
73
+
74
+ vpn = commands.add_parser("vpn", help="Prepare and manage a two-protocol VPN project")
75
+ vpn_actions = vpn.add_subparsers(dest="action", required=True)
76
+
77
+ for action in ("prepare", "deploy", "status", "destroy", "recover"):
78
+ child = vpn_actions.add_parser(action)
79
+ child.add_argument("--project", required=True, help="Private project directory")
80
+ child.add_argument("--format", choices=("text", "json"), default="text")
81
+
82
+ if action in {"deploy", "destroy", "recover"}:
83
+ child.add_argument("--allow-mutation", action="store_true")
84
+ child.add_argument("--scope-confirm", required=True, help="Repeat the exact cloud folder ID")
85
+
86
+ if action == "recover":
87
+ child.add_argument("--rollback", action="store_true")
88
+
89
+ export = vpn_actions.add_parser("export", help="Export existing device files without cloud access")
90
+ export.add_argument("--project", required=True, help="Private project directory")
91
+ export.add_argument("--device", required=True, help="One declared device ID")
92
+ export.add_argument("--protocol", required=True, choices=("amneziawg", "vless-reality"))
93
+ export.add_argument("--output", required=True, help="Separate private export root")
94
+ export.add_argument("--qr", action="store_true", help="Include numbered native AmneziaVPN QR SVGs")
95
+ export.add_argument("--format", choices=("text", "json"), default="text")
96
+
97
+ return parser
98
+
99
+
100
+ def _Lifecycle(args: argparse.Namespace) -> int:
101
+ """Require concrete scope opt-in before constructing a mutating provider adapter."""
102
+
103
+ from .core.lifecycle import LifecycleEngine, LifecycleError, LoadDeploymentPlan
104
+ from .core.state import StateError
105
+ from .providers.contracts import ProviderError
106
+ from .providers.yandex import YandexCloudSettings
107
+ from .providers.yandex_lifecycle import YandexLifecycleProvider
108
+
109
+ try:
110
+ plan = LoadDeploymentPlan(args.config)
111
+
112
+ if plan.identity.provider != "yandex-cloud":
113
+ raise LifecycleError("CLI lifecycle provider is unsupported")
114
+
115
+ if args.action != "status" and (
116
+ not args.allow_mutation or args.scope_confirm != plan.identity.scope_id
117
+ ):
118
+ raise LifecycleError("Mutation requires explicit opt-in and exact scope confirmation")
119
+
120
+ provider = YandexLifecycleProvider(YandexCloudSettings(
121
+ folder_id=plan.identity.scope_id, profile=args.yc_profile,
122
+ ))
123
+ engine = LifecycleEngine(plan, provider, args.state)
124
+
125
+ if args.action == "create":
126
+ report = engine.Create()
127
+
128
+ elif args.action == "status":
129
+ report = engine.Status()
130
+
131
+ elif args.action == "destroy":
132
+ report = engine.Destroy()
133
+
134
+ else:
135
+ report = engine.Recover(rollback=args.rollback)
136
+
137
+ except (LifecycleError, StateError, ProviderError, ValueError):
138
+ payload = {"action": args.action, "status": "failed", "recovery_required": True}
139
+ print(json.dumps(payload) if args.format == "json" else "Lifecycle operation blocked or failed")
140
+
141
+ return 2
142
+
143
+ print(json.dumps(asdict(report), sort_keys=True) if args.format == "json" else
144
+ f"Lifecycle {report.action}: {report.status}; resources={len(report.resources)}; "
145
+ f"recovery_required={str(report.recovery_required).lower()}")
146
+
147
+ return report.ExitCode()
148
+
149
+
150
+ def _VpnProject(args: argparse.Namespace) -> int:
151
+ """Dispatch local project creation or explicit VPN operations without revealing private data."""
152
+
153
+ from .core.contracts import ContractError
154
+ from .profiles.vpn import VpnError
155
+ from .providers.contracts import ProviderError
156
+ from .vpn_project import InitializeVpnProject, LoadVpnProject, RunVpnProject
157
+
158
+ try:
159
+ if args.command == "project":
160
+ InitializeVpnProject(args.directory)
161
+ print("Private project created. Edit project.toml, then run flayer vpn prepare --project DIRECTORY.")
162
+
163
+ return 0
164
+
165
+ project = LoadVpnProject(args.project)
166
+
167
+ if args.action == "export":
168
+ from .vpn_export import ExportVpnDevice
169
+
170
+ report = ExportVpnDevice(project, args.device, args.protocol, args.output, qr=args.qr)
171
+
172
+ else:
173
+ report = RunVpnProject(
174
+ project, args.action,
175
+ allow_mutation=getattr(args, "allow_mutation", False),
176
+ scope_confirm=getattr(args, "scope_confirm", ""),
177
+ rollback=getattr(args, "rollback", False),
178
+ )
179
+
180
+ except (ContractError, ProviderError, OSError) as error:
181
+ message = str(error) if isinstance(error, VpnError) else (
182
+ "Device export failed. Verify existing source manifests and private output permissions; source files were not changed."
183
+ if args.action == "export" else
184
+ "VPN operation failed. Check private artifact ownership, cloud access, and retained state before retrying."
185
+ )
186
+ payload = {"action": args.action, "status": "failed", "message": message,
187
+ "recovery_required": args.action in {"deploy", "destroy", "recover"}}
188
+ print(json.dumps(payload) if getattr(args, "format", "text") == "json" else message)
189
+
190
+ return 2
191
+
192
+ print(json.dumps(asdict(report), sort_keys=True) if args.format == "json" else
193
+ f"VPN {report.action}: {report.status}; cloud={report.cloud_status}; guest={report.guest_status}; "
194
+ f"connectivity={report.connectivity_status}. {report.details}")
195
+
196
+ for export in report.client_exports if args.format == "text" else ():
197
+ print(f"Client files: {export}")
198
+
199
+ return report.ExitCode()
200
+
201
+
202
+ def Main(arguments: Sequence[str] | None = None) -> int:
203
+ """Run explicit diagnostics or authorized lifecycle actions and return their outcome."""
204
+
205
+ args = Parser().parse_args(arguments)
206
+
207
+ if args.command in {"project", "vpn"}:
208
+ return _VpnProject(args)
209
+
210
+ if args.command == "lifecycle":
211
+ return _Lifecycle(args)
212
+
213
+ try:
214
+ if args.command == "status":
215
+ checks = (*RuntimeChecks(), ProviderStatus())
216
+
217
+ elif args.command == "check":
218
+ checks = RuntimeChecks()
219
+
220
+ elif not args.endpoint:
221
+ checks = (CheckResult(
222
+ "endpoint", DiagnosticStatus.UNSUPPORTED, "An explicit endpoint is required"
223
+ ),)
224
+
225
+ elif args.command == "health":
226
+ checks = (ProbeEndpoint(args.endpoint, args.timeout),)
227
+
228
+ else:
229
+ limits = BenchmarkLimits(args.timeout, args.count, args.byte_limit)
230
+ checks = RunBenchmark(args.endpoint, limits)
231
+
232
+ except ValueError:
233
+ report = DiagnosticReport(args.command, (CheckResult(
234
+ "input", DiagnosticStatus.FAILED, "Invalid endpoint or diagnostic limits"
235
+ ),))
236
+ print(RenderJson(report) if args.format == "json" else RenderText(report))
237
+
238
+ return 2
239
+
240
+ report = DiagnosticReport(args.command, checks)
241
+ print(RenderJson(report) if args.format == "json" else RenderText(report))
242
+
243
+ return report.ExitCode()
244
+
245
+
246
+ if __name__ == "__main__":
247
+ raise SystemExit(Main())
flayer/_vpn_guest.py ADDED
@@ -0,0 +1,130 @@
1
+ """Receive private VPN installation payloads over an already authenticated root SSH channel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import os
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+ from pathlib import Path
14
+
15
+ MAX_REQUEST_BYTES = 4 * 1024 * 1024
16
+ _FILENAME = re.compile(r"[a-z][a-z0-9-]{0,62}\.[a-z0-9]{1,12}\Z")
17
+
18
+
19
+ class GuestInstallError(ValueError):
20
+ """Carry only an allowlisted transport and phase, never raw installer diagnostics."""
21
+
22
+ def __init__(self, transport: str, phase: str) -> None:
23
+ """Record fixed internal operation labels for a sanitized failure receipt."""
24
+
25
+ super().__init__("Guest installation failed")
26
+ self.transport = transport
27
+ self.phase = phase
28
+
29
+
30
+ def DecodeRequest(content: bytes) -> tuple[tuple[str, dict[str, bytes]], ...]:
31
+ """Validate all private data before creating any guest files or running an installer."""
32
+
33
+ if not 1 <= len(content) <= MAX_REQUEST_BYTES:
34
+ raise ValueError("Invalid guest request size")
35
+
36
+ request = json.loads(content)
37
+
38
+ if not isinstance(request, dict) or set(request) != {"bundles"} or not isinstance(request["bundles"], list) or not 1 <= len(request["bundles"]) <= 2:
39
+ raise ValueError("Invalid guest request")
40
+
41
+ result: list[tuple[str, dict[str, bytes]]] = []
42
+
43
+ for bundle in request["bundles"]:
44
+ if (
45
+ not isinstance(bundle, dict) or set(bundle) != {"transport", "files"}
46
+ or bundle["transport"] not in {"amneziawg", "vless-reality"}
47
+ or not isinstance(bundle["files"], dict) or not 1 <= len(bundle["files"]) <= 8
48
+ ):
49
+ raise ValueError("Invalid transport bundle")
50
+
51
+ files = {}
52
+
53
+ for name, encoded in bundle["files"].items():
54
+ if not isinstance(name, str) or _FILENAME.fullmatch(name) is None or not isinstance(encoded, str):
55
+ raise ValueError("Invalid transport filename")
56
+
57
+ decoded = base64.b64decode(encoded, validate=True)
58
+
59
+ if not 1 <= len(decoded) <= 1048576:
60
+ raise ValueError("Invalid transport file size")
61
+
62
+ files[name] = decoded
63
+
64
+ entrypoint = "install.sh" if bundle["transport"] == "amneziawg" else "install.py"
65
+
66
+ if entrypoint not in files or any(transport == bundle["transport"] for transport, _ in result):
67
+ raise ValueError("Invalid installer entrypoint or duplicate transport")
68
+
69
+ result.append((bundle["transport"], files))
70
+
71
+ return tuple(result)
72
+
73
+
74
+ def Install(bundles: tuple[tuple[str, dict[str, bytes]], ...]) -> None:
75
+ """Use root-owned temporary directories and suppress potentially sensitive installer output."""
76
+
77
+ if os.geteuid() != 0:
78
+ raise ValueError("Root privileges are required")
79
+
80
+ for transport, files in bundles:
81
+ stage = Path(tempfile.mkdtemp(prefix="flayer-install-", dir="/var/tmp"))
82
+
83
+ phase = "stage"
84
+
85
+ try:
86
+ os.chmod(stage, 0o700)
87
+
88
+ for name, content in files.items():
89
+ descriptor = os.open(stage / name, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
90
+
91
+ with os.fdopen(descriptor, "wb") as stream:
92
+ stream.write(content)
93
+
94
+ command = ["/bin/bash", str(stage / "install.sh")] if transport == "amneziawg" else ["/usr/bin/python3", str(stage / "install.py")]
95
+ phase = "install"
96
+ subprocess.run(command, cwd=stage, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True, timeout=1800)
97
+ phase = "check"
98
+ subprocess.run(command + ["--check"], cwd=stage, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True, timeout=60)
99
+
100
+ except (OSError, subprocess.SubprocessError):
101
+ raise GuestInstallError(transport, phase) from None
102
+
103
+ finally:
104
+ shutil.rmtree(stage)
105
+
106
+
107
+ def Main() -> int:
108
+ """Return only fixed status text, never request data or private installer diagnostics."""
109
+
110
+ try:
111
+ bundles = DecodeRequest(sys.stdin.buffer.read(MAX_REQUEST_BYTES + 1))
112
+ Install(bundles)
113
+
114
+ except GuestInstallError as error:
115
+ print(f"FLAYER_GUEST_FAILED {error.transport} {error.phase}")
116
+
117
+ return 1
118
+
119
+ except Exception:
120
+ print("F-Layer guest installation failed", file=sys.stderr)
121
+
122
+ return 1
123
+
124
+ print("FLAYER_GUEST_INSTALL_OK")
125
+
126
+ return 0
127
+
128
+
129
+ if __name__ == "__main__":
130
+ raise SystemExit(Main())
@@ -0,0 +1 @@
1
+ """Provider-independent desired configuration and owned-state contracts."""
flayer/core/config.py ADDED
@@ -0,0 +1,198 @@
1
+ """Strict TOML desired-state loading without provider defaults or secret material."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import tomllib
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ from .contracts import (
12
+ SCHEMA_VERSION,
13
+ ContractError,
14
+ ParseIdentity,
15
+ RequireTable,
16
+ StackIdentity,
17
+ ValidateFields,
18
+ ValidateName,
19
+ ValidateSchemaVersion,
20
+ )
21
+
22
+ _ENVIRONMENT_NAME = re.compile(r"[A-Z_][A-Z0-9_]*")
23
+
24
+
25
+ class ConfigError(ContractError):
26
+ """Desired configuration cannot be safely parsed or read."""
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class SecretReference:
31
+ """A named credential source; resolution belongs to an authentication adapter."""
32
+
33
+ name: str
34
+ source: str
35
+ reference: str
36
+
37
+ def __post_init__(self) -> None:
38
+ """Allow environment names and file paths, never inline credential values."""
39
+
40
+ ValidateName(self.name, "credentials.name")
41
+
42
+ if not isinstance(self.source, str) or self.source not in {"env", "file"}:
43
+ raise ContractError("credentials.source must be env or file")
44
+
45
+ if self.source == "env":
46
+ if (
47
+ not isinstance(self.reference, str)
48
+ or _ENVIRONMENT_NAME.fullmatch(self.reference) is None
49
+ ):
50
+ raise ContractError("credentials.reference must be an environment variable name")
51
+
52
+ elif (
53
+ not isinstance(self.reference, str)
54
+ or not self.reference.strip()
55
+ or self.reference != self.reference.strip()
56
+ or len(self.reference) > 4096
57
+ or any(ord(character) < 32 or ord(character) == 127 for character in self.reference)
58
+ ):
59
+ raise ContractError("credentials.reference must be a nonempty file path")
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class DesiredResource:
64
+ """Provider-independent logical resource identity before an external resource exists."""
65
+
66
+ logical_id: str
67
+ kind: str
68
+ name: str
69
+
70
+ def __post_init__(self) -> None:
71
+ """Reject ambiguous logical identifiers and malformed names."""
72
+
73
+ ValidateName(self.logical_id, "resources.logical_id")
74
+ ValidateName(self.kind, "resources.kind")
75
+ ValidateName(self.name, "resources.name")
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class DesiredConfig:
80
+ """Immutable, validated desired intent without observed identifiers or credentials."""
81
+
82
+ identity: StackIdentity
83
+ profile: str
84
+ resources: tuple[DesiredResource, ...] = ()
85
+ credentials: tuple[SecretReference, ...] = ()
86
+ schema_version: int = SCHEMA_VERSION
87
+
88
+ def __post_init__(self) -> None:
89
+ """Preserve model invariants when callers construct plans directly."""
90
+
91
+ ValidateSchemaVersion(self.schema_version)
92
+
93
+ if not isinstance(self.identity, StackIdentity):
94
+ raise ContractError("identity must be a StackIdentity")
95
+
96
+ ValidateName(self.profile, "profile")
97
+
98
+ if not isinstance(self.resources, tuple) or any(
99
+ not isinstance(resource, DesiredResource) for resource in self.resources
100
+ ):
101
+ raise ContractError("resources must be a tuple of DesiredResource values")
102
+
103
+ if len({resource.logical_id for resource in self.resources}) != len(self.resources):
104
+ raise ContractError("resources.logical_id values must be unique")
105
+
106
+ if not isinstance(self.credentials, tuple) or any(
107
+ not isinstance(credential, SecretReference) for credential in self.credentials
108
+ ):
109
+ raise ContractError("credentials must be a tuple of SecretReference values")
110
+
111
+ if len({credential.name for credential in self.credentials}) != len(self.credentials):
112
+ raise ContractError("credentials.name values must be unique")
113
+
114
+
115
+ def _ParseResources(value: object) -> tuple[DesiredResource, ...]:
116
+ """Decode strictly typed logical resource tables."""
117
+
118
+ if not isinstance(value, list):
119
+ raise ContractError("resources must be an array of tables")
120
+
121
+ resources: list[DesiredResource] = []
122
+
123
+ for item in value:
124
+ table = RequireTable(item, "resources")
125
+ ValidateFields(table, frozenset({"logical_id", "kind", "name"}), frozenset(), "resources")
126
+ resources.append(
127
+ DesiredResource(
128
+ logical_id=ValidateName(table["logical_id"], "resources.logical_id"),
129
+ kind=ValidateName(table["kind"], "resources.kind"),
130
+ name=ValidateName(table["name"], "resources.name"),
131
+ )
132
+ )
133
+
134
+ return tuple(resources)
135
+
136
+
137
+ def _ParseCredentials(value: object) -> tuple[SecretReference, ...]:
138
+ """Decode references without consulting the environment or reading credential files."""
139
+
140
+ if not isinstance(value, list):
141
+ raise ContractError("credentials must be an array of tables")
142
+
143
+ credentials: list[SecretReference] = []
144
+
145
+ for item in value:
146
+ table = RequireTable(item, "credentials")
147
+ ValidateFields(
148
+ table, frozenset({"name", "source", "reference"}), frozenset(), "credentials"
149
+ )
150
+
151
+ if not isinstance(table["source"], str) or not isinstance(table["reference"], str):
152
+ raise ContractError("credentials.source and reference must be strings")
153
+
154
+ credentials.append(
155
+ SecretReference(
156
+ name=ValidateName(table["name"], "credentials.name"),
157
+ source=table["source"],
158
+ reference=table["reference"],
159
+ )
160
+ )
161
+
162
+ return tuple(credentials)
163
+
164
+
165
+ def ParseConfig(data: Mapping[str, object]) -> DesiredConfig:
166
+ """Validate decoded TOML; unknown fields and schema versions always fail closed."""
167
+
168
+ try:
169
+ ValidateFields(
170
+ data,
171
+ frozenset({"schema_version", "identity", "profile"}),
172
+ frozenset({"resources", "credentials"}),
173
+ "configuration",
174
+ )
175
+ ValidateSchemaVersion(data["schema_version"])
176
+
177
+ return DesiredConfig(
178
+ identity=ParseIdentity(data["identity"]),
179
+ profile=ValidateName(data["profile"], "profile"),
180
+ resources=_ParseResources(data.get("resources", [])),
181
+ credentials=_ParseCredentials(data.get("credentials", [])),
182
+ )
183
+
184
+ except ContractError as error:
185
+ raise ConfigError(str(error)) from error
186
+
187
+
188
+ def LoadConfig(path: str | Path) -> DesiredConfig:
189
+ """Load an explicit TOML path with no implicit local defaults or environment overrides."""
190
+
191
+ try:
192
+ with Path(path).open("rb") as stream:
193
+ data = tomllib.load(stream)
194
+
195
+ except (OSError, ValueError, RecursionError) as error:
196
+ raise ConfigError("Unable to read a valid TOML configuration") from error
197
+
198
+ return ParseConfig(data)
@@ -0,0 +1,114 @@
1
+ """Validated identities shared by desired plans and persisted resource ownership."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass
8
+
9
+ SCHEMA_VERSION = 1
10
+ MANAGED_BY = "f-layer"
11
+ IDENTITY_FIELDS = frozenset({"project", "stack", "provider", "scope_id", "owner_id"})
12
+ _NAME_PATTERN = re.compile(r"[a-z][a-z0-9-]{0,62}")
13
+
14
+
15
+ class ContractError(ValueError):
16
+ """An input violates a versioned core contract before any external operation."""
17
+
18
+
19
+ def ValidateName(value: object, label: str) -> str:
20
+ """Require a bounded portable name without coercing invalid input types."""
21
+
22
+ if not isinstance(value, str) or _NAME_PATTERN.fullmatch(value) is None:
23
+ raise ContractError(f"{label} must be a lowercase name of 1..63 characters")
24
+
25
+ return value
26
+
27
+
28
+ def ValidateIdentifier(value: object, label: str) -> str:
29
+ """Accept opaque provider identifiers without control characters or whitespace."""
30
+
31
+ if (
32
+ not isinstance(value, str)
33
+ or not 1 <= len(value) <= 256
34
+ or any(not 33 <= ord(character) <= 126 for character in value)
35
+ ):
36
+ raise ContractError(f"{label} must be a nonempty printable ASCII identifier")
37
+
38
+ return value
39
+
40
+
41
+ def ValidateSchemaVersion(value: object) -> None:
42
+ """Reject unknown schema versions and booleans masquerading as integers."""
43
+
44
+ if type(value) is not int or value != SCHEMA_VERSION:
45
+ raise ContractError(f"schema_version must be the supported integer {SCHEMA_VERSION}")
46
+
47
+
48
+ def ValidateFields(
49
+ data: Mapping[str, object], required: frozenset[str], optional: frozenset[str], label: str
50
+ ) -> None:
51
+ """Reject missing and unknown fields instead of silently accepting configuration typos."""
52
+
53
+ if not isinstance(data, Mapping):
54
+ raise ContractError(f"{label} must be a table")
55
+
56
+ if required - data.keys():
57
+ raise ContractError(f"{label} is missing required fields")
58
+
59
+ if data.keys() - required - optional:
60
+ raise ContractError(f"{label} contains unknown fields")
61
+
62
+
63
+ def RequireTable(value: object, label: str) -> Mapping[str, object]:
64
+ """Require a string-keyed table at an untrusted decoding boundary."""
65
+
66
+ if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
67
+ raise ContractError(f"{label} must be a table")
68
+
69
+ return value
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class StackIdentity:
74
+ """Exact ownership boundary for one provider scope and one managed stack."""
75
+
76
+ project: str
77
+ stack: str
78
+ provider: str
79
+ scope_id: str
80
+ owner_id: str
81
+
82
+ def __post_init__(self) -> None:
83
+ """Validate direct construction as strictly as decoded configuration."""
84
+
85
+ ValidateName(self.project, "identity.project")
86
+ ValidateName(self.stack, "identity.stack")
87
+ ValidateName(self.provider, "identity.provider")
88
+ ValidateIdentifier(self.scope_id, "identity.scope_id")
89
+ ValidateName(self.owner_id, "identity.owner_id")
90
+
91
+ def OwnershipLabels(self) -> dict[str, str]:
92
+ """Return explicit labels for provider adapters to attach and verify."""
93
+
94
+ return {
95
+ "managed-by": MANAGED_BY,
96
+ "flayer-project": self.project,
97
+ "flayer-stack": self.stack,
98
+ "flayer-owner": self.owner_id,
99
+ }
100
+
101
+
102
+ def ParseIdentity(value: object) -> StackIdentity:
103
+ """Decode a complete identity without defaulting missing ownership information."""
104
+
105
+ table = RequireTable(value, "identity")
106
+ ValidateFields(table, IDENTITY_FIELDS, frozenset(), "identity")
107
+
108
+ return StackIdentity(
109
+ project=ValidateName(table["project"], "identity.project"),
110
+ stack=ValidateName(table["stack"], "identity.stack"),
111
+ provider=ValidateName(table["provider"], "identity.provider"),
112
+ scope_id=ValidateIdentifier(table["scope_id"], "identity.scope_id"),
113
+ owner_id=ValidateName(table["owner_id"], "identity.owner_id"),
114
+ )