- Overview jobs: Change the playlist, Add or remove a HyperDeck, Add a MIDI link, Add a computer, Make a PC program startable; 'Add a key for it' on unwatched Companion connections. - Computers > Setup: steps with a [how] each and the set-up scripts generated per computer by settings/scripts.py from the templates in remote-access/ (no site-shaped value left in them); Remove this computer drops the fixes and checks that used it. - Site setup > Site: the running and staged code, Install the staged folder (a one-shot launchd job running install.sh --keep-config, refused while live), Export site bundle (zip), New campus from a bundle (this campus's site block stays; every copied computer is flagged until touched). - sites/: na -> the repo config, _template = the empty campus; install.sh --site <id>. - Review reads appended checks as 'added'; tests for all of it. - Deployed page-only to NA Ingest. (config.json, status_panel.py and readiness.py are left to the READY-page work in progress in the same tree.) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
553 lines
27 KiB
Python
553 lines
27 KiB
Python
"""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 isinstance(a, list) and isinstance(b, list) and all(isinstance(x, dict) for x in a + b) and a[:min(len(a), len(b))] == b[:min(len(a), len(b))]:
|
||
# the same records with some added at the end, or some removed from the end
|
||
for i in range(min(len(a), len(b)), max(len(a), len(b))):
|
||
out.append(("%s.%d" % (prefix, i), a[i] if i < len(a) else None, b[i] if i < len(b) else None))
|
||
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
|