Compare commits

..

No commits in common. "main" and "v0.8.27" have entirely different histories.

28 changed files with 38 additions and 1505 deletions

View file

@ -1,134 +1,3 @@
# v0.8.31 — UniFi's reparatiescript kan niet meer zoekraken
`exit 126` bij `unifi-mongo-init`: het compose-bestand mountte het losse
bestand `./init-mongo.sh`, en als dat (nog) niet in de stackmap staat maakt
Docker er stilzwijgend een lege map van. Bash zegt dan "Is a directory", de
container stopt met een kaal exit-nummer, en het bleef zo — want ook het
opnieuw renderen struikelde over diezelfde map.
- **De hele stackmap wordt gemount in plaats van het losse bestand.** Die map
bestaat altijd, dus Docker kan er niets meer bij verzinnen. Ontbreekt het
script toch, dan zegt de container wat je eraan doet in plaats van 126.
- **Een lege map op de plek van een bestand wordt bij het renderen opgeruimd**,
zodat 'Instellingen wijzigen → opslaan' het probleem oplost in plaats van er
zelf op vast te lopen.
Verder in deze uitgave: bouwen duurt geen kwartier meer.
De volgorde in de Dockerfile bepaalt hoeveel er bij elke build opnieuw moet.
`ARG SU_VERSION` stond met een LABEL erachter bovenaan, en die waarde verandert
per uitgave — dus élke release gooide alles daarna weg: `apt-get`, de
Docker-CLI van 70 MB, de front-end-bestanden van het CDN. Er veranderden drie
regels Python en de machine bouwde het hele image opnieuw.
- **Versie en label staan onderaan.** Alles erboven blijft in de cache; een
uitgave bouwt alleen nog de laatste lagen.
- **De front-end-bestanden staan vóór de broncode.** Hun versies liggen vast,
dus ze worden niet meer bij elke commit opnieuw van het CDN gehaald — en de
push uploadt alleen nog de laag die echt veranderd is.
- **Een test bewaakt die volgorde**, want dit is precies het soort ding dat
ongemerkt terugsluipt bij de volgende wijziging.
- **Duidelijker melding als het image ontbreekt.** Een Forgejo-release ontstaat
bij de git-tag, maar het image komt uit de build — en die pusht alleen als de
deploy weet naar welke registry. Dan zie je een release die je niet kunt
installeren, en "heeft deze server toegang?" is dan een misleidend antwoord.
Draait je installatie op een zelfgebouwd image, dan zegt de melding dat ook.
# 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
Het sjabloon pinde MongoDB op 4.4.29 "voor de Pi", maar juist vanaf 4.4.19 eist
MongoDB ARMv8.2-A. Een Pi 4 is ARMv8.0-A, dus mongod stopte daar meteen met
`Illegal instruction`, de container werd nooit healthy en UniFi startte niet:
`dependency failed to start: container unifi-mongo is unhealthy`.
- **Standaard 4.4.18**, de laatste versie die op een Pi 4 draait. Dit is een
patch-downgrade binnen 4.4; het datafomaat verandert daar niet mee.
- **De versie is instelbaar** (geavanceerd veld). Op een Pi 5 of een gewone
server kun je hoger — maar alleen bij een nieuwe installatie: een bestaande
4.4-database gaat niet zomaar naar 7.0.
- **De healthcheck heeft meer lucht.** Op een Pi kost het starten van de
mongo-shell zelf al seconden en duurt de eerste initialisatie op een SD-kaart
langer dan een halve minuut. Op een snelle host verandert er niets: die is na
de eerste controle gezond.
- **De reparatiecontainer volgt dezelfde versie**, zodat die niet alsnog een
image start dat de processor niet aankan.
# v0.8.28 — Installeren vanaf een ander adres
De ingebouwde hostnaam in `install.sh` lost niet overal op. Staat je Forgejo
achter een naam die alleen op je eigen netwerk werkt, dan strandt bijwerken
vanaf een andere locatie op `could not resolve host` — ook door een tunnel,
want die repareert de naam niet.
- **`--host ADRES`** geeft aan waar de broncode staat: een naam, een IP of een
tailnet-adres. Een schema mag mee (`--host http://10.0.20.22:3000`), want een
IP over https levert een certificaatfout op.
- **Een bestaande installatie gaat mee.** Die haalt op via de remote die in de
kloon staat — precies het adres dat het niet doet. Met `--host` wordt die
remote eerst omgezet, zodat bijwerken daarna vanzelf via het nieuwe adres
gaat.
- **`--branch` accepteert ook een tag**, en dat staat nu ook in de hulptekst.
# v0.8.27 — Wachtwoorden, wachtrijen, snelheid en versies
Eerste stabiele uitgave sinds v0.8.12. Wie vanaf die versie bijwerkt, krijgt de

View file

@ -4,16 +4,13 @@ COPY server-up/requirements.txt .
RUN pip install --no-cache-dir --prefix=/inst -r requirements.txt
FROM python:3.12-slim
# De volgorde hieronder is met opzet: alles wat zelden verandert staat boven,
# alles wat bij elke commit verandert onderaan. Docker hergebruikt een laag
# alleen zolang niets ervóór is gewijzigd, dus één regel op de verkeerde plek
# kost bij elke build opnieuw de hele rit.
#
# Wat hier eerder misging: `ARG SU_VERSION` met een LABEL erachter stond
# bovenaan. Die waarde verandert per release, dus elke uitgave gooide álle
# lagen erna weg — apt-get, de Docker-CLI van 70 MB, de front-end-bestanden,
# alles. Vandaar dat versie en label nu helemaal onderaan staan.
# Build-arg: zet bij build naar de git-tag, zodat de image z'n eigen versie kent.
# docker build --build-arg SU_VERSION=$(git describe --tags --always) ...
# Forgejo Actions doet dit automatisch (zie server-up-deploy/README.md).
ARG SU_VERSION=dev
LABEL org.opencontainers.image.title="Server Up" \
org.opencontainers.image.version="${SU_VERSION}" \
org.opencontainers.image.source="https://git.example.com/bes-r/server-up"
# util-linux levert `setpriv`, waarmee het entrypoint naar een gewone gebruiker
# afzakt zonder de capabilities te verliezen die Server Up nodig heeft. Het is
# een Essential-pakket en dus al aanwezig, maar expliciet noemen zorgt dat een
@ -43,11 +40,9 @@ RUN DPKG_ARCH=$(dpkg --print-architecture) \
COPY --from=build /inst /usr/local
WORKDIR /app
# De front-end-bestanden staan vóór de broncode: hun versies staan hier vast,
# dus deze lagen blijven staan zolang je ze niet zelf wijzigt. Stonden ze erna,
# dan haalde elke commit ze opnieuw van het CDN én werden ze bij elke push
# opnieuw geüpload.
COPY server-up/ ./
COPY modules/ ./modules-bundled/
COPY VERSION ./VERSION
RUN mkdir -p static/fonts \
&& curl -fsSL "https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css" -o static/fonts/mdi.min.css \
&& curl -fsSL "https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/fonts/materialdesignicons-webfont.woff2" -o static/fonts/materialdesignicons-webfont.woff2 \
@ -75,6 +70,7 @@ RUN mkdir -p /data/stacks /data/appdata /data/backups /data/git modules /root \
# eerste verbinding en bewaart de sleutel in /data/known_hosts. Wijzigt die
# sleutel later, dan faalt de verbinding — met `no` was elke MITM onzichtbaar.
ENV PYTHONUNBUFFERED=1 PORT=5000 HOME=/root \
SU_VERSION=${SU_VERSION} \
SU_CONFIG=/data/config.json SU_AUDIT=/data/audit.db SU_GIT_CACHE=/data/git \
SU_SECRET=/data/secret.key SU_JOBS=/data/jobs \
GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/data/known_hosts"
@ -87,22 +83,3 @@ HEALTHCHECK --interval=30s --timeout=8s --start-period=15s CMD curl -fs http://l
# SU_UID:SU_GID. Zonder die variabelen verandert er niets: dan blijft het root.
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["python","app.py"]
# ── Vanaf hier verandert er iets bij elke commit ─────────────────────────────
# Alles hierboven blijft in de cache staan; alleen deze laatste lagen worden
# opnieuw gebouwd. Daardoor duurt een release een halve minuut in plaats van
# een kwartier, en hoeft de push alleen deze lagen te uploaden.
COPY server-up/ ./
COPY modules/ ./modules-bundled/
COPY VERSION ./VERSION
# Build-arg: zet bij build naar de git-tag, zodat de image z'n eigen versie kent.
# docker build --build-arg SU_VERSION=$(git describe --tags --always) ...
# Forgejo Actions doet dit automatisch (zie server-up-deploy/README.md).
# Helemaal onderaan, want deze waarde verandert per uitgave en zou alles
# eronder ongeldig maken.
ARG SU_VERSION=dev
ENV SU_VERSION=${SU_VERSION}
LABEL org.opencontainers.image.title="Server Up" \
org.opencontainers.image.version="${SU_VERSION}" \
org.opencontainers.image.source="https://git.example.com/bes-r/server-up"

View file

@ -1 +1 @@
0.8.31
0.8.27

View file

@ -1,12 +0,0 @@
# 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

@ -1,31 +0,0 @@
{
"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

@ -1,24 +0,0 @@
# 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

@ -1,75 +0,0 @@
{
"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

@ -1,23 +0,0 @@
# 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

@ -1,50 +0,0 @@
{
"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

@ -27,10 +27,7 @@ services:
restart: unless-stopped
<< service_name >>-mongo:
# Vanaf 4.4.19 eist MongoDB ARMv8.2-A; een Pi 4 (Cortex-A72) heeft dat niet
# en mongod stopt daar met 'Illegal instruction'. Zie SERVER-71772. De
# versie is instelbaar, zodat je op nieuwere hardware hoger kunt.
image: mongo:<< mongo_version >>
image: mongo:4.4.29
container_name: << service_name >>-mongo
environment:
# Dezelfde beheerdersnaam als het allereerste sjabloon gebruikte. Zo
@ -53,18 +50,14 @@ services:
test:
- CMD-SHELL
- 'mongo --quiet --eval "db.adminCommand({ ping: 1 })"'
# Ruim bemeten voor trage hardware: op een Pi kost het starten van de
# mongo-shell zelf al seconden, en de eerste initialisatie op een
# SD-kaart duurt langer dan een halve minuut. Op een snelle host is de
# container gewoon na de eerste controle gezond.
interval: 15s
timeout: 20s
retries: 10
start_period: 90s
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
restart: unless-stopped
<< service_name >>-mongo-init:
image: mongo:<< mongo_version >>
image: mongo:4.4.29
container_name: << service_name >>-mongo-init
environment:
- MONGO_INITDB_ROOT_USERNAME=unifi
@ -76,25 +69,11 @@ services:
- MONGO_DBNAME=unifi
- MONGO_AUTHSOURCE=admin
volumes:
# De hele stackmap en niet het losse script. Een bind-mount naar een
# bestand dat (nog) niet bestaat laat Docker stilzwijgend een lege map
# aanmaken; de container stopt dan met "exit 126 — Is a directory" en
# blijft dat doen. Een map die er altijd is, kan dat niet overkomen.
- ./:/su-stack:ro
- ./init-mongo.sh:/init-mongo.sh:ro
# Dezelfde map als UniFi zelf: hierin staat system.properties, dat het
# image alleen bij de allereerste start schrijft.
- << appdata_dir >>/<< service_name >>/config:/unifi-config
entrypoint:
- /bin/bash
- -c
- |
if [ ! -f /su-stack/init-mongo.sh ]; then
echo "FOUT: init-mongo.sh ontbreekt in de stackmap van deze app."
echo "Herstel het met: Instellingen wijzigen -> opslaan, dan wordt"
echo "het sjabloon opnieuw uitgeschreven."
exit 1
fi
exec bash /su-stack/init-mongo.sh
entrypoint: ["/bin/bash", "/init-mongo.sh"]
depends_on:
<< service_name >>-mongo:
condition: service_healthy

View file

@ -24,7 +24,7 @@
"amd64",
"arm64"
],
"architecture_note": "Op een Raspberry Pi is een 64-bits besturingssysteem vereist. Let op de MongoDB-versie: vanaf 4.4.19 (en vanaf 5.0) eist MongoDB ARMv8.2-A, en dat heeft een Pi 4 niet. Op die hardware crasht mongod met 'Illegal instruction'. De standaard 4.4.18 werkt op alle ondersteunde architecturen.",
"architecture_note": "Op een Raspberry Pi is een 64-bits besturingssysteem vereist. Voor een Pi 4 is de database bewust vastgezet op MongoDB 4.4.29.",
"web": {
"scheme": "https",
"port": 8443
@ -114,15 +114,6 @@
{
"title": "Database",
"items": [
{
"name": "mongo_version",
"type": "str",
"title": "MongoDB-versie",
"default": "4.4.18",
"required": true,
"description": "4.4.18 is de laatste versie die op een Raspberry Pi 4 draait; nieuwer eist ARMv8.2-A. Op een Pi 5 of een gewone server kun je hoger, maar kies dat alleen bij een nieuwe installatie: een bestaande database gaat niet zomaar van 4.4 naar 7.0.",
"advanced": true
},
{
"name": "mongo_password",
"type": "str",

View file

@ -1,29 +0,0 @@
# 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

@ -1,138 +0,0 @@
{
"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

@ -15,8 +15,6 @@ set -eu
SCRIPT_VERSIE="1.2"
GIT_HOST="git.ramonbesselink.nl"
GIT_SCHEMA="https"
HOST_EXPLICIET=0
GIT_PAD="bes-r/server-up"
BRANCH="main"
DOEL="/opt/server-up"
@ -120,15 +118,7 @@ Gebruik: install.sh [opties]
--bind ADRES Waarop de poort luistert (standaard: ${BIND})
Gebruik 0.0.0.0 om er van buiten de server bij te kunnen,
maar zet er dan wel een reverse proxy met TLS voor.
--branch NAAM Branch of tag om te installeren (standaard: ${BRANCH})
--host ADRES Waar de broncode staat, als ${GIT_HOST} daar niet
oplost of niet bereikbaar is. Een naam, een IP of een
tailnet-adres; een schema mag mee omdat een IP over https
een certificaatfout geeft:
--host 10.0.20.22
--host http://10.0.20.22:3000
Bij een bestaande installatie wordt de git-remote meteen
omgezet, zodat bijwerken daarna via dit adres gaat.
--branch NAAM Branch om te installeren (standaard: ${BRANCH})
--token TOKEN Toegangstoken, als je git-repo niet openbaar is
--base-dir PAD Hoofdmap voor stacks, appdata en backups
(standaard: /opt/serverup). Later wijzigen kan via
@ -167,27 +157,6 @@ Voorbeelden:
UITLEG
}
# Waar de broncode vandaan komt. Standaard de naam hierboven, maar die is niet
# overal te gebruiken: staat je Forgejo achter een naam die alleen thuis
# oplost, dan kom je er vanaf een andere locatie (of door een tunnel) niet uit.
# Met `--host` geef je dan het adres op dat daar wél werkt — een IP, een
# tailnet-adres, een andere naam — zonder aan /etc/hosts te komen.
#
# Een IP over https levert een certificaatfout op, dus een schema mag mee:
# --host 10.0.20.22 → https://10.0.20.22
# --host http://10.0.20.22:3000 → precies dat
zet_host() {
_waarde="${1%/}"
case "$_waarde" in
http://*) GIT_SCHEMA="http"; _waarde="${_waarde#http://}" ;;
https://*) GIT_SCHEMA="https"; _waarde="${_waarde#https://}" ;;
esac
# Alles achter de eerste schuine streep is een pad; dat komt uit GIT_PAD.
GIT_HOST="${_waarde%%/*}"
[ -n "$GIT_HOST" ] || fout "--host vereist een adres, bijvoorbeeld 10.0.20.22:3000"
HOST_EXPLICIET=1
}
# ── Argumenten ───────────────────────────────────────────────────────────────
while [ $# -gt 0 ]; do
case "$1" in
@ -195,7 +164,6 @@ while [ $# -gt 0 ]; do
--port) POORT="${2:?--port vereist een nummer}"; POORT_EXPLICIET=1; shift 2 ;;
--bind) BIND="${2:?--bind vereist een adres}"; BIND_EXPLICIET=1; shift 2 ;;
--branch) BRANCH="${2:?--branch vereist een naam}"; shift 2 ;;
--host) zet_host "${2:?--host vereist een adres}"; shift 2 ;;
--token) TOKEN="${2:?--token vereist een waarde}"; shift 2 ;;
--user) GEBRUIKER="${2:?--user vereist een accountnaam}"
GEBRUIKER_EXPLICIET=1; shift 2 ;;
@ -356,7 +324,7 @@ controleer_docker() {
# In plaats daarvan: een bestand met modus 0600 dat na afloop weer weg is. Git
# leest het via een credential-helper, curl via --config; in beide gevallen
# staat alleen het pád op de opdrachtregel.
bron_url() { printf '%s://%s/%s.git' "$GIT_SCHEMA" "$GIT_HOST" "$GIT_PAD"; }
bron_url() { printf 'https://%s/%s.git' "$GIT_HOST" "$GIT_PAD"; }
TOKEN_BESTAND=""
CURL_BESTAND=""
@ -405,13 +373,6 @@ haal_broncode() {
# installatie is er niets om naar terug te gaan.
VORIGE_COMMIT="$($SUDO git -C "$DOEL" rev-parse HEAD 2>/dev/null || echo '')"
[ -n "$VORIGE_COMMIT" ] && log "Huidige versie: $(printf '%.9s' "$VORIGE_COMMIT")"
# Een bestaande kloon haalt op via de remote die erin staat, en die
# wijst naar de naam van toen. Juist bij --host is dat het adres dat het
# niet doet — dus eerst omzetten, anders heeft de optie geen effect.
if [ "$HOST_EXPLICIET" -eq 1 ]; then
log "Bron omgezet naar $(bron_url)"
doe $SUDO git -C "$DOEL" remote set-url origin "$(bron_url)"
fi
git_doe -C "$DOEL" fetch --depth 1 origin "$BRANCH"
# FETCH_HEAD en niet origin/$BRANCH: een tag krijgt geen origin/-ref,
# dus `--branch v0.7.92-beta` installeerde prima maar kon daarna nooit
@ -435,7 +396,7 @@ haal_broncode() {
waarschuw "Klonen mislukte; ik probeer het archief."
fi
archief="${GIT_SCHEMA}://${GIT_HOST}/${GIT_PAD}/archive/${BRANCH}.tar.gz"
archief="https://${GIT_HOST}/${GIT_PAD}/archive/${BRANCH}.tar.gz"
log "Downloaden: $archief"
if [ "$DROOG" -eq 0 ]; then
tijdelijk="$(mktemp -d)"

View file

@ -762,24 +762,6 @@ def _ontbrekende_afhankelijkheden(meta: dict, bronnen: dict) -> list[dict]:
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):
"""Backup vóór een actie die bestanden overschrijft of weggooit.
@ -896,8 +878,6 @@ def api_stack_action(name, action):
backups.prune(name, log_fn=lambda m: jobs.log(qq, "dim", m))
jobs.log(qq, "ok" if rc == 0 else "error",
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)
jobs.finish(jid, "done" if rc == 0 else "error")
except Exception as e:
@ -1134,10 +1114,6 @@ def api_stack_config(name):
# formulier ze leeg tonen en zou opslaan ze wissen.
waarden = {**waarden, **_geheimen_uit_env(d, meta)}
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)),
values=waarden,
network=meta.get("network", ""), ip=meta.get("ip", ""),
@ -1155,45 +1131,6 @@ def api_stack_config(name):
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:
"""Repo, branch en stand van de cache waar een sjabloon uit komt."""
if not rid:
@ -1417,8 +1354,6 @@ def api_stack_reconfigure(name):
jobs.log(qq, "ok" if rc == 0 else "error",
"Herstart met de nieuwe instellingen" if rc == 0
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",
ref=name, ip=ip)
jobs.finish(jid, "done" if rc == 0 else "error")
@ -2551,8 +2486,6 @@ def api_store_install():
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",
"Gestart" if rc == 0 else "Start mislukt")
if rc != 0:
_log_waarom_mislukt(qq, dest, inst)
audit.log("store", "install", "ok", ref=inst,
detail={"env": list(env_overrides.keys()), "tags": image_tags})
jobs.finish(jid, "done")

View file

@ -494,18 +494,6 @@ def render_to_dir(src: Path, dest: Path, values: dict[str, Any]) -> list[str]:
rel = p.relative_to(files_dir)
out = dest / rel
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):
try:
txt = p.read_text(encoding="utf-8")

View file

@ -307,42 +307,6 @@ def compose_up(d: Path, log_fn=None, name=None) -> int:
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:
f = find_compose(d)
if not f:

View file

@ -82,15 +82,6 @@ def image_repo(info: dict | None = None) -> str:
"""
expliciet = (cfg.load().get("UPDATE_IMAGE") or "").strip()
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
img = (info or inspect_self()).get("image", "")
if not img:
@ -186,22 +177,8 @@ def pull(tag: str, log_fn=None) -> tuple[bool, str]:
log_fn(f"Ophalen {image}")
rc = docker._stream([docker._bin(), "pull", image], log_fn=log_fn)
if rc != 0:
# "Not found" is bijna nooit een toegangsprobleem: er staat gewoon geen
# image met die tag. De release bestaat wel — die maakt Forgejo bij de
# git-tag — maar de build pusht alleen als de deploy-omgeving weet naar
# welke registry hij moet. Draait deze installatie bovendien op een
# zelfgebouwd image, dan valt er sowieso niets op te halen.
lokaal = inspect_self().get("image", "")
extra = ""
if lokaal and "/" not in lokaal.rsplit(":", 1)[0]:
extra = (f" Deze installatie draait '{lokaal}', een lokaal gebouwd "
"image; bijwerken gaat dan via install.sh en niet via deze "
"knop.")
return False, (
f"Ophalen van {image} mislukt. Er is een release met deze versie, "
"maar geen image met deze tag in de registry — controleer of de "
"build hem gepusht heeft (SU_IMAGE in de .env van je deploy) en of "
"deze server bij de registry mag." + extra)
return False, (f"Ophalen van {image} mislukt. Bestaat die tag in de "
"registry, en heeft deze server er toegang toe?")
return True, image

View file

@ -78,46 +78,11 @@ def image_tag(release_tag: str) -> str:
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]:
req = urllib.request.Request(
url, headers={"Accept": "application/json", "User-Agent": "server-up"})
with urllib.request.urlopen(req, timeout=8) as r:
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
data = json.loads(r.read().decode("utf-8"))
return data if isinstance(data, list) else [data]
@ -133,7 +98,7 @@ def check(current: str, force: bool = False) -> dict:
return dict(_CACHE)
c = cfg.load()
url = api_url(c.get("UPDATE_API_URL") or "")
url = (c.get("UPDATE_API_URL") or "").strip()
ch = channel()
res = {"enabled": bool(url), "current": current, "channel": ch,
"current_is_prerelease": is_prerelease(current),

View file

@ -1776,25 +1776,6 @@ tailwind.config = {
<span x-text="t('reconfigure_warning')"></span>
</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
déze cache: loopt die achter, dan trek je een oude versie over een
werkende stack heen zonder dat iets dat zegt. -->
@ -4032,7 +4013,6 @@ function app() {
app_services: [], depends_on: [],
extras: { volumes: [], devices: [] },
image_tags: {},
image_drift: [],
compatibility: { known: false, supported: true, architecture: '', reason: '' },
architecture_note: ''
};
@ -4291,7 +4271,6 @@ function app() {
extras: { volumes: (d.extras||{}).volumes || [],
devices: (d.extras||{}).devices || [] },
image_tags: { ...(d.image_tags || {}) },
image_drift: d.image_drift || [],
};
await this.loadAppNets(stack);
this.fieldInfo = null;

View file

@ -138,7 +138,7 @@
"release_notes": "Release notes",
"view_release": "View release",
"update_api_url": "Releases API URL",
"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.",
"update_api_hint": "Forgejo/GitHub Releases API returning the latest release. Empty = disabled.",
"include_prerelease": "Include pre-releases (beta)",
"login": "Sign in",
"login_failed": "Sign-in failed",
@ -501,6 +501,5 @@
"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_done": "App removed",
"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."
"remove_failed": "Failed to remove app"
}

View file

@ -138,7 +138,7 @@
"release_notes": "Release notes",
"view_release": "Bekijk release",
"update_api_url": "Releases-API URL",
"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.",
"update_api_hint": "Forgejo/GitHub Releases-API die de laatste release teruggeeft. Leeg = uitgeschakeld.",
"include_prerelease": "Pre-releases (beta) meenemen",
"login": "Inloggen",
"login_failed": "Inloggen mislukt",
@ -501,6 +501,5 @@
"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_done": "App verwijderd",
"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."
"remove_failed": "App verwijderen mislukt"
}

View file

@ -157,8 +157,8 @@ GEDEELDE_POORTEN = {
80: {"nginx-proxy-manager", "traefik", "caddy", "zoraxy", "pangolin"}, # reverse proxies
443: {"nginx-proxy-manager", "traefik", "caddy", "zoraxy", "pangolin"},
53: {"adguard-home", "pihole"}, # dns-blokkers
# Drie manieren om een WireGuard-eindpunt te draaien; je gebruikt er één.
51820: {"pangolin", "wg-easy", "wireguard"},
# Twee manieren om een WireGuard-tunnel te draaien; je gebruikt er één.
51820: {"pangolin", "wg-easy"},
# Hieronder: botsingen die er al waren vóór deze testsuite. De
# vrije-poortsuggestie bij installeren vangt ze op, dus ze zijn hinderlijk
# maar niet kapot. Nieuwe apps horen hier niet bij te komen.
@ -406,20 +406,17 @@ def test_unifi_heeft_pi_geschikte_en_correct_geinitialiseerde_mongodb():
mongo = doc["services"]["unifi-mongo"]
reparatie = doc["services"]["unifi-mongo-init"]
unifi = doc["services"]["unifi"]
assert mongo["image"] == reparatie["image"], "reparatie moet dezelfde versie draaien"
assert mongo["image"] == "mongo:4.4.29"
assert "./init-mongo.sh:/docker-entrypoint-initdb.d/init-mongo.sh:ro" in mongo["volumes"]
assert any(v == "MONGO_AUTHSOURCE=admin" for v in unifi["environment"])
assert any(v == "MONGO_AUTHSOURCE=admin" for v in mongo["environment"])
assert unifi["depends_on"]["unifi-mongo"]["condition"] == "service_healthy"
assert unifi["depends_on"]["unifi-mongo-init"]["condition"] == \
"service_completed_successfully"
assert reparatie["image"] == "mongo:4.4.29"
assert reparatie["depends_on"]["unifi-mongo"]["condition"] == "service_healthy"
assert reparatie["restart"] == "no"
# De hele map en niet het losse bestand: een bind-mount naar een bestand
# dat nog niet bestaat laat Docker een lege map aanmaken, en dan stopt de
# container met exit 126 zonder dat iets uitlegt waarom.
assert "./:/su-stack:ro" in reparatie["volumes"]
assert not any(v.startswith("./init-mongo.sh:") for v in reparatie["volumes"])
assert "./init-mongo.sh:/init-mongo.sh:ro" in reparatie["volumes"]
assert any(v == "MONGO_HOST=unifi-mongo" for v in reparatie["environment"])
# De beheerdersnaam van het allereerste sjabloon. Een volume van toen kent
# geen 'root', en dan mislukte elke reparatie én elke mongodump.
@ -564,43 +561,3 @@ def test_catalogus_markeert_aantoonbaar_amd64_only_apps():
meta = bp.metadata(WORTEL / "apps" / naam)
assert meta["architectures"] == ["amd64"], naam
assert "Raspberry Pi" in meta["architecture_note"], naam
def test_unifi_pint_mongodb_op_een_versie_die_op_een_pi_4_draait():
"""Vanaf 4.4.19 eist MongoDB ARMv8.2-A. Een Pi 4 is ARMv8.0-A, dus daar
stopt mongod met 'Illegal instruction' de container wordt nooit healthy
en UniFi start niet ("dependency failed to start"). Het sjabloon zegt Pi's
te ondersteunen, dus de standaardversie moet dat ook echt doen.
Zie https://jira.mongodb.org/browse/SERVER-71772
"""
app = WORTEL / "apps" / "unifi-network"
velden = {f["name"]: f for f in bp.fields(app)}
assert "mongo_version" in velden, "de versie hoort instelbaar te zijn"
standaard = str(velden["mongo_version"]["default"])
major, minor, patch = (int(x) for x in standaard.split(".")[:3])
assert (major, minor) == (4, 4), f"{standaard} draait niet op een Pi 4"
assert patch <= 18, (
f"mongo:{standaard} eist ARMv8.2-A en crasht op een Pi 4; "
"4.4.18 is de laatste die daar werkt")
assert "arm64" in bp.metadata(app)["architectures"]
assert "ARMv8.2" in bp.metadata(app)["architecture_note"], \
"leg in de notitie uit waarom de versie zo laag staat"
def test_unifi_healthcheck_is_ruim_genoeg_voor_trage_hardware():
"""Op een Pi kost het starten van de mongo-shell zelf al seconden; met een
krappe timeout is de container ongezond terwijl de database prima draait."""
app = WORTEL / "apps" / "unifi-network"
with tempfile.TemporaryDirectory() as t:
gezond = _render(app, Path(t))["services"]["unifi-mongo"]["healthcheck"]
def _sec(waarde):
return int(str(waarde).rstrip("s"))
assert _sec(gezond["timeout"]) >= 15
assert _sec(gezond["start_period"]) >= 60
# Genoeg pogingen om een trage eerste initialisatie te overleven.
assert _sec(gezond["interval"]) * gezond["retries"] >= 120

View file

@ -1,59 +0,0 @@
"""De volgorde van de lagen in de Dockerfile bepaalt hoe lang een build duurt.
Docker hergebruikt een laag alleen zolang niets ervóór is gewijzigd. Stond het
versienummer bovenaan, dan gooide élke uitgave alles daarna weg: apt-get, de
Docker-CLI van 70 MB, de front-end-bestanden. Een release duurde daardoor een
kwartier terwijl er drie regels Python waren veranderd.
"""
from pathlib import Path
WORTEL = Path(__file__).resolve().parent.parent
REGELS = (WORTEL / "Dockerfile").read_text(encoding="utf-8").splitlines()
def _regel_van(fragment: str) -> int:
"""Het regelnummer van de eerste regel die dit fragment bevat."""
for i, r in enumerate(REGELS):
if fragment in r and not r.lstrip().startswith("#"):
return i
raise AssertionError(f"niet gevonden in de Dockerfile: {fragment}")
def test_de_broncode_komt_na_alles_wat_zelden_verandert():
"""De code wijzigt bij elke commit; wat daarvoor staat blijft in de cache."""
code = _regel_van("COPY server-up/ ./")
for eerder in ("apt-get update",
"download.docker.com",
"COPY --from=build /inst",
"static/fonts",
"static/vendor",
"mkdir -p /data/stacks"):
assert _regel_van(eerder) < code, \
f"'{eerder}' hoort vóór de broncode te staan, anders vervalt zijn cache"
def test_het_versienummer_staat_helemaal_onderaan():
"""SU_VERSION verandert per uitgave. Alles wat erná komt wordt opnieuw
gebouwd, dus er hoort niets meer achter te staan dan het label zelf."""
versie = _regel_van("ARG SU_VERSION")
for later in ("apt-get update", "download.docker.com", "static/vendor",
"COPY server-up/ ./", "ENTRYPOINT"):
assert _regel_van(later) < versie, \
f"'{later}' staat ná ARG SU_VERSION en wordt dus elke release herbouwd"
def test_de_frontendbestanden_worden_niet_door_de_broncode_overschreven():
"""Ze komen nu vóór `COPY server-up/`. Dat mag alleen als daar geen eigen
static-map in zit, want anders zou die de gedownloade bestanden vervangen."""
assert not (WORTEL / "server-up" / "static").exists(), (
"server-up/static bestaat nu wél; zet de downloads dan weer ná de COPY "
"of geef ze een andere plek")
def test_de_gepinde_versies_staan_er_nog():
"""De cache blijft alleen staan zolang deze regels niet wijzigen — dus als
ze veranderen hoort dat een bewuste keuze te zijn."""
tekst = "\n".join(REGELS)
for gepind in ("@mdi/font@7.4.47", "alpinejs@3.14.1", "htmx.org@2.0.3",
"tailwindcss.com/3.4.16", "docker-27.5.1.tgz", "compose/releases/download/v2.32.4"):
assert gepind in tekst, f"versie niet meer vastgezet: {gepind}"

View file

@ -53,7 +53,7 @@ def test_geen_bashismen():
def test_help_werkt_en_noemt_alle_opties():
r = _draai("--help")
assert r.returncode == 0
for optie in ("--dir", "--port", "--bind", "--branch", "--host", "--token",
for optie in ("--dir", "--port", "--bind", "--branch", "--token",
"--update", "--uninstall", "--yes", "--dry-run",
"--admin", "--admin-password-file", "--create-admin"):
assert optie in r.stdout, f"{optie} niet gedocumenteerd in --help"
@ -1213,51 +1213,3 @@ def test_doctor_kijkt_naar_koppelingen_onder_base_dir():
assert "eigen koppeling, maar niet in de container" in blok
# De uitweg hoort erbij te staan, anders weet je nog niets.
assert "docker-compose.yml" in blok
# ── Een andere bron opgeven ──────────────────────────────────────────────────
# De ingebouwde hostnaam lost niet overal op: staat je Forgejo achter een naam
# die alleen thuis werkt, dan kom je er vanaf een andere locatie niet uit en
# strandt het bijwerken op "could not resolve host".
def test_host_wordt_in_de_bron_url_gebruikt(tmp_path):
r = _draai("--dry-run", "--yes", "--dir", str(tmp_path / "su"),
"--host", "10.0.20.22")
assert "https://10.0.20.22/bes-r/server-up" in r.stdout, r.stdout[-800:]
assert "git.ramonbesselink.nl" not in r.stdout
def test_host_met_schema_en_poort(tmp_path):
"""Een IP over https geeft een certificaatfout, dus http moet kunnen."""
r = _draai("--dry-run", "--yes", "--dir", str(tmp_path / "su"),
"--host", "http://10.0.20.22:3000")
assert "http://10.0.20.22:3000/bes-r/server-up" in r.stdout
def test_host_negeert_een_pad_erachter(tmp_path):
r = _draai("--dry-run", "--yes", "--dir", str(tmp_path / "su"),
"--host", "https://voorbeeld.nl/iets/anders/")
assert "https://voorbeeld.nl/bes-r/server-up" in r.stdout
def test_host_zonder_waarde_stopt(tmp_path):
r = _draai("--dry-run", "--host")
assert r.returncode != 0
assert "--host" in (r.stdout + r.stderr)
def test_zonder_host_blijft_de_standaard_staan(tmp_path):
r = _draai("--dry-run", "--yes", "--dir", str(tmp_path / "su"))
assert "git.ramonbesselink.nl" in r.stdout
def test_bestaande_installatie_krijgt_de_nieuwe_remote(tmp_path):
"""Een bestaande kloon haalt op via de remote die erin staat — precies het
adres dat het niet doet. Zonder omzetten heeft --host geen effect."""
if not shutil.which("git"):
pytest.skip("git niet aanwezig")
doel = tmp_path / "su"
(doel / ".git").mkdir(parents=True)
r = _draai("--dry-run", "--yes", "--dir", str(doel),
"--host", "100.64.0.5")
assert "remote set-url origin https://100.64.0.5/bes-r/server-up.git" in r.stdout

View file

@ -1,155 +0,0 @@
"""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"
# ── Als het image er niet is ─────────────────────────────────────────────────
def test_mislukte_pull_noemt_de_waarschijnlijke_oorzaak(env, monkeypatch):
"""Een Forgejo-release ontstaat bij de git-tag, maar het image komt uit de
build en die pusht alleen als de deploy weet naar welke registry. Dan zie
je een release die je niet kunt installeren, en 'geen toegang' is dan een
misleidend antwoord."""
from core import selfupdate, docker
env["core"].patch({"UPDATE_IMAGE": "git.voorbeeld.nl/bes-r/server-up"})
monkeypatch.setattr(selfupdate, "_login", lambda log_fn=None: (True, ""))
monkeypatch.setattr(selfupdate, "inspect_self",
lambda: {"image": "git.voorbeeld.nl/bes-r/server-up:0.8.29"})
monkeypatch.setattr(docker, "_stream", lambda *a, **k: 1)
ok, msg = selfupdate.pull("v0.8.30")
assert ok is False
assert "geen image met deze tag" in msg
assert "SU_IMAGE" in msg
def test_lokaal_gebouwd_image_wordt_als_zodanig_gemeld(env, monkeypatch):
"""Wie zijn image zelf bouwt kan met deze knop nooit iets ophalen; dat hoort
de melding te zeggen in plaats van naar de registry te wijzen."""
from core import selfupdate, docker
env["core"].patch({"UPDATE_IMAGE": "git.voorbeeld.nl/bes-r/server-up"})
monkeypatch.setattr(selfupdate, "_login", lambda log_fn=None: (True, ""))
monkeypatch.setattr(selfupdate, "inspect_self",
lambda: {"image": "server-up:latest"})
monkeypatch.setattr(docker, "_stream", lambda *a, **k: 1)
ok, msg = selfupdate.pull("v0.8.30")
assert ok is False
assert "lokaal gebouwd image" in msg
assert "install.sh" in msg

View file

@ -1,115 +0,0 @@
"""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

@ -1,246 +0,0 @@
"""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 /su-stack/init-mongo.sh ]" in bron
assert "Instellingen wijzigen" in bron
assert "exec bash /su-stack/init-mongo.sh" in bron
# En het script wordt via de map gemount, zodat Docker er geen lege map
# van kan maken.
assert "./:/su-stack:ro" in bron