feat(selfhost): add persistent host update service
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user