feat(selfhost): add persistent host update service
All checks were successful
Build and Release FEDEO / verify (push) Successful in 47s
Build and Release FEDEO / build-and-release (push) Successful in 5m53s

This commit is contained in:
2026-09-23 18:09:04 +02:00
parent cbec5b058d
commit 7164989da9
12 changed files with 896 additions and 10 deletions

View File

@@ -37,6 +37,10 @@ class UpdateError(Exception):
pass
class PlanChangedError(UpdateError):
pass
def now():
return datetime.datetime.now(datetime.timezone.utc).isoformat()
@@ -288,7 +292,7 @@ class Updater:
if shutil.disk_usage(directory).free < required:
raise UpdateError(f"Zu wenig freier Speicher auf {directory}: mindestens {required // GIB + 1} GiB erforderlich.")
def prepare(self, version):
def prepare(self, version, *, allow_current=False):
for name in (".env", "docker-compose.yml"):
path = self.root / name
if not path.is_file() or path.is_symlink():
@@ -318,7 +322,8 @@ class Updater:
self.target = self.manifest["version"]
self.save("validating", version=self.target, revision=self.manifest["commit"])
old_version = self.current["services"]["backend"].get("environment", {}).get("FEDEO_VERSION", "dev")
if VERSION.fullmatch(old_version) and tuple(map(int, old_version.split("."))) >= tuple(map(int, self.target.split("."))):
update_available = not VERSION.fullmatch(old_version) or tuple(map(int, old_version.split("."))) < tuple(map(int, self.target.split(".")))
if not allow_current and not update_available:
raise UpdateError("Zielversion muss neuer sein; Downgrades und erneute Installation sind gesperrt.")
previous = read_json(self.home / "installed.json", {})
write_json(self.job / "original-installed.json", previous)
@@ -381,7 +386,11 @@ class Updater:
"images": self.images, "composeDiff": str(self.job / "compose.diff"),
"releaseNotes": str(self.job / "CHANGELOG.md"), "requiresComposeAdoption": not self.managed,
"estimatedBackupBytes": self.backup_size, "services": list(self.candidate["services"]),
"retiredServices": self.retired_services, "retainedDataPaths": self.matrix_backup_paths}
"retiredServices": self.retired_services, "retainedDataPaths": self.matrix_backup_paths,
"updateAvailable": update_available,
# Kept in private job records; the service never exposes configuration fingerprints.
"approvalFingerprint": sha(json.dumps({"manifest": self.manifest, "configuration": self.before,
"targetCompose": sha((self.job / "target.compose.yml").read_bytes())}, sort_keys=True).encode())}
write_json(self.job / "plan.json", plan)
return plan
@@ -593,7 +602,7 @@ class Updater:
write_json(self.home / "installed.json", {"manifest": self.manifest, "composeSha256": sha((self.root / "docker-compose.yml").read_bytes()), "installedAt": now()})
self.save("complete", status="succeeded", recoveryRequired=False, maintenance=False)
def execute(self, command, version=None, accept_compose=False):
def execute(self, command, version=None, accept_compose=False, *, expected_fingerprint=None, request_id=None):
with self.lock():
previous = read_json(self.home / "status.json", {})
if previous.get("recoveryRequired"):
@@ -602,9 +611,13 @@ class Updater:
self.job.mkdir(mode=0o700)
self.state = {"status": "running", "phase": "preparing", "job": str(self.job), "command": command,
"startedAt": now(), "recoveryRequired": False, "mutationStarted": False}
if request_id:
self.state["requestId"] = request_id
self.save()
try:
plan = self.prepare(version)
plan = self.prepare(version, allow_current=command == "check")
if expected_fingerprint is not None and plan["approvalFingerprint"] != expected_fingerprint:
raise PlanChangedError("Release oder Konfiguration haben sich seit der Prüfung geändert; erneut prüfen.")
if command == "check":
self.save("checked", status="checked")
else: