f-layer 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. f_layer-2.0.0.dist-info/METADATA +232 -0
  2. f_layer-2.0.0.dist-info/RECORD +45 -0
  3. f_layer-2.0.0.dist-info/WHEEL +4 -0
  4. f_layer-2.0.0.dist-info/entry_points.txt +2 -0
  5. f_layer-2.0.0.dist-info/licenses/LICENSE +201 -0
  6. flayer/__init__.py +5 -0
  7. flayer/__main__.py +247 -0
  8. flayer/_vpn_guest.py +130 -0
  9. flayer/core/__init__.py +1 -0
  10. flayer/core/config.py +198 -0
  11. flayer/core/contracts.py +114 -0
  12. flayer/core/lifecycle.py +767 -0
  13. flayer/core/state.py +351 -0
  14. flayer/diagnostics/__init__.py +20 -0
  15. flayer/diagnostics/benchmark.py +272 -0
  16. flayer/diagnostics/contracts.py +167 -0
  17. flayer/diagnostics/health.py +154 -0
  18. flayer/diagnostics/reporting.py +24 -0
  19. flayer/extensions/__init__.py +25 -0
  20. flayer/extensions/builtins.py +165 -0
  21. flayer/extensions/contracts.py +196 -0
  22. flayer/extensions/discovery.py +152 -0
  23. flayer/extensions/registry.py +347 -0
  24. flayer/guest.py +270 -0
  25. flayer/integrations/__init__.py +23 -0
  26. flayer/integrations/adapters.py +92 -0
  27. flayer/integrations/contracts.py +241 -0
  28. flayer/integrations/wire.py +273 -0
  29. flayer/profiles/__init__.py +33 -0
  30. flayer/profiles/_amnezia_export.py +176 -0
  31. flayer/profiles/_vless_guest.py +432 -0
  32. flayer/profiles/amneziawg.py +555 -0
  33. flayer/profiles/artifacts.py +609 -0
  34. flayer/profiles/gateway.py +729 -0
  35. flayer/profiles/static_site.py +596 -0
  36. flayer/profiles/vless.py +422 -0
  37. flayer/profiles/vpn.py +524 -0
  38. flayer/providers/__init__.py +1 -0
  39. flayer/providers/contracts.py +207 -0
  40. flayer/providers/lifecycle.py +177 -0
  41. flayer/providers/yandex.py +387 -0
  42. flayer/providers/yandex_lifecycle.py +781 -0
  43. flayer/py.typed +0 -0
  44. flayer/vpn_export.py +72 -0
  45. flayer/vpn_project.py +828 -0
@@ -0,0 +1,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)))