@le-space/rootfs 0.4.1 → 0.4.2

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.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Shared rootfs contract parsing, reference profile assets, and build helpers.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -306,19 +306,22 @@ def sign_aleph_message(unsigned_message: dict[str, object], private_key: str) ->
306
306
 
307
307
  def broadcast_aleph_message(api_host: str, message: dict[str, object]) -> tuple[int, dict]:
308
308
  url = urllib.parse.urljoin(api_host.rstrip("/") + "/", "api/v0/messages")
309
- attempts = [
310
- {"sync": True, "message": message},
311
- {**message, "sync": True},
312
- dict(message),
313
- ]
309
+ request_body = {"sync": True, "message": message}
310
+ max_attempts = 3
314
311
  last: tuple[int, dict] | None = None
315
- for attempt in attempts:
316
- last = post_json(url, attempt)
312
+ for index in range(max_attempts):
313
+ last = post_json(url, request_body)
317
314
  if 200 <= last[0] < 300:
318
315
  return last
319
- if last is None:
320
- raise RuntimeError("Aleph broadcast failed before any attempt was made")
321
- raise RuntimeError(f"Aleph broadcast failed: {last[0]} {json_dumps(last[1])}")
316
+ publication_status = last[1].get("publication_status") if isinstance(last[1], dict) else None
317
+ retryable = last[0] >= 500 or (
318
+ isinstance(publication_status, dict)
319
+ and isinstance(publication_status.get("status"), str)
320
+ and publication_status.get("status").strip().lower() == "error"
321
+ )
322
+ if index >= max_attempts - 1 or not retryable:
323
+ raise RuntimeError(f"Aleph broadcast failed: {last[0]} {json_dumps(last[1])}")
324
+ raise RuntimeError("Aleph broadcast failed: retry budget exhausted")
322
325
 
323
326
 
324
327
  def parse_post_record(entry: object) -> dict[str, object] | None:
@@ -155,22 +155,16 @@ def is_retryable_broadcast_failure(http_status: int, payload: object) -> bool:
155
155
 
156
156
  def broadcast_aleph_message(api_host: str, message: dict[str, object]) -> tuple[int, object]:
157
157
  url = urllib.parse.urljoin(api_host.rstrip("/") + "/", "api/v0/messages")
158
- attempts = [
159
- {"sync": True, "message": message},
160
- {**message, "sync": True},
161
- dict(message),
162
- ]
163
- for index, attempt in enumerate(attempts):
164
- http_status, payload = post_json(url, attempt)
158
+ request_body = {"sync": True, "message": message}
159
+ max_attempts = 3
160
+ for index in range(max_attempts):
161
+ http_status, payload = post_json(url, request_body)
165
162
  if 200 <= http_status < 300:
166
163
  return http_status, payload
167
- can_retry = index < len(attempts) - 1 and (
168
- is_invalid_message_format(http_status, payload)
169
- or is_retryable_broadcast_failure(http_status, payload)
170
- )
164
+ can_retry = index < max_attempts - 1 and is_retryable_broadcast_failure(http_status, payload)
171
165
  if not can_retry:
172
166
  raise RuntimeError(f"Aleph broadcast failed: {http_status} {json_dumps(payload)}")
173
- raise RuntimeError("Aleph broadcast failed: no compatible request format was accepted")
167
+ raise RuntimeError("Aleph broadcast failed: retry budget exhausted")
174
168
 
175
169
 
176
170
  def parse_post_record(entry: object) -> dict[str, object] | None:
@@ -246,13 +246,13 @@ def build_post_content(
246
246
  owner_authorization: dict[str, object] | None,
247
247
  relay_proof: dict[str, object],
248
248
  now_ms: int,
249
+ now_seconds: float,
249
250
  ref: str,
250
251
  post_type: str,
251
252
  ) -> dict[str, object]:
252
253
  content = {
253
254
  "peerId": peer_id,
254
255
  "multiaddrs": multiaddrs,
255
- "browserMultiaddrs": browser_multiaddrs if browser_multiaddrs else None,
256
256
  "registrationId": registration_id,
257
257
  "profile": profile,
258
258
  "version": version,
@@ -266,12 +266,14 @@ def build_post_content(
266
266
  "relayProof": relay_proof,
267
267
  "updatedAt": now_ms,
268
268
  }
269
+ if browser_multiaddrs:
270
+ content["browserMultiaddrs"] = browser_multiaddrs
269
271
  return {
270
272
  "type": post_type,
271
273
  "address": sender,
272
274
  "ref": ref,
273
275
  "content": content,
274
- "time": now_ms,
276
+ "time": now_seconds,
275
277
  }
276
278
 
277
279
 
@@ -347,22 +349,16 @@ def is_retryable_broadcast_failure(http_status: int, payload: object) -> bool:
347
349
 
348
350
  def broadcast_aleph_message(api_host: str, message: dict[str, object]) -> tuple[int, object]:
349
351
  url = urllib.parse.urljoin(api_host.rstrip("/") + "/", "api/v0/messages")
350
- attempts = [
351
- {"sync": True, "message": message},
352
- {**message, "sync": True},
353
- dict(message),
354
- ]
355
- for index, attempt in enumerate(attempts):
356
- http_status, payload = post_json(url, attempt)
352
+ request_body = {"sync": True, "message": message}
353
+ max_attempts = 3
354
+ for index in range(max_attempts):
355
+ http_status, payload = post_json(url, request_body)
357
356
  if 200 <= http_status < 300:
358
357
  return http_status, payload
359
- can_retry = index < len(attempts) - 1 and (
360
- is_invalid_message_format(http_status, payload)
361
- or is_retryable_broadcast_failure(http_status, payload)
362
- )
358
+ can_retry = index < max_attempts - 1 and is_retryable_broadcast_failure(http_status, payload)
363
359
  if not can_retry:
364
360
  raise RuntimeError(f"Aleph broadcast failed: {http_status} {json_dumps(payload)}")
365
- raise RuntimeError("Aleph broadcast failed: no compatible request format was accepted")
361
+ raise RuntimeError("Aleph broadcast failed: retry budget exhausted")
366
362
 
367
363
 
368
364
  def parse_post_record(entry: object) -> dict[str, object] | None:
@@ -538,6 +534,7 @@ def main() -> None:
538
534
  owner_authorization,
539
535
  relay_proof,
540
536
  now_ms,
537
+ now_seconds,
541
538
  ref,
542
539
  post_type,
543
540
  )
@@ -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"
@@ -153,9 +181,223 @@ def _run_configure_process(args: list[str]) -> tuple[bool, str]:
153
181
  return False, error.stderr.strip() or error.stdout.strip() or str(error)
154
182
 
155
183
  _start_metadata_generation()
184
+ if isinstance(bootstrap_record, dict):
185
+ try:
186
+ _publish_vm_bootstrap_config_signal(bootstrap_record)
187
+ except Exception as error: # pragma: no cover - runtime error path
188
+ return False, f"configured relay but failed to publish bootstrap config signal: {error}"
156
189
  return True, result.stdout.strip()
157
190
 
158
191
 
192
+ def _address_from_private_key(private_key: str) -> str:
193
+ try:
194
+ from eth_account import Account
195
+ except ImportError as error: # pragma: no cover - runtime dependency
196
+ raise RuntimeError(
197
+ "eth-account is required for guest-side bootstrap status publishing"
198
+ ) from error
199
+
200
+ return Account.from_key(private_key).address
201
+
202
+
203
+ def _sign_personal_message(private_key: str, payload: str) -> str:
204
+ try:
205
+ from eth_account import Account
206
+ from eth_account.messages import encode_defunct
207
+ except ImportError as error: # pragma: no cover - runtime dependency
208
+ raise RuntimeError(
209
+ "eth-account is required for guest-side bootstrap status publishing"
210
+ ) from error
211
+
212
+ message = encode_defunct(text=payload)
213
+ signed = Account.sign_message(message, private_key=private_key)
214
+ signature = signed.signature.hex()
215
+ return signature if signature.startswith("0x") else f"0x{signature}"
216
+
217
+
218
+ def _signature_payload(chain: str, sender: str, message_type: str, item_hash: str) -> str:
219
+ return "\n".join([chain, sender, message_type, item_hash])
220
+
221
+
222
+ def _iter_validation_errors(payload: object) -> list[dict]:
223
+ if isinstance(payload, list):
224
+ return [entry for entry in payload if isinstance(entry, dict)]
225
+ return []
226
+
227
+
228
+ def _is_invalid_message_format(http_status: int, payload: object) -> bool:
229
+ if http_status != 422:
230
+ return False
231
+
232
+ if isinstance(payload, dict):
233
+ details = payload.get("details")
234
+ if isinstance(details, str) and "InvalidMessageFormat" in details:
235
+ return True
236
+ if isinstance(details, dict):
237
+ message = details.get("message")
238
+ if isinstance(message, str) and "InvalidMessageFormat" in message:
239
+ return True
240
+
241
+ for entry in _iter_validation_errors(payload):
242
+ message = entry.get("msg")
243
+ location = entry.get("loc")
244
+ if isinstance(message, str) and "InvalidMessageFormat" in message:
245
+ return True
246
+ if message == "Field required" and location == ["message"]:
247
+ return True
248
+
249
+ return False
250
+
251
+
252
+ def _is_retryable_broadcast_failure(http_status: int, payload: object) -> bool:
253
+ if http_status >= 500:
254
+ return True
255
+ if isinstance(payload, dict):
256
+ publication_status = payload.get("publication_status")
257
+ if isinstance(publication_status, dict):
258
+ status = publication_status.get("status")
259
+ if isinstance(status, str) and status.strip().lower() == "error":
260
+ return True
261
+ return False
262
+
263
+
264
+ def _post_json(url: str, body: dict[str, object]) -> tuple[int, object]:
265
+ data = _json_dumps(body).encode("utf-8")
266
+ request = urllib_request.Request(
267
+ url,
268
+ data=data,
269
+ headers={"content-type": "application/json"},
270
+ method="POST",
271
+ )
272
+ try:
273
+ with urllib_request.urlopen(request, timeout=30) as response:
274
+ payload = response.read().decode("utf-8")
275
+ return response.status, json.loads(payload or "{}")
276
+ except urllib_error.HTTPError as error:
277
+ payload = error.read().decode("utf-8")
278
+ try:
279
+ return error.code, json.loads(payload or "{}")
280
+ except json.JSONDecodeError:
281
+ return error.code, {"details": payload}
282
+
283
+
284
+ def _broadcast_aleph_message(api_host: str, message: dict[str, object]) -> tuple[int, object]:
285
+ url = f"{api_host.rstrip('/')}/api/v0/messages"
286
+ request_body = {"sync": True, "message": message}
287
+ max_attempts = 3
288
+ for index in range(max_attempts):
289
+ http_status, payload = _post_json(url, request_body)
290
+ if 200 <= http_status < 300:
291
+ return http_status, payload
292
+ can_retry = index < max_attempts - 1 and _is_retryable_broadcast_failure(http_status, payload)
293
+ if not can_retry:
294
+ raise RuntimeError(
295
+ f"Aleph bootstrap status broadcast failed: {http_status} {_json_dumps(payload)}"
296
+ )
297
+ raise RuntimeError("Aleph bootstrap status broadcast failed: retry budget exhausted")
298
+
299
+
300
+ def _publish_vm_bootstrap_config_signal(record: dict) -> None:
301
+ env_values = _parse_env_file(ENV_FILE)
302
+ publisher_private_key = env_values.get("ALEPH_BOOTSTRAP_PUBLISHER_PRIVATE_KEY", "").strip()
303
+ if not publisher_private_key:
304
+ raise RuntimeError("missing ALEPH_BOOTSTRAP_PUBLISHER_PRIVATE_KEY")
305
+
306
+ owner_authorization_b64 = env_values.get("ALEPH_BOOTSTRAP_OWNER_AUTHORIZATION_B64", "").strip()
307
+ owner_authorization = None
308
+ if owner_authorization_b64:
309
+ decoded = base64.b64decode(owner_authorization_b64).decode("utf-8")
310
+ parsed = json.loads(decoded)
311
+ if isinstance(parsed, dict):
312
+ owner_authorization = parsed
313
+
314
+ owner_address = ""
315
+ if isinstance(owner_authorization, dict):
316
+ payload = owner_authorization.get("payload")
317
+ if isinstance(payload, dict):
318
+ owner_address = str(payload.get("ownerAddress") or "").strip()
319
+ if not owner_address:
320
+ owner_address = str(record.get("ownerAddress") or "").strip()
321
+ if not owner_address:
322
+ raise RuntimeError("missing ownerAddress for bootstrap config signal")
323
+
324
+ deployment_token = str(record.get("deploymentToken") or "").strip()
325
+ profile = str(record.get("profile") or "").strip()
326
+ instance_item_hash = str(record.get("instanceItemHash") or "").strip()
327
+ if not deployment_token or not profile or not instance_item_hash:
328
+ raise RuntimeError("bootstrap config signal is missing deployment metadata")
329
+
330
+ metadata, metadata_error = _fresh_metadata_payload()
331
+ if not metadata:
332
+ raise RuntimeError(
333
+ f"bootstrap config signal metadata is not ready: {metadata_error or 'metadata unavailable'}"
334
+ )
335
+
336
+ peer_id = metadata.get("peer_id")
337
+ probe_multiaddrs = metadata.get("probe_multiaddrs")
338
+ browser_bootstrap_multiaddrs = metadata.get("browser_bootstrap_multiaddrs")
339
+ if not isinstance(peer_id, str) or not peer_id.strip():
340
+ raise RuntimeError("bootstrap config signal is missing peer_id")
341
+ if not isinstance(probe_multiaddrs, list) or not any(
342
+ isinstance(entry, str) and entry.strip() for entry in probe_multiaddrs
343
+ ):
344
+ raise RuntimeError("bootstrap config signal is missing probe_multiaddrs")
345
+ if not isinstance(browser_bootstrap_multiaddrs, list):
346
+ browser_bootstrap_multiaddrs = []
347
+
348
+ publisher_address = _address_from_private_key(publisher_private_key)
349
+ now_ms = int(time.time() * 1000)
350
+ content = {
351
+ "type": BOOTSTRAP_CONFIG_SIGNAL_POST_TYPE,
352
+ "address": publisher_address,
353
+ "ref": BOOTSTRAP_CONFIG_SIGNAL_REF,
354
+ "content": {
355
+ "deploymentToken": deployment_token,
356
+ "status": "applied",
357
+ "profile": profile,
358
+ "ownerAddress": owner_address,
359
+ "instanceItemHash": instance_item_hash,
360
+ "updatedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now_ms / 1000)),
361
+ "publisherAddress": publisher_address,
362
+ "authorization": owner_authorization,
363
+ "peerId": peer_id.strip(),
364
+ "probeMultiaddrs": [
365
+ entry.strip()
366
+ for entry in probe_multiaddrs
367
+ if isinstance(entry, str) and entry.strip()
368
+ ],
369
+ "browserBootstrapMultiaddrs": [
370
+ entry.strip()
371
+ for entry in browser_bootstrap_multiaddrs
372
+ if isinstance(entry, str) and entry.strip()
373
+ ],
374
+ },
375
+ "time": now_ms,
376
+ }
377
+ item_content = _json_dumps(content)
378
+ unsigned_message = {
379
+ "channel": BOOTSTRAP_CHANNEL,
380
+ "sender": publisher_address,
381
+ "chain": "ETH",
382
+ "type": "POST",
383
+ "time": now_ms / 1000,
384
+ "item_type": "inline",
385
+ "item_content": item_content,
386
+ "item_hash": hashlib.sha256(item_content.encode("utf-8")).hexdigest(),
387
+ }
388
+ signed_message = dict(unsigned_message)
389
+ signed_message["signature"] = _sign_personal_message(
390
+ publisher_private_key,
391
+ _signature_payload(
392
+ str(unsigned_message["chain"]),
393
+ str(unsigned_message["sender"]),
394
+ str(unsigned_message["type"]),
395
+ str(unsigned_message["item_hash"]),
396
+ ),
397
+ )
398
+ _broadcast_aleph_message(ALEPH_API_HOST, signed_message)
399
+
400
+
159
401
  def _load_vm_bootstrap_record() -> tuple[str | None, str | None, dict | None]:
160
402
  owner_address, deployment_token = _extract_guest_bootstrap_locator()
161
403
  if not owner_address or not deployment_token:
@@ -197,7 +439,7 @@ def _poll_bootstrap_config_loop() -> None:
197
439
  except ValueError:
198
440
  time.sleep(BOOTSTRAP_CONFIG_POLL_SECONDS)
199
441
  continue
200
- ok, _ = _run_configure_process(args)
442
+ ok, _ = _run_configure_process(args, record)
201
443
  if ok:
202
444
  return
203
445
  time.sleep(BOOTSTRAP_CONFIG_POLL_SECONDS)