fix(netwerk): koppel niet langer de database aan het gedeelde netwerk
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 6m16s

add_shared_network hing elke service van een stack aan het gedeelde netwerk.
Eén druk op 'koppelen' maakte daarmee de database van die app bereikbaar voor
elke andere gekoppelde app — en niet elk sjabloon zet daar een wachtwoord op.

- Standaard gaan alleen services mee die geen database, cache of zoekindex
  zijn; brokers als mosquitto en rabbitmq blijven wel deelbaar.
- Per onderdeel te kiezen in de stap 'Verbinden'; de keuze staat in
  .serverup.json en geldt ook bij een volgende wijziging.
- mongodump/mongorestore sturen geen inloggegevens meer mee bij een database
  zonder authenticatie; die dump mislukte altijd.
- Joblogs worden bewaard in SU_JOBS (standaard /data/jobs) en teruggelezen na
  een herstart; een afgekapte taak meldt dat in plaats van 'unknown'.

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 23:14:24 +02:00
parent 483f80e032
commit c658418b10
17 changed files with 515 additions and 33 deletions

11
AGENTS.md Normal file
View file

@ -0,0 +1,11 @@
# Projectafspraken
- Werk nooit rechtstreeks op main
- Push naar de dev branch
- Werk met versienummering (v0.0.00)
- In de dev-branch altijd beta weergeven bij de versienummering
- Beschrijf gewijzigde functionaliteiten puntsgewijs in de pull request.
- Gebruik geen sudo
- Commit geen wachtwoorden, tokens, `.env`-bestanden of privésleutels.
- Voer relevante tests uit voor iedere commit.
- Push wijzigingen naar Forgejo, maar merge niet zelfstandig.
- Bij opdracht "Release versie" maak een versie releaseversie

View file

@ -1,3 +1,27 @@
# v0.8.24-beta — Koppelen laat je database met rust
Koppelen aan het gedeelde netwerk hing élke service van een stack eraan, ook de
database. Eén druk op de knop maakte `roosterwijs-db` of `graylog-mongo` op
containernaam bereikbaar voor elke andere gekoppelde app — en lang niet elk
sjabloon zet een wachtwoord op zijn database.
- **Alleen de app gaat mee, niet zijn opslag.** Databases, caches en
zoekindexen blijven standaard binnen hun eigen stack. Een MQTT-broker of
RabbitMQ hoort daar bewust niet bij: die dééllen is juist waarvoor het
gedeelde netwerk bestaat.
- **Per onderdeel te kiezen.** Wil je één Postgres voor twee stacks, dan zet je
hem in de stap 'Verbinden' aan. De keuze wordt bewaard en blijft gelden bij
een volgende wijziging.
- **Het joblog vertelt wat er gekoppeld is**, in plaats van alleen het netwerk.
- **Een backup van een database zonder wachtwoord werkt weer.** Het
dumpcommando stuurde altijd inloggegevens mee, ook bij een MongoDB zonder
authenticatie — dat mislukte gegarandeerd, terwijl de melding erna 'Backup
klaar' was.
- **Het joblog overleeft een herstart.** Taken stonden alleen in het geheugen;
wie tijdens het bijwerken meekeek zag daarna alleen nog "unknown". De laatste
twintig taken staan nu in `/data/jobs`, en een taak die door een herstart is
afgekapt zegt dat ook.
# v0.8.23-beta — De branch van een repo is te zien en te wijzigen
Sjablonen komen uit de git-cache van een app-repo, niet uit het image van

13
CLAUDE.md Normal file
View file

@ -0,0 +1,13 @@
# Projectafspraken
- Werk nooit rechtstreeks op main
- Push naar de dev branch
- Werk met versienummering (v0.0.00)
- Verhoog per uitgave met 0.0.01, dus 0.7.95 → 0.7.96. Geen sprongen van tien,
en de middelste positie blijft één cijfer: na 0.7.99 komt 0.8.00.
- In de dev-branch altijd beta weergeven bij de versienummering
- Beschrijf gewijzigde functionaliteiten puntsgewijs in de pull request.
- Gebruik geen sudo
- Commit geen wachtwoorden, tokens, `.env`-bestanden of privésleutels.
- Voer relevante tests uit voor iedere commit.
- Push wijzigingen naar Forgejo, maar merge niet zelfstandig.
- Bij opdracht "Release versie" maak een versie releaseversie

View file

@ -72,7 +72,7 @@ RUN mkdir -p /data/stacks /data/appdata /data/backups /data/git modules /root \
ENV PYTHONUNBUFFERED=1 PORT=5000 HOME=/root \
SU_VERSION=${SU_VERSION} \
SU_CONFIG=/data/config.json SU_AUDIT=/data/audit.db SU_GIT_CACHE=/data/git \
SU_SECRET=/data/secret.key \
SU_SECRET=/data/secret.key SU_JOBS=/data/jobs \
GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/data/known_hosts"
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh

View file

@ -1 +1 @@
0.8.23-beta
0.8.24-beta

View file

@ -39,6 +39,9 @@ services:
- SU_CONFIG=/data/config.json
- SU_AUDIT=/data/audit.db
- SU_GIT_CACHE=/data/git
# Joblogs overleven hier een herstart; zonder deze map is na het
# bijwerken niet meer terug te zien hoe de taak afliep.
- SU_JOBS=/data/jobs
- SU_CONTAINER=server-up
# Onder welk account Server Up draait. install.sh vult dit in; leeg of 0
# betekent root. De container krijgt hiermee géén extra rechten — er gaan

View file

@ -1113,6 +1113,7 @@ def api_stack_config(name):
ips=meta.get("ips") or {},
connected=bool(meta.get("connected")),
app_networks=meta.get("app_networks") or [],
app_services=meta.get("app_services") or [],
source=meta.get("source", ""), repo_id=meta.get("repo_id", ""),
name=meta.get("name", name))
@ -1155,6 +1156,9 @@ def api_stack_reconfigure(name):
app_netten = [n for n in (meta.get("app_networks") or []) if n] or (
[gedeeld_net] if meta.get("connected") else [])
verbinden = bool(app_netten)
# Welke onderdelen op dat netwerk komen. Zonder keuze blijven databases er
# buiten; zie compose_transform.add_shared_network.
app_diensten = _gekozen_app_diensten(body, meta)
# Ook hier: een veld leegmaken mag niet stilzwijgend een kapotte stack
# opleveren — en er ligt al een werkende versie die we niet moeten slopen.
@ -1230,9 +1234,14 @@ def api_stack_reconfigure(name):
else:
jobs.log(qq, "warn", f"Netwerk '{naam}' niet beschikbaar: {fout_net}")
if klaar:
tekst = cf.read_text(encoding="utf-8")
diensten_nu = app_diensten or \
compose_transform.deelbare_diensten(tekst)
cf.write_text(compose_transform.add_shared_network(
cf.read_text(encoding="utf-8"), klaar), encoding="utf-8")
jobs.log(qq, "ok", "Gekoppeld aan " + ", ".join(f"'{n}'" for n in klaar))
tekst, klaar, services=diensten_nu), encoding="utf-8")
jobs.log(qq, "ok", "Gekoppeld aan "
+ ", ".join(f"'{n}'" for n in klaar)
+ " (" + ", ".join(diensten_nu) + ")")
ok_cfg, err = docker.validate_compose(d)
if not ok_cfg:
@ -1247,6 +1256,7 @@ def api_stack_reconfigure(name):
"secret_keys": secret_keys,
"network": net_name, "ip": net_ip, "ips": net_ips,
"app_networks": app_netten,
"app_services": app_diensten,
"service_ports": poorten_meta,
"web": boilerplates.metadata(bron).get("web") or {},
"connected": bool(app_netten)})
@ -1284,8 +1294,10 @@ def api_stack_connect(name):
lib, d = _stack_dir(name)
if not d or not d.exists():
return jsonify(ok=False, msg="niet gevonden"), 404
aan = bool((request.json or {}).get("connect", True))
body = request.json or {}
aan = bool(body.get("connect", True))
gedeeld_net = (cfg.load().get("SHARED_NETWORK") or "serverup").strip()
app_diensten = _gekozen_app_diensten(body, _stack_meta_lezen(d))
jid, q = jobs.create(f"connect:{name}")
# De job draait in een eigen thread; `request` bestaat daar niet meer.
@ -1309,19 +1321,22 @@ def api_stack_connect(name):
cf = docker.find_compose(d)
origineel = cf.read_text(encoding="utf-8")
cf.write_text(compose_transform.add_shared_network(origineel, gedeeld_net),
encoding="utf-8")
diensten_nu = app_diensten or \
compose_transform.deelbare_diensten(origineel)
cf.write_text(compose_transform.add_shared_network(
origineel, gedeeld_net, services=diensten_nu), encoding="utf-8")
ok_cfg, err = docker.validate_compose(d)
if not ok_cfg:
cf.write_text(origineel, encoding="utf-8")
jobs.log(qq, "error", f"Compose werd ongeldig, teruggedraaid:\n{err}")
jobs.finish(jid, "error"); jobs.done(qq); return
_stack_meta_schrijven(d, {"connected": True})
_stack_meta_schrijven(d, {"connected": True,
"app_services": diensten_nu})
docker.compose_up(d, log_fn=lf, name=name)
jobs.log(qq, "ok",
f"Gekoppeld aan '{gedeeld_net}' \u2014 andere apps zijn nu "
"bereikbaar op hun containernaam.")
f"Gekoppeld aan '{gedeeld_net}' ({', '.join(diensten_nu)}) "
"\u2014 die zijn nu bereikbaar op hun containernaam.")
audit.log("stacks", "connect", "ok", ref=name, ip=ip)
jobs.finish(jid, "done")
except Exception as e:
@ -1597,6 +1612,19 @@ def _gekozen_app_netwerken(body: dict, standaard: str) -> list[str]:
return [standaard] if verbinden else []
def _gekozen_app_diensten(body: dict, meta: dict | None = None) -> list[str]:
"""Welke onderdelen van deze stack mogen op het gedeelde netwerk?
Leeg betekent: laat `add_shared_network` kiezen, en die houdt databases en
zoekindexen erbuiten. Wie zijn database bewust wil delen één Postgres voor
twee stacks zet hem hier expliciet bij.
"""
if isinstance(body.get("app_services"), list):
return [s for s in body["app_services"] if isinstance(s, str) and s]
return [s for s in ((meta or {}).get("app_services") or [])
if isinstance(s, str) and s]
def _geinstalleerde_bronnen() -> dict[str, list[str]]:
"""Per app-sjabloon de stacks die eruit geïnstalleerd zijn.
@ -1782,10 +1810,15 @@ def api_store_preview():
# afleiden uit de veldnamen gaat mis zodra een onderdeel meer dan één
# container heeft.
diensten: list[str] = []
dienst_info: list[dict] = []
try:
doc = yaml.safe_load(preview_files.get("compose.yaml")
or preview_files.get("docker-compose.yml") or "") or {}
compose_tekst = (preview_files.get("compose.yaml")
or preview_files.get("docker-compose.yml") or "")
doc = yaml.safe_load(compose_tekst) or {}
diensten = [n for n in (doc.get("services") or {})]
# Mét image en de vraag of het een database is: daarmee laat de
# interface zien wat er wel en niet op het gedeelde netwerk komt.
dienst_info = compose_transform.diensten(compose_tekst)
except Exception:
diensten = []
return jsonify(
@ -1797,6 +1830,7 @@ def api_store_preview():
compatibility=boilerplates.compatibility(meta),
fields=flds,
services=diensten,
service_info=dienst_info,
depends_on=_afhankelijkheden_met_status(meta),
preview_files=preview_files,
# Backwards-compat voor de oude UI:
@ -1905,6 +1939,7 @@ def api_store_install():
# schakelaar en dus één netwerk voor álles; nu kun je groepjes maken.
gedeeld_net = (cfg.load().get("SHARED_NETWORK") or "serverup").strip()
app_netten = _gekozen_app_netwerken(d, gedeeld_net)
app_diensten = _gekozen_app_diensten(d)
verbinden = bool(app_netten)
if not stack:
return jsonify(ok=False, msg="stack vereist")
@ -2167,11 +2202,15 @@ def api_store_install():
if klaar:
cf = docker.find_compose(dest)
try:
tekst = cf.read_text(encoding="utf-8")
diensten_nu = app_diensten or \
compose_transform.deelbare_diensten(tekst)
cf.write_text(compose_transform.add_shared_network(
cf.read_text(encoding="utf-8"), klaar), encoding="utf-8")
tekst, klaar, services=diensten_nu), encoding="utf-8")
jobs.log(qq, "ok",
"Gekoppeld aan " + ", ".join(f"'{n}'" for n in klaar)
+ " — apps daarin zijn bereikbaar op hun containernaam.")
+ " (" + ", ".join(diensten_nu) + ") — bereikbaar "
"op hun containernaam.")
except compose_transform.TransformError as e:
jobs.log(qq, "warn", f"Koppelen mislukt: {e}")
@ -2212,6 +2251,7 @@ def api_store_install():
"ips": net_ips if net else {},
"connected": bool(app_netten),
"app_networks": app_netten,
"app_services": app_diensten,
"service_ports": poorten_meta,
"categories": meta.get("categories") or [],
"tags": meta.get("tags") or [],
@ -4109,6 +4149,12 @@ if __name__ == "__main__":
print(f"\n🚀 Server Up {VERSION} · http://{host}:{port}\n")
_controleer_assets()
audit.init()
# Taken van vóór deze start terughalen. Wie hier tijdens het bijwerken op
# het joblog stond te kijken, ziet zo alsnog hoe het afliep in plaats van
# een venster dat op "unknown" blijft staan.
hersteld = jobs.herstel()
if hersteld:
print(f"Joblog: {hersteld} eerdere taak(en) teruggelezen")
i18n.load()
print("Boot repos:")
_apply_boot_repos()

View file

@ -237,9 +237,12 @@ def _dump_commando(dienst: dict) -> tuple[list[str], dict]:
hulp = "mariadb-dump" if s == "mariadb" else "mysqldump"
return ([hulp, "-u", gb or "root", f"-p{ww}", "--single-transaction",
db or gb], {})
return (["sh", "-c",
f"mongodump --archive --username='{gb or 'root'}' "
f"--password='{ww}' --authenticationDatabase=admin"], {})
# Zonder wachtwoord draait deze MongoDB zonder authenticatie (zoals het
# Graylog-sjabloon doet). Dan toch inloggegevens meesturen levert gegarandeerd
# "Authentication failed" op — en dus nooit een dump van die stack.
inlog = (f"--username='{gb or 'root'}' --password='{ww}' "
"--authenticationDatabase=admin ") if ww else ""
return (["sh", "-c", f"mongodump --archive {inlog}".rstrip()], {})
def maak_dumps(stack: str, tmp: Path, log_fn=None) -> list[Path]:
@ -800,9 +803,11 @@ def _herstel_commando(dienst: dict) -> list[str]:
if s in ("mariadb", "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"mongorestore --archive --drop --username='{gb or 'root'}' "
f"--password='{ww}' --authenticationDatabase=admin"]
# Zie _dump_commando: zonder wachtwoord draait deze MongoDB zonder
# authenticatie, en dan werken inloggegevens juist averechts.
inlog = (f"--username='{gb or 'root'}' --password='{ww}' "
"--authenticationDatabase=admin ") if ww else ""
return ["sh", "-c", f"mongorestore --archive --drop {inlog}".rstrip()]
def _wacht_op_database(dienst: dict, pogingen: int = 30, log_fn=None) -> bool:

View file

@ -116,8 +116,52 @@ def _laad(compose_text: str) -> dict:
return doc
def add_shared_network(compose_text: str, network) -> str:
"""Hang elke service ook aan een of meer gedeelde netwerken.
# Opslag- en zoekdiensten horen bij één stack. Een MQTT-broker of RabbitMQ
# staat er bewust niet tussen: die dééllen tussen stacks is juist waarvoor het
# gedeelde netwerk bestaat.
_HULPDIENST_IMAGES = (
"postgres", "postgis", "pgvector", "timescaledb", "mysql", "mariadb",
"mongo", "redis", "valkey", "keydb", "dragonfly", "memcached",
"elasticsearch", "opensearch", "clickhouse", "influxdb", "meilisearch",
"typesense", "qdrant", "solr", "etcd",
)
def is_hulpdienst(image: str) -> bool:
"""Is dit een database of zoekindex die alleen bij zijn eigen app hoort?"""
kaal = (image or "").split("/")[-1].split(":")[0].lower()
return kaal.startswith(_HULPDIENST_IMAGES)
def diensten(compose_text: str) -> list[dict]:
"""De services met hun image en of ze een hulpdienst zijn.
De interface laat hiermee kiezen wat er op het gedeelde netwerk komt, zonder
dat je het compose-bestand hoeft te lezen.
"""
try:
doc = yaml.safe_load(compose_text) or {}
except yaml.YAMLError:
return []
uit = []
for naam, svc in (doc.get("services") or {}).items():
if not isinstance(svc, dict):
continue
image = str(svc.get("image") or "")
uit.append({"name": naam, "image": image,
"helper": is_hulpdienst(image),
"host_network": bool(svc.get("network_mode"))})
return uit
def deelbare_diensten(compose_text: str) -> list[str]:
"""Welke services standaard op het gedeelde netwerk horen."""
return [d["name"] for d in diensten(compose_text)
if not d["helper"] and not d["host_network"]]
def add_shared_network(compose_text: str, network, services=None) -> str:
"""Hang services ook aan een of meer gedeelde netwerken.
`network` mag een naam zijn of een lijst namen. Meerdere netwerken maken
groepjes mogelijk: media-apps onderling, en de rest daarbuiten.
@ -125,8 +169,13 @@ def add_shared_network(compose_text: str, network) -> str:
Elke stack draait als eigen compose-project en krijgt daarmee zijn eigen
netwerk. Twee stacks kunnen elkaar dan niet op servicenaam bereiken een
app als Zigbee2MQTT vindt de MQTT-broker uit een andere stack dus niet.
Door ze allemaal aan één gedeeld bridge-netwerk te hangen, lost de
container-naam wél op tussen stacks.
Door ze aan één gedeeld bridge-netwerk te hangen, lost de container-naam wél
op tussen stacks.
`services` bepaalt welke; zonder opgave gaan alleen de services mee die geen
database of zoekindex zijn. Die hebben buiten hun eigen stack niets te
zoeken, en er staan er genoeg zonder wachtwoord op het interne netwerk
die zou je met een druk op 'koppelen' voor elke andere app openzetten.
Bestaande netwerken (zoals een macvlan met vast IP) blijven staan; dit komt
er naast.
@ -143,10 +192,17 @@ def add_shared_network(compose_text: str, network) -> str:
if not namen:
return compose_text
if services is None:
gekozen = set(deelbare_diensten(compose_text))
else:
gekozen = {s for s in services if s}
aangesloten = 0
for svc in doc["services"].values():
for snaam, svc in doc["services"].items():
if not isinstance(svc, dict):
continue
if snaam not in gekozen:
continue
# Een service met `network_mode` deelt al de namespace van de host of
# van een andere container. Docker compose weigert die combinatie:
# "declares mutually exclusive network_mode and networks". Dit raakt

View file

@ -1,5 +1,6 @@
"""Background job runner with log streaming."""
import threading, time, uuid, queue
import json, os, threading, time, uuid, queue
from pathlib import Path
_jobs: dict[str, dict] = {}
_queues: dict[str, queue.Queue] = {}
@ -11,9 +12,82 @@ _TTL = 3600
# onbeperkt in het geheugen van het proces.
_MAX_LINES = 2000
# Waar afgeronde jobs blijven staan. Het geheugen was de enige plek: bij het
# bijwerken herstart de container middenin een taak, en dan was niet meer terug
# te zien of hij geslaagd was — het joblog gaf alleen nog "unknown". Ook na een
# uur (de TTL hierboven) verdween het bewijs van wat een nachtelijke backup had
# gedaan.
_dir = Path(os.environ.get("SU_JOBS", "/data/jobs"))
_BEWAAR = 20
def _bestand(jid: str) -> Path:
return _dir / f"{jid}.json"
def _bewaar(jid: str):
"""Schrijf de huidige stand van een job weg. Aanroepen onder `_lock`."""
job = _jobs.get(jid)
if job is None:
return
try:
_dir.mkdir(parents=True, exist_ok=True)
tijdelijk = _bestand(jid).with_suffix(".tmp")
tijdelijk.write_text(json.dumps(job), encoding="utf-8")
# Vervangen in één stap: een half geschreven bestand zou bij de
# volgende start een onleesbare job opleveren.
tijdelijk.replace(_bestand(jid))
except Exception:
# Een joblog is geen reden om de taak zelf te laten mislukken.
pass
def _opruimen_schijf():
"""Houd alleen de laatste `_BEWAAR` jobs op schijf."""
try:
bestanden = sorted(_dir.glob("*.json"), key=lambda p: p.stat().st_mtime,
reverse=True)
for oud in bestanden[_BEWAAR:]:
oud.unlink(missing_ok=True)
except Exception:
pass
def herstel():
"""Lees bewaarde jobs terug en markeer wat door een herstart is afgekapt.
Zonder deze stap kijkt de interface na een herstart naar een job die niet
meer bestaat, blijft pollen op "unknown" en vertelt niemand wat er van de
taak geworden is.
"""
if not _dir.is_dir():
return 0
aantal = 0
for pad in sorted(_dir.glob("*.json")):
try:
job = json.loads(pad.read_text(encoding="utf-8"))
except Exception:
continue
if not isinstance(job, dict) or not job.get("id"):
continue
if job.get("status") == "running":
job["status"] = "error"
job.setdefault("lines", []).append(
{"level": "warn",
"text": "Server Up is herstart terwijl deze taak liep; "
"wat erna gebeurde staat hier niet in."})
with _lock:
_jobs[job["id"]] = job
aantal += 1
_opruimen_schijf()
return aantal
def _cleanup():
"""Verwijder voltooide jobs ouder dan TTL uit geheugen."""
"""Verwijder voltooide jobs ouder dan TTL uit geheugen.
Op schijf blijven ze staan; `stream` haalt ze daar weer vandaan.
"""
cutoff = time.time() - _TTL
with _lock:
stale = [jid for jid, j in _jobs.items()
@ -65,6 +139,8 @@ def finish(jid: str, status="done"):
with _lock:
if jid in _jobs:
_jobs[jid]["status"] = status
_bewaar(jid)
_opruimen_schijf()
def done(q):
@ -75,7 +151,15 @@ def done(q):
def stream(jid: str, offset=0) -> dict:
job = _jobs.get(jid)
if not job:
# Uit het geheugen verdwenen door de TTL of door een herstart, maar de
# laatste stand staat nog op schijf.
try:
bewaard = json.loads(_bestand(jid).read_text(encoding="utf-8"))
except Exception:
return {"lines": [], "status": "unknown"}
return {"lines": (bewaard.get("lines") or [])[offset:],
"status": bewaard.get("status", "unknown"),
"progress": bewaard.get("progress")}
with _stream_locks.get(jid, threading.Lock()):
q = _queues.get(jid)
if q:
@ -94,6 +178,15 @@ def stream(jid: str, offset=0) -> dict:
"text": f"{dropped + job.get('dropped', 0)} eerdere regels weggelaten"}]
+ job["lines"][-_MAX_LINES:])
job["dropped"] = dropped + job.get("dropped", 0)
# Tussentijds wegschrijven, hooguit eens per paar seconden: valt de
# container om tijdens een lange taak, dan is tot dat punt terug te
# lezen wat er gebeurd is. Bij elke regel schrijven zou een `compose
# pull` in duizenden schrijfacties veranderen.
if job["status"] == "running" and \
time.time() - job.get("bewaard_op", 0) > 3:
job["bewaard_op"] = time.time()
with _lock:
_bewaar(jid)
return {"lines": job["lines"][offset:], "status": job["status"],
"progress": job.get("progress")}

View file

@ -1895,6 +1895,30 @@ tailwind.config = {
</div>
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('new_network_hint')"></p>
</div>
<!-- Welke onderdelen op dat netwerk komen. Eerder gingen ze
allemaal mee, database incluis: die stond dan open voor
elke andere gekoppelde app. -->
<div x-show="(install.app_networks||[]).length && (install.service_info||[]).length > 1">
<div class="form-label" x-text="t('shared_services')">Onderdelen op het gedeelde netwerk</div>
<p class="text-[11px] text-dm dark:text-dm-dark mb-2" x-text="t('shared_services_hint')"></p>
<div class="space-y-1.5">
<template x-for="svc in (install.service_info||[])" :key="svc.name">
<label class="flex items-center gap-2.5 px-3 py-2 rounded-xl border border-bd dark:border-bd-dark bg-s2/40 dark:bg-s2-dark/40"
:class="svc.host_network ? 'opacity-50' : 'cursor-pointer'">
<input type="checkbox" :disabled="svc.host_network"
:checked="gedeeldeDienstAan(svc)"
@change="toggleGedeeldeDienst(svc.name, $event.target.checked)"
class="w-5 h-5 rounded border-bd dark:border-bd-dark text-ac shrink-0">
<span class="min-w-0 flex-1">
<span class="text-sm font-mono text-tx dark:text-tx-dark" x-text="svc.name"></span>
<span x-show="svc.helper" class="badge badge-warn ml-1.5" x-text="t('database_service')">database</span>
</span>
<span class="text-[10px] font-mono text-dm dark:text-dm-dark truncate max-w-[45%]" x-text="svc.image"></span>
</label>
</template>
</div>
</div>
<div>
<label class="form-label" x-text="t('network')">Netwerk</label>
<select class="form-input" x-model="install.network" @change="pickIp()">
@ -3303,6 +3327,23 @@ function app() {
this.appNets = d.networks || [];
if (!this.newNetName) this.newNetName = d.suggested || '';
},
// Zonder eigen keuze bepaalt de server het: alles behalve databases en
// zoekindexen. Dat spiegelen we hier, zodat de vinkjes kloppen met wat er
// straks in het compose-bestand belandt.
gedeeldeDienstAan(svc) {
const gekozen = this.install.app_services;
if (Array.isArray(gekozen) && gekozen.length) return gekozen.includes(svc.name);
return !svc.helper && !svc.host_network;
},
toggleGedeeldeDienst(naam, aan) {
const huidig = (Array.isArray(this.install.app_services) && this.install.app_services.length)
? this.install.app_services
: (this.install.service_info || [])
.filter(s => !s.helper && !s.host_network).map(s => s.name);
const lijst = new Set(huidig);
aan ? lijst.add(naam) : lijst.delete(naam);
this.install.app_services = [...lijst];
},
toggleAppNet(naam, aan) {
const lijst = new Set(this.install.app_networks || []);
aan ? lijst.add(naam) : lijst.delete(naam);
@ -3796,7 +3837,8 @@ function app() {
envVars: [], images: [], envOverrides: {}, imageTags: {},
fields: [], values: {}, preview_files: {},
step: 0, tab: 'compose', editing: false, composeText: '', envText: '',
network: '', ip: '', ips: {}, services: [], depends_on: [],
network: '', ip: '', ips: {}, services: [], service_info: [],
app_services: [], depends_on: [],
compatibility: { known: false, supported: true, architecture: '', reason: '' },
architecture_note: ''
};
@ -3992,6 +4034,7 @@ function app() {
if (!d || !d.ok) return;
this.install.preview_files = d.preview_files || {};
this.install.services = d.services || [];
this.install.service_info = d.service_info || [];
this.install.composeText = this.bpPreview();
this.install.envText = d.env_preview || '';
// Een onderdeel dat je hebt uitgezet levert geen container meer op; zijn
@ -4046,6 +4089,7 @@ function app() {
// Bij wijzigen is de bron nodig om opnieuw te kunnen renderen.
repo_id: d.repo_id || '',
app_networks: d.app_networks || (d.connected ? [this.sharedNetwork] : []),
app_services: d.app_services || [], service_info: [],
};
await this.loadAppNets(stack);
this.fieldInfo = null;
@ -4067,6 +4111,7 @@ function app() {
ips: this.install.ips || {},
connect_apps: this.install.connect_apps !== false,
app_networks: this.install.app_networks || [],
app_services: this.install.app_services || [],
});
if (!d.job_id) { this.toast(d.msg || this.t('error'), 'er'); return; }
this.modal = null;
@ -4100,6 +4145,7 @@ function app() {
}
body.connect_apps = this.install.connect_apps !== false;
body.app_networks = this.install.app_networks || [];
body.app_services = this.install.app_services || [];
if (this.install.format === 'boilerplate') body.values = this.install.values;
else { body.env_overrides = this.install.envOverrides; body.image_tags = this.install.imageTags; }
// Heb je het resultaat in de laatste stap zelf bewerkt, dan telt jouw

View file

@ -319,6 +319,9 @@
"step_secrets": "Access",
"step_review": "Review",
"address_available": "This address is free",
"shared_services": "Parts on the shared network",
"shared_services_hint": "Databases and search indexes stay inside this app by default. Tick one to deliberately share it with another app.",
"database_service": "database",
"app_networks": "Shared networks",
"app_networks_hint": "Apps on the same network can reach each other by container name. Not everything needs to be connected to everything: feel free to make a separate network for a group.",
"check_availability": "Check availability",

View file

@ -319,6 +319,9 @@
"step_secrets": "Toegang",
"step_review": "Controleren",
"address_available": "Dit adres is vrij",
"shared_services": "Onderdelen op het gedeelde netwerk",
"shared_services_hint": "Databases en zoekindexen blijven standaard binnen deze app. Zet er een aan als je hem bewust met een andere app wilt delen.",
"database_service": "database",
"app_networks": "Gedeelde netwerken",
"app_networks_hint": "Apps in hetzelfde netwerk kunnen elkaar op containernaam bereiken. Niet alles hoeft aan alles vast: maak gerust een apart netwerk voor een groepje.",
"check_availability": "Beschikbaarheid controleren",

View file

@ -23,6 +23,7 @@ def env(tmp_path, monkeypatch):
monkeypatch.setenv("SU_AUDIT", str(data / "audit.db"))
monkeypatch.setenv("SU_GIT_CACHE", str(data / "git"))
monkeypatch.setenv("SU_SECRET", str(data / "secret.key"))
monkeypatch.setenv("SU_JOBS", str(data / "jobs"))
monkeypatch.setenv("LIBRARY_DIR", str(lib))
monkeypatch.setenv("DATA_DIR", str(tmp_path / "appdata"))
monkeypatch.setenv("BACKUP_DIR", str(tmp_path / "backups"))

View file

@ -310,6 +310,25 @@ def test_mongodump_gebruikt_de_beheerder_uit_compose(env, monkeypatch):
assert "--username='app'" not in cmd[-1]
def test_mongo_zonder_authenticatie_wordt_zonder_inlog_gedumpt(env):
"""Het Graylog-sjabloon draait MongoDB zonder wachtwoord. Toch inloggegevens
meesturen gaf gegarandeerd "Authentication failed" en dus een archief
zonder database, terwijl de melding erna 'Backup klaar' was."""
from core import backups
dienst = {"soort": "mongo", "gebruiker": "", "database": "", "wachtwoord": "",
"service": "graylog-mongo", "container": "graylog-mongo"}
cmd, _ = backups._dump_commando(dienst)
assert "--username" not in cmd[-1]
assert "--password" not in cmd[-1]
assert cmd[-1].strip() == "mongodump --archive"
assert "--username" not in backups._herstel_commando(dienst)[-1]
# Mét wachtwoord blijven de inloggegevens er gewoon in staan.
met = {**dienst, "gebruiker": "unifi", "wachtwoord": "geheim"}
assert "--username='unifi'" in backups._dump_commando(met)[0][-1]
assert "--username='unifi'" in backups._herstel_commando(met)[-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."""

View file

@ -27,15 +27,40 @@ services:
# ── Gedeeld netwerk ──────────────────────────────────────────────────────────
def test_gedeeld_netwerk_komt_bij_elke_service():
def test_gedeeld_netwerk_komt_bij_de_app_maar_niet_bij_de_database():
"""Zonder gedeeld netwerk zit elke stack in zijn eigen compose-netwerk en
kan geen enkele app een andere op naam bereiken."""
kan geen enkele app een andere op naam bereiken.
De database van een stack heeft daarbuiten niets te zoeken: die stond met
één druk op 'koppelen' open voor elke andere app op de host, en lang niet
elk sjabloon zet er een wachtwoord op.
"""
doc = yaml.safe_load(ct.add_shared_network(BASIS, "serverup"))
for naam, svc in doc["services"].items():
assert "serverup" in svc["networks"], naam
assert "serverup" in doc["services"]["web"]["networks"]
assert "networks" not in doc["services"]["db"]
assert doc["networks"]["serverup"] == {"external": True}
def test_database_kan_er_bewust_wel_bij():
"""Eén database delen tussen twee stacks is een geldige keuze — hij mag
alleen niet stilzwijgend gebeuren."""
doc = yaml.safe_load(ct.add_shared_network(BASIS, "serverup",
services=["web", "db"]))
assert "serverup" in doc["services"]["db"]["networks"]
def test_diensten_worden_herkend():
lijst = {d["name"]: d for d in ct.diensten(BASIS)}
assert lijst["db"]["helper"] is True
assert lijst["web"]["helper"] is False
assert ct.deelbare_diensten(BASIS) == ["web"]
# Een broker delen is juist de reden dat het gedeelde netwerk bestaat.
assert not ct.is_hulpdienst("eclipse-mosquitto:2")
assert not ct.is_hulpdienst("rabbitmq:3-management")
assert ct.is_hulpdienst("docker.io/library/postgres:16-alpine")
assert ct.is_hulpdienst("opensearchproject/opensearch:2")
def test_gedeeld_netwerk_naast_een_eigen_ip():
"""Een stack met een macvlan-adres moet het gedeelde netwerk erbij krijgen,
niet in plaats daarvan."""
@ -302,3 +327,46 @@ def test_store_ziet_geen_installatie_van_een_andere_app(env, tmp_path):
stacks = {s["dir"]: s for s in git._scan_compose_dirs(sjablonen, lib)}
assert stacks["radarr"]["instances"] == []
def test_installeren_koppelt_de_database_niet_mee(client, env, monkeypatch):
"""Het echte pad: installeren met koppeling aan, en dan hoort de database
in zijn eigen netwerk te blijven."""
import json as _json
import time as _time
from core import git
csrf = login(client)
wortel = git.cache_dir("server-up") / "apps" / "metdb"
(wortel / "files").mkdir(parents=True, exist_ok=True)
(wortel / "template.json").write_text(_json.dumps({
"kind": "compose", "metadata": {"name": "metdb"},
"variables": [{"title": "Algemeen", "items": [
{"name": "service_name", "type": "str", "title": "Naam",
"default": "metdb", "required": True}]}]}), encoding="utf-8")
(wortel / "files" / "compose.yaml").write_text(
'services:\n << service_name >>:\n image: nginx\n'
' << service_name >>-db:\n image: postgres:16-alpine\n', encoding="utf-8")
import app as app_module
monkeypatch.setattr(app_module.networks, "ensure_shared", lambda n: (True, ""))
r = client.post("/api/store/install",
json={"stack": "metdb", "repo_id": "server-up",
"instance": "mdb", "connect_apps": True},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
jid = r.get_json()["job_id"]
einde = _time.time() + 10
while _time.time() < einde:
if (client.get(f"/api/jobs/{jid}").get_json() or {}).get("status") in ("done", "error"):
break
_time.sleep(0.05)
compose = next(p for p in (env["lib"] / "mdb").iterdir()
if p.name.startswith(("compose", "docker-compose")))
doc = yaml.safe_load(compose.read_text())
app_svc = next(n for n in doc["services"] if not n.endswith("-db"))
db_svc = next(n for n in doc["services"] if n.endswith("-db"))
assert "serverup" in doc["services"][app_svc]["networks"]
assert "networks" not in doc["services"][db_svc], \
"de database hoort niet op het gedeelde netwerk"

91
tests/test_jobs.py Normal file
View file

@ -0,0 +1,91 @@
"""Het joblog moet een herstart overleven.
Jobs stonden alleen in het geheugen. Juist bij het bijwerken herstart de
container middenin de taak, en dan gaf `/api/jobs/<id>` alleen nog "unknown":
de interface bleef pollen zonder ooit te vertellen hoe het afliep.
"""
import json
import sys
from pathlib import Path
WORTEL = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(WORTEL / "server-up"))
def _job_met_regels(jobs, tag="backup:web", regels=("bezig", "klaar")):
jid, q = jobs.create(tag)
for r in regels:
jobs.log(q, "dim", r)
jobs.stream(jid) # regels uit de queue in de job trekken
return jid
def test_afgeronde_job_blijft_op_schijf_staan(env):
from core import jobs
jid = _job_met_regels(jobs)
jobs.finish(jid, "done")
bestand = Path(env["data"]) / "jobs" / f"{jid}.json"
assert bestand.is_file()
bewaard = json.loads(bestand.read_text())
assert bewaard["status"] == "done"
assert [r["text"] for r in bewaard["lines"]] == ["bezig", "klaar"]
def test_log_blijft_leesbaar_nadat_de_job_uit_het_geheugen_is(env):
"""Na de TTL ruimt Server Up jobs op; het log hoort dan nog te bestaan."""
from core import jobs
jid = _job_met_regels(jobs)
jobs.finish(jid, "done")
jobs._jobs.clear() # zoals _cleanup() na een uur doet
d = jobs.stream(jid)
assert d["status"] == "done"
assert [r["text"] for r in d["lines"]] == ["bezig", "klaar"]
assert jobs.stream(jid, offset=1)["lines"][0]["text"] == "klaar"
def test_onderbroken_job_wordt_als_zodanig_teruggelezen(env):
"""Een taak die liep toen de container omviel mag niet als 'running'
terugkomen: dan blijft de interface eeuwig wachten."""
from core import jobs
# Geen finish(): de job loopt nog. Het pollen van de interface schrijft
# tussentijds weg, en dat is precies wat er van hem overblijft.
jid = _job_met_regels(jobs, regels=("backup wordt gemaakt",))
assert (Path(env["data"]) / "jobs" / f"{jid}.json").is_file()
jobs._jobs.clear() # de herstart
assert jobs.herstel() >= 1
d = jobs.stream(jid)
assert d["status"] == "error"
tekst = " ".join(r["text"] for r in d["lines"])
assert "backup wordt gemaakt" in tekst
assert "herstart" in tekst
def test_onbekende_job_blijft_onbekend(env):
from core import jobs
assert jobs.stream("bestaatniet")["status"] == "unknown"
def test_er_blijven_niet_eindeloos_joblogs_liggen(env):
from core import jobs
for i in range(jobs._BEWAAR + 5):
jobs.finish(_job_met_regels(jobs, tag=f"taak-{i}"), "done")
bestanden = list((Path(env["data"]) / "jobs").glob("*.json"))
assert len(bestanden) <= jobs._BEWAAR
def test_joblog_van_de_api_komt_ook_van_schijf(client, env):
"""De route zelf moet het bewaarde log teruggeven, niet alleen de module."""
from conftest import login
from core import jobs
login(client)
jid = _job_met_regels(jobs, regels=("stap 1",))
jobs.finish(jid, "error")
jobs._jobs.clear()
d = client.get(f"/api/jobs/{jid}").get_json()
assert d["status"] == "error"
assert d["lines"][0]["text"] == "stap 1"