Steps 4-6: the jobs, the set-up tab with generated scripts, Install the staged folder, site bundles and the campus template
- 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>
This commit is contained in:
@@ -20,18 +20,23 @@ import logging.handlers
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
import io
|
||||
import plistlib
|
||||
|
||||
from settings import PARENT, HERE # noqa: F401
|
||||
import schema
|
||||
import status_panel as sp
|
||||
from settings import apply as applying
|
||||
from settings import builders
|
||||
from settings import scripts
|
||||
from settings import keyguide, probes
|
||||
|
||||
log = logging.getLogger("status-settings")
|
||||
@@ -645,6 +650,28 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
if path == "/api/live/midi":
|
||||
snap = site.read_json("snapshot.json", {}) or {}
|
||||
return self.send(200, {"local": snap.get("midi"), "remote": snap.get("remote_midi")})
|
||||
if path == "/api/setup":
|
||||
return self.send(200, {"computers": scripts.setup_all(site.load())})
|
||||
if path == "/api/program/staged":
|
||||
return self.send(200, self.staged())
|
||||
if path == "/api/site/bundle":
|
||||
cfg = site.load()
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
z.writestr("config.json", applying.config_text(cfg))
|
||||
for name in ("atem-reference.xml", "nav_locations.json"):
|
||||
fp = os.path.join(site.data_dir, name)
|
||||
if os.path.exists(fp):
|
||||
z.write(fp, name)
|
||||
z.writestr("README.txt", "Site bundle of %s, exported %s from the settings page.\nDrop it in the repo as sites/%s/ or feed it to "
|
||||
"'New campus from a bundle' on another campus's page.\n" % ((cfg.get("site") or {}).get("name", "?"), time.strftime("%Y-%m-%d %H:%M"), (cfg.get("site") or {}).get("id", "site")))
|
||||
data = buf.getvalue()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/zip")
|
||||
self.send_header("Content-Disposition", "attachment; filename=\"site-%s-%s.zip\"" % ((cfg.get("site") or {}).get("id", "site"), time.strftime("%Y%m%d")))
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
return self.wfile.write(data)
|
||||
if path == "/api/builders":
|
||||
return self.send(200, {"fixes": builders.template_list(site.cfg), "checks": builders.check_template_list()})
|
||||
if path == "/api/facts":
|
||||
@@ -801,6 +828,32 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
return self.send(200 if ok else 502, {"ok": ok, "sent": text})
|
||||
if path == "/api/atem-reference":
|
||||
return self.send(200, self.take_atem(raw))
|
||||
if path == "/api/program/install":
|
||||
live, reason = site.applier.live()
|
||||
if live:
|
||||
return self.send(409, {"error": "live now (%s): the program is not updated during a service" % reason})
|
||||
return self.send(200, self.install_staged(who, keep_config=body.get("keep_config", True)))
|
||||
if path == "/api/site/import":
|
||||
try:
|
||||
new_cfg = self.bundle_config(raw)
|
||||
except (ValueError, KeyError, zipfile.BadZipFile) as e:
|
||||
return self.send(400, {"error": "not a site bundle or settings file: %s" % e})
|
||||
cfg = site.load()
|
||||
mine = cfg.get("site") or {}
|
||||
src = (new_cfg.get("site") or {}).get("name") or "the other campus"
|
||||
new_cfg["site"] = dict(new_cfg.get("site") or {}, **{k: mine[k] for k in ("id", "name", "ingest_name", "ingest_hostname", "ingest_ips", "editing_code", "settings_port") if k in mine})
|
||||
flag = "copied from %s - check it" % src
|
||||
for alias, h in (new_cfg.get("hosts") or {}).items():
|
||||
if h.get("self"):
|
||||
for k in ("ssh", "mac", "wake_broadcast"):
|
||||
h.pop(k, None)
|
||||
h["notes"] = (flag + (". " + h["notes"] if h.get("notes") else ""))
|
||||
for f in (new_cfg.get("fixes") or {}).values():
|
||||
f["label"] = f.get("label", "")
|
||||
errors, warnings = schema.validate(new_cfg)
|
||||
draft, _ = site.draft.update(new_cfg, who, site.cfg_sha)
|
||||
log.info("%s started a new campus from a bundle of %s", who, src)
|
||||
return self.send(200, {"draft": self.masked_draft(draft), "from": src, "errors": errors, "warnings": warnings})
|
||||
if path == "/api/atem-reference/pick-newest":
|
||||
folder = os.path.expanduser((site.cfg.get("atem") or {}).get("source_folder") or "")
|
||||
files = sorted(glob.glob(os.path.join(folder, "*.xml")), key=os.path.getmtime) if folder and os.path.isdir(folder) else []
|
||||
@@ -825,6 +878,73 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
log.exception("%s %s", self.command, path)
|
||||
return self.send(500, {"error": str(e)})
|
||||
|
||||
def staged(self):
|
||||
"""The staged folder (~/status-panel-install) against the running code."""
|
||||
src = os.path.expanduser("~/status-panel-install")
|
||||
out = {"folder": src, "present": os.path.isfile(os.path.join(src, "install.sh")), "staged_version": None, "staged_when": None,
|
||||
"running_version": (self.site.read_json("started.json", {}) or {}).get("version"), "job": None, "log": []}
|
||||
sp_path = os.path.join(src, "status_panel.py")
|
||||
if os.path.isfile(sp_path):
|
||||
m = re.search(r'^VERSION\s*=\s*"([^"]+)"', open(sp_path, encoding="utf-8").read(), re.M)
|
||||
out["staged_version"] = m.group(1) if m else "?"
|
||||
out["staged_when"] = os.path.getmtime(sp_path)
|
||||
logp = os.path.expanduser("~/Library/Logs/status-install.log")
|
||||
if os.path.exists(logp):
|
||||
try:
|
||||
out["log"] = open(logp, encoding="utf-8", errors="replace").read().splitlines()[-25:]
|
||||
out["log_when"] = os.path.getmtime(logp)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
r = subprocess.run(["/bin/launchctl", "print", "gui/%d/org.waterschurch.status-install" % os.getuid()], capture_output=True, text=True, timeout=5)
|
||||
out["job"] = "running" if "state = running" in r.stdout else ("loaded" if r.returncode == 0 else None)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
return out
|
||||
|
||||
def install_staged(self, who, keep_config=True):
|
||||
"""Run install.sh from the staged folder as a one-shot launchd job (it survives this agent's own restart)."""
|
||||
src = os.path.expanduser("~/status-panel-install")
|
||||
if not os.path.isfile(os.path.join(src, "install.sh")):
|
||||
return {"ok": False, "error": "nothing is staged in %s (copy the folder there with scp first)" % src}
|
||||
label = "org.waterschurch.status-install"
|
||||
plist = os.path.expanduser("~/Library/LaunchAgents/%s.plist" % label)
|
||||
args = ["/bin/zsh", os.path.join(src, "install.sh")] + (["--keep-config"] if keep_config else [])
|
||||
with open(plist, "wb") as f:
|
||||
plistlib.dump({"Label": label, "ProgramArguments": args, "RunAtLoad": True, "AbandonProcessGroup": True,
|
||||
"StandardOutPath": os.path.expanduser("~/Library/Logs/status-install.log"),
|
||||
"StandardErrorPath": os.path.expanduser("~/Library/Logs/status-install.log"),
|
||||
"EnvironmentVariables": {"HOME": os.path.expanduser("~"), "PATH": "/usr/bin:/bin:/usr/sbin:/sbin"}}, f)
|
||||
domain = "gui/%d" % os.getuid()
|
||||
subprocess.run(["/bin/launchctl", "bootout", "%s/%s" % (domain, label)], capture_output=True, text=True, timeout=10)
|
||||
try:
|
||||
open(os.path.expanduser("~/Library/Logs/status-install.log"), "a").write("\n==== %s install started by %s from the settings page ====\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), who))
|
||||
except OSError:
|
||||
pass
|
||||
r = subprocess.run(["/bin/launchctl", "bootstrap", domain, plist], capture_output=True, text=True, timeout=10)
|
||||
log.info("%s started the install job (%s): rc=%s %s", who, " ".join(args), r.returncode, (r.stderr or r.stdout).strip())
|
||||
if r.returncode != 0:
|
||||
return {"ok": False, "error": "launchctl: %s" % (r.stderr or r.stdout).strip()[-200:]}
|
||||
return {"ok": True, "detail": "install.sh is running; this page comes back in about 20 seconds"}
|
||||
|
||||
def bundle_config(self, raw):
|
||||
"""config.json out of a site bundle (zip) or a bare settings file."""
|
||||
if raw[:2] == b"PK":
|
||||
with zipfile.ZipFile(io.BytesIO(raw)) as z:
|
||||
names = [n for n in z.namelist() if n.endswith("config.json")]
|
||||
if not names:
|
||||
raise ValueError("no config.json in the bundle")
|
||||
cfg = json.loads(z.read(names[0]).decode("utf-8"))
|
||||
xml = [n for n in z.namelist() if n.endswith("atem-reference.xml")]
|
||||
if xml:
|
||||
with open(os.path.join(self.site.data_dir, "draft-atem.xml"), "wb") as f:
|
||||
f.write(z.read(xml[0]))
|
||||
else:
|
||||
cfg = json.loads(raw.decode("utf-8"))
|
||||
if not isinstance(cfg, dict) or "tiles" not in cfg:
|
||||
raise ValueError("no keys in it")
|
||||
return cfg
|
||||
|
||||
def masked_draft(self, draft):
|
||||
return dict(draft, config=schema.mask_secrets(draft["config"]))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user