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.
- f_layer-2.0.0.dist-info/METADATA +232 -0
- f_layer-2.0.0.dist-info/RECORD +45 -0
- f_layer-2.0.0.dist-info/WHEEL +4 -0
- f_layer-2.0.0.dist-info/entry_points.txt +2 -0
- f_layer-2.0.0.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
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""Read-only Yandex Cloud CLI adapter with bounded commands and sanitized failures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import subprocess
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Protocol
|
|
11
|
+
|
|
12
|
+
from .contracts import (
|
|
13
|
+
ProviderCapability,
|
|
14
|
+
ProviderError,
|
|
15
|
+
ProviderErrorCode,
|
|
16
|
+
ProviderIdentity,
|
|
17
|
+
ProviderResource,
|
|
18
|
+
ProviderStatus,
|
|
19
|
+
ResourceKind,
|
|
20
|
+
ResourceReference,
|
|
21
|
+
ValidateIdentifier,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
PROVIDER_ID = "yandex-cloud"
|
|
25
|
+
MAX_CLI_LIST_LIMIT = 1000
|
|
26
|
+
RESOURCE_COMMANDS: dict[ResourceKind, tuple[str, str]] = {
|
|
27
|
+
ResourceKind.INSTANCE: ("compute", "instance"),
|
|
28
|
+
ResourceKind.DISK: ("compute", "disk"),
|
|
29
|
+
ResourceKind.NETWORK: ("vpc", "network"),
|
|
30
|
+
ResourceKind.SUBNET: ("vpc", "subnet"),
|
|
31
|
+
ResourceKind.ADDRESS: ("vpc", "address"),
|
|
32
|
+
ResourceKind.SECURITY_GROUP: ("vpc", "security-group"),
|
|
33
|
+
}
|
|
34
|
+
ERROR_MARKERS: tuple[tuple[ProviderErrorCode, tuple[str, ...]], ...] = (
|
|
35
|
+
(ProviderErrorCode.AUTHENTICATION, ("unauthenticated", "invalid token", "token expired")),
|
|
36
|
+
(ProviderErrorCode.PERMISSION_DENIED, ("permissiondenied", "permission_denied", "forbidden")),
|
|
37
|
+
(ProviderErrorCode.NOT_FOUND, ("notfound", "not_found", "not found")),
|
|
38
|
+
(ProviderErrorCode.TIMEOUT, ("deadlineexceeded", "deadline_exceeded", "timed out")),
|
|
39
|
+
(ProviderErrorCode.THROTTLED, ("resourceexhausted", "resource_exhausted", "too many requests")),
|
|
40
|
+
(ProviderErrorCode.CONFLICT, ("alreadyexists", "already_exists", "conflict")),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class YandexCloudSettings:
|
|
46
|
+
"""Explicit folder/profile configuration with no stored tokens or secret keys."""
|
|
47
|
+
|
|
48
|
+
folder_id: str
|
|
49
|
+
profile: str = "default"
|
|
50
|
+
executable: str = "yc"
|
|
51
|
+
command_timeout: float = 45.0
|
|
52
|
+
inventory_limit: int = 10000
|
|
53
|
+
|
|
54
|
+
def __post_init__(self) -> None:
|
|
55
|
+
"""Reject unsafe scope selectors and unbounded subprocess parameters."""
|
|
56
|
+
|
|
57
|
+
ValidateIdentifier(self.folder_id)
|
|
58
|
+
ValidateIdentifier(self.profile)
|
|
59
|
+
|
|
60
|
+
if (
|
|
61
|
+
not isinstance(self.executable, str)
|
|
62
|
+
or not self.executable.strip()
|
|
63
|
+
or "\x00" in self.executable
|
|
64
|
+
):
|
|
65
|
+
raise ValueError("Yandex CLI executable must be a non-empty executable path or name")
|
|
66
|
+
|
|
67
|
+
if (
|
|
68
|
+
isinstance(self.command_timeout, bool)
|
|
69
|
+
or not isinstance(self.command_timeout, (int, float))
|
|
70
|
+
or not math.isfinite(self.command_timeout)
|
|
71
|
+
or not 0 < self.command_timeout <= 300
|
|
72
|
+
):
|
|
73
|
+
raise ValueError(
|
|
74
|
+
"Provider command timeout must be finite and between 0 and 300 seconds"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if (
|
|
78
|
+
isinstance(self.inventory_limit, bool)
|
|
79
|
+
or not isinstance(self.inventory_limit, int)
|
|
80
|
+
or not 1 <= self.inventory_limit <= 100000
|
|
81
|
+
):
|
|
82
|
+
raise ValueError("Provider inventory limit must be an integer between 1 and 100000")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True, slots=True)
|
|
86
|
+
class CommandResult:
|
|
87
|
+
"""An ephemeral command result whose representation omits potentially secret output."""
|
|
88
|
+
|
|
89
|
+
return_code: int
|
|
90
|
+
stdout: str = field(default="", repr=False)
|
|
91
|
+
stderr: str = field(default="", repr=False)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class CommandRunner(Protocol):
|
|
95
|
+
"""Injectable execution boundary for deterministic tests and alternative transports."""
|
|
96
|
+
|
|
97
|
+
def Run(self, command: tuple[str, ...], timeout: float) -> CommandResult:
|
|
98
|
+
"""Execute one argument vector without modifying its order or scope."""
|
|
99
|
+
|
|
100
|
+
...
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class SubprocessCommandRunner:
|
|
104
|
+
"""Execute the configured CLI directly without a shell or interactive standard input."""
|
|
105
|
+
|
|
106
|
+
def Run(self, command: tuple[str, ...], timeout: float) -> CommandResult:
|
|
107
|
+
"""Capture output transiently while enforcing a per-command timeout."""
|
|
108
|
+
|
|
109
|
+
result = subprocess.run(
|
|
110
|
+
command,
|
|
111
|
+
check=False,
|
|
112
|
+
stdin=subprocess.DEVNULL,
|
|
113
|
+
capture_output=True,
|
|
114
|
+
text=True,
|
|
115
|
+
encoding="utf-8",
|
|
116
|
+
errors="replace",
|
|
117
|
+
shell=False,
|
|
118
|
+
timeout=timeout,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return CommandResult(result.returncode, result.stdout, result.stderr)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class YandexCloudProvider:
|
|
125
|
+
"""Normalize CLI inventory inside one folder; expose no cloud mutation methods."""
|
|
126
|
+
|
|
127
|
+
def __init__(
|
|
128
|
+
self, settings: YandexCloudSettings, runner: CommandRunner | None = None
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Construct a provider without executing commands or reading credential files."""
|
|
131
|
+
|
|
132
|
+
self._settings = settings
|
|
133
|
+
self._runner = runner if runner is not None else SubprocessCommandRunner()
|
|
134
|
+
self._identity = ProviderIdentity(PROVIDER_ID, settings.folder_id, "cli-profile")
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def Identity(self) -> ProviderIdentity:
|
|
138
|
+
"""Return the non-secret identity used to validate all resources and references."""
|
|
139
|
+
|
|
140
|
+
return self._identity
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def Capabilities(self) -> frozenset[ProviderCapability]:
|
|
144
|
+
"""Declare implemented read operations without inferring lifecycle capability."""
|
|
145
|
+
|
|
146
|
+
return frozenset({ProviderCapability.INVENTORY, ProviderCapability.RESOURCE_LOOKUP})
|
|
147
|
+
|
|
148
|
+
def CheckAvailability(self) -> ProviderStatus:
|
|
149
|
+
"""Check CLI execution locally; no token, profile or infrastructure read occurs."""
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
self._Run(("--version",), "availability", scoped=False)
|
|
153
|
+
|
|
154
|
+
except ProviderError as error:
|
|
155
|
+
return ProviderStatus(self.Identity, False, error_code=error.code)
|
|
156
|
+
|
|
157
|
+
return ProviderStatus(self.Identity, True)
|
|
158
|
+
|
|
159
|
+
def CheckAuthentication(self) -> ProviderStatus:
|
|
160
|
+
"""Verify access to the selected folder through the CLI's existing credentials."""
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
payload = self._ReadJson(
|
|
164
|
+
("resource-manager", "folder", "get", "--id", self._settings.folder_id),
|
|
165
|
+
"authentication",
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
if not isinstance(payload, dict) or payload.get("id") != self._settings.folder_id:
|
|
169
|
+
raise ProviderError(ProviderErrorCode.SCOPE_MISMATCH, "authentication")
|
|
170
|
+
|
|
171
|
+
except ProviderError as error:
|
|
172
|
+
available = error.code != ProviderErrorCode.UNAVAILABLE
|
|
173
|
+
|
|
174
|
+
return ProviderStatus(self.Identity, available, False, error.code)
|
|
175
|
+
|
|
176
|
+
return ProviderStatus(self.Identity, True, True)
|
|
177
|
+
|
|
178
|
+
def ListResources(self, kind: ResourceKind) -> tuple[ProviderResource, ...]:
|
|
179
|
+
"""Read one resource kind, rejecting malformed or possibly truncated inventory."""
|
|
180
|
+
|
|
181
|
+
command = self._CommandForKind(kind)
|
|
182
|
+
limit = min(self._settings.inventory_limit, MAX_CLI_LIST_LIMIT)
|
|
183
|
+
payload = self._ReadJson(
|
|
184
|
+
(*command, "list", "--limit", str(limit)), "list"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if not isinstance(payload, list):
|
|
188
|
+
raise ProviderError(ProviderErrorCode.INVALID_RESPONSE, "list")
|
|
189
|
+
|
|
190
|
+
if len(payload) >= limit:
|
|
191
|
+
raise ProviderError(ProviderErrorCode.INCOMPLETE_INVENTORY, "list")
|
|
192
|
+
|
|
193
|
+
resources = tuple(self._NormalizeResource(item, kind, "list") for item in payload)
|
|
194
|
+
|
|
195
|
+
if len({resource.reference.resource_id for resource in resources}) != len(resources):
|
|
196
|
+
raise ProviderError(ProviderErrorCode.INVALID_RESPONSE, "list")
|
|
197
|
+
|
|
198
|
+
return tuple(sorted(resources, key=lambda item: item.reference.resource_id))
|
|
199
|
+
|
|
200
|
+
def GetResource(self, reference: ResourceReference) -> ProviderResource:
|
|
201
|
+
"""Read by ID and revalidate returned scope because vendor ID lookup is global."""
|
|
202
|
+
|
|
203
|
+
if (
|
|
204
|
+
reference.provider_id != self.Identity.provider_id
|
|
205
|
+
or reference.scope_id != self.Identity.scope_id
|
|
206
|
+
):
|
|
207
|
+
raise ProviderError(ProviderErrorCode.SCOPE_MISMATCH, "get")
|
|
208
|
+
|
|
209
|
+
command = self._CommandForKind(reference.kind)
|
|
210
|
+
payload = self._ReadJson((*command, "get", "--id", reference.resource_id), "get")
|
|
211
|
+
resource = self._NormalizeResource(payload, reference.kind, "get")
|
|
212
|
+
|
|
213
|
+
if resource.reference != reference:
|
|
214
|
+
raise ProviderError(ProviderErrorCode.SCOPE_MISMATCH, "get")
|
|
215
|
+
|
|
216
|
+
return resource
|
|
217
|
+
|
|
218
|
+
def DiscoverInventory(self) -> tuple[ProviderResource, ...]:
|
|
219
|
+
"""Return a deterministic complete inventory, never a partial success snapshot."""
|
|
220
|
+
|
|
221
|
+
resources: list[ProviderResource] = []
|
|
222
|
+
|
|
223
|
+
for kind in ResourceKind:
|
|
224
|
+
resources.extend(self.ListResources(kind))
|
|
225
|
+
|
|
226
|
+
return tuple(resources)
|
|
227
|
+
|
|
228
|
+
@staticmethod
|
|
229
|
+
def _CommandForKind(kind: ResourceKind) -> tuple[str, str]:
|
|
230
|
+
"""Require an explicit supported enum rather than arbitrary CLI arguments."""
|
|
231
|
+
|
|
232
|
+
if not isinstance(kind, ResourceKind) or kind not in RESOURCE_COMMANDS:
|
|
233
|
+
raise ProviderError(ProviderErrorCode.UNSUPPORTED, "resource-kind")
|
|
234
|
+
|
|
235
|
+
return RESOURCE_COMMANDS[kind]
|
|
236
|
+
|
|
237
|
+
def _Run(
|
|
238
|
+
self, arguments: tuple[str, ...], operation: str, scoped: bool = True
|
|
239
|
+
) -> CommandResult:
|
|
240
|
+
"""Run one bounded command and discard vendor error details before propagating."""
|
|
241
|
+
|
|
242
|
+
flags = (
|
|
243
|
+
"--profile", self._settings.profile,
|
|
244
|
+
"--folder-id", self._settings.folder_id,
|
|
245
|
+
"--format", "json",
|
|
246
|
+
"--no-browser",
|
|
247
|
+
"--retry", "0",
|
|
248
|
+
) if scoped else ()
|
|
249
|
+
command = (self._settings.executable, *arguments, *flags)
|
|
250
|
+
|
|
251
|
+
failure_code: ProviderErrorCode | None = None
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
result = self._runner.Run(command, self._settings.command_timeout)
|
|
255
|
+
|
|
256
|
+
except subprocess.TimeoutExpired:
|
|
257
|
+
failure_code = ProviderErrorCode.TIMEOUT
|
|
258
|
+
|
|
259
|
+
except OSError:
|
|
260
|
+
failure_code = ProviderErrorCode.UNAVAILABLE
|
|
261
|
+
|
|
262
|
+
if failure_code is not None:
|
|
263
|
+
raise ProviderError(failure_code, operation)
|
|
264
|
+
|
|
265
|
+
if result.return_code != 0:
|
|
266
|
+
error = self._CommandFailure(arguments, result, operation)
|
|
267
|
+
del result
|
|
268
|
+
|
|
269
|
+
raise error
|
|
270
|
+
|
|
271
|
+
return result
|
|
272
|
+
|
|
273
|
+
def _CommandFailure(
|
|
274
|
+
self, arguments: tuple[str, ...], result: CommandResult, operation: str,
|
|
275
|
+
) -> ProviderError:
|
|
276
|
+
"""Classify transient command output without retaining raw vendor diagnostics."""
|
|
277
|
+
|
|
278
|
+
details = (result.stderr or result.stdout).casefold()
|
|
279
|
+
code = ProviderErrorCode.COMMAND_FAILED
|
|
280
|
+
|
|
281
|
+
for candidate, markers in ERROR_MARKERS:
|
|
282
|
+
if any(marker in details for marker in markers):
|
|
283
|
+
code = candidate
|
|
284
|
+
break
|
|
285
|
+
|
|
286
|
+
return ProviderError(code, operation)
|
|
287
|
+
|
|
288
|
+
def _ReadJson(self, arguments: tuple[str, ...], operation: str) -> object:
|
|
289
|
+
"""Parse JSON without retaining its text in propagated decode exceptions."""
|
|
290
|
+
|
|
291
|
+
result = self._Run(arguments, operation)
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
payload: object = json.loads(result.stdout)
|
|
295
|
+
|
|
296
|
+
except (ValueError, RecursionError):
|
|
297
|
+
pass
|
|
298
|
+
|
|
299
|
+
else:
|
|
300
|
+
return payload
|
|
301
|
+
|
|
302
|
+
del result
|
|
303
|
+
|
|
304
|
+
raise ProviderError(ProviderErrorCode.INVALID_RESPONSE, operation)
|
|
305
|
+
|
|
306
|
+
def _NormalizeResource(
|
|
307
|
+
self, payload: object, kind: ResourceKind, operation: str
|
|
308
|
+
) -> ProviderResource:
|
|
309
|
+
"""Keep only contract fields and fail closed on malformed identity or endpoints."""
|
|
310
|
+
|
|
311
|
+
if not isinstance(payload, dict):
|
|
312
|
+
raise ProviderError(ProviderErrorCode.INVALID_RESPONSE, operation)
|
|
313
|
+
|
|
314
|
+
if payload.get("folder_id") != self.Identity.scope_id:
|
|
315
|
+
raise ProviderError(ProviderErrorCode.SCOPE_MISMATCH, operation)
|
|
316
|
+
|
|
317
|
+
resource_id = payload.get("id")
|
|
318
|
+
name = payload.get("name", "")
|
|
319
|
+
status = payload.get("status", "UNKNOWN")
|
|
320
|
+
zone_id = payload.get("zone_id")
|
|
321
|
+
labels = payload.get("labels", {})
|
|
322
|
+
|
|
323
|
+
if (
|
|
324
|
+
not isinstance(resource_id, str)
|
|
325
|
+
or not isinstance(name, str)
|
|
326
|
+
or not isinstance(status, str)
|
|
327
|
+
or (zone_id is not None and not isinstance(zone_id, str))
|
|
328
|
+
or not isinstance(labels, dict)
|
|
329
|
+
or not all(
|
|
330
|
+
isinstance(key, str) and isinstance(value, str) for key, value in labels.items()
|
|
331
|
+
)
|
|
332
|
+
):
|
|
333
|
+
raise ProviderError(ProviderErrorCode.INVALID_RESPONSE, operation)
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
reference = ResourceReference(PROVIDER_ID, self.Identity.scope_id, kind, resource_id)
|
|
337
|
+
public_addresses = self._PublicAddresses(payload, kind)
|
|
338
|
+
resource = ProviderResource(
|
|
339
|
+
reference, name, status, zone_id, tuple(sorted(labels.items())), public_addresses
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
except (ValueError, TypeError, AttributeError):
|
|
343
|
+
pass
|
|
344
|
+
|
|
345
|
+
else:
|
|
346
|
+
return resource
|
|
347
|
+
|
|
348
|
+
raise ProviderError(ProviderErrorCode.INVALID_RESPONSE, operation)
|
|
349
|
+
|
|
350
|
+
@staticmethod
|
|
351
|
+
def _PublicAddresses(payload: dict[str, object], kind: ResourceKind) -> tuple[str, ...]:
|
|
352
|
+
"""Extract documented external IPv4 fields without retaining arbitrary metadata."""
|
|
353
|
+
|
|
354
|
+
addresses: list[str] = []
|
|
355
|
+
|
|
356
|
+
if kind == ResourceKind.ADDRESS:
|
|
357
|
+
external = payload.get("external_ipv4_address", {})
|
|
358
|
+
|
|
359
|
+
if not isinstance(external, dict):
|
|
360
|
+
raise ValueError("External address must be an object")
|
|
361
|
+
|
|
362
|
+
address = external.get("address")
|
|
363
|
+
|
|
364
|
+
if address is not None:
|
|
365
|
+
if not isinstance(address, str) or not address:
|
|
366
|
+
raise ValueError("External IPv4 address must be a non-empty string")
|
|
367
|
+
|
|
368
|
+
addresses.append(str(ipaddress.IPv4Address(address)))
|
|
369
|
+
|
|
370
|
+
elif kind == ResourceKind.INSTANCE:
|
|
371
|
+
interfaces = payload.get("network_interfaces", [])
|
|
372
|
+
|
|
373
|
+
if not isinstance(interfaces, list):
|
|
374
|
+
raise ValueError("Instance interfaces must be a list")
|
|
375
|
+
|
|
376
|
+
for interface in interfaces:
|
|
377
|
+
address = interface.get("primary_v4_address", {}).get("one_to_one_nat", {}).get(
|
|
378
|
+
"address"
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
if address is not None:
|
|
382
|
+
if not isinstance(address, str) or not address:
|
|
383
|
+
raise ValueError("External IPv4 address must be a non-empty string")
|
|
384
|
+
|
|
385
|
+
addresses.append(str(ipaddress.IPv4Address(address)))
|
|
386
|
+
|
|
387
|
+
return tuple(sorted(set(addresses)))
|