fix(netwerk): koppel niet langer de database aan het gedeelde netwerk
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 6m16s
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:
parent
483f80e032
commit
c658418b10
17 changed files with 515 additions and 33 deletions
11
AGENTS.md
Normal file
11
AGENTS.md
Normal 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
|
||||||
24
CHANGELOG.md
24
CHANGELOG.md
|
|
@ -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
|
# 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
|
Sjablonen komen uit de git-cache van een app-repo, niet uit het image van
|
||||||
|
|
|
||||||
13
CLAUDE.md
Normal file
13
CLAUDE.md
Normal 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
|
||||||
|
|
@ -72,7 +72,7 @@ RUN mkdir -p /data/stacks /data/appdata /data/backups /data/git modules /root \
|
||||||
ENV PYTHONUNBUFFERED=1 PORT=5000 HOME=/root \
|
ENV PYTHONUNBUFFERED=1 PORT=5000 HOME=/root \
|
||||||
SU_VERSION=${SU_VERSION} \
|
SU_VERSION=${SU_VERSION} \
|
||||||
SU_CONFIG=/data/config.json SU_AUDIT=/data/audit.db SU_GIT_CACHE=/data/git \
|
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"
|
GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/data/known_hosts"
|
||||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.8.23-beta
|
0.8.24-beta
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,9 @@ services:
|
||||||
- SU_CONFIG=/data/config.json
|
- SU_CONFIG=/data/config.json
|
||||||
- SU_AUDIT=/data/audit.db
|
- SU_AUDIT=/data/audit.db
|
||||||
- SU_GIT_CACHE=/data/git
|
- 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
|
- SU_CONTAINER=server-up
|
||||||
# Onder welk account Server Up draait. install.sh vult dit in; leeg of 0
|
# 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
|
# betekent root. De container krijgt hiermee géén extra rechten — er gaan
|
||||||
|
|
|
||||||
|
|
@ -1113,6 +1113,7 @@ def api_stack_config(name):
|
||||||
ips=meta.get("ips") or {},
|
ips=meta.get("ips") or {},
|
||||||
connected=bool(meta.get("connected")),
|
connected=bool(meta.get("connected")),
|
||||||
app_networks=meta.get("app_networks") or [],
|
app_networks=meta.get("app_networks") or [],
|
||||||
|
app_services=meta.get("app_services") or [],
|
||||||
source=meta.get("source", ""), repo_id=meta.get("repo_id", ""),
|
source=meta.get("source", ""), repo_id=meta.get("repo_id", ""),
|
||||||
name=meta.get("name", name))
|
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 (
|
app_netten = [n for n in (meta.get("app_networks") or []) if n] or (
|
||||||
[gedeeld_net] if meta.get("connected") else [])
|
[gedeeld_net] if meta.get("connected") else [])
|
||||||
verbinden = bool(app_netten)
|
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
|
# Ook hier: een veld leegmaken mag niet stilzwijgend een kapotte stack
|
||||||
# opleveren — en er ligt al een werkende versie die we niet moeten slopen.
|
# opleveren — en er ligt al een werkende versie die we niet moeten slopen.
|
||||||
|
|
@ -1230,9 +1234,14 @@ def api_stack_reconfigure(name):
|
||||||
else:
|
else:
|
||||||
jobs.log(qq, "warn", f"Netwerk '{naam}' niet beschikbaar: {fout_net}")
|
jobs.log(qq, "warn", f"Netwerk '{naam}' niet beschikbaar: {fout_net}")
|
||||||
if klaar:
|
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.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))
|
jobs.log(qq, "ok", "Gekoppeld aan "
|
||||||
|
+ ", ".join(f"'{n}'" for n in klaar)
|
||||||
|
+ " (" + ", ".join(diensten_nu) + ")")
|
||||||
|
|
||||||
ok_cfg, err = docker.validate_compose(d)
|
ok_cfg, err = docker.validate_compose(d)
|
||||||
if not ok_cfg:
|
if not ok_cfg:
|
||||||
|
|
@ -1247,6 +1256,7 @@ def api_stack_reconfigure(name):
|
||||||
"secret_keys": secret_keys,
|
"secret_keys": secret_keys,
|
||||||
"network": net_name, "ip": net_ip, "ips": net_ips,
|
"network": net_name, "ip": net_ip, "ips": net_ips,
|
||||||
"app_networks": app_netten,
|
"app_networks": app_netten,
|
||||||
|
"app_services": app_diensten,
|
||||||
"service_ports": poorten_meta,
|
"service_ports": poorten_meta,
|
||||||
"web": boilerplates.metadata(bron).get("web") or {},
|
"web": boilerplates.metadata(bron).get("web") or {},
|
||||||
"connected": bool(app_netten)})
|
"connected": bool(app_netten)})
|
||||||
|
|
@ -1284,8 +1294,10 @@ def api_stack_connect(name):
|
||||||
lib, d = _stack_dir(name)
|
lib, d = _stack_dir(name)
|
||||||
if not d or not d.exists():
|
if not d or not d.exists():
|
||||||
return jsonify(ok=False, msg="niet gevonden"), 404
|
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()
|
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}")
|
jid, q = jobs.create(f"connect:{name}")
|
||||||
# De job draait in een eigen thread; `request` bestaat daar niet meer.
|
# 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)
|
cf = docker.find_compose(d)
|
||||||
origineel = cf.read_text(encoding="utf-8")
|
origineel = cf.read_text(encoding="utf-8")
|
||||||
cf.write_text(compose_transform.add_shared_network(origineel, gedeeld_net),
|
diensten_nu = app_diensten or \
|
||||||
encoding="utf-8")
|
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)
|
ok_cfg, err = docker.validate_compose(d)
|
||||||
if not ok_cfg:
|
if not ok_cfg:
|
||||||
cf.write_text(origineel, encoding="utf-8")
|
cf.write_text(origineel, encoding="utf-8")
|
||||||
jobs.log(qq, "error", f"Compose werd ongeldig, teruggedraaid:\n{err}")
|
jobs.log(qq, "error", f"Compose werd ongeldig, teruggedraaid:\n{err}")
|
||||||
jobs.finish(jid, "error"); jobs.done(qq); return
|
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)
|
docker.compose_up(d, log_fn=lf, name=name)
|
||||||
jobs.log(qq, "ok",
|
jobs.log(qq, "ok",
|
||||||
f"Gekoppeld aan '{gedeeld_net}' \u2014 andere apps zijn nu "
|
f"Gekoppeld aan '{gedeeld_net}' ({', '.join(diensten_nu)}) "
|
||||||
"bereikbaar op hun containernaam.")
|
"\u2014 die zijn nu bereikbaar op hun containernaam.")
|
||||||
audit.log("stacks", "connect", "ok", ref=name, ip=ip)
|
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:
|
||||||
|
|
@ -1597,6 +1612,19 @@ def _gekozen_app_netwerken(body: dict, standaard: str) -> list[str]:
|
||||||
return [standaard] if verbinden else []
|
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]]:
|
def _geinstalleerde_bronnen() -> dict[str, list[str]]:
|
||||||
"""Per app-sjabloon de stacks die eruit geïnstalleerd zijn.
|
"""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
|
# afleiden uit de veldnamen gaat mis zodra een onderdeel meer dan één
|
||||||
# container heeft.
|
# container heeft.
|
||||||
diensten: list[str] = []
|
diensten: list[str] = []
|
||||||
|
dienst_info: list[dict] = []
|
||||||
try:
|
try:
|
||||||
doc = yaml.safe_load(preview_files.get("compose.yaml")
|
compose_tekst = (preview_files.get("compose.yaml")
|
||||||
or preview_files.get("docker-compose.yml") or "") or {}
|
or preview_files.get("docker-compose.yml") or "")
|
||||||
|
doc = yaml.safe_load(compose_tekst) or {}
|
||||||
diensten = [n for n in (doc.get("services") 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:
|
except Exception:
|
||||||
diensten = []
|
diensten = []
|
||||||
return jsonify(
|
return jsonify(
|
||||||
|
|
@ -1797,6 +1830,7 @@ def api_store_preview():
|
||||||
compatibility=boilerplates.compatibility(meta),
|
compatibility=boilerplates.compatibility(meta),
|
||||||
fields=flds,
|
fields=flds,
|
||||||
services=diensten,
|
services=diensten,
|
||||||
|
service_info=dienst_info,
|
||||||
depends_on=_afhankelijkheden_met_status(meta),
|
depends_on=_afhankelijkheden_met_status(meta),
|
||||||
preview_files=preview_files,
|
preview_files=preview_files,
|
||||||
# Backwards-compat voor de oude UI:
|
# 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.
|
# schakelaar en dus één netwerk voor álles; nu kun je groepjes maken.
|
||||||
gedeeld_net = (cfg.load().get("SHARED_NETWORK") or "serverup").strip()
|
gedeeld_net = (cfg.load().get("SHARED_NETWORK") or "serverup").strip()
|
||||||
app_netten = _gekozen_app_netwerken(d, gedeeld_net)
|
app_netten = _gekozen_app_netwerken(d, gedeeld_net)
|
||||||
|
app_diensten = _gekozen_app_diensten(d)
|
||||||
verbinden = bool(app_netten)
|
verbinden = bool(app_netten)
|
||||||
if not stack:
|
if not stack:
|
||||||
return jsonify(ok=False, msg="stack vereist")
|
return jsonify(ok=False, msg="stack vereist")
|
||||||
|
|
@ -2167,11 +2202,15 @@ def api_store_install():
|
||||||
if klaar:
|
if klaar:
|
||||||
cf = docker.find_compose(dest)
|
cf = docker.find_compose(dest)
|
||||||
try:
|
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.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",
|
jobs.log(qq, "ok",
|
||||||
"Gekoppeld aan " + ", ".join(f"'{n}'" for n in klaar)
|
"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:
|
except compose_transform.TransformError as e:
|
||||||
jobs.log(qq, "warn", f"Koppelen mislukt: {e}")
|
jobs.log(qq, "warn", f"Koppelen mislukt: {e}")
|
||||||
|
|
||||||
|
|
@ -2212,6 +2251,7 @@ def api_store_install():
|
||||||
"ips": net_ips if net else {},
|
"ips": net_ips if net else {},
|
||||||
"connected": bool(app_netten),
|
"connected": bool(app_netten),
|
||||||
"app_networks": app_netten,
|
"app_networks": app_netten,
|
||||||
|
"app_services": app_diensten,
|
||||||
"service_ports": poorten_meta,
|
"service_ports": poorten_meta,
|
||||||
"categories": meta.get("categories") or [],
|
"categories": meta.get("categories") or [],
|
||||||
"tags": meta.get("tags") or [],
|
"tags": meta.get("tags") or [],
|
||||||
|
|
@ -4109,6 +4149,12 @@ if __name__ == "__main__":
|
||||||
print(f"\n🚀 Server Up {VERSION} · http://{host}:{port}\n")
|
print(f"\n🚀 Server Up {VERSION} · http://{host}:{port}\n")
|
||||||
_controleer_assets()
|
_controleer_assets()
|
||||||
audit.init()
|
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()
|
i18n.load()
|
||||||
print("Boot repos:")
|
print("Boot repos:")
|
||||||
_apply_boot_repos()
|
_apply_boot_repos()
|
||||||
|
|
|
||||||
|
|
@ -237,9 +237,12 @@ def _dump_commando(dienst: dict) -> tuple[list[str], dict]:
|
||||||
hulp = "mariadb-dump" if s == "mariadb" else "mysqldump"
|
hulp = "mariadb-dump" if s == "mariadb" else "mysqldump"
|
||||||
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",
|
# Zonder wachtwoord draait deze MongoDB zonder authenticatie (zoals het
|
||||||
f"mongodump --archive --username='{gb or 'root'}' "
|
# Graylog-sjabloon doet). Dan toch inloggegevens meesturen levert gegarandeerd
|
||||||
f"--password='{ww}' --authenticationDatabase=admin"], {})
|
# "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]:
|
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"):
|
if s in ("mariadb", "mysql"):
|
||||||
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",
|
# Zie _dump_commando: zonder wachtwoord draait deze MongoDB zonder
|
||||||
f"mongorestore --archive --drop --username='{gb or 'root'}' "
|
# authenticatie, en dan werken inloggegevens juist averechts.
|
||||||
f"--password='{ww}' --authenticationDatabase=admin"]
|
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:
|
def _wacht_op_database(dienst: dict, pogingen: int = 30, log_fn=None) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -116,8 +116,52 @@ def _laad(compose_text: str) -> dict:
|
||||||
return doc
|
return doc
|
||||||
|
|
||||||
|
|
||||||
def add_shared_network(compose_text: str, network) -> str:
|
# Opslag- en zoekdiensten horen bij één stack. Een MQTT-broker of RabbitMQ
|
||||||
"""Hang elke service ook aan een of meer gedeelde netwerken.
|
# 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
|
`network` mag een naam zijn of een lijst namen. Meerdere netwerken maken
|
||||||
groepjes mogelijk: media-apps onderling, en de rest daarbuiten.
|
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
|
Elke stack draait als eigen compose-project en krijgt daarmee zijn eigen
|
||||||
netwerk. Twee stacks kunnen elkaar dan niet op servicenaam bereiken — een
|
netwerk. Twee stacks kunnen elkaar dan niet op servicenaam bereiken — een
|
||||||
app als Zigbee2MQTT vindt de MQTT-broker uit een andere stack dus niet.
|
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
|
Door ze aan één gedeeld bridge-netwerk te hangen, lost de container-naam wél
|
||||||
container-naam wél op tussen stacks.
|
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
|
Bestaande netwerken (zoals een macvlan met vast IP) blijven staan; dit komt
|
||||||
er naast.
|
er naast.
|
||||||
|
|
@ -143,10 +192,17 @@ def add_shared_network(compose_text: str, network) -> str:
|
||||||
if not namen:
|
if not namen:
|
||||||
return compose_text
|
return compose_text
|
||||||
|
|
||||||
|
if services is None:
|
||||||
|
gekozen = set(deelbare_diensten(compose_text))
|
||||||
|
else:
|
||||||
|
gekozen = {s for s in services if s}
|
||||||
|
|
||||||
aangesloten = 0
|
aangesloten = 0
|
||||||
for svc in doc["services"].values():
|
for snaam, svc in doc["services"].items():
|
||||||
if not isinstance(svc, dict):
|
if not isinstance(svc, dict):
|
||||||
continue
|
continue
|
||||||
|
if snaam not in gekozen:
|
||||||
|
continue
|
||||||
# Een service met `network_mode` deelt al de namespace van de host of
|
# Een service met `network_mode` deelt al de namespace van de host of
|
||||||
# van een andere container. Docker compose weigert die combinatie:
|
# van een andere container. Docker compose weigert die combinatie:
|
||||||
# "declares mutually exclusive network_mode and networks". Dit raakt
|
# "declares mutually exclusive network_mode and networks". Dit raakt
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"""Background job runner with log streaming."""
|
"""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] = {}
|
_jobs: dict[str, dict] = {}
|
||||||
_queues: dict[str, queue.Queue] = {}
|
_queues: dict[str, queue.Queue] = {}
|
||||||
|
|
@ -11,9 +12,82 @@ _TTL = 3600
|
||||||
# onbeperkt in het geheugen van het proces.
|
# onbeperkt in het geheugen van het proces.
|
||||||
_MAX_LINES = 2000
|
_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():
|
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
|
cutoff = time.time() - _TTL
|
||||||
with _lock:
|
with _lock:
|
||||||
stale = [jid for jid, j in _jobs.items()
|
stale = [jid for jid, j in _jobs.items()
|
||||||
|
|
@ -65,6 +139,8 @@ def finish(jid: str, status="done"):
|
||||||
with _lock:
|
with _lock:
|
||||||
if jid in _jobs:
|
if jid in _jobs:
|
||||||
_jobs[jid]["status"] = status
|
_jobs[jid]["status"] = status
|
||||||
|
_bewaar(jid)
|
||||||
|
_opruimen_schijf()
|
||||||
|
|
||||||
|
|
||||||
def done(q):
|
def done(q):
|
||||||
|
|
@ -75,7 +151,15 @@ def done(q):
|
||||||
def stream(jid: str, offset=0) -> dict:
|
def stream(jid: str, offset=0) -> dict:
|
||||||
job = _jobs.get(jid)
|
job = _jobs.get(jid)
|
||||||
if not job:
|
if not job:
|
||||||
return {"lines": [], "status": "unknown"}
|
# 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()):
|
with _stream_locks.get(jid, threading.Lock()):
|
||||||
q = _queues.get(jid)
|
q = _queues.get(jid)
|
||||||
if q:
|
if q:
|
||||||
|
|
@ -94,6 +178,15 @@ def stream(jid: str, offset=0) -> dict:
|
||||||
"text": f"… {dropped + job.get('dropped', 0)} eerdere regels weggelaten"}]
|
"text": f"… {dropped + job.get('dropped', 0)} eerdere regels weggelaten"}]
|
||||||
+ job["lines"][-_MAX_LINES:])
|
+ job["lines"][-_MAX_LINES:])
|
||||||
job["dropped"] = dropped + job.get("dropped", 0)
|
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"],
|
return {"lines": job["lines"][offset:], "status": job["status"],
|
||||||
"progress": job.get("progress")}
|
"progress": job.get("progress")}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1895,6 +1895,30 @@ tailwind.config = {
|
||||||
</div>
|
</div>
|
||||||
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('new_network_hint')"></p>
|
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('new_network_hint')"></p>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label class="form-label" x-text="t('network')">Netwerk</label>
|
<label class="form-label" x-text="t('network')">Netwerk</label>
|
||||||
<select class="form-input" x-model="install.network" @change="pickIp()">
|
<select class="form-input" x-model="install.network" @change="pickIp()">
|
||||||
|
|
@ -3303,6 +3327,23 @@ function app() {
|
||||||
this.appNets = d.networks || [];
|
this.appNets = d.networks || [];
|
||||||
if (!this.newNetName) this.newNetName = d.suggested || '';
|
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) {
|
toggleAppNet(naam, aan) {
|
||||||
const lijst = new Set(this.install.app_networks || []);
|
const lijst = new Set(this.install.app_networks || []);
|
||||||
aan ? lijst.add(naam) : lijst.delete(naam);
|
aan ? lijst.add(naam) : lijst.delete(naam);
|
||||||
|
|
@ -3796,7 +3837,8 @@ function app() {
|
||||||
envVars: [], images: [], envOverrides: {}, imageTags: {},
|
envVars: [], images: [], envOverrides: {}, imageTags: {},
|
||||||
fields: [], values: {}, preview_files: {},
|
fields: [], values: {}, preview_files: {},
|
||||||
step: 0, tab: 'compose', editing: false, composeText: '', envText: '',
|
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: '' },
|
compatibility: { known: false, supported: true, architecture: '', reason: '' },
|
||||||
architecture_note: ''
|
architecture_note: ''
|
||||||
};
|
};
|
||||||
|
|
@ -3992,6 +4034,7 @@ function app() {
|
||||||
if (!d || !d.ok) return;
|
if (!d || !d.ok) return;
|
||||||
this.install.preview_files = d.preview_files || {};
|
this.install.preview_files = d.preview_files || {};
|
||||||
this.install.services = d.services || [];
|
this.install.services = d.services || [];
|
||||||
|
this.install.service_info = d.service_info || [];
|
||||||
this.install.composeText = this.bpPreview();
|
this.install.composeText = this.bpPreview();
|
||||||
this.install.envText = d.env_preview || '';
|
this.install.envText = d.env_preview || '';
|
||||||
// Een onderdeel dat je hebt uitgezet levert geen container meer op; zijn
|
// 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.
|
// Bij wijzigen is de bron nodig om opnieuw te kunnen renderen.
|
||||||
repo_id: d.repo_id || '',
|
repo_id: d.repo_id || '',
|
||||||
app_networks: d.app_networks || (d.connected ? [this.sharedNetwork] : []),
|
app_networks: d.app_networks || (d.connected ? [this.sharedNetwork] : []),
|
||||||
|
app_services: d.app_services || [], service_info: [],
|
||||||
};
|
};
|
||||||
await this.loadAppNets(stack);
|
await this.loadAppNets(stack);
|
||||||
this.fieldInfo = null;
|
this.fieldInfo = null;
|
||||||
|
|
@ -4067,6 +4111,7 @@ function app() {
|
||||||
ips: this.install.ips || {},
|
ips: this.install.ips || {},
|
||||||
connect_apps: this.install.connect_apps !== false,
|
connect_apps: this.install.connect_apps !== false,
|
||||||
app_networks: this.install.app_networks || [],
|
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; }
|
if (!d.job_id) { this.toast(d.msg || this.t('error'), 'er'); return; }
|
||||||
this.modal = null;
|
this.modal = null;
|
||||||
|
|
@ -4100,6 +4145,7 @@ function app() {
|
||||||
}
|
}
|
||||||
body.connect_apps = this.install.connect_apps !== false;
|
body.connect_apps = this.install.connect_apps !== false;
|
||||||
body.app_networks = this.install.app_networks || [];
|
body.app_networks = this.install.app_networks || [];
|
||||||
|
body.app_services = this.install.app_services || [];
|
||||||
if (this.install.format === 'boilerplate') body.values = this.install.values;
|
if (this.install.format === 'boilerplate') body.values = this.install.values;
|
||||||
else { body.env_overrides = this.install.envOverrides; body.image_tags = this.install.imageTags; }
|
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
|
// Heb je het resultaat in de laatste stap zelf bewerkt, dan telt jouw
|
||||||
|
|
|
||||||
|
|
@ -319,6 +319,9 @@
|
||||||
"step_secrets": "Access",
|
"step_secrets": "Access",
|
||||||
"step_review": "Review",
|
"step_review": "Review",
|
||||||
"address_available": "This address is free",
|
"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": "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.",
|
"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",
|
"check_availability": "Check availability",
|
||||||
|
|
|
||||||
|
|
@ -319,6 +319,9 @@
|
||||||
"step_secrets": "Toegang",
|
"step_secrets": "Toegang",
|
||||||
"step_review": "Controleren",
|
"step_review": "Controleren",
|
||||||
"address_available": "Dit adres is vrij",
|
"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": "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.",
|
"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",
|
"check_availability": "Beschikbaarheid controleren",
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ def env(tmp_path, monkeypatch):
|
||||||
monkeypatch.setenv("SU_AUDIT", str(data / "audit.db"))
|
monkeypatch.setenv("SU_AUDIT", str(data / "audit.db"))
|
||||||
monkeypatch.setenv("SU_GIT_CACHE", str(data / "git"))
|
monkeypatch.setenv("SU_GIT_CACHE", str(data / "git"))
|
||||||
monkeypatch.setenv("SU_SECRET", str(data / "secret.key"))
|
monkeypatch.setenv("SU_SECRET", str(data / "secret.key"))
|
||||||
|
monkeypatch.setenv("SU_JOBS", str(data / "jobs"))
|
||||||
monkeypatch.setenv("LIBRARY_DIR", str(lib))
|
monkeypatch.setenv("LIBRARY_DIR", str(lib))
|
||||||
monkeypatch.setenv("DATA_DIR", str(tmp_path / "appdata"))
|
monkeypatch.setenv("DATA_DIR", str(tmp_path / "appdata"))
|
||||||
monkeypatch.setenv("BACKUP_DIR", str(tmp_path / "backups"))
|
monkeypatch.setenv("BACKUP_DIR", str(tmp_path / "backups"))
|
||||||
|
|
|
||||||
|
|
@ -310,6 +310,25 @@ def test_mongodump_gebruikt_de_beheerder_uit_compose(env, monkeypatch):
|
||||||
assert "--username='app'" not in cmd[-1]
|
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):
|
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."""
|
||||||
|
|
|
||||||
|
|
@ -27,15 +27,40 @@ services:
|
||||||
|
|
||||||
# ── Gedeeld netwerk ──────────────────────────────────────────────────────────
|
# ── 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
|
"""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"))
|
doc = yaml.safe_load(ct.add_shared_network(BASIS, "serverup"))
|
||||||
for naam, svc in doc["services"].items():
|
assert "serverup" in doc["services"]["web"]["networks"]
|
||||||
assert "serverup" in svc["networks"], naam
|
assert "networks" not in doc["services"]["db"]
|
||||||
assert doc["networks"]["serverup"] == {"external": True}
|
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():
|
def test_gedeeld_netwerk_naast_een_eigen_ip():
|
||||||
"""Een stack met een macvlan-adres moet het gedeelde netwerk erbij krijgen,
|
"""Een stack met een macvlan-adres moet het gedeelde netwerk erbij krijgen,
|
||||||
niet in plaats daarvan."""
|
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)}
|
stacks = {s["dir"]: s for s in git._scan_compose_dirs(sjablonen, lib)}
|
||||||
assert stacks["radarr"]["instances"] == []
|
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
91
tests/test_jobs.py
Normal 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"
|
||||||
Loading…
Reference in a new issue