Step 2: the settings page can change things - shared draft, Review & Apply, rollback, history, undo
- settings/apply.py: the Apply pipeline (check, keep history, write, restart the watchdog, re-import pages by the marker-only rule, recolour page keys), rollback when the watchdog does not come back, the recording guard with the Emergency (LIVE) override, history entries and Go back / Undo. - settings/server.py: the write routes behind the editing code (draft PUT/DELETE with 409 on a conflict, validate, apply, restore, undo, watchdog restart/stop/start, commands to the watchdog, the switcher file upload / pick newest / discard, gear notes). - settings/ui/app.js: every screen grows inputs with Editing on; keys (checks, fixes, hold-always, members), computers, fixes, site setup (schedules, Dante devices, Spotify, ATEM), Review & Apply with live progress, History with Go back and Undo, Run this fix now. - schema.unmask, push_page.push, nav_keys.colour; tests for all of it (163 pass). - Verified live on NA Ingest: a page-only Apply, a restart Apply, Undo, history. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,548 @@
|
||||
"""Saving a change: the draft, the checks, the history, and Apply itself.
|
||||
|
||||
Apply, in order, each step only after the previous one worked:
|
||||
keep a copy of the live config in history/ -> write the new file in one go (and the new switcher
|
||||
file with it) -> wait for any running fix to finish -> restart the watchdog -> prove it came back
|
||||
(started.json carries the new file's fingerprint) -> only then re-import the Companion pages when
|
||||
the built page differs from the last verified one -> recolour the page-number keys when a page
|
||||
changed. If the watchdog does not come back, the previous version is put back, once.
|
||||
Nothing here runs while a HyperDeck is recording unless the Emergency override says so.
|
||||
"""
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
from settings import PARENT # noqa: F401
|
||||
import make_page
|
||||
import nav_keys
|
||||
import push_page
|
||||
import schema
|
||||
import status_panel as sp
|
||||
|
||||
WATCHDOG = "org.waterschurch.status-panel"
|
||||
# Fields whose change needs no restart and no page: the watchdog never reads them.
|
||||
PAGE_ONLY = {"none"}
|
||||
|
||||
|
||||
def sha(data):
|
||||
return hashlib.sha256(data if isinstance(data, bytes) else json.dumps(data, sort_keys=True).encode()).hexdigest()
|
||||
|
||||
|
||||
def strip_notes(export):
|
||||
export = json.loads(json.dumps(export))
|
||||
for row in export["page"]["controls"].values():
|
||||
for ctl in (row or {}).values():
|
||||
if ctl and "options" in ctl:
|
||||
ctl["options"].pop("notes", None)
|
||||
return export
|
||||
|
||||
|
||||
def build_hashes(cfg):
|
||||
"""Fingerprints of both pages as built from cfg, notes stripped (a note change never re-imports)."""
|
||||
out = {"status": sha(strip_notes(make_page.build(cfg)))}
|
||||
if cfg.get("restart_page"):
|
||||
out["restart"] = sha(strip_notes(make_page.build_restart(cfg)))
|
||||
return out
|
||||
|
||||
|
||||
def config_text(cfg):
|
||||
return (json.dumps(cfg, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
class Draft:
|
||||
"""One shared draft per site, in draft.json (0600): a closed tab loses nothing."""
|
||||
|
||||
def __init__(self, data_dir):
|
||||
self.path = os.path.join(data_dir, "draft.json")
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def read(self):
|
||||
try:
|
||||
with open(self.path) as f:
|
||||
return json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
def write(self, draft):
|
||||
with self.lock:
|
||||
sp.write_json(self.path, draft)
|
||||
os.chmod(self.path, 0o600)
|
||||
|
||||
def clear(self):
|
||||
try:
|
||||
os.remove(self.path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def update(self, config, by, base_sha, expect=None):
|
||||
"""Replace the draft's config. `expect` is the version the tab last saw; another tab's
|
||||
newer write is reported with the fields that differ (409), never overwritten silently."""
|
||||
with self.lock:
|
||||
cur = self.read()
|
||||
if cur and expect is not None and cur.get("version") != expect:
|
||||
return None, cur
|
||||
now = time.time()
|
||||
draft = {"config": config, "by": by, "base_sha": base_sha, "started": (cur or {}).get("started", now),
|
||||
"started_by": (cur or {}).get("started_by", by), "updated": now, "version": "%d-%s" % (int(now * 1000), by)}
|
||||
sp.write_json(self.path, draft)
|
||||
os.chmod(self.path, 0o600)
|
||||
return draft, None
|
||||
|
||||
|
||||
def changed_paths(a, b, prefix=""):
|
||||
"""Every leaf path that differs between two configs, with (before, after)."""
|
||||
out = []
|
||||
if isinstance(a, dict) and isinstance(b, dict):
|
||||
for k in sorted(set(a) | set(b)):
|
||||
p = "%s.%s" % (prefix, k) if prefix else k
|
||||
if k not in a:
|
||||
out.append((p, None, b[k]))
|
||||
elif k not in b:
|
||||
out.append((p, a[k], None))
|
||||
else:
|
||||
out += changed_paths(a[k], b[k], p)
|
||||
elif isinstance(a, list) and isinstance(b, list) and all(isinstance(x, dict) for x in a + b) and any("id" in x for x in a + b):
|
||||
# lists of records with ids (tiles, restart keys): match by id so a move is not a delete + add
|
||||
ida = {x.get("id"): x for x in a}
|
||||
idb = {x.get("id"): x for x in b}
|
||||
for k in list(dict.fromkeys(list(ida) + list(idb))):
|
||||
p = "%s[%s]" % (prefix, k)
|
||||
if k not in ida:
|
||||
out.append((p, None, idb[k]))
|
||||
elif k not in idb:
|
||||
out.append((p, ida[k], None))
|
||||
else:
|
||||
out += changed_paths(ida[k], idb[k], p)
|
||||
elif isinstance(a, list) and isinstance(b, list) and a and len(a) == len(b) and all(isinstance(x, dict) for x in a + b):
|
||||
# lists of plain records of the same length (checks, windows): by position
|
||||
for i, (x, y) in enumerate(zip(a, b)):
|
||||
out += changed_paths(x, y, "%s.%d" % (prefix, i))
|
||||
elif a != b:
|
||||
out.append((prefix, a, b))
|
||||
return out
|
||||
|
||||
|
||||
def generic_path(path):
|
||||
"""A concrete path -> the registry's spelling: tiles[hd2].checks.0.optional -> tiles[].checks[].optional,
|
||||
hosts.lighting.notes -> hosts.*.notes, auto_fix.windows.0.start -> auto_fix.windows[].start."""
|
||||
g = re.sub(r"\[[^\]]*\]", "[]", path)
|
||||
g = re.sub(r"^(tiles\[\]|restart_page\.keys\[\])\.checks\.\d+\.", r"\1.checks[].", g)
|
||||
g = re.sub(r"^(hosts|fixes|gear)\.[^.]+", r"\1.*", g)
|
||||
g = re.sub(r"^(auto_fix\.windows|service_windows|site\.live_services)\.\d+", r"\1[]", g)
|
||||
return g
|
||||
|
||||
|
||||
def leaves(path, value):
|
||||
"""Every leaf under an added or removed subtree, so each can be looked up in the registry."""
|
||||
if isinstance(value, dict) and value:
|
||||
for k, v in value.items():
|
||||
yield from leaves("%s.%s" % (path, k), v)
|
||||
elif isinstance(value, list) and value and all(isinstance(x, dict) for x in value):
|
||||
for i, v in enumerate(value):
|
||||
yield from leaves("%s[%s]" % (path, v.get("id", i)) if "id" in v else "%s.%d" % (path, i), v)
|
||||
else:
|
||||
yield path
|
||||
|
||||
|
||||
def words_for(path, before, after, cfg_before, cfg_after):
|
||||
"""One plain-words line for a changed path, using the registry's names."""
|
||||
tiles = {t["id"]: t.get("label", t["id"]) for t in (cfg_after.get("tiles") or cfg_before.get("tiles") or [])}
|
||||
keys = {k["id"]: k.get("label", k["id"]) for k in ((cfg_after.get("restart_page") or cfg_before.get("restart_page") or {}).get("keys") or [])}
|
||||
hosts = {a: h.get("name", a) for a, h in {**(cfg_before.get("hosts") or {}), **(cfg_after.get("hosts") or {})}.items()}
|
||||
fixes = {a: f.get("short", a) for a, f in {**(cfg_before.get("fixes") or {}), **(cfg_after.get("fixes") or {})}.items()}
|
||||
where = path
|
||||
where = re.sub(r"^tiles\[([^\]]+)\]", lambda m: "key %s" % tiles.get(m.group(1), m.group(1)), where)
|
||||
where = re.sub(r"^restart_page\.keys\[([^\]]+)\]", lambda m: "restart key %s" % keys.get(m.group(1), m.group(1)), where)
|
||||
where = re.sub(r"^hosts\.([a-z0-9_]+)", lambda m: "computer %s" % hosts.get(m.group(1), m.group(1)), where)
|
||||
where = re.sub(r"^fixes\.([A-Za-z0-9_]+)", lambda m: "fix %s" % fixes.get(m.group(1), m.group(1)), where)
|
||||
where = re.sub(r"\.checks\.(\d+)", lambda m: " › check %d" % (int(m.group(1)) + 1), where)
|
||||
where = where.replace("restart_page", "Restart page").replace("auto_fix", "auto-fix").replace("site.", "site › ")
|
||||
field = path.split(".")[-1].split("[")[0]
|
||||
generic = {f["path"].split(".")[-1]: f for f in schema.F.values()}
|
||||
help_ = (generic.get(field) or {}).get("help", "")
|
||||
if where.endswith("." + field):
|
||||
where = where[:-len(field) - 1] + " › " + field.replace("_", " ")
|
||||
elif where.endswith(field):
|
||||
where = where[:-len(field)] + field.replace("_", " ")
|
||||
|
||||
def val(v):
|
||||
if v is None:
|
||||
return "—"
|
||||
if isinstance(v, bool):
|
||||
return "yes" if v else "no"
|
||||
if isinstance(v, (dict, list)):
|
||||
s = json.dumps(v, ensure_ascii=False)
|
||||
return s if len(s) < 80 else s[:77] + "..."
|
||||
return str(v)
|
||||
secret = any(f["secret"] for f in schema.F.values() if f["path"].endswith(field))
|
||||
if secret:
|
||||
return {"path": path, "where": where, "text": "%s changed" % where, "command": False}
|
||||
text = "%s: %s → %s" % (where, val(before), val(after)) if before is not None and after is not None else \
|
||||
("%s added: %s" % (where, val(after)) if before is None else "%s removed (was %s)" % (where, val(before)))
|
||||
command = field in ("run", "cmd", "match") or path.endswith("pref.want")
|
||||
return {"path": path, "where": where, "text": text, "help": help_, "command": command}
|
||||
|
||||
|
||||
def what_apply_does(cfg_before, cfg_after, hashes_before):
|
||||
"""Which steps a change needs: the watchdog restarts unless every changed field is page-only
|
||||
data; the pages re-import when the built page differs; page-number keys follow a page change."""
|
||||
changes = changed_paths(cfg_before, cfg_after)
|
||||
restart = False
|
||||
for path, before, after in changes:
|
||||
for leaf in leaves(path, after if before is None else before):
|
||||
f = schema.F.get(generic_path(leaf))
|
||||
if f is None or f["apply"] != "none":
|
||||
restart = True
|
||||
pages = []
|
||||
try:
|
||||
after = build_hashes(cfg_after)
|
||||
except Exception as e: # a config that cannot build is caught by validate; be safe here
|
||||
return {"restart": restart, "pages": [], "nav": False, "error": str(e), "changes": changes}
|
||||
for which, h in after.items():
|
||||
if hashes_before.get(which) != h:
|
||||
pages.append(which)
|
||||
if cfg_before.get("restart_page") and not cfg_after.get("restart_page"):
|
||||
pages.append("restart-removed")
|
||||
return {"restart": restart, "pages": pages, "nav": bool(pages) or cfg_before.get("page") != cfg_after.get("page")
|
||||
or (cfg_before.get("nav_keys") != cfg_after.get("nav_keys")), "changes": changes, "hashes_after": after}
|
||||
|
||||
|
||||
class Applier:
|
||||
"""Runs one Apply in a thread and reports its steps. One at a time (apply.lock)."""
|
||||
|
||||
def __init__(self, site, run=None, sleep=None):
|
||||
self.site = site
|
||||
self.run = run or subprocess.run
|
||||
self.sleep = sleep or time.sleep
|
||||
self.status = {"state": "idle", "steps": [], "started": None, "finished": None, "outcome": "", "note": "", "by": ""}
|
||||
self.thread = None
|
||||
self.lock_path = os.path.join(site.data_dir, "apply.lock")
|
||||
self.log_path = os.path.join(site.data_dir, "apply.log")
|
||||
|
||||
# --- helpers ---
|
||||
def log(self, text):
|
||||
line = "%s %s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), text)
|
||||
try:
|
||||
with open(self.log_path, "a") as f:
|
||||
f.write(line)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def step(self, name, state, detail=""):
|
||||
for s in self.status["steps"]:
|
||||
if s["name"] == name:
|
||||
s.update(state=state, detail=detail)
|
||||
break
|
||||
else:
|
||||
self.status["steps"].append({"name": name, "state": state, "detail": detail})
|
||||
self.log("%s: %s %s" % (name, state, detail))
|
||||
self.write_status()
|
||||
|
||||
def write_status(self):
|
||||
try:
|
||||
sp.write_json(os.path.join(self.site.data_dir, "apply-status.json"), self.status)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def busy(self):
|
||||
return bool(self.thread and self.thread.is_alive())
|
||||
|
||||
def live(self):
|
||||
"""(live?, reason) from the watchdog's live-now.json; stale = not live (the schedule has none at NA)."""
|
||||
d = self.site.read_json("live-now.json", {}) or {}
|
||||
if d and time.time() - d.get("time", 0) < 30:
|
||||
return bool(d.get("live")), d.get("reason", "")
|
||||
return False, ""
|
||||
|
||||
def hashes(self):
|
||||
h = self.site.read_json("build-hashes.json", None)
|
||||
if h is None: # first use: the pages were imported from the live config
|
||||
h = build_hashes(self.site.load())
|
||||
sp.write_json(os.path.join(self.site.data_dir, "build-hashes.json"), h)
|
||||
return h
|
||||
|
||||
def history_dir(self):
|
||||
d = os.path.join(self.site.data_dir, "history")
|
||||
os.makedirs(d, exist_ok=True)
|
||||
return d
|
||||
|
||||
def keep_history(self, note, by, address, actions, sha_before, sha_after, outcome="applied"):
|
||||
"""Copy the live config (and name the switcher file) into history/, 0600."""
|
||||
ts = time.strftime("%Y%m%d-%H%M%S")
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", (note or "change").lower())[:30].strip("-") or "change"
|
||||
base = os.path.join(self.history_dir(), "%s-%s" % (ts, slug))
|
||||
shutil.copyfile(self.site.config_path, base + ".json")
|
||||
os.chmod(base + ".json", 0o600)
|
||||
xml = os.path.join(self.site.data_dir, "atem-reference.xml")
|
||||
meta = {"when": time.time(), "by": by, "address": address, "note": note, "sha_before": sha_before, "sha_after": sha_after,
|
||||
"actions": actions, "outcome": outcome,
|
||||
"atem_sha": sha(open(xml, "rb").read()) if os.path.exists(xml) else None}
|
||||
sp.write_json(base + ".meta.json", meta)
|
||||
os.chmod(base + ".meta.json", 0o600)
|
||||
return os.path.basename(base)
|
||||
|
||||
def watchdog_pid(self):
|
||||
try:
|
||||
out = self.run(["/bin/launchctl", "print", "gui/%d/%s" % (os.getuid(), WATCHDOG)], capture_output=True, text=True, timeout=5).stdout
|
||||
m = re.search(r"pid = (\d+)", out)
|
||||
return int(m.group(1)) if m else None, "state = running" in out
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None, False
|
||||
|
||||
def restart_watchdog(self, want_sha, timeout=25):
|
||||
"""kickstart and wait for started.json with our fingerprint. (ok, detail)."""
|
||||
waited = 0
|
||||
while self.site.read_json("fixes-running.json", {}) and waited < 60:
|
||||
self.sleep(2)
|
||||
waited += 2
|
||||
if self.site.read_json("fixes-running.json", {}):
|
||||
return False, "a fix is still running on %s" % ", ".join(v.get("key", "?") for v in self.site.read_json("fixes-running.json", {}).values())
|
||||
old_pid, loaded = self.watchdog_pid()
|
||||
t0 = time.time()
|
||||
domain = "gui/%d" % os.getuid()
|
||||
if loaded or old_pid:
|
||||
r = self.run(["/bin/launchctl", "kickstart", "-k", "%s/%s" % (domain, WATCHDOG)], capture_output=True, text=True, timeout=30)
|
||||
else:
|
||||
plist = os.path.expanduser("~/Library/LaunchAgents/%s.plist" % WATCHDOG)
|
||||
r = self.run(["/bin/launchctl", "bootstrap", domain, plist], capture_output=True, text=True, timeout=30)
|
||||
if r.returncode != 0:
|
||||
return False, "launchctl: %s" % (r.stderr or r.stdout).strip()[-200:]
|
||||
waited = 0
|
||||
while waited < timeout:
|
||||
self.sleep(1)
|
||||
waited += 1
|
||||
st = self.site.read_json("started.json", {}) or {}
|
||||
if st.get("started_at", 0) >= t0 - 1 and st.get("pid") != old_pid:
|
||||
if want_sha and st.get("config_sha") != want_sha:
|
||||
return False, "the watchdog started on a different settings file (%s)" % (st.get("config_sha") or "?")[:12]
|
||||
self.sleep(3)
|
||||
pid, running = self.watchdog_pid()
|
||||
if not running:
|
||||
return False, "the watchdog started and stopped again - see the log"
|
||||
return True, "running (code %s)" % st.get("version")
|
||||
return False, "no start stamp after %d s - see ~/Library/Logs/status-panel.err.log" % timeout
|
||||
|
||||
def import_pages(self, cfg, which):
|
||||
"""make_page -> push_page with the marker-only rule -> verify, for the pages in `which`."""
|
||||
comp = cfg["companion"]
|
||||
host, port = comp.get("host", "127.0.0.1"), comp.get("port", 8000)
|
||||
out_dir = self.site.data_dir
|
||||
done = []
|
||||
for name in which:
|
||||
if name == "status":
|
||||
page, expected, fname = cfg["page"], cfg["page_name"], "status-page.companionconfig"
|
||||
export = make_page.build(cfg)
|
||||
elif name == "restart" and cfg.get("restart_page"):
|
||||
page, expected, fname = cfg["restart_page"]["page"], cfg["restart_page"]["page_name"], "restart-page.companionconfig"
|
||||
export = make_page.build_restart(cfg)
|
||||
else:
|
||||
continue
|
||||
path = os.path.join(out_dir, fname)
|
||||
make_page.write(path, export)
|
||||
live = push_page.fetch_page(host, port, page)
|
||||
ok, why = push_page.safe_target(live, expected)
|
||||
if not ok:
|
||||
return False, "page %d: %s" % (page, why)
|
||||
push_page.push(host, port, page, path, expected)
|
||||
after = push_page.fetch_page(host, port, page)
|
||||
want = {(r, c) for r, row in export["page"]["controls"].items() for c, ctl in row.items() if ctl and ctl.get("type") == "button-layered"}
|
||||
have = {(r, c) for r, row in (after.get("page") or {}).get("controls", {}).items() for c, ctl in (row or {}).items() if ctl and ctl.get("type") == "button-layered"}
|
||||
if want != have:
|
||||
return False, "page %d: the import did not land as built (%d keys built, %d on the page)" % (page, len(want), len(have))
|
||||
done.append(name)
|
||||
return True, ", ".join("page %d" % (cfg["page"] if n == "status" else cfg["restart_page"]["page"]) for n in done) or "nothing to import"
|
||||
|
||||
def colour_nav_keys(self, cfg):
|
||||
comp = cfg["companion"]
|
||||
try:
|
||||
n = nav_keys.colour(comp.get("host", "127.0.0.1"), comp.get("port", 8000), cfg["page"], cfg.get("nav_keys", list(make_page.NAV_LOCATIONS)),
|
||||
os.path.join(self.site.data_dir, "nav_locations.json"))
|
||||
return True, "%d page keys recoloured" % n
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
# --- the pipeline ---
|
||||
def start(self, new_cfg, note, by, address, emergency=None, atem_file=None):
|
||||
"""Begin an Apply in the background; the caller shows /api/apply/status. Refusals are immediate."""
|
||||
if self.busy():
|
||||
return {"ok": False, "error": "an Apply is already running"}
|
||||
live, reason = self.live()
|
||||
if live and not (emergency and emergency.get("word") == "LIVE" and emergency.get("reason")):
|
||||
return {"ok": False, "error": "live now (%s): changes wait until it is over" % reason, "live": True}
|
||||
if os.path.exists(self.lock_path) and time.time() - os.path.getmtime(self.lock_path) < 600:
|
||||
return {"ok": False, "error": "an Apply is already running"}
|
||||
self.status = {"state": "running", "steps": [], "started": time.time(), "finished": None, "outcome": "", "note": note, "by": by,
|
||||
"emergency": emergency}
|
||||
self.thread = threading.Thread(target=self._run, args=(new_cfg, note, by, address, atem_file), daemon=True)
|
||||
self.thread.start()
|
||||
return {"ok": True}
|
||||
|
||||
def _run(self, new_cfg, note, by, address, atem_file):
|
||||
open(self.lock_path, "w").close()
|
||||
try:
|
||||
self._pipeline(new_cfg, note, by, address, atem_file)
|
||||
except Exception as e:
|
||||
self.log("apply crashed: %r" % e)
|
||||
self.status["state"], self.status["outcome"] = "failed", "Apply stopped with an error: %s" % e
|
||||
finally:
|
||||
self.status["finished"] = time.time()
|
||||
try:
|
||||
os.remove(self.lock_path)
|
||||
except OSError:
|
||||
pass
|
||||
self.write_status()
|
||||
|
||||
def _pipeline(self, new_cfg, note, by, address, atem_file):
|
||||
site = self.site
|
||||
old_cfg = site.load()
|
||||
sha_before = site.cfg_sha
|
||||
if self.status.get("emergency"):
|
||||
self.log("EMERGENCY apply by %s: %s" % (by, self.status["emergency"].get("reason")))
|
||||
# 1. check
|
||||
self.step("check", "running")
|
||||
errors, warnings = schema.validate(new_cfg)
|
||||
tmp = tempfile.NamedTemporaryFile("wb", suffix=".json", delete=False)
|
||||
tmp.write(config_text(new_cfg)); tmp.close()
|
||||
try:
|
||||
sp.load_config(tmp.name)
|
||||
except (ValueError, KeyError, TypeError) as e:
|
||||
errors.append("the watchdog would not accept it: %s" % e)
|
||||
finally:
|
||||
os.remove(tmp.name)
|
||||
if errors:
|
||||
self.step("check", "failed", "; ".join(errors)[:500])
|
||||
self.status["state"], self.status["outcome"] = "failed", "Not applied: " + errors[0]
|
||||
return
|
||||
plan = what_apply_does(old_cfg, new_cfg, self.hashes())
|
||||
if plan.get("error"):
|
||||
self.step("check", "failed", plan["error"])
|
||||
self.status["state"], self.status["outcome"] = "failed", "Not applied: the pages could not be built: " + plan["error"]
|
||||
return
|
||||
self.step("check", "ok", "%d change%s" % (len(plan["changes"]), "" if len(plan["changes"]) == 1 else "s"))
|
||||
# 2. history + write
|
||||
self.step("save", "running")
|
||||
actions = [a for a, on in (("restart", plan["restart"] or bool(atem_file)), ("pages", bool(plan["pages"])), ("nav", plan["nav"])) if on]
|
||||
if atem_file and os.path.exists(atem_file):
|
||||
actions.append("switcher file")
|
||||
text = config_text(new_cfg)
|
||||
sha_after = sha(text)
|
||||
entry = self.keep_history(note, by, address, actions, sha_before, sha_after)
|
||||
tmp_path = site.config_path + ".tmp"
|
||||
with open(tmp_path, "wb") as f:
|
||||
f.write(text)
|
||||
os.replace(tmp_path, site.config_path)
|
||||
xml_backup = None
|
||||
if atem_file and os.path.exists(atem_file):
|
||||
xml = os.path.join(site.data_dir, "atem-reference.xml")
|
||||
if os.path.exists(xml):
|
||||
hist = os.path.join(self.history_dir(), "atem")
|
||||
os.makedirs(hist, exist_ok=True)
|
||||
xml_backup = os.path.join(hist, time.strftime("%Y%m%d-%H%M%S") + ".xml")
|
||||
shutil.copyfile(xml, xml_backup)
|
||||
os.replace(atem_file, xml)
|
||||
site.load()
|
||||
self.step("save", "ok", "kept as %s" % entry)
|
||||
# 3. restart the watchdog
|
||||
if plan["restart"] or atem_file:
|
||||
self.step("restart", "running")
|
||||
ok, detail = self.restart_watchdog(sha_after)
|
||||
if not ok:
|
||||
self.step("restart", "failed", detail)
|
||||
self._rollback(entry, xml_backup, plan, old_cfg, detail)
|
||||
return
|
||||
self.step("restart", "ok", detail)
|
||||
else:
|
||||
self.step("restart", "skipped", "nothing the watchdog reads changed")
|
||||
# 4. pages
|
||||
if plan["pages"]:
|
||||
self.step("pages", "running")
|
||||
ok, detail = self.import_pages(new_cfg, [p for p in plan["pages"] if p in ("status", "restart")])
|
||||
if not ok:
|
||||
self.step("pages", "failed", detail)
|
||||
sp.write_json(os.path.join(site.data_dir, "pages-out-of-sync.json"), {"when": time.time(), "detail": detail})
|
||||
self._rollback(entry, xml_backup, plan, old_cfg, "the pages could not be imported: " + detail)
|
||||
return
|
||||
sp.write_json(os.path.join(site.data_dir, "build-hashes.json"), plan["hashes_after"])
|
||||
try:
|
||||
os.remove(os.path.join(site.data_dir, "pages-out-of-sync.json"))
|
||||
except OSError:
|
||||
pass
|
||||
self.step("pages", "ok", detail)
|
||||
else:
|
||||
self.step("pages", "skipped", "the deck's layout did not change")
|
||||
if plan["nav"]:
|
||||
self.step("page keys", "running")
|
||||
ok, detail = self.colour_nav_keys(new_cfg)
|
||||
self.step("page keys", "ok" if ok else "failed", detail)
|
||||
self.status["state"], self.status["outcome"] = "done", "Applied. " + ("The watchdog restarted. " if plan["restart"] else "") + \
|
||||
("Pages re-imported. " if plan["pages"] else "")
|
||||
self.log("applied by %s (%s): %s" % (by, address, note))
|
||||
|
||||
def _rollback(self, entry, xml_backup, plan, old_cfg, why):
|
||||
"""Put the previous version back, once."""
|
||||
site = self.site
|
||||
self.step("rollback", "running", why)
|
||||
src = os.path.join(self.history_dir(), entry + ".json")
|
||||
shutil.copyfile(src, site.config_path + ".tmp")
|
||||
os.replace(site.config_path + ".tmp", site.config_path)
|
||||
if xml_backup:
|
||||
shutil.copyfile(xml_backup, os.path.join(site.data_dir, "atem-reference.xml"))
|
||||
site.load()
|
||||
ok, detail = self.restart_watchdog(site.cfg_sha)
|
||||
pages_note = ""
|
||||
if ok and plan.get("pages"):
|
||||
pok, pdetail = self.import_pages(old_cfg, [p for p in plan["pages"] if p in ("status", "restart")])
|
||||
pages_note = "; pages put back" if pok else "; the pages could not be put back: " + pdetail
|
||||
self.step("rollback", "ok" if ok else "failed", ("previous settings restored, " + detail + pages_note) if ok else detail)
|
||||
self.status["state"] = "failed"
|
||||
self.status["outcome"] = "Not applied - %s. %s" % (why, "The previous settings are back." if ok else "The previous settings could not be restarted either: " + detail)
|
||||
sp.write_json(os.path.join(site.data_dir, "last-apply-failed.json"), {"when": time.time(), "why": why, "restored": ok})
|
||||
for e in (entry,):
|
||||
meta_path = os.path.join(self.history_dir(), e + ".meta.json")
|
||||
try:
|
||||
meta = json.load(open(meta_path))
|
||||
meta["outcome"] = "failed, rolled back: " + why
|
||||
sp.write_json(meta_path, meta)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def history_entries(data_dir):
|
||||
"""Newest first: the Apply copies plus the installer's config.json.bak-* files."""
|
||||
out = []
|
||||
hist = os.path.join(data_dir, "history")
|
||||
if os.path.isdir(hist):
|
||||
for name in sorted(os.listdir(hist), reverse=True):
|
||||
if not name.endswith(".json") or name.endswith(".meta.json"):
|
||||
continue
|
||||
base = name[:-5]
|
||||
try:
|
||||
meta = json.load(open(os.path.join(hist, base + ".meta.json")))
|
||||
except (OSError, ValueError):
|
||||
meta = {}
|
||||
out.append({"id": base, "when": meta.get("when"), "by": meta.get("by", "?"), "note": meta.get("note", ""),
|
||||
"outcome": meta.get("outcome", ""), "actions": meta.get("actions", []), "kind": "apply"})
|
||||
for name in sorted(os.listdir(data_dir), reverse=True):
|
||||
m = re.match(r"config\.json\.bak-(\d{8})-(\d{6})$", name)
|
||||
if m:
|
||||
t = time.mktime(time.strptime(m.group(1) + m.group(2), "%Y%m%d%H%M%S"))
|
||||
out.append({"id": name, "when": t, "by": "the installer", "note": "config.json replaced by install.sh", "outcome": "", "actions": [], "kind": "install"})
|
||||
out.sort(key=lambda e: e["when"] or 0, reverse=True)
|
||||
return out
|
||||
|
||||
|
||||
def history_config(data_dir, entry_id):
|
||||
path = os.path.join(data_dir, "history", entry_id + ".json") if not entry_id.startswith("config.json.bak-") else os.path.join(data_dir, entry_id)
|
||||
if not os.path.realpath(path).startswith(os.path.realpath(data_dir)):
|
||||
return None
|
||||
try:
|
||||
return json.load(open(path))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
Reference in New Issue
Block a user