1#!/usr/bin/env python3
2import argparse
3from datetime import datetime, timezone
4import fcntl
5import hashlib
6import json
7import os
8from pathlib import Path
9import re
10import subprocess
11import time
12
13
14ROOT = Path("/opt/studio")
15RELEASES = ROOT / "releases"
16STATE = Path("/var/lib/studio")
17HISTORY = STATE / "deployments.json"
18SOURCES = ("config", "service", "tools", "nixos", "dashboard", "guest", "flake.nix", "flake.lock", "readme.md")
19RELEASE_ID = re.compile(r"[0-9a-f]{16}\Z")
20STAGE_ID = re.compile(r"[a-z][a-z0-9-]*\Z")
21
22
23def excluded_services(root):
24 path = root / "config/excluded-services.json"
25 return set(json.loads(path.read_text())) if path.exists() else set()
26
27
28def files(root):
29 excluded = excluded_services(root)
30 for name in SOURCES:
31 source = root / name
32 if name == "service" and source.is_dir():
33 paths = []
34 for directory, directories, filenames in os.walk(source):
35 directories[:] = [entry for entry in directories if entry not in excluded]
36 paths.extend(Path(directory) / entry for entry in [*directories, *filenames]
37 if not (entry.endswith(".pkl") and entry[:-4] in excluded))
38 else:
39 paths = source.rglob("*") if source.is_dir() else [source]
40 for path in sorted(paths):
41 relative = path.relative_to(root)
42 if relative.parts[:2] in (
43 ("dashboard", "node_modules"), ("dashboard", "dist"),
44 ("dashboard", ".cache"), ("dashboard", "data"), ("dashboard", "target"),
45 ("guest", "build"),
46 ):
47 continue
48 if any(part in {".DS_Store", "__pycache__", ".identities.lock", "identities.pending"} or part.startswith("._") or part.endswith(".pyc") for part in relative.parts):
49 continue
50 if path.is_symlink():
51 raise ValueError(f"unsupported release file: {relative}")
52 if path.is_dir():
53 continue
54 if not path.is_file():
55 raise ValueError(f"unsupported release file: {relative}")
56 yield path, relative
57
58
59def _hash_contents(digest, path):
60 with path.open("rb") as source:
61 while chunk := source.read(65536):
62 digest.update(chunk)
63
64
65def tree_digest(root):
66 digest = hashlib.sha256()
67 for path, relative in files(root):
68 digest.update(str(relative).encode() + b"\0")
69 _hash_contents(digest, path)
70 return digest.hexdigest()
71
72
73def host_digest(root):
74 digest = hashlib.sha256()
75 paths = sorted(path for path, relative in files(root)
76 if relative.parts[0] in {"nixos", "dashboard", "config", "service"}
77 or str(relative) in {"flake.nix", "flake.lock", "tools/router.py", "tools/dashboard-host.py", "tools/dashboard-run.py", "tools/release.py", "tools/vms.py", "tools/vm-screen.py", "guest/protocol.json"})
78 for path in paths:
79 digest.update(str(path.relative_to(root)).encode())
80 _hash_contents(digest, path)
81 return digest.hexdigest()
82
83
84def check_release(release, legacy=False):
85 if not RELEASE_ID.fullmatch(release):
86 raise ValueError("invalid release ID")
87 path = RELEASES / release
88 if not path.is_dir():
89 raise ValueError(f"release is missing: {release}")
90 manifest = path / ".studio-release.json"
91 if not manifest.exists():
92 if not legacy:
93 raise ValueError(f"release has no manifest: {release}")
94 else:
95 details = json.loads(manifest.read_text())
96 if details.get("id") != release or details.get("digest") != tree_digest(path) or details.get("version", 1) not in (1, 2):
97 raise ValueError(f"release contents changed: {release}")
98 return path
99
100
101def current_release(link="current"):
102 current = ROOT / link
103 if not current.is_symlink():
104 return None
105 target = current.resolve()
106 if target.parent != RELEASES or not RELEASE_ID.fullmatch(target.name):
107 raise ValueError(f"{link} points outside releases: {target}")
108 return target.name
109
110
111def main_release(version=None):
112 version = version or current_release("main")
113 if version is None:
114 return None
115 if not RELEASE_ID.fullmatch(version):
116 raise ValueError("invalid release ID")
117 details = json.loads((RELEASES / version / ".studio-release.json").read_text())
118 revision = details.get("main")
119 if (not isinstance(revision, dict) or not isinstance(revision.get("commit"), str)
120 or not re.fullmatch(r"[0-9a-f]{40}", revision["commit"])
121 or not isinstance(revision.get("description"), str) or not revision["description"].strip()):
122 raise ValueError("This release did not come from a described main commit. Publish main first.")
123 return {"release": version, "commit": revision["commit"], "description": revision["description"]}
124
125
126def history():
127 return json.loads(HISTORY.read_text()) if HISTORY.exists() else []
128
129
130def save_history(entries):
131 pending = HISTORY.with_suffix(".pending")
132 pending.write_text(json.dumps(entries, indent=2) + "\n")
133 pending.replace(HISTORY)
134
135
136def jobs(path):
137 rendered = subprocess.run(
138 ["python3", str(path / "tools/studio.py"), "render"],
139 check=True, capture_output=True, text=True,
140 ).stdout
141 result = set(re.findall(r'^job "([a-z][a-z0-9-]*)" \{$', rendered, re.MULTILINE))
142 if not result:
143 raise ValueError(f"no Nomad jobs rendered by {path}")
144 return result
145
146
147def activate(release, legacy=False, initial=False):
148 path = check_release(release, legacy)
149 script = path / "tools/studio.py"
150 managed = STATE / "managed-jobs.json"
151 current = current_release()
152 previous = set(json.loads(managed.read_text())) if managed.exists() else (
153 jobs(RELEASES / current) if current and not initial else set()
154 )
155 digest = host_digest(path)
156 hostname = os.uname().nodename.split(".", 1)[0]
157 configuration = "vm" if hostname == "clover-demo" else hostname
158 recorded = ROOT / "host.digest"
159 old_digest = recorded.read_text().strip() if recorded.exists() else None
160 if old_digest != digest:
161 subprocess.run(["nixos-rebuild", "dry-build", "--flake", f"path:{path}#{configuration}"], check=True)
162 subprocess.run(["python3", str(script), "preflight"], check=True)
163 backup = None
164 if current and not initial:
165 backup_script = ROOT / "data.py"
166 if not backup_script.is_file():
167 backup_script = path / "tools/data.py"
168 result = subprocess.run(
169 ["python3", str(backup_script), "backup", current, release],
170 check=True, capture_output=True, text=True,
171 )
172 print(result.stdout.strip(), flush=True)
173 backup = result.stdout.split("backup=", 1)[1].split()[0]
174 if old_digest != digest:
175 if current is None:
176 (ROOT / "current").symlink_to(path)
177 subprocess.run(["nixos-rebuild", "switch", "--flake", f"path:{path}#{configuration}"], check=True)
178 next_link = ROOT / ("next-" + release)
179 next_link.unlink(missing_ok=True)
180 next_link.symlink_to(path)
181 next_link.replace(ROOT / "current")
182 if old_digest != digest:
183 recorded.write_text(digest + "\n")
184 for _ in range(60):
185 response = subprocess.run(
186 ["curl", "--fail", "--silent", "--max-time", "2", "http://127.0.0.1:4646/v1/status/leader"],
187 capture_output=True, text=True,
188 )
189 if response.returncode == 0 and json.loads(response.stdout):
190 break
191 time.sleep(2)
192 else:
193 raise RuntimeError("Nomad API did not become ready")
194 subprocess.run(["systemctl", "restart", "studio-router.service"], check=True)
195 if (path / "nixos/dashboard.nix").exists():
196 subprocess.run(["systemctl", "start", "studio-dashboard.service"], check=True)
197 subprocess.run(["systemctl", "is-active", "--quiet", "studio-dashboard.service"], check=True)
198 subprocess.run(["python3", str(script), "pool"], check=True)
199 subprocess.run(["python3", str(script), "deploy"], check=True)
200 desired = jobs(path)
201 for name in sorted(previous - desired):
202 subprocess.run(
203 ["nomad", "job", "stop", "-purge", "-yes", name], check=True,
204 env={**os.environ, "NOMAD_TOKEN": (STATE / "nomad.token").read_text().strip()},
205 )
206 pending = managed.with_suffix(".pending")
207 pending.write_text(json.dumps(sorted(desired)) + "\n")
208 pending.replace(managed)
209 if (path / "tools/log-shipper.py").exists():
210 subprocess.run(["systemctl", "restart", "studio-log-shipper.service"], check=True)
211 manifest = path / ".studio-release.json"
212 if not legacy and json.loads(manifest.read_text()).get("version", 1) >= 2:
213 subprocess.run(["python3", str(script), "check"], check=True)
214 return backup
215
216
217def main():
218 parser = argparse.ArgumentParser(description="Deploy main or roll back a home server release")
219 parser.add_argument("mode", choices=["publish", "deploy", "rollback", "history", "bootstrap", "verify"])
220 parser.add_argument("target", nargs="?")
221 args = parser.parse_args()
222 STATE.mkdir(parents=True, exist_ok=True)
223 if args.mode == "verify":
224 if not args.target:
225 parser.error("verify requires a release ID")
226 check_release(args.target)
227 return
228 if args.mode == "history":
229 if args.target:
230 parser.error("history takes no target")
231 current = current_release()
232 entries = history()
233 for index, entry in enumerate(entries[-12:], start=max(0, len(entries) - 12)):
234 marker = "*" if index == len(entries) - 1 and entry["release"] == current else " "
235 when = datetime.fromtimestamp(entry["time"], timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
236 print(f"{marker} {entry['release']} {when} {entry['source']}")
237 if current and (not entries or entries[-1]["release"] != current):
238 print(f"* {current} deployment incomplete")
239 return
240 with (STATE / "release.lock").open("w") as lock:
241 fcntl.flock(lock, fcntl.LOCK_EX)
242 if args.mode == "publish":
243 if not args.target:
244 parser.error("publish requires a main release ID")
245 path = check_release(args.target)
246 main_release(args.target)
247 pending = ROOT / "main.pending"
248 pending.unlink(missing_ok=True)
249 pending.symlink_to(path)
250 pending.replace(ROOT / "main")
251 print(f"main={args.target}")
252 return
253 entries = history()
254 current = current_release()
255 if current and not entries and args.mode != "bootstrap":
256 entries.append({"release": current, "source": "previous", "time": int(time.time()), "legacy": not (RELEASES / current / ".studio-release.json").exists()})
257 save_history(entries)
258 if args.mode == "deploy":
259 candidate = main_release()
260 if not candidate or args.target != candidate["release"]:
261 raise ValueError("main changed or is unavailable. Publish main and retry deployment.")
262 release = candidate["release"]
263 source = "main"
264 legacy = False
265 elif args.mode == "bootstrap":
266 if not args.target or not RELEASE_ID.fullmatch(args.target) or entries:
267 parser.error("bootstrap requires a release ID and no successful deployment")
268 release, source, legacy = args.target, "bootstrap", False
269 main_release(release)
270 else:
271 if args.target:
272 match = next((item for item in reversed(entries) if item["release"] == args.target), None)
273 if not match:
274 parser.error("rollback target is not in production history")
275 else:
276 match = next((item for item in reversed(entries) if item["release"] != current), None)
277 if not match:
278 parser.error("no earlier production release")
279 release, source, legacy = match["release"], "rollback", match.get("legacy", False)
280 if release == current and entries and entries[-1]["release"] == current:
281 print(f"already running {release}")
282 return
283 backup = activate(release, legacy, args.mode == "bootstrap")
284 entries.append({"release": release, "source": source, "time": int(time.time()), "legacy": legacy, "backup": backup})
285 save_history(entries)
286 print(f"production={release} previous={current or ''}")
287
288
289if __name__ == "__main__":
290 main()