fix(stacks): geslaagde wijziging niet meer terugdraaien
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 4m12s

Het audit-logboek las request.remote_addr uit in de achtergrondthread van de
job. De RuntimeError die daarop volgde belandde in de foutafhandeling, die de
zojuist gemaakte backup terugzette — inclusief het oude compose-bestand.

- Adres vóór de threadstart vastleggen bij herconfigureren, koppelen, backup
  maken en terugrollen.
- UniFi: init-script probeert de beheerders 'unifi' en 'root' en herkent een
  server zonder authenticatie, zodat ook een oud Mongo-volume gerepareerd
  wordt; bestaande rollen blijven staan en 'backup'/'restore' worden aangevuld.
- UniFi: healthcheck van Mongo zonder inloggegevens, anders bleef de container
  ongezond en startte de reparatiecontainer nooit.
- Backups: mongodump/mongorestore gebruiken de beheerder uit compose in plaats
  van de verzonnen gebruiker 'app'.
- Appkaart: knop die de webinterface opent, met het adres uit /api/stacks.
- Joblog: geen dubbele regels meer doordat polls elkaar konden overlappen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
This commit is contained in:
Ramon 2026-08-06 16:44:01 +02:00
parent 6f549357b1
commit 3ee09eebc7
15 changed files with 452 additions and 29 deletions

View file

@ -1,3 +1,31 @@
# v0.8.21-beta — Wijzigingen blijven staan, UniFi komt online
Een geslaagde instellingenwijziging werd meteen weer teruggedraaid: het
audit-logboek las het IP-adres van de aanvrager pas uit in de achtergrondthread,
waar geen request meer bestaat. De `RuntimeError` die daarop volgde kwam in de
foutafhandeling terecht, die netjes de backup terugzette — inclusief het oude
compose-bestand. De MongoDB-reparatie van v0.8.20 kon zo nooit landen.
- **Het adres wordt vóór de job vastgelegd.** Herconfigureren, koppelen, een
backup maken en terugrollen loggen weer wie de actie startte, zonder crash.
- **UniFi repareert ook een oud database-volume.** Het init-script probeert
achtereenvolgens de beheerders `unifi` en `root` en herkent een server zonder
authenticatie, in plaats van te veronderstellen dat `root` bestaat.
- **De healthcheck van Mongo gebruikt geen inloggegevens meer.** Op een volume
zonder de gebruiker `root` bleef de container ongezond, waardoor de
reparatiecontainer nooit startte.
- **Bestaande rollen blijven staan.** De reparatie trekt alleen het wachtwoord
gelijk en vult ontbrekende rollen aan — inclusief `backup` en `restore`, die
mongodump en mongorestore nodig hebben.
- **Backups van MongoDB werken.** Het dumpcommando gebruikt de beheerder uit
het compose-bestand in plaats van de verzonnen gebruiker `app`.
- **Elke app met een webinterface krijgt een openknop op de kaart.** Het adres
stond alleen in de modal met inloggegevens, waar je niet kijkt als je de app
gewoon wilt openen.
- **Geen dubbele regels meer in het joblog.** Bij een trage stap vertrokken
meerdere polls met dezelfde offset, waardoor hetzelfde blok tot vijf keer in
beeld kwam.
# v0.8.20-beta — UniFi herstelt zijn MongoDB-gebruiker # v0.8.20-beta — UniFi herstelt zijn MongoDB-gebruiker
Een eerder geïnitialiseerd Mongo-volume voerde het init-script niet opnieuw uit. Een eerder geïnitialiseerd Mongo-volume voerde het init-script niet opnieuw uit.

View file

@ -1 +1 @@
0.8.20-beta 0.8.21-beta

View file

@ -30,7 +30,10 @@ services:
image: mongo:4.4.29 image: mongo:4.4.29
container_name: << service_name >>-mongo container_name: << service_name >>-mongo
environment: environment:
- MONGO_INITDB_ROOT_USERNAME=root # Dezelfde beheerdersnaam als het allereerste sjabloon gebruikte. Zo
# passen een vers volume en een volume van een oudere installatie op
# dezelfde inloggegevens — ook die van de backup (mongodump).
- MONGO_INITDB_ROOT_USERNAME=unifi
- MONGO_INITDB_ROOT_PASSWORD=<< mongo_password >> - MONGO_INITDB_ROOT_PASSWORD=<< mongo_password >>
- MONGO_USER=unifi - MONGO_USER=unifi
- MONGO_PASS=<< mongo_password >> - MONGO_PASS=<< mongo_password >>
@ -40,9 +43,13 @@ services:
- << service_name >>_mongodata:/data/db - << service_name >>_mongodata:/data/db
- ./init-mongo.sh:/docker-entrypoint-initdb.d/init-mongo.sh:ro - ./init-mongo.sh:/docker-entrypoint-initdb.d/init-mongo.sh:ro
healthcheck: healthcheck:
# Bewust zonder inloggegevens: `ping` mag ook zonder aanmelden. Een
# volume dat door een oudere versie van dit sjabloon is aangemaakt kent
# de gebruiker 'root' niet, en dan bleef deze container voor altijd
# ongezond — waardoor de reparatiecontainer hieronder nooit startte.
test: test:
- CMD-SHELL - CMD-SHELL
- 'mongo --quiet --username root --password "$${MONGO_INITDB_ROOT_PASSWORD}" --authenticationDatabase admin --eval "db.adminCommand({ ping: 1 })"' - 'mongo --quiet --eval "db.adminCommand({ ping: 1 })"'
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 12 retries: 12
@ -53,7 +60,7 @@ services:
image: mongo:4.4.29 image: mongo:4.4.29
container_name: << service_name >>-mongo-init container_name: << service_name >>-mongo-init
environment: environment:
- MONGO_INITDB_ROOT_USERNAME=root - MONGO_INITDB_ROOT_USERNAME=unifi
- MONGO_INITDB_ROOT_PASSWORD=<< mongo_password >> - MONGO_INITDB_ROOT_PASSWORD=<< mongo_password >>
- MONGO_USER=unifi - MONGO_USER=unifi
- MONGO_PASS=<< mongo_password >> - MONGO_PASS=<< mongo_password >>

View file

@ -18,32 +18,88 @@ if [[ -n "${MONGO_HOST:-}" ]]; then
fi fi
"${mongo_init_bin}" "${mongo_args[@]}" <<EOF "${mongo_init_bin}" "${mongo_args[@]}" <<EOF
const authDb = db.getSiblingDB("${MONGO_AUTHSOURCE}"); const beheerNaam = "${MONGO_INITDB_ROOT_USERNAME}";
if (!authDb.auth("${MONGO_INITDB_ROOT_USERNAME}", "${MONGO_INITDB_ROOT_PASSWORD}")) { const beheerWachtwoord = "${MONGO_INITDB_ROOT_PASSWORD}";
const appNaam = "${MONGO_USER}";
const appWachtwoord = "${MONGO_PASS}";
const authBron = "${MONGO_AUTHSOURCE}" || "admin";
const appDatabase = "${MONGO_DBNAME}";
const adminDb = db.getSiblingDB("admin");
// Welke beheerder in dit volume zit, hangt af van wie het heeft aangemaakt:
// het eerste sjabloon maakte 'unifi' aan, een later sjabloon 'root'. Daarom
// proberen we ze allemaal in plaats van er één te veronderstellen — anders
// mislukt de reparatie juist op de installaties die haar nodig hebben.
const kandidaten = [beheerNaam, appNaam, "root", "unifi"]
.filter((naam, i, alle) => naam && alle.indexOf(naam) === i);
function aanmelden() {
for (const naam of kandidaten) {
try {
if (adminDb.auth(naam, beheerWachtwoord)) {
return naam;
}
} catch (e) {
// Onbekende gebruiker of verkeerd wachtwoord: volgende kandidaat.
}
}
try {
// Draait deze server nog zonder authenticatie, dan mogen we zo verder.
adminDb.getUsers();
return "(zonder aanmelden)";
} catch (e) {
return "";
}
}
const beheerder = aanmelden();
if (!beheerder) {
print("");
print("FOUT: geen enkele MongoDB-beheerder kon inloggen op dit volume.");
print("Het wachtwoord in .env hoort niet bij de database die er al staat.");
print("Zet het oorspronkelijke wachtwoord terug in .env, of verwijder het");
print("volume om opnieuw te beginnen (dat wist wel je UniFi-gegevens).");
print("");
quit(18); quit(18);
} }
print("MongoDB-beheerder: " + beheerder);
const roles = [ const roles = [
{ db: "admin", role: "clusterMonitor" }, { db: "admin", role: "clusterMonitor" },
{ db: "${MONGO_DBNAME}", role: "dbOwner" }, // Zonder deze twee kan Server Up geen mongodump/mongorestore draaien voor
{ db: "${MONGO_DBNAME}_stat", role: "dbOwner" }, // de backup van deze stack.
{ db: "${MONGO_DBNAME}_audit", role: "dbOwner" }, { db: "admin", role: "backup" },
{ db: "${MONGO_DBNAME}_restore", role: "dbOwner" } { db: "admin", role: "restore" },
{ db: appDatabase, role: "dbOwner" },
{ db: appDatabase + "_stat", role: "dbOwner" },
{ db: appDatabase + "_audit", role: "dbOwner" },
{ db: appDatabase + "_restore", role: "dbOwner" }
]; ];
function ensureUser(database) { function ensureUser(database) {
const userDb = db.getSiblingDB(database); const userDb = db.getSiblingDB(database);
const existing = userDb.getUser("${MONGO_USER}"); const bestaand = userDb.getUser(appNaam);
if (existing) { if (!bestaand) {
userDb.updateUser("${MONGO_USER}", { pwd: "${MONGO_PASS}", roles: roles }); userDb.createUser({ user: appNaam, pwd: appWachtwoord, roles: roles });
} else { print("Gebruiker " + appNaam + " aangemaakt in " + database + ".");
userDb.createUser({ user: "${MONGO_USER}", pwd: "${MONGO_PASS}", roles: roles }); return;
} }
// Alleen het wachtwoord gelijktrekken en ontbrekende rollen aanvullen. De
// rollen overschrijven zou de beheerder van een oudere installatie
// degraderen, waarna onder meer de backup geen dump meer kan maken.
userDb.updateUser(appNaam, { pwd: appWachtwoord });
const missend = roles.filter(
(r) => !(bestaand.roles || []).some((b) => b.role === r.role && b.db === r.db));
if (missend.length) {
userDb.grantRolesToUser(appNaam, missend);
}
print("Gebruiker " + appNaam + " bijgewerkt in " + database + ".");
} }
ensureUser("${MONGO_AUTHSOURCE}"); ensureUser(authBron);
if ("${MONGO_AUTHSOURCE}" !== "${MONGO_DBNAME}") { if (authBron !== appDatabase) {
ensureUser("${MONGO_DBNAME}"); ensureUser(appDatabase);
} }
EOF EOF
<% endraw %> <% endraw %>

View file

@ -449,13 +449,15 @@ def api_update_rollback():
return jsonify(ok=False, msg="Geen vorige versie bekend"), 400 return jsonify(ok=False, msg="Geen vorige versie bekend"), 400
jid, q = jobs.create("update:rollback") jid, q = jobs.create("update:rollback")
# De job draait in een eigen thread; `request` bestaat daar niet meer.
ip = request.remote_addr
def _run(jid): def _run(jid):
qq = jobs.get_queue(jid) qq = jobs.get_queue(jid)
ok, msg = selfupdate.rollback(log_fn=lambda m: jobs.log(qq, "dim", m)) ok, msg = selfupdate.rollback(log_fn=lambda m: jobs.log(qq, "dim", m))
jobs.log(qq, "ok" if ok else "error", msg) jobs.log(qq, "ok" if ok else "error", msg)
audit.log("update", "rollback", "ok" if ok else "error", audit.log("update", "rollback", "ok" if ok else "error",
ref=st.get("previous_tag", ""), ip=request.remote_addr) ref=st.get("previous_tag", ""), ip=ip)
jobs.finish(jid, "done" if ok else "error") jobs.finish(jid, "done" if ok else "error")
jobs.done(qq) jobs.done(qq)
@ -671,6 +673,32 @@ def api_stacks():
return h["logo_url"], h["icon"] return h["logo_url"], h["icon"]
return "", "" return "", ""
# Het browseradres per stack. De bronmetadata wordt per sjabloon onthouden:
# dezelfde app twee keer geïnstalleerd zou anders twee keer de git-cache
# doorzoeken.
host = request.host.split(":")[0]
bron_meta_cache: dict[str, dict] = {}
def _bron_meta(meta: dict) -> dict:
sleutel = f"{meta.get('repo_id', '')}/{meta.get('source', '')}"
if sleutel not in bron_meta_cache:
uit = {}
try:
src = _find_stack_src(meta.get("source", ""), meta.get("repo_id", ""))
if src:
uit = boilerplates.metadata(src)
except Exception:
uit = {}
bron_meta_cache[sleutel] = uit
return bron_meta_cache[sleutel]
def _web_url(d, meta: dict) -> str:
try:
return _stack_webadres(meta, _bron_meta(meta),
docker.read_compose(d), host)
except Exception:
return ""
out = [] out = []
for d in sorted(lib.iterdir()): for d in sorted(lib.iterdir()):
if not d.is_dir() or d.name.startswith("."): if not d.is_dir() or d.name.startswith("."):
@ -693,6 +721,10 @@ def api_stacks():
"containers": ct, "containers": ct,
"logo_url": logo_url, "icon": icon, "logo_url": logo_url, "icon": icon,
"network": meta.get("network", ""), "ip": meta.get("ip", ""), "network": meta.get("network", ""), "ip": meta.get("ip", ""),
# Waar de webinterface van deze app draait; leeg als de app er geen
# heeft. Stond eerder alleen in de modal met inloggegevens, waar je
# hem niet zoekt als je gewoon de app wilt openen.
"web_url": _web_url(d, meta) if heeft_compose else "",
# Bij installatie vastgelegd; ontbreekt dat (oudere installatie), # Bij installatie vastgelegd; ontbreekt dat (oudere installatie),
# dan leiden we het alsnog af uit de opgeslagen naam. # dan leiden we het alsnog af uit de opgeslagen naam.
"connected": bool(meta.get("connected")), "connected": bool(meta.get("connected")),
@ -1132,6 +1164,11 @@ def api_stack_reconfigure(name):
missing=ontbreekt), 400 missing=ontbreekt), 400
jid, q = jobs.create(f"reconfigure:{name}") jid, q = jobs.create(f"reconfigure:{name}")
# De job draait in een eigen thread; `request` bestaat daar niet meer.
# Uitlezen ná de start gaf een RuntimeError precies nadat de stack met de
# nieuwe instellingen al draaide — en de foutafhandeling zette dan de
# backup terug, dus een geslaagde wijziging werd alsnog teruggedraaid.
ip = request.remote_addr
def _run(jid): def _run(jid):
qq = jobs.get_queue(jid) qq = jobs.get_queue(jid)
@ -1220,7 +1257,7 @@ def api_stack_reconfigure(name):
"Herstart met de nieuwe instellingen" if rc == 0 "Herstart met de nieuwe instellingen" if rc == 0
else "Starten mislukt \u2014 zet de backup terug via Backups") else "Starten mislukt \u2014 zet de backup terug via Backups")
audit.log("stacks", "reconfigure", "ok" if rc == 0 else "error", audit.log("stacks", "reconfigure", "ok" if rc == 0 else "error",
ref=name, ip=request.remote_addr) ref=name, ip=ip)
jobs.finish(jid, "done" if rc == 0 else "error") jobs.finish(jid, "done" if rc == 0 else "error")
except Exception as e: except Exception as e:
jobs.log(qq, "error", f"{type(e).__name__}: {e}") jobs.log(qq, "error", f"{type(e).__name__}: {e}")
@ -1251,6 +1288,8 @@ def api_stack_connect(name):
gedeeld_net = (cfg.load().get("SHARED_NETWORK") or "serverup").strip() gedeeld_net = (cfg.load().get("SHARED_NETWORK") or "serverup").strip()
jid, q = jobs.create(f"connect:{name}") jid, q = jobs.create(f"connect:{name}")
# De job draait in een eigen thread; `request` bestaat daar niet meer.
ip = request.remote_addr
def _run(jid): def _run(jid):
qq = jobs.get_queue(jid) qq = jobs.get_queue(jid)
@ -1283,7 +1322,7 @@ def api_stack_connect(name):
jobs.log(qq, "ok", jobs.log(qq, "ok",
f"Gekoppeld aan '{gedeeld_net}' \u2014 andere apps zijn nu " f"Gekoppeld aan '{gedeeld_net}' \u2014 andere apps zijn nu "
"bereikbaar op hun containernaam.") "bereikbaar op hun containernaam.")
audit.log("stacks", "connect", "ok", ref=name, ip=request.remote_addr) audit.log("stacks", "connect", "ok", ref=name, ip=ip)
jobs.finish(jid, "done") jobs.finish(jid, "done")
except Exception as e: except Exception as e:
jobs.log(qq, "error", str(e)) jobs.log(qq, "error", str(e))
@ -2213,6 +2252,8 @@ def api_backups():
@app.route("/api/backups/<stack>/create", methods=["POST"]) @app.route("/api/backups/<stack>/create", methods=["POST"])
def api_backup_create(stack): def api_backup_create(stack):
jid, q = jobs.create(f"backup:{stack}") jid, q = jobs.create(f"backup:{stack}")
# De job draait in een eigen thread; `request` bestaat daar niet meer.
ip = request.remote_addr
def _run(jid): def _run(jid):
qq = jobs.get_queue(jid) qq = jobs.get_queue(jid)
@ -2222,7 +2263,7 @@ def api_backup_create(stack):
log_fn=lambda m: jobs.log(qq, "dim", m)) log_fn=lambda m: jobs.log(qq, "dim", m))
backups.prune(stack, log_fn=lambda m: jobs.log(qq, "dim", m)) backups.prune(stack, log_fn=lambda m: jobs.log(qq, "dim", m))
jobs.log(qq, "ok", f"Backup klaar ({meta['file']})") jobs.log(qq, "ok", f"Backup klaar ({meta['file']})")
audit.log("backups", "create", "ok", ref=stack, ip=request.remote_addr) audit.log("backups", "create", "ok", ref=stack, ip=ip)
jobs.finish(jid, "done") jobs.finish(jid, "done")
except backups.BackupError as e: except backups.BackupError as e:
jobs.log(qq, "error", str(e)) jobs.log(qq, "error", str(e))

View file

@ -159,7 +159,10 @@ _DUMPERS = {
"postgis": ("POSTGRES_USER", "POSTGRES_DB", "POSTGRES_PASSWORD"), "postgis": ("POSTGRES_USER", "POSTGRES_DB", "POSTGRES_PASSWORD"),
"mariadb": ("MARIADB_USER", "MARIADB_DATABASE", "MARIADB_PASSWORD"), "mariadb": ("MARIADB_USER", "MARIADB_DATABASE", "MARIADB_PASSWORD"),
"mysql": ("MYSQL_USER", "MYSQL_DATABASE", "MYSQL_PASSWORD"), "mysql": ("MYSQL_USER", "MYSQL_DATABASE", "MYSQL_PASSWORD"),
"mongo": (None, None, "MONGO_INITDB_ROOT_PASSWORD"), # De beheerdersnaam stond hier op None; dan viel het dump-commando terug op
# de verzonnen gebruiker 'app' en mislukte elke mongodump met
# "Authentication failed".
"mongo": ("MONGO_INITDB_ROOT_USERNAME", None, "MONGO_INITDB_ROOT_PASSWORD"),
} }
@ -235,7 +238,7 @@ def _dump_commando(dienst: dict) -> tuple[list[str], dict]:
return ([hulp, "-u", gb or "root", f"-p{ww}", "--single-transaction", return ([hulp, "-u", gb or "root", f"-p{ww}", "--single-transaction",
db or gb], {}) db or gb], {})
return (["sh", "-c", return (["sh", "-c",
f"mongodump --archive --username='{gb or 'app'}' " f"mongodump --archive --username='{gb or 'root'}' "
f"--password='{ww}' --authenticationDatabase=admin"], {}) f"--password='{ww}' --authenticationDatabase=admin"], {})
@ -798,7 +801,7 @@ def _herstel_commando(dienst: dict) -> list[str]:
hulp = "mariadb" if s == "mariadb" else "mysql" hulp = "mariadb" if s == "mariadb" else "mysql"
return ["sh", "-c", f"{hulp} -u '{gb or 'root'}' -p'{ww}' '{db or gb}'"] return ["sh", "-c", f"{hulp} -u '{gb or 'root'}' -p'{ww}' '{db or gb}'"]
return ["sh", "-c", return ["sh", "-c",
f"mongorestore --archive --drop --username='{gb or 'app'}' " f"mongorestore --archive --drop --username='{gb or 'root'}' "
f"--password='{ww}' --authenticationDatabase=admin"] f"--password='{ww}' --authenticationDatabase=admin"]

View file

@ -395,6 +395,13 @@ tailwind.config = {
</button> </button>
<button class="btn-sm" :title="t('restart')" @click="stackAction(s.name, 'restart')"><span class="mdi mdi-restart"></span></button> <button class="btn-sm" :title="t('restart')" @click="stackAction(s.name, 'restart')"><span class="mdi mdi-restart"></span></button>
<button class="btn-sm" :title="t('logs')" @click="openLogs(s.name)"><span class="mdi mdi-text-box-outline"></span></button> <button class="btn-sm" :title="t('logs')" @click="openLogs(s.name)"><span class="mdi mdi-text-box-outline"></span></button>
<!-- De webinterface openen is waar de meeste apps voor
draaien; die stond alleen in de modal met inloggegevens.
Daarom ook op de telefoon zichtbaar en niet in het menu. -->
<a x-show="s.web_url" :href="s.web_url" target="_blank" rel="noopener"
class="btn-sm" :title="t('open_web') + ': ' + s.web_url">
<span class="mdi mdi-open-in-new"></span>
</a>
<!-- Vanaf sm alles uitgeklapt --> <!-- Vanaf sm alles uitgeklapt -->
<button class="btn-sm hidden sm:inline-flex" :title="t('update')" @click="stackAction(s.name, 'update')"><span class="mdi mdi-update"></span></button> <button class="btn-sm hidden sm:inline-flex" :title="t('update')" @click="stackAction(s.name, 'update')"><span class="mdi mdi-update"></span></button>
@ -2926,8 +2933,18 @@ function app() {
this.job = { id: jid, lines: [], status: 'running', timer: null }; this.job = { id: jid, lines: [], status: 'running', timer: null };
this.termOpen = true; this.termOpen = true;
let off = 0; let off = 0;
// De offset schuift pas op als het antwoord binnen is. Zonder deze vlag
// vertrekken er bij een trage stap meerdere polls met dezelfde offset en
// komt hetzelfde blok regels twee, drie of vijf keer in beeld.
let bezig = false;
this.job.timer = setInterval(async () => { this.job.timer = setInterval(async () => {
const d = await this.rpc(`/api/jobs/${jid}?offset=${off}`); if (bezig) return;
bezig = true;
let d;
try {
d = await this.rpc(`/api/jobs/${jid}?offset=${off}`);
} finally { bezig = false; }
if (!d) return;
(d.lines || []).forEach(l => { this.job.lines.push(l); off++; }); (d.lines || []).forEach(l => { this.job.lines.push(l); off++; });
if (d.status && d.status !== 'running') { if (d.status && d.status !== 'running') {
clearInterval(this.job.timer); this.job.timer = null; clearInterval(this.job.timer); this.job.timer = null;

View file

@ -303,6 +303,7 @@
"manual_edit_warning": "You are editing the result yourself. If you change a field after this, your edit will be overwritten.", "manual_edit_warning": "You are editing the result yourself. If you change a field after this, your edit will be overwritten.",
"no_credentials_hint": "This app has no preset credentials: you create an account yourself on first visit.", "no_credentials_hint": "This app has no preset credentials: you create an account yourself on first visit.",
"nothing_here": "(empty)", "nothing_here": "(empty)",
"open_web": "Open web interface",
"nothing_to_choose": "Nothing to configure here.", "nothing_to_choose": "Nothing to configure here.",
"one_page": "All on one page", "one_page": "All on one page",
"preview": "View", "preview": "View",

View file

@ -303,6 +303,7 @@
"manual_edit_warning": "Je bewerkt het resultaat zelf. Wijzig je hierna nog een veld, dan wordt je aanpassing overschreven.", "manual_edit_warning": "Je bewerkt het resultaat zelf. Wijzig je hierna nog een veld, dan wordt je aanpassing overschreven.",
"no_credentials_hint": "Deze app heeft geen vooraf ingestelde inloggegevens: je maakt bij het eerste bezoek zelf een account aan.", "no_credentials_hint": "Deze app heeft geen vooraf ingestelde inloggegevens: je maakt bij het eerste bezoek zelf een account aan.",
"nothing_here": "(leeg)", "nothing_here": "(leeg)",
"open_web": "Webinterface openen",
"nothing_to_choose": "Hier valt niets in te stellen.", "nothing_to_choose": "Hier valt niets in te stellen.",
"one_page": "Alles op één pagina", "one_page": "Alles op één pagina",
"preview": "Bekijken", "preview": "Bekijken",

66
tests/js/joblog.test.js Normal file
View file

@ -0,0 +1,66 @@
// Draait de echte Alpine-component uit index.html en toetst dat pollJob niet
// twee keer tegelijk vraagt. Wordt aangeroepen door tests/test_ui_logica.py;
// los draaien kan met: node tests/js/joblog.test.js
const fs = require('fs');
const html = fs.readFileSync(__dirname + '/../../server-up/templates/index.html', 'utf8');
const start = html.indexOf('function app() {');
const eind = html.indexOf('</script>', start);
const bron = html.slice(start, eind);
global.localStorage = { getItem: () => null, setItem: () => {} };
global.location = { hash: '', search: '' };
global.window = { matchMedia: () => ({matches:false, addEventListener(){}}),
addEventListener(){}, innerWidth: 1200 };
global.document = { documentElement: {classList:{add(){},remove(){}}},
addEventListener(){}, querySelector: () => null };
global.navigator = { clipboard: { writeText: async () => {} }, language: 'nl' };
global.crypto = require('crypto').webcrypto;
eval(bron);
let fouten = 0;
const check = (naam, waar) => { if (!waar) { fouten++; console.log(' ✖ ' + naam); }
else console.log(' ✓ ' + naam); };
const rustig = () => new Promise(r => setTimeout(r, 0));
(async () => {
const c = app();
c.t = (k) => k;
c.$nextTick = (fn) => fn();
c.$refs = {};
// De tikken van setInterval zelf in de hand houden: zo kunnen we een tweede
// tik afvuren terwijl het antwoord op de eerste nog onderweg is — precies
// wat er gebeurt bij een trage stap zoals een backup terugzetten.
let tik = null;
global.setInterval = (fn) => { tik = fn; return 1; };
global.clearInterval = () => {};
const openstaand = [];
c.rpc = () => new Promise(res => openstaand.push(res));
c.pollJob('abc123');
tik(); await rustig();
tik(); await rustig();
tik(); await rustig();
check('maar één verzoek tegelijk onderweg', openstaand.length === 1);
openstaand.forEach(res => res({lines: [{level: 'dim', text: 'Backup wordt teruggezet…'}],
status: 'running'}));
await rustig();
check('de regel staat er één keer',
c.job.lines.filter(l => l.text === 'Backup wordt teruggezet…').length === 1);
// Daarna schuift de offset op en mag er weer gevraagd worden.
tik(); await rustig();
check('volgende tik vraagt verder vanaf de nieuwe offset', openstaand.length === 2);
let klaar = '';
c.job.timer = 1;
openstaand[1]({lines: [], status: 'done'});
await rustig();
check('afgeronde job stopt de poll', c.job.status === 'done');
console.log(fouten === 0 ? '\nAlle joblogcontroles geslaagd' : `\n${fouten} FOUTEN`);
process.exit(fouten ? 1 : 0);
})();

View file

@ -3,6 +3,7 @@
Deze suite draait over de echte templates, niet over voorbeelden. Zo valt een Deze suite draait over de echte templates, niet over voorbeelden. Zo valt een
kapot template op vóórdat iemand het probeert te installeren. kapot template op vóórdat iemand het probeert te installeren.
""" """
import re
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
@ -377,6 +378,24 @@ def test_huidige_versie_heeft_een_changelog_sectie():
assert f"# v{versie}" in changelog, f"geen changelog-sectie voor {versie}" assert f"# v{versie}" in changelog, f"geen changelog-sectie voor {versie}"
def _controleer_javascript_van_initscript(script: str):
"""`bash -n` ziet de JavaScript in de heredoc niet — node wel.
Een typefout daarin blijkt anders pas op de server, wanneer de reparatie
van de database stilletjes niets doet.
"""
import shutil
node = shutil.which("node")
if not node:
pytest.skip("node niet beschikbaar")
binnen = script.split("<<EOF\n", 1)[1].split("\nEOF", 1)[0]
# De shell vult ${...} in vóór het script de mongo-shell in gaat.
js = re.sub(r"\$\{[^}]+\}", "x", binnen)
r = subprocess.run([node, "--check", "-"], input=js, text=True,
capture_output=True)
assert r.returncode == 0, r.stderr
def test_unifi_heeft_pi_geschikte_en_correct_geinitialiseerde_mongodb(): def test_unifi_heeft_pi_geschikte_en_correct_geinitialiseerde_mongodb():
app = WORTEL / "apps" / "unifi-network" app = WORTEL / "apps" / "unifi-network"
with tempfile.TemporaryDirectory() as t: with tempfile.TemporaryDirectory() as t:
@ -399,18 +418,31 @@ def test_unifi_heeft_pi_geschikte_en_correct_geinitialiseerde_mongodb():
assert reparatie["restart"] == "no" assert reparatie["restart"] == "no"
assert "./init-mongo.sh:/init-mongo.sh:ro" in reparatie["volumes"] assert "./init-mongo.sh:/init-mongo.sh:ro" in reparatie["volumes"]
assert any(v == "MONGO_HOST=unifi-mongo" for v in reparatie["environment"]) assert any(v == "MONGO_HOST=unifi-mongo" for v in reparatie["environment"])
# De beheerdersnaam van het allereerste sjabloon. Een volume van toen kent
# geen 'root', en dan mislukte elke reparatie én elke mongodump.
assert any(v == "MONGO_INITDB_ROOT_USERNAME=unifi" for v in mongo["environment"])
assert any(v == "MONGO_INITDB_ROOT_USERNAME=unifi" for v in reparatie["environment"])
# De healthcheck mag geen inloggegevens gebruiken: bestaat de beheerder
# niet, dan blijft de container ongezond en start de reparatie nooit.
assert mongo["healthcheck"]["test"][0] == "CMD-SHELL" assert mongo["healthcheck"]["test"][0] == "CMD-SHELL"
assert isinstance(mongo["healthcheck"]["test"][1], str) assert isinstance(mongo["healthcheck"]["test"][1], str)
assert "$${MONGO_INITDB_ROOT_PASSWORD}" in mongo["healthcheck"]["test"][1] assert "--username" not in mongo["healthcheck"]["test"][1]
assert "ping" in mongo["healthcheck"]["test"][1]
assert "createUser" in script assert "createUser" in script
assert "clusterMonitor" in script assert "clusterMonitor" in script
assert 'role: "dbOwner"' in script assert 'role: "dbOwner"' in script
assert "getUser" in script and "updateUser" in script assert "getUser" in script and "updateUser" in script
assert 'ensureUser("${MONGO_AUTHSOURCE}")' in script assert "grantRolesToUser" in script
assert 'ensureUser("${MONGO_DBNAME}")' in script # Rollen die de backup nodig heeft voor mongodump/mongorestore.
assert 'role: "backup"' in script and 'role: "restore"' in script
# Meerdere beheerders proberen, inclusief de naam uit het eerste sjabloon.
assert '"root", "unifi"' in script
assert "ensureUser(authBron)" in script
assert "ensureUser(appDatabase)" in script
syntaxis = subprocess.run(["bash", "-n"], input=script, text=True, syntaxis = subprocess.run(["bash", "-n"], input=script, text=True,
capture_output=True) capture_output=True)
assert syntaxis.returncode == 0, syntaxis.stderr assert syntaxis.returncode == 0, syntaxis.stderr
_controleer_javascript_van_initscript(script)
assert bp.metadata(app)["architectures"] == ["amd64", "arm64"] assert bp.metadata(app)["architectures"] == ["amd64", "arm64"]
assert bp.metadata(app)["web"] == {"scheme": "https", "port": 8443} assert bp.metadata(app)["web"] == {"scheme": "https", "port": 8443}

View file

@ -279,6 +279,37 @@ def test_databasedienst_wordt_herkend_met_wachtwoord_uit_env(env, monkeypatch):
assert d["wachtwoord"] == "geheim123" assert d["wachtwoord"] == "geheim123"
def test_mongodump_gebruikt_de_beheerder_uit_compose(env, monkeypatch):
"""De naam van de MongoDB-beheerder stond niet in de tabel met dumpvelden.
Het commando viel dan terug op de verzonnen gebruiker 'app' en elke dump
van bijvoorbeeld UniFi mislukte met "Authentication failed".
"""
import json as _json
from core import backups, docker
monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt())
d = env["lib"] / "unifi-network"
d.mkdir(parents=True, exist_ok=True)
(d / "docker-compose.yml").write_text(
"services:\n"
" unifi-mongo:\n"
" image: mongo:4.4.29\n"
" container_name: unifi-mongo\n"
" environment:\n"
" - MONGO_INITDB_ROOT_USERNAME=unifi\n"
" - MONGO_INITDB_ROOT_PASSWORD=${MONGO_PASSWORD}\n")
(d / ".env").write_text("MONGO_PASSWORD=geheim123\n")
(d / ".serverup.json").write_text(_json.dumps({"source": "unifi-network"}))
dienst = backups.databasediensten("unifi-network")[0]
assert dienst["gebruiker"] == "unifi"
assert dienst["wachtwoord"] == "geheim123"
cmd, _env = backups._dump_commando(dienst)
assert "--username='unifi'" in cmd[-1]
assert "--username='app'" not in cmd[-1]
def test_dump_die_mislukt_wordt_gemeld_en_niet_stil_overgeslagen(env, monkeypatch): def test_dump_die_mislukt_wordt_gemeld_en_niet_stil_overgeslagen(env, monkeypatch):
"""Een archief dat compleet lijkt terwijl de database ontbreekt is het """Een archief dat compleet lijkt terwijl de database ontbreekt is het
ergste dat een backup kan doen.""" ergste dat een backup kan doen."""

View file

@ -109,6 +109,97 @@ def test_installatie_bewaart_de_gemaakte_keuzes(client, env, monkeypatch):
assert meta["source"] == "keuzeapp" assert meta["source"] == "keuzeapp"
def _installeer_keuzeapp(client, csrf, poort=9123):
"""Zet een sjabloon in de git-cache en installeer het als stack 'kz'."""
import json as _json
from core import git
wortel = git.cache_dir("server-up") / "apps" / "keuzeapp"
(wortel / "files").mkdir(parents=True, exist_ok=True)
(wortel / "template.json").write_text(_json.dumps({
"kind": "compose", "metadata": {"name": "keuzeapp"},
"variables": [{"title": "Algemeen", "items": [
{"name": "service_name", "type": "str", "title": "Naam",
"default": "keuzeapp", "required": True},
{"name": "port", "type": "int", "title": "Poort", "default": 8099},
]}]}), encoding="utf-8")
(wortel / "files" / "compose.yaml").write_text(
'services:\n << service_name >>:\n image: nginx\n'
' ports:\n - "<< port >>:80"\n', encoding="utf-8")
r = client.post("/api/store/install",
json={"stack": "keuzeapp", "repo_id": "server-up",
"instance": "kz", "values": {"port": poort}},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
_wacht_op_job(client, r.get_json()["job_id"])
def test_geslaagde_wijziging_wordt_niet_teruggedraaid(client, env, monkeypatch):
"""De job draait in een eigen thread, waar `request` niet meer bestaat.
Het adres voor het audit-logboek werd daar alsnog uitgelezen: een
RuntimeError precies nádat de stack met de nieuwe instellingen al draaide.
De foutafhandeling zette vervolgens de backup terug, dus een geslaagde
wijziging werd alsnog ongedaan gemaakt.
"""
import app as app_module
csrf = login(client)
_installeer_keuzeapp(client, csrf)
monkeypatch.setattr(app_module.docker, "validate_compose",
lambda *a, **k: (True, ""))
monkeypatch.setattr(app_module.docker, "compose_down", lambda *a, **k: 0)
monkeypatch.setattr(app_module.docker, "compose_up", lambda *a, **k: 0)
monkeypatch.setattr(app_module.backups, "create",
lambda *a, **k: {"file": "kz_test.tar.gz"})
monkeypatch.setattr(app_module.backups, "restore",
lambda *a, **k: pytest.fail("backup werd teruggezet"))
r = client.post("/api/stacks/kz/reconfigure",
json={"values": {"service_name": "keuzeapp", "port": 9124},
"connect_apps": False},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
d = _wacht_op_job(client, r.get_json()["job_id"])
tekst = "\n".join(r["text"] for r in d.get("lines", []))
assert "RuntimeError" not in tekst, tekst
assert d.get("status") == "done", tekst
compose = next((p for p in (env["lib"] / "kz").iterdir()
if p.name in ("compose.yaml", "compose.yml",
"docker-compose.yml", "docker-compose.yaml")))
assert '"9124:80"' in compose.read_text()
def test_wijziging_wordt_vastgelegd_met_het_adres_van_de_aanvrager(
client, env, monkeypatch):
"""Het IP-adres hoort in het audit-logboek te belanden, niet verloren te
gaan doordat we het pas in de achtergrondthread opvragen."""
import app as app_module
from core import audit
audit.init()
csrf = login(client)
_installeer_keuzeapp(client, csrf)
monkeypatch.setattr(app_module.docker, "validate_compose",
lambda *a, **k: (True, ""))
monkeypatch.setattr(app_module.docker, "compose_down", lambda *a, **k: 0)
monkeypatch.setattr(app_module.docker, "compose_up", lambda *a, **k: 0)
monkeypatch.setattr(app_module.backups, "create",
lambda *a, **k: {"file": "kz_test.tar.gz"})
r = client.post("/api/stacks/kz/reconfigure",
json={"values": {"service_name": "keuzeapp", "port": 9125},
"connect_apps": False},
headers={"X-CSRF-Token": csrf})
_wacht_op_job(client, r.get_json()["job_id"])
regels = [x for x in audit.query(50, src="stacks")
if x["action"] == "reconfigure"]
assert regels and regels[0]["status"] == "ok"
assert regels[0]["ip"]
# ── Koppelen ───────────────────────────────────────────────────────────────── # ── Koppelen ─────────────────────────────────────────────────────────────────
def test_connect_van_onbekende_stack(client): def test_connect_van_onbekende_stack(client):

View file

@ -117,3 +117,41 @@ def test_stack_meldt_wat_er_niet_meer_draait(client, env, monkeypatch):
(mq / ".serverup.json").write_text(_json.dumps({"source": "mosquitto"})) (mq / ".serverup.json").write_text(_json.dumps({"source": "mosquitto"}))
st = {s["name"]: s for s in client.get("/api/stacks").get_json()} st = {s["name"]: s for s in client.get("/api/stacks").get_json()}
assert st["z2m"]["depends_missing"] == [] assert st["z2m"]["depends_missing"] == []
def test_stacklijst_geeft_het_webadres_van_de_app(client, env):
"""Het adres stond alleen in de modal met inloggegevens; op de kaart hoort
een knop te staan die de app opent dus moet de lijst het adres kennen."""
import json
d = env["lib"] / "unifi-network"
d.mkdir(parents=True, exist_ok=True)
(d / "docker-compose.yml").write_text(
"services:\n"
" unifi-mongo:\n"
" image: mongo:4.4.29\n"
" unifi:\n"
" image: lscr.io/linuxserver/unifi-network-application:latest\n"
" ports:\n"
' - "8443:8443"\n'
' - "8080:8080"\n')
(d / ".serverup.json").write_text(json.dumps({
"source": "unifi-network", "values": {"port_ui": 8443},
"web": {"scheme": "https", "port": 8443}}))
login(client)
per_naam = {s["name"]: s for s in client.get("/api/stacks").get_json()}
assert per_naam["unifi-network"]["web_url"].startswith("https://")
assert per_naam["unifi-network"]["web_url"].endswith(":8443")
def test_stack_zonder_gepubliceerde_poort_heeft_geen_webadres(client, env):
import json
d = env["lib"] / "worker"
d.mkdir(parents=True, exist_ok=True)
(d / "docker-compose.yml").write_text(
"services:\n worker:\n image: busybox\n")
(d / ".serverup.json").write_text(json.dumps({"source": "worker"}))
login(client)
per_naam = {s["name"]: s for s in client.get("/api/stacks").get_json()}
assert per_naam["worker"]["web_url"] == ""

View file

@ -21,6 +21,17 @@ def test_invulmenu_logica():
assert r.returncode == 0, r.stdout + r.stderr assert r.returncode == 0, r.stdout + r.stderr
@pytest.mark.skipif(not shutil.which("node"), reason="node ontbreekt")
def test_joblog_vraagt_niet_twee_keer_tegelijk():
"""Elke 400 ms een nieuwe poll starten terwijl de vorige nog loopt, laat
meerdere verzoeken met dezelfde offset vertrekken. Bij een trage stap
een backup terugzetten bijvoorbeeld kwam hetzelfde blok logregels dan
twee tot vijf keer in beeld."""
r = subprocess.run(["node", str(WORTEL / "tests" / "js" / "joblog.test.js")],
capture_output=True, text=True, timeout=60, cwd=str(WORTEL))
assert r.returncode == 0, r.stdout + r.stderr
@pytest.mark.skipif(not shutil.which("node"), reason="node ontbreekt") @pytest.mark.skipif(not shutil.which("node"), reason="node ontbreekt")
def test_installatiemodal_in_een_echte_dom(): def test_installatiemodal_in_een_echte_dom():
"""Rendert de modal met Alpine en klikt op Volgende. """Rendert de modal met Alpine en klikt op Volgende.