"""Auditable, additive configuration migration; no source/history/schema changes.""" import argparse import hashlib import json import os from pathlib import Path import sys sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from console.native_plans import profile_for, validate_config from console.settings import Settings from console.store import Store, now def migrate(store, backup, apply=False): changes = [] for item in store.scripts(): profile = profile_for(store.settings, item) if not profile: continue if hashlib.sha256(Path(item["source_path"]).read_bytes()).hexdigest() != item["sha256"]: raise ValueError("Source changed: " + profile["id"]) updated = {**item} updated["config"] = validate_config(store.settings, item, {"timeout_seconds": profile.get("timeout_seconds", 3600), "native_options": item["config"].get("native_options", {})}) available = profile.get("available", True) updated.update(enabled=available, blocked=not available, status="ready" if available else "needs_configuration", reason="" if available else profile.get("unavailable_reason", "原环境不可用")) if all(updated[k] == item.get(k) for k in ("config", "enabled", "blocked", "status", "reason")): continue updated["updated_at"] = now() changes.append((item, updated)) if len({profile_for(store.settings, item)["id"] for item in store.scripts() if profile_for(store.settings, item)}) != len(store.settings.native_catalog["profiles"]): raise ValueError("Registered sources do not match trusted profiles") if not apply: return {"pending": len(changes), "applied": False} if not changes: return {"pending": 0, "applied": True} with store.connect(write=True) as db: if db.execute("SELECT 1 FROM runs WHERE state IN ('queued','running')").fetchone(): raise ValueError("Drain active runs before migrating") saved = [] for old, updated in changes: raw = db.execute("SELECT data FROM scripts WHERE id=?", (old["id"],)).fetchone()[0] if json.loads(raw) != old: raise ValueError("Concurrent configuration change; migration aborted") saved.append({"id": old["id"], "before": raw, "after": json.dumps(updated)}) backup.parent.mkdir(parents=True, exist_ok=True) with backup.open("x", encoding="utf-8") as handle: json.dump({"created_at": now(), "scripts": saved}, handle, ensure_ascii=False, indent=2) backup.chmod(0o600) for item in saved: db.execute("UPDATE scripts SET data=? WHERE id=?", (item["after"], item["id"])) return {"pending": len(changes), "applied": True, "backup": str(backup)} def restore(store, backup): saved = json.loads(backup.read_text(encoding="utf-8"))["scripts"] with store.connect(write=True) as db: if db.execute("SELECT 1 FROM runs WHERE state IN ('queued','running')").fetchone(): raise ValueError("Drain active runs before rollback") for item in saved: current = db.execute("SELECT data FROM scripts WHERE id=?", (item["id"],)).fetchone()[0] if current not in (item["before"], item["after"]): raise ValueError("Configuration changed after migration; review before rollback") for item in saved: db.execute("UPDATE scripts SET data=? WHERE id=?", (item["before"], item["id"])) return {"restored": len(saved), "history_preserved": True} if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--apply", action="store_true") parser.add_argument("--restore", action="store_true") parser.add_argument("--backup", type=Path, required=True) args = parser.parse_args() if os.geteuid() != 0: raise SystemExit("Run using the documented sudo flow") if args.apply and args.restore: parser.error("Choose apply or restore") store = Store(Settings.from_env()) print(json.dumps(restore(store, args.backup) if args.restore else migrate(store, args.backup, args.apply)))