Compare commits
No commits in common. "main" and "v0.8.29" have entirely different histories.
25 changed files with 25 additions and 1315 deletions
96
CHANGELOG.md
96
CHANGELOG.md
|
|
@ -1,99 +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
|
||||
|
|
|
|||
45
Dockerfile
45
Dockerfile
|
|
@ -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"
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.8.31
|
||||
0.8.29
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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."}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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'."}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -76,25 +76,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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 komma’s (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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -415,11 +415,7 @@ def test_unifi_heeft_pi_geschikte_en_correct_geinitialiseerde_mongodb():
|
|||
"service_completed_successfully"
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
Loading…
Reference in a new issue