@le-space/rootfs 0.3.23 → 0.3.25
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.
package/package.json
CHANGED
|
@@ -111,31 +111,49 @@ def sign_aleph_message(unsigned_message: dict[str, object], private_key: str) ->
|
|
|
111
111
|
return signed
|
|
112
112
|
|
|
113
113
|
|
|
114
|
-
def
|
|
114
|
+
def iter_validation_errors(payload: object) -> list[dict]:
|
|
115
|
+
if isinstance(payload, list):
|
|
116
|
+
return [entry for entry in payload if isinstance(entry, dict)]
|
|
117
|
+
return []
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def is_invalid_message_format(http_status: int, payload: object) -> bool:
|
|
115
121
|
if http_status != 422:
|
|
116
122
|
return False
|
|
117
|
-
|
|
118
|
-
if isinstance(
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
123
|
+
|
|
124
|
+
if isinstance(payload, dict):
|
|
125
|
+
details = payload.get("details")
|
|
126
|
+
if isinstance(details, str) and "InvalidMessageFormat" in details:
|
|
127
|
+
return True
|
|
128
|
+
if isinstance(details, dict):
|
|
129
|
+
message = details.get("message")
|
|
130
|
+
if isinstance(message, str) and "InvalidMessageFormat" in message:
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
for entry in iter_validation_errors(payload):
|
|
134
|
+
message = entry.get("msg")
|
|
135
|
+
location = entry.get("loc")
|
|
122
136
|
if isinstance(message, str) and "InvalidMessageFormat" in message:
|
|
123
137
|
return True
|
|
138
|
+
if message == "Field required" and location == ["message"]:
|
|
139
|
+
return True
|
|
140
|
+
|
|
124
141
|
return False
|
|
125
142
|
|
|
126
143
|
|
|
127
|
-
def is_retryable_broadcast_failure(http_status: int, payload:
|
|
144
|
+
def is_retryable_broadcast_failure(http_status: int, payload: object) -> bool:
|
|
128
145
|
if http_status >= 500:
|
|
129
146
|
return True
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
147
|
+
if isinstance(payload, dict):
|
|
148
|
+
publication_status = payload.get("publication_status")
|
|
149
|
+
if isinstance(publication_status, dict):
|
|
150
|
+
status = publication_status.get("status")
|
|
151
|
+
if isinstance(status, str) and status.strip().lower() == "error":
|
|
152
|
+
return True
|
|
135
153
|
return False
|
|
136
154
|
|
|
137
155
|
|
|
138
|
-
def broadcast_aleph_message(api_host: str, message: dict[str, object]) -> tuple[int,
|
|
156
|
+
def broadcast_aleph_message(api_host: str, message: dict[str, object]) -> tuple[int, object]:
|
|
139
157
|
url = urllib.parse.urljoin(api_host.rstrip("/") + "/", "api/v0/messages")
|
|
140
158
|
attempts = [
|
|
141
159
|
{"sync": True, "message": message},
|
|
@@ -303,31 +303,49 @@ def sign_aleph_message(unsigned_message: dict[str, object], private_key: str) ->
|
|
|
303
303
|
return signed
|
|
304
304
|
|
|
305
305
|
|
|
306
|
-
def
|
|
306
|
+
def iter_validation_errors(payload: object) -> list[dict]:
|
|
307
|
+
if isinstance(payload, list):
|
|
308
|
+
return [entry for entry in payload if isinstance(entry, dict)]
|
|
309
|
+
return []
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def is_invalid_message_format(http_status: int, payload: object) -> bool:
|
|
307
313
|
if http_status != 422:
|
|
308
314
|
return False
|
|
309
|
-
|
|
310
|
-
if isinstance(
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
315
|
+
|
|
316
|
+
if isinstance(payload, dict):
|
|
317
|
+
details = payload.get("details")
|
|
318
|
+
if isinstance(details, str) and "InvalidMessageFormat" in details:
|
|
319
|
+
return True
|
|
320
|
+
if isinstance(details, dict):
|
|
321
|
+
message = details.get("message")
|
|
322
|
+
if isinstance(message, str) and "InvalidMessageFormat" in message:
|
|
323
|
+
return True
|
|
324
|
+
|
|
325
|
+
for entry in iter_validation_errors(payload):
|
|
326
|
+
message = entry.get("msg")
|
|
327
|
+
location = entry.get("loc")
|
|
314
328
|
if isinstance(message, str) and "InvalidMessageFormat" in message:
|
|
315
329
|
return True
|
|
330
|
+
if message == "Field required" and location == ["message"]:
|
|
331
|
+
return True
|
|
332
|
+
|
|
316
333
|
return False
|
|
317
334
|
|
|
318
335
|
|
|
319
|
-
def is_retryable_broadcast_failure(http_status: int, payload:
|
|
336
|
+
def is_retryable_broadcast_failure(http_status: int, payload: object) -> bool:
|
|
320
337
|
if http_status >= 500:
|
|
321
338
|
return True
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
339
|
+
if isinstance(payload, dict):
|
|
340
|
+
publication_status = payload.get("publication_status")
|
|
341
|
+
if isinstance(publication_status, dict):
|
|
342
|
+
status = publication_status.get("status")
|
|
343
|
+
if isinstance(status, str) and status.strip().lower() == "error":
|
|
344
|
+
return True
|
|
327
345
|
return False
|
|
328
346
|
|
|
329
347
|
|
|
330
|
-
def broadcast_aleph_message(api_host: str, message: dict[str, object]) -> tuple[int,
|
|
348
|
+
def broadcast_aleph_message(api_host: str, message: dict[str, object]) -> tuple[int, object]:
|
|
331
349
|
url = urllib.parse.urljoin(api_host.rstrip("/") + "/", "api/v0/messages")
|
|
332
350
|
attempts = [
|
|
333
351
|
{"sync": True, "message": message},
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
+
import base64
|
|
3
|
+
import hashlib
|
|
2
4
|
import ipaddress
|
|
3
5
|
import json
|
|
4
6
|
import os
|
|
@@ -7,6 +9,8 @@ import threading
|
|
|
7
9
|
import time
|
|
8
10
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
9
11
|
from pathlib import Path
|
|
12
|
+
from urllib import error as urllib_error
|
|
13
|
+
from urllib import request as urllib_request
|
|
10
14
|
from urllib.error import HTTPError, URLError
|
|
11
15
|
from urllib.parse import urlencode, urlsplit
|
|
12
16
|
from urllib.request import urlopen
|
|
@@ -23,12 +27,36 @@ AUTHORIZED_KEYS_FILE = os.environ.get("AUTHORIZED_KEYS_FILE", "/root/.ssh/author
|
|
|
23
27
|
ALEPH_API_HOST = os.environ.get("ALEPH_API_HOST", "https://api2.aleph.im")
|
|
24
28
|
BOOTSTRAP_CONFIG_KEY = os.environ.get("BOOTSTRAP_CONFIG_KEY", "vm-bootstrap-config")
|
|
25
29
|
BOOTSTRAP_CONFIG_POLL_SECONDS = float(os.environ.get("BOOTSTRAP_CONFIG_POLL_SECONDS", "5"))
|
|
30
|
+
BOOTSTRAP_CONFIG_SIGNAL_REF = os.environ.get("BOOTSTRAP_CONFIG_SIGNAL_REF", "vm-bootstrap-config")
|
|
31
|
+
BOOTSTRAP_CONFIG_SIGNAL_POST_TYPE = os.environ.get(
|
|
32
|
+
"BOOTSTRAP_CONFIG_SIGNAL_POST_TYPE", "vm-bootstrap-config-status"
|
|
33
|
+
)
|
|
34
|
+
BOOTSTRAP_CHANNEL = os.environ.get("ALEPH_BOOTSTRAP_CHANNEL", "simple-todo")
|
|
26
35
|
DESCRIBE_TIMEOUT_SECONDS = int(os.environ.get("DESCRIBE_TIMEOUT_SECONDS", "15"))
|
|
27
36
|
DESCRIBE_INTERVAL_SECONDS = float(os.environ.get("DESCRIBE_INTERVAL_SECONDS", "1"))
|
|
28
37
|
|
|
29
38
|
CONFIGURE_LOCK = threading.Lock()
|
|
30
39
|
|
|
31
40
|
|
|
41
|
+
def _json_dumps(payload: object) -> str:
|
|
42
|
+
return json.dumps(payload, separators=(",", ":"))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _parse_env_file(path: str) -> dict[str, str]:
|
|
46
|
+
values: dict[str, str] = {}
|
|
47
|
+
if not os.path.exists(path):
|
|
48
|
+
return values
|
|
49
|
+
|
|
50
|
+
with open(path, encoding="utf-8") as handle:
|
|
51
|
+
for line in handle:
|
|
52
|
+
stripped = line.strip()
|
|
53
|
+
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
|
54
|
+
continue
|
|
55
|
+
key, value = stripped.split("=", 1)
|
|
56
|
+
values[key.strip()] = value.strip()
|
|
57
|
+
return values
|
|
58
|
+
|
|
59
|
+
|
|
32
60
|
def _cors_headers(handler: BaseHTTPRequestHandler) -> None:
|
|
33
61
|
handler.send_header("Access-Control-Allow-Origin", "*")
|
|
34
62
|
handler.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
@@ -143,7 +171,7 @@ def _build_configure_args(record: dict) -> list[str]:
|
|
|
143
171
|
return args
|
|
144
172
|
|
|
145
173
|
|
|
146
|
-
def _run_configure_process(args: list[str]) -> tuple[bool, str]:
|
|
174
|
+
def _run_configure_process(args: list[str], bootstrap_record: dict | None = None) -> tuple[bool, str]:
|
|
147
175
|
with CONFIGURE_LOCK:
|
|
148
176
|
if os.path.exists(READY_FILE):
|
|
149
177
|
return True, "ready"
|
|
@@ -152,10 +180,202 @@ def _run_configure_process(args: list[str]) -> tuple[bool, str]:
|
|
|
152
180
|
except subprocess.CalledProcessError as error:
|
|
153
181
|
return False, error.stderr.strip() or error.stdout.strip() or str(error)
|
|
154
182
|
|
|
183
|
+
if isinstance(bootstrap_record, dict):
|
|
184
|
+
try:
|
|
185
|
+
_publish_vm_bootstrap_config_signal(bootstrap_record)
|
|
186
|
+
except Exception as error: # pragma: no cover - runtime error path
|
|
187
|
+
return False, f"configured relay but failed to publish bootstrap config signal: {error}"
|
|
188
|
+
|
|
155
189
|
_start_metadata_generation()
|
|
156
190
|
return True, result.stdout.strip()
|
|
157
191
|
|
|
158
192
|
|
|
193
|
+
def _address_from_private_key(private_key: str) -> str:
|
|
194
|
+
try:
|
|
195
|
+
from eth_account import Account
|
|
196
|
+
except ImportError as error: # pragma: no cover - runtime dependency
|
|
197
|
+
raise RuntimeError(
|
|
198
|
+
"eth-account is required for guest-side bootstrap status publishing"
|
|
199
|
+
) from error
|
|
200
|
+
|
|
201
|
+
return Account.from_key(private_key).address
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _sign_personal_message(private_key: str, payload: str) -> str:
|
|
205
|
+
try:
|
|
206
|
+
from eth_account import Account
|
|
207
|
+
from eth_account.messages import encode_defunct
|
|
208
|
+
except ImportError as error: # pragma: no cover - runtime dependency
|
|
209
|
+
raise RuntimeError(
|
|
210
|
+
"eth-account is required for guest-side bootstrap status publishing"
|
|
211
|
+
) from error
|
|
212
|
+
|
|
213
|
+
message = encode_defunct(text=payload)
|
|
214
|
+
signed = Account.sign_message(message, private_key=private_key)
|
|
215
|
+
signature = signed.signature.hex()
|
|
216
|
+
return signature if signature.startswith("0x") else f"0x{signature}"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _signature_payload(chain: str, sender: str, message_type: str, item_hash: str) -> str:
|
|
220
|
+
return "\n".join([chain, sender, message_type, item_hash])
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _iter_validation_errors(payload: object) -> list[dict]:
|
|
224
|
+
if isinstance(payload, list):
|
|
225
|
+
return [entry for entry in payload if isinstance(entry, dict)]
|
|
226
|
+
return []
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _is_invalid_message_format(http_status: int, payload: object) -> bool:
|
|
230
|
+
if http_status != 422:
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
if isinstance(payload, dict):
|
|
234
|
+
details = payload.get("details")
|
|
235
|
+
if isinstance(details, str) and "InvalidMessageFormat" in details:
|
|
236
|
+
return True
|
|
237
|
+
if isinstance(details, dict):
|
|
238
|
+
message = details.get("message")
|
|
239
|
+
if isinstance(message, str) and "InvalidMessageFormat" in message:
|
|
240
|
+
return True
|
|
241
|
+
|
|
242
|
+
for entry in _iter_validation_errors(payload):
|
|
243
|
+
message = entry.get("msg")
|
|
244
|
+
location = entry.get("loc")
|
|
245
|
+
if isinstance(message, str) and "InvalidMessageFormat" in message:
|
|
246
|
+
return True
|
|
247
|
+
if message == "Field required" and location == ["message"]:
|
|
248
|
+
return True
|
|
249
|
+
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _is_retryable_broadcast_failure(http_status: int, payload: object) -> bool:
|
|
254
|
+
if http_status >= 500:
|
|
255
|
+
return True
|
|
256
|
+
if isinstance(payload, dict):
|
|
257
|
+
publication_status = payload.get("publication_status")
|
|
258
|
+
if isinstance(publication_status, dict):
|
|
259
|
+
status = publication_status.get("status")
|
|
260
|
+
if isinstance(status, str) and status.strip().lower() == "error":
|
|
261
|
+
return True
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _post_json(url: str, body: dict[str, object]) -> tuple[int, object]:
|
|
266
|
+
data = _json_dumps(body).encode("utf-8")
|
|
267
|
+
request = urllib_request.Request(
|
|
268
|
+
url,
|
|
269
|
+
data=data,
|
|
270
|
+
headers={"content-type": "application/json"},
|
|
271
|
+
method="POST",
|
|
272
|
+
)
|
|
273
|
+
try:
|
|
274
|
+
with urllib_request.urlopen(request, timeout=30) as response:
|
|
275
|
+
payload = response.read().decode("utf-8")
|
|
276
|
+
return response.status, json.loads(payload or "{}")
|
|
277
|
+
except urllib_error.HTTPError as error:
|
|
278
|
+
payload = error.read().decode("utf-8")
|
|
279
|
+
try:
|
|
280
|
+
return error.code, json.loads(payload or "{}")
|
|
281
|
+
except json.JSONDecodeError:
|
|
282
|
+
return error.code, {"details": payload}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _broadcast_aleph_message(api_host: str, message: dict[str, object]) -> tuple[int, object]:
|
|
286
|
+
url = f"{api_host.rstrip('/')}/api/v0/messages"
|
|
287
|
+
attempts = [
|
|
288
|
+
{"sync": True, "message": message},
|
|
289
|
+
{**message, "sync": True},
|
|
290
|
+
dict(message),
|
|
291
|
+
]
|
|
292
|
+
for index, attempt in enumerate(attempts):
|
|
293
|
+
http_status, payload = _post_json(url, attempt)
|
|
294
|
+
if 200 <= http_status < 300:
|
|
295
|
+
return http_status, payload
|
|
296
|
+
can_retry = index < len(attempts) - 1 and (
|
|
297
|
+
_is_invalid_message_format(http_status, payload)
|
|
298
|
+
or _is_retryable_broadcast_failure(http_status, payload)
|
|
299
|
+
)
|
|
300
|
+
if not can_retry:
|
|
301
|
+
raise RuntimeError(
|
|
302
|
+
f"Aleph bootstrap status broadcast failed: {http_status} {_json_dumps(payload)}"
|
|
303
|
+
)
|
|
304
|
+
raise RuntimeError("Aleph bootstrap status broadcast failed: no compatible request format was accepted")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _publish_vm_bootstrap_config_signal(record: dict) -> None:
|
|
308
|
+
env_values = _parse_env_file(ENV_FILE)
|
|
309
|
+
publisher_private_key = env_values.get("ALEPH_BOOTSTRAP_PUBLISHER_PRIVATE_KEY", "").strip()
|
|
310
|
+
if not publisher_private_key:
|
|
311
|
+
raise RuntimeError("missing ALEPH_BOOTSTRAP_PUBLISHER_PRIVATE_KEY")
|
|
312
|
+
|
|
313
|
+
owner_authorization_b64 = env_values.get("ALEPH_BOOTSTRAP_OWNER_AUTHORIZATION_B64", "").strip()
|
|
314
|
+
owner_authorization = None
|
|
315
|
+
if owner_authorization_b64:
|
|
316
|
+
decoded = base64.b64decode(owner_authorization_b64).decode("utf-8")
|
|
317
|
+
parsed = json.loads(decoded)
|
|
318
|
+
if isinstance(parsed, dict):
|
|
319
|
+
owner_authorization = parsed
|
|
320
|
+
|
|
321
|
+
owner_address = ""
|
|
322
|
+
if isinstance(owner_authorization, dict):
|
|
323
|
+
payload = owner_authorization.get("payload")
|
|
324
|
+
if isinstance(payload, dict):
|
|
325
|
+
owner_address = str(payload.get("ownerAddress") or "").strip()
|
|
326
|
+
if not owner_address:
|
|
327
|
+
owner_address = str(record.get("ownerAddress") or "").strip()
|
|
328
|
+
if not owner_address:
|
|
329
|
+
raise RuntimeError("missing ownerAddress for bootstrap config signal")
|
|
330
|
+
|
|
331
|
+
deployment_token = str(record.get("deploymentToken") or "").strip()
|
|
332
|
+
profile = str(record.get("profile") or "").strip()
|
|
333
|
+
instance_item_hash = str(record.get("instanceItemHash") or "").strip()
|
|
334
|
+
if not deployment_token or not profile or not instance_item_hash:
|
|
335
|
+
raise RuntimeError("bootstrap config signal is missing deployment metadata")
|
|
336
|
+
|
|
337
|
+
publisher_address = _address_from_private_key(publisher_private_key)
|
|
338
|
+
now_ms = int(time.time() * 1000)
|
|
339
|
+
content = {
|
|
340
|
+
"type": BOOTSTRAP_CONFIG_SIGNAL_POST_TYPE,
|
|
341
|
+
"address": publisher_address,
|
|
342
|
+
"ref": BOOTSTRAP_CONFIG_SIGNAL_REF,
|
|
343
|
+
"content": {
|
|
344
|
+
"deploymentToken": deployment_token,
|
|
345
|
+
"status": "applied",
|
|
346
|
+
"profile": profile,
|
|
347
|
+
"ownerAddress": owner_address,
|
|
348
|
+
"instanceItemHash": instance_item_hash,
|
|
349
|
+
"updatedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now_ms / 1000)),
|
|
350
|
+
"publisherAddress": publisher_address,
|
|
351
|
+
"authorization": owner_authorization,
|
|
352
|
+
},
|
|
353
|
+
"time": now_ms,
|
|
354
|
+
}
|
|
355
|
+
item_content = _json_dumps(content)
|
|
356
|
+
unsigned_message = {
|
|
357
|
+
"channel": BOOTSTRAP_CHANNEL,
|
|
358
|
+
"sender": publisher_address,
|
|
359
|
+
"chain": "ETH",
|
|
360
|
+
"type": "POST",
|
|
361
|
+
"time": now_ms / 1000,
|
|
362
|
+
"item_type": "inline",
|
|
363
|
+
"item_content": item_content,
|
|
364
|
+
"item_hash": hashlib.sha256(item_content.encode("utf-8")).hexdigest(),
|
|
365
|
+
}
|
|
366
|
+
signed_message = dict(unsigned_message)
|
|
367
|
+
signed_message["signature"] = _sign_personal_message(
|
|
368
|
+
publisher_private_key,
|
|
369
|
+
_signature_payload(
|
|
370
|
+
str(unsigned_message["chain"]),
|
|
371
|
+
str(unsigned_message["sender"]),
|
|
372
|
+
str(unsigned_message["type"]),
|
|
373
|
+
str(unsigned_message["item_hash"]),
|
|
374
|
+
),
|
|
375
|
+
)
|
|
376
|
+
_broadcast_aleph_message(ALEPH_API_HOST, signed_message)
|
|
377
|
+
|
|
378
|
+
|
|
159
379
|
def _load_vm_bootstrap_record() -> tuple[str | None, str | None, dict | None]:
|
|
160
380
|
owner_address, deployment_token = _extract_guest_bootstrap_locator()
|
|
161
381
|
if not owner_address or not deployment_token:
|
|
@@ -197,7 +417,7 @@ def _poll_bootstrap_config_loop() -> None:
|
|
|
197
417
|
except ValueError:
|
|
198
418
|
time.sleep(BOOTSTRAP_CONFIG_POLL_SECONDS)
|
|
199
419
|
continue
|
|
200
|
-
ok, _ = _run_configure_process(args)
|
|
420
|
+
ok, _ = _run_configure_process(args, record)
|
|
201
421
|
if ok:
|
|
202
422
|
return
|
|
203
423
|
time.sleep(BOOTSTRAP_CONFIG_POLL_SECONDS)
|