Compare commits

..

4 commits

Author SHA1 Message Date
Ramon
0bcbe25646 release: v0.8.30
Some checks failed
Deploy server-up (dev) / deploy (push) Has been cancelled
Release / release (push) Successful in 3s
Deploy server-up (prod) / deploy (push) Has been cancelled
Je ziet nu waarom een app niet start: de logs en healthcheck-uitvoer van de
containers die niet opkwamen belanden in hetzelfde joblog, het wijzigscherm
meldt een bijgewerkt sjabloon, en de lege map die Docker achterlaat waar een
bestand hoort wordt opgeruimd in plaats van het probleem in stand te houden.

Bevat ook de vier nieuwe verbindingsapps en de soepelere updater-adressen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
2026-08-07 15:43:37 +02:00
Ramon
ec49337167 feat(apps): WireGuard, Tailscale, Cloudflare Tunnel en Proton Mail Bridge
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 3m18s
De catalogus had niets om een machine elders bij je eigen netwerk te laten
komen, en niets om Proton Mail bruikbaar te maken voor apps die geen Proton
spreken.

- wireguard: client (eigen wg0.conf in wg_confs) of server, met NET_ADMIN,
  SYS_MODULE, de kernelmodules van de host en src_valid_mark.
- tailscale: network_mode host zodat de tunnel voor de hele machine geldt,
  met bewaarde staat en optionele subnetroutes.
- cloudflared: publiceert zelf geen poorten en werkt zichzelf niet bij.
- protonmail-bridge: SMTP op 1026 zodat hij naast Mailpit past; het sjabloon
  legt de eenmalige aanmelding via de CLI uit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
2026-08-07 15:29:47 +02:00
Ramon
5068997599 fix(updates): neem het webadres van je releases ook aan
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 4m23s
Het adres uit de adresbalk van Forgejo of GitHub geeft HTML terug, en de
enige melding was een JSONDecodeError. Dat adres wordt nu omgerekend naar
/api/v1/repos/...; komt er toch HTML, dan zegt de melding wat er mis is.
Een https://-schema voor het imageveld wordt weggehaald, want een
image-adres heeft er nooit een.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
2026-08-07 15:13:53 +02:00
Ramon
b4a41afc1c chore: dev terug op een betanummer (0.8.30-beta)
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 4m35s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
2026-08-07 15:05:10 +02:00
23 changed files with 1125 additions and 10 deletions

View file

@ -1,3 +1,63 @@
# v0.8.30 — Je ziet nu waarom een app niet start
Een app die niet opkwam meldde alleen `dependency failed to start: container X
is unhealthy`. Dat is een gevolg, geen oorzaak: de echte reden stond in de log
van díé container, en daar kwam je alleen met `docker logs` — als je wist dat
dat kon. Op een Raspberry Pi bleef een MongoDB die stopt met `Illegal
instruction` daardoor dagenlang een raadsel.
- **De reden staat in hetzelfde joblog.** Mislukt het starten, dan haalt Server
Up van elke container die niet draait of ongezond is de uitvoer van de
healthcheck en de laatste logregels op en zet die eronder. Bij starten,
bijwerken, installeren en instellingen wijzigen.
- **De lege map die Docker achterlaat, wordt opgeruimd.** Wijst een bind-mount
naar een bestand dat nog niet in de stackmap staat, dan maakt Docker er een
map van. De container stopt daarna met `exit 126` (Is a directory), en het
opnieuw renderen struikelde over diezelfde map — waarmee het probleem
zichzelf in stand hield. Een lege map wordt nu opgeruimd; staat er iets in,
dan krijg je te horen wat er aan de hand is.
- **UniFi's reparatiecontainer zegt wat er mis is** als zijn script ontbreekt,
in plaats van een kaal exit-nummer.
- **Een bijgewerkt sjabloon valt op.** Opnieuw starten rendert een app niet
opnieuw, dus een verbeterd sjabloon bereikte een bestaande installatie nooit.
Het wijzigscherm laat nu zien welke images het sjabloon inmiddels gebruikt en
wat jouw app draait — een eigen versiekeuze telt daarbij niet als verschil.
Vier nieuwe apps voor iets wat de catalogus nog niet had: een machine ergens
anders bij je eigen netwerk laten komen, en Proton Mail bruikbaar maken voor
apps die geen Proton spreken.
- **WireGuard.** Verbind deze machine met een ander netwerk door je eigen
`wg0.conf` in de map `wg_confs` te zetten, of laat hem zelf een server met
clientprofielen aanmaken. Krijgt de rechten die een tunnel nodig heeft:
`NET_ADMIN`, de kernelmodules van de host en `src_valid_mark`.
- **Tailscale.** Zet de machine in je eigen tailnet, met `network_mode: host`
zodat de tunnel voor de hele machine geldt. Optioneel als subnetrouter, zodat
je bij je hele thuisnetwerk kunt.
- **Cloudflare Tunnel.** Een app van buitenaf bereikbaar zonder een poort open
te zetten; publiceert zelf niets, want dat is het hele punt.
- **Proton Mail Bridge.** Vertaalt je Proton-account naar gewone IMAP en SMTP.
Staat op poort 1026 in plaats van 1025, zodat hij naast Mailpit past. Het
sjabloon vertelt hoe je de eenmalige aanmelding doet — die kan niet via een
formulier, want Proton vraagt om een tweede factor.
En de updater is niet meer kieskeurig over het adres dat je invult.
Wie in Forgejo naar zijn releases keek en dat adres in de instellingen plakte,
kreeg een webpagina in plaats van JSON — en als enige melding
`JSONDecodeError: Expecting value: line 1 column 1 (char 0)`. Daar leidt
niemand uit af dat er `/api/v1/repos/` in het adres had moeten staan.
- **Het adres wordt omgerekend.** `https://git.example.nl/bes-r/server-up` en
`.../releases` worden allebei het juiste API-adres; GitHub krijgt zijn eigen
`api.github.com`. Een adres dat al klopt of dat we niet herkennen blijft staan.
- **Een begrijpelijke melding** als er tóch HTML terugkomt, met wat er dan wél
in het veld hoort.
- **`https://` voor het imageveld wordt weggehaald.** Een image-adres heeft
nooit een schema, dus `docker pull https://…` faalde op iets wat er in het
veld goed uitzag.
# v0.8.29 — UniFi start weer op een Raspberry Pi 4 # v0.8.29 — UniFi start weer op een Raspberry Pi 4
Het sjabloon pinde MongoDB op 4.4.29 "voor de Pi", maar juist vanaf 4.4.19 eist Het sjabloon pinde MongoDB op 4.4.29 "voor de Pi", maar juist vanaf 4.4.19 eist

View file

@ -1 +1 @@
0.8.29 0.8.30

View file

@ -0,0 +1,12 @@
# De tunnel belt zelf naar buiten naar Cloudflare, dus er hoeft geen poort open
# in je router en je publieke IP blijft onzichtbaar. Wat er door de tunnel
# bereikbaar is, bepaal je in het Cloudflare-dashboard bij de tunnel zelf.
services:
<< service_name >>:
image: cloudflare/cloudflared:latest
container_name: << service_name >>
command: tunnel --no-autoupdate run --token << tunnel_token >>
environment:
- TZ=<< timezone >>
- TUNNEL_METRICS=0.0.0.0:2000
restart: unless-stopped

View file

@ -0,0 +1,31 @@
{
"kind": "compose",
"metadata": {
"name": "Cloudflare Tunnel",
"description": "Maak een app van buitenaf bereikbaar zonder een poort open te zetten. De tunnel belt zelf naar Cloudflare; je publieke IP blijft onzichtbaar en er komt niets ongevraagd binnen.",
"tags": ["tunnel", "proxy", "op afstand", "netwerk", "cloudflare"],
"icon": {"provider": "selfhst", "id": "cloudflare"},
"version": {"name": "latest"},
"categories": ["netwerk", "beveiliging"],
"architectures": ["amd64", "arm64"]
},
"variables": [
{
"title": "Algemeen",
"items": [
{"name": "service_name", "type": "str", "title": "Servicenaam",
"default": "cloudflared", "required": true},
{"name": "timezone", "type": "str", "title": "Tijdzone",
"default": "Europe/Amsterdam", "required": true}
]
},
{
"title": "Toegang",
"items": [
{"name": "tunnel_token", "type": "str", "title": "Tunneltoken",
"default": "", "required": true, "secret": true, "eigen_invoer": true,
"help": "Maak in het Zero Trust-dashboard van Cloudflare een tunnel aan; bij 'Docker' staat het token in het voorbeeldcommando. Koppel daar ook welke app op welk adres uitkomt."}
]
}
]
}

View file

@ -0,0 +1,24 @@
# De bridge vertaalt je Proton Mail-account naar gewone IMAP en SMTP, zodat
# apps die geen Proton spreken er toch bij kunnen.
#
# Eenmalig aanmelden gebeurt in de container zelf; dat kan niet via een
# formulier omdat Proton om een tweede factor kan vragen:
#
# docker exec -it << service_name >> /protonmail/bridge --cli
# login — je Proton-account, inclusief tweede factor
# info — toont de gebruikersnaam en het wachtwoord voor je mailclient
#
# Let op: het wachtwoord dat 'info' toont is een bridge-wachtwoord, niet je
# Proton-wachtwoord. Dat vul je in bij de app die mail moet versturen.
services:
<< service_name >>:
image: shenxn/protonmail-bridge:latest
container_name: << service_name >>
environment:
- TZ=<< timezone >>
volumes:
- << appdata_dir >>/<< service_name >>:/root
ports:
- "<< port_imap >>:143"
- "<< port_smtp >>:25"
restart: unless-stopped

View file

@ -0,0 +1,75 @@
{
"kind": "compose",
"metadata": {
"name": "Proton Mail Bridge",
"description": "Vertaalt je Proton Mail-account naar gewone IMAP en SMTP, zodat apps die geen Proton spreken toch mail kunnen lezen en versturen. Denk aan meldingen uit Uptime Kuma, Vaultwarden of Paperless.",
"tags": [
"mail",
"smtp",
"imap",
"proton",
"meldingen"
],
"icon": {
"provider": "selfhst",
"id": "proton-mail"
},
"version": {
"name": "latest"
},
"categories": [
"communicatie",
"netwerk"
],
"architectures": [
"amd64",
"arm64"
],
"architecture_note": "Aanmelden gebeurt eenmalig in de container zelf, omdat Proton om een tweede factor kan vragen: docker exec -it protonmail-bridge /protonmail/bridge --cli, dan 'login' en daarna 'info' voor de gegevens voor je mailclient."
},
"variables": [
{
"title": "Algemeen",
"items": [
{
"name": "service_name",
"type": "str",
"title": "Servicenaam",
"default": "protonmail-bridge",
"required": true
},
{
"name": "port_smtp",
"type": "int",
"title": "SMTP-poort",
"default": 1026,
"required": true,
"description": "Hierop laat je andere apps hun mail aanbieden. Standaard 1026 en niet 1025, omdat Mailpit die al gebruikt en je ze prima naast elkaar kunt draaien."
},
{
"name": "port_imap",
"type": "int",
"title": "IMAP-poort",
"default": 1143,
"required": true,
"description": "Alleen nodig als een app je mail moet kunnen lézen."
},
{
"name": "appdata_dir",
"type": "str",
"title": "Appdata-map",
"default": "/opt/serverup/appdata",
"required": true,
"description": "Hier blijft de aanmelding staan, zodat je na een herstart niet opnieuw hoeft in te loggen."
},
{
"name": "timezone",
"type": "str",
"title": "Tijdzone",
"default": "Europe/Amsterdam",
"required": true
}
]
}
]
}

View file

@ -0,0 +1,23 @@
# Tailscale bouwt zijn eigen netwerk over bestaande verbindingen heen: geen
# poort open in je router, geen vaste adressen. Deze container brengt de hele
# host in dat netwerk als je hem als subnetrouter laat draaien.
services:
<< service_name >>:
image: tailscale/tailscale:latest
container_name: << service_name >>
hostname: << machine_naam >>
environment:
- TS_AUTHKEY=<< auth_key >>
- TS_STATE_DIR=/var/lib/tailscale
- TS_EXTRA_ARGS=<< extra_args >>
- TS_ROUTES=<< routes >>
- TS_ACCEPT_DNS=<< accept_dns >>
- TS_USERSPACE=false
volumes:
- << appdata_dir >>/<< service_name >>:/var/lib/tailscale
- /dev/net/tun:/dev/net/tun
cap_add:
- NET_ADMIN
- NET_RAW
network_mode: host
restart: unless-stopped

View file

@ -0,0 +1,50 @@
{
"kind": "compose",
"metadata": {
"name": "Tailscale",
"description": "Zet deze machine in je eigen Tailscale-netwerk, zodat je er vanaf elke locatie bij kunt zonder poorten open te zetten. Als subnetrouter maakt hij ook de rest van je thuisnetwerk bereikbaar.",
"tags": ["vpn", "wireguard", "op afstand", "netwerk", "mesh"],
"icon": {"provider": "selfhst", "id": "tailscale"},
"version": {"name": "latest"},
"categories": ["netwerk", "beveiliging"],
"architectures": ["amd64", "arm64"],
"architecture_note": "Draait met network_mode: host, zodat de tunnel voor de hele machine geldt en niet alleen voor deze container."
},
"variables": [
{
"title": "Algemeen",
"items": [
{"name": "service_name", "type": "str", "title": "Servicenaam",
"default": "tailscale", "required": true},
{"name": "machine_naam", "type": "str", "title": "Naam in je netwerk",
"default": "server-up", "required": true,
"description": "Onder deze naam verschijnt de machine in je tailnet."},
{"name": "appdata_dir", "type": "str", "title": "Appdata-map",
"default": "/opt/serverup/appdata", "required": true,
"description": "Hier blijft de aanmelding staan, zodat je na een herstart niet opnieuw hoeft te koppelen."}
]
},
{
"title": "Toegang",
"items": [
{"name": "auth_key", "type": "str", "title": "Auth key",
"default": "", "required": true, "secret": true, "eigen_invoer": true,
"help": "Maak er een aan in de Tailscale-console onder Settings → Keys. Een reusable key is handig als je meerdere machines aansluit."}
]
},
{
"title": "Netwerk",
"items": [
{"name": "routes", "type": "str", "title": "Netwerken achter deze machine",
"default": "", "required": false,
"description": "Bijvoorbeeld 10.0.20.0/24 om je hele thuisnetwerk bereikbaar te maken. Keur de route daarna goed in de Tailscale-console. Leeg = alleen deze machine."},
{"name": "accept_dns", "type": "str", "title": "DNS van Tailscale gebruiken",
"default": "false", "required": true, "advanced": true,
"description": "'true' laat deze machine de DNS-instellingen uit je tailnet volgen — handig voor split-DNS naar je eigen domein, maar het overschrijft de resolver van de host."},
{"name": "extra_args", "type": "str", "title": "Extra opties",
"default": "--accept-routes", "required": false, "advanced": true,
"description": "Wordt meegegeven aan het commando 'tailscale up'."}
]
}
]
}

View file

@ -80,7 +80,21 @@ services:
# Dezelfde map als UniFi zelf: hierin staat system.properties, dat het # Dezelfde map als UniFi zelf: hierin staat system.properties, dat het
# image alleen bij de allereerste start schrijft. # image alleen bij de allereerste start schrijft.
- << appdata_dir >>/<< service_name >>/config:/unifi-config - << appdata_dir >>/<< service_name >>/config:/unifi-config
entrypoint: ["/bin/bash", "/init-mongo.sh"] # Eerst kijken óf het script er is. Ontbreekt het in de stackmap, dan mount
# Docker er een lege map overheen en stopt de container met "exit 126" —
# een getal waar niemand iets aan heeft. Nu staat er wat je eraan doet.
entrypoint:
- /bin/bash
- -c
- |
if [ ! -f /init-mongo.sh ]; then
echo "FOUT: /init-mongo.sh ontbreekt in de stackmap van deze app."
echo "Docker heeft er daarom een lege map van gemaakt."
echo "Herstel het met: Instellingen wijzigen -> opslaan, dan wordt"
echo "het sjabloon opnieuw uitgeschreven."
exit 1
fi
exec bash /init-mongo.sh
depends_on: depends_on:
<< service_name >>-mongo: << service_name >>-mongo:
condition: service_healthy condition: service_healthy

View file

@ -0,0 +1,29 @@
# Twee rollen in één image. Zet je eigen `wg0.conf` in de map wg_confs, dan
# verbindt deze container met dát netwerk (client). Laat je die map leeg en vul
# je de servervelden in, dan maakt hij zelf een server met clientprofielen aan.
services:
<< service_name >>:
image: lscr.io/linuxserver/wireguard:latest
container_name: << service_name >>
environment:
- PUID=<< puid >>
- PGID=<< pgid >>
- TZ=<< timezone >>
# Alleen voor de serverrol; bij een eigen wg0.conf doen ze niets.
- SERVERURL=<< server_url >>
- SERVERPORT=<< port >>
- PEERS=<< peers >>
- PEERDNS=<< peer_dns >>
- INTERNAL_SUBNET=<< internal_subnet >>
- ALLOWEDIPS=<< allowed_ips >>
volumes:
- << appdata_dir >>/<< service_name >>:/config
- /lib/modules:/lib/modules:ro
ports:
- "<< port >>:51820/udp"
cap_add:
- NET_ADMIN
- SYS_MODULE
sysctls:
- net.ipv4.conf.all.src_valid_mark=1
restart: unless-stopped

View file

@ -0,0 +1,138 @@
{
"kind": "compose",
"metadata": {
"name": "WireGuard",
"description": "Verbind deze machine met een ander netwerk via WireGuard, of draai er zelf een VPN-server mee. Zet je eigen wg0.conf in de configuratiemap voor de clientrol; vul de servervelden in om profielen te laten aanmaken.",
"tags": [
"vpn",
"wireguard",
"tunnel",
"netwerk",
"op afstand"
],
"icon": {
"provider": "selfhst",
"id": "wireguard"
},
"version": {
"name": "latest"
},
"categories": [
"netwerk",
"beveiliging"
],
"architectures": [
"amd64",
"arm64"
],
"architecture_note": "De kernelmodule komt van de host. Op een Raspberry Pi met een recente 64-bits kernel zit WireGuard er standaard in; op oudere kernels moet je hem zelf installeren."
},
"variables": [
{
"title": "Algemeen",
"items": [
{
"name": "service_name",
"type": "str",
"title": "Servicenaam",
"default": "wireguard",
"required": true
},
{
"name": "port",
"type": "int",
"title": "WireGuard-poort (UDP)",
"default": 51820,
"required": true,
"description": "Alleen nodig als deze machine de server is. Als client verbind je naar buiten en hoeft er niets open."
},
{
"name": "appdata_dir",
"type": "str",
"title": "Appdata-map",
"default": "/opt/serverup/appdata",
"required": true,
"description": "Hierin komt de map wg_confs; zet daar je eigen wg0.conf neer om met een bestaand netwerk te verbinden."
},
{
"name": "timezone",
"type": "str",
"title": "Tijdzone",
"default": "Europe/Amsterdam",
"required": true
}
]
},
{
"title": "Gebruiker",
"items": [
{
"name": "puid",
"type": "int",
"title": "PUID",
"default": 1000,
"required": true,
"advanced": true
},
{
"name": "pgid",
"type": "int",
"title": "PGID",
"default": 1000,
"required": true,
"advanced": true
}
]
},
{
"title": "Als server",
"items": [
{
"name": "server_url",
"type": "str",
"title": "Publiek adres",
"default": "auto",
"required": true,
"description": "Het adres waarop clients deze server bereiken. 'auto' laat de container zijn eigen publieke IP opzoeken.",
"advanced": true
},
{
"name": "peers",
"type": "str",
"title": "Clientprofielen",
"default": "1",
"required": true,
"description": "Een aantal (bijvoorbeeld 3) of namen gescheiden door kommas (laptop,telefoon). De profielen en QR-codes komen in de appdata-map.",
"advanced": true
},
{
"name": "peer_dns",
"type": "str",
"title": "DNS voor clients",
"default": "auto",
"required": true,
"description": "Wijs dit naar je eigen DNS-server als je thuisnamen wilt kunnen opzoeken door de tunnel.",
"advanced": true
},
{
"name": "internal_subnet",
"type": "str",
"title": "Subnet van de tunnel",
"default": "10.13.13.0",
"required": true,
"description": "Mag niet botsen met een netwerk dat je al gebruikt.",
"advanced": true
},
{
"name": "allowed_ips",
"type": "str",
"title": "Netwerken door de tunnel",
"default": "0.0.0.0/0",
"required": true,
"description": "Wat clients via deze server bereiken. Alleen je thuisnetwerk? Zet er dan bijvoorbeeld 10.0.20.0/24 neer in plaats van al het verkeer.",
"advanced": true
}
]
}
]
}

View file

@ -762,6 +762,24 @@ def _ontbrekende_afhankelijkheden(meta: dict, bronnen: dict) -> list[dict]:
return uit return uit
def _log_waarom_mislukt(qq, pad: Path, naam: str):
"""Zet de reden van een mislukte start in hetzelfde joblog.
Compose zegt alleen `dependency failed to start: container X is unhealthy`.
De echte oorzaak staat in de log van díé container, en daar kwam je alleen
met `docker logs` als je wist dat dat kon. Zo staat hij er meteen bij.
"""
try:
regels = docker.waarom_mislukt(pad, name=naam)
except Exception:
return
if not regels:
return
jobs.log(qq, "warn", "Dit zeggen de containers die niet opkwamen:")
for regel in regels:
jobs.log(qq, "dim", regel)
def _auto_backup(qq, stack: str, config_key: str, reden: str): def _auto_backup(qq, stack: str, config_key: str, reden: str):
"""Backup vóór een actie die bestanden overschrijft of weggooit. """Backup vóór een actie die bestanden overschrijft of weggooit.
@ -878,6 +896,8 @@ def api_stack_action(name, action):
backups.prune(name, log_fn=lambda m: jobs.log(qq, "dim", m)) backups.prune(name, log_fn=lambda m: jobs.log(qq, "dim", m))
jobs.log(qq, "ok" if rc == 0 else "error", jobs.log(qq, "ok" if rc == 0 else "error",
f"{action} {'klaar' if rc == 0 else 'mislukt'}") f"{action} {'klaar' if rc == 0 else 'mislukt'}")
if rc != 0:
_log_waarom_mislukt(qq, pdir, name)
audit.log("stacks", action, "ok" if rc == 0 else "error", ref=name) audit.log("stacks", action, "ok" if rc == 0 else "error", ref=name)
jobs.finish(jid, "done" if rc == 0 else "error") jobs.finish(jid, "done" if rc == 0 else "error")
except Exception as e: except Exception as e:
@ -1114,6 +1134,10 @@ def api_stack_config(name):
# formulier ze leeg tonen en zou opslaan ze wissen. # formulier ze leeg tonen en zou opslaan ze wissen.
waarden = {**waarden, **_geheimen_uit_env(d, meta)} waarden = {**waarden, **_geheimen_uit_env(d, meta)}
return jsonify(ok=True, editable=True, return jsonify(ok=True, editable=True,
# Is het sjabloon veranderd sinds jij deze app uitrolde?
# Opnieuw starten rendert niet opnieuw, dus een verbeterd
# sjabloon bereikt je app pas als je hier op opslaan drukt.
image_drift=_sjabloon_image_verschil(d, meta, bron, waarden),
fields=_annotate_fields(boilerplates.fields(bron)), fields=_annotate_fields(boilerplates.fields(bron)),
values=waarden, values=waarden,
network=meta.get("network", ""), ip=meta.get("ip", ""), network=meta.get("network", ""), ip=meta.get("ip", ""),
@ -1131,6 +1155,45 @@ def api_stack_config(name):
source_repo=_sjabloon_herkomst(meta.get("repo_id", ""))) source_repo=_sjabloon_herkomst(meta.get("repo_id", "")))
def _sjabloon_image_verschil(d: Path, meta: dict, bron, waarden: dict) -> list[dict]:
"""Welke images draait deze app anders dan het sjabloon nu zegt?
Een app opnieuw starten rendert hem niet opnieuw: het compose-bestand in de
stackmap blijft staan zoals het ooit gerenderd is. Een sjabloon dat later
verbeterd wordt een databaseversie die op een Raspberry Pi wél draait,
bijvoorbeeld bereikt zo nooit een bestaande installatie, en aan niets is
te zien dat je op opslaan moet drukken.
Alleen de images vergelijken en niet het hele bestand: de rest van de
stackmap krijgt na het renderen nog netwerken, extra's en geheimen
overheen, en dan verschilt er altijd wel iets zonder dat het iets betekent.
"""
if not bron:
return []
try:
huidig = {i["service"]: i["image"] for i in
compose_transform.images_per_service(docker.read_compose(d))}
if not huidig:
return []
import tempfile as _tmp
with _tmp.TemporaryDirectory() as t:
boilerplates.render_to_dir(bron, Path(t), waarden)
verse = docker.read_compose(Path(t))
# De eigen versiekeuze hoort er overheen; anders meldt hij een verschil
# dat de gebruiker zelf heeft ingesteld.
tags = meta.get("image_tags") or {}
if tags:
verse = compose_transform.zet_image_tags(verse, tags)
nieuw = {i["service"]: i["image"] for i in
compose_transform.images_per_service(verse)}
except Exception:
return []
return [{"service": s, "nu": huidig[s], "sjabloon": nieuw[s]}
for s in sorted(nieuw)
if s in huidig and huidig[s] != nieuw[s]]
def _sjabloon_herkomst(rid: str) -> dict: def _sjabloon_herkomst(rid: str) -> dict:
"""Repo, branch en stand van de cache waar een sjabloon uit komt.""" """Repo, branch en stand van de cache waar een sjabloon uit komt."""
if not rid: if not rid:
@ -1354,6 +1417,8 @@ def api_stack_reconfigure(name):
jobs.log(qq, "ok" if rc == 0 else "error", jobs.log(qq, "ok" if rc == 0 else "error",
"Herstart met de nieuwe instellingen" if rc == 0 "Herstart met de nieuwe instellingen" if rc == 0
else "Starten mislukt \u2014 zet de backup terug via Backups") else "Starten mislukt \u2014 zet de backup terug via Backups")
if rc != 0:
_log_waarom_mislukt(qq, d, name)
audit.log("stacks", "reconfigure", "ok" if rc == 0 else "error", audit.log("stacks", "reconfigure", "ok" if rc == 0 else "error",
ref=name, ip=ip) ref=name, ip=ip)
jobs.finish(jid, "done" if rc == 0 else "error") jobs.finish(jid, "done" if rc == 0 else "error")
@ -2486,6 +2551,8 @@ def api_store_install():
rc = docker.compose_up(dest, log_fn=lambda m: jobs.log(qq, "dim", m), name=inst) rc = docker.compose_up(dest, log_fn=lambda m: jobs.log(qq, "dim", m), name=inst)
jobs.log(qq, "ok" if rc == 0 else "warn", jobs.log(qq, "ok" if rc == 0 else "warn",
"Gestart" if rc == 0 else "Start mislukt") "Gestart" if rc == 0 else "Start mislukt")
if rc != 0:
_log_waarom_mislukt(qq, dest, inst)
audit.log("store", "install", "ok", ref=inst, audit.log("store", "install", "ok", ref=inst,
detail={"env": list(env_overrides.keys()), "tags": image_tags}) detail={"env": list(env_overrides.keys()), "tags": image_tags})
jobs.finish(jid, "done") jobs.finish(jid, "done")

View file

@ -494,6 +494,18 @@ def render_to_dir(src: Path, dest: Path, values: dict[str, Any]) -> list[str]:
rel = p.relative_to(files_dir) rel = p.relative_to(files_dir)
out = dest / rel out = dest / rel
out.parent.mkdir(parents=True, exist_ok=True) out.parent.mkdir(parents=True, exist_ok=True)
# Docker maakt een lege map aan waar een compose-bestand een bestand
# mount dat nog niet bestaat. Daarna faalt de container met "exit 126"
# (Is a directory) en blijft dat zo, want het renderen struikelt hier
# over diezelfde map. Opruimen is veilig: hij is per definitie leeg, en
# wat erin hoort staat hier in de bron.
if out.is_dir() and not out.is_symlink():
try:
out.rmdir()
except OSError as e:
raise BoilerplateError(
f"{rel} is een map en geen bestand; leeg of verwijder "
f"{out} en probeer opnieuw") from e
if _looks_textual(p): if _looks_textual(p):
try: try:
txt = p.read_text(encoding="utf-8") txt = p.read_text(encoding="utf-8")

View file

@ -307,6 +307,42 @@ def compose_up(d: Path, log_fn=None, name=None) -> int:
return _stream(cmd, cwd=str(d), log_fn=log_fn) return _stream(cmd, cwd=str(d), log_fn=log_fn)
def waarom_mislukt(d: Path, name=None, regels: int = 12) -> list[str]:
"""Waarom kwam deze stack niet op? De reden staat in de containerlogs.
Compose meldt alleen `dependency failed to start: container X is unhealthy`.
Dát is geen oorzaak maar een gevolg, en wie de echte reden wil weten moet
zelf `docker logs` gaan draaien als hij weet dát dat kan. Een container
die op een Raspberry Pi stopt met "Illegal instruction" bleef zo dagen een
raadsel. Deze functie haalt van elke container die niet draait of ongezond
is de laatste regels op, zodat ze in hetzelfde joblog belanden.
"""
uit: list[str] = []
for c in compose_ps(d, name=name):
naam = c.get("name") or ""
status = (c.get("status") or "").lower()
if not naam:
continue
if c.get("running") and "unhealthy" not in status:
continue
# Wat zei de healthcheck zelf? Vaak staat daar de kern al in.
r = _run([_bin(), "inspect", "-f",
"{{if .State.Health}}{{range .State.Health.Log}}{{.Output}}{{end}}{{end}}",
naam], timeout=15)
gezondheid = (r.stdout or "").strip().splitlines()
log = container_logs(naam, tail=regels).strip().splitlines()
if not gezondheid and not log:
continue
uit.append(f"── {naam}: {c.get('status') or 'gestopt'}")
for regel in gezondheid[-3:]:
if regel.strip():
uit.append(f" healthcheck: {regel.strip()[:200]}")
for regel in log[-regels:]:
if regel.strip():
uit.append(f" {regel.strip()[:200]}")
return uit
def compose_down(d: Path, log_fn=None, volumes=False, name=None) -> int: def compose_down(d: Path, log_fn=None, volumes=False, name=None) -> int:
f = find_compose(d) f = find_compose(d)
if not f: if not f:

View file

@ -82,6 +82,15 @@ def image_repo(info: dict | None = None) -> str:
""" """
expliciet = (cfg.load().get("UPDATE_IMAGE") or "").strip() expliciet = (cfg.load().get("UPDATE_IMAGE") or "").strip()
if expliciet: if expliciet:
# Een image-adres heeft nooit een schema. Wie het uit zijn adresbalk
# plakt neemt `https://` mee, en dan faalt `docker pull` op iets wat er
# in het veld goed uitziet. Weghalen is hier eenduidig: er bestaat geen
# geldig image dat zo begint.
for schema in ("https://", "http://"):
if expliciet.lower().startswith(schema):
expliciet = expliciet[len(schema):]
break
expliciet = expliciet.strip("/")
return expliciet.rsplit(":", 1)[0] if _has_tag(expliciet) else expliciet return expliciet.rsplit(":", 1)[0] if _has_tag(expliciet) else expliciet
img = (info or inspect_self()).get("image", "") img = (info or inspect_self()).get("image", "")
if not img: if not img:

View file

@ -78,11 +78,46 @@ def image_tag(release_tag: str) -> str:
return tag[1:] if tag.startswith("v") and _parse(tag) else tag return tag[1:] if tag.startswith("v") and _parse(tag) else tag
def api_url(url: str) -> str:
"""Maak van een geplakt adres het API-adres.
Wie in Forgejo of GitHub naar zijn releases kijkt en dat adres uit de
adresbalk plakt, geeft het adres van een wébpagina op. Dat levert HTML op,
en de enige melding die je daarna kreeg was `JSONDecodeError: Expecting
value: line 1 column 1` waaruit niemand opmaakt dat er `/api/v1/` had
moeten staan. Dus rekenen we het om in plaats van erover te vallen.
https://git.voorbeeld.nl/bes-r/server-up/releases
https://git.voorbeeld.nl/api/v1/repos/bes-r/server-up/releases
https://github.com/bes-r/server-up/releases
https://api.github.com/repos/bes-r/server-up/releases
"""
schoon = (url or "").strip().rstrip("/")
if not schoon or "/api/" in schoon:
return schoon
m = re.match(r"^(https?)://([^/]+)/([^/]+)/([^/]+?)(?:/releases.*|\.git)?$",
schoon)
if not m:
return schoon
schema, host, eigenaar, repo = m.groups()
if host.lower() in ("github.com", "www.github.com"):
return f"https://api.github.com/repos/{eigenaar}/{repo}/releases"
return f"{schema}://{host}/api/v1/repos/{eigenaar}/{repo}/releases"
def _releases(url: str) -> list[dict]: def _releases(url: str) -> list[dict]:
req = urllib.request.Request( req = urllib.request.Request(
url, headers={"Accept": "application/json", "User-Agent": "server-up"}) url, headers={"Accept": "application/json", "User-Agent": "server-up"})
with urllib.request.urlopen(req, timeout=8) as r: with urllib.request.urlopen(req, timeout=8) as r:
data = json.loads(r.read().decode("utf-8")) rauw = r.read().decode("utf-8", "replace")
try:
data = json.loads(rauw)
except ValueError:
# Bijna altijd een webpagina in plaats van de API. Zeg dát, in plaats
# van de gebruiker met een JSON-fout te laten zitten.
raise ValueError(
f"{url} geeft geen JSON terug maar een webpagina. Vul het "
"API-adres in, met /api/v1/repos/ erin.") from None
return data if isinstance(data, list) else [data] return data if isinstance(data, list) else [data]
@ -98,7 +133,7 @@ def check(current: str, force: bool = False) -> dict:
return dict(_CACHE) return dict(_CACHE)
c = cfg.load() c = cfg.load()
url = (c.get("UPDATE_API_URL") or "").strip() url = api_url(c.get("UPDATE_API_URL") or "")
ch = channel() ch = channel()
res = {"enabled": bool(url), "current": current, "channel": ch, res = {"enabled": bool(url), "current": current, "channel": ch,
"current_is_prerelease": is_prerelease(current), "current_is_prerelease": is_prerelease(current),

View file

@ -1776,6 +1776,25 @@ tailwind.config = {
<span x-text="t('reconfigure_warning')"></span> <span x-text="t('reconfigure_warning')"></span>
</p> </p>
<!-- Het sjabloon is veranderd sinds deze app is uitgerold. Opnieuw
starten rendert niet opnieuw, dus zonder deze melding blijf je op de
oude versie zitten zonder te weten dat er een betere klaarstaat. -->
<div x-show="(install.image_drift || []).length"
class="text-[11px] rounded-xl border border-ac/40 bg-ac/5 px-3 py-2 flex items-start gap-2">
<span class="mdi mdi-package-up text-ac dark:text-ac-dark shrink-0"></span>
<span class="min-w-0">
<span x-text="t('image_drift')"></span>
<template x-for="v in (install.image_drift || [])" :key="v.service">
<span class="block font-mono text-[10px] mt-0.5">
<span x-text="v.service"></span>:
<span class="line-through opacity-70" x-text="v.nu"></span>
<span class="mdi mdi-arrow-right"></span>
<span class="text-ac dark:text-ac-dark" x-text="v.sjabloon"></span>
</span>
</template>
</span>
</div>
<!-- Waar het sjabloon vandaan komt. Opslaan rendert de app opnieuw uit <!-- Waar het sjabloon vandaan komt. Opslaan rendert de app opnieuw uit
déze cache: loopt die achter, dan trek je een oude versie over een déze cache: loopt die achter, dan trek je een oude versie over een
werkende stack heen zonder dat iets dat zegt. --> werkende stack heen zonder dat iets dat zegt. -->
@ -4013,6 +4032,7 @@ function app() {
app_services: [], depends_on: [], app_services: [], depends_on: [],
extras: { volumes: [], devices: [] }, extras: { volumes: [], devices: [] },
image_tags: {}, image_tags: {},
image_drift: [],
compatibility: { known: false, supported: true, architecture: '', reason: '' }, compatibility: { known: false, supported: true, architecture: '', reason: '' },
architecture_note: '' architecture_note: ''
}; };
@ -4271,6 +4291,7 @@ function app() {
extras: { volumes: (d.extras||{}).volumes || [], extras: { volumes: (d.extras||{}).volumes || [],
devices: (d.extras||{}).devices || [] }, devices: (d.extras||{}).devices || [] },
image_tags: { ...(d.image_tags || {}) }, image_tags: { ...(d.image_tags || {}) },
image_drift: d.image_drift || [],
}; };
await this.loadAppNets(stack); await this.loadAppNets(stack);
this.fieldInfo = null; this.fieldInfo = null;

View file

@ -138,7 +138,7 @@
"release_notes": "Release notes", "release_notes": "Release notes",
"view_release": "View release", "view_release": "View release",
"update_api_url": "Releases API URL", "update_api_url": "Releases API URL",
"update_api_hint": "Forgejo/GitHub Releases API returning the latest release. Empty = disabled.", "update_api_hint": "Address of your releases. You may paste the address from your browser; Server Up converts it to the API address with /api/v1/repos/ itself. Empty = disabled.",
"include_prerelease": "Include pre-releases (beta)", "include_prerelease": "Include pre-releases (beta)",
"login": "Sign in", "login": "Sign in",
"login_failed": "Sign-in failed", "login_failed": "Sign-in failed",
@ -501,5 +501,6 @@
"remove_keep_data_hint": "Stops the containers and removes the app configuration. App data and named volumes are preserved.", "remove_keep_data_hint": "Stops the containers and removes the app configuration. App data and named volumes are preserved.",
"remove_all_hint": "Also removes app data and non-external named volumes. This cannot be undone.", "remove_all_hint": "Also removes app data and non-external named volumes. This cannot be undone.",
"remove_done": "App removed", "remove_done": "App removed",
"remove_failed": "Failed to remove app" "remove_failed": "Failed to remove app",
"image_drift": "The template has been updated since you deployed this app. Saving adopts the new version; restarting does not."
} }

View file

@ -138,7 +138,7 @@
"release_notes": "Release notes", "release_notes": "Release notes",
"view_release": "Bekijk release", "view_release": "Bekijk release",
"update_api_url": "Releases-API URL", "update_api_url": "Releases-API URL",
"update_api_hint": "Forgejo/GitHub Releases-API die de laatste release teruggeeft. Leeg = uitgeschakeld.", "update_api_hint": "Adres van je releases. Je mag het adres uit de adresbalk plakken; Server Up rekent het zelf om naar het API-adres met /api/v1/repos/. Leeg = uitgeschakeld.",
"include_prerelease": "Pre-releases (beta) meenemen", "include_prerelease": "Pre-releases (beta) meenemen",
"login": "Inloggen", "login": "Inloggen",
"login_failed": "Inloggen mislukt", "login_failed": "Inloggen mislukt",
@ -501,5 +501,6 @@
"remove_keep_data_hint": "Stopt de containers en verwijdert de appconfiguratie. Appdata en named volumes blijven bewaard.", "remove_keep_data_hint": "Stopt de containers en verwijdert de appconfiguratie. Appdata en named volumes blijven bewaard.",
"remove_all_hint": "Verwijdert ook appdata en niet-externe named volumes. Dit kan niet ongedaan worden gemaakt.", "remove_all_hint": "Verwijdert ook appdata en niet-externe named volumes. Dit kan niet ongedaan worden gemaakt.",
"remove_done": "App verwijderd", "remove_done": "App verwijderd",
"remove_failed": "App verwijderen mislukt" "remove_failed": "App verwijderen mislukt",
"image_drift": "Het sjabloon is bijgewerkt sinds je deze app uitrolde. Opslaan neemt de nieuwe versie over; opnieuw starten doet dat niet."
} }

View file

@ -157,8 +157,8 @@ GEDEELDE_POORTEN = {
80: {"nginx-proxy-manager", "traefik", "caddy", "zoraxy", "pangolin"}, # reverse proxies 80: {"nginx-proxy-manager", "traefik", "caddy", "zoraxy", "pangolin"}, # reverse proxies
443: {"nginx-proxy-manager", "traefik", "caddy", "zoraxy", "pangolin"}, 443: {"nginx-proxy-manager", "traefik", "caddy", "zoraxy", "pangolin"},
53: {"adguard-home", "pihole"}, # dns-blokkers 53: {"adguard-home", "pihole"}, # dns-blokkers
# Twee manieren om een WireGuard-tunnel te draaien; je gebruikt er één. # Drie manieren om een WireGuard-eindpunt te draaien; je gebruikt er één.
51820: {"pangolin", "wg-easy"}, 51820: {"pangolin", "wg-easy", "wireguard"},
# Hieronder: botsingen die er al waren vóór deze testsuite. De # Hieronder: botsingen die er al waren vóór deze testsuite. De
# vrije-poortsuggestie bij installeren vangt ze op, dus ze zijn hinderlijk # vrije-poortsuggestie bij installeren vangt ze op, dus ze zijn hinderlijk
# maar niet kapot. Nieuwe apps horen hier niet bij te komen. # maar niet kapot. Nieuwe apps horen hier niet bij te komen.

119
tests/test_update_adres.py Normal file
View file

@ -0,0 +1,119 @@
"""Het adres van de releases-API en van het image, zoals mensen ze invullen.
Wie in Forgejo naar zijn releases kijkt en dat adres uit de adresbalk plakt,
geeft het adres van een webpagina op. De enige melding die daarop volgde was
`JSONDecodeError: Expecting value: line 1 column 1 (char 0)`, en daar leidt
niemand uit af dat er `/api/v1/repos/` in had moeten staan.
"""
import sys
from pathlib import Path
import pytest
WORTEL = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(WORTEL / "server-up"))
# ── Het releases-adres ───────────────────────────────────────────────────────
@pytest.mark.parametrize("ingevuld,verwacht", [
# Wat je uit de adresbalk van Forgejo plakt.
("https://git.voorbeeld.nl/bes-r/server-up/releases",
"https://git.voorbeeld.nl/api/v1/repos/bes-r/server-up/releases"),
# Zonder /releases erachter.
("https://git.voorbeeld.nl/bes-r/server-up",
"https://git.voorbeeld.nl/api/v1/repos/bes-r/server-up/releases"),
# Met poort, en http.
("http://10.0.20.22:3000/bes-r/server-up/releases",
"http://10.0.20.22:3000/api/v1/repos/bes-r/server-up/releases"),
# De kloon-URL.
("https://git.voorbeeld.nl/bes-r/server-up.git",
"https://git.voorbeeld.nl/api/v1/repos/bes-r/server-up/releases"),
# GitHub heeft zijn API op een eigen host.
("https://github.com/bes-r/server-up/releases",
"https://api.github.com/repos/bes-r/server-up/releases"),
])
def test_webadres_wordt_een_apiadres(env, ingevuld, verwacht):
from core import updater
assert updater.api_url(ingevuld) == verwacht
@pytest.mark.parametrize("al_goed", [
"https://git.voorbeeld.nl/api/v1/repos/bes-r/server-up/releases",
"https://api.github.com/repos/bes-r/server-up/releases",
])
def test_een_apiadres_blijft_ongemoeid(env, al_goed):
from core import updater
assert updater.api_url(al_goed) == al_goed
def test_leeg_blijft_leeg(env):
from core import updater
assert updater.api_url("") == ""
assert updater.api_url(" ") == ""
def test_onherkenbaar_adres_blijft_staan(env):
"""Niet slimmer doen dan we zijn: wat we niet herkennen laten we met rust,
zodat een eigen proxy-adres blijft werken."""
from core import updater
raar = "https://voorbeeld.nl/een/diep/pad/met/releases.json"
assert updater.api_url(raar) == raar
def test_html_geeft_een_begrijpelijke_fout(env, monkeypatch):
"""De melding moet zeggen wát er mis is, niet dat JSON niet parseert."""
from core import updater
class _Antwoord:
def read(self):
return b"<!DOCTYPE html>\n<html lang=\"en-US\">"
def __enter__(self):
return self
def __exit__(self, *a):
return False
monkeypatch.setattr(updater.urllib.request, "urlopen",
lambda *a, **k: _Antwoord())
with pytest.raises(ValueError) as fout:
updater._releases("https://git.voorbeeld.nl/bes-r/server-up/releases")
melding = str(fout.value)
assert "webpagina" in melding
assert "/api/v1/repos/" in melding
assert "JSONDecode" not in melding
def test_check_gebruikt_het_omgerekende_adres(env, monkeypatch):
from core import updater
env["core"].patch({"UPDATE_API_URL":
"https://git.voorbeeld.nl/bes-r/server-up/releases"})
gebruikt = []
monkeypatch.setattr(updater, "_releases",
lambda u: gebruikt.append(u) or [
{"tag_name": "v9.9.99", "prerelease": False}])
res = updater.check("0.8.28", force=True)
assert gebruikt == [
"https://git.voorbeeld.nl/api/v1/repos/bes-r/server-up/releases"]
assert res["update_available"] is True
# ── Het imageadres ───────────────────────────────────────────────────────────
def test_schema_voor_het_image_wordt_weggehaald(env):
"""`docker pull https://…` bestaat niet, maar in het veld ziet het er goed
uit. Er is geen geldig image dat met een schema begint, dus weghalen kan."""
from core import selfupdate
env["core"].patch({"UPDATE_IMAGE": "https://git.voorbeeld.nl/bes-r/server-up"})
assert selfupdate.image_repo({}) == "git.voorbeeld.nl/bes-r/server-up"
env["core"].patch({"UPDATE_IMAGE": "http://10.0.20.22:3000/bes-r/server-up/"})
assert selfupdate.image_repo({}) == "10.0.20.22:3000/bes-r/server-up"
def test_image_zonder_schema_blijft_gelijk(env):
from core import selfupdate
env["core"].patch({"UPDATE_IMAGE": "git.voorbeeld.nl/bes-r/server-up"})
assert selfupdate.image_repo({}) == "git.voorbeeld.nl/bes-r/server-up"
# Een tag hoort er niet in; die komt van de release.
env["core"].patch({"UPDATE_IMAGE": "git.voorbeeld.nl/bes-r/server-up:0.8.28"})
assert selfupdate.image_repo({}) == "git.voorbeeld.nl/bes-r/server-up"

View file

@ -0,0 +1,115 @@
"""De apps waarmee je een machine met een ander netwerk verbindt.
Deze vier hebben iets gemeen dat je in een formulier niet ziet: ze werken alleen
met rechten die een gewone container niet heeft (NET_ADMIN, /dev/net/tun), of ze
vragen een eenmalige handeling in de terminal. Zonder dat staat er een app die
start en niets doet.
"""
import json
import sys
import tempfile
from pathlib import Path
import pytest
import yaml
WORTEL = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(WORTEL / "server-up"))
from core import boilerplates as bp
VERBINDINGSAPPS = ["cloudflared", "protonmail-bridge", "tailscale", "wireguard"]
def _tpl(app: str) -> dict:
return json.loads((WORTEL / "apps" / app / "template.json").read_text("utf-8"))
def _render(app: str) -> dict:
pad = WORTEL / "apps" / app
velden = bp.fields(pad)
waarden = {f["name"]: f["default"] for f in velden if f.get("default") is not None}
for f in velden:
if f.get("required") and not waarden.get(f["name"]):
waarden[f["name"]] = "testwaarde12345678901234567890ab"
with tempfile.TemporaryDirectory() as t:
bp.render_to_dir(pad, Path(t), waarden)
return yaml.safe_load((Path(t) / "docker-compose.yml").read_text())
@pytest.mark.parametrize("app", VERBINDINGSAPPS)
def test_staan_in_de_catalogus_en_draaien_op_een_pi(app):
meta = _tpl(app)["metadata"]
assert {"amd64", "arm64"} <= set(meta["architectures"]), \
"juist op een Pi wil je een tunnel draaien"
assert "netwerk" in meta["categories"]
def test_wireguard_krijgt_de_rechten_die_een_tunnel_nodig_heeft():
"""Zonder NET_ADMIN en de kernelmodules start wg-quick niet, en zonder
src_valid_mark valt het verkeer stil zodra alles door de tunnel gaat."""
svc = _render("wireguard")["services"]["wireguard"]
assert "NET_ADMIN" in svc["cap_add"]
assert "SYS_MODULE" in svc["cap_add"]
assert "/lib/modules:/lib/modules:ro" in svc["volumes"]
assert "net.ipv4.conf.all.src_valid_mark=1" in svc["sysctls"]
assert any(str(p).endswith("51820/udp") for p in svc["ports"])
def test_wireguard_legt_uit_waar_je_eigen_configuratie_heen_moet():
"""De clientrol is de reden dat deze app er is; die werkt alleen als je
weet dat je wg0.conf in wg_confs hoort."""
bron = (WORTEL / "apps" / "wireguard" / "files" / "compose.yaml").read_text("utf-8")
assert "wg_confs" in bron
velden = {f["name"]: f for f in bp.fields(WORTEL / "apps" / "wireguard")}
assert "wg_confs" in velden["appdata_dir"]["description"]
def test_tailscale_draait_op_de_host_en_onthoudt_zijn_aanmelding():
"""In een eigen netwerknamespace bereikt de tunnel alleen die container;
en zonder bewaarde staat moet je na elke herstart opnieuw koppelen."""
svc = _render("tailscale")["services"]["tailscale"]
assert svc["network_mode"] == "host"
assert "NET_ADMIN" in svc["cap_add"]
assert any("/var/lib/tailscale" in v for v in svc["volumes"])
assert "/dev/net/tun:/dev/net/tun" in svc["volumes"]
def test_tailscale_vraagt_om_een_auth_key_als_geheim():
velden = {f["name"]: f for f in bp.fields(WORTEL / "apps" / "tailscale")}
assert velden["auth_key"]["secret"] is True
assert velden["auth_key"]["required"] is True
# De routes zijn optioneel: de meeste mensen willen alleen deze machine.
assert velden["routes"]["required"] is False
def test_cloudflared_zet_geen_poort_open():
"""Dat is het hele punt van een tunnel; publiceerde hij poorten, dan kon je
net zo goed een reverse proxy nemen."""
svc = _render("cloudflared")["services"]["cloudflared"]
assert "ports" not in svc
assert "--no-autoupdate" in svc["command"], \
"een container die zichzelf bijwerkt, wijkt af van wat je uitrolde"
velden = {f["name"]: f for f in bp.fields(WORTEL / "apps" / "cloudflared")}
assert velden["tunnel_token"]["secret"] is True
def test_protonmail_bridge_botst_niet_met_mailpit():
"""Allebei praten ze SMTP en je kunt ze prima naast elkaar draaien: de een
vangt testmail op, de ander verstuurt echte mail."""
velden = {f["name"]: f for f in bp.fields(WORTEL / "apps" / "protonmail-bridge")}
mailpit = {f["name"]: f for f in bp.fields(WORTEL / "apps" / "mailpit")}
smtp_poorten = {f["default"] for f in mailpit.values()
if f.get("type") == "int"}
assert velden["port_smtp"]["default"] not in smtp_poorten
def test_protonmail_bridge_vertelt_over_de_eenmalige_aanmelding():
"""Die kan niet via een formulier: Proton vraagt om een tweede factor. Staat
het er niet bij, dan draait de bridge zonder account en snapt niemand
waarom er geen mail doorkomt."""
meta = _tpl("protonmail-bridge")["metadata"]
bron = (WORTEL / "apps" / "protonmail-bridge" / "files" / "compose.yaml").read_text("utf-8")
for tekst in (meta["architecture_note"], bron):
assert "--cli" in tekst
assert "docker exec" in meta["architecture_note"]

View file

@ -0,0 +1,243 @@
"""Als een stack niet opkomt, hoort de reden in het joblog te staan.
Compose meldt `dependency failed to start: container X is unhealthy`. Dat is
een gevolg, geen oorzaak. De echte reden een image dat de processor niet
aankan, een fout wachtwoord, een volle schijf staat in de log van díé
container, en daar kwam je alleen met `docker logs`, als je wist dat dat kon.
"""
import sys
import time
from pathlib import Path
WORTEL = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(WORTEL / "server-up"))
from conftest import login
ILLEGAL = ("WARNING: MongoDB requires ARMv8.2-A or higher\n"
"docker-entrypoint.sh: line 416: 26 Illegal instruction")
def _stack(env, naam="unifi-network"):
d = env["lib"] / naam
d.mkdir(parents=True, exist_ok=True)
(d / "docker-compose.yml").write_text(
"services:\n unifi-mongo:\n image: mongo:4.4.29\n")
return d
def _nep_docker(monkeypatch, docker, status="Up (unhealthy)", draait=True):
monkeypatch.setattr(docker, "compose_ps", lambda *a, **k: [
{"name": "unifi-mongo", "service": "unifi-mongo", "state": "running",
"status": status, "ports": "", "running": draait}])
monkeypatch.setattr(docker, "container_logs", lambda naam, tail=200: ILLEGAL)
monkeypatch.setattr(docker, "_run", lambda *a, **k: type(
"R", (), {"returncode": 0, "stdout": "ping kon niet verbinden\n",
"stderr": ""})())
def test_reden_wordt_uit_de_container_gehaald(env, monkeypatch):
from core import docker
_nep_docker(monkeypatch, docker)
regels = docker.waarom_mislukt(_stack(env))
tekst = "\n".join(regels)
assert "unifi-mongo" in tekst
assert "Illegal instruction" in tekst
assert "ARMv8.2-A" in tekst
# De healthcheck-uitvoer telt mee: daar staat de kern vaak al in.
assert "healthcheck: ping kon niet verbinden" in tekst
def test_draaiende_gezonde_container_levert_niets_op(env, monkeypatch):
"""Alleen wat stuk is hoort in het log; anders verdrinkt de melding."""
from core import docker
_nep_docker(monkeypatch, docker, status="Up 2 minutes")
assert docker.waarom_mislukt(_stack(env)) == []
def test_gestopte_container_telt_ook_mee(env, monkeypatch):
from core import docker
_nep_docker(monkeypatch, docker, status="Exited (132)", draait=False)
assert "Illegal instruction" in "\n".join(docker.waarom_mislukt(_stack(env)))
def test_joblog_van_een_mislukte_start_bevat_de_reden(client, env, monkeypatch):
"""De hele weg: op Start drukken, de start mislukt, en in hetzelfde venster
staat waaróm."""
import app as app_module
from core import docker
csrf = login(client)
_stack(env)
_nep_docker(monkeypatch, docker)
monkeypatch.setattr(app_module.docker, "compose_up", lambda *a, **k: 1)
r = client.post("/api/stacks/unifi-network/start",
json={}, 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:
d = client.get(f"/api/jobs/{jid}").get_json() or {}
if d.get("status") in ("done", "error"):
break
time.sleep(0.05)
tekst = "\n".join(x["text"] for x in d.get("lines", []))
assert "start mislukt" in tekst.lower()
assert "Dit zeggen de containers die niet opkwamen" in tekst
assert "Illegal instruction" in tekst
# ── Sjabloon gewijzigd, app nog niet ─────────────────────────────────────────
def test_gewijzigd_sjabloon_wordt_gemeld_in_het_wijzigscherm(client, env):
"""Precies de val van de Pi: het sjabloon kreeg een MongoDB-versie die daar
wél draait, maar de app bleef de oude starten omdat opnieuw starten niet
opnieuw rendert."""
import json as _json
from core import git
login(client)
d = env["lib"] / "unifi-network"
d.mkdir(parents=True, exist_ok=True)
(d / "docker-compose.yml").write_text(
"services:\n"
" unifi:\n image: lscr.io/linuxserver/unifi-network-application:latest\n"
" unifi-mongo:\n image: mongo:4.4.29\n")
(d / ".serverup.json").write_text(_json.dumps({
"source": "unifi-network", "repo_id": "server-up",
"values": {"service_name": "unifi"}}))
# Het sjabloon in de cache staat inmiddels op een versie die op een Pi werkt.
wortel = git.cache_dir("server-up") / "apps" / "unifi-network"
(wortel / "files").mkdir(parents=True, exist_ok=True)
(wortel / "template.json").write_text(_json.dumps({
"kind": "compose", "metadata": {"name": "unifi"},
"variables": [{"title": "Algemeen", "items": [
{"name": "service_name", "type": "str", "title": "Naam",
"default": "unifi", "required": True}]}]}), encoding="utf-8")
(wortel / "files" / "compose.yaml").write_text(
"services:\n"
" << service_name >>:\n image: lscr.io/linuxserver/unifi-network-application:latest\n"
" << service_name >>-mongo:\n image: mongo:4.4.18\n", encoding="utf-8")
d2 = client.get("/api/stacks/unifi-network/config").get_json()
assert d2["ok"] is True
drift = {v["service"]: v for v in d2["image_drift"]}
assert "unifi-mongo" in drift, d2["image_drift"]
assert drift["unifi-mongo"]["nu"] == "mongo:4.4.29"
assert drift["unifi-mongo"]["sjabloon"] == "mongo:4.4.18"
# Wat gelijk is hoort er niet bij; anders is de melding ruis.
assert "unifi" not in drift
def test_geen_melding_als_alles_gelijk_is(client, env):
import json as _json
from core import git
login(client)
d = env["lib"] / "web"
d.mkdir(parents=True, exist_ok=True)
(d / "docker-compose.yml").write_text("services:\n web:\n image: nginx:1.27\n")
(d / ".serverup.json").write_text(_json.dumps({
"source": "web", "repo_id": "server-up", "values": {"service_name": "web"}}))
wortel = git.cache_dir("server-up") / "apps" / "web"
(wortel / "files").mkdir(parents=True, exist_ok=True)
(wortel / "template.json").write_text(_json.dumps({
"kind": "compose", "metadata": {"name": "web"},
"variables": [{"title": "Algemeen", "items": [
{"name": "service_name", "type": "str", "title": "Naam",
"default": "web", "required": True}]}]}), encoding="utf-8")
(wortel / "files" / "compose.yaml").write_text(
"services:\n << service_name >>:\n image: nginx:1.27\n", encoding="utf-8")
assert client.get("/api/stacks/web/config").get_json()["image_drift"] == []
def test_eigen_versiekeuze_telt_niet_als_verschil(client, env):
"""Wie zelf een versie vastzette, hoort daar geen melding over te krijgen."""
import json as _json
from core import git
login(client)
d = env["lib"] / "web"
d.mkdir(parents=True, exist_ok=True)
(d / "docker-compose.yml").write_text("services:\n web:\n image: nginx:1.25-alpine\n")
(d / ".serverup.json").write_text(_json.dumps({
"source": "web", "repo_id": "server-up", "values": {"service_name": "web"},
"image_tags": {"web": "1.25-alpine"}}))
wortel = git.cache_dir("server-up") / "apps" / "web"
(wortel / "files").mkdir(parents=True, exist_ok=True)
(wortel / "template.json").write_text(_json.dumps({
"kind": "compose", "metadata": {"name": "web"},
"variables": [{"title": "Algemeen", "items": [
{"name": "service_name", "type": "str", "title": "Naam",
"default": "web", "required": True}]}]}), encoding="utf-8")
(wortel / "files" / "compose.yaml").write_text(
"services:\n << service_name >>:\n image: nginx:1.27\n", encoding="utf-8")
assert client.get("/api/stacks/web/config").get_json()["image_drift"] == []
# ── De map die Docker maakt waar een bestand hoort ───────────────────────────
def test_render_ruimt_een_door_docker_gemaakte_map_op(env, tmp_path):
"""Compose maakt een lege map aan als een bind-mount naar een bestand wijst
dat nog niet bestaat. De container stopt dan met exit 126 (Is a directory),
en zonder deze opruiming struikelt het opnieuw renderen over diezelfde map
waarmee het probleem zichzelf in stand houdt."""
import json as _json
from core import boilerplates as bp
bron = tmp_path / "sjabloon"
(bron / "files").mkdir(parents=True)
(bron / "template.json").write_text(_json.dumps({
"kind": "compose", "metadata": {"name": "x"},
"variables": [{"title": "Algemeen", "items": [
{"name": "service_name", "type": "str", "title": "Naam",
"default": "x", "required": True}]}]}), encoding="utf-8")
(bron / "files" / "compose.yaml").write_text(
"services:\n << service_name >>:\n image: nginx\n", encoding="utf-8")
(bron / "files" / "init.sh").write_text("#!/bin/bash\necho hoi\n", encoding="utf-8")
doel = tmp_path / "stack"
(doel / "init.sh").mkdir(parents=True) # zoals Docker hem achterlaat
assert (doel / "init.sh").is_dir()
bp.render_to_dir(bron, doel, {"service_name": "x"})
assert (doel / "init.sh").is_file()
assert "echo hoi" in (doel / "init.sh").read_text()
def test_gevulde_map_wordt_niet_stilzwijgend_gewist(env, tmp_path):
"""Alleen de lege map die Docker maakt mag weg. Staat er iets in, dan is
het van de gebruiker en hoort hij te horen wat er aan de hand is."""
import json as _json
import pytest as _pytest
from core import boilerplates as bp
bron = tmp_path / "sjabloon"
(bron / "files").mkdir(parents=True)
(bron / "template.json").write_text(_json.dumps({
"kind": "compose", "metadata": {"name": "x"},
"variables": [{"title": "Algemeen", "items": [
{"name": "service_name", "type": "str", "title": "Naam",
"default": "x", "required": True}]}]}), encoding="utf-8")
(bron / "files" / "compose.yaml").write_text(
"services:\n << service_name >>:\n image: nginx\n", encoding="utf-8")
(bron / "files" / "init.sh").write_text("#!/bin/bash\n", encoding="utf-8")
doel = tmp_path / "stack"
(doel / "init.sh").mkdir(parents=True)
(doel / "init.sh" / "iets.txt").write_text("van de gebruiker")
with _pytest.raises(bp.BoilerplateError) as fout:
bp.render_to_dir(bron, doel, {"service_name": "x"})
assert "map en geen bestand" in str(fout.value)
def test_unifi_init_zegt_wat_er_mis_is_in_plaats_van_exit_126():
"""Een ontbrekend script gaf 'exit 126' — een getal waar niemand iets aan
heeft. De container hoort te vertellen wat je eraan doet."""
bron = (WORTEL / "apps" / "unifi-network" / "files" / "compose.yaml").read_text("utf-8")
assert "if [ ! -f /init-mongo.sh ]" in bron
assert "Instellingen wijzigen" in bron
assert "exec bash /init-mongo.sh" in bron