@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@le-space/rootfs",
3
- "version": "0.3.23",
3
+ "version": "0.3.25",
4
4
  "description": "Shared rootfs contract parsing, reference profile assets, and build helpers.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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 is_invalid_message_format(http_status: int, payload: dict) -> bool:
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
- details = payload.get("details")
118
- if isinstance(details, str) and "InvalidMessageFormat" in details:
119
- return True
120
- if isinstance(details, dict):
121
- message = details.get("message")
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: dict) -> bool:
144
+ def is_retryable_broadcast_failure(http_status: int, payload: object) -> bool:
128
145
  if http_status >= 500:
129
146
  return True
130
- publication_status = payload.get("publication_status")
131
- if isinstance(publication_status, dict):
132
- status = publication_status.get("status")
133
- if isinstance(status, str) and status.strip().lower() == "error":
134
- return True
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, dict]:
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 is_invalid_message_format(http_status: int, payload: dict) -> bool:
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
- details = payload.get("details")
310
- if isinstance(details, str) and "InvalidMessageFormat" in details:
311
- return True
312
- if isinstance(details, dict):
313
- message = details.get("message")
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: dict) -> bool:
336
+ def is_retryable_broadcast_failure(http_status: int, payload: object) -> bool:
320
337
  if http_status >= 500:
321
338
  return True
322
- publication_status = payload.get("publication_status")
323
- if isinstance(publication_status, dict):
324
- status = publication_status.get("status")
325
- if isinstance(status, str) and status.strip().lower() == "error":
326
- return True
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, dict]:
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)