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