fix(stacks): geslaagde wijziging niet meer terugdraaien
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 4m12s
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:
parent
6f549357b1
commit
3ee09eebc7
15 changed files with 452 additions and 29 deletions
28
CHANGELOG.md
28
CHANGELOG.md
|
|
@ -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
|
||||
|
||||
Een eerder geïnitialiseerd Mongo-volume voerde het init-script niet opnieuw uit.
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.8.20-beta
|
||||
0.8.21-beta
|
||||
|
|
|
|||
|
|
@ -30,7 +30,10 @@ services:
|
|||
image: mongo:4.4.29
|
||||
container_name: << service_name >>-mongo
|
||||
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_USER=unifi
|
||||
- MONGO_PASS=<< mongo_password >>
|
||||
|
|
@ -40,9 +43,13 @@ services:
|
|||
- << service_name >>_mongodata:/data/db
|
||||
- ./init-mongo.sh:/docker-entrypoint-initdb.d/init-mongo.sh:ro
|
||||
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:
|
||||
- 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
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
|
|
@ -53,7 +60,7 @@ services:
|
|||
image: mongo:4.4.29
|
||||
container_name: << service_name >>-mongo-init
|
||||
environment:
|
||||
- MONGO_INITDB_ROOT_USERNAME=root
|
||||
- MONGO_INITDB_ROOT_USERNAME=unifi
|
||||
- MONGO_INITDB_ROOT_PASSWORD=<< mongo_password >>
|
||||
- MONGO_USER=unifi
|
||||
- MONGO_PASS=<< mongo_password >>
|
||||
|
|
|
|||
|
|
@ -18,32 +18,88 @@ if [[ -n "${MONGO_HOST:-}" ]]; then
|
|||
fi
|
||||
|
||||
"${mongo_init_bin}" "${mongo_args[@]}" <<EOF
|
||||
const authDb = db.getSiblingDB("${MONGO_AUTHSOURCE}");
|
||||
if (!authDb.auth("${MONGO_INITDB_ROOT_USERNAME}", "${MONGO_INITDB_ROOT_PASSWORD}")) {
|
||||
const beheerNaam = "${MONGO_INITDB_ROOT_USERNAME}";
|
||||
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);
|
||||
}
|
||||
print("MongoDB-beheerder: " + beheerder);
|
||||
|
||||
const roles = [
|
||||
{ db: "admin", role: "clusterMonitor" },
|
||||
{ db: "${MONGO_DBNAME}", role: "dbOwner" },
|
||||
{ db: "${MONGO_DBNAME}_stat", role: "dbOwner" },
|
||||
{ db: "${MONGO_DBNAME}_audit", role: "dbOwner" },
|
||||
{ db: "${MONGO_DBNAME}_restore", role: "dbOwner" }
|
||||
// Zonder deze twee kan Server Up geen mongodump/mongorestore draaien voor
|
||||
// de backup van deze stack.
|
||||
{ db: "admin", role: "backup" },
|
||||
{ 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) {
|
||||
const userDb = db.getSiblingDB(database);
|
||||
const existing = userDb.getUser("${MONGO_USER}");
|
||||
if (existing) {
|
||||
userDb.updateUser("${MONGO_USER}", { pwd: "${MONGO_PASS}", roles: roles });
|
||||
} else {
|
||||
userDb.createUser({ user: "${MONGO_USER}", pwd: "${MONGO_PASS}", roles: roles });
|
||||
const bestaand = userDb.getUser(appNaam);
|
||||
if (!bestaand) {
|
||||
userDb.createUser({ user: appNaam, pwd: appWachtwoord, roles: roles });
|
||||
print("Gebruiker " + appNaam + " aangemaakt in " + database + ".");
|
||||
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}");
|
||||
if ("${MONGO_AUTHSOURCE}" !== "${MONGO_DBNAME}") {
|
||||
ensureUser("${MONGO_DBNAME}");
|
||||
ensureUser(authBron);
|
||||
if (authBron !== appDatabase) {
|
||||
ensureUser(appDatabase);
|
||||
}
|
||||
EOF
|
||||
<% endraw %>
|
||||
|
|
|
|||
|
|
@ -449,13 +449,15 @@ def api_update_rollback():
|
|||
return jsonify(ok=False, msg="Geen vorige versie bekend"), 400
|
||||
|
||||
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):
|
||||
qq = jobs.get_queue(jid)
|
||||
ok, msg = selfupdate.rollback(log_fn=lambda m: jobs.log(qq, "dim", m))
|
||||
jobs.log(qq, "ok" if ok else "error", msg)
|
||||
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.done(qq)
|
||||
|
||||
|
|
@ -671,6 +673,32 @@ def api_stacks():
|
|||
return h["logo_url"], h["icon"]
|
||||
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 = []
|
||||
for d in sorted(lib.iterdir()):
|
||||
if not d.is_dir() or d.name.startswith("."):
|
||||
|
|
@ -693,6 +721,10 @@ def api_stacks():
|
|||
"containers": ct,
|
||||
"logo_url": logo_url, "icon": icon,
|
||||
"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),
|
||||
# dan leiden we het alsnog af uit de opgeslagen naam.
|
||||
"connected": bool(meta.get("connected")),
|
||||
|
|
@ -1132,6 +1164,11 @@ def api_stack_reconfigure(name):
|
|||
missing=ontbreekt), 400
|
||||
|
||||
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):
|
||||
qq = jobs.get_queue(jid)
|
||||
|
|
@ -1220,7 +1257,7 @@ def api_stack_reconfigure(name):
|
|||
"Herstart met de nieuwe instellingen" if rc == 0
|
||||
else "Starten mislukt \u2014 zet de backup terug via Backups")
|
||||
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")
|
||||
except Exception as 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()
|
||||
|
||||
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):
|
||||
qq = jobs.get_queue(jid)
|
||||
|
|
@ -1283,7 +1322,7 @@ def api_stack_connect(name):
|
|||
jobs.log(qq, "ok",
|
||||
f"Gekoppeld aan '{gedeeld_net}' \u2014 andere apps zijn nu "
|
||||
"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")
|
||||
except Exception as e:
|
||||
jobs.log(qq, "error", str(e))
|
||||
|
|
@ -2213,6 +2252,8 @@ def api_backups():
|
|||
@app.route("/api/backups/<stack>/create", methods=["POST"])
|
||||
def api_backup_create(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):
|
||||
qq = jobs.get_queue(jid)
|
||||
|
|
@ -2222,7 +2263,7 @@ def api_backup_create(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']})")
|
||||
audit.log("backups", "create", "ok", ref=stack, ip=request.remote_addr)
|
||||
audit.log("backups", "create", "ok", ref=stack, ip=ip)
|
||||
jobs.finish(jid, "done")
|
||||
except backups.BackupError as e:
|
||||
jobs.log(qq, "error", str(e))
|
||||
|
|
|
|||
|
|
@ -159,7 +159,10 @@ _DUMPERS = {
|
|||
"postgis": ("POSTGRES_USER", "POSTGRES_DB", "POSTGRES_PASSWORD"),
|
||||
"mariadb": ("MARIADB_USER", "MARIADB_DATABASE", "MARIADB_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",
|
||||
db or gb], {})
|
||||
return (["sh", "-c",
|
||||
f"mongodump --archive --username='{gb or 'app'}' "
|
||||
f"mongodump --archive --username='{gb or 'root'}' "
|
||||
f"--password='{ww}' --authenticationDatabase=admin"], {})
|
||||
|
||||
|
||||
|
|
@ -798,7 +801,7 @@ def _herstel_commando(dienst: dict) -> list[str]:
|
|||
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"mongorestore --archive --drop --username='{gb or 'app'}' "
|
||||
f"mongorestore --archive --drop --username='{gb or 'root'}' "
|
||||
f"--password='{ww}' --authenticationDatabase=admin"]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -395,6 +395,13 @@ tailwind.config = {
|
|||
</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>
|
||||
<!-- 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 -->
|
||||
<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.termOpen = true;
|
||||
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 () => {
|
||||
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++; });
|
||||
if (d.status && d.status !== 'running') {
|
||||
clearInterval(this.job.timer); this.job.timer = null;
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
"no_credentials_hint": "This app has no preset credentials: you create an account yourself on first visit.",
|
||||
"nothing_here": "(empty)",
|
||||
"open_web": "Open web interface",
|
||||
"nothing_to_choose": "Nothing to configure here.",
|
||||
"one_page": "All on one page",
|
||||
"preview": "View",
|
||||
|
|
|
|||
|
|
@ -303,6 +303,7 @@
|
|||
"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.",
|
||||
"nothing_here": "(leeg)",
|
||||
"open_web": "Webinterface openen",
|
||||
"nothing_to_choose": "Hier valt niets in te stellen.",
|
||||
"one_page": "Alles op één pagina",
|
||||
"preview": "Bekijken",
|
||||
|
|
|
|||
66
tests/js/joblog.test.js
Normal file
66
tests/js/joblog.test.js
Normal 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);
|
||||
})();
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
Deze suite draait over de echte templates, niet over voorbeelden. Zo valt een
|
||||
kapot template op vóórdat iemand het probeert te installeren.
|
||||
"""
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
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}"
|
||||
|
||||
|
||||
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():
|
||||
app = WORTEL / "apps" / "unifi-network"
|
||||
with tempfile.TemporaryDirectory() as t:
|
||||
|
|
@ -399,18 +418,31 @@ def test_unifi_heeft_pi_geschikte_en_correct_geinitialiseerde_mongodb():
|
|||
assert reparatie["restart"] == "no"
|
||||
assert "./init-mongo.sh:/init-mongo.sh:ro" in reparatie["volumes"]
|
||||
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 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 "clusterMonitor" in script
|
||||
assert 'role: "dbOwner"' in script
|
||||
assert "getUser" in script and "updateUser" in script
|
||||
assert 'ensureUser("${MONGO_AUTHSOURCE}")' in script
|
||||
assert 'ensureUser("${MONGO_DBNAME}")' in script
|
||||
assert "grantRolesToUser" 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,
|
||||
capture_output=True)
|
||||
assert syntaxis.returncode == 0, syntaxis.stderr
|
||||
_controleer_javascript_van_initscript(script)
|
||||
assert bp.metadata(app)["architectures"] == ["amd64", "arm64"]
|
||||
assert bp.metadata(app)["web"] == {"scheme": "https", "port": 8443}
|
||||
|
||||
|
|
|
|||
|
|
@ -279,6 +279,37 @@ def test_databasedienst_wordt_herkend_met_wachtwoord_uit_env(env, monkeypatch):
|
|||
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):
|
||||
"""Een archief dat compleet lijkt terwijl de database ontbreekt is het
|
||||
ergste dat een backup kan doen."""
|
||||
|
|
|
|||
|
|
@ -109,6 +109,97 @@ def test_installatie_bewaart_de_gemaakte_keuzes(client, env, monkeypatch):
|
|||
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_connect_van_onbekende_stack(client):
|
||||
|
|
|
|||
|
|
@ -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"}))
|
||||
st = {s["name"]: s for s in client.get("/api/stacks").get_json()}
|
||||
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"] == ""
|
||||
|
|
|
|||
|
|
@ -21,6 +21,17 @@ def test_invulmenu_logica():
|
|||
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")
|
||||
def test_installatiemodal_in_een_echte_dom():
|
||||
"""Rendert de modal met Alpine en klikt op Volgende.
|
||||
|
|
|
|||
Loading…
Reference in a new issue