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

@@ -0,0 +1,33 @@
[Unit]
Description=FEDEO host update service
Wants=network-online.target
After=network-online.target docker.service
Requires=docker.service
[Service]
Type=simple
User=root
Group=fedeo-updater
Environment=HOME=/root
Environment=PYTHONDONTWRITEBYTECODE=1
EnvironmentFile=-/etc/fedeo/updater.env
ExecStart=/usr/bin/python3 -B /usr/local/lib/fedeo-updater/selfhost-update-service.py serve --directory @FEDEO_DIRECTORY@
Restart=on-failure
RestartSec=5s
KillMode=mixed
TimeoutStopSec=infinity
UMask=0077
RuntimeDirectory=fedeo-updater
RuntimeDirectoryMode=0750
RuntimeDirectoryPreserve=yes
NoNewPrivileges=true
ProtectSystem=full
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -33,6 +33,9 @@ export function releaseFiles(manifest, notes) {
['selfhost-install.sh', Buffer.from(readFileSync('scripts/selfhost-install.sh', 'utf8').replace('FEDEO_REF:-dev', `FEDEO_REF:-${manifest.tag}`))],
['selfhost-setup.sh', Buffer.from(readFileSync('scripts/selfhost-setup.sh', 'utf8').replaceAll('FEDEO_VERSION:-dev', `FEDEO_VERSION:-${manifest.version}`))],
['selfhost-update.py', Buffer.from(readFileSync('scripts/selfhost-update.py', 'utf8'))],
['selfhost-update-service.py', Buffer.from(readFileSync('scripts/selfhost-update-service.py', 'utf8'))],
['selfhost-update-service-install.sh', Buffer.from(readFileSync('scripts/selfhost-update-service-install.sh', 'utf8'))],
['fedeo-updater.service', Buffer.from(readFileSync('scripts/fedeo-updater.service', 'utf8'))],
['SELFHOST-UPDATES.md', Buffer.from(readFileSync('docs/selfhost-updates.md', 'utf8'))],
]);
files.set('SHA256SUMS', Buffer.from([...files].map(([name, data]) => `${createHash('sha256').update(data).digest('hex')} ${name}`).join('\n') + '\n'));

View File

@@ -152,6 +152,10 @@ test('release assets pin compose, environment and installer to one version', ()
assert.doesNotMatch(files.get('docker-compose.selfhost.yml').toString(), /FEDEO_VERSION:-dev/);
assert.match(files.get('.env.example').toString(), /^FEDEO_VERSION=1\.2\.0$/m);
assert.match(files.get('selfhost-install.sh').toString(), /FEDEO_REF:-v1\.2\.0/);
for (const name of ['selfhost-update-service.py', 'selfhost-update-service-install.sh', 'fedeo-updater.service']) {
assert.ok(files.get(name)?.length > 0, `missing service asset: ${name}`);
assert.ok(files.get('SHA256SUMS').toString().includes(` ${name}\n`));
}
assert.equal(files.get('SHA256SUMS').toString().trim().split('\n').length, files.size - 1);
});

View File

@@ -186,6 +186,9 @@ download_selfhost_files() {
download_file "$FEDEO_RAW_BASE_URL/.env.example" "$FEDEO_DIR/.env.example"
download_file "$FEDEO_RAW_BASE_URL/scripts/selfhost-setup.sh" "$FEDEO_DIR/scripts/selfhost-setup.sh"
download_file "$FEDEO_RAW_BASE_URL/scripts/selfhost-update.py" "$FEDEO_DIR/scripts/selfhost-update.py"
download_file "$FEDEO_RAW_BASE_URL/scripts/selfhost-update-service.py" "$FEDEO_DIR/scripts/selfhost-update-service.py"
download_file "$FEDEO_RAW_BASE_URL/scripts/selfhost-update-service-install.sh" "$FEDEO_DIR/scripts/selfhost-update-service-install.sh"
download_file "$FEDEO_RAW_BASE_URL/scripts/fedeo-updater.service" "$FEDEO_DIR/scripts/fedeo-updater.service"
download_file "$FEDEO_RAW_BASE_URL/docs/selfhost-updates.md" "$FEDEO_DIR/SELFHOST-UPDATES.md"
chmod +x "$FEDEO_DIR/scripts/selfhost-setup.sh"
}

View File

@@ -0,0 +1,93 @@
#!/usr/bin/env bash
set -euo pipefail
FEDEO_DIR=/opt/fedeo
PRINT_UNIT=false
SOURCE_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
while [[ $# -gt 0 ]]; do
case "$1" in
--directory) FEDEO_DIR="${2:?Verzeichnis fehlt}"; shift 2 ;;
--print-unit) PRINT_UNIT=true; shift ;;
--help|-h) echo "sudo bash $0 [--directory /opt/fedeo] [--print-unit]"; exit 0 ;;
*) echo "Unbekanntes Argument: $1" >&2; exit 1 ;;
esac
done
render_unit() {
python3 - "$SOURCE_DIR/fedeo-updater.service" "$FEDEO_DIR" <<'PY'
import pathlib, sys
directory = sys.argv[2]
if not directory.startswith('/') or any(c in directory for c in '\n\r\0'):
raise SystemExit('Ein gültiges absolutes Betriebsverzeichnis angeben.')
quoted = '"' + directory.replace('\\', '\\\\').replace('"', '\\"').replace('%', '%%').replace('$', '$$') + '"'
sys.stdout.write(pathlib.Path(sys.argv[1]).read_text().replace('@FEDEO_DIRECTORY@', quoted))
PY
}
if [[ "$PRINT_UNIT" == true ]]; then
render_unit
exit 0
fi
[[ "$(id -u)" -eq 0 ]] || { echo "Als root ausführen." >&2; exit 1; }
[[ -d /run/systemd/system ]] || { echo "Ein Linux-Host mit laufendem systemd wird benötigt." >&2; exit 1; }
FEDEO_DIR="$(realpath -e -- "$FEDEO_DIR")"
[[ -f "$FEDEO_DIR/.env" && -f "$FEDEO_DIR/docker-compose.yml" && ! -e "$FEDEO_DIR/.git" ]] || {
echo "Ein bestehendes Selfhost-Betriebsverzeichnis ohne Git-Checkout angeben." >&2; exit 1;
}
python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else "Python ab 3.10 wird benötigt.")'
for name in selfhost-update.py selfhost-update-service.py fedeo-updater.service; do
[[ -f "$SOURCE_DIR/$name" ]] || { echo "Fehlende Installationsdatei: $name" >&2; exit 1; }
done
python3 - "$SOURCE_DIR" <<'PY'
import importlib.util, inspect, pathlib, sys
source = pathlib.Path(sys.argv[1])
spec = importlib.util.spec_from_file_location('fedeo_update_install_check', source / 'selfhost-update.py')
engine = importlib.util.module_from_spec(spec)
spec.loader.exec_module(engine)
if not {'expected_fingerprint', 'request_id'}.issubset(inspect.signature(engine.Updater.execute).parameters):
raise SystemExit('Updater und Dienst passen nicht zusammen. Alle Dateien aus demselben Stand bereitstellen.')
compile((source / 'selfhost-update-service.py').read_text(), 'selfhost-update-service.py', 'exec')
PY
command -v flock >/dev/null
command -v docker >/dev/null
command -v curl >/dev/null
docker compose version >/dev/null
UNIT_CONTENT="$(render_unit)"
# Drain an active service job before replacing either Python module. No containers are restarted here.
if systemctl is-active --quiet fedeo-updater.service; then
systemctl stop fedeo-updater.service
fi
[[ ! -L "$FEDEO_DIR/.updates" ]] || { echo ".updates darf kein symbolischer Link sein." >&2; exit 1; }
install -d -m 0700 "$FEDEO_DIR/.updates"
exec 9>"$FEDEO_DIR/.updates/lock"
flock -n 9 || { echo "Ein CLI-Update läuft noch. Dienstinstallation später wiederholen." >&2; exit 1; }
getent group fedeo-updater >/dev/null || groupadd --system fedeo-updater
install -d -o root -g root -m 0700 /usr/local/lib/fedeo-updater /etc/fedeo
install -o root -g root -m 0700 "$SOURCE_DIR/selfhost-update.py" /usr/local/lib/fedeo-updater/selfhost-update.py
install -o root -g root -m 0700 "$SOURCE_DIR/selfhost-update-service.py" /usr/local/lib/fedeo-updater/selfhost-update-service.py
if [[ ! -e /etc/fedeo/updater.env ]]; then
(umask 077; printf '%s\n' '# Optional: FEDEO_RELEASE_TOKEN=... (nur Leserechte)' > /etc/fedeo/updater.env)
fi
chmod 0600 /etc/fedeo/updater.env
UNIT_TMP="$(mktemp /etc/systemd/system/fedeo-updater.service.XXXXXX)"
trap 'rm -f -- "$UNIT_TMP"' EXIT
printf '%s\n' "$UNIT_CONTENT" > "$UNIT_TMP"
chmod 0644 "$UNIT_TMP"
mv -- "$UNIT_TMP" /etc/systemd/system/fedeo-updater.service
flock -u 9
exec 9>&-
systemctl daemon-reload
systemctl enable --now fedeo-updater.service
READY=false
for attempt in {1..50}; do
if curl --silent --fail --max-time 1 --unix-socket /run/fedeo-updater/control.sock http://localhost/v1/status >/dev/null; then
READY=true
break
fi
sleep 0.1
done
systemctl --no-pager --full status fedeo-updater.service
[[ "$READY" == true ]] || { echo "Dienstschnittstelle noch nicht erreichbar; journalctl -u fedeo-updater.service prüfen." >&2; exit 1; }
echo "Lokale Schnittstelle: /run/fedeo-updater/control.sock"

View File

@@ -0,0 +1,373 @@
#!/usr/bin/env python3
"""Local HTTP/Unix-socket control service for the FEDEO host updater. No TCP listener."""
import argparse
import contextlib
import fcntl
from http.server import BaseHTTPRequestHandler
import importlib.util
import json
import os
from pathlib import Path
import re
import signal
import socketserver
import stat
import threading
import traceback
spec = importlib.util.spec_from_file_location("fedeo_updater", Path(__file__).with_name("selfhost-update.py"))
engine = importlib.util.module_from_spec(spec)
spec.loader.exec_module(engine)
REQUEST_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{7,79}\Z")
PHASES = {"preparing", "validating", "checked", "pulling", "maintenance", "backed_up", "applying",
"retiring_matrix", "migrating", "starting", "verifying", "complete", "resolved"}
STATUSES = {"idle", "queued", "running", "checked", "up_to_date", "succeeded", "failed", "interrupted", "recovered"}
ERRORS = {
"INVALID_REQUEST": "Ungültige Anfrage.",
"NOT_FOUND": "Auftrag oder Endpunkt nicht gefunden.",
"BUSY": "Eine Prüfung oder ein Update läuft bereits.",
"SHUTTING_DOWN": "Der Dienst wird beendet.",
"REQUEST_CONFLICT": "Diese Auftrags-ID wurde für eine andere Anfrage verwendet.",
"CHECK_REQUIRED": "Zuerst genau diese Zielversion erfolgreich prüfen.",
"COMPOSE_REVIEW_REQUIRED": "Die Compose-Datei muss zuerst auf dem Host geprüft und übernommen werden.",
"RECOVERY_REQUIRED": "Ein vorheriges Update benötigt Wiederherstellung auf dem Host.",
"PLAN_CHANGED": "Release oder Konfiguration haben sich geändert. Erneut prüfen.",
"UPDATE_FAILED": "Auftrag fehlgeschlagen. Geschützte Protokolle auf dem Host prüfen.",
"INTERRUPTED": "Der Auftrag wurde unterbrochen. Status auf dem Host prüfen.",
}
class ApiError(Exception):
def __init__(self, code, status=400):
self.code, self.status = code, status
def identifier(value):
if not isinstance(value, str) or not REQUEST_ID.fullmatch(value):
raise ApiError("INVALID_REQUEST")
return value
def public_state(state):
"""An allowlist, never raw command logs, exceptions, host paths, config or diff contents."""
result = {"status": state.get("status") if state.get("status") in STATUSES else "idle"}
if state.get("phase") in PHASES:
result["phase"] = state["phase"]
for key in ("recoveryRequired", "maintenance", "mutationStarted"):
if key in state:
result[key] = bool(state[key])
for key in ("version", "previousVersion"):
if isinstance(state.get(key), str) and engine.VERSION.fullmatch(state[key]):
result[key] = state[key]
if re.fullmatch(r"[a-f0-9]{40}", str(state.get("revision", ""))):
result["revision"] = state["revision"]
for key in ("startedAt", "updatedAt"):
if isinstance(state.get(key), str) and re.fullmatch(r"[0-9T:+.Z-]{10,40}", state[key]):
result[key] = state[key]
if state.get("requestId") and REQUEST_ID.fullmatch(str(state["requestId"])):
result["requestId"] = state["requestId"]
if state.get("command") in ("check", "apply"):
result["command"] = state["command"]
return result
def public_plan(plan):
result = {k: v for k, v in public_state(plan).items() if k != "status"}
for key in ("updateAvailable", "requiresComposeAdoption"):
result[key] = bool(plan.get(key))
if type(plan.get("estimatedBackupBytes")) is int:
result["estimatedBackupBytes"] = plan["estimatedBackupBytes"]
result["retiredServices"] = sorted(set(plan.get("retiredServices", [])) & engine.LEGACY_MATRIX_SERVICES)
return result
class Controller:
def __init__(self, directory, *, updater_factory=engine.Updater):
self.directory = Path(directory).resolve()
self.factory = updater_factory
updater = self.factory(self.directory)
self.home = updater.home / "service"
self.home.mkdir(mode=0o700, exist_ok=True)
if self.home.is_symlink():
raise engine.UpdateError("Dienstverzeichnis darf kein symbolischer Link sein.")
os.chmod(self.home, 0o700)
self.gate = threading.Lock()
self.worker = None
self.closing = False
self.active = None
self.daemon_lock = (self.home / "daemon.lock").open("a")
try:
fcntl.flock(self.daemon_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
self.daemon_lock.close()
raise engine.UpdateError("Für diese Instanz läuft bereits ein Update-Dienst.") from None
# A persisted request is an audit record, not a queue that silently replays updates.
try:
for path in self.home.glob("*.json"):
record = engine.read_json(path)
if record.get("status") in ("queued", "running"):
record.update(status="interrupted", errorCode="INTERRUPTED", updatedAt=engine.now())
engine.write_json(path, record)
updater.status() # Detect an abandoned engine job without touching an active CLI job.
except BaseException:
self.daemon_lock.close()
raise
def path(self, request_id):
return self.home / (identifier(request_id) + ".json")
def read(self, request_id):
record = engine.read_json(self.path(request_id))
if not record:
raise ApiError("NOT_FOUND", 404)
return record
def request_status(self, request_id):
record = self.read(request_id)
result = public_state(record)
if record.get("plan"):
result["plan"] = public_plan(record["plan"])
code = record.get("errorCode")
if code in ERRORS:
result["error"] = {"code": code, "message": ERRORS[code]}
if record["status"] == "running":
state = self.factory(self.directory).status()
if state.get("requestId") == request_id:
result["progress"] = public_state(state)
return result
def status(self):
updater = self.factory(self.directory)
result = {"apiVersion": 1, "engine": public_state(updater.status()), "acceptingRequests": not self.closing}
installed = engine.read_json(updater.home / "installed.json", {}).get("manifest", {})
result["installed"] = {k: v for k, v in public_state({"version": installed.get("version"),
"revision": installed.get("commit")}).items() if k != "status"}
if self.active:
result["request"] = self.request_status(self.active)
return result
def submit(self, command, body):
if not isinstance(body, dict) or command not in ("check", "apply"):
raise ApiError("INVALID_REQUEST")
allowed = {"requestId", "version"} | ({"checkRequestId"} if command == "apply" else set())
if set(body) - allowed:
raise ApiError("INVALID_REQUEST")
request_id = identifier(body.get("requestId"))
version = body.get("version")
if version is not None and (not isinstance(version, str) or not engine.VERSION.fullmatch(version)):
raise ApiError("INVALID_REQUEST")
if command == "apply" and not version:
raise ApiError("INVALID_REQUEST")
check_id = identifier(body.get("checkRequestId")) if command == "apply" else None
payload = {"command": command, "version": version, "checkRequestId": check_id}
with self.gate:
previous = engine.read_json(self.path(request_id))
if previous:
if previous["input"] != payload:
raise ApiError("REQUEST_CONFLICT", 409)
return 200, self.request_status(request_id)
if self.closing:
raise ApiError("SHUTTING_DOWN", 503)
if self.worker and self.worker.is_alive():
raise ApiError("BUSY", 409)
state = self.factory(self.directory).status()
if state.get("recoveryRequired"):
raise ApiError("RECOVERY_REQUIRED", 409)
if state.get("status") == "running":
raise ApiError("BUSY", 409)
fingerprint = None
if command == "apply":
try:
check = self.read(check_id)
except ApiError:
raise ApiError("CHECK_REQUIRED", 409) from None
plan = check.get("plan", {})
if (check.get("command") != "check" or check.get("status") != "checked"
or plan.get("version") != version or not plan.get("updateAvailable")
or not re.fullmatch(r"[a-f0-9]{64}", str(plan.get("approvalFingerprint", "")))):
raise ApiError("CHECK_REQUIRED", 409)
if plan.get("requiresComposeAdoption"):
raise ApiError("COMPOSE_REVIEW_REQUIRED", 409)
fingerprint = plan["approvalFingerprint"]
record = {"requestId": request_id, "command": command, "input": payload,
"status": "queued", "startedAt": engine.now(), "updatedAt": engine.now()}
engine.write_json(self.path(request_id), record)
self.active = request_id
self.worker = threading.Thread(target=self.run, args=(record, fingerprint), daemon=False)
try:
self.worker.start()
except BaseException:
record.update(status="failed", errorCode="UPDATE_FAILED", updatedAt=engine.now())
engine.write_json(self.path(request_id), record)
raise
return 202, public_state(record)
def run(self, record, fingerprint):
request_id = record["requestId"]
try:
record.update(status="running", updatedAt=engine.now())
engine.write_json(self.path(request_id), record)
updater = self.factory(self.directory)
result = updater.execute(record["command"], record["input"]["version"],
expected_fingerprint=fingerprint, request_id=request_id)
record.update(status=result["status"], plan=result["plan"])
if record["command"] == "check" and not result["plan"]["updateAvailable"]:
record["status"] = "up_to_date"
except BaseException as error:
code = "PLAN_CHANGED" if isinstance(error, engine.PlanChangedError) else "UPDATE_FAILED"
record.update(status="failed", errorCode=code)
with (self.home / (request_id + ".log")).open("a") as log:
os.chmod(log.name, 0o600)
traceback.print_exc(file=log)
finally:
record["updatedAt"] = engine.now()
engine.write_json(self.path(request_id), record)
def close(self):
with self.gate:
self.closing = True
worker = self.worker
if worker:
worker.join() # Normal service stop drains the active update instead of interrupting a migration.
self.daemon_lock.close()
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.0"
server_version = "FEDEO-Updater/1"
sys_version = ""
def setup(self):
super().setup()
self.connection.settimeout(5)
def log_message(self, *_):
pass
def send_json(self, status, body):
data = json.dumps(body, ensure_ascii=False).encode()
self.close_connection = True
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(data)
def dispatch(self):
try:
if self.headers.get("Origin") or self.headers.get("Transfer-Encoding"):
raise ApiError("INVALID_REQUEST")
controller = self.server.controller
if self.command == "GET" and self.path == "/v1/status":
self.send_json(200, controller.status())
elif self.command == "GET" and self.path.startswith("/v1/requests/"):
self.send_json(200, controller.request_status(self.path.removeprefix("/v1/requests/")))
elif self.command == "POST" and self.path in ("/v1/check", "/v1/apply"):
lengths = self.headers.get_all("Content-Length", [])
if (len(lengths) != 1 or not lengths[0].isdigit() or not 1 <= int(lengths[0]) <= 4096
or self.headers.get_content_type() != "application/json"):
raise ApiError("INVALID_REQUEST")
body = json.loads(self.rfile.read(int(lengths[0])))
status, result = controller.submit(self.path.rsplit("/", 1)[-1], body)
self.send_json(status, result)
else:
raise ApiError("NOT_FOUND", 404)
except ApiError as error:
self.send_json(error.status, {"error": {"code": error.code, "message": ERRORS[error.code]}})
except (ValueError, UnicodeError):
self.send_json(400, {"error": {"code": "INVALID_REQUEST", "message": ERRORS["INVALID_REQUEST"]}})
except (OSError, engine.UpdateError, KeyError, TypeError):
with contextlib.suppress(OSError):
self.send_json(500, {"error": {"code": "UPDATE_FAILED", "message": ERRORS["UPDATE_FAILED"]}})
do_GET = dispatch
do_POST = dispatch
class Server(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
daemon_threads = True
request_queue_size = 8
def __init__(self, path, controller):
self.controller = controller
self.slots = threading.BoundedSemaphore(16)
super().__init__(str(path), Handler)
os.chmod(path, 0o660)
def process_request(self, request, client_address):
if not self.slots.acquire(blocking=False):
self.shutdown_request(request)
return
try:
super().process_request(request, client_address)
except BaseException:
self.slots.release()
raise
def process_request_thread(self, request, client_address):
try:
super().process_request_thread(request, client_address)
finally:
self.slots.release()
def handle_error(self, *_):
pass # Do not send request data or tracebacks to the journal.
def serve(directory, socket_path):
controller = None
path = Path(socket_path)
stop = threading.Event()
server = None
socket_lock = None
try:
path.parent.mkdir(mode=0o750, parents=True, exist_ok=True)
parent = path.parent.stat()
if path.parent.is_symlink() or parent.st_uid != 0 or parent.st_mode & 0o022:
raise engine.UpdateError("Socket-Verzeichnis muss root gehören und darf nicht durch andere beschreibbar sein.")
socket_lock = path.with_name(path.name + ".lock").open("a")
try:
fcntl.flock(socket_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
raise engine.UpdateError("An diesem Socket läuft bereits ein Update-Dienst.") from None
controller = Controller(directory)
if path.is_symlink() or (path.exists() and not stat.S_ISSOCK(path.stat().st_mode)):
raise engine.UpdateError("Socket-Pfad ist bereits anderweitig belegt.")
# Both the instance and endpoint are locked before replacing a stale socket.
path.unlink(missing_ok=True)
server = Server(path, controller)
for sig in (signal.SIGTERM, signal.SIGINT):
signal.signal(sig, lambda *_: stop.set())
server.timeout = 0.5
while not stop.is_set():
server.handle_request()
finally:
if controller:
controller.close()
if server:
server.server_close()
path.unlink(missing_ok=True)
if socket_lock:
socket_lock.close()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("command", choices=("serve",))
parser.add_argument("--directory", default="/opt/fedeo")
parser.add_argument("--socket", default="/run/fedeo-updater/control.sock")
args = parser.parse_args()
if os.geteuid() != 0:
parser.error("Der Host-Dienst muss als root laufen.")
os.umask(0o077)
try:
serve(args.directory, args.socket)
except (OSError, engine.UpdateError) as error:
parser.exit(1, f"Update-Dienst konnte nicht gestartet werden: {error}\n")
if __name__ == "__main__":
main()

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:

View File

@@ -0,0 +1,268 @@
import concurrent.futures
import http.client
import importlib.util
import json
from pathlib import Path
import socket
import subprocess
import tempfile
import threading
import unittest
ROOT = Path(__file__).resolve().parents[3]
spec = importlib.util.spec_from_file_location('update_service', ROOT / 'scripts/selfhost-update-service.py')
s = importlib.util.module_from_spec(spec)
spec.loader.exec_module(s)
class Factory:
def __init__(self):
self.calls = []
self.started = threading.Event()
self.release = threading.Event()
self.release.set()
self.failure = None
self.adoption = False
self.available = True
self.fingerprint = 'a' * 64
def __call__(self, directory):
factory = self
class FakeUpdater(s.engine.Updater):
def execute(self, command, version, *, expected_fingerprint, request_id):
with self.lock():
factory.calls.append((command, version, expected_fingerprint, request_id))
self.job = self.home / ('job-' + request_id)
self.job.mkdir()
self.state = {'requestId': request_id, 'status': 'running', 'phase': 'validating',
'job': str(self.job), 'recoveryRequired': False}
self.save()
factory.started.set()
if not factory.release.wait(10):
raise AssertionError('test worker timed out')
if factory.failure:
self.save(status='failed')
raise factory.failure
if expected_fingerprint and expected_fingerprint != factory.fingerprint:
self.save(status='failed')
raise s.engine.PlanChangedError('private changed configuration')
plan = {'version': version or '3.2.0', 'revision': 'b' * 40,
'updateAvailable': factory.available, 'requiresComposeAdoption': factory.adoption,
'estimatedBackupBytes': 1000, 'approvalFingerprint': factory.fingerprint,
'composeDiff': '/private/path/secret.diff', 'retiredServices': ['matrix-db']}
self.save(status='checked' if command == 'check' else 'succeeded', phase='complete')
return {**self.state, 'plan': plan}
return FakeUpdater(directory)
class UnixConnection(http.client.HTTPConnection):
def __init__(self, path):
super().__init__('localhost', timeout=3)
self.path = str(path)
def connect(self):
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.sock.settimeout(self.timeout)
self.sock.connect(self.path)
class ServiceTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
self.factory = Factory()
self.controller = s.Controller(self.root, updater_factory=self.factory)
self.socket = self.root / 'control.sock'
self.server = s.Server(self.socket, self.controller)
self.thread = threading.Thread(target=self.server.serve_forever, kwargs={'poll_interval': 0.01}, daemon=True)
self.thread.start()
def tearDown(self):
self.factory.release.set()
self.server.shutdown()
self.server.server_close()
self.thread.join(3)
self.controller.close()
self.temp.cleanup()
def request(self, method, path, body=None, headers=None):
connection = UnixConnection(self.socket)
connection.request(method, path, None if body is None else json.dumps(body),
headers or ({'Content-Type': 'application/json'} if body is not None else {}))
response = connection.getresponse()
result = response.status, json.loads(response.read())
self.assertEqual(response.getheader('Cache-Control'), 'no-store')
connection.close()
return result
def finish(self):
self.controller.worker.join(3)
self.assertFalse(self.controller.worker.is_alive())
def check(self, request_id='check-request-01'):
status, _ = self.request('POST', '/v1/check', {'requestId': request_id, 'version': '3.2.0'})
self.assertEqual(status, 202)
self.finish()
return request_id
def test_async_job_survives_client_disconnect_and_status_stays_responsive(self):
self.factory.release.clear()
connection = UnixConnection(self.socket)
connection.request('POST', '/v1/check', json.dumps({'requestId': 'check-disconnected'}),
{'Content-Type': 'application/json'})
connection.close()
self.assertTrue(self.factory.started.wait(3))
status, result = self.request('GET', '/v1/status')
self.assertEqual(status, 200)
self.assertEqual(result['request']['status'], 'running')
self.assertEqual(result['request']['progress']['phase'], 'validating')
self.factory.release.set()
self.finish()
self.assertEqual(self.request('GET', '/v1/requests/check-disconnected')[1]['status'], 'checked')
def test_duplicate_request_is_idempotent_even_after_completion_and_restart(self):
request_id = self.check()
status, result = self.request('POST', '/v1/check', {'requestId': request_id, 'version': '3.2.0'})
self.assertEqual(status, 200)
self.assertEqual(result['status'], 'checked')
self.controller.close()
self.controller = s.Controller(self.root, updater_factory=self.factory)
self.server.controller = self.controller
status, _ = self.request('POST', '/v1/check', {'requestId': request_id, 'version': '3.2.0'})
self.assertEqual(status, 200)
self.assertEqual(len(self.factory.calls), 1)
status, result = self.request('POST', '/v1/check', {'requestId': request_id, 'version': '3.3.0'})
self.assertEqual(status, 409)
self.assertEqual(result['error']['code'], 'REQUEST_CONFLICT')
def test_concurrent_submissions_start_exactly_one_worker(self):
self.factory.release.clear()
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
responses = list(pool.map(lambda i: self.request('POST', '/v1/check', {'requestId': f'parallel-{i}'}), range(2)))
self.assertEqual(sorted(r[0] for r in responses), [202, 409])
self.assertTrue(self.factory.started.wait(3))
self.assertEqual(len(self.factory.calls), 1)
def test_apply_requires_reviewed_version_and_passes_private_fingerprint(self):
body = {'requestId': 'apply-request-01', 'version': '3.2.0', 'checkRequestId': 'check-request-01'}
self.assertEqual(self.request('POST', '/v1/apply', body)[1]['error']['code'], 'CHECK_REQUIRED')
self.check()
self.assertEqual(self.request('POST', '/v1/apply', {**body, 'version': '3.3.0'})[0], 409)
self.assertEqual(self.request('POST', '/v1/apply', body)[0], 202)
self.finish()
self.assertEqual(self.factory.calls[-1], ('apply', '3.2.0', 'a' * 64, 'apply-request-01'))
self.assertEqual(self.request('GET', '/v1/requests/apply-request-01')[1]['status'], 'succeeded')
def test_unmanaged_compose_and_changed_plan_cannot_be_applied_remotely(self):
self.factory.adoption = True
self.check()
body = {'requestId': 'apply-request-01', 'version': '3.2.0', 'checkRequestId': 'check-request-01'}
self.assertEqual(self.request('POST', '/v1/apply', body)[1]['error']['code'], 'COMPOSE_REVIEW_REQUIRED')
self.factory.adoption = False
self.check('check-request-02')
self.factory.fingerprint = 'b' * 64
self.assertEqual(self.request('POST', '/v1/apply', {**body, 'checkRequestId': 'check-request-02'})[0], 202)
self.finish()
self.assertEqual(self.request('GET', '/v1/requests/apply-request-01')[1]['error']['code'], 'PLAN_CHANGED')
def test_already_current_is_a_successful_check_without_apply(self):
self.factory.available = False
self.check()
self.assertEqual(self.request('GET', '/v1/requests/check-request-01')[1]['status'], 'up_to_date')
body = {'requestId': 'apply-request-01', 'version': '3.2.0', 'checkRequestId': 'check-request-01'}
self.assertEqual(self.request('POST', '/v1/apply', body)[0], 409)
def test_restart_marks_pending_requests_interrupted_without_replaying(self):
for status in ('queued', 'running'):
s.engine.write_json(self.controller.path('abandoned-' + status),
{'requestId': 'abandoned-' + status, 'command': 'apply', 'status': status})
self.controller.close()
self.controller = s.Controller(self.root, updater_factory=self.factory)
self.server.controller = self.controller
for status in ('queued', 'running'):
record = self.request('GET', '/v1/requests/abandoned-' + status)[1]
self.assertEqual(record['status'], 'interrupted')
self.assertEqual(self.factory.calls, [])
def test_shutdown_drains_worker_and_rejects_new_work(self):
self.factory.release.clear()
self.assertEqual(self.request('POST', '/v1/check', {'requestId': 'check-request-01'})[0], 202)
self.assertTrue(self.factory.started.wait(3))
closing = threading.Thread(target=self.controller.close)
closing.start()
# Acquire the gate after close has entered it, without relying on a wall-clock sleep.
for _ in range(1000):
with self.controller.gate:
if self.controller.closing:
break
threading.Event().wait(0.001)
self.assertTrue(closing.is_alive())
self.assertEqual(self.request('POST', '/v1/check', {'requestId': 'check-request-02'})[1]['error']['code'], 'SHUTTING_DOWN')
self.factory.release.set()
closing.join(3)
self.assertFalse(closing.is_alive())
self.assertEqual(self.controller.request_status('check-request-01')['status'], 'checked')
def test_cli_lock_and_recovery_block_new_requests(self):
updater = self.factory(self.root)
job = updater.home / 'cli-job'
job.mkdir()
s.engine.write_json(updater.home / 'status.json', {'status': 'running', 'job': str(job)})
with updater.lock():
self.assertEqual(self.request('POST', '/v1/check', {'requestId': 'check-request-01'})[1]['error']['code'], 'BUSY')
s.engine.write_json(updater.home / 'status.json', {'status': 'failed', 'recoveryRequired': True})
self.assertEqual(self.request('POST', '/v1/check', {'requestId': 'check-request-01'})[1]['error']['code'], 'RECOVERY_REQUIRED')
def test_status_never_returns_raw_errors_paths_logs_or_configuration_fingerprints(self):
self.factory.failure = ValueError('password-secret /opt/private/.env')
self.check()
result = self.request('GET', '/v1/requests/check-request-01')[1]
self.assertEqual(result['error']['code'], 'UPDATE_FAILED')
self.assertNotIn('password-secret', json.dumps(result))
self.factory.failure = None
self.check('check-request-02')
payload = json.dumps(self.request('GET', '/v1/requests/check-request-02')[1])
self.assertNotIn('/private', payload)
self.assertNotIn('approvalFingerprint', payload)
self.assertNotIn('a' * 64, payload)
self.assertEqual(self.request('GET', '/v1/requests/../../secret')[0], 400)
self.assertEqual(self.request('GET', '/v1/logs')[0], 404)
self.assertEqual((self.controller.home / 'check-request-01.log').stat().st_mode & 0o777, 0o600)
def test_api_rejects_unsupported_commands_options_bodies_and_browser_requests(self):
for body in [[], {'requestId': 'bad/path'}, {'requestId': 'check-request-01', 'version': ';reboot'},
{'requestId': 'check-request-01', 'directory': '/etc'},
{'requestId': 'check-request-01', 'acceptComposeReplacement': True}]:
with self.subTest(body=body):
self.assertEqual(self.request('POST', '/v1/check', body)[0], 400)
self.assertEqual(self.request('POST', '/v1/resolve', {'requestId': 'resolve-request'})[0], 404)
self.assertEqual(self.request('POST', '/v1/check', {'requestId': 'check-request-01'},
{'Content-Type': 'application/json', 'Origin': 'https://example.test'})[0], 400)
self.assertEqual(self.request('POST', '/v1/check', {'requestId': 'check-request-01', 'padding': 'x' * 5000})[0], 400)
self.assertEqual(self.factory.calls, [])
def test_second_controller_cannot_take_over_active_instance(self):
self.assertEqual(self.socket.stat().st_mode & 0o777, 0o660)
with self.assertRaisesRegex(s.engine.UpdateError, 'bereits ein Update-Dienst'):
s.Controller(self.root, updater_factory=self.factory)
class InstallerTests(unittest.TestCase):
def test_unit_render_is_read_only_and_escapes_systemd_expansions(self):
installer = ROOT / 'scripts/selfhost-update-service-install.sh'
subprocess.run(['bash', '-n', str(installer)], check=True)
result = subprocess.run(['bash', str(installer), '--print-unit', '--directory', '/opt/fedeo test%$"'],
text=True, capture_output=True, check=True)
self.assertIn('serve --directory "/opt/fedeo test%%$$\\\""', result.stdout)
self.assertIn('KillMode=mixed', result.stdout)
self.assertIn('TimeoutStopSec=infinity', result.stdout)
self.assertNotIn('@FEDEO_DIRECTORY@', result.stdout)
invalid = subprocess.run(['bash', str(installer), '--print-unit', '--directory', '/opt/fedeo\nExecStart=reboot'],
capture_output=True)
self.assertNotEqual(invalid.returncode, 0)
if __name__ == '__main__':
unittest.main()

View File

@@ -567,6 +567,37 @@ class UpdateTests(unittest.TestCase):
self.run_update()
self.assertFalse(self.called('stop'))
def test_check_current_release_reports_no_available_update(self):
(self.root / '.env').write_bytes(u.set_version(self.original_env, '3.1.0'))
result = self.updater.execute('check', '3.1.0')
self.assertEqual(result['status'], 'checked')
self.assertFalse(result['plan']['updateAvailable'])
self.assertFalse(self.called('pull'))
with self.assertRaisesRegex(u.UpdateError, 'erneute Installation'):
self.run_update()
def test_reviewed_plan_can_be_applied_but_configuration_changes_require_recheck(self):
result = self.updater.execute('check', '3.1.0', request_id='check-request')
self.assertEqual(self.updater.status()['requestId'], 'check-request')
fingerprint = result['plan']['approvalFingerprint']
(self.root / '.env').write_bytes(self.original_env + b'\nCUSTOM_SETTING=changed\n')
with self.assertRaises(u.PlanChangedError):
self.run_update(expected_fingerprint=fingerprint)
self.assertFalse(self.called('pull'))
self.assertFalse(self.called('stop'))
result = self.updater.execute('check', '3.1.0')
self.assertEqual(self.run_update(expected_fingerprint=result['plan']['approvalFingerprint'])['status'], 'succeeded')
def test_changed_release_digests_cannot_reuse_an_approved_plan(self):
result = self.updater.execute('check', '3.1.0')
self.http.manifest['images'][0]['digest'] = 'sha256:' + 'c' * 64
self.http.files['release-manifest.json'] = data(self.http.manifest)
self.http.refresh()
with self.assertRaises(u.PlanChangedError):
self.run_update(expected_fingerprint=result['plan']['approvalFingerprint'])
self.assert_untouched()
self.assertFalse(self.called('pull'))
def test_backup_integrity_manifest_is_complete(self):
result = self.run_update()
backup = Path(result['backup'])