"""Per-instance sync.json — the opt-in marker + minimal config. If ``/.cloud-sync/sync.json`` doesn't exist, the pyz is a no-op (PreLaunch hook returns 0, MC launches normally). Its presence opts the instance into sync; its contents tell the script where to sync to (`url`) and which restic sub-path to use (`instance_id`). Intentionally minimal: NO label (UI display name comes from ``$INST_NAME`` / ``$INST_ID`` / ``--instance-label`` at runtime so the Prism rename flow Just Works), NO token (mode-600 ``token`` lives next to it), NO state (``state.json`` is separate, written by the sync flow). instance_id is a 26-char ULID-style string (timestamp + 80 random bits, base32, no padding) — sortable, URL-safe, no hyphens. Stable forever. """ from __future__ import annotations import base64 import hashlib import json import os import platform import secrets import socket import time from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path SCHEMA_VERSION = 1 @dataclass(frozen=True) class SyncConfig: url: str instance_id: str created_at: datetime host_fingerprint: str def config_path(pack_folder: Path) -> Path: return pack_folder / ".cloud-sync" / "sync.json" def exists(pack_folder: Path) -> bool: """Cheap opt-in check used by ``pull`` / ``push`` before doing anything else.""" return config_path(pack_folder).exists() def read(pack_folder: Path) -> SyncConfig | None: """Parse sync.json or return None if missing / corrupt / wrong schema.""" p = config_path(pack_folder) if not p.exists(): return None try: data = json.loads(p.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return None if data.get("schema") != SCHEMA_VERSION: return None try: return SyncConfig( url=data["url"], instance_id=data["instance_id"], created_at=_parse_iso(data["created_at"]), host_fingerprint=data.get("host_fingerprint", ""), ) except (KeyError, ValueError): return None def write(pack_folder: Path, cfg: SyncConfig) -> None: """Persist sync.json (mode 644 — not a secret).""" p = config_path(pack_folder) p.parent.mkdir(parents=True, exist_ok=True) payload = { "schema": SCHEMA_VERSION, "url": cfg.url, "instance_id": cfg.instance_id, "created_at": cfg.created_at.astimezone(timezone.utc) .isoformat() .replace("+00:00", "Z"), "host_fingerprint": cfg.host_fingerprint, } p.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") p.chmod(0o644) def disabled_path(pack_folder: Path) -> Path: return pack_folder / ".cloud-sync" / "sync.json.disabled" def disable(pack_folder: Path) -> bool: """Rename sync.json → sync.json.disabled. Reversible via :func:`enable`. Preserves instance_id + url so re-enabling the instance later picks back up the same restic repo (no new ULID minted, no orphaned snapshot history). Returns True if a file was renamed; False if sync.json wasn't there (already off — silent no-op). If sync.json.disabled already exists, it's overwritten by the move — that case means the user disabled, then somehow re-ran setup which minted a fresh sync.json; the older disabled file is stale. """ src = config_path(pack_folder) if not src.exists(): return False dst = disabled_path(pack_folder) src.replace(dst) return True def enable(pack_folder: Path) -> bool: """Rename sync.json.disabled → sync.json. Inverse of :func:`disable`. Returns True if a file was renamed; False if .disabled wasn't there (nothing to re-enable). Refuses if sync.json already exists (would clobber an active config); caller should ``disable`` first. """ src = disabled_path(pack_folder) if not src.exists(): return False dst = config_path(pack_folder) if dst.exists(): raise FileExistsError( f"refusing to enable: sync.json already exists at {dst}. " "Run 'disable' first if you want to swap configs." ) src.replace(dst) return True def delete(pack_folder: Path) -> bool: """Hard-delete sync.json (and any .disabled sibling). Used by tests and as an escape hatch — the ``disable`` subcommand uses the rename flow instead so re-enabling preserves the same instance_id.""" removed = False for p in (config_path(pack_folder), disabled_path(pack_folder)): if p.exists(): p.unlink() removed = True return removed # --------------------------------------------------------------------------- # minting # --------------------------------------------------------------------------- def mint(url: str) -> SyncConfig: """Build a fresh SyncConfig with a new ULID-style id and host fingerprint.""" return SyncConfig( url=url, instance_id=new_instance_id(), created_at=datetime.now(timezone.utc), host_fingerprint=_machine_fingerprint(), ) def new_instance_id() -> str: """26-char base32 string: 6 bytes ms timestamp + 10 bytes random. Like a ULID, minus the strict Crockford alphabet. Sortable by mint time, URL-safe, no padding, no hyphens. """ ms = int(time.time() * 1000).to_bytes(6, "big") rand = secrets.token_bytes(10) return base64.b32encode(ms + rand).decode("ascii").rstrip("=") def _machine_fingerprint() -> str: """sha256 of (hostname || node || nanosecond timestamp), first 16 hex chars. Not a cryptographic identity — just a hint we can compare across pulls to detect "this instance was first set up on a different machine" cases later. Don't use it for authz. """ src = f"{platform.node()}:{socket.gethostname()}:{time.time_ns()}" return hashlib.sha256(src.encode()).hexdigest()[:16] def _parse_iso(s: str) -> datetime: if s.endswith("Z"): s = s[:-1] + "+00:00" dt = datetime.fromisoformat(s) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt.astimezone(timezone.utc) # --------------------------------------------------------------------------- # display label resolution (NOT stored — derived per-run) # --------------------------------------------------------------------------- def resolve_label(flag_label: str | None, instance_id: str) -> str: """Pick the UI display name for an instance. Precedence: 1. ``--instance-label`` CLI flag (explicit override) 2. ``$INST_NAME`` (Prism's rename-tracking display name) 3. ``$INST_ID`` (Prism's stable folder name) 4. first 8 chars of the instance_id (last-resort) Never stored on disk — recomputed every run so Prism renames flow through naturally. """ if flag_label: return flag_label env = os.environ.get("INST_NAME") or os.environ.get("INST_ID") if env: return env return instance_id[:8]