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/__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())
|
flayer/core/__init__.py
ADDED
|
@@ -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)
|
flayer/core/contracts.py
ADDED
|
@@ -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
|
+
)
|