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/guest.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Strict host-pinned SSH transport for private VPN installation and service observations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shlex
|
|
10
|
+
import stat
|
|
11
|
+
import subprocess
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from importlib.resources import files
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Protocol
|
|
16
|
+
|
|
17
|
+
from flayer.profiles.artifacts import ArtifactBundle
|
|
18
|
+
from flayer.profiles.vpn import VpnEndpoint, VpnError
|
|
19
|
+
|
|
20
|
+
MAX_GUEST_REQUEST_BYTES = 4 * 1024 * 1024
|
|
21
|
+
_SERVICE = re.compile(r"[a-z][a-z0-9-]{0,79}\.service\Z")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class GuestResult:
|
|
26
|
+
"""Transient subprocess output excluded from object representations and error messages."""
|
|
27
|
+
|
|
28
|
+
return_code: int
|
|
29
|
+
stdout: bytes = field(default=b"", repr=False)
|
|
30
|
+
stderr: bytes = field(default=b"", repr=False)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class GuestRunner(Protocol):
|
|
34
|
+
"""Injectable subprocess boundary for deterministic tests without SSH connections."""
|
|
35
|
+
|
|
36
|
+
def Run(self, command: tuple[str, ...], payload: bytes, timeout: float) -> GuestResult:
|
|
37
|
+
"""Run a bounded argument vector with private standard input and captured output."""
|
|
38
|
+
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SubprocessGuestRunner:
|
|
43
|
+
"""Execute OpenSSH without a local shell or inherited standard input."""
|
|
44
|
+
|
|
45
|
+
def Run(self, command: tuple[str, ...], payload: bytes, timeout: float) -> GuestResult:
|
|
46
|
+
"""Keep any guest or SSH diagnostic text out of user-visible exception messages."""
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
result = subprocess.run(
|
|
50
|
+
command, input=payload, capture_output=True, check=False, shell=False, timeout=timeout,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
54
|
+
raise VpnError("Guest connection failed or exceeded its time limit") from None
|
|
55
|
+
|
|
56
|
+
return GuestResult(result.returncode, result.stdout, result.stderr)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def ValidateSshPathSyntax(path: Path) -> None:
|
|
60
|
+
"""Reject OpenSSH substitutions before authorizing any project or guest operation."""
|
|
61
|
+
|
|
62
|
+
if any(character in str(path) for character in ('"', "\n", "\r", "\\", "%", "$")):
|
|
63
|
+
raise VpnError("Project paths must not contain quotes, controls, backslashes, percent signs, or dollar signs")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def PrivatePath(path: Path, *, directory: bool = False) -> Path:
|
|
67
|
+
"""Require caller-owned private POSIX objects without symlinks or hardlinked files."""
|
|
68
|
+
|
|
69
|
+
if os.name != "posix" or ".." in path.parts:
|
|
70
|
+
raise VpnError("VPN projects require a POSIX host and paths without traversal")
|
|
71
|
+
|
|
72
|
+
absolute = path.absolute()
|
|
73
|
+
|
|
74
|
+
if any(item.is_symlink() for item in (absolute, *absolute.parents)):
|
|
75
|
+
raise VpnError("VPN project paths must not contain symbolic links")
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
info = absolute.lstat()
|
|
79
|
+
|
|
80
|
+
except OSError:
|
|
81
|
+
raise VpnError("Required private project path is unavailable") from None
|
|
82
|
+
|
|
83
|
+
kind = stat.S_ISDIR if directory else stat.S_ISREG
|
|
84
|
+
|
|
85
|
+
if (
|
|
86
|
+
not kind(info.st_mode) or info.st_uid != os.getuid()
|
|
87
|
+
or stat.S_IMODE(info.st_mode) != (0o700 if directory else 0o600)
|
|
88
|
+
or (not directory and info.st_nlink != 1)
|
|
89
|
+
):
|
|
90
|
+
raise VpnError("VPN project paths must be caller-owned directories0700 or files0600")
|
|
91
|
+
|
|
92
|
+
return absolute
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def ReadPrivate(path: Path, *, limit: int = 65536) -> bytes:
|
|
96
|
+
"""Read one bounded private file and detect replacements or in-place edits during the read."""
|
|
97
|
+
|
|
98
|
+
absolute = PrivatePath(path)
|
|
99
|
+
descriptor: int | None = None
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
before = absolute.lstat()
|
|
103
|
+
descriptor = os.open(absolute, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
104
|
+
opened = os.fstat(descriptor)
|
|
105
|
+
|
|
106
|
+
if (before.st_dev, before.st_ino) != (opened.st_dev, opened.st_ino) or not stat.S_ISREG(opened.st_mode):
|
|
107
|
+
raise VpnError("Private project file changed during read")
|
|
108
|
+
|
|
109
|
+
if opened.st_uid != os.getuid() or stat.S_IMODE(opened.st_mode) != 0o600 or opened.st_nlink != 1:
|
|
110
|
+
raise VpnError("Private project file ownership changed during read")
|
|
111
|
+
|
|
112
|
+
content = bytearray()
|
|
113
|
+
|
|
114
|
+
while len(content) <= limit:
|
|
115
|
+
chunk = os.read(descriptor, min(65536, limit + 1 - len(content)))
|
|
116
|
+
|
|
117
|
+
if not chunk:
|
|
118
|
+
break
|
|
119
|
+
|
|
120
|
+
content.extend(chunk)
|
|
121
|
+
|
|
122
|
+
after = os.fstat(descriptor)
|
|
123
|
+
|
|
124
|
+
if (
|
|
125
|
+
len(content) > limit or opened.st_size != after.st_size
|
|
126
|
+
or opened.st_mtime_ns != after.st_mtime_ns or opened.st_ctime_ns != after.st_ctime_ns
|
|
127
|
+
):
|
|
128
|
+
raise VpnError("Private project file is oversized or changed during read")
|
|
129
|
+
|
|
130
|
+
return bytes(content)
|
|
131
|
+
|
|
132
|
+
except OSError:
|
|
133
|
+
raise VpnError("Unable to read a private project file") from None
|
|
134
|
+
|
|
135
|
+
finally:
|
|
136
|
+
if descriptor is not None:
|
|
137
|
+
os.close(descriptor)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def WritePrivate(path: Path, content: bytes) -> None:
|
|
141
|
+
"""Create a private project file exclusively without overwriting an existing object."""
|
|
142
|
+
|
|
143
|
+
PrivatePath(path.parent, directory=True)
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
147
|
+
|
|
148
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
149
|
+
stream.write(content)
|
|
150
|
+
stream.flush()
|
|
151
|
+
os.fsync(stream.fileno())
|
|
152
|
+
|
|
153
|
+
except OSError:
|
|
154
|
+
raise VpnError("Unable to exclusively create a private project file") from None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class SshGuestProvisioner:
|
|
158
|
+
"""Connect only to an explicit IPv4 endpoint using an independently pinned Ed25519 host key."""
|
|
159
|
+
|
|
160
|
+
def __init__(
|
|
161
|
+
self, endpoint: VpnEndpoint, username: str, identity_file: Path,
|
|
162
|
+
known_hosts_file: Path, runner: GuestRunner | None = None,
|
|
163
|
+
) -> None:
|
|
164
|
+
"""Validate private key/trust paths before constructing any SSH argument vector."""
|
|
165
|
+
|
|
166
|
+
import ipaddress
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
ipaddress.IPv4Address(endpoint.host)
|
|
170
|
+
|
|
171
|
+
except (AttributeError, ValueError):
|
|
172
|
+
raise VpnError("Guest provisioning requires the actual reserved IPv4 address") from None
|
|
173
|
+
|
|
174
|
+
if not isinstance(username, str) or re.fullmatch(r"[a-z][a-z0-9-]{0,31}", username) is None or username == "root":
|
|
175
|
+
raise VpnError("Guest administration requires an explicit non-root username")
|
|
176
|
+
|
|
177
|
+
identity_file = PrivatePath(identity_file)
|
|
178
|
+
known_hosts_file = PrivatePath(known_hosts_file)
|
|
179
|
+
|
|
180
|
+
ValidateSshPathSyntax(identity_file)
|
|
181
|
+
ValidateSshPathSyntax(known_hosts_file)
|
|
182
|
+
|
|
183
|
+
if not ReadPrivate(known_hosts_file).strip():
|
|
184
|
+
raise VpnError("Guest provisioning requires an independently authenticated host-key pin")
|
|
185
|
+
|
|
186
|
+
self.runner = runner or SubprocessGuestRunner()
|
|
187
|
+
self.command = (
|
|
188
|
+
"ssh", "-F", "/dev/null", "-T", "-p", str(endpoint.port), "-i", str(identity_file),
|
|
189
|
+
"-o", "BatchMode=yes", "-o", "IdentityAgent=none", "-o", "IdentitiesOnly=yes",
|
|
190
|
+
"-o", "StrictHostKeyChecking=yes", "-o", "GlobalKnownHostsFile=/dev/null",
|
|
191
|
+
"-o", f'UserKnownHostsFile="{known_hosts_file}"', "-o", "UpdateHostKeys=no",
|
|
192
|
+
"-o", "VerifyHostKeyDNS=no", "-o", "HostKeyAlgorithms=ssh-ed25519",
|
|
193
|
+
"-o", "ConnectTimeout=15", "-o", "ConnectionAttempts=1",
|
|
194
|
+
"-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3",
|
|
195
|
+
"-o", "ControlMaster=no", "-o", "ClearAllForwardings=yes",
|
|
196
|
+
"-o", "PermitLocalCommand=no", "-o", "ProxyCommand=none", "-o", "ProxyJump=none",
|
|
197
|
+
f"{username}@{endpoint.host}",
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def Install(self, bundles: tuple[tuple[str, ArtifactBundle], ...]) -> None:
|
|
201
|
+
"""Stage only validated sensitive server files on the trusted guest and run installers."""
|
|
202
|
+
|
|
203
|
+
if not isinstance(bundles, tuple) or not 1 <= len(bundles) <= 2 or any(
|
|
204
|
+
not isinstance(item, tuple) or len(item) != 2 for item in bundles
|
|
205
|
+
):
|
|
206
|
+
raise VpnError("Guest installation requires one or two explicit server bundles")
|
|
207
|
+
|
|
208
|
+
transports: set[str] = set()
|
|
209
|
+
documents = []
|
|
210
|
+
|
|
211
|
+
for transport, bundle in bundles:
|
|
212
|
+
if (
|
|
213
|
+
not isinstance(transport, str) or transport not in {"amneziawg", "vless-reality"} or transport in transports
|
|
214
|
+
or not isinstance(bundle, ArtifactBundle) or bundle.kind != "server"
|
|
215
|
+
or any(not item.sensitive for item in bundle.files)
|
|
216
|
+
):
|
|
217
|
+
raise VpnError("Guest installation requires unique supported private server bundles")
|
|
218
|
+
|
|
219
|
+
transports.add(transport)
|
|
220
|
+
documents.append({"transport": transport, "files": {
|
|
221
|
+
item.name: base64.b64encode(item.content).decode("ascii") for item in bundle.files
|
|
222
|
+
}})
|
|
223
|
+
|
|
224
|
+
if len({bundle.identity for _, bundle in bundles}) != 1:
|
|
225
|
+
raise VpnError("Guest installation cannot mix different stack owners")
|
|
226
|
+
|
|
227
|
+
payload = json.dumps({"bundles": documents}, sort_keys=True).encode("utf-8")
|
|
228
|
+
|
|
229
|
+
if len(payload) > MAX_GUEST_REQUEST_BYTES:
|
|
230
|
+
raise VpnError("Guest installation request exceeds its bounded size")
|
|
231
|
+
|
|
232
|
+
command = "sudo -n /usr/bin/python3 -c " + shlex.quote(files("flayer").joinpath("_vpn_guest.py").read_text(encoding="utf-8"))
|
|
233
|
+
result = self.runner.Run((*self.command, command), payload, 3700)
|
|
234
|
+
|
|
235
|
+
if result.return_code != 0 or result.stdout.strip() != b"FLAYER_GUEST_INSTALL_OK":
|
|
236
|
+
receipt = result.stdout.strip() if len(result.stdout) <= 128 else b""
|
|
237
|
+
match = re.fullmatch(rb"FLAYER_GUEST_FAILED (amneziawg|vless-reality) (stage|install|check)", receipt)
|
|
238
|
+
|
|
239
|
+
if match is not None:
|
|
240
|
+
transport, phase = (item.decode("ascii") for item in match.groups())
|
|
241
|
+
hint = (
|
|
242
|
+
"Check the Reality target supports TLS 1.3 and HTTP/2, and guest access to the pinned Xray download"
|
|
243
|
+
if transport == "vless-reality" else
|
|
244
|
+
"Check Ubuntu 24.04 amd64 compatibility and guest access to the pinned Go and GitHub downloads"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
raise VpnError(f"VPN {transport} {phase} failed. {hint}. Cloud resources and private artifacts were retained for retry")
|
|
248
|
+
|
|
249
|
+
raise VpnError("Guest SSH connection or installation failed. Check administrator source CIDRs, host trust, and guest logs; resources and private artifacts were retained for retry")
|
|
250
|
+
|
|
251
|
+
def ServiceStatus(self, services: tuple[str, ...]) -> tuple[tuple[str, bool], ...]:
|
|
252
|
+
"""Observe service activity without claiming client connectivity or performing guest writes."""
|
|
253
|
+
|
|
254
|
+
if not isinstance(services, tuple) or not 1 <= len(services) <= 2 or any(
|
|
255
|
+
not isinstance(item, str) or _SERVICE.fullmatch(item) is None for item in services
|
|
256
|
+
):
|
|
257
|
+
raise VpnError("Guest status requires bounded explicit service names")
|
|
258
|
+
|
|
259
|
+
result = []
|
|
260
|
+
|
|
261
|
+
for service in services:
|
|
262
|
+
command = "sudo -n /usr/bin/systemctl is-active --quiet " + shlex.quote(service)
|
|
263
|
+
observed = self.runner.Run((*self.command, command), b"", 30)
|
|
264
|
+
|
|
265
|
+
if observed.return_code not in {0, 3}:
|
|
266
|
+
raise VpnError("Unable to observe the owned guest service")
|
|
267
|
+
|
|
268
|
+
result.append((service, observed.return_code == 0))
|
|
269
|
+
|
|
270
|
+
return tuple(result)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Consumer-neutral observation contracts without transport, dispatch, or authorization."""
|
|
2
|
+
|
|
3
|
+
from .adapters import AdaptDiagnostics, AdaptLifecycle, AdaptState
|
|
4
|
+
from .contracts import (
|
|
5
|
+
CheckEvidence,
|
|
6
|
+
DiagnosticEvidence,
|
|
7
|
+
IntegrationError,
|
|
8
|
+
IntegrationOperation,
|
|
9
|
+
IntegrationRequest,
|
|
10
|
+
IntegrationResult,
|
|
11
|
+
LifecycleEvidence,
|
|
12
|
+
Metric,
|
|
13
|
+
ResourceCount,
|
|
14
|
+
StateEvidence,
|
|
15
|
+
)
|
|
16
|
+
from .wire import ParseRequest, ParseResult, SerializeRequest, SerializeResult
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AdaptDiagnostics", "AdaptLifecycle", "AdaptState", "CheckEvidence", "DiagnosticEvidence",
|
|
20
|
+
"IntegrationError", "IntegrationOperation", "IntegrationRequest", "IntegrationResult",
|
|
21
|
+
"LifecycleEvidence", "Metric", "ParseRequest", "ParseResult", "ResourceCount", "SerializeRequest",
|
|
22
|
+
"SerializeResult", "StateEvidence",
|
|
23
|
+
]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Read-only conversions project trusted caller-selected models into minimal evidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from typing import cast
|
|
7
|
+
|
|
8
|
+
from flayer.core.contracts import StackIdentity
|
|
9
|
+
from flayer.core.lifecycle import LifecycleObservation, LifecycleReport
|
|
10
|
+
from flayer.core.state import StackState
|
|
11
|
+
from flayer.diagnostics.contracts import DiagnosticReport
|
|
12
|
+
|
|
13
|
+
from .contracts import (
|
|
14
|
+
MAX_CHECKS,
|
|
15
|
+
MAX_RESOURCES,
|
|
16
|
+
METRIC_BOUNDS,
|
|
17
|
+
RESOURCE_STATUSES,
|
|
18
|
+
CheckEvidence,
|
|
19
|
+
DiagnosticEvidence,
|
|
20
|
+
IntegrationError,
|
|
21
|
+
IntegrationOperation,
|
|
22
|
+
IntegrationRequest,
|
|
23
|
+
IntegrationResult,
|
|
24
|
+
LifecycleEvidence,
|
|
25
|
+
Metric,
|
|
26
|
+
RequireIdentity,
|
|
27
|
+
ResourceCount,
|
|
28
|
+
StateEvidence,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def AdaptDiagnostics(
|
|
33
|
+
request: IntegrationRequest, report: DiagnosticReport, observed_identity: StackIdentity,
|
|
34
|
+
) -> IntegrationResult:
|
|
35
|
+
"""Bind selected status, health, or benchmark observations and omit arbitrary source text."""
|
|
36
|
+
|
|
37
|
+
RequireIdentity(observed_identity, request.identity)
|
|
38
|
+
|
|
39
|
+
if (
|
|
40
|
+
request.operation not in {
|
|
41
|
+
IntegrationOperation.STATUS, IntegrationOperation.HEALTH, IntegrationOperation.BENCHMARK,
|
|
42
|
+
}
|
|
43
|
+
or not isinstance(report, DiagnosticReport)
|
|
44
|
+
or report.command != request.operation.value
|
|
45
|
+
or len(report.checks) > MAX_CHECKS
|
|
46
|
+
):
|
|
47
|
+
raise IntegrationError("Diagnostic source does not match the integration request")
|
|
48
|
+
|
|
49
|
+
checks = tuple(CheckEvidence(check.status, tuple(
|
|
50
|
+
Metric(name, cast(int | float | None, check.details[name])) for name in sorted(METRIC_BOUNDS)
|
|
51
|
+
if name in check.details
|
|
52
|
+
)) for check in report.checks)
|
|
53
|
+
|
|
54
|
+
return IntegrationResult(request, DiagnosticEvidence(checks))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def AdaptState(request: IntegrationRequest, state: StackState) -> IntegrationResult:
|
|
58
|
+
"""Summarize recorded ownership without exporting locators or claiming remote readiness."""
|
|
59
|
+
|
|
60
|
+
if request.operation is not IntegrationOperation.STATE or not isinstance(state, StackState):
|
|
61
|
+
raise IntegrationError("Owned state source does not match the integration request")
|
|
62
|
+
|
|
63
|
+
RequireIdentity(state.identity, request.identity)
|
|
64
|
+
|
|
65
|
+
return IntegrationResult(request, StateEvidence(len(state.resources)))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def AdaptLifecycle(
|
|
69
|
+
request: IntegrationRequest, report: LifecycleReport, observed_identity: StackIdentity,
|
|
70
|
+
) -> IntegrationResult:
|
|
71
|
+
"""Summarize supplied lifecycle evidence with caller-bound scope, never remote attestation."""
|
|
72
|
+
|
|
73
|
+
RequireIdentity(observed_identity, request.identity)
|
|
74
|
+
|
|
75
|
+
if (
|
|
76
|
+
request.operation is not IntegrationOperation.LIFECYCLE_REPORT
|
|
77
|
+
or not isinstance(report, LifecycleReport)
|
|
78
|
+
or not isinstance(report.resources, tuple)
|
|
79
|
+
or len(report.resources) > MAX_RESOURCES
|
|
80
|
+
or any(not isinstance(resource, LifecycleObservation) for resource in report.resources)
|
|
81
|
+
or any(not isinstance(resource.status, str) or resource.status not in RESOURCE_STATUSES
|
|
82
|
+
for resource in report.resources)
|
|
83
|
+
):
|
|
84
|
+
raise IntegrationError("Lifecycle source does not match the integration request")
|
|
85
|
+
|
|
86
|
+
counts = Counter(resource.status for resource in report.resources)
|
|
87
|
+
evidence = LifecycleEvidence(
|
|
88
|
+
report.action, report.status, report.recovery_required,
|
|
89
|
+
tuple(ResourceCount(status, count) for status, count in sorted(counts.items())),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return IntegrationResult(request, evidence)
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Consumer-neutral immutable envelopes for strictly allowlisted integration evidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from typing import TypeAlias
|
|
10
|
+
|
|
11
|
+
from flayer.core.contracts import ContractError, StackIdentity, ValidateSchemaVersion
|
|
12
|
+
from flayer.diagnostics.contracts import STATUS_PRIORITY, DiagnosticStatus
|
|
13
|
+
|
|
14
|
+
MAX_WIRE_BYTES = 64 * 1024
|
|
15
|
+
MAX_CHECKS = 128
|
|
16
|
+
MAX_RESOURCES = 4096
|
|
17
|
+
REQUEST_ID_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,63}\Z")
|
|
18
|
+
METRIC_BOUNDS = {
|
|
19
|
+
"duration_seconds": (0, 30),
|
|
20
|
+
"received_bytes": (0, 1024 * 1024),
|
|
21
|
+
"byte_limit": (1, 1024 * 1024),
|
|
22
|
+
"http_status": (100, 599),
|
|
23
|
+
"throughput_mbps": (0, 1_000_000_000_000),
|
|
24
|
+
}
|
|
25
|
+
INTEGER_METRICS = frozenset({"received_bytes", "byte_limit", "http_status"})
|
|
26
|
+
NULLABLE_METRICS = frozenset({"http_status", "throughput_mbps"})
|
|
27
|
+
LIFECYCLE_ACTIONS = frozenset({"status", "create", "destroy", "recover"})
|
|
28
|
+
LIFECYCLE_STATUSES = frozenset({
|
|
29
|
+
"complete", "incomplete", "uncertain", "rolled-back", "rollback-incomplete",
|
|
30
|
+
})
|
|
31
|
+
RESOURCE_STATUSES = frozenset({"recorded", "present", "missing", "not-recorded"})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class IntegrationError(ContractError):
|
|
35
|
+
"""An envelope violates the bounded evidence contract without exposing its input."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class IntegrationOperation(StrEnum):
|
|
39
|
+
"""Supported observations convey no infrastructure execution authorization."""
|
|
40
|
+
|
|
41
|
+
STATUS = "status"
|
|
42
|
+
HEALTH = "health"
|
|
43
|
+
BENCHMARK = "benchmark"
|
|
44
|
+
STATE = "state"
|
|
45
|
+
LIFECYCLE_REPORT = "lifecycle-report"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def RequireIdentity(actual: StackIdentity, expected: StackIdentity) -> None:
|
|
49
|
+
"""Require all ownership and provider scope fields to match independently held context."""
|
|
50
|
+
|
|
51
|
+
if not isinstance(expected, StackIdentity) or actual != expected:
|
|
52
|
+
raise IntegrationError("Integration identity does not match the expected ownership boundary")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def ValidateCount(value: object) -> int:
|
|
56
|
+
"""Reject coercion and bound recorded-resource and observation counts."""
|
|
57
|
+
|
|
58
|
+
if type(value) is not int or not 0 <= value <= MAX_RESOURCES:
|
|
59
|
+
raise IntegrationError("Integration resource count exceeds its supported bounds")
|
|
60
|
+
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class IntegrationRequest:
|
|
66
|
+
"""Non-authorizing observation intent bound to exact scope, owner, and correlation."""
|
|
67
|
+
|
|
68
|
+
identity: StackIdentity
|
|
69
|
+
request_id: str
|
|
70
|
+
operation: IntegrationOperation
|
|
71
|
+
schema_version: int = 1
|
|
72
|
+
|
|
73
|
+
def __post_init__(self) -> None:
|
|
74
|
+
"""Validate direct requests with the same strict rules as decoded requests."""
|
|
75
|
+
|
|
76
|
+
ValidateSchemaVersion(self.schema_version)
|
|
77
|
+
|
|
78
|
+
if (
|
|
79
|
+
not isinstance(self.identity, StackIdentity)
|
|
80
|
+
or not isinstance(self.request_id, str)
|
|
81
|
+
or REQUEST_ID_PATTERN.fullmatch(self.request_id) is None
|
|
82
|
+
or not isinstance(self.operation, IntegrationOperation)
|
|
83
|
+
):
|
|
84
|
+
raise IntegrationError("Integration request fields do not match the contract")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, slots=True)
|
|
88
|
+
class Metric:
|
|
89
|
+
"""One allowlisted bounded measurement that cannot contain strings or raw payloads."""
|
|
90
|
+
|
|
91
|
+
name: str
|
|
92
|
+
value: int | float | None
|
|
93
|
+
|
|
94
|
+
def __post_init__(self) -> None:
|
|
95
|
+
"""Require finite measurements, strict integer metrics, and supported nullable fields."""
|
|
96
|
+
|
|
97
|
+
if not isinstance(self.name, str) or self.name not in METRIC_BOUNDS:
|
|
98
|
+
raise IntegrationError("Integration metric is unsupported")
|
|
99
|
+
|
|
100
|
+
if self.value is None:
|
|
101
|
+
if self.name not in NULLABLE_METRICS:
|
|
102
|
+
raise IntegrationError("Integration metric cannot be null")
|
|
103
|
+
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
lower, upper = METRIC_BOUNDS[self.name]
|
|
107
|
+
|
|
108
|
+
if (
|
|
109
|
+
type(self.value) not in {int, float}
|
|
110
|
+
or (self.name in INTEGER_METRICS and type(self.value) is not int)
|
|
111
|
+
or not lower <= self.value <= upper
|
|
112
|
+
or not math.isfinite(self.value)
|
|
113
|
+
):
|
|
114
|
+
raise IntegrationError("Integration metric exceeds its supported bounds")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True, slots=True)
|
|
118
|
+
class CheckEvidence:
|
|
119
|
+
"""A diagnostic outcome and measurements without private names, prose, or details."""
|
|
120
|
+
|
|
121
|
+
status: DiagnosticStatus
|
|
122
|
+
metrics: tuple[Metric, ...] = ()
|
|
123
|
+
|
|
124
|
+
def __post_init__(self) -> None:
|
|
125
|
+
"""Reject mutable or duplicate measurements and invalid diagnostic outcomes."""
|
|
126
|
+
|
|
127
|
+
if (
|
|
128
|
+
not isinstance(self.status, DiagnosticStatus)
|
|
129
|
+
or not isinstance(self.metrics, tuple)
|
|
130
|
+
or len(self.metrics) > len(METRIC_BOUNDS)
|
|
131
|
+
or any(not isinstance(metric, Metric) for metric in self.metrics)
|
|
132
|
+
or len({metric.name for metric in self.metrics}) != len(self.metrics)
|
|
133
|
+
):
|
|
134
|
+
raise IntegrationError("Integration check fields do not match the contract")
|
|
135
|
+
|
|
136
|
+
object.__setattr__(self, "metrics", tuple(sorted(self.metrics, key=lambda metric: metric.name)))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True, slots=True)
|
|
140
|
+
class DiagnosticEvidence:
|
|
141
|
+
"""Ordered diagnostic checks retain truthful aggregation without external text."""
|
|
142
|
+
|
|
143
|
+
checks: tuple[CheckEvidence, ...]
|
|
144
|
+
|
|
145
|
+
def __post_init__(self) -> None:
|
|
146
|
+
"""Bound diagnostic evidence and require immutable validated observations."""
|
|
147
|
+
|
|
148
|
+
if (
|
|
149
|
+
not isinstance(self.checks, tuple)
|
|
150
|
+
or len(self.checks) > MAX_CHECKS
|
|
151
|
+
or any(not isinstance(check, CheckEvidence) for check in self.checks)
|
|
152
|
+
):
|
|
153
|
+
raise IntegrationError("Integration diagnostic evidence exceeds its supported bounds")
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def Status(self) -> DiagnosticStatus:
|
|
157
|
+
"""Use the established severity contract, including unsupported empty evidence."""
|
|
158
|
+
|
|
159
|
+
return max(
|
|
160
|
+
(check.status for check in self.checks), key=STATUS_PRIORITY.__getitem__,
|
|
161
|
+
default=DiagnosticStatus.UNSUPPORTED,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass(frozen=True, slots=True)
|
|
166
|
+
class StateEvidence:
|
|
167
|
+
"""A local owned-state count makes no remote existence or health assertion."""
|
|
168
|
+
|
|
169
|
+
resource_count: int
|
|
170
|
+
|
|
171
|
+
def __post_init__(self) -> None:
|
|
172
|
+
"""Validate the minimal non-identifying inventory summary."""
|
|
173
|
+
|
|
174
|
+
ValidateCount(self.resource_count)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass(frozen=True, slots=True)
|
|
178
|
+
class ResourceCount:
|
|
179
|
+
"""One stable lifecycle observation category without resource locators."""
|
|
180
|
+
|
|
181
|
+
status: str
|
|
182
|
+
count: int
|
|
183
|
+
|
|
184
|
+
def __post_init__(self) -> None:
|
|
185
|
+
"""Reject arbitrary provider text and unbounded lifecycle category counts."""
|
|
186
|
+
|
|
187
|
+
if not isinstance(self.status, str) or self.status not in RESOURCE_STATUSES:
|
|
188
|
+
raise IntegrationError("Integration lifecycle resource status is unsupported")
|
|
189
|
+
|
|
190
|
+
ValidateCount(self.count)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass(frozen=True, slots=True)
|
|
194
|
+
class LifecycleEvidence:
|
|
195
|
+
"""Historical lifecycle outcome and recovery requirement convey no mutation permission."""
|
|
196
|
+
|
|
197
|
+
action: str
|
|
198
|
+
status: str
|
|
199
|
+
recovery_required: bool
|
|
200
|
+
resources: tuple[ResourceCount, ...]
|
|
201
|
+
|
|
202
|
+
def __post_init__(self) -> None:
|
|
203
|
+
"""Require only known lifecycle actions, outcomes, and immutable aggregate counts."""
|
|
204
|
+
|
|
205
|
+
if (
|
|
206
|
+
not isinstance(self.action, str) or self.action not in LIFECYCLE_ACTIONS
|
|
207
|
+
or not isinstance(self.status, str) or self.status not in LIFECYCLE_STATUSES
|
|
208
|
+
or type(self.recovery_required) is not bool
|
|
209
|
+
or not isinstance(self.resources, tuple)
|
|
210
|
+
or len(self.resources) > len(RESOURCE_STATUSES)
|
|
211
|
+
or any(not isinstance(resource, ResourceCount) for resource in self.resources)
|
|
212
|
+
or len({resource.status for resource in self.resources}) != len(self.resources)
|
|
213
|
+
):
|
|
214
|
+
raise IntegrationError("Integration lifecycle evidence fields do not match the contract")
|
|
215
|
+
|
|
216
|
+
ValidateCount(sum(resource.count for resource in self.resources))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
Evidence: TypeAlias = DiagnosticEvidence | StateEvidence | LifecycleEvidence
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@dataclass(frozen=True, slots=True)
|
|
223
|
+
class IntegrationResult:
|
|
224
|
+
"""Allowlisted evidence bound to the exact original validated observation request."""
|
|
225
|
+
|
|
226
|
+
request: IntegrationRequest
|
|
227
|
+
evidence: Evidence
|
|
228
|
+
|
|
229
|
+
def __post_init__(self) -> None:
|
|
230
|
+
"""Prevent operation and evidence type mismatches during direct construction."""
|
|
231
|
+
|
|
232
|
+
if not isinstance(self.request, IntegrationRequest):
|
|
233
|
+
raise IntegrationError("Integration result requires a validated request")
|
|
234
|
+
|
|
235
|
+
expected_type: type[Evidence] = {
|
|
236
|
+
IntegrationOperation.STATE: StateEvidence,
|
|
237
|
+
IntegrationOperation.LIFECYCLE_REPORT: LifecycleEvidence,
|
|
238
|
+
}.get(self.request.operation, DiagnosticEvidence)
|
|
239
|
+
|
|
240
|
+
if not isinstance(self.evidence, expected_type):
|
|
241
|
+
raise IntegrationError("Integration evidence does not match the requested operation")
|