Files

84 lines
4.1 KiB
Python
Raw Permalink Normal View History

"""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)))