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,781 @@
|
|
|
1
|
+
"""Bounded Yandex mutations with explicit ownership and recoverable uncertain outcomes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import ipaddress
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import stat
|
|
12
|
+
import tempfile
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import cast
|
|
16
|
+
|
|
17
|
+
from flayer.core.contracts import StackIdentity, ValidateName
|
|
18
|
+
|
|
19
|
+
from .contracts import (
|
|
20
|
+
ProviderCapability,
|
|
21
|
+
ProviderError,
|
|
22
|
+
ProviderErrorCode,
|
|
23
|
+
ProviderResource,
|
|
24
|
+
ResourceKind,
|
|
25
|
+
ResourceReference,
|
|
26
|
+
ValidateIdentifier,
|
|
27
|
+
)
|
|
28
|
+
from .lifecycle import LOGICAL_ID_LABEL, OPERATION_ID_LABEL, JsonValue, MutationError, ResourceSpec
|
|
29
|
+
from .yandex import CommandResult, YandexCloudProvider
|
|
30
|
+
|
|
31
|
+
MAX_USER_DATA_BYTES = 262144
|
|
32
|
+
USERNAME_PATTERN = re.compile(r"[a-z_][a-z0-9_-]{0,31}\Z")
|
|
33
|
+
SHA256_PATTERN = re.compile(r"[a-f0-9]{64}\Z")
|
|
34
|
+
OPERATION_ID_PATTERN = re.compile(r"[a-f0-9]{32}\Z")
|
|
35
|
+
RESOURCE_NAME_PATTERN = re.compile(r"[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?\Z")
|
|
36
|
+
REQUIRED_PARAMETERS: dict[ResourceKind, frozenset[str]] = {
|
|
37
|
+
ResourceKind.NETWORK: frozenset(),
|
|
38
|
+
ResourceKind.SUBNET: frozenset({"zone_id", "ipv4_cidr", "network_dependency"}),
|
|
39
|
+
ResourceKind.SECURITY_GROUP: frozenset({"network_dependency", "rules"}),
|
|
40
|
+
ResourceKind.ADDRESS: frozenset({"zone_id"}),
|
|
41
|
+
ResourceKind.DISK: frozenset({"zone_id", "image_id", "size_gib"}),
|
|
42
|
+
ResourceKind.INSTANCE: frozenset({
|
|
43
|
+
"zone_id", "cores", "memory_gib", "boot_disk_dependency", "subnet_dependency",
|
|
44
|
+
"security_group_dependency", "address_dependency", "ssh_public_key",
|
|
45
|
+
}),
|
|
46
|
+
}
|
|
47
|
+
OPTIONAL_PARAMETERS: dict[ResourceKind, frozenset[str]] = {
|
|
48
|
+
ResourceKind.NETWORK: frozenset(),
|
|
49
|
+
ResourceKind.SUBNET: frozenset(),
|
|
50
|
+
ResourceKind.SECURITY_GROUP: frozenset(),
|
|
51
|
+
ResourceKind.ADDRESS: frozenset(),
|
|
52
|
+
ResourceKind.DISK: frozenset({"type"}),
|
|
53
|
+
ResourceKind.INSTANCE: frozenset({
|
|
54
|
+
"ssh_username", "user_data_file", "user_data_sha256", "platform_id", "core_fraction",
|
|
55
|
+
}),
|
|
56
|
+
}
|
|
57
|
+
DEPENDENCY_KINDS = {
|
|
58
|
+
"network_dependency": ResourceKind.NETWORK,
|
|
59
|
+
"boot_disk_dependency": ResourceKind.DISK,
|
|
60
|
+
"subnet_dependency": ResourceKind.SUBNET,
|
|
61
|
+
"security_group_dependency": ResourceKind.SECURITY_GROUP,
|
|
62
|
+
"address_dependency": ResourceKind.ADDRESS,
|
|
63
|
+
}
|
|
64
|
+
KNOWN_STATUSES = frozenset({
|
|
65
|
+
"UNKNOWN", "ACTIVE", "CREATING", "PROVISIONING", "READY", "RUNNING", "STARTING",
|
|
66
|
+
"STOPPING", "STOPPED", "RESTARTING", "UPDATING", "DELETING", "ERROR", "FAILED",
|
|
67
|
+
})
|
|
68
|
+
USER_DATA_FIELDS = frozenset({
|
|
69
|
+
"ssh_pwauth", "disable_root", "users", "package_update", "package_upgrade", "packages",
|
|
70
|
+
"write_files", "runcmd",
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _String(value: JsonValue) -> str:
|
|
75
|
+
"""Require a string without coercion or echoing external input."""
|
|
76
|
+
|
|
77
|
+
if not isinstance(value, str):
|
|
78
|
+
raise ValueError("Lifecycle option requires a string")
|
|
79
|
+
|
|
80
|
+
return value
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _Integer(value: JsonValue, minimum: int, maximum: int) -> int:
|
|
84
|
+
"""Bound numeric resource requests while rejecting booleans and floats."""
|
|
85
|
+
|
|
86
|
+
if type(value) is not int or not minimum <= value <= maximum:
|
|
87
|
+
raise ValueError("Lifecycle numeric option exceeds its bounds")
|
|
88
|
+
|
|
89
|
+
return value
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def ValidateComputeShape(options: Mapping[str, JsonValue]) -> None:
|
|
93
|
+
"""Validate the supported Intel shapes without consulting a cloud account."""
|
|
94
|
+
|
|
95
|
+
cores = _Integer(options["cores"], 2, 32)
|
|
96
|
+
memory = _Integer(options["memory_gib"], 1, 128)
|
|
97
|
+
|
|
98
|
+
if "platform_id" not in options and "core_fraction" not in options:
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
if "platform_id" not in options or "core_fraction" not in options:
|
|
102
|
+
raise ValueError("Specify platform_id and core_fraction together")
|
|
103
|
+
|
|
104
|
+
platform = _String(options["platform_id"])
|
|
105
|
+
fractions = {"standard-v1": (5, 20, 100), "standard-v2": (5, 20, 50, 100),
|
|
106
|
+
"standard-v3": (20, 50, 100)}
|
|
107
|
+
fraction = _Integer(options["core_fraction"], 1, 100)
|
|
108
|
+
|
|
109
|
+
if platform not in fractions or fraction not in fractions[platform]:
|
|
110
|
+
raise ValueError("Unsupported platform_id/core_fraction combination")
|
|
111
|
+
|
|
112
|
+
if _String(options.get("zone_id", "")).startswith("kz1-") and platform != "standard-v3":
|
|
113
|
+
raise ValueError("Kazakhstan deployments require platform_id standard-v3")
|
|
114
|
+
|
|
115
|
+
if fraction == 100:
|
|
116
|
+
valid_cores = (*range(2, 17, 2), 20, 24, 28, 32)
|
|
117
|
+
ratios = tuple(float(value) for value in range(1, 9 if platform == "standard-v1" else 17))
|
|
118
|
+
|
|
119
|
+
else:
|
|
120
|
+
valid_cores = (2, 4)
|
|
121
|
+
ratios = tuple(value / 2 for value in range(1, 5 if fraction == 5 else 9))
|
|
122
|
+
|
|
123
|
+
if platform == "standard-v2" and fraction == 5:
|
|
124
|
+
ratios = (0.25, *ratios)
|
|
125
|
+
|
|
126
|
+
if cores not in valid_cores or memory / cores not in ratios:
|
|
127
|
+
raise ValueError("Unsupported vCPU/RAM combination for the selected platform and core_fraction")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _PublicKey(value: JsonValue) -> str:
|
|
131
|
+
"""Accept a structurally valid public SSH key and discard optional comments."""
|
|
132
|
+
|
|
133
|
+
text = _String(value)
|
|
134
|
+
|
|
135
|
+
if len(text) > 8192 or "\n" in text or "\r" in text:
|
|
136
|
+
raise ValueError("SSH public key must be one bounded OpenSSH line")
|
|
137
|
+
|
|
138
|
+
parts = text.split()
|
|
139
|
+
|
|
140
|
+
if len(parts) < 2 or parts[0] not in {"ssh-ed25519", "ssh-rsa"}:
|
|
141
|
+
raise ValueError("SSH key must use a supported public key algorithm")
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
content = base64.b64decode(parts[1], validate=True)
|
|
145
|
+
offset = 0
|
|
146
|
+
fields: list[bytes] = []
|
|
147
|
+
|
|
148
|
+
while offset < len(content):
|
|
149
|
+
if len(content) - offset < 4:
|
|
150
|
+
raise ValueError("SSH public key field is truncated")
|
|
151
|
+
|
|
152
|
+
length = int.from_bytes(content[offset:offset + 4], "big")
|
|
153
|
+
offset += 4
|
|
154
|
+
|
|
155
|
+
if length < 1 or offset + length > len(content):
|
|
156
|
+
raise ValueError("SSH public key field is malformed")
|
|
157
|
+
|
|
158
|
+
fields.append(content[offset:offset + length])
|
|
159
|
+
offset += length
|
|
160
|
+
|
|
161
|
+
valid = bool(fields) and fields[0] == parts[0].encode("ascii")
|
|
162
|
+
|
|
163
|
+
if parts[0] == "ssh-ed25519":
|
|
164
|
+
valid = valid and len(fields) == 2 and len(fields[1]) == 32
|
|
165
|
+
|
|
166
|
+
else:
|
|
167
|
+
valid = valid and len(fields) == 3 and 2048 <= int.from_bytes(
|
|
168
|
+
fields[2], "big"
|
|
169
|
+
).bit_length() <= 16384 and int.from_bytes(fields[1], "big") >= 3
|
|
170
|
+
|
|
171
|
+
if not valid:
|
|
172
|
+
raise ValueError("SSH public key structure is unsupported")
|
|
173
|
+
|
|
174
|
+
except (ValueError, IndexError):
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
else:
|
|
178
|
+
return " ".join(parts[:2])
|
|
179
|
+
|
|
180
|
+
raise ValueError("SSH public key structure is unsupported")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _Rules(value: JsonValue) -> tuple[str, ...]:
|
|
184
|
+
"""Translate a strict bounded IPv4 rule subset without arbitrary CLI properties."""
|
|
185
|
+
|
|
186
|
+
if not isinstance(value, tuple) or not 1 <= len(value) <= 64:
|
|
187
|
+
raise ValueError("Security rules require 1..64 immutable rules")
|
|
188
|
+
|
|
189
|
+
results: list[str] = []
|
|
190
|
+
|
|
191
|
+
for row in value:
|
|
192
|
+
if not isinstance(row, tuple) or any(
|
|
193
|
+
not isinstance(pair, tuple) or len(pair) != 2 or not isinstance(pair[0], str)
|
|
194
|
+
for pair in row
|
|
195
|
+
):
|
|
196
|
+
raise ValueError("Security rule requires immutable named pairs")
|
|
197
|
+
|
|
198
|
+
rule = dict(cast(tuple[tuple[str, JsonValue], ...], row))
|
|
199
|
+
|
|
200
|
+
if len(rule) != len(row) or set(rule) - {
|
|
201
|
+
"direction", "protocol", "cidr", "from_port", "to_port"
|
|
202
|
+
} or not {"direction", "protocol", "cidr"} <= set(rule):
|
|
203
|
+
raise ValueError("Security rule contains missing or unsupported options")
|
|
204
|
+
|
|
205
|
+
direction = _String(rule["direction"])
|
|
206
|
+
protocol = _String(rule["protocol"])
|
|
207
|
+
|
|
208
|
+
if direction not in {"ingress", "egress"} or protocol not in {"tcp", "udp", "any"}:
|
|
209
|
+
raise ValueError("Security rule direction or protocol is unsupported")
|
|
210
|
+
|
|
211
|
+
cidr = str(ipaddress.IPv4Network(_String(rule["cidr"]), strict=True))
|
|
212
|
+
result = f"direction={direction},protocol={protocol},v4-cidrs={cidr}"
|
|
213
|
+
|
|
214
|
+
if protocol == "any":
|
|
215
|
+
if "from_port" in rule or "to_port" in rule:
|
|
216
|
+
raise ValueError("Any-protocol rules cannot specify ports")
|
|
217
|
+
|
|
218
|
+
# The CLI requires a port selector even for an unrestricted protocol.
|
|
219
|
+
result += ",from-port=0,to-port=65535"
|
|
220
|
+
|
|
221
|
+
else:
|
|
222
|
+
if not {"from_port", "to_port"} <= set(rule):
|
|
223
|
+
raise ValueError("TCP and UDP rules require an explicit port range")
|
|
224
|
+
|
|
225
|
+
lower = _Integer(rule["from_port"], 1, 65535)
|
|
226
|
+
upper = _Integer(rule["to_port"], lower, 65535)
|
|
227
|
+
result += f",from-port={lower},to-port={upper}"
|
|
228
|
+
|
|
229
|
+
results.append(result)
|
|
230
|
+
|
|
231
|
+
if len(set(results)) != len(results):
|
|
232
|
+
raise ValueError("Security rules must be unique")
|
|
233
|
+
|
|
234
|
+
return tuple(results)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class YandexLifecycleProvider(YandexCloudProvider):
|
|
238
|
+
"""Create and delete an explicit six-kind subset using existing read-only observation."""
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def Capabilities(self) -> frozenset[ProviderCapability]:
|
|
242
|
+
"""Advertise implemented mutations without promising credentials or account permission."""
|
|
243
|
+
|
|
244
|
+
return super().Capabilities | frozenset({
|
|
245
|
+
ProviderCapability.CREATE_RESOURCE, ProviderCapability.DELETE_RESOURCE,
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
def _Run(
|
|
249
|
+
self, arguments: tuple[str, ...], operation: str, scoped: bool = True
|
|
250
|
+
) -> CommandResult:
|
|
251
|
+
"""Sanitize unexpected custom transport failures for reads and mutations alike."""
|
|
252
|
+
|
|
253
|
+
failed = False
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
result = super()._Run(arguments, operation, scoped)
|
|
257
|
+
|
|
258
|
+
except ProviderError:
|
|
259
|
+
raise
|
|
260
|
+
|
|
261
|
+
except Exception:
|
|
262
|
+
failed = True
|
|
263
|
+
|
|
264
|
+
if failed:
|
|
265
|
+
raise ProviderError(ProviderErrorCode.COMMAND_FAILED, operation)
|
|
266
|
+
|
|
267
|
+
return result
|
|
268
|
+
|
|
269
|
+
def ValidateSpec(self, spec: ResourceSpec) -> None:
|
|
270
|
+
"""Validate the complete supported option/dependency subset without cloud access."""
|
|
271
|
+
|
|
272
|
+
failure = False
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
if not isinstance(spec, ResourceSpec):
|
|
276
|
+
raise ValueError("Lifecycle requests require ResourceSpec")
|
|
277
|
+
|
|
278
|
+
if RESOURCE_NAME_PATTERN.fullmatch(spec.name) is None:
|
|
279
|
+
raise ValueError("Resource name violates provider naming requirements")
|
|
280
|
+
|
|
281
|
+
options = dict(spec.parameters)
|
|
282
|
+
required = REQUIRED_PARAMETERS[spec.kind]
|
|
283
|
+
|
|
284
|
+
if not required <= options.keys() or options.keys() - required - OPTIONAL_PARAMETERS[
|
|
285
|
+
spec.kind
|
|
286
|
+
]:
|
|
287
|
+
raise ValueError("Unsupported lifecycle options")
|
|
288
|
+
|
|
289
|
+
expected: set[str] = set()
|
|
290
|
+
|
|
291
|
+
for key in options:
|
|
292
|
+
if key in DEPENDENCY_KINDS:
|
|
293
|
+
dependency = _String(options[key])
|
|
294
|
+
ValidateName(dependency, "dependency")
|
|
295
|
+
expected.add(dependency)
|
|
296
|
+
|
|
297
|
+
dependency_options = sum(key in DEPENDENCY_KINDS for key in options)
|
|
298
|
+
|
|
299
|
+
if expected != set(spec.dependencies) or len(expected) != dependency_options:
|
|
300
|
+
raise ValueError("Dependencies must match explicit typed dependency options")
|
|
301
|
+
|
|
302
|
+
if "zone_id" in options:
|
|
303
|
+
ValidateIdentifier(_String(options["zone_id"]))
|
|
304
|
+
|
|
305
|
+
if spec.kind == ResourceKind.SUBNET:
|
|
306
|
+
ipaddress.IPv4Network(_String(options["ipv4_cidr"]), strict=True)
|
|
307
|
+
|
|
308
|
+
elif spec.kind == ResourceKind.SECURITY_GROUP:
|
|
309
|
+
_Rules(options["rules"])
|
|
310
|
+
|
|
311
|
+
elif spec.kind == ResourceKind.DISK:
|
|
312
|
+
ValidateIdentifier(_String(options["image_id"]))
|
|
313
|
+
_Integer(options["size_gib"], 10, 1024)
|
|
314
|
+
|
|
315
|
+
if options.get("type", "network-ssd") not in {"network-ssd", "network-hdd"}:
|
|
316
|
+
raise ValueError("Unsupported boot disk type")
|
|
317
|
+
|
|
318
|
+
elif spec.kind == ResourceKind.INSTANCE:
|
|
319
|
+
ValidateComputeShape(options)
|
|
320
|
+
_PublicKey(options["ssh_public_key"])
|
|
321
|
+
|
|
322
|
+
if USERNAME_PATTERN.fullmatch(_String(options.get("ssh_username", "yc-user"))) is None:
|
|
323
|
+
raise ValueError("Unsupported SSH username")
|
|
324
|
+
|
|
325
|
+
if ("user_data_file" in options) != ("user_data_sha256" in options):
|
|
326
|
+
raise ValueError("Initialization file requires its immutable content digest")
|
|
327
|
+
|
|
328
|
+
if "user_data_file" in options:
|
|
329
|
+
path = Path(_String(options["user_data_file"]))
|
|
330
|
+
|
|
331
|
+
if not str(path) or ".." in path.parts or "\x00" in str(path):
|
|
332
|
+
raise ValueError("Initialization file path is unsafe")
|
|
333
|
+
|
|
334
|
+
if SHA256_PATTERN.fullmatch(_String(options["user_data_sha256"])) is None:
|
|
335
|
+
raise ValueError("Initialization digest must be SHA256")
|
|
336
|
+
|
|
337
|
+
except (ValueError, TypeError, KeyError):
|
|
338
|
+
failure = True
|
|
339
|
+
|
|
340
|
+
if failure:
|
|
341
|
+
raise MutationError(ProviderErrorCode.UNSUPPORTED, "validate-spec", False)
|
|
342
|
+
|
|
343
|
+
def _ValidateIdentity(self, identity: StackIdentity) -> None:
|
|
344
|
+
"""Require the selected provider and folder to match the complete stack boundary."""
|
|
345
|
+
|
|
346
|
+
if not isinstance(identity, StackIdentity) or (
|
|
347
|
+
identity.provider != self.Identity.provider_id
|
|
348
|
+
or identity.scope_id != self.Identity.scope_id
|
|
349
|
+
):
|
|
350
|
+
raise MutationError(ProviderErrorCode.SCOPE_MISMATCH, "identity", False)
|
|
351
|
+
|
|
352
|
+
@staticmethod
|
|
353
|
+
def _ValidateObservation(resource: ProviderResource) -> ProviderResource:
|
|
354
|
+
"""Reject opaque vendor status/name text before it reaches orchestration or reports."""
|
|
355
|
+
|
|
356
|
+
failed = resource.status not in KNOWN_STATUSES
|
|
357
|
+
|
|
358
|
+
try:
|
|
359
|
+
ValidateName(resource.name, "observed.name")
|
|
360
|
+
|
|
361
|
+
except ValueError:
|
|
362
|
+
failed = True
|
|
363
|
+
|
|
364
|
+
if failed:
|
|
365
|
+
raise ProviderError(ProviderErrorCode.INVALID_RESPONSE, "observe")
|
|
366
|
+
|
|
367
|
+
return resource
|
|
368
|
+
|
|
369
|
+
def GetResource(self, reference: ResourceReference) -> ProviderResource:
|
|
370
|
+
"""Use the scoped base adapter while restricting observed lifecycle fields."""
|
|
371
|
+
|
|
372
|
+
return self._ValidateObservation(super().GetResource(reference))
|
|
373
|
+
|
|
374
|
+
def FindResource(
|
|
375
|
+
self, spec: ResourceSpec, identity: StackIdentity
|
|
376
|
+
) -> ProviderResource | None:
|
|
377
|
+
"""Reconcile exact logical ownership; duplicates and changed desired content conflict."""
|
|
378
|
+
|
|
379
|
+
self.ValidateSpec(spec)
|
|
380
|
+
self._ValidateIdentity(identity)
|
|
381
|
+
labels = identity.OwnershipLabels()
|
|
382
|
+
labels[LOGICAL_ID_LABEL] = spec.logical_id
|
|
383
|
+
ownership = tuple(sorted(labels.items()))
|
|
384
|
+
matches = tuple(
|
|
385
|
+
resource for resource in self.ListResources(spec.kind) if resource.HasLabels(ownership)
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
if len(matches) > 1:
|
|
389
|
+
raise ProviderError(ProviderErrorCode.CONFLICT, "find")
|
|
390
|
+
|
|
391
|
+
if not matches:
|
|
392
|
+
return None
|
|
393
|
+
|
|
394
|
+
resource = self._ValidateObservation(matches[0])
|
|
395
|
+
|
|
396
|
+
if not resource.HasLabels(spec.OwnershipLabels(identity)) or resource.name != spec.name:
|
|
397
|
+
raise ProviderError(ProviderErrorCode.CONFLICT, "find")
|
|
398
|
+
|
|
399
|
+
options = dict(spec.parameters)
|
|
400
|
+
|
|
401
|
+
if "zone_id" in options and self._ResourceZone(resource) != options["zone_id"]:
|
|
402
|
+
raise ProviderError(ProviderErrorCode.SCOPE_MISMATCH, "find")
|
|
403
|
+
|
|
404
|
+
return resource
|
|
405
|
+
|
|
406
|
+
@staticmethod
|
|
407
|
+
def _ResourceZone(resource: ProviderResource) -> str | None:
|
|
408
|
+
"""Return the normalized zone; address adapters populate the nested IPv4 zone."""
|
|
409
|
+
|
|
410
|
+
return resource.zone_id
|
|
411
|
+
|
|
412
|
+
def _NormalizeResource(
|
|
413
|
+
self, payload: object, kind: ResourceKind, operation: str
|
|
414
|
+
) -> ProviderResource:
|
|
415
|
+
"""Preserve the documented address allocation zone without storing vendor metadata."""
|
|
416
|
+
|
|
417
|
+
if kind == ResourceKind.ADDRESS and isinstance(payload, dict):
|
|
418
|
+
external = payload.get("external_ipv4_address")
|
|
419
|
+
|
|
420
|
+
if isinstance(external, dict) and "zone_id" in external:
|
|
421
|
+
payload = {**payload, "zone_id": external["zone_id"]}
|
|
422
|
+
|
|
423
|
+
return super()._NormalizeResource(payload, kind, operation)
|
|
424
|
+
|
|
425
|
+
def _Dependencies(
|
|
426
|
+
self, spec: ResourceSpec, identity: StackIdentity,
|
|
427
|
+
dependencies: Mapping[str, ProviderResource],
|
|
428
|
+
) -> dict[str, ProviderResource]:
|
|
429
|
+
"""Refresh every dependency by ID and verify type, scope, complete ownership and zone."""
|
|
430
|
+
|
|
431
|
+
if not isinstance(dependencies, Mapping) or set(dependencies) != set(spec.dependencies):
|
|
432
|
+
raise MutationError(ProviderErrorCode.UNSUPPORTED, "dependencies", False)
|
|
433
|
+
|
|
434
|
+
options = dict(spec.parameters)
|
|
435
|
+
refreshed: dict[str, ProviderResource] = {}
|
|
436
|
+
|
|
437
|
+
for key, kind in DEPENDENCY_KINDS.items():
|
|
438
|
+
if key not in options:
|
|
439
|
+
continue
|
|
440
|
+
|
|
441
|
+
logical_id = _String(options[key])
|
|
442
|
+
resource = dependencies[logical_id]
|
|
443
|
+
|
|
444
|
+
if not isinstance(resource, ProviderResource) or resource.reference.kind != kind:
|
|
445
|
+
raise MutationError(ProviderErrorCode.SCOPE_MISMATCH, "dependencies", False)
|
|
446
|
+
|
|
447
|
+
observed = self.GetResource(resource.reference)
|
|
448
|
+
labels = identity.OwnershipLabels()
|
|
449
|
+
labels[LOGICAL_ID_LABEL] = logical_id
|
|
450
|
+
|
|
451
|
+
if not observed.HasLabels(tuple(sorted(labels.items()))):
|
|
452
|
+
raise MutationError(ProviderErrorCode.SCOPE_MISMATCH, "dependencies", False)
|
|
453
|
+
|
|
454
|
+
if kind in {ResourceKind.SUBNET, ResourceKind.DISK, ResourceKind.ADDRESS} and (
|
|
455
|
+
observed.zone_id != options.get("zone_id")
|
|
456
|
+
):
|
|
457
|
+
raise MutationError(ProviderErrorCode.SCOPE_MISMATCH, "dependencies", False)
|
|
458
|
+
|
|
459
|
+
if observed.status not in {"UNKNOWN", "ACTIVE", "READY", "RUNNING"}:
|
|
460
|
+
raise MutationError(ProviderErrorCode.CONFLICT, "dependencies", False)
|
|
461
|
+
|
|
462
|
+
refreshed[logical_id] = observed
|
|
463
|
+
|
|
464
|
+
return refreshed
|
|
465
|
+
|
|
466
|
+
@staticmethod
|
|
467
|
+
def _ReadUserData(path: Path, expected_digest: str) -> bytes:
|
|
468
|
+
"""Read one owned, private, bounded nonsecret cloud-init JSON artifact without symlinks."""
|
|
469
|
+
|
|
470
|
+
descriptor: int | None = None
|
|
471
|
+
content: bytes | None = None
|
|
472
|
+
|
|
473
|
+
try:
|
|
474
|
+
absolute = path.absolute()
|
|
475
|
+
|
|
476
|
+
for component in (absolute, *absolute.parents):
|
|
477
|
+
if component.is_symlink():
|
|
478
|
+
raise ValueError("Initialization paths cannot contain symbolic links")
|
|
479
|
+
|
|
480
|
+
before = absolute.lstat()
|
|
481
|
+
|
|
482
|
+
if not stat.S_ISREG(before.st_mode):
|
|
483
|
+
raise ValueError("Initialization artifact must be a regular file")
|
|
484
|
+
|
|
485
|
+
descriptor = os.open(
|
|
486
|
+
absolute, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
|
|
487
|
+
)
|
|
488
|
+
actual = os.fstat(descriptor)
|
|
489
|
+
|
|
490
|
+
if not stat.S_ISREG(actual.st_mode) or (actual.st_dev, actual.st_ino) != (
|
|
491
|
+
before.st_dev, before.st_ino
|
|
492
|
+
) or not 0 < actual.st_size <= MAX_USER_DATA_BYTES:
|
|
493
|
+
raise ValueError("Initialization artifact is unsafe")
|
|
494
|
+
|
|
495
|
+
if os.name == "posix" and (
|
|
496
|
+
actual.st_uid != os.getuid() or stat.S_IMODE(actual.st_mode) & 0o077
|
|
497
|
+
):
|
|
498
|
+
raise ValueError("Initialization artifact must be private and owned")
|
|
499
|
+
|
|
500
|
+
with os.fdopen(descriptor, "rb") as source:
|
|
501
|
+
descriptor = None
|
|
502
|
+
data = source.read(MAX_USER_DATA_BYTES + 1)
|
|
503
|
+
|
|
504
|
+
if len(data) > MAX_USER_DATA_BYTES or hashlib.sha256(data).hexdigest() != expected_digest:
|
|
505
|
+
raise ValueError("Initialization artifact content differs from the plan")
|
|
506
|
+
|
|
507
|
+
text = data.decode("utf-8")
|
|
508
|
+
|
|
509
|
+
if text.startswith("#cloud-config\n"):
|
|
510
|
+
text = text[len("#cloud-config\n"):]
|
|
511
|
+
|
|
512
|
+
body = json.loads(text)
|
|
513
|
+
|
|
514
|
+
if not isinstance(body, dict) or set(body) - USER_DATA_FIELDS:
|
|
515
|
+
raise ValueError("Initialization artifact contains unsupported fields")
|
|
516
|
+
|
|
517
|
+
content = data
|
|
518
|
+
|
|
519
|
+
except (OSError, ValueError, RecursionError):
|
|
520
|
+
pass
|
|
521
|
+
|
|
522
|
+
finally:
|
|
523
|
+
if descriptor is not None:
|
|
524
|
+
os.close(descriptor)
|
|
525
|
+
|
|
526
|
+
if content is None:
|
|
527
|
+
raise MutationError(ProviderErrorCode.UNSUPPORTED, "initialization", False)
|
|
528
|
+
|
|
529
|
+
return content
|
|
530
|
+
|
|
531
|
+
def _Arguments(
|
|
532
|
+
self, spec: ResourceSpec, dependencies: Mapping[str, ProviderResource]
|
|
533
|
+
) -> tuple[str, ...]:
|
|
534
|
+
"""Translate only the verified six-kind subset to documented direct CLI arguments."""
|
|
535
|
+
|
|
536
|
+
options = dict(spec.parameters)
|
|
537
|
+
arguments: list[str] = []
|
|
538
|
+
|
|
539
|
+
if spec.kind == ResourceKind.SUBNET:
|
|
540
|
+
network = dependencies[_String(options["network_dependency"])]
|
|
541
|
+
arguments.extend((
|
|
542
|
+
"--network-id", network.reference.resource_id, "--zone", _String(options["zone_id"]),
|
|
543
|
+
"--range", _String(options["ipv4_cidr"]),
|
|
544
|
+
))
|
|
545
|
+
|
|
546
|
+
elif spec.kind == ResourceKind.SECURITY_GROUP:
|
|
547
|
+
network = dependencies[_String(options["network_dependency"])]
|
|
548
|
+
arguments.extend(("--network-id", network.reference.resource_id))
|
|
549
|
+
|
|
550
|
+
for rule in _Rules(options["rules"]):
|
|
551
|
+
arguments.extend(("--rule", rule))
|
|
552
|
+
|
|
553
|
+
elif spec.kind == ResourceKind.ADDRESS:
|
|
554
|
+
arguments.extend(("--external-ipv4", f"zone={options['zone_id']}"))
|
|
555
|
+
|
|
556
|
+
elif spec.kind == ResourceKind.DISK:
|
|
557
|
+
arguments.extend((
|
|
558
|
+
"--zone", _String(options["zone_id"]), "--source-image-id", _String(options["image_id"]),
|
|
559
|
+
"--size", str(options["size_gib"]), "--type", _String(options.get("type", "network-ssd")),
|
|
560
|
+
))
|
|
561
|
+
|
|
562
|
+
elif spec.kind == ResourceKind.INSTANCE:
|
|
563
|
+
disk = dependencies[_String(options["boot_disk_dependency"])]
|
|
564
|
+
subnet = dependencies[_String(options["subnet_dependency"])]
|
|
565
|
+
group = dependencies[_String(options["security_group_dependency"])]
|
|
566
|
+
address = dependencies[_String(options["address_dependency"])]
|
|
567
|
+
|
|
568
|
+
if len(address.public_addresses) != 1:
|
|
569
|
+
raise MutationError(ProviderErrorCode.INVALID_RESPONSE, "address", False)
|
|
570
|
+
|
|
571
|
+
interface = (
|
|
572
|
+
f"subnet-id={subnet.reference.resource_id},nat-ip-version=ipv4,"
|
|
573
|
+
f"nat-address={address.public_addresses[0]},"
|
|
574
|
+
f"security-group-ids={group.reference.resource_id}"
|
|
575
|
+
)
|
|
576
|
+
username = _String(options.get("ssh_username", "yc-user"))
|
|
577
|
+
public_key = _PublicKey(options["ssh_public_key"])
|
|
578
|
+
arguments.extend((
|
|
579
|
+
"--zone", _String(options["zone_id"]), "--cores", str(options["cores"]),
|
|
580
|
+
"--memory", str(options["memory_gib"]),
|
|
581
|
+
"--use-boot-disk", f"disk-id={disk.reference.resource_id},auto-delete=false",
|
|
582
|
+
"--network-interface", interface, "--metadata", f"ssh-keys={username}:{public_key}",
|
|
583
|
+
))
|
|
584
|
+
|
|
585
|
+
if "platform_id" in options:
|
|
586
|
+
arguments.extend(("--platform", _String(options["platform_id"]),
|
|
587
|
+
"--core-fraction", str(options["core_fraction"])))
|
|
588
|
+
|
|
589
|
+
return tuple(arguments)
|
|
590
|
+
|
|
591
|
+
def _CommandFailure(
|
|
592
|
+
self, arguments: tuple[str, ...], result: CommandResult, operation: str,
|
|
593
|
+
) -> ProviderError:
|
|
594
|
+
"""Recognize only proven local parser refusals, never generic RPC error categories."""
|
|
595
|
+
|
|
596
|
+
if result.return_code == 1 and not result.stdout.strip() and operation == "create":
|
|
597
|
+
message = result.stderr.strip()
|
|
598
|
+
unknown_flag = re.fullmatch(r"ERROR: execute command: unknown flag: (--[a-z][a-z0-9-]*)", message)
|
|
599
|
+
rule_rejection = (
|
|
600
|
+
arguments[:3] == ("vpc", "security-group", "create")
|
|
601
|
+
and message == "ERROR: execute command: run command: for rule spec port or [from/to]-port fields is required"
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
if rule_rejection or (unknown_flag is not None and unknown_flag[1] in arguments):
|
|
605
|
+
return MutationError(ProviderErrorCode.UNSUPPORTED, operation, False)
|
|
606
|
+
|
|
607
|
+
return super()._CommandFailure(arguments, result, operation)
|
|
608
|
+
|
|
609
|
+
def _MutationRun(self, arguments: tuple[str, ...], operation: str) -> CommandResult:
|
|
610
|
+
"""Never retry a dispatched mutation or retain raw runner exceptions in the error."""
|
|
611
|
+
|
|
612
|
+
code: ProviderErrorCode | None = None
|
|
613
|
+
outcome_unknown = True
|
|
614
|
+
|
|
615
|
+
try:
|
|
616
|
+
result = self._Run(arguments, operation)
|
|
617
|
+
|
|
618
|
+
except ProviderError as error:
|
|
619
|
+
code = error.code
|
|
620
|
+
|
|
621
|
+
if isinstance(error, MutationError):
|
|
622
|
+
outcome_unknown = error.outcome_unknown
|
|
623
|
+
|
|
624
|
+
except Exception:
|
|
625
|
+
code = ProviderErrorCode.COMMAND_FAILED
|
|
626
|
+
|
|
627
|
+
else:
|
|
628
|
+
return result
|
|
629
|
+
|
|
630
|
+
raise MutationError(code or ProviderErrorCode.COMMAND_FAILED, operation, outcome_unknown)
|
|
631
|
+
|
|
632
|
+
def CreateResource(
|
|
633
|
+
self, spec: ResourceSpec, identity: StackIdentity,
|
|
634
|
+
dependencies: Mapping[str, ProviderResource], operation_id: str,
|
|
635
|
+
) -> ProviderResource:
|
|
636
|
+
"""Create once with stable ownership labels and an explicit, separately owned boot disk."""
|
|
637
|
+
|
|
638
|
+
self.ValidateSpec(spec)
|
|
639
|
+
self._ValidateIdentity(identity)
|
|
640
|
+
|
|
641
|
+
if not isinstance(operation_id, str) or OPERATION_ID_PATTERN.fullmatch(operation_id) is None:
|
|
642
|
+
raise MutationError(ProviderErrorCode.UNSUPPORTED, "operation-id", False)
|
|
643
|
+
|
|
644
|
+
options = dict(spec.parameters)
|
|
645
|
+
content: bytes | None = None
|
|
646
|
+
existing: ProviderResource | None = None
|
|
647
|
+
arguments: tuple[str, ...] = ()
|
|
648
|
+
preflight_failure: ProviderErrorCode | None = None
|
|
649
|
+
|
|
650
|
+
try:
|
|
651
|
+
content = self._ReadUserData(
|
|
652
|
+
Path(_String(options["user_data_file"])), _String(options["user_data_sha256"])
|
|
653
|
+
) if "user_data_file" in options else None
|
|
654
|
+
existing = self.FindResource(spec, identity)
|
|
655
|
+
|
|
656
|
+
if existing is None:
|
|
657
|
+
refreshed = self._Dependencies(spec, identity, dependencies)
|
|
658
|
+
arguments = self._Arguments(spec, refreshed)
|
|
659
|
+
|
|
660
|
+
except MutationError:
|
|
661
|
+
raise
|
|
662
|
+
|
|
663
|
+
except ProviderError as error:
|
|
664
|
+
preflight_failure = error.code
|
|
665
|
+
|
|
666
|
+
except Exception:
|
|
667
|
+
preflight_failure = ProviderErrorCode.COMMAND_FAILED
|
|
668
|
+
|
|
669
|
+
if preflight_failure is not None:
|
|
670
|
+
raise MutationError(preflight_failure, "create", False)
|
|
671
|
+
|
|
672
|
+
if existing is not None:
|
|
673
|
+
return existing
|
|
674
|
+
|
|
675
|
+
creation_labels = (*spec.OwnershipLabels(identity), (OPERATION_ID_LABEL, operation_id))
|
|
676
|
+
labels = ",".join(f"{key}={value}" for key, value in creation_labels)
|
|
677
|
+
command = (*self._CommandForKind(spec.kind), "create", "--name", spec.name, "--labels", labels)
|
|
678
|
+
temporary_path: str | None = None
|
|
679
|
+
mutation_started = False
|
|
680
|
+
failure: ProviderErrorCode | None = None
|
|
681
|
+
created: ProviderResource | None = None
|
|
682
|
+
|
|
683
|
+
try:
|
|
684
|
+
if content is not None:
|
|
685
|
+
with tempfile.NamedTemporaryFile(mode="wb", delete=False) as destination:
|
|
686
|
+
temporary_path = destination.name
|
|
687
|
+
destination.write(content)
|
|
688
|
+
destination.flush()
|
|
689
|
+
os.fsync(destination.fileno())
|
|
690
|
+
|
|
691
|
+
arguments = (*arguments, "--metadata-from-file", f"user-data={temporary_path}")
|
|
692
|
+
|
|
693
|
+
mutation_started = True
|
|
694
|
+
result = self._MutationRun((*command, *arguments), "create")
|
|
695
|
+
|
|
696
|
+
try:
|
|
697
|
+
payload: object = json.loads(result.stdout)
|
|
698
|
+
|
|
699
|
+
except (ValueError, RecursionError):
|
|
700
|
+
failure = ProviderErrorCode.INVALID_RESPONSE
|
|
701
|
+
|
|
702
|
+
else:
|
|
703
|
+
resource = self._ValidateObservation(self._NormalizeResource(payload, spec.kind, "create"))
|
|
704
|
+
|
|
705
|
+
if not resource.HasLabels(creation_labels) or resource.name != spec.name:
|
|
706
|
+
failure = ProviderErrorCode.SCOPE_MISMATCH
|
|
707
|
+
|
|
708
|
+
elif "zone_id" in options and resource.zone_id != options["zone_id"]:
|
|
709
|
+
failure = ProviderErrorCode.SCOPE_MISMATCH
|
|
710
|
+
|
|
711
|
+
else:
|
|
712
|
+
created = resource
|
|
713
|
+
|
|
714
|
+
except MutationError:
|
|
715
|
+
raise
|
|
716
|
+
|
|
717
|
+
except ProviderError as error:
|
|
718
|
+
failure = error.code
|
|
719
|
+
|
|
720
|
+
except (OSError, ValueError):
|
|
721
|
+
failure = ProviderErrorCode.COMMAND_FAILED
|
|
722
|
+
|
|
723
|
+
finally:
|
|
724
|
+
if temporary_path is not None:
|
|
725
|
+
try:
|
|
726
|
+
os.unlink(temporary_path)
|
|
727
|
+
|
|
728
|
+
except FileNotFoundError:
|
|
729
|
+
pass
|
|
730
|
+
|
|
731
|
+
except OSError:
|
|
732
|
+
failure = ProviderErrorCode.COMMAND_FAILED
|
|
733
|
+
|
|
734
|
+
if failure is None and created is not None:
|
|
735
|
+
return created
|
|
736
|
+
|
|
737
|
+
raise MutationError(failure or ProviderErrorCode.INVALID_RESPONSE, "create", mutation_started)
|
|
738
|
+
|
|
739
|
+
def DeleteResource(
|
|
740
|
+
self, reference: ResourceReference, identity: StackIdentity, logical_id: str,
|
|
741
|
+
operation_id: str | None = None,
|
|
742
|
+
) -> None:
|
|
743
|
+
"""Observe and verify ownership before exact-ID deletion, then confirm absence once."""
|
|
744
|
+
|
|
745
|
+
self._ValidateIdentity(identity)
|
|
746
|
+
ValidateName(logical_id, "delete.logical_id")
|
|
747
|
+
labels = identity.OwnershipLabels()
|
|
748
|
+
labels[LOGICAL_ID_LABEL] = logical_id
|
|
749
|
+
|
|
750
|
+
if operation_id is not None:
|
|
751
|
+
if not isinstance(operation_id, str) or OPERATION_ID_PATTERN.fullmatch(operation_id) is None:
|
|
752
|
+
raise MutationError(ProviderErrorCode.UNSUPPORTED, "operation-id", False)
|
|
753
|
+
|
|
754
|
+
labels[OPERATION_ID_LABEL] = operation_id
|
|
755
|
+
|
|
756
|
+
try:
|
|
757
|
+
resource = self.GetResource(reference)
|
|
758
|
+
|
|
759
|
+
except ProviderError as error:
|
|
760
|
+
if error.code == ProviderErrorCode.NOT_FOUND:
|
|
761
|
+
return
|
|
762
|
+
|
|
763
|
+
raise
|
|
764
|
+
|
|
765
|
+
if not resource.HasLabels(tuple(sorted(labels.items()))):
|
|
766
|
+
raise MutationError(ProviderErrorCode.SCOPE_MISMATCH, "delete", False)
|
|
767
|
+
|
|
768
|
+
command = self._CommandForKind(reference.kind)
|
|
769
|
+
self._MutationRun((*command, "delete", "--id", reference.resource_id), "delete")
|
|
770
|
+
failure = ProviderErrorCode.CONFLICT
|
|
771
|
+
|
|
772
|
+
try:
|
|
773
|
+
self.GetResource(reference)
|
|
774
|
+
|
|
775
|
+
except ProviderError as error:
|
|
776
|
+
if error.code == ProviderErrorCode.NOT_FOUND:
|
|
777
|
+
return
|
|
778
|
+
|
|
779
|
+
failure = error.code
|
|
780
|
+
|
|
781
|
+
raise MutationError(failure, "delete", True)
|