Compare commits
No commits in common. "main" and "v0.8.28" have entirely different histories.
26 changed files with 34 additions and 1398 deletions
115
CHANGELOG.md
115
CHANGELOG.md
|
|
@ -1,118 +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
|
# v0.8.28 — Installeren vanaf een ander adres
|
||||||
|
|
||||||
De ingebouwde hostnaam in `install.sh` lost niet overal op. Staat je Forgejo
|
De ingebouwde hostnaam in `install.sh` lost niet overal op. Staat je Forgejo
|
||||||
|
|
|
||||||
45
Dockerfile
45
Dockerfile
|
|
@ -4,16 +4,13 @@ COPY server-up/requirements.txt .
|
||||||
RUN pip install --no-cache-dir --prefix=/inst -r requirements.txt
|
RUN pip install --no-cache-dir --prefix=/inst -r requirements.txt
|
||||||
|
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
# De volgorde hieronder is met opzet: alles wat zelden verandert staat boven,
|
# Build-arg: zet bij build naar de git-tag, zodat de image z'n eigen versie kent.
|
||||||
# alles wat bij elke commit verandert onderaan. Docker hergebruikt een laag
|
# docker build --build-arg SU_VERSION=$(git describe --tags --always) ...
|
||||||
# alleen zolang niets ervóór is gewijzigd, dus één regel op de verkeerde plek
|
# Forgejo Actions doet dit automatisch (zie server-up-deploy/README.md).
|
||||||
# kost bij elke build opnieuw de hele rit.
|
ARG SU_VERSION=dev
|
||||||
#
|
LABEL org.opencontainers.image.title="Server Up" \
|
||||||
# Wat hier eerder misging: `ARG SU_VERSION` met een LABEL erachter stond
|
org.opencontainers.image.version="${SU_VERSION}" \
|
||||||
# bovenaan. Die waarde verandert per release, dus elke uitgave gooide álle
|
org.opencontainers.image.source="https://git.example.com/bes-r/server-up"
|
||||||
# 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.
|
|
||||||
|
|
||||||
# util-linux levert `setpriv`, waarmee het entrypoint naar een gewone gebruiker
|
# util-linux levert `setpriv`, waarmee het entrypoint naar een gewone gebruiker
|
||||||
# afzakt zonder de capabilities te verliezen die Server Up nodig heeft. Het is
|
# 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
|
# 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
|
COPY --from=build /inst /usr/local
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY server-up/ ./
|
||||||
# De front-end-bestanden staan vóór de broncode: hun versies staan hier vast,
|
COPY modules/ ./modules-bundled/
|
||||||
# dus deze lagen blijven staan zolang je ze niet zelf wijzigt. Stonden ze erna,
|
COPY VERSION ./VERSION
|
||||||
# dan haalde elke commit ze opnieuw van het CDN én werden ze bij elke push
|
|
||||||
# opnieuw geüpload.
|
|
||||||
RUN mkdir -p static/fonts \
|
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/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 \
|
&& 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
|
# eerste verbinding en bewaart de sleutel in /data/known_hosts. Wijzigt die
|
||||||
# sleutel later, dan faalt de verbinding — met `no` was elke MITM onzichtbaar.
|
# sleutel later, dan faalt de verbinding — met `no` was elke MITM onzichtbaar.
|
||||||
ENV PYTHONUNBUFFERED=1 PORT=5000 HOME=/root \
|
ENV PYTHONUNBUFFERED=1 PORT=5000 HOME=/root \
|
||||||
|
SU_VERSION=${SU_VERSION} \
|
||||||
SU_CONFIG=/data/config.json SU_AUDIT=/data/audit.db SU_GIT_CACHE=/data/git \
|
SU_CONFIG=/data/config.json SU_AUDIT=/data/audit.db SU_GIT_CACHE=/data/git \
|
||||||
SU_SECRET=/data/secret.key SU_JOBS=/data/jobs \
|
SU_SECRET=/data/secret.key SU_JOBS=/data/jobs \
|
||||||
GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/data/known_hosts"
|
GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/data/known_hosts"
|
||||||
|
|
@ -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.
|
# SU_UID:SU_GID. Zonder die variabelen verandert er niets: dan blijft het root.
|
||||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||||
CMD ["python","app.py"]
|
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.28
|
||||||
|
|
|
||||||
|
|
@ -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'."}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -27,10 +27,7 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
<< service_name >>-mongo:
|
<< service_name >>-mongo:
|
||||||
# Vanaf 4.4.19 eist MongoDB ARMv8.2-A; een Pi 4 (Cortex-A72) heeft dat niet
|
image: mongo:4.4.29
|
||||||
# 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 >>
|
|
||||||
container_name: << service_name >>-mongo
|
container_name: << service_name >>-mongo
|
||||||
environment:
|
environment:
|
||||||
# Dezelfde beheerdersnaam als het allereerste sjabloon gebruikte. Zo
|
# Dezelfde beheerdersnaam als het allereerste sjabloon gebruikte. Zo
|
||||||
|
|
@ -53,18 +50,14 @@ services:
|
||||||
test:
|
test:
|
||||||
- CMD-SHELL
|
- CMD-SHELL
|
||||||
- 'mongo --quiet --eval "db.adminCommand({ ping: 1 })"'
|
- 'mongo --quiet --eval "db.adminCommand({ ping: 1 })"'
|
||||||
# Ruim bemeten voor trage hardware: op een Pi kost het starten van de
|
interval: 10s
|
||||||
# mongo-shell zelf al seconden, en de eerste initialisatie op een
|
timeout: 5s
|
||||||
# SD-kaart duurt langer dan een halve minuut. Op een snelle host is de
|
retries: 12
|
||||||
# container gewoon na de eerste controle gezond.
|
start_period: 30s
|
||||||
interval: 15s
|
|
||||||
timeout: 20s
|
|
||||||
retries: 10
|
|
||||||
start_period: 90s
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
<< service_name >>-mongo-init:
|
<< service_name >>-mongo-init:
|
||||||
image: mongo:<< mongo_version >>
|
image: mongo:4.4.29
|
||||||
container_name: << service_name >>-mongo-init
|
container_name: << service_name >>-mongo-init
|
||||||
environment:
|
environment:
|
||||||
- MONGO_INITDB_ROOT_USERNAME=unifi
|
- MONGO_INITDB_ROOT_USERNAME=unifi
|
||||||
|
|
@ -76,25 +69,11 @@ services:
|
||||||
- MONGO_DBNAME=unifi
|
- MONGO_DBNAME=unifi
|
||||||
- MONGO_AUTHSOURCE=admin
|
- MONGO_AUTHSOURCE=admin
|
||||||
volumes:
|
volumes:
|
||||||
# De hele stackmap en niet het losse script. Een bind-mount naar een
|
- ./init-mongo.sh:/init-mongo.sh:ro
|
||||||
# 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
|
|
||||||
# Dezelfde map als UniFi zelf: hierin staat system.properties, dat het
|
# Dezelfde map als UniFi zelf: hierin staat system.properties, dat het
|
||||||
# image alleen bij de allereerste start schrijft.
|
# image alleen bij de allereerste start schrijft.
|
||||||
- << appdata_dir >>/<< service_name >>/config:/unifi-config
|
- << appdata_dir >>/<< service_name >>/config:/unifi-config
|
||||||
entrypoint:
|
entrypoint: ["/bin/bash", "/init-mongo.sh"]
|
||||||
- /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
|
|
||||||
depends_on:
|
depends_on:
|
||||||
<< service_name >>-mongo:
|
<< service_name >>-mongo:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
"amd64",
|
"amd64",
|
||||||
"arm64"
|
"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": {
|
"web": {
|
||||||
"scheme": "https",
|
"scheme": "https",
|
||||||
"port": 8443
|
"port": 8443
|
||||||
|
|
@ -114,15 +114,6 @@
|
||||||
{
|
{
|
||||||
"title": "Database",
|
"title": "Database",
|
||||||
"items": [
|
"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",
|
"name": "mongo_password",
|
||||||
"type": "str",
|
"type": "str",
|
||||||
|
|
|
||||||
|
|
@ -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
|
return uit
|
||||||
|
|
||||||
|
|
||||||
def _log_waarom_mislukt(qq, pad: Path, naam: str):
|
|
||||||
"""Zet de reden van een mislukte start in hetzelfde joblog.
|
|
||||||
|
|
||||||
Compose zegt alleen `dependency failed to start: container X is unhealthy`.
|
|
||||||
De echte oorzaak staat in de log van díé container, en daar kwam je alleen
|
|
||||||
met `docker logs` — als je wist dat dat kon. Zo staat hij er meteen bij.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
regels = docker.waarom_mislukt(pad, name=naam)
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
if not regels:
|
|
||||||
return
|
|
||||||
jobs.log(qq, "warn", "Dit zeggen de containers die niet opkwamen:")
|
|
||||||
for regel in regels:
|
|
||||||
jobs.log(qq, "dim", regel)
|
|
||||||
|
|
||||||
|
|
||||||
def _auto_backup(qq, stack: str, config_key: str, reden: str):
|
def _auto_backup(qq, stack: str, config_key: str, reden: str):
|
||||||
"""Backup vóór een actie die bestanden overschrijft of weggooit.
|
"""Backup vóór een actie die bestanden overschrijft of weggooit.
|
||||||
|
|
||||||
|
|
@ -896,8 +878,6 @@ def api_stack_action(name, action):
|
||||||
backups.prune(name, log_fn=lambda m: jobs.log(qq, "dim", m))
|
backups.prune(name, log_fn=lambda m: jobs.log(qq, "dim", m))
|
||||||
jobs.log(qq, "ok" if rc == 0 else "error",
|
jobs.log(qq, "ok" if rc == 0 else "error",
|
||||||
f"{action} {'klaar' if rc == 0 else 'mislukt'}")
|
f"{action} {'klaar' if rc == 0 else 'mislukt'}")
|
||||||
if rc != 0:
|
|
||||||
_log_waarom_mislukt(qq, pdir, name)
|
|
||||||
audit.log("stacks", action, "ok" if rc == 0 else "error", ref=name)
|
audit.log("stacks", action, "ok" if rc == 0 else "error", ref=name)
|
||||||
jobs.finish(jid, "done" if rc == 0 else "error")
|
jobs.finish(jid, "done" if rc == 0 else "error")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -1134,10 +1114,6 @@ def api_stack_config(name):
|
||||||
# formulier ze leeg tonen en zou opslaan ze wissen.
|
# formulier ze leeg tonen en zou opslaan ze wissen.
|
||||||
waarden = {**waarden, **_geheimen_uit_env(d, meta)}
|
waarden = {**waarden, **_geheimen_uit_env(d, meta)}
|
||||||
return jsonify(ok=True, editable=True,
|
return jsonify(ok=True, editable=True,
|
||||||
# Is het sjabloon veranderd sinds jij deze app uitrolde?
|
|
||||||
# Opnieuw starten rendert niet opnieuw, dus een verbeterd
|
|
||||||
# sjabloon bereikt je app pas als je hier op opslaan drukt.
|
|
||||||
image_drift=_sjabloon_image_verschil(d, meta, bron, waarden),
|
|
||||||
fields=_annotate_fields(boilerplates.fields(bron)),
|
fields=_annotate_fields(boilerplates.fields(bron)),
|
||||||
values=waarden,
|
values=waarden,
|
||||||
network=meta.get("network", ""), ip=meta.get("ip", ""),
|
network=meta.get("network", ""), ip=meta.get("ip", ""),
|
||||||
|
|
@ -1155,45 +1131,6 @@ def api_stack_config(name):
|
||||||
source_repo=_sjabloon_herkomst(meta.get("repo_id", "")))
|
source_repo=_sjabloon_herkomst(meta.get("repo_id", "")))
|
||||||
|
|
||||||
|
|
||||||
def _sjabloon_image_verschil(d: Path, meta: dict, bron, waarden: dict) -> list[dict]:
|
|
||||||
"""Welke images draait deze app anders dan het sjabloon nu zegt?
|
|
||||||
|
|
||||||
Een app opnieuw starten rendert hem niet opnieuw: het compose-bestand in de
|
|
||||||
stackmap blijft staan zoals het ooit gerenderd is. Een sjabloon dat later
|
|
||||||
verbeterd wordt — een databaseversie die op een Raspberry Pi wél draait,
|
|
||||||
bijvoorbeeld — bereikt zo nooit een bestaande installatie, en aan niets is
|
|
||||||
te zien dat je op opslaan moet drukken.
|
|
||||||
|
|
||||||
Alleen de images vergelijken en niet het hele bestand: de rest van de
|
|
||||||
stackmap krijgt na het renderen nog netwerken, extra's en geheimen
|
|
||||||
overheen, en dan verschilt er altijd wel iets zonder dat het iets betekent.
|
|
||||||
"""
|
|
||||||
if not bron:
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
huidig = {i["service"]: i["image"] for i in
|
|
||||||
compose_transform.images_per_service(docker.read_compose(d))}
|
|
||||||
if not huidig:
|
|
||||||
return []
|
|
||||||
import tempfile as _tmp
|
|
||||||
with _tmp.TemporaryDirectory() as t:
|
|
||||||
boilerplates.render_to_dir(bron, Path(t), waarden)
|
|
||||||
verse = docker.read_compose(Path(t))
|
|
||||||
# De eigen versiekeuze hoort er overheen; anders meldt hij een verschil
|
|
||||||
# dat de gebruiker zelf heeft ingesteld.
|
|
||||||
tags = meta.get("image_tags") or {}
|
|
||||||
if tags:
|
|
||||||
verse = compose_transform.zet_image_tags(verse, tags)
|
|
||||||
nieuw = {i["service"]: i["image"] for i in
|
|
||||||
compose_transform.images_per_service(verse)}
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
|
|
||||||
return [{"service": s, "nu": huidig[s], "sjabloon": nieuw[s]}
|
|
||||||
for s in sorted(nieuw)
|
|
||||||
if s in huidig and huidig[s] != nieuw[s]]
|
|
||||||
|
|
||||||
|
|
||||||
def _sjabloon_herkomst(rid: str) -> dict:
|
def _sjabloon_herkomst(rid: str) -> dict:
|
||||||
"""Repo, branch en stand van de cache waar een sjabloon uit komt."""
|
"""Repo, branch en stand van de cache waar een sjabloon uit komt."""
|
||||||
if not rid:
|
if not rid:
|
||||||
|
|
@ -1417,8 +1354,6 @@ def api_stack_reconfigure(name):
|
||||||
jobs.log(qq, "ok" if rc == 0 else "error",
|
jobs.log(qq, "ok" if rc == 0 else "error",
|
||||||
"Herstart met de nieuwe instellingen" if rc == 0
|
"Herstart met de nieuwe instellingen" if rc == 0
|
||||||
else "Starten mislukt \u2014 zet de backup terug via Backups")
|
else "Starten mislukt \u2014 zet de backup terug via Backups")
|
||||||
if rc != 0:
|
|
||||||
_log_waarom_mislukt(qq, d, name)
|
|
||||||
audit.log("stacks", "reconfigure", "ok" if rc == 0 else "error",
|
audit.log("stacks", "reconfigure", "ok" if rc == 0 else "error",
|
||||||
ref=name, ip=ip)
|
ref=name, ip=ip)
|
||||||
jobs.finish(jid, "done" if rc == 0 else "error")
|
jobs.finish(jid, "done" if rc == 0 else "error")
|
||||||
|
|
@ -2551,8 +2486,6 @@ def api_store_install():
|
||||||
rc = docker.compose_up(dest, log_fn=lambda m: jobs.log(qq, "dim", m), name=inst)
|
rc = docker.compose_up(dest, log_fn=lambda m: jobs.log(qq, "dim", m), name=inst)
|
||||||
jobs.log(qq, "ok" if rc == 0 else "warn",
|
jobs.log(qq, "ok" if rc == 0 else "warn",
|
||||||
"Gestart" if rc == 0 else "Start mislukt")
|
"Gestart" if rc == 0 else "Start mislukt")
|
||||||
if rc != 0:
|
|
||||||
_log_waarom_mislukt(qq, dest, inst)
|
|
||||||
audit.log("store", "install", "ok", ref=inst,
|
audit.log("store", "install", "ok", ref=inst,
|
||||||
detail={"env": list(env_overrides.keys()), "tags": image_tags})
|
detail={"env": list(env_overrides.keys()), "tags": image_tags})
|
||||||
jobs.finish(jid, "done")
|
jobs.finish(jid, "done")
|
||||||
|
|
|
||||||
|
|
@ -494,18 +494,6 @@ def render_to_dir(src: Path, dest: Path, values: dict[str, Any]) -> list[str]:
|
||||||
rel = p.relative_to(files_dir)
|
rel = p.relative_to(files_dir)
|
||||||
out = dest / rel
|
out = dest / rel
|
||||||
out.parent.mkdir(parents=True, exist_ok=True)
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
# Docker maakt een lege map aan waar een compose-bestand een bestand
|
|
||||||
# mount dat nog niet bestaat. Daarna faalt de container met "exit 126"
|
|
||||||
# (Is a directory) en blijft dat zo, want het renderen struikelt hier
|
|
||||||
# over diezelfde map. Opruimen is veilig: hij is per definitie leeg, en
|
|
||||||
# wat erin hoort staat hier in de bron.
|
|
||||||
if out.is_dir() and not out.is_symlink():
|
|
||||||
try:
|
|
||||||
out.rmdir()
|
|
||||||
except OSError as e:
|
|
||||||
raise BoilerplateError(
|
|
||||||
f"{rel} is een map en geen bestand; leeg of verwijder "
|
|
||||||
f"{out} en probeer opnieuw") from e
|
|
||||||
if _looks_textual(p):
|
if _looks_textual(p):
|
||||||
try:
|
try:
|
||||||
txt = p.read_text(encoding="utf-8")
|
txt = p.read_text(encoding="utf-8")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
return _stream(cmd, cwd=str(d), log_fn=log_fn)
|
||||||
|
|
||||||
|
|
||||||
def waarom_mislukt(d: Path, name=None, regels: int = 12) -> list[str]:
|
|
||||||
"""Waarom kwam deze stack niet op? De reden staat in de containerlogs.
|
|
||||||
|
|
||||||
Compose meldt alleen `dependency failed to start: container X is unhealthy`.
|
|
||||||
Dát is geen oorzaak maar een gevolg, en wie de echte reden wil weten moet
|
|
||||||
zelf `docker logs` gaan draaien — als hij weet dát dat kan. Een container
|
|
||||||
die op een Raspberry Pi stopt met "Illegal instruction" bleef zo dagen een
|
|
||||||
raadsel. Deze functie haalt van elke container die niet draait of ongezond
|
|
||||||
is de laatste regels op, zodat ze in hetzelfde joblog belanden.
|
|
||||||
"""
|
|
||||||
uit: list[str] = []
|
|
||||||
for c in compose_ps(d, name=name):
|
|
||||||
naam = c.get("name") or ""
|
|
||||||
status = (c.get("status") or "").lower()
|
|
||||||
if not naam:
|
|
||||||
continue
|
|
||||||
if c.get("running") and "unhealthy" not in status:
|
|
||||||
continue
|
|
||||||
# Wat zei de healthcheck zelf? Vaak staat daar de kern al in.
|
|
||||||
r = _run([_bin(), "inspect", "-f",
|
|
||||||
"{{if .State.Health}}{{range .State.Health.Log}}{{.Output}}{{end}}{{end}}",
|
|
||||||
naam], timeout=15)
|
|
||||||
gezondheid = (r.stdout or "").strip().splitlines()
|
|
||||||
log = container_logs(naam, tail=regels).strip().splitlines()
|
|
||||||
if not gezondheid and not log:
|
|
||||||
continue
|
|
||||||
uit.append(f"── {naam}: {c.get('status') or 'gestopt'}")
|
|
||||||
for regel in gezondheid[-3:]:
|
|
||||||
if regel.strip():
|
|
||||||
uit.append(f" healthcheck: {regel.strip()[:200]}")
|
|
||||||
for regel in log[-regels:]:
|
|
||||||
if regel.strip():
|
|
||||||
uit.append(f" {regel.strip()[:200]}")
|
|
||||||
return uit
|
|
||||||
|
|
||||||
|
|
||||||
def compose_down(d: Path, log_fn=None, volumes=False, name=None) -> int:
|
def compose_down(d: Path, log_fn=None, volumes=False, name=None) -> int:
|
||||||
f = find_compose(d)
|
f = find_compose(d)
|
||||||
if not f:
|
if not f:
|
||||||
|
|
|
||||||
|
|
@ -82,15 +82,6 @@ def image_repo(info: dict | None = None) -> str:
|
||||||
"""
|
"""
|
||||||
expliciet = (cfg.load().get("UPDATE_IMAGE") or "").strip()
|
expliciet = (cfg.load().get("UPDATE_IMAGE") or "").strip()
|
||||||
if expliciet:
|
if expliciet:
|
||||||
# Een image-adres heeft nooit een schema. Wie het uit zijn adresbalk
|
|
||||||
# plakt neemt `https://` mee, en dan faalt `docker pull` op iets wat er
|
|
||||||
# in het veld goed uitziet. Weghalen is hier eenduidig: er bestaat geen
|
|
||||||
# geldig image dat zo begint.
|
|
||||||
for schema in ("https://", "http://"):
|
|
||||||
if expliciet.lower().startswith(schema):
|
|
||||||
expliciet = expliciet[len(schema):]
|
|
||||||
break
|
|
||||||
expliciet = expliciet.strip("/")
|
|
||||||
return expliciet.rsplit(":", 1)[0] if _has_tag(expliciet) else expliciet
|
return expliciet.rsplit(":", 1)[0] if _has_tag(expliciet) else expliciet
|
||||||
img = (info or inspect_self()).get("image", "")
|
img = (info or inspect_self()).get("image", "")
|
||||||
if not img:
|
if not img:
|
||||||
|
|
@ -186,22 +177,8 @@ def pull(tag: str, log_fn=None) -> tuple[bool, str]:
|
||||||
log_fn(f"Ophalen {image}…")
|
log_fn(f"Ophalen {image}…")
|
||||||
rc = docker._stream([docker._bin(), "pull", image], log_fn=log_fn)
|
rc = docker._stream([docker._bin(), "pull", image], log_fn=log_fn)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
# "Not found" is bijna nooit een toegangsprobleem: er staat gewoon geen
|
return False, (f"Ophalen van {image} mislukt. Bestaat die tag in de "
|
||||||
# image met die tag. De release bestaat wel — die maakt Forgejo bij de
|
"registry, en heeft deze server er toegang toe?")
|
||||||
# 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 True, image
|
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
|
return tag[1:] if tag.startswith("v") and _parse(tag) else tag
|
||||||
|
|
||||||
|
|
||||||
def api_url(url: str) -> str:
|
|
||||||
"""Maak van een geplakt adres het API-adres.
|
|
||||||
|
|
||||||
Wie in Forgejo of GitHub naar zijn releases kijkt en dat adres uit de
|
|
||||||
adresbalk plakt, geeft het adres van een wébpagina op. Dat levert HTML op,
|
|
||||||
en de enige melding die je daarna kreeg was `JSONDecodeError: Expecting
|
|
||||||
value: line 1 column 1` — waaruit niemand opmaakt dat er `/api/v1/` had
|
|
||||||
moeten staan. Dus rekenen we het om in plaats van erover te vallen.
|
|
||||||
|
|
||||||
https://git.voorbeeld.nl/bes-r/server-up/releases
|
|
||||||
→ https://git.voorbeeld.nl/api/v1/repos/bes-r/server-up/releases
|
|
||||||
https://github.com/bes-r/server-up/releases
|
|
||||||
→ https://api.github.com/repos/bes-r/server-up/releases
|
|
||||||
"""
|
|
||||||
schoon = (url or "").strip().rstrip("/")
|
|
||||||
if not schoon or "/api/" in schoon:
|
|
||||||
return schoon
|
|
||||||
m = re.match(r"^(https?)://([^/]+)/([^/]+)/([^/]+?)(?:/releases.*|\.git)?$",
|
|
||||||
schoon)
|
|
||||||
if not m:
|
|
||||||
return schoon
|
|
||||||
schema, host, eigenaar, repo = m.groups()
|
|
||||||
if host.lower() in ("github.com", "www.github.com"):
|
|
||||||
return f"https://api.github.com/repos/{eigenaar}/{repo}/releases"
|
|
||||||
return f"{schema}://{host}/api/v1/repos/{eigenaar}/{repo}/releases"
|
|
||||||
|
|
||||||
|
|
||||||
def _releases(url: str) -> list[dict]:
|
def _releases(url: str) -> list[dict]:
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
url, headers={"Accept": "application/json", "User-Agent": "server-up"})
|
url, headers={"Accept": "application/json", "User-Agent": "server-up"})
|
||||||
with urllib.request.urlopen(req, timeout=8) as r:
|
with urllib.request.urlopen(req, timeout=8) as r:
|
||||||
rauw = r.read().decode("utf-8", "replace")
|
data = json.loads(r.read().decode("utf-8"))
|
||||||
try:
|
|
||||||
data = json.loads(rauw)
|
|
||||||
except ValueError:
|
|
||||||
# Bijna altijd een webpagina in plaats van de API. Zeg dát, in plaats
|
|
||||||
# van de gebruiker met een JSON-fout te laten zitten.
|
|
||||||
raise ValueError(
|
|
||||||
f"{url} geeft geen JSON terug maar een webpagina. Vul het "
|
|
||||||
"API-adres in, met /api/v1/repos/ erin.") from None
|
|
||||||
return data if isinstance(data, list) else [data]
|
return data if isinstance(data, list) else [data]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -133,7 +98,7 @@ def check(current: str, force: bool = False) -> dict:
|
||||||
return dict(_CACHE)
|
return dict(_CACHE)
|
||||||
|
|
||||||
c = cfg.load()
|
c = cfg.load()
|
||||||
url = api_url(c.get("UPDATE_API_URL") or "")
|
url = (c.get("UPDATE_API_URL") or "").strip()
|
||||||
ch = channel()
|
ch = channel()
|
||||||
res = {"enabled": bool(url), "current": current, "channel": ch,
|
res = {"enabled": bool(url), "current": current, "channel": ch,
|
||||||
"current_is_prerelease": is_prerelease(current),
|
"current_is_prerelease": is_prerelease(current),
|
||||||
|
|
|
||||||
|
|
@ -1776,25 +1776,6 @@ tailwind.config = {
|
||||||
<span x-text="t('reconfigure_warning')"></span>
|
<span x-text="t('reconfigure_warning')"></span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Het sjabloon is veranderd sinds deze app is uitgerold. Opnieuw
|
|
||||||
starten rendert niet opnieuw, dus zonder deze melding blijf je op de
|
|
||||||
oude versie zitten zonder te weten dat er een betere klaarstaat. -->
|
|
||||||
<div x-show="(install.image_drift || []).length"
|
|
||||||
class="text-[11px] rounded-xl border border-ac/40 bg-ac/5 px-3 py-2 flex items-start gap-2">
|
|
||||||
<span class="mdi mdi-package-up text-ac dark:text-ac-dark shrink-0"></span>
|
|
||||||
<span class="min-w-0">
|
|
||||||
<span x-text="t('image_drift')"></span>
|
|
||||||
<template x-for="v in (install.image_drift || [])" :key="v.service">
|
|
||||||
<span class="block font-mono text-[10px] mt-0.5">
|
|
||||||
<span x-text="v.service"></span>:
|
|
||||||
<span class="line-through opacity-70" x-text="v.nu"></span>
|
|
||||||
<span class="mdi mdi-arrow-right"></span>
|
|
||||||
<span class="text-ac dark:text-ac-dark" x-text="v.sjabloon"></span>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Waar het sjabloon vandaan komt. Opslaan rendert de app opnieuw uit
|
<!-- Waar het sjabloon vandaan komt. Opslaan rendert de app opnieuw uit
|
||||||
déze cache: loopt die achter, dan trek je een oude versie over een
|
déze cache: loopt die achter, dan trek je een oude versie over een
|
||||||
werkende stack heen zonder dat iets dat zegt. -->
|
werkende stack heen zonder dat iets dat zegt. -->
|
||||||
|
|
@ -4032,7 +4013,6 @@ function app() {
|
||||||
app_services: [], depends_on: [],
|
app_services: [], depends_on: [],
|
||||||
extras: { volumes: [], devices: [] },
|
extras: { volumes: [], devices: [] },
|
||||||
image_tags: {},
|
image_tags: {},
|
||||||
image_drift: [],
|
|
||||||
compatibility: { known: false, supported: true, architecture: '', reason: '' },
|
compatibility: { known: false, supported: true, architecture: '', reason: '' },
|
||||||
architecture_note: ''
|
architecture_note: ''
|
||||||
};
|
};
|
||||||
|
|
@ -4291,7 +4271,6 @@ function app() {
|
||||||
extras: { volumes: (d.extras||{}).volumes || [],
|
extras: { volumes: (d.extras||{}).volumes || [],
|
||||||
devices: (d.extras||{}).devices || [] },
|
devices: (d.extras||{}).devices || [] },
|
||||||
image_tags: { ...(d.image_tags || {}) },
|
image_tags: { ...(d.image_tags || {}) },
|
||||||
image_drift: d.image_drift || [],
|
|
||||||
};
|
};
|
||||||
await this.loadAppNets(stack);
|
await this.loadAppNets(stack);
|
||||||
this.fieldInfo = null;
|
this.fieldInfo = null;
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,7 @@
|
||||||
"release_notes": "Release notes",
|
"release_notes": "Release notes",
|
||||||
"view_release": "View release",
|
"view_release": "View release",
|
||||||
"update_api_url": "Releases API URL",
|
"update_api_url": "Releases API URL",
|
||||||
"update_api_hint": "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)",
|
"include_prerelease": "Include pre-releases (beta)",
|
||||||
"login": "Sign in",
|
"login": "Sign in",
|
||||||
"login_failed": "Sign-in failed",
|
"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_keep_data_hint": "Stops the containers and removes the app configuration. App data and named volumes are preserved.",
|
||||||
"remove_all_hint": "Also removes app data and non-external named volumes. This cannot be undone.",
|
"remove_all_hint": "Also removes app data and non-external named volumes. This cannot be undone.",
|
||||||
"remove_done": "App removed",
|
"remove_done": "App removed",
|
||||||
"remove_failed": "Failed to remove app",
|
"remove_failed": "Failed to remove app"
|
||||||
"image_drift": "The template has been updated since you deployed this app. Saving adopts the new version; restarting does not."
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,7 @@
|
||||||
"release_notes": "Release notes",
|
"release_notes": "Release notes",
|
||||||
"view_release": "Bekijk release",
|
"view_release": "Bekijk release",
|
||||||
"update_api_url": "Releases-API URL",
|
"update_api_url": "Releases-API URL",
|
||||||
"update_api_hint": "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",
|
"include_prerelease": "Pre-releases (beta) meenemen",
|
||||||
"login": "Inloggen",
|
"login": "Inloggen",
|
||||||
"login_failed": "Inloggen mislukt",
|
"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_keep_data_hint": "Stopt de containers en verwijdert de appconfiguratie. Appdata en named volumes blijven bewaard.",
|
||||||
"remove_all_hint": "Verwijdert ook appdata en niet-externe named volumes. Dit kan niet ongedaan worden gemaakt.",
|
"remove_all_hint": "Verwijdert ook appdata en niet-externe named volumes. Dit kan niet ongedaan worden gemaakt.",
|
||||||
"remove_done": "App verwijderd",
|
"remove_done": "App verwijderd",
|
||||||
"remove_failed": "App verwijderen mislukt",
|
"remove_failed": "App verwijderen mislukt"
|
||||||
"image_drift": "Het sjabloon is bijgewerkt sinds je deze app uitrolde. Opslaan neemt de nieuwe versie over; opnieuw starten doet dat niet."
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -157,8 +157,8 @@ GEDEELDE_POORTEN = {
|
||||||
80: {"nginx-proxy-manager", "traefik", "caddy", "zoraxy", "pangolin"}, # reverse proxies
|
80: {"nginx-proxy-manager", "traefik", "caddy", "zoraxy", "pangolin"}, # reverse proxies
|
||||||
443: {"nginx-proxy-manager", "traefik", "caddy", "zoraxy", "pangolin"},
|
443: {"nginx-proxy-manager", "traefik", "caddy", "zoraxy", "pangolin"},
|
||||||
53: {"adguard-home", "pihole"}, # dns-blokkers
|
53: {"adguard-home", "pihole"}, # dns-blokkers
|
||||||
# Drie manieren om een WireGuard-eindpunt te draaien; je gebruikt er één.
|
# Twee manieren om een WireGuard-tunnel te draaien; je gebruikt er één.
|
||||||
51820: {"pangolin", "wg-easy", "wireguard"},
|
51820: {"pangolin", "wg-easy"},
|
||||||
# Hieronder: botsingen die er al waren vóór deze testsuite. De
|
# Hieronder: botsingen die er al waren vóór deze testsuite. De
|
||||||
# vrije-poortsuggestie bij installeren vangt ze op, dus ze zijn hinderlijk
|
# vrije-poortsuggestie bij installeren vangt ze op, dus ze zijn hinderlijk
|
||||||
# maar niet kapot. Nieuwe apps horen hier niet bij te komen.
|
# maar niet kapot. Nieuwe apps horen hier niet bij te komen.
|
||||||
|
|
@ -406,20 +406,17 @@ def test_unifi_heeft_pi_geschikte_en_correct_geinitialiseerde_mongodb():
|
||||||
mongo = doc["services"]["unifi-mongo"]
|
mongo = doc["services"]["unifi-mongo"]
|
||||||
reparatie = doc["services"]["unifi-mongo-init"]
|
reparatie = doc["services"]["unifi-mongo-init"]
|
||||||
unifi = doc["services"]["unifi"]
|
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 "./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 unifi["environment"])
|
||||||
assert any(v == "MONGO_AUTHSOURCE=admin" for v in mongo["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"]["condition"] == "service_healthy"
|
||||||
assert unifi["depends_on"]["unifi-mongo-init"]["condition"] == \
|
assert unifi["depends_on"]["unifi-mongo-init"]["condition"] == \
|
||||||
"service_completed_successfully"
|
"service_completed_successfully"
|
||||||
|
assert reparatie["image"] == "mongo:4.4.29"
|
||||||
assert reparatie["depends_on"]["unifi-mongo"]["condition"] == "service_healthy"
|
assert reparatie["depends_on"]["unifi-mongo"]["condition"] == "service_healthy"
|
||||||
assert reparatie["restart"] == "no"
|
assert reparatie["restart"] == "no"
|
||||||
# De hele map en niet het losse bestand: een bind-mount naar een bestand
|
assert "./init-mongo.sh:/init-mongo.sh:ro" in reparatie["volumes"]
|
||||||
# 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 any(v == "MONGO_HOST=unifi-mongo" for v in reparatie["environment"])
|
assert any(v == "MONGO_HOST=unifi-mongo" for v in reparatie["environment"])
|
||||||
# De beheerdersnaam van het allereerste sjabloon. Een volume van toen kent
|
# De beheerdersnaam van het allereerste sjabloon. Een volume van toen kent
|
||||||
# geen 'root', en dan mislukte elke reparatie én elke mongodump.
|
# 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)
|
meta = bp.metadata(WORTEL / "apps" / naam)
|
||||||
assert meta["architectures"] == ["amd64"], naam
|
assert meta["architectures"] == ["amd64"], naam
|
||||||
assert "Raspberry Pi" in meta["architecture_note"], 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
|
|
||||||
|
|
|
||||||
|
|
@ -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