84 lines
4.1 KiB
Python
84 lines
4.1 KiB
Python
"""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)))
|