Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Updates aus dem Webinterface. Auf Klick, mit Fortschritt und automatischem Rollback; ein eigener, optionaler Container erledigt die Arbeit. Ende-zu-Ende mit echten Images getestet, siehe Handbuch „Update aus dem Webinterface (optional)“: - **Updates aus dem Webinterface (optional):** Einstellungen › „OPNexus aktualisieren“ zeigt die neueste Version samt Release-Notes und aktualisiert auf Klick — mit Fortschrittsanzeige, automatischem Rollback bei einem fehlgeschlagenen Update und „Auf Vorversion zurücksetzen“ (mit Hinweis, ob dabei Daten verloren gehen). Dahinter arbeitet ein eigener, kleiner **Updater-Container** (`updater/`, Compose-Profil `updater`, mit `COMPOSE_PROFILES=updater` in `.env` aktivieren), der dieselbe Logik wie `opnexus update` ausführt. Er ist standardmäßig **aus** und der einzige Container mit Docker-Socket; das Backend bekommt ihn nie. Der Updater hat keinen veröffentlichten Port, ist nur mit einem selbst erzeugten Token ansprechbar und kann ausschließlich „aktualisieren auf die vom Release-Feed gemeldete neueste stabile Version“, „zurückrollen“ und „Status“. Hauptversions-Sprünge bleiben der Kommandozeile vorbehalten. Anfragen stehen im Audit-Log (neuer Typ „OPNexus-System“). Gilt nur für Release-Images. - `opnexus` (jetzt in `updater/opnexus`, `scripts/opnexus` ist ein Symlink): `status --porcelain`, Exit-Code 3 für „fehlgeschlagen und sauber zurückgerollt“. Release-Images und -Bundle enthalten zusätzlich `opnexus-updater`. - Der Banner „Neue Version verfügbar“ verlinkt auf den neuen Abschnitt in den Einstellungen.
216 lines
9.7 KiB
Python
216 lines
9.7 KiB
Python
"""Tests für updater/server.py — nur Standardbibliothek:
|
|
|
|
python3 -m unittest discover -s updater -p 'test_*.py'
|
|
|
|
Das echte `opnexus`-Skript und Docker werden durch ein Fake-Skript ersetzt, das
|
|
`status --porcelain` beantwortet und `update`/`rollback` mit einem vorgegebenen
|
|
Exit-Code beendet; so sind Zustandsübergänge, Ablehnungen und die Authentifizierung
|
|
prüfbar, ohne einen Docker-Daemon.
|
|
"""
|
|
import json
|
|
import os
|
|
import stat
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import unittest
|
|
import urllib.error
|
|
import urllib.request
|
|
from http.server import ThreadingHTTPServer
|
|
from pathlib import Path
|
|
|
|
import server
|
|
|
|
FAKE = """#!/bin/sh
|
|
case "$1" in
|
|
status)
|
|
echo "running_version=${FAKE_RUNNING:-1.14.0}"
|
|
echo "db_revision=0004"
|
|
echo "service.backend=healthy"
|
|
if [ -n "$FAKE_LAST" ]; then
|
|
echo "last_update.prev_version=1.13.0"
|
|
echo "last_update.prev_setting=1.13.0"
|
|
echo "last_update.prev_db_revision=${FAKE_PREV_REV:-0004}"
|
|
fi
|
|
;;
|
|
update|rollback)
|
|
[ -z "$OPNEXUS_PHASE_FILE" ] || echo pulling >> "$OPNEXUS_PHASE_FILE"
|
|
echo "arbeite $1 $2"
|
|
sleep "${FAKE_SLEEP:-0.2}"
|
|
exit "${FAKE_EXIT:-0}"
|
|
;;
|
|
esac
|
|
"""
|
|
|
|
|
|
class UpdaterTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
root = Path(self.tmp.name)
|
|
(root / "state").mkdir()
|
|
(root / "project").mkdir()
|
|
(root / "project" / ".env").write_text("OPNEXUS_IMAGE_REPO=reg.test/opnexus\nOPNEXUS_VERSION=1.14.0\n")
|
|
script = root / "opnexus"
|
|
script.write_text(FAKE)
|
|
script.chmod(script.stat().st_mode | stat.S_IEXEC)
|
|
state = root / "state"
|
|
server.STATE_DIR = state
|
|
server.TOKEN_FILE = state / "token"
|
|
server.JOB_FILE = state / "job.json"
|
|
server.LOG_FILE = state / "job.log"
|
|
server.PHASE_FILE = state / "job.phases"
|
|
server.PROJECT_DIR = root / "project"
|
|
server.SCRIPT = str(script)
|
|
server._status_cache.update(at=0.0, value=None)
|
|
for key in ("FAKE_EXIT", "FAKE_RUNNING", "FAKE_LAST", "FAKE_PREV_REV", "FAKE_SLEEP"):
|
|
os.environ.pop(key, None)
|
|
self.token = server.load_token()
|
|
server.Handler.token = self.token
|
|
self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), server.Handler)
|
|
self.httpd.daemon_threads = True
|
|
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
|
|
self.base = f"http://127.0.0.1:{self.httpd.server_address[1]}"
|
|
|
|
def tearDown(self):
|
|
self.httpd.shutdown()
|
|
self.httpd.server_close()
|
|
deadline = time.time() + 5
|
|
while server._lock.locked() and time.time() < deadline:
|
|
time.sleep(0.05)
|
|
self.tmp.cleanup()
|
|
|
|
def call(self, method, path, body=None, token="__default__"):
|
|
token = self.token if token == "__default__" else token
|
|
headers = {"Content-Type": "application/json"}
|
|
if token is not None:
|
|
headers["Authorization"] = "Bearer " + token
|
|
req = urllib.request.Request(self.base + path, method=method, headers=headers,
|
|
data=json.dumps(body).encode() if body is not None else None)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
return resp.status, json.loads(resp.read())
|
|
except urllib.error.HTTPError as err:
|
|
return err.code, json.loads(err.read())
|
|
|
|
def wait_done(self):
|
|
deadline = time.time() + 10
|
|
while time.time() < deadline:
|
|
_, body = self.call("GET", "/status")
|
|
if body["job"] and body["job"]["state"] != "running":
|
|
return body
|
|
time.sleep(0.1)
|
|
self.fail("job did not finish")
|
|
|
|
# --- Authentifizierung -------------------------------------------------
|
|
def test_health_is_public_everything_else_needs_the_token(self):
|
|
self.assertEqual(self.call("GET", "/health", token=None)[0], 200)
|
|
for token in (None, "", "wrong", self.token[:-1]):
|
|
self.assertEqual(self.call("GET", "/status", token=token)[0], 401, token)
|
|
self.assertEqual(self.call("POST", "/update", {"version": "1.14.1"}, token=token)[0], 401)
|
|
self.assertEqual(self.call("POST", "/rollback", {}, token=token)[0], 401)
|
|
|
|
def test_token_is_generated_once_and_reused(self):
|
|
self.assertGreaterEqual(len(self.token), 32)
|
|
self.assertEqual(server.load_token(), self.token)
|
|
|
|
def test_unknown_paths_are_404(self):
|
|
self.assertEqual(self.call("GET", "/etc/passwd")[0], 404)
|
|
self.assertEqual(self.call("POST", "/exec", {"cmd": "id"})[0], 404)
|
|
|
|
# --- Status ------------------------------------------------------------
|
|
def test_status_reports_version_and_mode(self):
|
|
status, body = self.call("GET", "/status")
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(body["mode"], "release")
|
|
self.assertEqual(body["running_version"], "1.14.0")
|
|
self.assertFalse(body["rollback_available"])
|
|
|
|
def test_local_build_install_is_reported_as_local(self):
|
|
(server.PROJECT_DIR / ".env").write_text("POSTGRES_PASSWORD=x\n")
|
|
_, body = self.call("GET", "/status")
|
|
self.assertEqual(body["mode"], "local")
|
|
self.assertEqual(self.call("POST", "/update", {"version": "1.14.1"})[1], {"error": "not_release_mode"})
|
|
|
|
# --- Update ------------------------------------------------------------
|
|
def test_update_runs_and_succeeds(self):
|
|
status, body = self.call("POST", "/update", {"version": "1.14.1"})
|
|
self.assertEqual(status, 202)
|
|
self.assertEqual(body["job"]["target"], "1.14.1")
|
|
done = self.wait_done()
|
|
self.assertEqual(done["job"]["state"], "succeeded")
|
|
self.assertIn("pulling", done["phases"])
|
|
self.assertTrue(any("arbeite update 1.14.1" in line for line in done["log"]))
|
|
|
|
def test_failed_update_exit_codes(self):
|
|
os.environ["FAKE_EXIT"] = "3"
|
|
self.call("POST", "/update", {"version": "1.14.1"})
|
|
self.assertEqual(self.wait_done()["job"]["state"], "rolled_back")
|
|
server._status_cache.update(at=0.0, value=None)
|
|
os.environ["FAKE_EXIT"] = "1"
|
|
self.call("POST", "/update", {"version": "1.14.1"})
|
|
self.assertEqual(self.wait_done()["job"]["state"], "failed")
|
|
|
|
def test_invalid_and_disallowed_versions_never_start_a_job(self):
|
|
cases = {
|
|
"1.14.0": "not_newer", "1.13.9": "not_newer", "2.0.0": "major_change",
|
|
"latest": "invalid_version", "1.14.1-rc1": "invalid_version", "1.14": "invalid_version",
|
|
"1.14.1; id": "invalid_version", "../1.14.1": "invalid_version", "": "invalid_version",
|
|
}
|
|
for version, error in cases.items():
|
|
status, body = self.call("POST", "/update", {"version": version})
|
|
self.assertEqual(body, {"error": error}, version)
|
|
self.assertIn(status, (400, 409))
|
|
self.assertEqual(self.call("POST", "/update", {})[1], {"error": "invalid_version"})
|
|
self.assertEqual(self.call("POST", "/update", {"version": 5})[1], {"error": "invalid_version"})
|
|
self.assertIsNone(self.call("GET", "/status")[1]["job"])
|
|
|
|
def test_second_request_while_running_is_busy(self):
|
|
os.environ["FAKE_SLEEP"] = "1.5"
|
|
self.assertEqual(self.call("POST", "/update", {"version": "1.14.1"})[0], 202)
|
|
self.assertEqual(self.call("POST", "/update", {"version": "1.14.2"}), (409, {"error": "busy"}))
|
|
self.assertEqual(self.call("POST", "/rollback", {}), (409, {"error": "busy"}))
|
|
_, running = self.call("GET", "/status")
|
|
self.assertEqual(running["job"]["state"], "running")
|
|
self.assertEqual(self.wait_done()["job"]["state"], "succeeded")
|
|
|
|
def test_oversized_or_malformed_body_is_rejected(self):
|
|
req = urllib.request.Request(self.base + "/update", method="POST", data=b"x" * 5000,
|
|
headers={"Authorization": "Bearer " + self.token, "Content-Length": "5000"})
|
|
with self.assertRaises(urllib.error.HTTPError) as ctx:
|
|
urllib.request.urlopen(req, timeout=5)
|
|
self.assertEqual(ctx.exception.code, 413)
|
|
req = urllib.request.Request(self.base + "/update", method="POST", data=b"{not json",
|
|
headers={"Authorization": "Bearer " + self.token})
|
|
with self.assertRaises(urllib.error.HTTPError) as ctx:
|
|
urllib.request.urlopen(req, timeout=5)
|
|
self.assertEqual(ctx.exception.code, 400)
|
|
|
|
# --- Rollback ----------------------------------------------------------
|
|
def test_rollback_needs_a_previous_update(self):
|
|
self.assertEqual(self.call("POST", "/rollback", {}), (409, {"error": "no_rollback"}))
|
|
|
|
def test_rollback_and_data_loss_flag(self):
|
|
os.environ["FAKE_LAST"] = "1"
|
|
_, body = self.call("GET", "/status")
|
|
self.assertTrue(body["rollback_available"])
|
|
self.assertFalse(body["rollback_loses_data"]) # Revision unverändert
|
|
server._status_cache.update(at=0.0, value=None)
|
|
os.environ["FAKE_PREV_REV"] = "0003"
|
|
_, body = self.call("GET", "/status")
|
|
self.assertTrue(body["rollback_loses_data"])
|
|
server._status_cache.update(at=0.0, value=None)
|
|
self.assertEqual(self.call("POST", "/rollback", {})[0], 202)
|
|
self.assertEqual(self.wait_done()["job"]["state"], "succeeded")
|
|
|
|
# --- Neustart ----------------------------------------------------------
|
|
def test_job_left_running_after_a_restart_is_marked_failed(self):
|
|
server.write_job({"id": "x", "action": "update", "target": "1.14.1", "state": "running"})
|
|
server.mark_interrupted()
|
|
job = server.read_job()
|
|
self.assertEqual(job["state"], "failed")
|
|
self.assertIn("neu gestartet", server.LOG_FILE.read_text())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|