Files
cloud-sync/cloud_sync/config.py
T
claude-timemachine 9074121898
CI / test (3.10) (push) Successful in 7s
CI / test (3.11) (push) Successful in 7s
CI / test (3.12) (push) Successful in 6s
CI / build-pyz (push) Successful in 4s
CI / release (push) Has been skipped
feat: 'disable' renames to sync.json.disabled; new 'enable' rename-back
Pause/resume sync without losing the instance_id.

  disable  sync.json -> sync.json.disabled
  enable   sync.json.disabled -> sync.json

Re-enabling preserves the original ULID + url so the same restic
repo continues. No new instance_id minted, no orphaned snapshot
history. Tradeoff vs the previous 'disable = delete' semantics:
the on-disk artifact survives, so a truly fresh start now needs
'disable && rm sync.json.disabled' before 'setup'.

Implementation:

  config.disable(pack)  os.rename(sync.json -> sync.json.disabled).
                        False if no sync.json.
  config.enable(pack)   os.rename(sync.json.disabled -> sync.json).
                        Refuses if sync.json already exists
                        (caller must disable first).
  config.delete(pack)   now sweeps BOTH forms (escape hatch / tests).

setup_flow gains a precheck: if sync.json.disabled is present, point
the user at 'enable' instead of silently minting a fresh ULID over
their existing instance.

Opt-in gate (cfgmod.exists) is unchanged — only literal sync.json
counts. The .disabled sibling is invisible to pull/push, so the
silent-no-op behavior for paused instances Just Works.

cli adds 'enable' subcommand alongside 'disable'. _run_disable prints
'already disabled' when called twice; _run_enable refuses to clobber
an active config (exits 2 with the FileExistsError message).

7 new tests for disable/enable behavior + edge cases (idempotency,
nothing-to-X, refuse-clobber). 80 tests total.
2026-06-05 09:58:56 +02:00

216 lines
6.9 KiB
Python

"""Per-instance sync.json — the opt-in marker + minimal config.
If ``<pack-folder>/.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]