"""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))) if cfg.get("ready_page"): out["ready"] = sha(strip_notes(make_page.build_ready(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 › ").replace(".params.", " › ").replace(".params", " › fields") 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("_", " ") val = plain_value 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} command = field in ("run", "cmd", "match") or path.endswith("pref.want") b, a = val(before), val(after) long_ = command or len(b) + len(a) > 90 if before is not None and after is not None: text = "%s changed" % where if long_ else "%s: %s → %s" % (where, b, a) elif before is None: text = "%s added" % where if long_ else "%s added: %s" % (where, a) else: text = "%s removed" % where if long_ else "%s removed (was %s)" % (where, b) out = {"path": path, "where": where, "text": text, "help": help_, "command": command} if long_: out["before"], out["after"] = (b if before is not None else None), (a if after is not None else None) return out def hhmm12(t): m = re.match(r"^(\d{1,2}):(\d{2})$", str(t or "")) if not m: return str(t) h, mm = int(m.group(1)), int(m.group(2)) if h == 24 and mm == 0: return "midnight" return "%d%s %s" % (h % 12 or 12, (":%02d" % mm) if mm else "", "am" if h < 12 else "pm") def plain_value(v): """A value in words: windows as days and hours, lists joined, records as 'field value' pairs.""" if v is None: return "—" if isinstance(v, bool): return "yes" if v else "no" if isinstance(v, list): if v and all(isinstance(w, dict) and "days" in w and "start" in w for w in v): return "; ".join("%s %s – %s" % (", ".join(d[:1].upper() + d[1:] for d in w.get("days", [])), hhmm12(w.get("start")), hhmm12(w.get("end"))) for w in v) or "never" if all(isinstance(x, (str, int, float)) for x in v): return ", ".join(str(x) for x in v) or "none" return "; ".join(plain_value(x) for x in v) if isinstance(v, dict): return ", ".join("%s %s" % (k.replace("_", " "), plain_value(x)) for k, x in v.items()) or "nothing" return str(v) def set_path(cfg, path, value): """Put a value at a changed_paths() path (tiles[hd2].checks.0.optional, hosts.prop.ssh ...); None removes it.""" tokens = re.findall(r"\[([^\]]+)\]|([^.\[\]]+)", path) node = cfg for i, (idx, key) in enumerate(tokens): last = i == len(tokens) - 1 if idx: # a record in an id list rec = next((x for x in node if isinstance(x, dict) and x.get("id") == idx), None) if last: if value is None: if rec is not None: node.remove(rec) elif rec is None: node.append(value) else: rec.clear(); rec.update(value) return if rec is None: rec = {"id": idx}; node.append(rec) node = rec elif isinstance(node, list): k = int(key) if last: if value is None: if k < len(node): node.pop(k) elif k < len(node): node[k] = value else: node.append(value) return while len(node) <= k: node.append({}) node = node[k] else: if last: if value is None: node.pop(key, None) else: node[key] = value return if key not in node or node[key] is None: node[key] = [] if (i + 1 < len(tokens) and (tokens[i + 1][0] or tokens[i + 1][1].isdigit())) else {} node = node[key] def rebase(draft, live_cfg, base_cfg): """A draft started on an older config: its own changes, replayed on the live config.""" merged = json.loads(json.dumps(live_cfg)) changes = changed_paths(base_cfg, draft["config"]) for path, _, after in changes: set_path(merged, path, after) return merged, changes 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) elif name == "ready" and cfg.get("ready_page"): page, expected, fname = cfg["ready_page"]["page"], cfg["ready_page"]["page_name"], "ready-page.companionconfig" export = make_page.build_ready(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) try: os.remove(os.path.join(site.data_dir, "page-only.json")) except OSError: pass else: self.step("restart", "skipped", "nothing the watchdog reads changed") started = site.read_json("started.json", {}) or {} prev = site.read_json("page-only.json", {}) or {} sp.write_json(os.path.join(site.data_dir, "page-only.json"), {"sha": sha_after, "watchdog_sha": prev.get("watchdog_sha") if prev.get("watchdog_sha") == started.get("config_sha") else started.get("config_sha")}) # 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", "ready")]) 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", "ready")]) 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": "the settings file was replaced by the installer", "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