Files
status-indicators/settings/apply.py
T
christianandClaude Fable 5.1 02d0c77cd9 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>
2026-10-14 14:06:42 -04:00

549 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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