Compare commits

...

17 commits

Author SHA1 Message Date
Ramon
850a80f86b release: v0.8.28
Some checks failed
Deploy server-up (dev) / deploy (push) Has been cancelled
Release / release (push) Successful in 3s
Deploy server-up (prod) / deploy (push) Successful in 12m56s
install.sh krijgt --host, zodat een server die de ingebouwde hostnaam niet
kan oplossen toch bij de broncode kan. Bij een bestaande installatie wordt de
git-remote meteen omgezet, anders had de optie daar geen effect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
2026-08-07 14:05:29 +02:00
Ramon
ac3a357ed7 release: v0.8.27
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 4m32s
Deploy server-up (prod) / deploy (push) Successful in 13m34s
Release / release (push) Successful in 3s
Eerste stabiele uitgave sinds v0.8.12: de UniFi-storing verholpen, koppelen
laat databases met rust, eigen mappen en apparaten per app, herkomst van
sjablonen zichtbaar, en de vier verbeteringen rond wachtwoorden, wachtrijen,
snelheid en versietags.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
2026-08-07 08:25:59 +02:00
Ramon
d3ebf40165 feat: wachtwoordsync, slot per app, sneller dashboard en versietags
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 4m37s
Vier verbeteringen die elk pas opvielen als je ze nodig had.

- Een gewijzigd databasewachtwoord wordt vóór de herstart omgezet in de nog
  draaiende database (ALTER USER / updateUser). Lukt dat niet, dan breekt de
  wijziging af en gaat de backup terug — 64 sjablonen hebben zo'n veld, en
  POSTGRES_PASSWORD werkt alleen bij een lege datamap. Wachtwoorden gaan via
  de omgeving en via stdin, niet via argumenten.
- jobs.met_slot serialiseert taken per app; de geplande backup slaat een
  bezette app over in plaats van een half herschreven map in te pakken. Een
  taak die klapt laat het slot los en wordt als mislukt afgesloten.
- api_stacks en api_connectable gebruiken één docker ps met het projectlabel
  in plaats van een compose ps per stackmap.
- Per service een versietag te kiezen, bewaard in .serverup.json en opnieuw
  toegepast na het renderen; het containerpaneel toont het draaiende image.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
2026-08-07 00:14:26 +02:00
Ramon
07d5effcec feat(apps): eigen mappen en apparaten per app instelbaar
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 6m57s
Een mediamap koppelen of een USB-stick doorgeven kon alleen door het
compose-bestand met de hand te bewerken, en dat werd bij de eerstvolgende
'Instellingen wijzigen' overschreven.

- Nieuwe stap 'Extra's' in het invulmenu: per onderdeel een map van de host
  (optioneel alleen lezen) of een apparaat.
- De keuzes staan in .serverup.json en worden ná het renderen toegepast, dus
  ze overleven elke wijziging.
- /api/devices vindt seriële poorten (/dev/serial/by-id, ttyUSB*, ttyACM*) en
  videokaarten, met een 'vast'-label voor by-id-paden.
- Het hostpad wordt vooraf gecontroleerd via /api/paths/check.
- Docker-socket, /etc, /proc, /sys en /boot worden geweigerd met uitleg, in
  compose_transform en nog eens in de API.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
2026-08-06 23:32:10 +02:00
Ramon
b40a6a5a4c feat(apps): toon uit welk sjabloon een app gerenderd wordt
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 5m17s
Sjablonen komen uit de git-cache van een app-repo. Loopt die achter, dan trekt
'Instellingen wijzigen' een oude versie over een werkende app heen zonder dat
iets dat zegt — precies wat er bij UniFi gebeurde.

- git.cache_status geeft commit, datum, synchronisatiemoment en de VERSION uit
  de cache terug.
- /api/repos en /api/stacks/<naam>/config sturen die stand mee, met een
  outdated-vlag als de repo-VERSION ouder is dan de draaiende versie.
- Het wijzigscherm toont repo, branch en hoe lang geleden er opgehaald is, en
  waarschuwt bij een achterlopende cache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
2026-08-06 23:21:21 +02:00
Ramon
c658418b10 fix(netwerk): koppel niet langer de database aan het gedeelde netwerk
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 6m16s
add_shared_network hing elke service van een stack aan het gedeelde netwerk.
Eén druk op 'koppelen' maakte daarmee de database van die app bereikbaar voor
elke andere gekoppelde app — en niet elk sjabloon zet daar een wachtwoord op.

- Standaard gaan alleen services mee die geen database, cache of zoekindex
  zijn; brokers als mosquitto en rabbitmq blijven wel deelbaar.
- Per onderdeel te kiezen in de stap 'Verbinden'; de keuze staat in
  .serverup.json en geldt ook bij een volgende wijziging.
- mongodump/mongorestore sturen geen inloggegevens meer mee bij een database
  zonder authenticatie; die dump mislukte altijd.
- Joblogs worden bewaard in SU_JOBS (standaard /data/jobs) en teruggelezen na
  een herstart; een afgekapte taak meldt dat in plaats van 'unknown'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
2026-08-06 23:14:24 +02:00
Ramon
483f80e032 feat(repos): branch van een app-repo tonen en wijzigen
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 6m45s
Sjablonen komen uit de git-cache van een app-repo, niet uit het image. Welke
branch die volgt was nergens te zien en alleen te wijzigen door de repo te
verwijderen en opnieuw toe te voegen — waarbij de id een volgnummer krijgt en
het bronsjabloon van bestaande apps onvindbaar wordt.

- PUT /api/repos/<id> past naam, branch, submap en optioneel de URL aan; de id
  blijft staan zodat .serverup.json van bestaande stacks blijft kloppen.
- Een leeg tokenveld laat het bestaande token staan.
- De repolijst toont de branch en heeft een bewerkknop; na opslaan wordt de
  repo meteen opnieuw opgehaald.
- Nieuwe auditactie 'edit' vertaald in nl en en.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
2026-08-06 22:10:42 +02:00
Ramon
dce4e34894 fix(unifi): databasegegevens bij elke start gelijktrekken
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 5m46s
Het linuxserver-image schrijft /config/data/system.properties alleen bij de
allereerste start. De authSource en het wachtwoord die daarna in Compose
veranderden kwamen er nooit in, dus UniFi bleef verbinden met de gegevens van
de eerste installatie — MongoDB antwoordde met UserNotFound: unifi@unifi.

- De reparatiecontainer zet db.mongo.uri, statdb.mongo.uri, db.mongo.local en
  unifi.db.name opnieuw uit de actuele omgeving; overige instellingen blijven.
- Gebruikersnaam en wachtwoord worden url-gecodeerd in de URI gezet.
- Het bestand wordt op zijn plaats overschreven, dus eigenaar en rechten
  blijven van UniFi zelf.
- Het wachtwoord uit system.properties telt mee als inlogpoging, zodat een
  afgeweken .env de stack niet onherstelbaar maakt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
2026-08-06 20:24:54 +02:00
Ramon
3ee09eebc7 fix(stacks): geslaagde wijziging niet meer terugdraaien
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 4m12s
Het audit-logboek las request.remote_addr uit in de achtergrondthread van de
job. De RuntimeError die daarop volgde belandde in de foutafhandeling, die de
zojuist gemaakte backup terugzette — inclusief het oude compose-bestand.

- Adres vóór de threadstart vastleggen bij herconfigureren, koppelen, backup
  maken en terugrollen.
- UniFi: init-script probeert de beheerders 'unifi' en 'root' en herkent een
  server zonder authenticatie, zodat ook een oud Mongo-volume gerepareerd
  wordt; bestaande rollen blijven staan en 'backup'/'restore' worden aangevuld.
- UniFi: healthcheck van Mongo zonder inloggegevens, anders bleef de container
  ongezond en startte de reparatiecontainer nooit.
- Backups: mongodump/mongorestore gebruiken de beheerder uit compose in plaats
  van de verzonnen gebruiker 'app'.
- Appkaart: knop die de webinterface opent, met het adres uit /api/stacks.
- Joblog: geen dubbele regels meer doordat polls elkaar konden overlappen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
2026-08-06 16:44:01 +02:00
Ramon
6f549357b1 fix(unifi): herstel MongoDB-gebruiker
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 5m44s
2026-08-06 09:36:37 +02:00
Ramon
da9981378b fix(ui): herstel mobiele appacties
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 4m39s
2026-08-06 09:11:43 +02:00
Ramon
8f62df90d8 fix(unifi): open webinterface via https
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 5m46s
2026-08-06 08:39:26 +02:00
Ramon
9008e39fe4 feat(apps): voeg elf ARM64-geschikte apps toe
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 8m14s
2026-08-06 00:34:42 +02:00
Ramon
75eb24741f fix: onthoud gesloten startwizard
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 6m7s
2026-08-04 22:16:11 +02:00
Ramon
6c84def759 fix: herstel app verwijderen en mobiele modals
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 4m46s
2026-08-04 18:50:16 +02:00
Ramon
1575db42c2 v0.8.14-beta - Raspberry Pi compatibiliteit en UniFi Mongo herstellen
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 5m5s
2026-08-04 16:49:24 +02:00
Ramon
6cc078e7b9 v0.8.13-beta - updates voor beta en releases herstellen
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 7m43s
2026-08-04 16:10:59 +02:00
90 changed files with 5415 additions and 228 deletions

View file

@ -23,6 +23,10 @@ PORT=5000
# hand aanpassen kan om terug te rollen naar een eerdere versie.
#SU_TAG=0.5.40-beta
# De versie die bij die image-tag hoort. De deploy-workflow en updatehelper
# houden SU_TAG en SU_VERSION samen bij; normaal hoef je dit niet zelf te zetten.
#SU_VERSION=0.5.40-beta
# Registry-image, zónder tag. Staat dit ingevuld, dan draait deze server vanaf
# een registry en werkt de knop "Nu bijwerken" in de interface. Laat het leeg
# als de server zelf bouwt na een push naar Git — dan is Git de update-route.

View file

@ -40,6 +40,7 @@ jobs:
if [ -z "$VERSION" ]; then
VERSION="$(tr -d '[:space:]' < VERSION)"
fi
VERSION="${VERSION#v}"
IMAGE="${{ vars.REGISTRY }}/${{ vars.OWNER }}/server-up"
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
echo "IMAGE=${IMAGE}" >> "$GITHUB_ENV"
@ -131,6 +132,7 @@ jobs:
--pull \
--build-arg "SU_VERSION=${VERSION}" \
-t "${IMAGE}:${VERSION}" \
-t "${IMAGE}:v${VERSION}" \
-t "${IMAGE}:${EXTRA_TAG}" \
.
@ -138,8 +140,11 @@ jobs:
run: |
set -euo pipefail
docker push "${IMAGE}:${VERSION}"
# Compatibiliteit met Server Up <= 0.8.12: die vroeg ten onrechte
# om de Forgejo-tag mét v. Nieuwe versies gebruiken de kale tag.
docker push "${IMAGE}:v${VERSION}"
docker push "${IMAGE}:${EXTRA_TAG}"
echo "Gepusht: ${IMAGE}:${VERSION} en ${IMAGE}:${EXTRA_TAG}"
echo "Gepusht: ${IMAGE}:${VERSION}, :v${VERSION} en :${EXTRA_TAG}"
- name: Uitloggen
if: always()

View file

@ -146,8 +146,8 @@ jobs:
if [ -w .env ] || { [ ! -e .env ] && [ -w . ]; }; then
touch .env
sed -i '/^SU_TAG=/d' .env
echo "SU_TAG=${VERSION}" >> .env
sed -i '/^SU_TAG=/d; /^SU_VERSION=/d' .env
printf 'SU_TAG=%s\nSU_VERSION=%s\n' "${VERSION}" "${VERSION}" >> .env
echo "Inhoud van $(pwd)/.env:"
grep -vE '^\s*(#|$)' .env | sed 's/^/ /'
else
@ -172,6 +172,11 @@ jobs:
| docker login "${{ vars.REGISTRY }}" -u "${{ vars.OWNER }}" --password-stdin
docker tag "${SU_IMAGE}:${VERSION}" "${SU_IMAGE}:${CHANNEL}" || true
docker push "${SU_IMAGE}:${VERSION}"
# Compatibiliteit met Server Up <= 0.8.12: de oude updater vroeg
# om de Forgejo-tag mét v. Hiermee kan die zichzelf één keer naar
# de gerepareerde updater brengen; nieuwe versies gebruiken de kale tag.
docker tag "${SU_IMAGE}:${VERSION}" "${SU_IMAGE}:v${VERSION}"
docker push "${SU_IMAGE}:v${VERSION}"
# Pre-releases krijgen geen 'latest': dat hoort bij stable.
case "$VERSION" in
*-*) docker tag "${SU_IMAGE}:${VERSION}" "${SU_IMAGE}:beta"

View file

@ -149,8 +149,8 @@ jobs:
if [ -w .env ] || { [ ! -e .env ] && [ -w . ]; }; then
touch .env
sed -i '/^SU_TAG=/d' .env
echo "SU_TAG=${VERSION}" >> .env
sed -i '/^SU_TAG=/d; /^SU_VERSION=/d' .env
printf 'SU_TAG=%s\nSU_VERSION=%s\n' "${VERSION}" "${VERSION}" >> .env
echo "Inhoud van $(pwd)/.env:"
grep -vE '^\s*(#|$)' .env | sed 's/^/ /'
else

11
AGENTS.md Normal file
View file

@ -0,0 +1,11 @@
# Projectafspraken
- Werk nooit rechtstreeks op main
- Push naar de dev branch
- Werk met versienummering (v0.0.00)
- In de dev-branch altijd beta weergeven bij de versienummering
- Beschrijf gewijzigde functionaliteiten puntsgewijs in de pull request.
- Gebruik geen sudo
- Commit geen wachtwoorden, tokens, `.env`-bestanden of privésleutels.
- Voer relevante tests uit voor iedere commit.
- Push wijzigingen naar Forgejo, maar merge niet zelfstandig.
- Bij opdracht "Release versie" maak een versie releaseversie

View file

@ -1,3 +1,348 @@
# v0.8.28 — Installeren vanaf een ander adres
De ingebouwde hostnaam in `install.sh` lost niet overal op. Staat je Forgejo
achter een naam die alleen op je eigen netwerk werkt, dan strandt bijwerken
vanaf een andere locatie op `could not resolve host` — ook door een tunnel,
want die repareert de naam niet.
- **`--host ADRES`** geeft aan waar de broncode staat: een naam, een IP of een
tailnet-adres. Een schema mag mee (`--host http://10.0.20.22:3000`), want een
IP over https levert een certificaatfout op.
- **Een bestaande installatie gaat mee.** Die haalt op via de remote die in de
kloon staat — precies het adres dat het niet doet. Met `--host` wordt die
remote eerst omgezet, zodat bijwerken daarna vanzelf via het nieuwe adres
gaat.
- **`--branch` accepteert ook een tag**, en dat staat nu ook in de hulptekst.
# v0.8.27 — Wachtwoorden, wachtrijen, snelheid en versies
Eerste stabiele uitgave sinds v0.8.12. Wie vanaf die versie bijwerkt, krijgt de
hele beta-reeks v0.8.13 tot en met v0.8.26 er in één keer bij; de losse secties
daarvan staan hieronder. De hoofdpunten:
- **UniFi komt weer online.** Een geslaagde instellingswijziging werd door een
fout in het audit-logboek meteen teruggedraaid, en UniFi's eigen kopie van de
databasegegevens werd nooit bijgewerkt. Beide zijn verholpen; de MongoDB-
gebruiker wordt ook op een oud volume gerepareerd.
- **Koppelen laat je database met rust.** Alleen de app zelf gaat op het
gedeelde netwerk, niet zijn opslag.
- **Eigen mappen en apparaten per app.** Een mediamap of USB-stick koppelen
zonder het compose-bestand met de hand te bewerken, en zonder dat een
volgende wijziging het weer weghaalt.
- **Zichtbaar waar een sjabloon vandaan komt**, met een waarschuwing als de
app-repo achterloopt op de draaiende versie — en de branch van een repo is nu
te zien en te wijzigen.
En de vier verbeteringen van deze uitgave zelf, die alle vier dezelfde vorm
hebben: iets dat er goed uitzag totdat je het nodig had.
- **Een databasewachtwoord wijzigen breekt je app niet meer.**
`POSTGRES_PASSWORD` en verwanten worden door de officiële images alleen bij
een lege datamap gebruikt. Wie dat veld wijzigde kreeg een app met een nieuw
wachtwoord en een database met het oude — 64 van de sjablonen hebben zo'n
veld. Server Up zet het wachtwoord nu eerst om in de nog draaiende database
(`ALTER USER`, `updateUser`) en breekt de wijziging af als dat niet lukt.
Het oude en nieuwe wachtwoord gaan daarbij via de omgeving, niet via de
argumenten: die zijn op de host zichtbaar in de procestabel.
- **Eén taak tegelijk per app.** De nachtelijke backup kon starten midden in
een instellingswijziging en zo een half herschreven map inpakken. Taken op
dezelfde app wachten nu netjes op elkaar; de geplande backup slaat een
bezette app over en meldt dat. Een taak die klapt laat het slot los en komt
niet meer voor eeuwig op 'bezig' te staan.
- **Het dashboard doet één docker-aanroep in plaats van één per app.** Bij
vijftien apps waren dat vijftien processen per keer verversen, en nog eens
zoveel bij elk openen van de installatiemodal.
- **Versie per app te kiezen.** De meeste sjablonen staan op `latest`, waardoor
Bijwerken je ongemerkt over een majorversie kan tillen. In de stap 'Extra's'
zet je per onderdeel een versie vast; die overleeft elke volgende wijziging.
In het containerpaneel staat welke versie er nu draait.
# v0.8.26-beta — Eigen mappen en apparaten per app
Een sjabloon kan niet elke situatie voorzien: een mediamap die ergens anders
staat, een USB-stick voor Zigbee, een videokaart om mee te transcoderen. Dat
kon alleen door het compose-bestand met de hand te bewerken — en dat werd bij
de eerstvolgende 'Instellingen wijzigen' weer overschreven.
- **Nieuwe stap 'Extra's' in het invulmenu.** Per onderdeel een map van de host
koppelen (met een vinkje 'alleen lezen') of een apparaat doorgeven.
- **Ze overleven een wijziging.** De keuzes staan in `.serverup.json` en worden
ná het renderen opnieuw toegepast, net als de netwerkinstellingen.
- **Apparaten worden gevonden.** Server Up leest `/dev/serial/by-id`,
`ttyUSB*`, `ttyACM*` en `/dev/dri` uit en biedt ze aan met één klik. Een
`by-id`-pad blijft gelijk als je de stick in een andere poort steekt, en
krijgt daarom het label 'vast'.
- **Padcontrole vooraf.** Een map die Server Up niet kan bereiken meldt zich
meteen, in plaats van een lege mount in de container op te leveren.
- **Gevaarlijke mounts worden geweigerd.** De Docker-socket, `/etc`, `/proc`,
`/sys` en `/boot` gaan niet via dit scherm: die geven een container de macht
over de hele host. Je krijgt te horen waaróm, in plaats van een stille
correctie.
# v0.8.25-beta — Zichtbaar uit welk sjabloon een app komt
Sjablonen komen uit de git-cache van een app-repo, niet uit het image van
Server Up. Die twee kunnen ver uit elkaar lopen: staat de repo op een branch
die achterloopt, dan rendert 'Instellingen wijzigen' een oud sjabloon over een
werkende app heen — en daar was aan niets te zien.
- **Het wijzigscherm noemt zijn bron.** Repo, branch en hoe lang geleden er
gesynchroniseerd is, boven de velden waar je op opslaan drukt.
- **Een achterlopende repo waarschuwt.** Levert de repo zelf een `VERSION` mee
(zoals deze) en is die ouder dan de draaiende versie, dan zegt het scherm dat
met de versie erbij.
- **De repolijst toont de stand.** Commit en synchronisatiemoment per repo,
zodat je niet hoeft te gokken of een sync nog nodig is.
# v0.8.24-beta — Koppelen laat je database met rust
Koppelen aan het gedeelde netwerk hing élke service van een stack eraan, ook de
database. Eén druk op de knop maakte `roosterwijs-db` of `graylog-mongo` op
containernaam bereikbaar voor elke andere gekoppelde app — en lang niet elk
sjabloon zet een wachtwoord op zijn database.
- **Alleen de app gaat mee, niet zijn opslag.** Databases, caches en
zoekindexen blijven standaard binnen hun eigen stack. Een MQTT-broker of
RabbitMQ hoort daar bewust niet bij: die dééllen is juist waarvoor het
gedeelde netwerk bestaat.
- **Per onderdeel te kiezen.** Wil je één Postgres voor twee stacks, dan zet je
hem in de stap 'Verbinden' aan. De keuze wordt bewaard en blijft gelden bij
een volgende wijziging.
- **Het joblog vertelt wat er gekoppeld is**, in plaats van alleen het netwerk.
- **Een backup van een database zonder wachtwoord werkt weer.** Het
dumpcommando stuurde altijd inloggegevens mee, ook bij een MongoDB zonder
authenticatie — dat mislukte gegarandeerd, terwijl de melding erna 'Backup
klaar' was.
- **Het joblog overleeft een herstart.** Taken stonden alleen in het geheugen;
wie tijdens het bijwerken meekeek zag daarna alleen nog "unknown". De laatste
twintig taken staan nu in `/data/jobs`, en een taak die door een herstart is
afgekapt zegt dat ook.
# v0.8.23-beta — De branch van een repo is te zien en te wijzigen
Sjablonen komen uit de git-cache van een app-repo, niet uit het image van
Server Up. Welke branch die repo volgt was nergens te zien, en alleen te
veranderen door de repo te verwijderen en opnieuw toe te voegen. De id wordt
dan uit de URL afgeleid en krijgt er een volgnummer bij zodra hij al bestaat —
en juist die id staat in de metadata van elke app uit die repo. Overstappen
maakte zo stilzwijgend het bronsjabloon van je apps onvindbaar.
- **De branch staat in de lijst.** Zo is te zien waarom een app een oudere
versie van zijn sjabloon blijft renderen.
- **Bewerken zonder de id te raken.** Naam, branch en submap zijn aan te passen;
na opslaan wordt de repo meteen opnieuw opgehaald.
- **Een leeg tokenveld wist het token niet.** De interface krijgt het token
nooit te zien, dus zou opslaan hem anders weggooien.
# v0.8.22-beta — UniFi leest zijn databasegegevens opnieuw in
Het linuxserver-image schrijft `/config/data/system.properties` alleen bij de
allereerste start ("Only evaluated on first run"). Alles wat daarna in Compose
verandert — de toegevoegde `authSource=admin`, een ander wachtwoord — kwam daar
dus nooit in terecht. UniFi bleef verbinden met de gegevens van de allereerste
installatie, en MongoDB antwoordde met `UserNotFound: unifi@unifi`.
- **De databasegegevens worden bij elke start gelijkgetrokken.** De
reparatiecontainer zet `db.mongo.uri`, `statdb.mongo.uri`, `db.mongo.local`
en `unifi.db.name` opnieuw uit de actuele Compose-omgeving. Andere
instellingen in het bestand blijven staan.
- **Bijzondere tekens gaan ge-url-codeerd de URI in.** Een `@` of `/` in het
wachtwoord brak de verbindingsreeks anders doormidden.
- **Het bestand houdt zijn eigenaar en rechten.** Het wordt op zijn plaats
overschreven, zodat UniFi er zelf in kan blijven schrijven.
- **Het oude wachtwoord telt mee bij de reparatie.** Wijkt `.env` af van
waarmee de database ooit is aangemaakt, dan meldt de reparatie zich aan met
het wachtwoord uit `system.properties` en trekt daarna alles gelijk — in
plaats van je met een dode stack achter te laten.
# v0.8.21-beta — Wijzigingen blijven staan, UniFi komt online
Een geslaagde instellingenwijziging werd meteen weer teruggedraaid: het
audit-logboek las het IP-adres van de aanvrager pas uit in de achtergrondthread,
waar geen request meer bestaat. De `RuntimeError` die daarop volgde kwam in de
foutafhandeling terecht, die netjes de backup terugzette — inclusief het oude
compose-bestand. De MongoDB-reparatie van v0.8.20 kon zo nooit landen.
- **Het adres wordt vóór de job vastgelegd.** Herconfigureren, koppelen, een
backup maken en terugrollen loggen weer wie de actie startte, zonder crash.
- **UniFi repareert ook een oud database-volume.** Het init-script probeert
achtereenvolgens de beheerders `unifi` en `root` en herkent een server zonder
authenticatie, in plaats van te veronderstellen dat `root` bestaat.
- **De healthcheck van Mongo gebruikt geen inloggegevens meer.** Op een volume
zonder de gebruiker `root` bleef de container ongezond, waardoor de
reparatiecontainer nooit startte.
- **Bestaande rollen blijven staan.** De reparatie trekt alleen het wachtwoord
gelijk en vult ontbrekende rollen aan — inclusief `backup` en `restore`, die
mongodump en mongorestore nodig hebben.
- **Backups van MongoDB werken.** Het dumpcommando gebruikt de beheerder uit
het compose-bestand in plaats van de verzonnen gebruiker `app`.
- **Elke app met een webinterface krijgt een openknop op de kaart.** Het adres
stond alleen in de modal met inloggegevens, waar je niet kijkt als je de app
gewoon wilt openen.
- **Geen dubbele regels meer in het joblog.** Bij een trage stap vertrokken
meerdere polls met dezelfde offset, waardoor hetzelfde blok tot vijf keer in
beeld kwam.
# v0.8.20-beta — UniFi herstelt zijn MongoDB-gebruiker
Een eerder geïnitialiseerd Mongo-volume voerde het init-script niet opnieuw uit.
Daardoor bleef UniFi herstarten met `UserNotFound: unifi@unifi` en kwam de
webinterface nooit online.
- **Idempotente databasereparatie.** Een korte init-container maakt de
UniFi-gebruiker aan of werkt hem bij, ook bij een bestaand Mongo-volume.
- **Oude installaties worden ondersteund.** Zowel de officiële authsource
`admin` als de eerder opgeslagen authsource `unifi` wordt gerepareerd.
- **UniFi wacht op de reparatie.** De applicatie start pas wanneer Mongo gezond
is en de gebruikerscontrole succesvol is afgerond.
- **Geen misleidende poort 8843.** De appkaart toont alleen werkelijk op de host
gepubliceerde poorten; UniFi's ongepubliceerde gastportaalpoort verdwijnt.
# v0.8.19-beta — Mobiele appacties reageren weer
In het mobiele actiemenu kregen normale acties zonder expliciete
uitschakelstatus toch het HTML-attribuut `disabled`. Daardoor deed onder meer
de knop Verwijderen zichtbaar helemaal niets.
- **Verwijderen is weer klikbaar.** Alleen acties die expliciet als
uitgeschakeld zijn gemarkeerd krijgen nog het `disabled`-attribuut.
- **De actie loopt vóór het menu sluit.** Zo blijft de Alpine-scope beschikbaar
totdat de verwijdermodal daadwerkelijk is geopend.
- **Echte DOM-regressietest.** De test rendert de appkaart met Alpine en klikt
zowel de desktop- als mobiele verwijderroute volledig door.
# v0.8.18-beta — UniFi opent via HTTPS
De UniFi-container was wel correct op zijn HTTPS-poort gepubliceerd, maar
Server Up maakte er altijd een `http://`-link van. Daardoor leek de app
onbereikbaar terwijl de browser met het verkeerde protocol verbinding maakte.
- **UniFi declareert HTTPS expliciet.** Het template vermeldt voortaan zowel
het protocol als de interne webpoort 8443.
- **De juiste hostpoort wordt gekozen.** Server Up koppelt de interne webpoort
aan de ingestelde hostpoort, ook wanneer een databasepoort eerder in het
Compose-bestand staat.
- **Bestaande installaties werken mee.** Ontbrekende installatiemetadata wordt
uit het actuele bronsjabloon aangevuld; opnieuw installeren is niet nodig.
- **Eigen IP-adressen blijven correct.** Een UniFi-stack met een eigen IP opent
als `https://<app-ip>:8443`.
# v0.8.17-beta — Elf nieuwe apps voor beheer, automatisering en monitoring
De app-store bevatte nog weinig lichte infrastructuurapps die zowel op een
reguliere x86-server als op een 64-bits Raspberry Pi werken. Daarom zijn elf
nieuwe templates toegevoegd met expliciete AMD64- en ARM64-ondersteuning.
- **Remote beheer:** RustDesk Server, MeshCentral en Apache Guacamole. Guacamole
genereert bij de eerste start automatisch het officiële PostgreSQL-schema.
- **Identiteit en discovery:** LLDAP en Consul bieden respectievelijk een lichte
LDAP-directory en service discovery met DNS en healthchecks.
- **Monitoring:** VictoriaMetrics en OpenObserve voegen efficiënte opslag toe
voor metrics en voor logs, metrics en traces.
- **Automatisering:** Activepieces en Windmill bieden visuele workflows,
scripts en interne apps. De Windmill-worker krijgt standaard geen toegang tot
de Docker-socket.
- **Bestandsoverdracht:** PairDrop maakt lokale bestandsoverdracht vanuit de
browser mogelijk.
- **Nomad als veilig control-plane:** de Nomad-container draait alleen als
server met webinterface. Workload-clients horen rechtstreeks op de hosts te
worden geïnstalleerd, omdat HashiCorp Nomad-clients in Docker niet officieel
ondersteunt.
- **Cataloguscontrole:** gerichte tests bewaken dat alle elf apps beschikbaar
blijven voor AMD64 en ARM64, dat Nomad geen containerized client wordt en dat
Guacamole zijn database vóór de webapp initialiseert.
# v0.8.16-beta — De startwizard blijft gesloten
**De knop Sluiten verborg de optionele startwizard alleen tijdelijk.** De
server kreeg daardoor nooit door dat de gebruiker de wizard had overgeslagen en
opende hem bij iedere nieuwe paginalading opnieuw.
- **Sluiten wordt blijvend onthouden.** De knop markeert de wizard nu via de API
als afgerond en sluit pas nadat de server dit heeft opgeslagen.
- **Een ontbrekende appmap reset de wizard niet meer.** Een nog niet gemounte of
tijdelijk onbereikbare library wordt als padprobleem behandeld en overschrijft
niet langer een expliciete keuze van de gebruiker.
- **Bewust opnieuw starten blijft mogelijk.** De knop “Opnieuw uitvoeren” in
Instellingen wist de voltooiingsstatus en opent de wizard weer vanaf het
begin.
# v0.8.15-beta — Apps verwijderen en mobiele schermen werken weer
**De verwijderknop stopte containers en verwijderde volumes, maar liet de
appconfiguratie staan.** Daardoor bleef de app in Server Up zichtbaar en leek de
knop niets te doen. Daarnaast konden modals en instellingen op een klein scherm
buiten de viewport vallen zonder dat de inhoud bereikbaar was.
- **Verwijderen doet nu wat de keuze zegt.** De veilige keuze verwijdert de
appconfiguratie, maar bewaart appdata en named volumes. De expliciete keuze
“app en alle data verwijderen” wist ook appdata en niet-externe volumes.
- **Overgenomen apps zijn verwijderbaar.** Bij een app die als symlink is
gekoppeld wordt alleen de Server Up-verwijzing weggehaald; de externe
Compose-map van de gebruiker blijft bestaan.
- **Duidelijke voortgang en fouten.** De dialoog onthoudt de juiste app,
voorkomt dubbele opdrachten en toont zowel succes als backendfouten.
- **Modals passen op ieder mobiel scherm.** Dialogen en de setupwizard gebruiken
de dynamische viewporthoogte, houden kop en knoppen in beeld en maken de
inhoud zelfstandig scrollbaar.
- **Instellingen blijven binnen de schermbreedte.** Brede keuzerijen kunnen
horizontaal scrollen, kaartkoppen lopen om en invoervelden mogen binnen hun
grid krimpen. Ook het loginvenster blijft op een laag scherm bereikbaar.
# v0.8.14-beta — Raspberry Pi krijgt werkende en eerlijke apps
**De UniFi-template startte een MongoDB-container, maar maakte de database-
gebruiker en rollen die UniFi nodig heeft niet aan.** Daardoor bleef de
controller zonder werkende database hangen. Tegelijk kon de app-store op een
Raspberry Pi ook images aanbieden die uitsluitend voor AMD64 bestaan; dat werd
pas zichtbaar nadat Docker de pull weigerde.
- **UniFi initialiseert MongoDB volledig.** Een init-script maakt de UniFi-
gebruiker met de voorgeschreven rollen aan, beide containers gebruiken
`authSource=admin` en UniFi wacht op de database-healthcheck.
- **Geschikt voor een 64-bits Raspberry Pi 4.** MongoDB staat vast op
`4.4.29`: deze tag heeft een ARM64-image en vermijdt de ARMv8.2-eis van
MongoDB 5 en hoger. De hoofdimage ondersteunt AMD64 en ARM64.
- **Platformcontrole vóór installatie.** Templates kunnen hun ondersteunde
Docker-architecturen vastleggen. De catalogus legt een mismatch uit, schakelt
de knop uit en de API weigert dezelfde installatie ook rechtstreeks.
- **Elf AMD64-only apps zijn gemarkeerd.** Alle 318 gebruikte image-manifests
zijn gecontroleerd. Cal.com, ComfyUI, Dawarich, Focalboard, Formbricks,
Mattermost, Pinchflat, Pixelfed, TubeArchivist, Unbound en Wazuh bevatten
momenteel minstens één image zonder ARM64-variant en worden daarom niet meer
als installeerbaar op een Pi getoond.
- **Init-scripts houden hun uitvoerrecht.** Tekstbestanden die tijdens het
renderen worden geschreven nemen voortaan de bestandsmodus van het sjabloon
over.
Een Mongo-init-script draait alleen bij een lege database. Server Up verwijdert
bestaande data daarom niet automatisch. Verwijder bij een eerdere mislukte
UniFi-installatie eerst bewust het oude Mongo-volume, of installeer onder een
nieuwe instantienaam.
# v0.8.13-beta — De update haalt nu de juiste release op
**Bijwerken vanuit de interface vroeg Docker om een image dat niet bestond.**
Forgejo noemt een release `v0.8.13`, terwijl de workflows bewust image
`:0.8.13` publiceren. De interface stuurde de Git-tag mét `v` door en dus
mislukte iedere beta- en stable/main-update bij `docker pull`.
- **Eén expliciete registry-tag.** De updatecheck geeft naast de Git-tag nu
`image_tag` terug. Frontend, endpoint en pull-laag normaliseren allemaal de
ene voorloop-`v`. De workflows publiceren ook `:v<versie>` als tijdelijke
compatibiliteitstag, zodat de al geïnstalleerde defecte updater zichzelf naar
deze reparatie kan brengen.
- **De herstart gebruikt exact het bestaande Compose-project.** Projectnaam,
projectmap en alle configuratiebestanden komen uit de containerlabels.
`--no-build --pull never` voorkomt dat Compose tijdens de update oude
broncode bouwt of onverwacht een andere tag ophaalt.
- **Tag en versie blijven bij elkaar.** De helper en beide deploy-workflows
schrijven `SU_TAG` en `SU_VERSION` samen. Ook na een handmatige Compose-
herstart toont en draait Server Up daardoor dezelfde beta- of releaseversie.
- **Terugrollen blijft naar een echte versie wijzen.** Als de container via de
beweeglijke alias `beta` of `latest` draaide, wordt voortaan het ingebakken
OCI-versielabel als terugrolpunt onthouden.
# v0.8.12-beta — De image-controle van twaalf minuten naar seconden
De controle of elk image nog bestaat kostte elf à twaalf minuten, bij élke push,

13
CLAUDE.md Normal file
View file

@ -0,0 +1,13 @@
# Projectafspraken
- Werk nooit rechtstreeks op main
- Push naar de dev branch
- Werk met versienummering (v0.0.00)
- Verhoog per uitgave met 0.0.01, dus 0.7.95 → 0.7.96. Geen sprongen van tien,
en de middelste positie blijft één cijfer: na 0.7.99 komt 0.8.00.
- In de dev-branch altijd beta weergeven bij de versienummering
- Beschrijf gewijzigde functionaliteiten puntsgewijs in de pull request.
- Gebruik geen sudo
- Commit geen wachtwoorden, tokens, `.env`-bestanden of privésleutels.
- Voer relevante tests uit voor iedere commit.
- Push wijzigingen naar Forgejo, maar merge niet zelfstandig.
- Bij opdracht "Release versie" maak een versie releaseversie

View file

@ -72,7 +72,7 @@ RUN mkdir -p /data/stacks /data/appdata /data/backups /data/git modules /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_SECRET=/data/secret.key \
SU_SECRET=/data/secret.key SU_JOBS=/data/jobs \
GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/data/known_hosts"
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh

View file

@ -1 +1 @@
0.8.12-beta
0.8.28

View file

@ -0,0 +1,13 @@
services:
<< service_name >>:
image: activepieces/activepieces:latest
container_name: << service_name >>
ports:
- "<< port >>:80"
environment:
- AP_REDIS_TYPE=MEMORY
- AP_DB_TYPE=PGLITE
- AP_FRONTEND_URL=<< frontend_url >>
volumes:
- << appdata_dir >>/<< service_name >>:/root/.activepieces
restart: unless-stopped

View file

@ -0,0 +1,24 @@
{
"kind": "compose",
"metadata": {
"name": "Activepieces",
"description": "Visuele workflow-automatisering als toegankelijk alternatief voor n8n. Deze lichte één-containeropzet gebruikt PGLite en een geheugenqueue en is bedoeld voor persoonlijk gebruik op één server.",
"tags": ["automatisering", "workflows", "integraties", "no-code"],
"icon": {"provider": "selfhst", "id": "activepieces"},
"version": {"name": "latest"},
"categories": ["productiviteit", "ontwikkeling"],
"architectures": ["amd64", "arm64"],
"architecture_note": "De officiële Activepieces-image ondersteunt AMD64 en ARM64."
},
"variables": [
{
"title": "Algemeen",
"items": [
{"name": "service_name", "type": "str", "title": "Servicenaam", "default": "activepieces", "required": true},
{"name": "port", "type": "int", "title": "Webinterface", "default": 14003, "required": true},
{"name": "appdata_dir", "type": "str", "title": "Appdata-map", "default": "/opt/serverup/appdata", "required": true},
{"name": "frontend_url", "type": "str", "title": "Publiek adres", "default": "http://localhost:14003", "required": true, "description": "Gebruik voor webhooks het HTTPS-adres van je reverse proxy."}
]
}
]
}

View file

@ -18,7 +18,11 @@
"categories": [
"productiviteit",
"zakelijk"
]
],
"architectures": [
"amd64"
],
"architecture_note": "Een of meer containerimages van deze app worden alleen voor AMD64 gepubliceerd. Deze app werkt daardoor niet op een Raspberry Pi."
},
"variables": [
{

View file

@ -17,7 +17,11 @@
},
"categories": [
"ai"
]
],
"architectures": [
"amd64"
],
"architecture_note": "Een of meer containerimages van deze app worden alleen voor AMD64 gepubliceerd. Deze app werkt daardoor niet op een Raspberry Pi."
},
"variables": [
{

View file

@ -0,0 +1,33 @@
services:
<< service_name >>:
image: hashicorp/consul:latest
container_name: << service_name >>
command:
- agent
- -server
- -bootstrap-expect=1
- -ui
- -client=0.0.0.0
- -advertise=<< advertise_ip >>
- -http-port=<< port_http >>
- -server-port=<< port_server >>
- -serf-lan-port=<< port_lan >>
- -serf-wan-port=<< port_wan >>
- -dns-port=<< port_dns >>
- -node=<< service_name >>
environment:
- CONSUL_BIND_INTERFACE=eth0
ports:
- "<< port_http >>:<< port_http >>"
- "<< port_server >>:<< port_server >>"
- "<< port_lan >>:<< port_lan >>/tcp"
- "<< port_lan >>:<< port_lan >>/udp"
- "<< port_wan >>:<< port_wan >>/tcp"
- "<< port_wan >>:<< port_wan >>/udp"
- "<< port_dns >>:<< port_dns >>/tcp"
- "<< port_dns >>:<< port_dns >>/udp"
volumes:
- << appdata_dir >>/<< service_name >>:/consul/data
cap_add:
- IPC_LOCK
restart: unless-stopped

33
apps/consul/template.json Normal file
View file

@ -0,0 +1,33 @@
{
"kind": "compose",
"metadata": {
"name": "Consul",
"description": "Service discovery, DNS en healthchecks met een webinterface. Deze eenvoudige single-serveropzet is geschikt voor een thuislab; gebruik voor productie een oneven cluster van meerdere servers.",
"tags": ["service discovery", "dns", "healthchecks", "hashicorp"],
"icon": {"provider": "selfhst", "id": "consul"},
"version": {"name": "latest"},
"categories": ["netwerk", "beheer", "ontwikkeling"],
"architectures": ["amd64", "arm64"],
"architecture_note": "HashiCorp publiceert officiële Consul-images voor AMD64 en ARM64."
},
"variables": [
{
"title": "Algemeen",
"items": [
{"name": "service_name", "type": "str", "title": "Servicenaam", "default": "consul", "required": true},
{"name": "appdata_dir", "type": "str", "title": "Appdata-map", "default": "/opt/serverup/appdata", "required": true},
{"name": "advertise_ip", "type": "str", "title": "LAN-IP van deze server", "default": "", "required": true, "description": "Het bereikbare hostadres dat Consul aan agents doorgeeft, bijvoorbeeld 192.168.1.10."}
]
},
{
"title": "Poorten",
"items": [
{"name": "port_http", "type": "int", "title": "HTTP API en webinterface", "default": 8500, "required": true},
{"name": "port_server", "type": "int", "title": "Server-RPC", "default": 8300, "required": true},
{"name": "port_lan", "type": "int", "title": "LAN-gossip", "default": 8301, "required": true},
{"name": "port_wan", "type": "int", "title": "WAN-gossip", "default": 8302, "required": true},
{"name": "port_dns", "type": "int", "title": "DNS", "default": 8600, "required": true}
]
}
]
}

View file

@ -18,7 +18,11 @@
"categories": [
"gezin",
"beveiliging"
]
],
"architectures": [
"amd64"
],
"architecture_note": "Een of meer containerimages van deze app worden alleen voor AMD64 gepubliceerd. Deze app werkt daardoor niet op een Raspberry Pi."
},
"variables": [
{

View file

@ -17,7 +17,11 @@
},
"categories": [
"productiviteit"
]
],
"architectures": [
"amd64"
],
"architecture_note": "Een of meer containerimages van deze app worden alleen voor AMD64 gepubliceerd. Deze app werkt daardoor niet op een Raspberry Pi."
},
"variables": [
{

View file

@ -18,7 +18,11 @@
"categories": [
"productiviteit",
"zakelijk"
]
],
"architectures": [
"amd64"
],
"architecture_note": "Een of meer containerimages van deze app worden alleen voor AMD64 gepubliceerd. Deze app werkt daardoor niet op een Raspberry Pi."
},
"variables": [
{

View file

@ -0,0 +1,58 @@
services:
<< service_name >>-init:
image: guacamole/guacamole:1.6.0
container_name: << service_name >>-init
user: "0:0"
entrypoint: ["/bin/sh", "-c"]
command:
- if [ ! -s /init/001-initdb.sql ]; then /opt/guacamole/bin/initdb.sh --postgresql > /init/001-initdb.sql; fi
volumes:
- guacamole-init:/init
restart: "no"
<< service_name >>-db:
image: postgres:16-alpine
container_name: << service_name >>-db
environment:
- POSTGRES_DB=guacamole
- POSTGRES_USER=guacamole
- POSTGRES_PASSWORD=<< db_password >>
volumes:
- << appdata_dir >>/<< service_name >>/db:/var/lib/postgresql/data
- guacamole-init:/docker-entrypoint-initdb.d:ro
depends_on:
<< service_name >>-init:
condition: service_completed_successfully
healthcheck:
test: ["CMD-SHELL", "pg_isready -U guacamole -d guacamole"]
interval: 10s
timeout: 5s
retries: 10
restart: unless-stopped
<< service_name >>-guacd:
image: guacamole/guacd:1.6.0
container_name: << service_name >>-guacd
restart: unless-stopped
<< service_name >>:
image: guacamole/guacamole:1.6.0
container_name: << service_name >>
ports:
- "<< port >>:8080"
environment:
- WEBAPP_CONTEXT=ROOT
- GUACD_HOSTNAME=<< service_name >>-guacd
- POSTGRESQL_HOSTNAME=<< service_name >>-db
- POSTGRESQL_DATABASE=guacamole
- POSTGRESQL_USERNAME=guacamole
- POSTGRESQL_PASSWORD=<< db_password >>
depends_on:
<< service_name >>-db:
condition: service_healthy
<< service_name >>-guacd:
condition: service_started
restart: unless-stopped
volumes:
guacamole-init:

View file

@ -0,0 +1,29 @@
{
"kind": "compose",
"metadata": {
"name": "Apache Guacamole",
"description": "Clientloze webgateway voor RDP, VNC en SSH. De PostgreSQL-database wordt bij de eerste start automatisch met het officiële Guacamole-schema ingericht.",
"tags": ["remote desktop", "rdp", "vnc", "ssh"],
"icon": {"provider": "selfhst", "id": "apache-guacamole"},
"version": {"name": "1.6.0"},
"categories": ["beheer", "netwerk"],
"architectures": ["amd64", "arm64"],
"architecture_note": "De officiële Guacamole-, guacd- en PostgreSQL-images ondersteunen AMD64 en ARM64."
},
"variables": [
{
"title": "Algemeen",
"items": [
{"name": "service_name", "type": "str", "title": "Servicenaam", "default": "guacamole", "required": true},
{"name": "port", "type": "int", "title": "Webinterface", "default": 14005, "required": true},
{"name": "appdata_dir", "type": "str", "title": "Appdata-map", "default": "/opt/serverup/appdata", "required": true}
]
},
{
"title": "Database",
"items": [
{"name": "db_password", "type": "str", "title": "Databasewachtwoord", "default": "", "required": true, "secret": true, "generate": "auto"}
]
}
]
}

View file

@ -0,0 +1,19 @@
services:
<< service_name >>:
image: lldap/lldap:stable
container_name: << service_name >>
ports:
- "<< port_web >>:17170"
- "<< port_ldap >>:3890"
environment:
- UID=<< puid >>
- GID=<< pgid >>
- TZ=<< timezone >>
- LLDAP_JWT_SECRET=<< jwt_secret >>
- LLDAP_KEY_SEED=<< key_seed >>
- LLDAP_LDAP_BASE_DN=<< base_dn >>
- LLDAP_LDAP_USER_PASS=<< ldap_user_pass >>
- LLDAP_HTTP_URL=<< http_url >>
volumes:
- << appdata_dir >>/<< service_name >>:/data
restart: unless-stopped

42
apps/lldap/template.json Normal file
View file

@ -0,0 +1,42 @@
{
"kind": "compose",
"metadata": {
"name": "LLDAP",
"description": "Lichtgewicht centrale gebruikersmap met LDAP en een eenvoudige webinterface. Bedoeld als accountbron voor onder meer Authelia, Authentik, Keycloak en Nextcloud. Stel LDAP niet rechtstreeks bloot aan internet.",
"tags": ["ldap", "accounts", "sso", "identiteit"],
"icon": {"provider": "selfhst", "id": "lldap"},
"version": {"name": "stable"},
"categories": ["beveiliging", "beheer"],
"architectures": ["amd64", "arm64"],
"architecture_note": "De officiële image ondersteunt AMD64, ARM64 en ARM/v7."
},
"variables": [
{
"title": "Algemeen",
"items": [
{"name": "service_name", "type": "str", "title": "Servicenaam", "default": "lldap", "required": true},
{"name": "port_web", "type": "int", "title": "Webinterface", "default": 17170, "required": true},
{"name": "port_ldap", "type": "int", "title": "LDAP-poort", "default": 3890, "required": true, "description": "Alleen op het vertrouwde LAN gebruiken; voor internet is LDAPS of een VPN nodig."},
{"name": "appdata_dir", "type": "str", "title": "Appdata-map", "default": "/opt/serverup/appdata", "required": true},
{"name": "timezone", "type": "str", "title": "Tijdzone", "default": "Europe/Amsterdam", "required": true},
{"name": "puid", "type": "int", "title": "PUID", "default": 1000, "required": true},
{"name": "pgid", "type": "int", "title": "PGID", "default": 1000, "required": true}
]
},
{
"title": "Directory",
"items": [
{"name": "base_dn", "type": "str", "title": "Basis-DN", "default": "dc=example,dc=com", "required": true, "description": "Pas example.com aan je eigen interne domein aan."},
{"name": "http_url", "type": "str", "title": "Publiek webadres", "default": "http://localhost:17170", "required": true}
]
},
{
"title": "Geheimen",
"items": [
{"name": "ldap_user_pass", "type": "str", "title": "Adminwachtwoord", "default": "", "required": true, "secret": true, "generate": true, "credential": "password", "description": "Inloggen gaat met gebruiker admin en dit wachtwoord."},
{"name": "jwt_secret", "type": "str", "title": "JWT-sleutel", "default": "", "required": true, "secret": true, "generate": "auto", "secret_bytes": 32},
{"name": "key_seed", "type": "str", "title": "Versleutelingsseed", "default": "", "required": true, "secret": true, "generate": "auto", "secret_bytes": 32}
]
}
]
}

View file

@ -18,7 +18,11 @@
"categories": [
"communicatie",
"productiviteit"
]
],
"architectures": [
"amd64"
],
"architecture_note": "Een of meer containerimages van deze app worden alleen voor AMD64 gepubliceerd. Deze app werkt daardoor niet op een Raspberry Pi."
},
"variables": [
{

View file

@ -0,0 +1,13 @@
services:
<< service_name >>:
image: ghcr.io/ylianst/meshcentral:latest
container_name: << service_name >>
ports:
- "<< port >>:443"
volumes:
- << appdata_dir >>/<< service_name >>/data:/opt/meshcentral/meshcentral-data
- ./config.json:/opt/meshcentral/meshcentral-data/config.json:ro
- << appdata_dir >>/<< service_name >>/files:/opt/meshcentral/meshcentral-files
- << appdata_dir >>/<< service_name >>/backups:/opt/meshcentral/meshcentral-backups
- << appdata_dir >>/<< service_name >>/web:/opt/meshcentral/meshcentral-web
restart: unless-stopped

View file

@ -0,0 +1,14 @@
{
"$schema": "https://raw.githubusercontent.com/Ylianst/MeshCentral/master/meshcentral-config-schema.json",
"settings": {
"cert": "<< hostname >>",
"port": 443,
"WANonly": true
},
"domains": {
"": {
"title": "MeshCentral",
"newAccounts": true
}
}
}

View file

@ -0,0 +1,24 @@
{
"kind": "compose",
"metadata": {
"name": "MeshCentral",
"description": "Beheer computers op afstand via de browser: bureaublad, terminal, bestanden en inventaris. Gebruikt ingebouwde opslag en een zelfondertekend certificaat. Het eerste account wordt beheerder; schakel daarna nieuwe registraties uit in config.json.",
"tags": ["remote beheer", "support", "inventaris", "desktop"],
"icon": {"provider": "selfhst", "id": "meshcentral"},
"version": {"name": "latest"},
"categories": ["beheer", "netwerk"],
"architectures": ["amd64", "arm64"],
"architecture_note": "De officiële image publiceert manifests voor AMD64 en ARM64."
},
"variables": [
{
"title": "Algemeen",
"items": [
{"name": "service_name", "type": "str", "title": "Servicenaam", "default": "meshcentral", "required": true},
{"name": "port", "type": "int", "title": "HTTPS-webinterface", "default": 14001, "required": true},
{"name": "hostname", "type": "str", "title": "Hostnaam of LAN-IP", "default": "", "required": true, "description": "De naam of het adres waarmee apparaten deze server bereiken, bijvoorbeeld mesh.example.nl of 192.168.1.10."},
{"name": "appdata_dir", "type": "str", "title": "Appdata-map", "default": "/opt/serverup/appdata", "required": true}
]
}
]
}

View file

@ -0,0 +1,18 @@
services:
<< service_name >>:
image: hashicorp/nomad:latest
container_name: << service_name >>
command:
- agent
- -config=/nomad/config/server.hcl
ports:
- "<< port_http >>:<< port_http >>"
- "<< port_rpc >>:<< port_rpc >>"
- "<< port_gossip >>:<< port_gossip >>/tcp"
- "<< port_gossip >>:<< port_gossip >>/udp"
volumes:
- << appdata_dir >>/<< service_name >>:/nomad/data
- ./server.hcl:/nomad/config/server.hcl:ro
cap_add:
- IPC_LOCK
restart: unless-stopped

View file

@ -0,0 +1,28 @@
data_dir = "/nomad/data"
bind_addr = "0.0.0.0"
addresses {
http = "0.0.0.0"
rpc = "0.0.0.0"
serf = "0.0.0.0"
}
ports {
http = << port_http >>
rpc = << port_rpc >>
serf = << port_gossip >>
}
advertise {
http = "<< advertise_ip >>:<< port_http >>"
rpc = "<< advertise_ip >>:<< port_rpc >>"
serf = "<< advertise_ip >>:<< port_gossip >>"
}
server {
enabled = true
bootstrap_expect = 1
}
client {
enabled = false

View file

@ -0,0 +1,31 @@
{
"kind": "compose",
"metadata": {
"name": "Nomad Server",
"description": "Nomad control-plane met webinterface voor een klein lab. Deze container draait bewust alleen als server; installeer Nomad-clients rechtstreeks op de hosts om workloads betrouwbaar te starten.",
"tags": ["orchestratie", "scheduler", "cluster", "hashicorp"],
"icon": {"provider": "selfhst", "id": "nomad"},
"version": {"name": "latest"},
"categories": ["beheer", "ontwikkeling"],
"architectures": ["amd64", "arm64"],
"architecture_note": "HashiCorp publiceert officiële Nomad-images voor AMD64 en ARM64. Een Nomad-client in Docker wordt niet officieel ondersteund."
},
"variables": [
{
"title": "Algemeen",
"items": [
{"name": "service_name", "type": "str", "title": "Servicenaam", "default": "nomad", "required": true},
{"name": "appdata_dir", "type": "str", "title": "Appdata-map", "default": "/opt/serverup/appdata", "required": true},
{"name": "advertise_ip", "type": "str", "title": "LAN-IP van deze server", "default": "", "required": true, "description": "Het bereikbare hostadres dat Nomad aan clients doorgeeft, bijvoorbeeld 192.168.1.10."}
]
},
{
"title": "Poorten",
"items": [
{"name": "port_http", "type": "int", "title": "HTTP API en webinterface", "default": 4646, "required": true},
{"name": "port_rpc", "type": "int", "title": "Server-RPC", "default": 4647, "required": true},
{"name": "port_gossip", "type": "int", "title": "Serf-gossip", "default": 4648, "required": true}
]
}
]
}

View file

@ -0,0 +1,15 @@
services:
<< service_name >>:
image: public.ecr.aws/zinclabs/openobserve:latest
container_name: << service_name >>
ports:
- "<< port >>:5080"
environment:
- ZO_DATA_DIR=/data
- ZO_LOCAL_MODE=true
- ZO_LOCAL_MODE_STORAGE=disk
- ZO_ROOT_USER_EMAIL=<< root_email >>
- ZO_ROOT_USER_PASSWORD=<< root_password >>
volumes:
- << appdata_dir >>/<< service_name >>:/data
restart: unless-stopped

View file

@ -0,0 +1,30 @@
{
"kind": "compose",
"metadata": {
"name": "OpenObserve",
"description": "Eén interface voor logs, metrics en traces met zoeken, dashboards en waarschuwingen. Deze template gebruikt de open-source single-nodeopzet met lokale schijfopslag.",
"tags": ["logs", "metrics", "traces", "opentelemetry"],
"icon": {"provider": "selfhst", "id": "openobserve"},
"version": {"name": "latest"},
"categories": ["monitoring", "analytics"],
"architectures": ["amd64", "arm64"],
"architecture_note": "De officiële OpenObserve OSS-image publiceert AMD64- en ARM64-manifests."
},
"variables": [
{
"title": "Algemeen",
"items": [
{"name": "service_name", "type": "str", "title": "Servicenaam", "default": "openobserve", "required": true},
{"name": "port", "type": "int", "title": "Web- en API-poort", "default": 14006, "required": true},
{"name": "appdata_dir", "type": "str", "title": "Appdata-map", "default": "/opt/serverup/appdata", "required": true}
]
},
{
"title": "Beheerder",
"items": [
{"name": "root_email", "type": "str", "title": "Beheerder e-mail", "default": "admin@example.com", "required": true, "credential": "username"},
{"name": "root_password", "type": "str", "title": "Beheerderwachtwoord", "default": "", "required": true, "secret": true, "generate": true, "credential": "password"}
]
}
]
}

View file

@ -0,0 +1,14 @@
services:
<< service_name >>:
image: lscr.io/linuxserver/pairdrop:latest
container_name: << service_name >>
ports:
- "<< port >>:3000"
environment:
- PUID=<< puid >>
- PGID=<< pgid >>
- TZ=<< timezone >>
- RATE_LIMIT=true
- WS_FALLBACK=<< ws_fallback >>
- DEBUG_MODE=false
restart: unless-stopped

View file

@ -0,0 +1,26 @@
{
"kind": "compose",
"metadata": {
"name": "PairDrop",
"description": "Stuur bestanden en tekst rechtstreeks tussen browsers op ieder platform, zonder account. WebRTC versleutelt de overdracht; zet voor publiek gebruik HTTPS via een reverse proxy aan.",
"tags": ["bestanden", "delen", "airdrop", "webrtc"],
"icon": {"provider": "selfhst", "id": "pairdrop"},
"version": {"name": "latest"},
"categories": ["productiviteit", "opslag"],
"architectures": ["amd64", "arm64"],
"architecture_note": "De LinuxServer-image ondersteunt AMD64 en ARM64."
},
"variables": [
{
"title": "Algemeen",
"items": [
{"name": "service_name", "type": "str", "title": "Servicenaam", "default": "pairdrop", "required": true},
{"name": "port", "type": "int", "title": "Webinterface", "default": 14007, "required": true},
{"name": "timezone", "type": "str", "title": "Tijdzone", "default": "Europe/Amsterdam", "required": true},
{"name": "puid", "type": "int", "title": "PUID", "default": 1000, "required": true},
{"name": "pgid", "type": "int", "title": "PGID", "default": 1000, "required": true},
{"name": "ws_fallback", "type": "bool", "title": "WebSocket-fallback", "default": false, "required": false, "description": "Routeert bestanden via de server als WebRTC niet werkt; de server kan die gegevens dan verwerken."}
]
}
]
}

View file

@ -18,7 +18,11 @@
"categories": [
"media",
"downloaden"
]
],
"architectures": [
"amd64"
],
"architecture_note": "Een of meer containerimages van deze app worden alleen voor AMD64 gepubliceerd. Deze app werkt daardoor niet op een Raspberry Pi."
},
"variables": [
{

View file

@ -18,7 +18,11 @@
"categories": [
"fotos",
"communicatie"
]
],
"architectures": [
"amd64"
],
"architecture_note": "Een of meer containerimages van deze app worden alleen voor AMD64 gepubliceerd. Deze app werkt daardoor niet op een Raspberry Pi."
},
"variables": [
{

View file

@ -0,0 +1,19 @@
services:
<< service_name >>-hbbs:
image: rustdesk/rustdesk-server:latest
container_name: << service_name >>-hbbs
command: hbbs
network_mode: host
volumes:
- << appdata_dir >>/<< service_name >>:/root
depends_on:
- << service_name >>-hbbr
restart: unless-stopped
<< service_name >>-hbbr:
image: rustdesk/rustdesk-server:latest
container_name: << service_name >>-hbbr
command: hbbr
network_mode: host
volumes:
- << appdata_dir >>/<< service_name >>:/root
restart: unless-stopped

View file

@ -0,0 +1,22 @@
{
"kind": "compose",
"metadata": {
"name": "RustDesk Server",
"description": "Eigen rendezvous- en relayserver voor RustDesk. Werkt ook op een Raspberry Pi. Gebruikt hostnetwerk en de TCP-poorten 21115-21119 plus UDP 21116; maak die alleen bereikbaar waar nodig.",
"tags": ["remote desktop", "beheer", "support", "rustdesk"],
"icon": {"provider": "selfhst", "id": "rustdesk"},
"version": {"name": "latest"},
"categories": ["beheer", "netwerk"],
"architectures": ["amd64", "arm64"],
"architecture_note": "De officiële image ondersteunt AMD64 en ARM64, waaronder een 64-bits Raspberry Pi."
},
"variables": [
{
"title": "Algemeen",
"items": [
{"name": "service_name", "type": "str", "title": "Servicenaam", "default": "rustdesk", "required": true},
{"name": "appdata_dir", "type": "str", "title": "Appdata-map", "default": "/opt/serverup/appdata", "required": true}
]
}
]
}

View file

@ -18,7 +18,11 @@
"categories": [
"media",
"downloaden"
]
],
"architectures": [
"amd64"
],
"architecture_note": "Een of meer containerimages van deze app worden alleen voor AMD64 gepubliceerd. Deze app werkt daardoor niet op een Raspberry Pi."
},
"variables": [
{

View file

@ -18,7 +18,11 @@
"categories": [
"netwerk",
"beveiliging"
]
],
"architectures": [
"amd64"
],
"architecture_note": "Een of meer containerimages van deze app worden alleen voor AMD64 gepubliceerd. Deze app werkt daardoor niet op een Raspberry Pi."
},
"variables": [
{

View file

@ -11,6 +11,7 @@ services:
- MONGO_HOST=<< service_name >>-mongo
- MONGO_PORT=27017
- MONGO_DBNAME=unifi
- MONGO_AUTHSOURCE=admin
volumes:
- << appdata_dir >>/<< service_name >>/config:/config
ports:
@ -19,19 +20,64 @@ services:
- "<< port_stun >>:3478/udp"
- "<< port_discovery >>:10001/udp"
depends_on:
- << service_name >>-mongo
<< service_name >>-mongo:
condition: service_healthy
<< service_name >>-mongo-init:
condition: service_completed_successfully
restart: unless-stopped
<< service_name >>-mongo:
image: mongo:4.4
image: mongo:4.4.29
container_name: << service_name >>-mongo
environment:
# Dezelfde beheerdersnaam als het allereerste sjabloon gebruikte. Zo
# passen een vers volume en een volume van een oudere installatie op
# dezelfde inloggegevens — ook die van de backup (mongodump).
- MONGO_INITDB_ROOT_USERNAME=unifi
- MONGO_INITDB_ROOT_PASSWORD=<< mongo_password >>
- MONGO_USER=unifi
- MONGO_PASS=<< mongo_password >>
- MONGO_DBNAME=unifi
- MONGO_AUTHSOURCE=admin
volumes:
- << service_name >>_mongodata:/data/db
- ./init-mongo.sh:/docker-entrypoint-initdb.d/init-mongo.sh:ro
healthcheck:
# Bewust zonder inloggegevens: `ping` mag ook zonder aanmelden. Een
# volume dat door een oudere versie van dit sjabloon is aangemaakt kent
# de gebruiker 'root' niet, en dan bleef deze container voor altijd
# ongezond — waardoor de reparatiecontainer hieronder nooit startte.
test:
- CMD-SHELL
- 'mongo --quiet --eval "db.adminCommand({ ping: 1 })"'
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
restart: unless-stopped
<< service_name >>-mongo-init:
image: mongo:4.4.29
container_name: << service_name >>-mongo-init
environment:
- MONGO_INITDB_ROOT_USERNAME=unifi
- MONGO_INITDB_ROOT_PASSWORD=<< mongo_password >>
- MONGO_INITDB_DATABASE=unifi
- MONGO_USER=unifi
- MONGO_PASS=<< mongo_password >>
- MONGO_HOST=<< service_name >>-mongo
- MONGO_PORT=27017
- MONGO_DBNAME=unifi
- MONGO_AUTHSOURCE=admin
volumes:
- << service_name >>_mongodata:/data/db
restart: unless-stopped
- ./init-mongo.sh:/init-mongo.sh:ro
# Dezelfde map als UniFi zelf: hierin staat system.properties, dat het
# image alleen bij de allereerste start schrijft.
- << appdata_dir >>/<< service_name >>/config:/unifi-config
entrypoint: ["/bin/bash", "/init-mongo.sh"]
depends_on:
<< service_name >>-mongo:
condition: service_healthy
restart: "no"
volumes:
<< service_name >>_mongodata:

View file

@ -0,0 +1,181 @@
<% raw %>
#!/bin/bash
set -e
if which mongosh > /dev/null 2>&1; then
mongo_init_bin="mongosh"
else
mongo_init_bin="mongo"
fi
mongo_args=()
if [[ -n "${MONGO_HOST:-}" ]]; then
mongo_args=(
--host "${MONGO_HOST}"
--port "${MONGO_PORT:-27017}"
)
fi
props="${UNIFI_CONFIG_DIR:-/unifi-config}/data/system.properties"
# UniFi bewaart in system.properties het wachtwoord waarmee de database ooit is
# aangemaakt. Wijkt dat af van wat er nu in .env staat, dan is dít het enige
# wachtwoord waarmee we nog binnenkomen — en dus de enige weg om het weer
# gelijk te trekken in plaats van de gebruiker met een dode stack te laten.
oud_wachtwoord=""
if [[ -f "${props}" ]]; then
oude_uri="$(grep -m1 '^db\.mongo\.uri=' "${props}" | cut -d= -f2- || true)"
oude_inlog="${oude_uri#mongodb://}"
oude_inlog="${oude_inlog%%@*}"
# Zonder @ staat er geen inlog in de URI; dan valt er ook niets te lezen.
if [[ "${oude_uri}" == *@* && "${oude_inlog}" == *:* ]]; then
oud_wachtwoord="${oude_inlog#*:}"
# %40 en vrienden weer terug naar het echte teken.
oud_wachtwoord="$(printf '%b' "${oud_wachtwoord//%/\\x}")"
fi
fi
# Aanhalingstekens en backslashes zouden de JavaScript-tekenreeks hieronder
# openbreken.
oud_wachtwoord="${oud_wachtwoord//\\/\\\\}"
oud_wachtwoord="${oud_wachtwoord//\"/\\\"}"
"${mongo_init_bin}" "${mongo_args[@]}" <<EOF
const oudWachtwoord = "${oud_wachtwoord}";
const beheerNaam = "${MONGO_INITDB_ROOT_USERNAME}";
const beheerWachtwoord = "${MONGO_INITDB_ROOT_PASSWORD}";
const appNaam = "${MONGO_USER}";
const appWachtwoord = "${MONGO_PASS}";
const authBron = "${MONGO_AUTHSOURCE}" || "admin";
const appDatabase = "${MONGO_DBNAME}";
const adminDb = db.getSiblingDB("admin");
// Welke beheerder in dit volume zit, hangt af van wie het heeft aangemaakt:
// het eerste sjabloon maakte 'unifi' aan, een later sjabloon 'root'. Daarom
// proberen we ze allemaal in plaats van er één te veronderstellen — anders
// mislukt de reparatie juist op de installaties die haar nodig hebben.
const kandidaten = [beheerNaam, appNaam, "root", "unifi"]
.filter((naam, i, alle) => naam && alle.indexOf(naam) === i);
// Het wachtwoord uit .env, en dat waarmee UniFi de database ooit heeft
// aangemaakt. Werkt alleen dat laatste nog, dan trekken we het hieronder
// alsnog gelijk.
const wachtwoorden = [beheerWachtwoord, oudWachtwoord]
.filter((ww, i, alle) => ww && alle.indexOf(ww) === i);
function aanmelden() {
for (const naam of kandidaten) {
for (const wachtwoord of wachtwoorden) {
try {
if (adminDb.auth(naam, wachtwoord)) {
return naam;
}
} catch (e) {
// Onbekende gebruiker of verkeerd wachtwoord: volgende combinatie.
}
}
}
try {
// Draait deze server nog zonder authenticatie, dan mogen we zo verder.
adminDb.getUsers();
return "(zonder aanmelden)";
} catch (e) {
return "";
}
}
const beheerder = aanmelden();
if (!beheerder) {
print("");
print("FOUT: geen enkele MongoDB-beheerder kon inloggen op dit volume.");
print("Het wachtwoord in .env hoort niet bij de database die er al staat.");
print("Zet het oorspronkelijke wachtwoord terug in .env, of verwijder het");
print("volume om opnieuw te beginnen (dat wist wel je UniFi-gegevens).");
print("");
quit(18);
}
print("MongoDB-beheerder: " + beheerder);
const roles = [
{ db: "admin", role: "clusterMonitor" },
// Zonder deze twee kan Server Up geen mongodump/mongorestore draaien voor
// de backup van deze stack.
{ db: "admin", role: "backup" },
{ db: "admin", role: "restore" },
{ db: appDatabase, role: "dbOwner" },
{ db: appDatabase + "_stat", role: "dbOwner" },
{ db: appDatabase + "_audit", role: "dbOwner" },
{ db: appDatabase + "_restore", role: "dbOwner" }
];
function ensureUser(database) {
const userDb = db.getSiblingDB(database);
const bestaand = userDb.getUser(appNaam);
if (!bestaand) {
userDb.createUser({ user: appNaam, pwd: appWachtwoord, roles: roles });
print("Gebruiker " + appNaam + " aangemaakt in " + database + ".");
return;
}
// Alleen het wachtwoord gelijktrekken en ontbrekende rollen aanvullen. De
// rollen overschrijven zou de beheerder van een oudere installatie
// degraderen, waarna onder meer de backup geen dump meer kan maken.
userDb.updateUser(appNaam, { pwd: appWachtwoord });
const missend = roles.filter(
(r) => !(bestaand.roles || []).some((b) => b.role === r.role && b.db === r.db));
if (missend.length) {
userDb.grantRolesToUser(appNaam, missend);
}
print("Gebruiker " + appNaam + " bijgewerkt in " + database + ".");
}
ensureUser(authBron);
if (authBron !== appDatabase) {
ensureUser(appDatabase);
}
EOF
# ── UniFi's eigen kopie van de databasegegevens ──────────────────────────────
# Het image schrijft /config/data/system.properties alleen als dat bestand er
# nog niet is ("Only evaluated on first run"). Alles wat daarna in compose
# verandert — een authSource erbij, een ander wachtwoord — komt er dus nooit in
# en UniFi blijft met de oude gegevens verbinden. Daarom zetten we de vier
# regels die over de database gaan bij elke start opnieuw goed.
if [[ -f "${props}" ]]; then
# De waarden gaan in een URI; een teken als @ of / zou die uit elkaar trekken.
urlcodeer() {
local invoer="$1" uit="" teken
for ((i = 0; i < ${#invoer}; i++)); do
teken="${invoer:i:1}"
case "${teken}" in
[a-zA-Z0-9.~_-]) uit+="${teken}" ;;
*) uit+="$(printf '%%%02X' "'${teken}")" ;;
esac
done
printf '%s' "${uit}"
}
gebruiker="$(urlcodeer "${MONGO_USER}")"
wachtwoord="$(urlcodeer "${MONGO_PASS}")"
basis="mongodb://${gebruiker}:${wachtwoord}@${MONGO_HOST}:${MONGO_PORT:-27017}"
achtervoegsel="?tls=false"
if [[ -n "${MONGO_AUTHSOURCE:-}" ]]; then
achtervoegsel="${achtervoegsel}&authSource=${MONGO_AUTHSOURCE}"
fi
tijdelijk="$(mktemp)"
grep -v -E '^(db\.mongo\.local|db\.mongo\.uri|statdb\.mongo\.uri|unifi\.db\.name)=' \
"${props}" > "${tijdelijk}" || true
{
echo "db.mongo.local=false"
echo "db.mongo.uri=${basis}/${MONGO_DBNAME}${achtervoegsel}"
echo "statdb.mongo.uri=${basis}/${MONGO_DBNAME}_stat${achtervoegsel}"
echo "unifi.db.name=${MONGO_DBNAME}"
} >> "${tijdelijk}"
# Via cat en niet via mv of sed -i: zo houdt het bestand zijn inode, en dus
# zijn eigenaar en rechten. Anders is het ineens van root en kan UniFi zelf
# niets meer wegschrijven.
cat "${tijdelijk}" > "${props}"
rm -f "${tijdelijk}"
echo "system.properties bijgewerkt met de huidige databasegegevens."
fi
<% endraw %>

View file

@ -19,7 +19,16 @@
"categories": [
"netwerk",
"beheer"
]
],
"architectures": [
"amd64",
"arm64"
],
"architecture_note": "Op een Raspberry Pi is een 64-bits besturingssysteem vereist. Voor een Pi 4 is de database bewust vastgezet op MongoDB 4.4.29.",
"web": {
"scheme": "https",
"port": 8443
}
},
"variables": [
{

View file

@ -0,0 +1,12 @@
services:
<< service_name >>:
image: victoriametrics/victoria-metrics:latest
container_name: << service_name >>
command:
- -storageDataPath=/storage
- -retentionPeriod=<< retention_months >>
ports:
- "<< port >>:8428"
volumes:
- << appdata_dir >>/<< service_name >>:/storage
restart: unless-stopped

View file

@ -0,0 +1,24 @@
{
"kind": "compose",
"metadata": {
"name": "VictoriaMetrics",
"description": "Snelle en zuinige tijdreeksdatabase voor Prometheus-metrics. Kan Prometheus remote_write ontvangen en Grafana als databron bedienen; geschikt voor een enkele server of Raspberry Pi.",
"tags": ["metrics", "prometheus", "tijdreeksen", "grafana"],
"icon": {"provider": "selfhst", "id": "victoriametrics"},
"version": {"name": "latest"},
"categories": ["monitoring", "analytics"],
"architectures": ["amd64", "arm64"],
"architecture_note": "VictoriaMetrics publiceert officiële AMD64- en ARM64-images en documenteert Raspberry Pi-ondersteuning."
},
"variables": [
{
"title": "Algemeen",
"items": [
{"name": "service_name", "type": "str", "title": "Servicenaam", "default": "victoriametrics", "required": true},
{"name": "port", "type": "int", "title": "Web- en API-poort", "default": 8428, "required": true},
{"name": "appdata_dir", "type": "str", "title": "Appdata-map", "default": "/opt/serverup/appdata", "required": true},
{"name": "retention_months", "type": "int", "title": "Bewaartermijn in maanden", "default": 12, "required": true}
]
}
]
}

View file

@ -18,7 +18,11 @@
"categories": [
"beveiliging",
"monitoring"
]
],
"architectures": [
"amd64"
],
"architecture_note": "Een of meer containerimages van deze app worden alleen voor AMD64 gepubliceerd. Deze app werkt daardoor niet op een Raspberry Pi."
},
"variables": [
{

View file

@ -0,0 +1,46 @@
services:
<< service_name >>:
image: ghcr.io/windmill-labs/windmill:main
container_name: << service_name >>
ports:
- "<< port >>:8000"
environment:
- MODE=server
- DATABASE_URL=postgres://postgres:<< db_password >>@<< service_name >>-db/windmill?sslmode=disable
volumes:
- << appdata_dir >>/<< service_name >>/logs:/tmp/windmill/logs
depends_on:
<< service_name >>-db:
condition: service_healthy
restart: unless-stopped
<< service_name >>-worker:
image: ghcr.io/windmill-labs/windmill:main
container_name: << service_name >>-worker
environment:
- MODE=worker
- WORKER_GROUP=default
- DATABASE_URL=postgres://postgres:<< db_password >>@<< service_name >>-db/windmill?sslmode=disable
volumes:
- << appdata_dir >>/<< service_name >>/worker-cache:/tmp/windmill/cache
- << appdata_dir >>/<< service_name >>/logs:/tmp/windmill/logs
depends_on:
<< service_name >>-db:
condition: service_healthy
restart: unless-stopped
<< service_name >>-db:
image: postgres:16-alpine
container_name: << service_name >>-db
environment:
- POSTGRES_DB=windmill
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=<< db_password >>
volumes:
- << appdata_dir >>/<< service_name >>/db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d windmill"]
interval: 10s
timeout: 5s
retries: 10
restart: unless-stopped

View file

@ -0,0 +1,29 @@
{
"kind": "compose",
"metadata": {
"name": "Windmill",
"description": "Open-source platform voor scripts, interne apps en workflows. De template start een server, een worker en PostgreSQL zonder toegang tot de Docker-socket.",
"tags": ["automatisering", "workflows", "scripts", "low-code"],
"icon": {"provider": "selfhst", "id": "windmill"},
"version": {"name": "latest"},
"categories": ["ontwikkeling", "productiviteit"],
"architectures": ["amd64", "arm64"],
"architecture_note": "De officiële Windmill- en PostgreSQL-images ondersteunen AMD64 en ARM64."
},
"variables": [
{
"title": "Algemeen",
"items": [
{"name": "service_name", "type": "str", "title": "Servicenaam", "default": "windmill", "required": true},
{"name": "port", "type": "int", "title": "Webinterface", "default": 14004, "required": true},
{"name": "appdata_dir", "type": "str", "title": "Appdata-map", "default": "/opt/serverup/appdata", "required": true}
]
},
{
"title": "Database",
"items": [
{"name": "db_password", "type": "str", "title": "Databasewachtwoord", "default": "", "required": true, "secret": true, "generate": "auto"}
]
}
]
}

View file

@ -3,8 +3,8 @@ services:
build:
context: .
args:
# Bakt de versie in het image. De deploy-workflow zet SU_VERSION
# (git-tag of main-<sha>); zonder waarde valt het terug op 'dev'.
# Bakt de versie in het image. De deploy-workflow zet SU_VERSION uit
# VERSION of de release-tag; zonder waarde valt het terug op 'dev'.
SU_VERSION: ${SU_VERSION:-dev}
# Image + tag staan los, zodat bijwerken vanuit de interface werkt: die
# schrijft SU_TAG in .env en laat compose de container hercreëren.
@ -39,6 +39,9 @@ services:
- SU_CONFIG=/data/config.json
- SU_AUDIT=/data/audit.db
- SU_GIT_CACHE=/data/git
# Joblogs overleven hier een herstart; zonder deze map is na het
# bijwerken niet meer terug te zien hoe de taak afliep.
- SU_JOBS=/data/jobs
- SU_CONTAINER=server-up
# Onder welk account Server Up draait. install.sh vult dit in; leeg of 0
# betekent root. De container krijgt hiermee géén extra rechten — er gaan

View file

@ -112,6 +112,20 @@ De testsuite controleert dit voor elke app.
| `categories` | Eén of meer categorie-ids (zie onder). Ontbreken ze, dan worden ze afgeleid uit de tags |
| `icon` | `{"provider": "selfhst", "id": "…"}` of `dashboard-icons`. Bestaat het icoon niet, dan valt de interface terug op een emoji |
| `version` | Alleen ter informatie in de catalogus |
| `architectures` | Optionele lijst met aantoonbaar ondersteunde Docker-architecturen: `amd64`, `arm64`, `arm/v7` of `arm/v6` |
| `architecture_note` | Uitleg die bij de platformmelding in de catalogus en het installatieformulier staat |
Laat `architectures` weg als de image-ondersteuning niet is geverifieerd. Een
expliciete mismatch blokkeert de installatie zowel in de interface als in de
API; ontbrekende of onbekende metadata blokkeert bewust niets. Voorbeeld:
```json
"architectures": ["amd64", "arm64"],
"architecture_note": "Op een Raspberry Pi is een 64-bits OS vereist."
```
Controleer alle images uit de compose-stack. Zodra een database- of
hulpcontainer alleen AMD64 aanbiedt, is de hele app alleen geschikt voor AMD64.
### Categorieën

View file

@ -56,15 +56,17 @@ aangeboden.
### Wat er gebeurt als je op "Nu bijwerken" klikt
```
1. docker pull git.example.com/bes-r/server-up:0.5.10
2. SU_TAG=0.5.10 wegschrijven in het .env van de deploy-map
3. helper-container starten: sleep 5 && docker compose up -d server-up
4. Server Up wordt vervangen; de browser pollt /healthz tot de nieuwe versie leeft
1. Forgejo-tag `v0.5.10` vertalen naar registry-tag `0.5.10`
2. docker pull git.example.com/bes-r/server-up:0.5.10
3. SU_TAG=0.5.10 en SU_VERSION=0.5.10 in de deploy-.env vastleggen
4. helper starten met exact dezelfde Compose-projectnaam en configuratiebestanden
5. Server Up vervangen; de browser pollt /healthz tot de nieuwe versie leeft
```
Stap 3 is nodig omdat een container zichzelf niet kan hercreëren: het commando
Stap 4 is nodig omdat een container zichzelf niet kan hercreëren: het commando
zou halverwege zijn eigen proces afbreken. De helper draait op het zojuist
gehaalde image — dat bevat de docker- en compose-CLI al — en ruimt zichzelf op.
Met `--no-build --pull never` gebruikt Compose gegarandeerd precies dat image.
De deploy-map wordt niet geraden maar uitgelezen uit de compose-labels op de
eigen container (`com.docker.compose.project.working_dir`). Draait Server Up niet
@ -170,6 +172,7 @@ Op de server waar Server Up draait, in de deploy-map (`/opt/server-up`):
# .env
SU_IMAGE=10.0.20.22:3000/bes-r/server-up
SU_TAG=0.5.10
SU_VERSION=0.5.10
BIND=127.0.0.1
PORT=5000
```
@ -178,10 +181,16 @@ PORT=5000
registry en werkt de updateknop. Staat het er niet, dan bouwt de server lokaal
zoals voorheen en meldt de interface netjes waarom bijwerken niet kan.
> Server Up schrijft `SU_TAG` niet zelf in je `.env`, en dat is met opzet: de
> installatiemap is nergens in de container gemount. De helper-container die de
> hercreatie doet, mount hem wél en zet de tag daar. Tot v0.7.95 probeerde
> Server Up het zelf, en faalde dat dus altijd.
De Git-release heet bijvoorbeeld `v0.5.10`, maar de canonieke image-tag heet
`:0.5.10`. Server Up verwijdert die ene voorloop-`v` automatisch voor zowel
stable/main- als beta-releases. De build publiceert daarnaast `:v0.5.10` als
compatibiliteitstag, zodat installaties met de oude updater één keer zonder
handmatige ingreep naar de gerepareerde versie kunnen overstappen.
> De draaiende Server Up-container schrijft niet rechtstreeks in je deploy-
> `.env`: de installatiemap is daar niet gemount. De updatehelper mount die map
> wel en werkt `SU_TAG` en `SU_VERSION` samen bij. Zo blijft ook een latere
> handmatige `docker compose up -d` op dezelfde versie.
Eerste keer overstappen van lokaal bouwen naar de registry:

View file

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

View file

@ -1,6 +1,6 @@
"""Server Up — Docker Manager."""
from __future__ import annotations
import inspect, json, os, re, secrets, shutil, socket, subprocess, sys, time
import glob, inspect, json, os, re, secrets, shutil, socket, subprocess, sys, time
from datetime import datetime, timedelta
from pathlib import Path
@ -15,7 +15,8 @@ from flask import Flask, Response, jsonify, render_template, request, send_file,
import core as cfg
from core import audit, jobs, i18n
from core import auth, docker, git, boilerplates, updater
from core import backups, categories, compose_transform, diskspace, networks
from core import backups, categories, compose_transform, dbwachtwoord
from core import diskspace, networks
from core import notify, paden, pangolin
from core import scheduler, selfupdate
from core import stackupdates
@ -407,10 +408,11 @@ def api_update_apply():
Kort na afloop verdwijnt deze container; de front-end pollt daarna /healthz.
"""
d = request.json or {}
tag = (d.get("tag") or "").strip()
tag = updater.image_tag(d.get("tag") or "")
if not tag:
info = updater.check(VERSION)
tag = info.get("latest_tag") or ""
tag = (info.get("image_tag")
or updater.image_tag(info.get("latest_tag") or ""))
if not tag:
return jsonify(ok=False, msg="Geen versie opgegeven en geen release gevonden"), 400
st = selfupdate.status()
@ -448,13 +450,15 @@ def api_update_rollback():
return jsonify(ok=False, msg="Geen vorige versie bekend"), 400
jid, q = jobs.create("update:rollback")
# De job draait in een eigen thread; `request` bestaat daar niet meer.
ip = request.remote_addr
def _run(jid):
qq = jobs.get_queue(jid)
ok, msg = selfupdate.rollback(log_fn=lambda m: jobs.log(qq, "dim", m))
jobs.log(qq, "ok" if ok else "error", msg)
audit.log("update", "rollback", "ok" if ok else "error",
ref=st.get("previous_tag", ""), ip=request.remote_addr)
ref=st.get("previous_tag", ""), ip=ip)
jobs.finish(jid, "done" if ok else "error")
jobs.done(qq)
@ -670,6 +674,36 @@ def api_stacks():
return h["logo_url"], h["icon"]
return "", ""
# Het browseradres per stack. De bronmetadata wordt per sjabloon onthouden:
# dezelfde app twee keer geïnstalleerd zou anders twee keer de git-cache
# doorzoeken.
host = request.host.split(":")[0]
bron_meta_cache: dict[str, dict] = {}
def _bron_meta(meta: dict) -> dict:
sleutel = f"{meta.get('repo_id', '')}/{meta.get('source', '')}"
if sleutel not in bron_meta_cache:
uit = {}
try:
src = _find_stack_src(meta.get("source", ""), meta.get("repo_id", ""))
if src:
uit = boilerplates.metadata(src)
except Exception:
uit = {}
bron_meta_cache[sleutel] = uit
return bron_meta_cache[sleutel]
def _web_url(d, meta: dict) -> str:
try:
return _stack_webadres(meta, _bron_meta(meta),
docker.read_compose(d), host)
except Exception:
return ""
# Eén `docker ps` voor alle apps samen. Per stackmap een `docker compose ps`
# starten betekende bij vijftien apps vijftien processen per keer verversen.
per_project = docker.containers_per_project()
out = []
for d in sorted(lib.iterdir()):
if not d.is_dir() or d.name.startswith("."):
@ -683,7 +717,7 @@ def api_stacks():
# plaats van een status, geen knoppen — die door dit `continue` nooit te
# zien kon zijn. Tonen mét die waarschuwing is beter dan verzwijgen.
heeft_compose = docker.has_compose(d)
ct = docker.compose_ps(d, name=d.name) if heeft_compose else []
ct = per_project.get(d.name, []) if heeft_compose else []
logo_url, icon = _stack_logo(d)
meta = _stack_meta(d)
out.append({
@ -692,6 +726,10 @@ def api_stacks():
"containers": ct,
"logo_url": logo_url, "icon": icon,
"network": meta.get("network", ""), "ip": meta.get("ip", ""),
# Waar de webinterface van deze app draait; leeg als de app er geen
# heeft. Stond eerder alleen in de modal met inloggegevens, waar je
# hem niet zoekt als je gewoon de app wilt openen.
"web_url": _web_url(d, meta) if heeft_compose else "",
# Bij installatie vastgelegd; ontbreekt dat (oudere installatie),
# dan leiden we het alsnog af uit de opgeslagen naam.
"connected": bool(meta.get("connected")),
@ -751,7 +789,15 @@ def _stack_dir(name: str) -> tuple[Path | None, Path | None]:
if not cfg.safe_name(name):
return None, None
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
d = (lib / name).resolve()
kandidaat = lib / name
# Overgenomen compose-apps zijn bewust een directe symlink in LIBRARY_DIR.
# resolve() maakte daarvan een pad buiten de library en blokkeerde daarna
# alle acties, inclusief het loskoppelen waarvoor hieronder speciale code
# staat. De veilige naam garandeert dat alleen dit ene directe kind wordt
# gekozen; een gewone map blijft volledig geresolved en begrensd.
if kandidaat.is_symlink():
return lib, kandidaat
d = kandidaat.resolve()
if not d.is_relative_to(lib) or d == lib:
return None, None
return lib, d
@ -762,17 +808,20 @@ def api_stack_action(name, action):
lib, d = _stack_dir(name)
if not d:
return jsonify(ok=False, msg="ongeldige naam"), 400
if not d.exists():
if not d.exists() and not (action == "remove" and d.is_symlink()):
return jsonify(ok=False, msg="niet gevonden"), 404
if action not in ("start", "stop", "restart", "update", "remove", "backup"):
return jsonify(ok=False, msg="onbekend"), 400
body = request.json or {}
remove_mode = body.get("mode", "full")
if action == "remove" and remove_mode not in ("stack", "full", "all"):
return jsonify(ok=False, msg="ongeldige verwijderkeuze"), 400
jid, q = jobs.create(f"{action}:{name}")
def _run(jid):
qq = jobs.get_queue(jid)
pdir = Path(cfg.load()["LIBRARY_DIR"]).resolve() / name
pdir = d
lf = lambda m: jobs.log(qq, "dim", m)
try:
jobs.log(qq, "section", f"{action}: {name}")
@ -789,27 +838,35 @@ def api_stack_action(name, action):
docker.compose_pull(pdir, log_fn=lf)
rc = docker.compose_up(pdir, log_fn=lf, name=name)
elif action == "remove":
mode = body.get("mode", "stack")
mode = remove_mode
if mode in ("full", "all"):
_auto_backup(qq, name, "BACKUP_BEFORE_REMOVE", "voor-remove")
if docker.has_compose(pdir):
docker.compose_down(pdir, log_fn=lf, volumes=True, name=name)
if mode in ("full", "all") and pdir.is_dir():
# De gewone verwijderactie bewaart data: dus ook named
# volumes. Alleen de expliciete alles-wissenkeuze gebruikt
# compose down -v. De oude API-modus `stack` behoudt zijn
# historische betekenis (alleen stoppen + volumes wissen).
docker.compose_down(
pdir, log_fn=lf, volumes=mode in ("stack", "all"),
name=name)
if mode in ("full", "all") and (
pdir.is_dir() or pdir.is_symlink()):
# Een overgenomen app is een symlink naar een map van de
# gebruiker zelf. Die map is niet van ons; alleen de
# verwijzing halen we weg. `shutil.rmtree` zou hier
# bovendien weigeren ("Cannot call rmtree on a symbolic
# link") en de verwijdering halverwege laten stranden.
# verwijzing halen we weg.
if pdir.is_symlink():
doelmap = os.path.realpath(pdir)
pdir.unlink()
jobs.log(qq, "ok", f"Losgekoppeld; {doelmap} blijft staan")
else:
shutil.rmtree(pdir)
jobs.log(qq, "ok", "Bestanden verwijderd")
jobs.log(qq, "ok", "Appbestanden verwijderd")
if mode == "all":
dd = Path(cfg.load()["DATA_DIR"]).resolve() / name
if dd.is_dir():
if dd.is_symlink():
dd.unlink()
jobs.log(qq, "ok", "Dataverwijzing verwijderd")
elif dd.is_dir():
shutil.rmtree(dd)
jobs.log(qq, "ok", "Data verwijderd")
elif action == "backup":
@ -828,7 +885,9 @@ def api_stack_action(name, action):
jobs.finish(jid, "error")
jobs.done(qq)
jobs.run(_run, jid)
# Eén taak tegelijk per app: anders kan een geplande backup midden
# in deze wijziging beginnen en een halve stack inpakken.
jobs.run(jobs.met_slot(name, _run), jid)
return jsonify(ok=True, job_id=jid)
@ -890,18 +949,15 @@ def api_stack_credentials(name):
items.append({"kind": soort, "title": f.get("title") or f["name"],
"value": waarde, "secret": bool(f.get("secret"))})
# Het adres waarop de app draait: eigen IP, anders de gepubliceerde poort.
adres = ""
# Het adres waarop de app draait. Sommige apps (waaronder UniFi) luisteren
# uitsluitend via HTTPS; het oude vaste http://-adres maakte hun link stuk.
try:
poorten = compose_transform.published_ports(docker.read_compose(d))
compose_text = docker.read_compose(d)
except Exception:
poorten = []
if meta.get("ip"):
adres = f"http://{meta['ip']}"
elif poorten:
eerste = str(poorten[0]).strip('"').split(":")
if len(eerste) >= 2 and eerste[-2].isdigit():
adres = f"http://{request.host.split(':')[0]}:{eerste[-2]}"
compose_text = ""
bron_meta = boilerplates.metadata(bron) if bron else {}
adres = _stack_webadres(meta, bron_meta, compose_text,
request.host.split(":")[0])
if items:
audit.log("stacks", "credentials_view", "ok", ref=name,
@ -913,6 +969,43 @@ def api_stack_credentials(name):
self_signup=not items)
def _stack_webadres(meta: dict, bron_meta: dict, compose_text: str,
host: str) -> str:
"""Bouw het browseradres uit template-metadata en Compose-poorten.
De bronmetadata blijft bewust een fallback: daarmee krijgen ook stacks die
vóór deze wijziging zijn geïnstalleerd meteen het juiste HTTPS-adres.
"""
web = meta.get("web") if isinstance(meta.get("web"), dict) else {}
if not web and isinstance(bron_meta.get("web"), dict):
web = bron_meta["web"]
schema = str(web.get("scheme") or "http").lower()
if schema not in ("http", "https"):
schema = "http"
try:
doelpoort = int(web.get("port") or 0)
except (TypeError, ValueError):
doelpoort = 0
eigen_ip = str(meta.get("ip") or "").strip()
if eigen_ip:
poort = doelpoort
host = eigen_ip
else:
poorten = [p for p in compose_transform.poorten_per_service(compose_text)
if p.get("proto") == "tcp"]
gekozen = next((p for p in poorten
if doelpoort and p.get("container") == doelpoort), None)
gekozen = gekozen or (poorten[0] if poorten else None)
poort = (gekozen or {}).get("host") or (gekozen or {}).get("container") or 0
if not host or (not eigen_ip and not poort):
return ""
standaard = (schema == "http" and poort == 80) or (
schema == "https" and poort == 443)
return f"{schema}://{host}{'' if not poort or standaard else f':{poort}'}"
@app.route("/api/stacks/<name>/compose")
def api_stack_compose(name):
lib, d = _stack_dir(name)
@ -1027,8 +1120,29 @@ def api_stack_config(name):
ips=meta.get("ips") or {},
connected=bool(meta.get("connected")),
app_networks=meta.get("app_networks") or [],
app_services=meta.get("app_services") or [],
extras=meta.get("extras") or {"volumes": [], "devices": []},
image_tags=meta.get("image_tags") or {},
source=meta.get("source", ""), repo_id=meta.get("repo_id", ""),
name=meta.get("name", name))
name=meta.get("name", name),
# Waar dit sjabloon vandaan komt. Opslaan rendert de app
# opnieuw uit déze cache; loopt die achter, dan haal je een
# oude versie over je werkende stack heen.
source_repo=_sjabloon_herkomst(meta.get("repo_id", "")))
def _sjabloon_herkomst(rid: str) -> dict:
"""Repo, branch en stand van de cache waar een sjabloon uit komt."""
if not rid:
return {}
repo = next((r for r in cfg.load().get("APP_REPOS", [])
if isinstance(r, dict) and r.get("id") == rid), None)
if not repo:
return {"id": rid, "missing": True}
stand = git.cache_status(rid)
stand["outdated"] = _cache_loopt_achter(stand.get("version", ""))
return {"id": rid, "name": repo.get("name") or rid,
"branch": repo.get("branch") or "main", **stand}
@app.route("/api/stacks/<name>/reconfigure", methods=["POST"])
@ -1069,6 +1183,15 @@ def api_stack_reconfigure(name):
app_netten = [n for n in (meta.get("app_networks") or []) if n] or (
[gedeeld_net] if meta.get("connected") else [])
verbinden = bool(app_netten)
# Welke onderdelen op dat netwerk komen. Zonder keuze blijven databases er
# buiten; zie compose_transform.add_shared_network.
app_diensten = _gekozen_app_diensten(body, meta)
extras, extra_fout = _gekozen_extras(body, meta)
if extra_fout:
return jsonify(ok=False, msg=extra_fout), 400
# Welke versietag per onderdeel. Zonder keuze blijft staan wat het sjabloon
# zelf zegt; met een keuze overleeft die elke volgende wijziging.
image_tags = _gekozen_image_tags(body, meta)
# Ook hier: een veld leegmaken mag niet stilzwijgend een kapotte stack
# opleveren — en er ligt al een werkende versie die we niet moeten slopen.
@ -1078,6 +1201,11 @@ def api_stack_reconfigure(name):
missing=ontbreekt), 400
jid, q = jobs.create(f"reconfigure:{name}")
# De job draait in een eigen thread; `request` bestaat daar niet meer.
# Uitlezen ná de start gaf een RuntimeError precies nadat de stack met de
# nieuwe instellingen al draaide — en de foutafhandeling zette dan de
# backup terug, dus een geslaagde wijziging werd alsnog teruggedraaid.
ip = request.remote_addr
def _run(jid):
qq = jobs.get_queue(jid)
@ -1092,6 +1220,12 @@ def api_stack_reconfigure(name):
jobs.log(qq, "error", f"Backup mislukt, wijziging afgebroken: {e}")
jobs.finish(jid, "error"); jobs.done(qq); return
# De databases zoals ze nú draaien, mét hun huidige wachtwoorden.
# Na het renderen staan die nergens meer, en zonder het oude
# wachtwoord valt een nieuw wachtwoord niet meer te zetten.
db_voor = backups.diensten_uit_compose(
docker.read_compose(d), backups._env_waarden(d))
boilerplates.render_to_dir(bron, d, nieuwe_waarden)
jobs.log(qq, "ok", "Opnieuw gerenderd")
@ -1139,9 +1273,42 @@ def api_stack_reconfigure(name):
else:
jobs.log(qq, "warn", f"Netwerk '{naam}' niet beschikbaar: {fout_net}")
if klaar:
tekst = cf.read_text(encoding="utf-8")
diensten_nu = app_diensten or \
compose_transform.deelbare_diensten(tekst)
cf.write_text(compose_transform.add_shared_network(
cf.read_text(encoding="utf-8"), klaar), encoding="utf-8")
jobs.log(qq, "ok", "Gekoppeld aan " + ", ".join(f"'{n}'" for n in klaar))
tekst, klaar, services=diensten_nu), encoding="utf-8")
jobs.log(qq, "ok", "Gekoppeld aan "
+ ", ".join(f"'{n}'" for n in klaar)
+ " (" + ", ".join(diensten_nu) + ")")
# De gekozen versietags terugzetten: het renderen haalt ze weg,
# want het sjabloon schrijft zijn eigen image-regel.
if cf and image_tags:
try:
nieuw_txt = compose_transform.zet_image_tags(
cf.read_text(encoding="utf-8"), image_tags)
if nieuw_txt != cf.read_text(encoding="utf-8"):
cf.write_text(nieuw_txt, encoding="utf-8")
for svc, tag in image_tags.items():
jobs.log(qq, "ok", f"Versie vastgezet: {svc}{tag}")
except compose_transform.TransformError as e:
jobs.log(qq, "warn", f"Versietag niet toegepast: {e}")
# Eigen mappen en apparaten als laatste: die horen ook op een
# service die net een eigen IP of netwerk kreeg.
if cf and (extras.get("volumes") or extras.get("devices")):
try:
cf.write_text(compose_transform.add_extras(
cf.read_text(encoding="utf-8"), extras), encoding="utf-8")
for v in extras.get("volumes") or []:
jobs.log(qq, "ok", f"Map gekoppeld: {v['host']}"
f"{v['service']}:{v['container']}")
for a in extras.get("devices") or []:
jobs.log(qq, "ok", f"Apparaat gekoppeld: {a['host']}"
f"{a['service']}")
except compose_transform.TransformError as e:
jobs.log(qq, "warn", f"Extra's niet toegepast: {e}")
ok_cfg, err = docker.validate_compose(d)
if not ok_cfg:
@ -1150,13 +1317,36 @@ def api_stack_reconfigure(name):
backups.restore(name, backup_naam, log_fn=lf)
jobs.finish(jid, "error"); jobs.done(qq); return
# Een gewijzigd databasewachtwoord staat nu in het compose-bestand,
# maar de database zelf weet daar niets van: die leest zijn
# wachtwoordvariabele alleen bij een lege datamap. Omzetten moet dus
# n\u00fa, terwijl de oude container nog draait met het oude wachtwoord.
db_na = backups.diensten_uit_compose(
docker.read_compose(d), backups._env_waarden(d))
for dienst in dbwachtwoord.wijzigingen(db_voor, db_na):
ok_ww, fout_ww = dbwachtwoord.omzetten(
dienst, dienst["wachtwoord"],
log_fn=lambda m: jobs.log(qq, "ok", m))
if not ok_ww:
jobs.log(qq, "error", fout_ww)
jobs.log(qq, "error",
"Zonder die wijziging kan de app straks niet meer "
"bij zijn database. De wijziging is afgebroken.")
jobs.log(qq, "dim", "Backup wordt teruggezet\u2026")
backups.restore(name, backup_naam, log_fn=lf)
jobs.finish(jid, "error"); jobs.done(qq); return
zichtbaar, geheime_namen = _splits_geheimen(velden_bron, nieuwe_waarden)
_stack_meta_schrijven(d, {
"values": zichtbaar, "secret_fields": geheime_namen,
"secret_keys": secret_keys,
"network": net_name, "ip": net_ip, "ips": net_ips,
"app_networks": app_netten,
"app_services": app_diensten,
"extras": extras,
"image_tags": image_tags,
"service_ports": poorten_meta,
"web": boilerplates.metadata(bron).get("web") or {},
"connected": bool(app_netten)})
docker.compose_down(d, log_fn=lf, name=name)
@ -1165,7 +1355,7 @@ def api_stack_reconfigure(name):
"Herstart met de nieuwe instellingen" if rc == 0
else "Starten mislukt \u2014 zet de backup terug via Backups")
audit.log("stacks", "reconfigure", "ok" if rc == 0 else "error",
ref=name, ip=request.remote_addr)
ref=name, ip=ip)
jobs.finish(jid, "done" if rc == 0 else "error")
except Exception as e:
jobs.log(qq, "error", f"{type(e).__name__}: {e}")
@ -1178,7 +1368,9 @@ def api_stack_reconfigure(name):
jobs.finish(jid, "error")
jobs.done(qq)
jobs.run(_run, jid)
# Eén taak tegelijk per app: anders kan een geplande backup midden
# in deze wijziging beginnen en een halve stack inpakken.
jobs.run(jobs.met_slot(name, _run), jid)
return jsonify(ok=True, job_id=jid)
@ -1192,10 +1384,14 @@ def api_stack_connect(name):
lib, d = _stack_dir(name)
if not d or not d.exists():
return jsonify(ok=False, msg="niet gevonden"), 404
aan = bool((request.json or {}).get("connect", True))
body = request.json or {}
aan = bool(body.get("connect", True))
gedeeld_net = (cfg.load().get("SHARED_NETWORK") or "serverup").strip()
app_diensten = _gekozen_app_diensten(body, _stack_meta_lezen(d))
jid, q = jobs.create(f"connect:{name}")
# De job draait in een eigen thread; `request` bestaat daar niet meer.
ip = request.remote_addr
def _run(jid):
qq = jobs.get_queue(jid)
@ -1215,27 +1411,32 @@ def api_stack_connect(name):
cf = docker.find_compose(d)
origineel = cf.read_text(encoding="utf-8")
cf.write_text(compose_transform.add_shared_network(origineel, gedeeld_net),
encoding="utf-8")
diensten_nu = app_diensten or \
compose_transform.deelbare_diensten(origineel)
cf.write_text(compose_transform.add_shared_network(
origineel, gedeeld_net, services=diensten_nu), encoding="utf-8")
ok_cfg, err = docker.validate_compose(d)
if not ok_cfg:
cf.write_text(origineel, encoding="utf-8")
jobs.log(qq, "error", f"Compose werd ongeldig, teruggedraaid:\n{err}")
jobs.finish(jid, "error"); jobs.done(qq); return
_stack_meta_schrijven(d, {"connected": True})
_stack_meta_schrijven(d, {"connected": True,
"app_services": diensten_nu})
docker.compose_up(d, log_fn=lf, name=name)
jobs.log(qq, "ok",
f"Gekoppeld aan '{gedeeld_net}' \u2014 andere apps zijn nu "
"bereikbaar op hun containernaam.")
audit.log("stacks", "connect", "ok", ref=name, ip=request.remote_addr)
f"Gekoppeld aan '{gedeeld_net}' ({', '.join(diensten_nu)}) "
"\u2014 die zijn nu bereikbaar op hun containernaam.")
audit.log("stacks", "connect", "ok", ref=name, ip=ip)
jobs.finish(jid, "done")
except Exception as e:
jobs.log(qq, "error", str(e))
jobs.finish(jid, "error")
jobs.done(qq)
jobs.run(_run, jid)
# Eén taak tegelijk per app: anders kan een geplande backup midden
# in deze wijziging beginnen en een halve stack inpakken.
jobs.run(jobs.met_slot(name, _run), jid)
return jsonify(ok=True, job_id=jid)
@ -1323,10 +1524,28 @@ def api_repos():
if r["id"] in gezien:
continue
gezien.add(r["id"])
result.append({**r, "cloned": git.cache_dir(r["id"]).exists()})
# Mét de stand van de cache: welke commit, van wanneer, en loopt de
# meegeleverde VERSION achter op wat hier draait? Zonder dat is niet te
# zien dat een app een oud sjabloon rendert.
stand = git.cache_status(r["id"])
stand["outdated"] = _cache_loopt_achter(stand.get("version", ""))
result.append({**r, "cloned": git.cache_dir(r["id"]).exists(),
"cache": stand})
return jsonify(result)
def _cache_loopt_achter(repo_versie: str) -> bool:
"""Is de VERSION in de git-cache ouder dan de draaiende Server Up?
Alleen zinvol voor repo's die zelf een VERSION meeleveren; voor de rest
zegt dit niets en blijft het antwoord False.
"""
if not repo_versie:
return False
hier, daar = updater._parse(VERSION), updater._parse(repo_versie)
return bool(hier and daar and daar < hier)
@app.route("/api/repos", methods=["POST"])
def api_repos_add():
d = request.json or {}
@ -1351,6 +1570,49 @@ def api_repos_add():
return jsonify(ok=True)
@app.route("/api/repos/<rid>", methods=["PUT"])
def api_repos_edit(rid):
"""Naam, branch of submap van een bestaande repo aanpassen.
Zonder dit kon je alleen verwijderen en opnieuw toevoegen. De id wordt
daarbij uit de URL afgeleid en krijgt er een volgnummer bij zodra hij al
bestaat en juist die id staat in de `.serverup.json` van elke stack die
uit deze repo komt. Overstappen van de ene branch naar de andere maakte zo
stilletjes het bronsjabloon van je apps onvindbaar.
"""
d = request.json or {}
repos = list(cfg.load().get("APP_REPOS", []))
repo = next((r for r in repos
if isinstance(r, dict) and r.get("id") == rid), None)
if not repo:
return jsonify(ok=False, msg="niet gevonden"), 404
if "url" in d:
url = str(d.get("url") or "").strip()
if not url:
return jsonify(ok=False, msg="URL vereist"), 400
if not cfg.valid_repo_url(url):
return jsonify(ok=False, msg=_BAD_URL_MSG), 400
repo["url"] = url
for veld in ("name", "branch", "subdir"):
if veld in d:
repo[veld] = str(d.get(veld) or "").strip()
# Een leeg tokenveld betekent "ongewijzigd": de interface krijgt het token
# nooit te zien, dus zou opslaan hem anders wissen.
if str(d.get("token") or "").strip():
repo["token"] = d["token"].strip()
if not repo.get("branch"):
repo["branch"] = "main"
cfg.patch({"APP_REPOS": repos})
audit.log("repos", "edit", "ok", ref=rid, detail=repo.get("branch", ""),
ip=request.remote_addr)
# De cache staat nog op de oude branch; die haalt de eerstvolgende sync op.
veilig = {k: v for k, v in repo.items() if k != "token"}
veilig["has_token"] = bool((repo.get("token") or "").strip())
return jsonify(ok=True, repo=veilig)
@app.route("/api/repos/<rid>", methods=["DELETE"])
def api_repos_del(rid):
# `r["id"]` op een regel zonder id gaf hier een KeyError, dus een 500 —
@ -1390,7 +1652,10 @@ def api_repos_stacks(rid):
repo = next((r for r in cfg.load().get("APP_REPOS", []) if r["id"] == rid), None)
if not repo:
return jsonify(stacks=[])
return jsonify(stacks=git.scan_stacks(rid, lib, subdir=repo.get("subdir", "")))
stacks = git.scan_stacks(rid, lib, subdir=repo.get("subdir", ""))
for stack in stacks:
stack["compatibility"] = boilerplates.compatibility(stack)
return jsonify(stacks=stacks)
def _is_stack_dir(p: Path) -> bool:
@ -1457,6 +1722,83 @@ def _gekozen_app_netwerken(body: dict, standaard: str) -> list[str]:
return [standaard] if verbinden else []
_TAG_TEKENS = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
def _gekozen_image_tags(body: dict, meta: dict | None = None) -> dict[str, str]:
"""Per service de gekozen versietag.
Zonder keuze blijft staan wat het sjabloon zelf zegt. Tags worden getoetst
aan wat Docker toestaat: ze belanden in het compose-bestand, dus een waarde
met een spatie of een dubbele punt erin zou dat bestand stukmaken.
"""
ruw = body.get("image_tags")
if not isinstance(ruw, dict):
return {s: t for s, t in ((meta or {}).get("image_tags") or {}).items()
if isinstance(t, str) and _TAG_TEKENS.match(t)}
return {str(s): str(t).strip() for s, t in ruw.items()
if str(t).strip() and _TAG_TEKENS.match(str(t).strip())}
def _gekozen_extras(body: dict, meta: dict | None = None) -> tuple[dict, str]:
"""Extra mappen en apparaten uit de request, gecontroleerd.
Geeft (extras, foutmelding) terug. Een fout hier is een 400 en geen stille
correctie: wie een pad intikt dat niet mag, hoort te horen waarom in plaats
van een app te krijgen die dat pad niet heeft.
"""
if "extras" not in body:
rest = (meta or {}).get("extras") or {}
return (rest if isinstance(rest, dict) else {}), ""
ruw = body.get("extras") or {}
if not isinstance(ruw, dict):
return {}, "extras moet een object zijn"
uit: dict[str, list] = {"volumes": [], "devices": []}
for item in (ruw.get("volumes") or [])[:50]:
if not isinstance(item, dict):
continue
host = str(item.get("host") or "").strip().rstrip("/")
binnen = str(item.get("container") or "").strip().rstrip("/") or host
dienst = str(item.get("service") or "").strip()
if not host and not dienst:
continue
bezwaar = compose_transform.mount_bezwaar(host)
if bezwaar:
return {}, bezwaar
if not binnen.startswith("/"):
return {}, f"Het pad binnen de container moet absoluut zijn: {binnen}"
uit["volumes"].append({"service": dienst, "host": host,
"container": binnen, "ro": bool(item.get("ro"))})
for item in (ruw.get("devices") or [])[:50]:
if not isinstance(item, dict):
continue
host = str(item.get("host") or "").strip()
binnen = str(item.get("container") or "").strip() or host
dienst = str(item.get("service") or "").strip()
if not host and not dienst:
continue
bezwaar = compose_transform.apparaat_bezwaar(host)
if bezwaar:
return {}, bezwaar
uit["devices"].append({"service": dienst, "host": host,
"container": binnen})
return uit, ""
def _gekozen_app_diensten(body: dict, meta: dict | None = None) -> list[str]:
"""Welke onderdelen van deze stack mogen op het gedeelde netwerk?
Leeg betekent: laat `add_shared_network` kiezen, en die houdt databases en
zoekindexen erbuiten. Wie zijn database bewust wil delen één Postgres voor
twee stacks zet hem hier expliciet bij.
"""
if isinstance(body.get("app_services"), list):
return [s for s in body["app_services"] if isinstance(s, str) and s]
return [s for s in ((meta or {}).get("app_services") or [])
if isinstance(s, str) and s]
def _geinstalleerde_bronnen() -> dict[str, list[str]]:
"""Per app-sjabloon de stacks die eruit geïnstalleerd zijn.
@ -1642,10 +1984,22 @@ def api_store_preview():
# afleiden uit de veldnamen gaat mis zodra een onderdeel meer dan één
# container heeft.
diensten: list[str] = []
dienst_info: list[dict] = []
try:
doc = yaml.safe_load(preview_files.get("compose.yaml")
or preview_files.get("docker-compose.yml") or "") or {}
compose_tekst = (preview_files.get("compose.yaml")
or preview_files.get("docker-compose.yml") or "")
doc = yaml.safe_load(compose_tekst) or {}
diensten = [n for n in (doc.get("services") or {})]
# Mét image en de vraag of het een database is: daarmee laat de
# interface zien wat er wel en niet op het gedeelde netwerk komt,
# en welke versie er per onderdeel te kiezen valt.
dienst_info = compose_transform.diensten(compose_tekst)
per_image = {i["service"]: i
for i in compose_transform.images_per_service(compose_tekst)}
for info in dienst_info:
bron = per_image.get(info["name"]) or {}
info["repo"] = bron.get("repo", "")
info["tag"] = bron.get("tag", "")
except Exception:
diensten = []
return jsonify(
@ -1654,8 +2008,10 @@ def api_store_preview():
repo_id=rid,
format="boilerplate",
metadata=meta,
compatibility=boilerplates.compatibility(meta),
fields=flds,
services=diensten,
service_info=dienst_info,
depends_on=_afhankelijkheden_met_status(meta),
preview_files=preview_files,
# Backwards-compat voor de oude UI:
@ -1764,6 +2120,11 @@ def api_store_install():
# schakelaar en dus één netwerk voor álles; nu kun je groepjes maken.
gedeeld_net = (cfg.load().get("SHARED_NETWORK") or "serverup").strip()
app_netten = _gekozen_app_netwerken(d, gedeeld_net)
app_diensten = _gekozen_app_diensten(d)
extras, extra_fout = _gekozen_extras(d)
if extra_fout:
return jsonify(ok=False, msg=extra_fout), 400
bp_image_tags = _gekozen_image_tags(d)
verbinden = bool(app_netten)
if not stack:
return jsonify(ok=False, msg="stack vereist")
@ -1803,6 +2164,11 @@ def api_store_install():
is_bp = boilerplates.is_boilerplate(src)
if not is_bp and not docker.has_compose(src):
return jsonify(ok=False, msg=f"'{stack}' heeft geen compose bestand")
if is_bp:
passend = boilerplates.compatibility(boilerplates.metadata(src))
if not passend["supported"]:
return jsonify(ok=False, msg=passend["reason"],
compatibility=passend), 400
# Verplichte velden vóór het aanmaken van de job: een leeg wachtwoord of
# een lege sleutel levert een container op die niet start of die met een
@ -2021,14 +2387,45 @@ def api_store_install():
if klaar:
cf = docker.find_compose(dest)
try:
tekst = cf.read_text(encoding="utf-8")
diensten_nu = app_diensten or \
compose_transform.deelbare_diensten(tekst)
cf.write_text(compose_transform.add_shared_network(
cf.read_text(encoding="utf-8"), klaar), encoding="utf-8")
tekst, klaar, services=diensten_nu), encoding="utf-8")
jobs.log(qq, "ok",
"Gekoppeld aan " + ", ".join(f"'{n}'" for n in klaar)
+ " — apps daarin zijn bereikbaar op hun containernaam.")
+ " (" + ", ".join(diensten_nu) + ") — bereikbaar "
"op hun containernaam.")
except compose_transform.TransformError as e:
jobs.log(qq, "warn", f"Koppelen mislukt: {e}")
# Versietags uit het invulmenu (boilerplate-apps).
if bp_image_tags:
cf = docker.find_compose(dest)
try:
cf.write_text(compose_transform.zet_image_tags(
cf.read_text(encoding="utf-8"), bp_image_tags),
encoding="utf-8")
for svc, tag in bp_image_tags.items():
jobs.log(qq, "ok", f"Versie vastgezet: {svc}{tag}")
except compose_transform.TransformError as e:
jobs.log(qq, "warn", f"Versietag niet toegepast: {e}")
# Eigen mappen en apparaten uit het invulmenu.
if extras.get("volumes") or extras.get("devices"):
cf = docker.find_compose(dest)
try:
cf.write_text(compose_transform.add_extras(
cf.read_text(encoding="utf-8"), extras), encoding="utf-8")
for v in extras.get("volumes") or []:
jobs.log(qq, "ok", f"Map gekoppeld: {v['host']}"
f"{v['service']}:{v['container']}")
for a in extras.get("devices") or []:
jobs.log(qq, "ok", f"Apparaat gekoppeld: {a['host']}"
f"{a['service']}")
except compose_transform.TransformError as e:
jobs.log(qq, "warn", f"Extra's niet toegepast: {e}")
# Valideer het resultaat vóór we proberen te starten, zodat een
# fout in het template een duidelijke melding geeft.
ok_cfg, err_cfg = docker.validate_compose(dest)
@ -2066,9 +2463,13 @@ def api_store_install():
"ips": net_ips if net else {},
"connected": bool(app_netten),
"app_networks": app_netten,
"app_services": app_diensten,
"extras": extras,
"image_tags": bp_image_tags,
"service_ports": poorten_meta,
"categories": meta.get("categories") or [],
"tags": meta.get("tags") or [],
"web": meta.get("web") or {},
# Zonder de gebruikte waarden valt het installatieformulier
# later niet opnieuw te openen om iets te wijzigen.
"format": "boilerplate" if is_bp else "compose",
@ -2096,7 +2497,9 @@ def api_store_install():
jobs.finish(jid, "error")
jobs.done(qq)
jobs.run(_run, jid)
# Eén taak tegelijk per app: anders kan een geplande backup midden
# in deze wijziging beginnen en een halve stack inpakken.
jobs.run(jobs.met_slot(inst, _run), jid)
return jsonify(ok=True, job_id=jid)
@ -2148,6 +2551,8 @@ def api_backups():
@app.route("/api/backups/<stack>/create", methods=["POST"])
def api_backup_create(stack):
jid, q = jobs.create(f"backup:{stack}")
# De job draait in een eigen thread; `request` bestaat daar niet meer.
ip = request.remote_addr
def _run(jid):
qq = jobs.get_queue(jid)
@ -2157,7 +2562,7 @@ def api_backup_create(stack):
log_fn=lambda m: jobs.log(qq, "dim", m))
backups.prune(stack, log_fn=lambda m: jobs.log(qq, "dim", m))
jobs.log(qq, "ok", f"Backup klaar ({meta['file']})")
audit.log("backups", "create", "ok", ref=stack, ip=request.remote_addr)
audit.log("backups", "create", "ok", ref=stack, ip=ip)
jobs.finish(jid, "done")
except backups.BackupError as e:
jobs.log(qq, "error", str(e))
@ -2165,7 +2570,9 @@ def api_backup_create(stack):
jobs.finish(jid, "error")
jobs.done(qq)
jobs.run(_run, jid)
# Eén taak tegelijk per app: anders kan een geplande backup midden
# in deze wijziging beginnen en een halve stack inpakken.
jobs.run(jobs.met_slot(stack, _run), jid)
return jsonify(ok=True, job_id=jid)
@ -2196,7 +2603,9 @@ def api_backup_restore(stack):
jobs.finish(jid, "error")
jobs.done(qq)
jobs.run(_run, jid)
# Eén taak tegelijk per app: anders kan een geplande backup midden
# in deze wijziging beginnen en een halve stack inpakken.
jobs.run(jobs.met_slot(stack, _run), jid)
return jsonify(ok=True, job_id=jid)
@ -2621,6 +3030,9 @@ def api_connectable():
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
uit = []
if lib.is_dir():
# Eén docker-aanroep voor alle apps samen; dit scherm opent bij elke
# installatie en deed er eerst één per stack.
per_project = docker.containers_per_project()
for d in sorted(lib.iterdir()):
if not d.is_dir() or d.name.startswith(".") or not docker.has_compose(d):
continue
@ -2633,7 +3045,7 @@ def api_connectable():
meta = {}
containers = [{"name": c["name"], "service": c.get("service", ""),
"ports": c.get("ports", "")}
for c in docker.compose_ps(d, name=d.name) if c.get("name")]
for c in per_project.get(d.name, []) if c.get("name")]
uit.append({
"stack": d.name,
"source": meta.get("source", ""), # uit welke app-template
@ -3601,6 +4013,39 @@ def api_path_check():
return jsonify(**uitslag)
@app.route("/api/devices")
def api_devices():
"""Welke apparaten kun je aan een app doorgeven?
Zelf `/dev/serial/by-id/` uitpluizen via ssh is precies het soort werk dat
deze interface hoort weg te nemen. Seriële poorten (USB-sticks voor Zigbee,
Z-Wave, P1) en videokaarten voor transcoderen.
"""
uit = []
# by-id blijft gelijk als je hem in een andere poort steekt; ttyUSB0 niet.
for map_ in ("/dev/serial/by-id", "/dev/serial/by-path"):
p = Path(map_)
if not p.is_dir():
continue
for kind in sorted(p.iterdir()):
try:
doel = os.path.realpath(kind)
except OSError:
doel = ""
uit.append({"path": str(kind), "kind": "serieel",
"target": doel, "stable": map_.endswith("by-id")})
if uit:
break
for patroon, soort in (("/dev/ttyUSB*", "serieel"), ("/dev/ttyACM*", "serieel"),
("/dev/dri/render*", "gpu"), ("/dev/dri/card*", "gpu"),
("/dev/video*", "video"), ("/dev/bus/usb/*/*", "usb")):
for pad in sorted(glob.glob(patroon))[:40]:
if any(x["path"] == pad or x.get("target") == pad for x in uit):
continue
uit.append({"path": pad, "kind": soort, "target": "", "stable": False})
return jsonify(devices=uit[:80], found=bool(uit))
@app.route("/api/paths/move", methods=["POST"])
def api_paths_move():
"""Verplaats de mappen van Server Up, met de stacks erbij.
@ -3788,6 +4233,17 @@ def _geplande_backups() -> bool:
if not d.is_dir() or d.name.startswith(".") or not docker.has_compose(d):
continue
try:
# Loopt er net een wijziging of update op deze app, dan is de
# stackmap halverwege herschreven. Even wachten; blijft hij bezet,
# dan slaan we hem over — een archief van een halve stack is erger
# dan een backup die een nacht overslaat.
with jobs.stack_slot(d.name, wacht=120.0) as vrij:
if not vrij:
print(f" [backup] {d.name} overgeslagen: er loopt een taak")
audit.log("backups", "scheduled", "error", ref=d.name,
detail="app was bezet")
mislukt.append(f"{d.name}: er liep een andere taak")
continue
meta = backups.create(d.name, reden=f"gepland-{plan}")
print(f" [backup] {meta['file']}")
audit.log("backups", "scheduled", "ok", ref=d.name)
@ -3960,6 +4416,12 @@ if __name__ == "__main__":
print(f"\n🚀 Server Up {VERSION} · http://{host}:{port}\n")
_controleer_assets()
audit.init()
# Taken van vóór deze start terughalen. Wie hier tijdens het bijwerken op
# het joblog stond te kijken, ziet zo alsnog hoe het afliep in plaats van
# een venster dat op "unknown" blijft staan.
hersteld = jobs.herstel()
if hersteld:
print(f"Joblog: {hersteld} eerdere taak(en) teruggelezen")
i18n.load()
print("Boot repos:")
_apply_boot_repos()

View file

@ -325,9 +325,6 @@ def load() -> dict:
val = cfg.get(key, "").rstrip("/")
if val in _unsafe or not val.startswith("/"):
cfg[key] = DEFAULTS[key]
# Auto-reset wizard als LIBRARY_DIR niet bestaat (verse installatie met oud volume)
if cfg.get("WIZARD_DONE") and not Path(cfg["LIBRARY_DIR"]).exists():
cfg["WIZARD_DONE"] = False
# Env-overrides gelden alleen voor eenvoudige waarden; lijsten en objecten
# (APP_REPOS, AUTH, NETWORKS, …) zouden anders een kale string worden.
for k, default in DEFAULTS.items():

View file

@ -159,7 +159,10 @@ _DUMPERS = {
"postgis": ("POSTGRES_USER", "POSTGRES_DB", "POSTGRES_PASSWORD"),
"mariadb": ("MARIADB_USER", "MARIADB_DATABASE", "MARIADB_PASSWORD"),
"mysql": ("MYSQL_USER", "MYSQL_DATABASE", "MYSQL_PASSWORD"),
"mongo": (None, None, "MONGO_INITDB_ROOT_PASSWORD"),
# De beheerdersnaam stond hier op None; dan viel het dump-commando terug op
# de verzonnen gebruiker 'app' en mislukte elke mongodump met
# "Authentication failed".
"mongo": ("MONGO_INITDB_ROOT_USERNAME", None, "MONGO_INITDB_ROOT_PASSWORD"),
}
@ -192,13 +195,24 @@ def _vul_in(waarde: str, env: dict[str, str]) -> str:
def databasediensten(stack: str) -> list[dict]:
"""De databasecontainers van deze stack, met wat er nodig is voor een dump."""
import yaml
d = _stack_dir(stack)
try:
doc = yaml.safe_load(docker.read_compose(d)) or {}
return diensten_uit_compose(docker.read_compose(d), _env_waarden(d))
except Exception:
return []
def diensten_uit_compose(compose_text: str, env: dict[str, str]) -> list[dict]:
"""Dezelfde lijst, maar uit losse tekst.
Bij het wijzigen van instellingen moet de situatie vóór het renderen naast
die erna gelegd worden en dan bestaan de oude bestanden niet meer.
"""
import yaml
try:
doc = yaml.safe_load(compose_text) or {}
except Exception:
return []
env = _env_waarden(d)
uit = []
for snaam, svc in (doc.get("services") or {}).items():
if not isinstance(svc, dict):
@ -234,9 +248,12 @@ def _dump_commando(dienst: dict) -> tuple[list[str], dict]:
hulp = "mariadb-dump" if s == "mariadb" else "mysqldump"
return ([hulp, "-u", gb or "root", f"-p{ww}", "--single-transaction",
db or gb], {})
return (["sh", "-c",
f"mongodump --archive --username='{gb or 'app'}' "
f"--password='{ww}' --authenticationDatabase=admin"], {})
# Zonder wachtwoord draait deze MongoDB zonder authenticatie (zoals het
# Graylog-sjabloon doet). Dan toch inloggegevens meesturen levert gegarandeerd
# "Authentication failed" op — en dus nooit een dump van die stack.
inlog = (f"--username='{gb or 'root'}' --password='{ww}' "
"--authenticationDatabase=admin ") if ww else ""
return (["sh", "-c", f"mongodump --archive {inlog}".rstrip()], {})
def maak_dumps(stack: str, tmp: Path, log_fn=None) -> list[Path]:
@ -797,9 +814,11 @@ def _herstel_commando(dienst: dict) -> list[str]:
if s in ("mariadb", "mysql"):
hulp = "mariadb" if s == "mariadb" else "mysql"
return ["sh", "-c", f"{hulp} -u '{gb or 'root'}' -p'{ww}' '{db or gb}'"]
return ["sh", "-c",
f"mongorestore --archive --drop --username='{gb or 'app'}' "
f"--password='{ww}' --authenticationDatabase=admin"]
# Zie _dump_commando: zonder wachtwoord draait deze MongoDB zonder
# authenticatie, en dan werken inloggegevens juist averechts.
inlog = (f"--username='{gb or 'root'}' --password='{ww}' "
"--authenticationDatabase=admin ") if ww else ""
return ["sh", "-c", f"mongorestore --archive --drop {inlog}".rstrip()]
def _wacht_op_database(dienst: dict, pogingen: int = 30, log_fn=None) -> bool:

View file

@ -10,7 +10,7 @@ Renders boilerplate templates into a flat directory that Server Up can manage
just like any other stack i.e. a single compose file (+ optional .env).
"""
from __future__ import annotations
import json, re, shutil
import json, platform, re, shutil
from pathlib import Path
from typing import Any
@ -72,9 +72,77 @@ def metadata(d: Path) -> dict:
"format": "boilerplate",
"draft": bool(md.get("draft")),
"depends_on": _afhankelijkheden(md.get("depends_on")),
"architectures": _architecturen(md.get("architectures")),
"architecture_note": str(md.get("architecture_note") or "").strip(),
"web": _webconfig(md.get("web")),
}
_ARCH_ALIASES = {
"x86_64": "amd64", "x86-64": "amd64", "amd64": "amd64",
"aarch64": "arm64", "arm64": "arm64", "arm64v8": "arm64",
"armv7l": "arm/v7", "armv7": "arm/v7", "arm/v7": "arm/v7",
"armv6l": "arm/v6", "armv6": "arm/v6", "arm/v6": "arm/v6",
}
def _webconfig(waarde) -> dict:
"""Normaliseer het webadres dat een app-template zelf declareert."""
if not isinstance(waarde, dict):
return {}
schema = str(waarde.get("scheme") or "").strip().lower()
if schema not in ("http", "https"):
return {}
try:
poort = int(waarde.get("port") or 0)
except (TypeError, ValueError):
return {}
if not 1 <= poort <= 65535:
return {}
return {"scheme": schema, "port": poort}
def _architectuur(waarde: str) -> str:
return _ARCH_ALIASES.get(str(waarde or "").strip().lower(), "")
def _architecturen(waarde) -> list[str]:
"Normaliseer Docker-architecturen en negeer onbekende schrijfwijzen."
if isinstance(waarde, str):
waarde = [waarde]
uit: list[str] = []
for item in (waarde or []):
arch = _architectuur(item)
if arch and arch not in uit:
uit.append(arch)
return uit
def host_architecture(machine: str | None = None) -> str:
"Docker-architectuurnaam van de host waarop Server Up draait."
return _architectuur(machine if machine is not None else platform.machine())
def compatibility(meta: dict, machine: str | None = None) -> dict:
"Beoordeel expliciete template-metadata voor de huidige host."
toegestaan = _architecturen(meta.get("architectures"))
huidig = host_architecture(machine)
bekend = bool(toegestaan and huidig)
ondersteund = not bekend or huidig in toegestaan
reden = ""
if bekend and not ondersteund:
labels = {"amd64": "AMD64", "arm64": "ARM64",
"arm/v7": "ARM 32-bit", "arm/v6": "ARMv6"}
verwacht = ", ".join(labels.get(a, a) for a in toegestaan)
reden = (f"Deze app ondersteunt {labels.get(huidig, huidig)} niet. "
f"Ondersteund: {verwacht}.")
notitie = str(meta.get("architecture_note") or "").strip()
if notitie:
reden += f" {notitie}"
return {"known": bekend, "supported": ondersteund,
"architecture": huidig, "reason": reden}
def _afhankelijkheden(waarde) -> list[dict]:
"""Welke andere apps heeft dit sjabloon nodig, en waarom?
@ -437,6 +505,12 @@ def render_to_dir(src: Path, dest: Path, values: dict[str, Any]) -> list[str]:
# Drop blocks of pure whitespace left over after conditionals strip
rendered = _tidy(rendered)
out.write_text(rendered, encoding="utf-8")
# Een tekstscript kan uitvoerbaar zijn. write_text maakt een nieuw
# bestand met standaardrechten; behoud daarom de bronmodus.
try:
shutil.copymode(p, out)
except OSError:
pass
else:
shutil.copy2(p, out)
written.append(str(rel))

View file

@ -18,6 +18,8 @@ Voorbeeld:
"""
from __future__ import annotations
from pathlib import PurePosixPath
import yaml
@ -116,8 +118,52 @@ def _laad(compose_text: str) -> dict:
return doc
def add_shared_network(compose_text: str, network) -> str:
"""Hang elke service ook aan een of meer gedeelde netwerken.
# Opslag- en zoekdiensten horen bij één stack. Een MQTT-broker of RabbitMQ
# staat er bewust niet tussen: die dééllen tussen stacks is juist waarvoor het
# gedeelde netwerk bestaat.
_HULPDIENST_IMAGES = (
"postgres", "postgis", "pgvector", "timescaledb", "mysql", "mariadb",
"mongo", "redis", "valkey", "keydb", "dragonfly", "memcached",
"elasticsearch", "opensearch", "clickhouse", "influxdb", "meilisearch",
"typesense", "qdrant", "solr", "etcd",
)
def is_hulpdienst(image: str) -> bool:
"""Is dit een database of zoekindex die alleen bij zijn eigen app hoort?"""
kaal = (image or "").split("/")[-1].split(":")[0].lower()
return kaal.startswith(_HULPDIENST_IMAGES)
def diensten(compose_text: str) -> list[dict]:
"""De services met hun image en of ze een hulpdienst zijn.
De interface laat hiermee kiezen wat er op het gedeelde netwerk komt, zonder
dat je het compose-bestand hoeft te lezen.
"""
try:
doc = yaml.safe_load(compose_text) or {}
except yaml.YAMLError:
return []
uit = []
for naam, svc in (doc.get("services") or {}).items():
if not isinstance(svc, dict):
continue
image = str(svc.get("image") or "")
uit.append({"name": naam, "image": image,
"helper": is_hulpdienst(image),
"host_network": bool(svc.get("network_mode"))})
return uit
def deelbare_diensten(compose_text: str) -> list[str]:
"""Welke services standaard op het gedeelde netwerk horen."""
return [d["name"] for d in diensten(compose_text)
if not d["helper"] and not d["host_network"]]
def add_shared_network(compose_text: str, network, services=None) -> str:
"""Hang services ook aan een of meer gedeelde netwerken.
`network` mag een naam zijn of een lijst namen. Meerdere netwerken maken
groepjes mogelijk: media-apps onderling, en de rest daarbuiten.
@ -125,8 +171,13 @@ def add_shared_network(compose_text: str, network) -> str:
Elke stack draait als eigen compose-project en krijgt daarmee zijn eigen
netwerk. Twee stacks kunnen elkaar dan niet op servicenaam bereiken een
app als Zigbee2MQTT vindt de MQTT-broker uit een andere stack dus niet.
Door ze allemaal aan één gedeeld bridge-netwerk te hangen, lost de
container-naam wél op tussen stacks.
Door ze aan één gedeeld bridge-netwerk te hangen, lost de container-naam wél
op tussen stacks.
`services` bepaalt welke; zonder opgave gaan alleen de services mee die geen
database of zoekindex zijn. Die hebben buiten hun eigen stack niets te
zoeken, en er staan er genoeg zonder wachtwoord op het interne netwerk
die zou je met een druk op 'koppelen' voor elke andere app openzetten.
Bestaande netwerken (zoals een macvlan met vast IP) blijven staan; dit komt
er naast.
@ -143,10 +194,17 @@ def add_shared_network(compose_text: str, network) -> str:
if not namen:
return compose_text
if services is None:
gekozen = set(deelbare_diensten(compose_text))
else:
gekozen = {s for s in services if s}
aangesloten = 0
for svc in doc["services"].values():
for snaam, svc in doc["services"].items():
if not isinstance(svc, dict):
continue
if snaam not in gekozen:
continue
# Een service met `network_mode` deelt al de namespace van de host of
# van een andere container. Docker compose weigert die combinatie:
# "declares mutually exclusive network_mode and networks". Dit raakt
@ -382,3 +440,167 @@ def published_ports(compose_text: str) -> list[str]:
for p in svc.get("ports") or []:
out.append(str(p))
return out
# ── Eigen mappen en apparaten ────────────────────────────────────────────────
# Een sjabloon kan niet elke situatie voorzien: een mediamap die ergens anders
# staat, een USB-stick voor Zigbee, een tekenkaart. Dat kon alleen door het
# compose-bestand met de hand te bewerken — en dat wordt bij de eerstvolgende
# 'Instellingen wijzigen' overschreven. Deze twee blokken worden juist ná het
# renderen toegepast, dus ze overleven dat.
# Mounten van deze paden geeft de container de sleutels van de host: hij kan
# dan zijn eigen host overnemen. Niet aan te bieden in een keuzemenu.
VERBODEN_MOUNTS = (
"/", "/etc", "/boot", "/proc", "/sys", "/dev", "/root",
"/var/run/docker.sock", "/run/docker.sock", "/var/lib/docker",
)
def mount_bezwaar(hostpad: str) -> str:
"""Waarom mag dit pad niet gemount worden? Lege tekst = geen bezwaar."""
p = (hostpad or "").strip().rstrip("/") or "/"
if not p.startswith("/"):
return "Gebruik een absoluut pad, beginnend met /."
if ".." in PurePosixPath(p).parts:
return "Een pad met '..' erin is niet toegestaan."
if p.endswith("docker.sock"):
return ("De Docker-socket geeft een container volledige macht over deze "
"host; dat gaat niet via dit scherm.")
for verboden in VERBODEN_MOUNTS:
# Ook alles eronder: /etc/ssh is net zo goed /etc.
if p == verboden or (verboden != "/" and p.startswith(verboden + "/")):
return f"{p} hoort bij het besturingssysteem en wordt niet gedeeld."
return ""
def apparaat_bezwaar(hostpad: str) -> str:
"""Alleen echte apparaten uit /dev mogen doorgegeven worden."""
p = (hostpad or "").strip()
if not p.startswith("/dev/") or p.rstrip("/") == "/dev":
return "Een apparaat begint met /dev/, bijvoorbeeld /dev/ttyUSB0."
if ".." in PurePosixPath(p).parts:
return "Een pad met '..' erin is niet toegestaan."
return ""
def _regels_toevoegen(doc: dict, service: str, sleutel: str,
nieuw: list[str]) -> int:
"""Voeg regels toe aan `volumes` of `devices` van één service."""
svc = (doc.get("services") or {}).get(service)
if not isinstance(svc, dict):
return 0
huidig = svc.get(sleutel)
if not isinstance(huidig, list):
huidig = [] if huidig is None else [huidig]
toegevoegd = 0
for regel in nieuw:
# Zelfde bronpad al aanwezig? Dan telt wat er staat; twee mounts op
# dezelfde plek laat compose alsnog stuklopen.
bron = regel.split(":", 1)[0]
if any(str(r).split(":", 1)[0] == bron for r in huidig):
continue
huidig.append(regel)
toegevoegd += 1
if toegevoegd or sleutel not in svc:
svc[sleutel] = huidig
return toegevoegd
def add_extras(compose_text: str, extras: dict) -> str:
"""Zet extra mappen en apparaten in het gerenderde compose-bestand.
`extras` = {"volumes": [{service, host, container, ro}],
"devices": [{service, host, container}]}
Onbekende services worden overgeslagen: een sjabloon kan hernoemd zijn en
dat mag geen kapot compose-bestand opleveren.
"""
if not extras or not (extras.get("volumes") or extras.get("devices")):
return compose_text
doc = _laad(compose_text)
if not isinstance(doc.get("services"), dict):
raise TransformError("compose bevat geen services")
for item in extras.get("volumes") or []:
host = str(item.get("host") or "").strip().rstrip("/")
binnen = str(item.get("container") or "").strip().rstrip("/") or host
if not host or mount_bezwaar(host):
continue
regel = f"{host}:{binnen}" + (":ro" if item.get("ro") else "")
_regels_toevoegen(doc, str(item.get("service") or ""), "volumes", [regel])
for item in extras.get("devices") or []:
host = str(item.get("host") or "").strip()
binnen = str(item.get("container") or "").strip() or host
if not host or apparaat_bezwaar(host):
continue
_regels_toevoegen(doc, str(item.get("service") or ""), "devices",
[f"{host}:{binnen}"])
return yaml.safe_dump(doc, sort_keys=False, default_flow_style=False,
allow_unicode=True)
# ── Versietags ───────────────────────────────────────────────────────────────
# De meeste sjablonen gebruiken `:latest`. Eén druk op Bijwerken kan je dan over
# een majorversie tillen — met een databasemigratie die je niet terugdraait.
# Deze twee maken het mogelijk om per app een versie te kiezen en vast te
# houden, ook nadat de app opnieuw uit zijn sjabloon gerenderd is.
def images_per_service(compose_text: str) -> list[dict]:
"""Per service het image, opgesplitst in repo en tag."""
try:
doc = yaml.safe_load(compose_text) or {}
except yaml.YAMLError:
return []
uit = []
for naam, svc in (doc.get("services") or {}).items():
if not isinstance(svc, dict):
continue
image = str(svc.get("image") or "")
if not image or "${" in image:
continue # variabele: daar gaan we niet over
# Een dubbele punt kan ook een poort in de registryhost zijn
# (registry.local:5000/app), dus alleen ná de laatste schuine streep.
laatste = image.rsplit("/", 1)[-1]
if ":" in laatste:
repo, tag = image.rsplit(":", 1)
else:
repo, tag = image, "latest"
uit.append({"service": naam, "image": image, "repo": repo, "tag": tag})
return uit
def zet_image_tags(compose_text: str, tags: dict[str, str]) -> str:
"""Zet per service een andere versietag op het image.
Een lege of onveranderde tag laat de regel met rust; een service die niet
bestaat wordt overgeslagen, zodat een hernoemd sjabloon geen kapot
compose-bestand oplevert.
"""
gewenst = {s: str(t).strip() for s, t in (tags or {}).items() if str(t).strip()}
if not gewenst:
return compose_text
doc = _laad(compose_text)
diensten = doc.get("services")
if not isinstance(diensten, dict):
raise TransformError("compose bevat geen services")
veranderd = False
for naam, tag in gewenst.items():
svc = diensten.get(naam)
if not isinstance(svc, dict):
continue
image = str(svc.get("image") or "")
if not image or "${" in image:
continue
laatste = image.rsplit("/", 1)[-1]
repo = image.rsplit(":", 1)[0] if ":" in laatste else image
nieuw = f"{repo}:{tag}"
if nieuw != image:
svc["image"] = nieuw
veranderd = True
if not veranderd:
return compose_text
return yaml.safe_dump(doc, sort_keys=False, default_flow_style=False,
allow_unicode=True, width=120)

View file

@ -0,0 +1,130 @@
"""Een gewijzigd databasewachtwoord ook echt in de database zetten.
`POSTGRES_PASSWORD` en zijn verwanten worden door de officiële images alleen
gebruikt bij een lege datamap. Wijzig je zo'n veld in de instellingen van een
app, dan krijgt de applicatie het nieuwe wachtwoord en de database niet en na
de herstart kan de app niet meer inloggen. Er staat dan een stack stil met een
foutmelding die nergens naar het wachtwoord verwijst.
Daarom draait Server Up het wachtwoord vóór de herstart om in de nog draaiende
database, met de oude inloggegevens. Lukt dat niet, dan hoort de wijziging af te
breken: een app die niet meer bij zijn gegevens kan is erger dan een instelling
die niet doorgevoerd is.
"""
from __future__ import annotations
from core import docker
class WachtwoordFout(Exception):
pass
def wijzigingen(oud: list[dict], nieuw: list[dict]) -> list[dict]:
"""Welke databases krijgen een ander wachtwoord?
Vergelijkt per service. Een service die verdwijnt of bijkomt telt niet mee:
daar valt niets om te zetten.
"""
per_naam = {d["service"]: d for d in oud}
uit = []
for d in nieuw:
vorige = per_naam.get(d["service"])
if not vorige or not vorige.get("wachtwoord") or not d.get("wachtwoord"):
continue
if vorige["wachtwoord"] != d["wachtwoord"]:
uit.append({**d, "oud_wachtwoord": vorige["wachtwoord"]})
return uit
def _pg_letterlijk(waarde: str) -> str:
"""Een tekenreeks voor PostgreSQL.
Met `standard_conforming_strings` (standaard sinds 9.1) is een backslash
daar een gewoon teken; die verdubbelen zou het wachtwoord juist veranderen.
"""
return waarde.replace("'", "''")
def _mysql_letterlijk(waarde: str) -> str:
"""Een tekenreeks voor MySQL/MariaDB, waar de backslash wél escapet."""
return waarde.replace("\\", "\\\\").replace("'", "''")
def _js_letterlijk(waarde: str) -> str:
return waarde.replace("\\", "\\\\").replace('"', '\\"')
def commando(dienst: dict, nieuw: str) -> tuple[list[str], dict]:
"""Het commando binnen de container, plus de omgeving ervoor.
Het oude wachtwoord gaat via de omgeving mee en niet via de argumenten:
argumenten zijn op de host zichtbaar in de procestabel.
"""
soort = dienst["soort"]
gebruiker = dienst.get("gebruiker") or ""
oud = dienst["oud_wachtwoord"]
if soort in ("postgres", "postgis"):
gb = gebruiker or "postgres"
# Het statement gaat via stdin naar binnen: dan staat het nieuwe
# wachtwoord niet in de procestabel van de host, en hoeven we niet te
# vertrouwen op variabele-interpolatie die `psql -c` niet altijd doet.
return (["sh", "-c",
f'printf "%s" "$SU_SQL" | psql -v ON_ERROR_STOP=1 -U {gb} '
f'-d {dienst.get("database") or gb}'],
{"PGPASSWORD": oud,
"SU_SQL": f"ALTER USER {gb} WITH PASSWORD "
f"'{_pg_letterlijk(nieuw)}';"})
if soort in ("mariadb", "mysql"):
client = "mariadb" if soort == "mariadb" else "mysql"
gb = gebruiker or "root"
# USER() is de gebruiker waarmee we net zijn ingelogd; zo hoeven we geen
# rootrechten te veronderstellen die dit sjabloon misschien niet geeft.
# Het statement gaat via stdin naar binnen in plaats van via -e, zodat
# het nieuwe wachtwoord niet in de procestabel van de host verschijnt.
return (["sh", "-c",
f'printf "%s" "$SU_SQL" | {client} -u {gb} -p"$SU_OUD"'],
{"SU_OUD": oud,
"SU_SQL": f"ALTER USER USER() IDENTIFIED BY "
f"'{_mysql_letterlijk(nieuw)}'; FLUSH PRIVILEGES;"})
if soort == "mongo":
gb = gebruiker or "root"
js = (f'db.getSiblingDB("admin").auth("{_js_letterlijk(gb)}", "{_js_letterlijk(oud)}");'
f'db.getSiblingDB("admin").updateUser("{_js_letterlijk(gb)}",'
f' {{pwd: "{_js_letterlijk(nieuw)}"}});')
# Ook hier via stdin: de mongo-shell leest een script net zo goed van
# de standaardinvoer als uit --eval.
return (["sh", "-c",
"if which mongosh > /dev/null 2>&1; then bin=mongosh; else bin=mongo; fi; "
'printf "%s" "$SU_JS" | "$bin" --quiet'],
{"SU_JS": js})
raise WachtwoordFout(f"Onbekend databasesoort: {soort}")
def omzetten(dienst: dict, nieuw: str, log_fn=None) -> tuple[bool, str]:
"""Zet het wachtwoord om in de draaiende container."""
naam = dienst["container"]
if not docker.container_draait(naam):
return False, (f"{naam} draait niet, dus het wachtwoord kan daar niet "
"gewijzigd worden.")
cmd, extra_env = commando(dienst, nieuw)
volledig = [docker._bin(), "exec"]
for k, v in extra_env.items():
volledig += ["-e", f"{k}={v}"]
volledig += [naam] + cmd
r = docker._run(volledig, capture_output=True, timeout=60)
if r.returncode != 0:
fout = (r.stderr or r.stdout or "").strip().splitlines()
# Het wachtwoord zelf mag niet in het joblog belanden.
laatste = (fout[-1] if fout else "onbekende fout")[:200]
for geheim in (dienst["oud_wachtwoord"], nieuw):
if geheim:
laatste = laatste.replace(geheim, "***")
return False, f"Wachtwoord van {naam} niet gewijzigd: {laatste}"
if log_fn:
log_fn(f"Wachtwoord van {naam} omgezet in de database zelf")
return True, ""

View file

@ -147,7 +147,8 @@ def list_containers(alle: bool = True) -> list[dict]:
"""
fmt = ("{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.State}}\t{{.Status}}\t{{.Ports}}"
"\t{{.Label \"com.docker.compose.project\"}}"
"\t{{.Label \"com.docker.compose.project.working_dir\"}}")
"\t{{.Label \"com.docker.compose.project.working_dir\"}}"
"\t{{.Label \"com.docker.compose.service\"}}")
cmd = [_bin(), "ps", "--format", fmt]
if alle:
cmd.insert(2, "-a")
@ -165,16 +166,31 @@ def list_containers(alle: bool = True) -> list[dict]:
d = regel.split("\t")
if len(d) < 4:
continue
d += [""] * (8 - len(d))
d += [""] * (9 - len(d))
uit.append({
"id": d[0], "name": d[1], "image": d[2],
"state": d[3], "status": d[4], "ports": d[5],
"project": d[6], "working_dir": d[7],
"project": d[6], "working_dir": d[7], "service": d[8],
"running": d[3].lower() in ("running", "up"),
})
return sorted(uit, key=lambda c: c["name"])
def containers_per_project(alle: bool = True) -> dict[str, list[dict]]:
"""Alle containers, gegroepeerd op compose-project — in één aanroep.
De stacklijst deed hiervoor een `docker compose ps` per stackmap: bij
vijftien apps vijftien processen per keer verversen, en nog eens zoveel elk
kwartier vanuit de achtergrondcontrole. Eén `docker ps` levert dezelfde
gegevens, want compose zet het projectlabel op elke container die het maakt.
"""
uit: dict[str, list[dict]] = {}
for c in list_containers(alle):
if c.get("project"):
uit.setdefault(c["project"], []).append(c)
return uit
def container_action(name: str, action: str) -> tuple[bool, str]:
"""Start, stop of herstart één container.
@ -229,6 +245,26 @@ def has_compose(d: Path) -> bool:
return find_compose(d) is not None
def _published_ports(container: dict) -> str:
"""Toon alleen echte hostmappings, niet alle EXPOSE-poorten van het image."""
if "Publishers" not in container:
# Oudere Compose-versies leveren alleen de reeds geformatteerde tekst.
return str(container.get("Ports") or "")
uit = []
for mapping in container.get("Publishers") or []:
if not isinstance(mapping, dict):
continue
host = mapping.get("PublishedPort")
doel = mapping.get("TargetPort")
if not host or not doel:
continue
protocol = str(mapping.get("Protocol") or "tcp")
tekst = f"{host}->{doel}/{protocol}"
if tekst not in uit:
uit.append(tekst)
return ", ".join(uit)
def compose_ps(d: Path, name=None) -> list[dict]:
f = find_compose(d)
if not f:
@ -247,9 +283,12 @@ def compose_ps(d: Path, name=None) -> list[dict]:
out.append({
"name": c.get("Name", ""),
"service": c.get("Service", ""),
# Welke versie er nu daadwerkelijk draait; bij een sjabloon dat
# `latest` gebruikt is dit het enige wat dat nog verraadt.
"image": c.get("Image", ""),
"state": c.get("State", ""),
"status": c.get("Status", ""),
"ports": c.get("Ports", ""),
"ports": _published_ports(c),
"running": c.get("State", "").lower() in ("running", "up"),
})
except Exception:
@ -377,6 +416,18 @@ def validate_compose_text(content: str, d: Path) -> tuple[bool, str]:
pass
def container_draait(naam: str) -> bool:
"""Draait deze container op dit moment?
Nodig voordat er iets in een container uitgevoerd wordt: `docker exec` op
een gestopte container geeft een foutmelding die niets uitlegt.
"""
if not naam:
return False
r = _run([_bin(), "inspect", "-f", "{{.State.Running}}", naam], timeout=15)
return r.returncode == 0 and r.stdout.strip() == "true"
def image_digest_local(image: str) -> str:
"""Digest van het lokaal aanwezige image, of "" als het er niet is.

View file

@ -48,6 +48,44 @@ def cache_dir(repo_id: str) -> Path:
return CACHE / repo_id
def cache_status(repo_id: str) -> dict:
"""Welke versie van de sjablonen staat er in de cache van deze repo?
Sjablonen komen hiervandaan en niet uit het image van Server Up. Die twee
kunnen ver uit elkaar lopen: staat de repo op een branch die achterloopt,
dan rendert een app een oud sjabloon terwijl de code splinternieuw is en
daar is aan niets te zien.
"""
d = cache_dir(repo_id)
if not (d / ".git").exists():
return {"cloned": False}
uit: dict = {"cloned": True}
try:
r = subprocess.run(["git", "log", "-1", "--format=%h%x1f%cI%x1f%s"],
cwd=str(d), capture_output=True, text=True, timeout=10)
if r.returncode == 0 and "\x1f" in r.stdout:
sha, datum, onderwerp = r.stdout.strip().split("\x1f", 2)
uit.update(commit=sha, commit_date=datum, subject=onderwerp[:120])
except Exception:
pass
# Wanneer is er voor het laatst opgehaald? FETCH_HEAD wordt bij elke fetch
# aangeraakt; bij een verse kloon bestaat hij nog niet.
for naam in ("FETCH_HEAD", "HEAD"):
p = d / ".git" / naam
if p.exists():
uit["synced_at"] = p.stat().st_mtime
break
# Repo's die zelf een VERSION meeleveren (zoals deze) verraden daarmee of de
# cache achterloopt op de draaiende versie.
versie = d / "VERSION"
if versie.is_file():
try:
uit["version"] = versie.read_text(encoding="utf-8").strip()[:40]
except Exception:
pass
return uit
def clone_or_pull(repo: dict, log_fn=None) -> tuple[bool, str]:
_ensure_git_safe()
url = repo.get("url", "").strip()

View file

@ -1,5 +1,6 @@
"""Background job runner with log streaming."""
import threading, time, uuid, queue
import contextlib, json, os, threading, time, uuid, queue
from pathlib import Path
_jobs: dict[str, dict] = {}
_queues: dict[str, queue.Queue] = {}
@ -11,9 +12,82 @@ _TTL = 3600
# onbeperkt in het geheugen van het proces.
_MAX_LINES = 2000
# Waar afgeronde jobs blijven staan. Het geheugen was de enige plek: bij het
# bijwerken herstart de container middenin een taak, en dan was niet meer terug
# te zien of hij geslaagd was — het joblog gaf alleen nog "unknown". Ook na een
# uur (de TTL hierboven) verdween het bewijs van wat een nachtelijke backup had
# gedaan.
_dir = Path(os.environ.get("SU_JOBS", "/data/jobs"))
_BEWAAR = 20
def _bestand(jid: str) -> Path:
return _dir / f"{jid}.json"
def _bewaar(jid: str):
"""Schrijf de huidige stand van een job weg. Aanroepen onder `_lock`."""
job = _jobs.get(jid)
if job is None:
return
try:
_dir.mkdir(parents=True, exist_ok=True)
tijdelijk = _bestand(jid).with_suffix(".tmp")
tijdelijk.write_text(json.dumps(job), encoding="utf-8")
# Vervangen in één stap: een half geschreven bestand zou bij de
# volgende start een onleesbare job opleveren.
tijdelijk.replace(_bestand(jid))
except Exception:
# Een joblog is geen reden om de taak zelf te laten mislukken.
pass
def _opruimen_schijf():
"""Houd alleen de laatste `_BEWAAR` jobs op schijf."""
try:
bestanden = sorted(_dir.glob("*.json"), key=lambda p: p.stat().st_mtime,
reverse=True)
for oud in bestanden[_BEWAAR:]:
oud.unlink(missing_ok=True)
except Exception:
pass
def herstel():
"""Lees bewaarde jobs terug en markeer wat door een herstart is afgekapt.
Zonder deze stap kijkt de interface na een herstart naar een job die niet
meer bestaat, blijft pollen op "unknown" en vertelt niemand wat er van de
taak geworden is.
"""
if not _dir.is_dir():
return 0
aantal = 0
for pad in sorted(_dir.glob("*.json")):
try:
job = json.loads(pad.read_text(encoding="utf-8"))
except Exception:
continue
if not isinstance(job, dict) or not job.get("id"):
continue
if job.get("status") == "running":
job["status"] = "error"
job.setdefault("lines", []).append(
{"level": "warn",
"text": "Server Up is herstart terwijl deze taak liep; "
"wat erna gebeurde staat hier niet in."})
with _lock:
_jobs[job["id"]] = job
aantal += 1
_opruimen_schijf()
return aantal
def _cleanup():
"""Verwijder voltooide jobs ouder dan TTL uit geheugen."""
"""Verwijder voltooide jobs ouder dan TTL uit geheugen.
Op schijf blijven ze staan; `stream` haalt ze daar weer vandaan.
"""
cutoff = time.time() - _TTL
with _lock:
stale = [jid for jid, j in _jobs.items()
@ -65,6 +139,8 @@ def finish(jid: str, status="done"):
with _lock:
if jid in _jobs:
_jobs[jid]["status"] = status
_bewaar(jid)
_opruimen_schijf()
def done(q):
@ -75,7 +151,15 @@ def done(q):
def stream(jid: str, offset=0) -> dict:
job = _jobs.get(jid)
if not job:
# Uit het geheugen verdwenen door de TTL of door een herstart, maar de
# laatste stand staat nog op schijf.
try:
bewaard = json.loads(_bestand(jid).read_text(encoding="utf-8"))
except Exception:
return {"lines": [], "status": "unknown"}
return {"lines": (bewaard.get("lines") or [])[offset:],
"status": bewaard.get("status", "unknown"),
"progress": bewaard.get("progress")}
with _stream_locks.get(jid, threading.Lock()):
q = _queues.get(jid)
if q:
@ -94,9 +178,80 @@ def stream(jid: str, offset=0) -> dict:
"text": f"{dropped + job.get('dropped', 0)} eerdere regels weggelaten"}]
+ job["lines"][-_MAX_LINES:])
job["dropped"] = dropped + job.get("dropped", 0)
# Tussentijds wegschrijven, hooguit eens per paar seconden: valt de
# container om tijdens een lange taak, dan is tot dat punt terug te
# lezen wat er gebeurd is. Bij elke regel schrijven zou een `compose
# pull` in duizenden schrijfacties veranderen.
if job["status"] == "running" and \
time.time() - job.get("bewaard_op", 0) > 3:
job["bewaard_op"] = time.time()
with _lock:
_bewaar(jid)
return {"lines": job["lines"][offset:], "status": job["status"],
"progress": job.get("progress")}
def run(fn, *args):
threading.Thread(target=fn, args=args, daemon=True).start()
# ── Eén taak tegelijk per app ────────────────────────────────────────────────
# Zonder dit kan de nachtelijke backup starten terwijl een instellingswijziging
# net `compose down` heeft gedaan en de bestanden herschrijft. Het archief bevat
# dan een halve stack, terwijl de melding erna 'Backup klaar' is. Twee keer op
# Bijwerken klikken levert hetzelfde op.
_stack_sloten: dict[str, threading.Lock] = {}
_sloten_lock = threading.Lock()
def _stackslot(naam: str) -> threading.Lock:
with _sloten_lock:
return _stack_sloten.setdefault(naam, threading.Lock())
def met_slot(naam: str, fn, wacht: float = 900.0):
"""Laat `fn` pas draaien als er geen andere taak op deze app bezig is.
Wikkelt de jobfunctie in plaats van hem te veranderen, zodat elke bestaande
taak dit met één regel krijgt.
"""
def _wikkel(jid, *args):
qq = get_queue(jid)
slot = _stackslot(naam)
if not slot.acquire(blocking=False):
log(qq, "warn", f"Er loopt al een taak voor '{naam}'"
"deze wacht tot die klaar is.")
if not slot.acquire(timeout=wacht):
log(qq, "error", f"Er liep te lang een andere taak voor '{naam}'. "
"Deze taak is niet uitgevoerd.")
finish(jid, "error")
done(qq)
return
try:
fn(jid, *args)
except Exception as e:
# Een taak die klapt zonder eigen afhandeling bleef anders voor
# altijd op 'running' staan, en de interface bleef erop pollen.
log(qq, "error", f"{type(e).__name__}: {e}")
finish(jid, "error")
done(qq)
finally:
slot.release()
return _wikkel
@contextlib.contextmanager
def stack_slot(naam: str, wacht: float = 0.0):
"""Hetzelfde slot, voor code die geen job is (de geplande backup).
Levert False op als de app bezet is; de aanroeper beslist dan zelf of hij
overslaat of wacht.
"""
slot = _stackslot(naam)
gekregen = slot.acquire(timeout=wacht) if wacht else slot.acquire(blocking=False)
try:
yield gekregen
finally:
if gekregen:
slot.release()

View file

@ -6,8 +6,8 @@ Up alleen het voorwerk (image ophalen, tag wegschrijven) en laat het de
daadwerkelijke hercreatie over aan een korte **helper-container**:
1. `docker pull <registry>/<repo>:<tag>` nieuw image binnenhalen
2. `SU_TAG=<tag>` in het .env van de deploy-map schrijven
3. helper starten: sleep 5 && docker compose up -d server-up
2. `SU_TAG=<tag>` en `SU_VERSION=<tag>` in de deploy-.env schrijven
3. helper starten met exact hetzelfde Compose-project en -configuratie
4. helper stopt zichzelf (`--rm`)
De helper draait op het zojuist gepulde image: dat bevat de docker- en
@ -27,7 +27,7 @@ import time
from pathlib import Path
import core as cfg
from core import docker
from core import docker, updater
# Labels die `docker compose` op elke container zet.
_LBL_PROJECT = "com.docker.compose.project"
@ -70,6 +70,7 @@ def inspect_self() -> dict:
"working_dir": labels.get(_LBL_WORKDIR, ""),
"config_files": labels.get(_LBL_CONFIGS, ""),
"service": labels.get(_LBL_SERVICE, "") or "server-up",
"version": labels.get("org.opencontainers.image.version", ""),
}
@ -159,6 +160,10 @@ def _login(log_fn=None) -> tuple[bool, str]:
def pull(tag: str, log_fn=None) -> tuple[bool, str]:
"""Haal `<repo>:<tag>` binnen. Retourneert (ok, image of foutmelding)."""
# Forgejo noemt de Git-tag v0.8.13, maar de workflows pushen :0.8.13.
# Normaliseer hier óók, zodat oude clients die nog latest_tag versturen
# nooit een niet-bestaande :v0.8.13 proberen op te halen.
tag = updater.image_tag(tag)
if not _TAG_RE.match(tag or ""):
return False, f"Ongeldige tag: {tag!r}"
repo = image_repo()
@ -180,22 +185,19 @@ def pull(tag: str, log_fn=None) -> tuple[bool, str]:
# ── Toepassen ────────────────────────────────────────────────────────────────
def _tag_script(tag: str) -> str:
"""Sh-regels die SU_TAG in het .env van de werkmap zetten.
"""Sh-regels die de image-tag én versie in de deploy-.env vastleggen.
Dit gebeurt in de helper-container en niet hier, en dat is geen detail: de
installatiemap (`/opt/server-up`) is nérgens in deze container gemount. Er
zijn maar drie mounts de docker-socket, het `su-data`-volume en `BASE_DIR`
en de installatiemap zit daar niet bij. Zelf schrijven belandde dus in de
overlay van de container of, waarschijnlijker, mislukte omdat de map hier
niet bestaat. Bijwerken vanuit de interface kon daardoor nooit werken.
De helper mount `working_dir` wél, op hetzelfde pad, en draait als root.
Dit gebeurt in de helper-container: de installatiemap is niet in de
draaiende Server Up-container gemount, maar wordt wel expliciet aan de
helper gekoppeld. SU_VERSION is nodig voor oudere compose-bestanden die de
ingebakken imageversie met een runtimewaarde overschrijven.
"""
q = shlex.quote(f"SU_TAG={tag}")
tag_regel = shlex.quote(f"SU_TAG={tag}")
versie_regel = shlex.quote(f"SU_VERSION={tag}")
return (
"touch .env; "
"sed -i '/^SU_TAG=/d' .env; "
f"printf '%s\\n' {q} >> .env; "
"sed -i '/^SU_TAG=/d; /^SU_VERSION=/d' .env; "
f"printf '%s\\n' {tag_regel} {versie_regel} >> .env; "
)
@ -216,6 +218,7 @@ def apply(tag: str, log_fn=None) -> tuple[bool, str]:
Bij succes keert deze functie terug terwijl de helper al gestart is; enkele
seconden later verdwijnt dit proces omdat de container hercreëerd wordt.
"""
tag = updater.image_tag(tag)
st = status()
if not st["can_apply"]:
return False, st["reason"]
@ -229,39 +232,66 @@ def apply(tag: str, log_fn=None) -> tuple[bool, str]:
return False, image_of_fout
image = image_of_fout
# Onthoud waar we vandaan komen, zodat terugrollen mogelijk blijft als het
# nieuwe image niet start.
# Een alias als latest of beta verschuift bij iedere release en is dus geen
# bruikbaar terugrolpunt. Het OCI-versielabel verwijst naar de echte,
# onveranderlijke versietag; val terug op de gebruikte imagetag voor oude
# images zonder label.
versie = updater.image_tag(info.get("version", ""))
huidig = info.get("image", "")
if _TAG_RE.match(versie or "") and versie != "dev":
vorige_tag = versie
else:
vorige_tag = huidig.rsplit(":", 1)[-1] if _has_tag(huidig) else "latest"
cfg.patch({"UPDATE_PREVIOUS_TAG": vorige_tag,
"UPDATE_LAST_APPLIED": {"tag": tag, "ts": time.time(),
"from": vorige_tag}})
if log_fn:
log_fn(f"Vorige versie: {vorige_tag} (terugrollen kan vanuit Instellingen)")
log_fn(f"SU_TAG={tag} wordt door de helper in {werkmap}/.env gezet")
log_fn(f"SU_TAG en SU_VERSION worden in {werkmap}/.env bijgewerkt")
log_fn("Helper starten die de container hercreëert…")
ok, melding = _spawn_helper(image, werkmap, service, tag, log_fn)
ok, melding = _spawn_helper(
image, werkmap, service, tag,
project=info.get("project", ""),
config_files=info.get("config_files", ""),
log_fn=log_fn,
)
if not ok:
return False, melding
return True, (f"Bijwerken naar {tag} gestart. Server Up herstart nu; "
"de interface komt over enkele seconden terug.")
def _compose_up(working_dir: str, project: str, config_files: str,
service: str) -> str:
"""Bouw het commando voor exact het huidige Compose-project."""
cmd = ["docker", "compose", "--project-directory", working_dir]
if project:
cmd.extend(["--project-name", project])
for config in (p.strip() for p in (config_files or "").split(",")):
if config:
cmd.extend(["--file", config])
# Het doelimage is vlak hiervoor expliciet gepulld. Bouw nooit stilletjes
# oude broncode uit de deploy-map en haal tijdens de herstart niets anders.
cmd.extend(["up", "-d", "--remove-orphans", "--no-build", "--pull", "never",
service])
return shlex.join(cmd)
def _spawn_helper(image: str, working_dir: str, service: str, tag: str,
project: str = "", config_files: str = "",
log_fn=None) -> tuple[bool, str]:
"""Start een korte container die `docker compose up -d <service>` uitvoert.
"""Start een helper die het bestaande Compose-project exact hercreëert.
Draait op hetzelfde (nieuwe) image, dat de docker- en compose-CLI al bevat.
`sleep` geeft dit proces de tijd om zijn HTTP-antwoord af te maken voordat
het onder zijn eigen voeten wordt weggehaald.
De helper zet ook SU_TAG: hij mount de werkmap, deze container niet.
Projectnaam en configuratiebestanden komen uit de labels van de huidige
container. Daardoor werkt dit ook bij `docker compose -p ... -f ...`, en
wordt niet per ongeluk een tweede project gestart. `--no-build` garandeert
dat Compose het zojuist gepullde release-image gebruikt.
"""
script = (f"sleep 5; "
f"{_tag_script(tag)}"
f"docker compose up -d --remove-orphans {shlex.quote(service)}")
compose_up = _compose_up(working_dir, project, config_files, service)
script = f"sleep 5; {_tag_script(tag)}{compose_up}"
cmd = [docker._bin(), "run", "--rm", "--detach",
"--name", "server-up-updater",
"-v", "/var/run/docker.sock:/var/run/docker.sock",

View file

@ -67,6 +67,17 @@ def is_prerelease(version: str) -> bool:
return bool(t and t[3] == 0)
def image_tag(release_tag: str) -> str:
"""Vertaal een Forgejo-release-tag naar de tag uit onze registry.
Git-tags heten volgens afspraak `v0.8.13`; de build-workflows verwijderen
die voorloop-v en pushen `:0.8.13`. Alleen een geldige semver wordt
aangepast, zodat een handmatige registry-tag zoals `beta` intact blijft.
"""
tag = (release_tag or "").strip()
return tag[1:] if tag.startswith("v") and _parse(tag) else tag
def _releases(url: str) -> list[dict]:
req = urllib.request.Request(
url, headers={"Accept": "application/json", "User-Agent": "server-up"})
@ -91,7 +102,8 @@ def check(current: str, force: bool = False) -> dict:
ch = channel()
res = {"enabled": bool(url), "current": current, "channel": ch,
"current_is_prerelease": is_prerelease(current),
"latest": None, "latest_tag": "", "update_available": False,
"latest": None, "latest_tag": "", "image_tag": "",
"update_available": False,
"url": "", "notes": "", "prerelease": False, "error": "",
"checked_at": time.time()}
if not url:
@ -121,7 +133,8 @@ def check(current: str, force: bool = False) -> dict:
tag = (best.get("tag_name") or best.get("name") or "")
res["latest_tag"] = tag
res["latest"] = tag.lstrip("v")
res["image_tag"] = image_tag(tag)
res["latest"] = res["image_tag"]
res["url"] = best.get("html_url") or best.get("url") or ""
res["notes"] = best.get("body") or ""
res["prerelease"] = bool(best.get("prerelease"))

View file

@ -50,7 +50,7 @@ tailwind.config = {
en JetBrains Mono worden gebruikt als ze lokaal geïnstalleerd zijn, anders
valt de stack terug op de systeemfonts uit tailwind.config. -->
<style>
body { font-family: 'Inter', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { font-family: 'Inter', system-ui, sans-serif; -webkit-font-smoothing: antialiased; height: 100vh; height: 100dvh; }
[x-cloak] { display: none !important; }
.mdi-spin { animation: mdis 1s linear infinite; display:inline-block; }
@keyframes mdis { to { transform: rotate(360deg); } }
@ -70,7 +70,7 @@ tailwind.config = {
<!-- Ligt bovenop alles (z-[90]) tot er een geldige sessie is. De API weigert
ondertussen sowieso elk verzoek met 401, dit is de nette voorkant. -->
<div x-show="auth.ready && !auth.authenticated" x-cloak
class="fixed inset-0 bg-bg dark:bg-bg-dark z-[90] grid place-items-center p-4">
class="fixed inset-0 bg-bg dark:bg-bg-dark z-[90] grid place-items-center p-4 overflow-y-auto">
<div class="w-full max-w-[380px] anim">
<div class="flex items-center gap-3 mb-6 justify-center">
<div class="w-11 h-11 rounded-xl bg-gradient-to-br from-ac to-ac2 grid place-items-center shadow-soft text-white text-xl">🚀</div>
@ -194,7 +194,7 @@ tailwind.config = {
<!-- Scroll area -->
<main class="flex-1 overflow-y-auto">
<div class="p-4 sm:p-7 max-w-6xl mx-auto pb-48">
<div class="p-4 sm:p-7 max-w-6xl mx-auto pb-48 min-w-0">
<!-- ══ Dashboard ══ -->
<section x-show="page === 'dashboard'" x-cloak class="anim">
@ -381,6 +381,9 @@ tailwind.config = {
<span>MEM <span class="text-tx dark:text-tx-dark" x-text="c.stats.mem"></span></span>
<span x-show="c.stats.net">NET <span class="text-tx dark:text-tx-dark" x-text="c.stats.net"></span></span>
</div>
<!-- Welke versie hier nu draait. Met `latest` in het
sjabloon is dat het enige dat het je nog vertelt. -->
<div class="text-[10px] text-dm dark:text-dm-dark mt-0.5 truncate" x-show="c.image" x-text="c.image"></div>
<div class="text-[10px] text-dm dark:text-dm-dark mt-0.5 truncate" x-show="c.ports" x-text="c.ports"></div>
</div>
</template>
@ -395,6 +398,13 @@ tailwind.config = {
</button>
<button class="btn-sm" :title="t('restart')" @click="stackAction(s.name, 'restart')"><span class="mdi mdi-restart"></span></button>
<button class="btn-sm" :title="t('logs')" @click="openLogs(s.name)"><span class="mdi mdi-text-box-outline"></span></button>
<!-- De webinterface openen is waar de meeste apps voor
draaien; die stond alleen in de modal met inloggegevens.
Daarom ook op de telefoon zichtbaar en niet in het menu. -->
<a x-show="s.web_url" :href="s.web_url" target="_blank" rel="noopener"
class="btn-sm" :title="t('open_web') + ': ' + s.web_url">
<span class="mdi mdi-open-in-new"></span>
</a>
<!-- Vanaf sm alles uitgeklapt -->
<button class="btn-sm hidden sm:inline-flex" :title="t('update')" @click="stackAction(s.name, 'update')"><span class="mdi mdi-update"></span></button>
@ -426,8 +436,8 @@ tailwind.config = {
<template x-for="a in stackMenuItems(s)" :key="a.id">
<button class="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-xs text-left transition"
:class="a.gevaar ? 'text-er hover:bg-er/10' : 'text-tx dark:text-tx-dark hover:bg-s2 dark:hover:bg-s2-dark'"
:disabled="a.uit" :style="a.uit ? 'opacity:.4' : ''"
@click="stackMenu = ''; a.doe()">
:disabled="a.uit === true" :style="a.uit ? 'opacity:.4' : ''"
@click="a.doe(); stackMenu = ''">
<span class="mdi text-base w-5 text-center" :class="a.icon"></span>
<span x-text="a.label"></span>
</button>
@ -650,6 +660,13 @@ tailwind.config = {
<div class="text-[11px] text-t2 dark:text-t2-dark line-clamp-2 leading-snug" x-text="stack.description"></div>
</div>
<span x-show="stack.format === 'boilerplate'" class="badge badge-info shrink-0" title="ChristianLempa Boilerplate">BP</span>
<span x-show="(stack.architectures || []).includes('arm64')"
class="badge badge-ok shrink-0" title="Ondersteunt ARM64 en een Raspberry Pi met 64-bits OS">Pi 64-bit</span>
</div>
<div x-show="stack.compatibility && !stack.compatibility.supported"
class="rounded-lg border border-er/35 bg-er/5 text-er text-[11px] leading-snug px-2.5 py-2 mb-3 flex gap-2">
<span class="mdi mdi-alert-circle-outline shrink-0"></span>
<span x-text="stack.compatibility ? stack.compatibility.reason : ''"></span>
</div>
<!-- Draait deze app al? Dat stond eerder als een klein
vinkje naast de knop, waar niemand het zag. -->
@ -684,6 +701,8 @@ tailwind.config = {
</div>
<div class="flex gap-2 mt-auto items-center">
<button @click="openInstall(stack, repo.id)" class="btn-sm flex-1"
:disabled="stack.compatibility && !stack.compatibility.supported"
:title="stack.compatibility && !stack.compatibility.supported ? stack.compatibility.reason : ''"
:class="(stack.instances || []).length ? '' : 'btn-primary'">
<span class="mdi mdi-download-outline"></span>
<span x-text="(stack.instances || []).length ? t('install_again') : t('install')">Installeren</span>
@ -807,7 +826,7 @@ tailwind.config = {
</section>
<!-- ══ Settings ══ -->
<section x-show="page === 'settings'" x-cloak class="anim space-y-5">
<section x-show="page === 'settings'" x-cloak class="anim space-y-5 min-w-0">
<div class="flex items-center gap-3">
<p class="text-sm text-t2 dark:text-t2-dark flex-1" x-text="t('settings')">Instellingen</p>
<span x-show="saved" class="text-xs text-ok flex items-center gap-1.5 font-semibold"><span class="mdi mdi-check-circle"></span> <span x-text="t('auto_saved')">Opgeslagen</span></span>
@ -816,7 +835,7 @@ tailwind.config = {
<div class="card card-pad grid sm:grid-cols-2 gap-6">
<div>
<div class="form-label" x-text="t('theme')">Thema</div>
<div class="inline-flex rounded-xl bg-s2 dark:bg-s2-dark p-1 gap-1">
<div class="inline-flex max-w-full overflow-x-auto rounded-xl bg-s2 dark:bg-s2-dark p-1 gap-1">
<button @click="setThemePref('system')" class="seg" :class="themePref==='system' ? 'seg-on' : ''"><span class="mdi mdi-monitor"></span> Auto</button>
<button @click="setThemePref('light')" class="seg" :class="themePref==='light' ? 'seg-on' : ''"><span class="mdi mdi-weather-sunny"></span> Light</button>
<button @click="setThemePref('dark')" class="seg" :class="themePref==='dark' ? 'seg-on' : ''"><span class="mdi mdi-weather-night"></span> Dark</button>
@ -832,7 +851,7 @@ tailwind.config = {
<!-- Setup wizard opnieuw doorlopen -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<div class="px-5 py-4 flex flex-wrap items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-wizard-hat text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="t('wizard')">Setup Wizard</span>
</div>
@ -849,10 +868,10 @@ tailwind.config = {
<!-- Beveiliging: accounts + wachtwoord -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<div class="px-5 py-4 flex flex-wrap items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-shield-account-outline text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="t('security')">Beveiliging</span>
<span class="badge badge-dim ml-auto" x-text="t('logged_in_as', {user: auth.user})"></span>
<span class="badge badge-dim sm:ml-auto max-w-full truncate" x-text="t('logged_in_as', {user: auth.user})"></span>
</div>
<div class="p-5 grid md:grid-cols-2 gap-6">
<div>
@ -898,7 +917,7 @@ tailwind.config = {
adressen hieronder; zonder die lijst weigert de server de modus. -->
<div class="px-5 pb-5 pt-4 border-t border-bd dark:border-bd-dark">
<div class="form-label" x-text="t('auth_mode')">Hoe wordt er ingelogd?</div>
<div class="inline-flex rounded-xl bg-s2 dark:bg-s2-dark p-1 gap-1 mb-2">
<div class="inline-flex max-w-full overflow-x-auto rounded-xl bg-s2 dark:bg-s2-dark p-1 gap-1 mb-2">
<button class="seg" :class="authCfg.mode==='local' ? 'seg-on' : ''"
@click="authCfg.mode='local'"><span class="mdi mdi-account-key-outline"></span> <span x-text="t('auth_mode_local')">Lokaal</span></button>
<button class="seg" :class="authCfg.mode==='proxy' ? 'seg-on' : ''"
@ -935,7 +954,7 @@ tailwind.config = {
<!-- Backups: bewaarbeleid en planning -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<div class="px-5 py-4 flex flex-wrap items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-archive-outline text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="t('backups')">Backups</span>
</div>
@ -1009,7 +1028,7 @@ tailwind.config = {
<!-- Meldingen: je hoort het als er 's nachts iets misgaat -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<div class="px-5 py-4 flex flex-wrap items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-bell-outline text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="t('notifications')">Meldingen</span>
</div>
@ -1104,7 +1123,7 @@ tailwind.config = {
<!-- Pangolin: publieke URL's zonder poorten open te zetten -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<div class="px-5 py-4 flex flex-wrap items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-earth text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm">Pangolin</span>
</div>
@ -1153,7 +1172,7 @@ tailwind.config = {
<!-- Netwerken: stacks een eigen IP-adres geven -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<div class="px-5 py-4 flex flex-wrap items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-lan text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="t('networks')">Netwerken</span>
<button class="btn-sm ml-auto" @click="modal='add-network'"><span class="mdi mdi-plus"></span> <span x-text="t('network')">Netwerk</span></button>
@ -1178,7 +1197,7 @@ tailwind.config = {
<!-- Updates -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<div class="px-5 py-4 flex flex-wrap items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-update text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="t('updates')">Updates</span>
<template x-if="update.update_available"><span class="badge badge-ok" x-text="t('update_available')">update beschikbaar</span></template>
@ -1222,7 +1241,7 @@ tailwind.config = {
<div class="pt-2 border-t border-bd dark:border-bd-dark space-y-3">
<div>
<label class="form-label" x-text="t('channel')">Kanaal</label>
<div class="inline-flex rounded-xl bg-s2 dark:bg-s2-dark p-1 gap-1">
<div class="inline-flex max-w-full overflow-x-auto rounded-xl bg-s2 dark:bg-s2-dark p-1 gap-1">
<button class="seg" :class="updChannel()==='stable' ? 'seg-on' : ''"
@click="setChannel('stable')"><span class="mdi mdi-check-decagram"></span> Stable</button>
<button class="seg" :class="updChannel()==='beta' ? 'seg-on' : ''"
@ -1269,7 +1288,7 @@ tailwind.config = {
<!-- Taalmodule (add-on) -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<div class="px-5 py-4 flex flex-wrap items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-translate text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="t('language_addon')">Taalmodule</span>
<span class="badge badge-info" x-text="languages.length"></span>
@ -1376,10 +1395,23 @@ tailwind.config = {
<summary class="px-5 py-4 cursor-pointer font-bold text-wh dark:text-wh-dark text-sm flex items-center gap-2.5"><span class="mdi mdi-source-branch text-ac dark:text-ac-dark text-lg"></span> Git Repositories</summary>
<div class="px-5 pb-5 space-y-2">
<template x-for="r in repos" :key="r.id">
<div class="flex items-center gap-2 py-2 border-b border-bd dark:border-bd-dark last:border-0">
<div class="flex items-center gap-2 py-2 border-b border-bd dark:border-bd-dark last:border-0 flex-wrap">
<span class="mdi mdi-source-branch text-dm dark:text-dm-dark"></span>
<span class="font-mono text-xs flex-1 truncate" x-text="r.name"></span>
<!-- De stand van de cache: hieruit komen de sjablonen, en die
kan ver achterlopen op de draaiende versie. -->
<span x-show="(r.cache||{}).outdated" class="badge badge-warn"
:title="t('cache_outdated_hint')" x-text="(r.cache||{}).version"></span>
<span x-show="(r.cache||{}).commit && !(r.cache||{}).outdated"
class="text-[10px] font-mono text-dm dark:text-dm-dark"
:title="(r.cache||{}).subject"
x-text="(r.cache||{}).commit + ' · ' + geleden((r.cache||{}).synced_at)"></span>
<!-- De branch bepaalt welke sjablonen je krijgt; die stilzwijgend
weglaten maakte het onmogelijk te zien waarom een app een
oude versie bleef renderen. -->
<span class="badge badge-dim font-mono !text-[10px]" x-text="r.branch || 'main'"></span>
<span class="text-[10px] text-t2 dark:text-t2-dark" x-text="r.subdir||''"></span>
<button @click="openRepoEdit(r)" class="btn-icon w-9 h-9" :title="t('edit')"><span class="mdi mdi-pencil-outline"></span></button>
<button @click="syncRepo(r.id)" class="btn-icon w-9 h-9"><span class="mdi mdi-refresh"></span></button>
<button @click="deleteRepo(r.id)" class="btn-icon w-9 h-9 !text-er hover:!border-er/40"><span class="mdi mdi-delete-outline"></span></button>
</div>
@ -1444,7 +1476,30 @@ tailwind.config = {
</div>
<!-- ░░ Modals ░░ -->
<div x-show="modal" @click.self="modal=null" x-cloak class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm z-[60] grid place-items-center p-0 sm:p-4">
<div x-show="modal" @click.self="modal=null" x-cloak class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm z-[60] grid place-items-center p-0 sm:p-4 overflow-hidden">
<!-- Repo bewerken: alleen branch, submap en naam. De URL en de id blijven
staan, want die id staat in de metadata van elke stack uit deze repo. -->
<div x-show="modal==='edit-repo'" class="modal max-w-md">
<div class="modal-head">
<span class="mdi mdi-source-branch text-ac dark:text-ac-dark"></span>
<span x-text="t('edit_repo')">Repository bewerken</span>
<span class="font-mono text-xs text-t2 dark:text-t2-dark" x-text="repoEdit.id"></span>
</div>
<div class="modal-body space-y-3">
<div><label class="form-label" x-text="t('repo_name')">Naam</label><input class="form-input" x-model="repoEdit.name"></div>
<div class="grid grid-cols-2 gap-3">
<div><label class="form-label" x-text="t('repo_branch')">Branch</label><input class="form-input font-mono" x-model="repoEdit.branch"></div>
<div><label class="form-label" x-text="t('repo_subdir')">Submap</label><input class="form-input" x-model="repoEdit.subdir" placeholder="apps"></div>
</div>
<p class="text-[11px] text-t2 dark:text-t2-dark font-mono truncate" x-text="repoEdit.url"></p>
<p class="text-[11px] text-t2 dark:text-t2-dark" x-text="t('repo_branch_hint')"></p>
</div>
<div class="modal-foot">
<button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button>
<button class="btn btn-primary" @click="saveRepo()"><span class="mdi mdi-content-save-outline"></span> <span x-text="t('save')">Opslaan</span></button>
</div>
</div>
<!-- Add repo -->
<div x-show="modal==='add-repo'" class="modal max-w-md">
@ -1721,6 +1776,27 @@ tailwind.config = {
<span x-text="t('reconfigure_warning')"></span>
</p>
<!-- Waar het sjabloon vandaan komt. Opslaan rendert de app opnieuw uit
déze cache: loopt die achter, dan trek je een oude versie over een
werkende stack heen zonder dat iets dat zegt. -->
<div x-show="install.mode === 'edit' && install.source_repo && install.source_repo.id"
class="text-[11px] rounded-xl border px-3 py-2 flex items-start gap-2"
:class="(install.source_repo||{}).outdated
? 'border-wr/40 bg-wr/5 text-wr'
: 'border-bd dark:border-bd-dark text-t2 dark:text-t2-dark'">
<span class="mdi shrink-0"
:class="(install.source_repo||{}).outdated ? 'mdi-alert-outline' : 'mdi-source-branch'"></span>
<span class="min-w-0">
<span x-text="t('template_from', {
repo: (install.source_repo||{}).name || (install.source_repo||{}).id,
branch: (install.source_repo||{}).branch || 'main'})"></span>
<span x-show="(install.source_repo||{}).synced_at"
x-text="' · ' + t('synced_ago', {ago: geleden((install.source_repo||{}).synced_at)})"></span>
<span x-show="(install.source_repo||{}).outdated" class="block mt-0.5"
x-text="t('cache_outdated', {version: (install.source_repo||{}).version, current: version})"></span>
</span>
</div>
<!-- Uitleg bij het veld waar je op het info-icoon klikte -->
<div x-show="fieldInfo" x-cloak
class="rounded-xl border border-ac/30 bg-ac/5 px-3.5 py-3">
@ -1746,6 +1822,13 @@ tailwind.config = {
<template x-if="install.format === 'boilerplate' && !install.loading">
<div class="space-y-3">
<div x-show="install.architecture_note"
class="rounded-xl border border-ac/35 bg-ac/5 px-3.5 py-3 flex items-start gap-2.5">
<span class="mdi mdi-raspberry-pi text-ac dark:text-ac-dark shrink-0"></span>
<span class="text-[11px] text-t2 dark:text-t2-dark leading-snug"
x-text="install.architecture_note"></span>
</div>
<!-- Wat deze app nodig heeft en nog niet draait. Advies, geen
blokkade: je kunt de broker of Home Assistant net zo goed op een
andere machine hebben staan. -->
@ -1844,6 +1927,30 @@ tailwind.config = {
</div>
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('new_network_hint')"></p>
</div>
<!-- Welke onderdelen op dat netwerk komen. Eerder gingen ze
allemaal mee, database incluis: die stond dan open voor
elke andere gekoppelde app. -->
<div x-show="(install.app_networks||[]).length && (install.service_info||[]).length > 1">
<div class="form-label" x-text="t('shared_services')">Onderdelen op het gedeelde netwerk</div>
<p class="text-[11px] text-dm dark:text-dm-dark mb-2" x-text="t('shared_services_hint')"></p>
<div class="space-y-1.5">
<template x-for="svc in (install.service_info||[])" :key="svc.name">
<label class="flex items-center gap-2.5 px-3 py-2 rounded-xl border border-bd dark:border-bd-dark bg-s2/40 dark:bg-s2-dark/40"
:class="svc.host_network ? 'opacity-50' : 'cursor-pointer'">
<input type="checkbox" :disabled="svc.host_network"
:checked="gedeeldeDienstAan(svc)"
@change="toggleGedeeldeDienst(svc.name, $event.target.checked)"
class="w-5 h-5 rounded border-bd dark:border-bd-dark text-ac shrink-0">
<span class="min-w-0 flex-1">
<span class="text-sm font-mono text-tx dark:text-tx-dark" x-text="svc.name"></span>
<span x-show="svc.helper" class="badge badge-warn ml-1.5" x-text="t('database_service')">database</span>
</span>
<span class="text-[10px] font-mono text-dm dark:text-dm-dark truncate max-w-[45%]" x-text="svc.image"></span>
</label>
</template>
</div>
</div>
<div>
<label class="form-label" x-text="t('network')">Netwerk</label>
<select class="form-input" x-model="install.network" @change="pickIp()">
@ -1908,6 +2015,114 @@ tailwind.config = {
</div>
</template>
<!-- Extra's: een map van de host en een USB-apparaat erbij.
Dat kon alleen door het compose-bestand met de hand te
bewerken, en dat wordt bij de eerstvolgende wijziging weer
overschreven. -->
<template x-if="s.id === 'extras'">
<div class="space-y-4">
<!-- Versie per onderdeel. De meeste sjablonen staan op
`latest`, en dan kan Bijwerken je ongemerkt over een
majorversie tillen. -->
<div x-show="(install.service_info||[]).length">
<div class="form-label" x-text="t('image_versions')">Versies</div>
<p class="text-[11px] text-dm dark:text-dm-dark mb-2" x-text="t('image_versions_hint')"></p>
<template x-for="svc in (install.service_info||[])" :key="'t'+svc.name">
<div class="flex items-center gap-2 mb-1.5" x-show="svc.repo">
<span class="text-[11px] font-mono text-t2 dark:text-t2-dark flex-1 truncate"
:title="svc.image" x-text="svc.name + ': ' + svc.repo"></span>
<input class="form-input !h-9 !text-xs font-mono !w-40"
:placeholder="svc.tag"
:value="(install.image_tags||{})[svc.name] || ''"
@input="install.image_tags[svc.name] = $event.target.value.trim()">
</div>
</template>
</div>
<div>
<div class="form-label" x-text="t('extra_volumes')">Mappen van deze host</div>
<p class="text-[11px] text-dm dark:text-dm-dark mb-2" x-text="t('extra_volumes_hint')"></p>
<template x-for="(v, i) in (install.extras.volumes || [])" :key="'v'+i">
<div class="flex flex-wrap items-center gap-1.5 mb-1.5">
<select class="form-input !h-9 !text-xs w-auto min-w-[7rem]" x-model="v.service">
<template x-for="svc in (install.service_info||[])" :key="svc.name">
<option :value="svc.name" x-text="svc.name"></option>
</template>
</select>
<input class="form-input !h-9 !text-xs font-mono flex-1 min-w-[9rem]"
x-model="v.host" :placeholder="t('host_path')"
@change="checkExtraPad(v)">
<span class="mdi mdi-arrow-right text-dm dark:text-dm-dark"></span>
<input class="form-input !h-9 !text-xs font-mono flex-1 min-w-[8rem]"
x-model="v.container" placeholder="/media">
<label class="flex items-center gap-1.5 text-[11px] cursor-pointer px-1">
<input type="checkbox" x-model="v.ro"
class="w-4 h-4 rounded border-bd dark:border-bd-dark text-ac">
<span x-text="t('read_only')">alleen lezen</span>
</label>
<button type="button" class="btn-sm !h-9 !px-2 btn-danger"
@click="install.extras.volumes.splice(i, 1)">
<span class="mdi mdi-delete-outline"></span>
</button>
<p x-show="v.fout" class="w-full text-[11px] text-er" x-text="v.fout"></p>
</div>
</template>
<button type="button" class="btn-sm mt-1" @click="voegMapToe()">
<span class="mdi mdi-folder-plus-outline"></span>
<span x-text="t('add_volume')">Map toevoegen</span>
</button>
</div>
<div>
<div class="form-label" x-text="t('extra_devices')">Apparaten</div>
<p class="text-[11px] text-dm dark:text-dm-dark mb-2" x-text="t('extra_devices_hint')"></p>
<template x-for="(a, i) in (install.extras.devices || [])" :key="'d'+i">
<div class="flex flex-wrap items-center gap-1.5 mb-1.5">
<select class="form-input !h-9 !text-xs w-auto min-w-[7rem]" x-model="a.service">
<template x-for="svc in (install.service_info||[])" :key="svc.name">
<option :value="svc.name" x-text="svc.name"></option>
</template>
</select>
<input class="form-input !h-9 !text-xs font-mono flex-1 min-w-[12rem]"
x-model="a.host" list="apparatenlijst" placeholder="/dev/ttyUSB0">
<span class="mdi mdi-arrow-right text-dm dark:text-dm-dark"></span>
<input class="form-input !h-9 !text-xs font-mono flex-1 min-w-[8rem]"
x-model="a.container" :placeholder="a.host || '/dev/ttyACM0'">
<button type="button" class="btn-sm !h-9 !px-2 btn-danger"
@click="install.extras.devices.splice(i, 1)">
<span class="mdi mdi-delete-outline"></span>
</button>
</div>
</template>
<datalist id="apparatenlijst">
<template x-for="dev in devices" :key="dev.path">
<option :value="dev.path"></option>
</template>
</datalist>
<button type="button" class="btn-sm mt-1" @click="voegApparaatToe()">
<span class="mdi mdi-usb"></span>
<span x-text="t('add_device')">Apparaat toevoegen</span>
</button>
<!-- De gevonden apparaten met één klik, want /dev/serial/by-id
zelf uitpluizen via ssh is precies wat dit scherm
overbodig hoort te maken. -->
<div x-show="devices.length" class="mt-2 space-y-1">
<template x-for="dev in devices.slice(0, 12)" :key="dev.path">
<button type="button"
class="w-full text-left px-2.5 py-1.5 rounded-lg border border-bd dark:border-bd-dark bg-s2/40 dark:bg-s2-dark/40 hover:border-ac/40"
@click="voegApparaatToe(dev.path)">
<span class="font-mono text-[11px] text-tx dark:text-tx-dark" x-text="dev.path"></span>
<span x-show="dev.stable" class="badge badge-ok ml-1.5" x-text="t('device_stable')">vast</span>
<span x-show="dev.target" class="text-[10px] text-dm dark:text-dm-dark ml-1.5" x-text="'→ ' + dev.target"></span>
</button>
</template>
</div>
<p x-show="!devices.length" class="text-[11px] text-dm dark:text-dm-dark mt-2"
x-text="t('no_devices')"></p>
</div>
</div>
</template>
<!-- Controleren: het gerenderde resultaat, bewerkbaar -->
<template x-if="s.id === 'controleren'">
<div class="space-y-2">
@ -2181,6 +2396,7 @@ tailwind.config = {
<span x-text="t('next')">Volgende</span> <span class="mdi mdi-chevron-right"></span>
</button>
<button class="btn btn-primary flex-1 sm:flex-none" x-show="!wizardActief() || opLaatsteStap()"
:disabled="install.mode !== 'edit' && install.compatibility && !install.compatibility.supported"
@click="install.mode === 'edit' ? doReconfigure() : doInstall()">
<span class="mdi" :class="install.mode === 'edit' ? 'mdi-content-save-outline' : 'mdi-rocket-launch'"></span>
<span x-text="install.mode === 'edit' ? t('save') : t('install')">Installeren</span>
@ -2190,15 +2406,38 @@ tailwind.config = {
<!-- Remove confirm -->
<div x-show="modal==='remove'" class="modal max-w-md">
<div class="modal-head"><span class="mdi mdi-delete-alert-outline text-er"></span> <span x-text="t('remove_stack')">Verwijderen</span></div>
<div class="modal-head">
<span class="mdi mdi-delete-alert-outline text-er"></span>
<span x-text="t('remove_stack')">App verwijderen</span>
</div>
<div class="modal-body">
<p class="text-sm mb-4 text-t2 dark:text-t2-dark">App: <strong class="text-wh dark:text-wh-dark" x-text="install.stack"></strong></p>
<div class="flex flex-col gap-2">
<button class="btn" @click="doRemove('stack')"><span class="mdi mdi-stop-circle-outline"></span> <span x-text="t('remove_stack_only')">Stop + volumes</span></button>
<button class="btn btn-danger" @click="doRemove('all')"><span class="mdi mdi-delete-outline"></span> <span x-text="t('remove_all')">Verwijder alles</span></button>
<p class="text-sm mb-4 text-t2 dark:text-t2-dark">App:
<strong class="text-wh dark:text-wh-dark break-all" x-text="removeTarget"></strong>
</p>
<div class="flex flex-col gap-3">
<button class="btn !h-auto !py-3 !justify-start text-left"
:disabled="removeBusy" @click="doRemove('full')">
<span class="mdi mdi-delete-outline text-lg"></span>
<span class="min-w-0">
<span class="block" x-text="t('remove_stack_only')">App verwijderen · data bewaren</span>
<span class="block mt-0.5 text-[11px] font-normal text-dm dark:text-dm-dark"
x-text="t('remove_keep_data_hint')"></span>
</span>
</button>
<button class="btn btn-danger !h-auto !py-3 !justify-start text-left"
:disabled="removeBusy" @click="doRemove('all')">
<span class="mdi mdi-delete-forever-outline text-lg"></span>
<span class="min-w-0">
<span class="block" x-text="t('remove_all')">App en alle data verwijderen</span>
<span class="block mt-0.5 text-[11px] font-normal opacity-80"
x-text="t('remove_all_hint')"></span>
</span>
</button>
</div>
</div>
<div class="modal-foot"><button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button></div>
<div class="modal-foot">
<button class="btn" :disabled="removeBusy" @click="modal=null" x-text="t('cancel')">Annuleren</button>
</div>
</div>
<!-- Editor -->
@ -2208,7 +2447,7 @@ tailwind.config = {
<span x-text="editor.stack"></span>
<!-- Tabbladen: tot nu toe moest je de modal sluiten en opnieuw openen om
van compose naar .env te gaan. -->
<div class="ml-auto flex items-center gap-1">
<div class="ml-auto max-w-full flex items-center gap-1 overflow-x-auto">
<button class="btn-sm" :class="editor.type==='compose' ? '!bg-ac !text-white' : ''"
@click="switchEditor('compose')">docker-compose.yml</button>
<button class="btn-sm" :class="editor.type==='env' ? '!bg-ac !text-white' : ''"
@ -2327,10 +2566,10 @@ tailwind.config = {
</div>
<!-- ░░ First-run wizard ░░ -->
<div x-show="wizard.open" x-cloak class="fixed inset-0 bg-slate-900/80 backdrop-blur z-[80] grid place-items-center p-4">
<div class="bg-s1 dark:bg-s1-dark border border-bd dark:border-bd-dark rounded-2xl w-full max-w-xl shadow-pop overflow-hidden">
<div x-show="wizard.open" x-cloak class="fixed inset-0 bg-slate-900/80 backdrop-blur z-[80] grid place-items-center p-0 sm:p-4 overflow-hidden">
<div class="modal max-w-xl">
<div class="h-1.5 bg-s2 dark:bg-s2-dark"><div class="h-full bg-gradient-to-r from-ac to-ac2 transition-all" :style="`width:${(wizard.step+1)/(wizard.steps.length||1)*100}%`"></div></div>
<div class="p-7 min-h-[270px]">
<div class="modal-body !p-5 sm:!p-7 min-h-[270px]">
<h2 class="text-2xl font-extrabold text-wh dark:text-wh-dark mb-1 tracking-tight" x-text="wizard.steps[wizard.step]?.title || ''"></h2>
<p class="text-sm text-t2 dark:text-t2-dark mb-6" x-text="wizard.steps[wizard.step]?.subtitle || ''"></p>
<div x-show="wizardStapId() === 'welkom'">
@ -2436,8 +2675,8 @@ tailwind.config = {
<div x-show="wizardStapId() === 'klaar'"><p class="text-sm">We synchroniseren nu de repos en zijn klaar voor gebruik.</p></div>
</div>
<div class="px-7 py-4 border-t border-bd dark:border-bd-dark flex justify-end gap-2">
<button class="btn" @click="wizard.open = false" x-text="t('close')">Sluiten</button>
<div class="modal-foot !px-5 sm:!px-7">
<button class="btn" @click="dismissWizard()" x-text="t('close')">Sluiten</button>
<button class="btn" @click="wizard.step = Math.max(0, wizard.step-1)" :disabled="wizard.step===0" x-text="t('prev')">Terug</button>
<button class="btn btn-primary" @click="wizardNext()"><span x-text="wizard.step < wizard.steps.length-1 ? 'Volgende' : 'Klaar'"></span></button>
</div>
@ -2455,7 +2694,7 @@ tailwind.config = {
.btn-sm { @apply inline-flex items-center justify-center gap-1.5 px-3 h-9 rounded-lg border border-bd dark:border-bd-dark bg-s1 dark:bg-s1-dark text-tx dark:text-tx-dark text-xs font-semibold hover:bg-s2 dark:hover:bg-s2-dark hover:border-bd2 dark:hover:border-bd2-dark active:scale-95 cursor-pointer transition shrink-0; }
.seg { @apply inline-flex items-center gap-1.5 px-3 h-9 rounded-lg text-xs font-semibold text-t2 dark:text-t2-dark cursor-pointer transition; }
.seg-on { @apply bg-s1 dark:bg-s1-dark text-ac dark:text-ac-dark shadow-soft; }
.card { @apply bg-s1 dark:bg-s1-dark border border-bd dark:border-bd-dark rounded-2xl shadow-soft; }
.card { @apply min-w-0 bg-s1 dark:bg-s1-dark border border-bd dark:border-bd-dark rounded-2xl shadow-soft; }
.card-hover { @apply hover:shadow-soft2 hover:-translate-y-0.5 transition duration-200; }
.card-pad { @apply p-5; }
.nav-link { @apply flex items-center gap-3 px-3.5 h-11 rounded-xl text-sm font-semibold text-t2 dark:text-t2-dark hover:bg-s2 dark:hover:bg-s2-dark hover:text-tx dark:hover:text-tx-dark cursor-pointer transition mb-0.5; }
@ -2465,7 +2704,7 @@ tailwind.config = {
.stat-value { @apply text-2xl sm:text-3xl font-extrabold text-wh dark:text-wh-dark leading-none tracking-tight; }
.stat-label { @apply text-[11px] text-t2 dark:text-t2-dark mt-1.5 uppercase tracking-wider font-semibold truncate; }
.form-label { @apply block text-[11px] font-bold tracking-wider uppercase text-dm dark:text-dm-dark mb-1.5; }
.form-input { @apply w-full bg-s1 dark:bg-s2-dark border border-bd dark:border-bd-dark rounded-xl px-3.5 h-11 text-sm text-tx dark:text-tx-dark outline-none focus:border-ac focus:ring-4 focus:ring-ac/10 transition placeholder:text-dm dark:placeholder:text-dm-dark; }
.form-input { @apply min-w-0 w-full bg-s1 dark:bg-s2-dark border border-bd dark:border-bd-dark rounded-xl px-3.5 h-11 text-sm text-tx dark:text-tx-dark outline-none focus:border-ac focus:ring-4 focus:ring-ac/10 transition placeholder:text-dm dark:placeholder:text-dm-dark; }
textarea.form-input { @apply h-auto py-2.5; }
.badge { @apply inline-flex items-center text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded-md; }
.badge-ok { @apply bg-ok/10 text-ok; }
@ -2476,10 +2715,31 @@ tailwind.config = {
.empty { @apply text-center py-16 text-dm dark:text-dm-dark; }
.empty span { @apply text-5xl opacity-40 block mb-2; }
.empty p { @apply text-sm; }
.modal { @apply bg-s1 dark:bg-s1-dark border border-bd dark:border-bd-dark rounded-2xl w-full shadow-pop; }
.modal-head { @apply px-4 sm:px-5 py-4 text-base font-extrabold text-wh dark:text-wh-dark flex items-center gap-2.5 border-b border-bd dark:border-bd-dark; }
.modal-body { @apply px-4 sm:px-5 py-4; }
.modal-foot { @apply px-4 sm:px-5 py-3 sm:py-4 flex flex-wrap justify-end gap-2 border-t border-bd dark:border-bd-dark; }
.modal { @apply min-w-0 bg-s1 dark:bg-s1-dark border border-bd dark:border-bd-dark rounded-2xl w-full shadow-pop flex flex-col overflow-hidden;
max-width: calc(100vw - 2rem);
max-height: calc(100vh - 2rem);
max-height: calc(100dvh - 2rem); }
.modal-head { @apply min-w-0 shrink-0 px-4 sm:px-5 py-4 text-base font-extrabold text-wh dark:text-wh-dark flex items-center gap-2.5 border-b border-bd dark:border-bd-dark; }
.modal-body { @apply min-h-0 flex-1 px-4 sm:px-5 py-4 overflow-y-auto overflow-x-auto; }
.modal-foot { @apply shrink-0 px-4 sm:px-5 py-3 sm:py-4 flex flex-wrap justify-end gap-2 border-t border-bd dark:border-bd-dark; }
}
.modal-body {
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
@media (max-width: 639px) {
.modal {
width: 100vw !important;
max-width: 100vw !important;
height: 100vh;
height: 100dvh;
max-height: 100vh !important;
max-height: 100dvh !important;
border-radius: 0 !important;
border-left-width: 0;
border-right-width: 0;
}
.modal-head { flex-wrap: wrap; }
}
</style>
@ -2537,6 +2797,7 @@ function app() {
docker: { running: 0, containers: 0, images: 0 },
stacks: [],
stackQuery: '', stackCat: '',
removeTarget: '', removeBusy: false,
storeQuery: '', storeTag: '', storeCat: '',
filterOpen: false, filterTagQuery: '', stackMenu: '', stackFilterOpen: false,
hoofdmap: '', afgeleidePaden: {}, padCheck: {}, padenVoorWijziging: {},
@ -2569,6 +2830,8 @@ function app() {
job: { id: null, lines: [], status: 'idle', timer: null },
newRepo: { name:'', url:'', branch:'main', subdir:'apps' },
repoEdit: { id:'', name:'', url:'', branch:'', subdir:'' },
devices: [],
install: {
stack: '', repo_id: '', instance: '',
format: 'compose', loading: false,
@ -2864,8 +3127,18 @@ function app() {
this.job = { id: jid, lines: [], status: 'running', timer: null };
this.termOpen = true;
let off = 0;
// De offset schuift pas op als het antwoord binnen is. Zonder deze vlag
// vertrekken er bij een trage stap meerdere polls met dezelfde offset en
// komt hetzelfde blok regels twee, drie of vijf keer in beeld.
let bezig = false;
this.job.timer = setInterval(async () => {
const d = await this.rpc(`/api/jobs/${jid}?offset=${off}`);
if (bezig) return;
bezig = true;
let d;
try {
d = await this.rpc(`/api/jobs/${jid}?offset=${off}`);
} finally { bezig = false; }
if (!d) return;
(d.lines || []).forEach(l => { this.job.lines.push(l); off++; });
if (d.status && d.status !== 'running') {
clearInterval(this.job.timer); this.job.timer = null;
@ -3081,12 +3354,28 @@ function app() {
if (d.job_id) this.pollJob(d.job_id, () => this.loadStacks());
else if (d.error) this.toast('Fout: '+d.error, 'er');
},
confirmRemove(name) { this.install.stack = name; this.modal = 'remove'; },
confirmRemove(name) {
this.removeTarget = name;
this.removeBusy = false;
this.modal = 'remove';
},
async doRemove(mode) {
const name = this.install.stack;
this.modal = null;
const name = this.removeTarget;
if (!name || this.removeBusy) return;
this.removeBusy = true;
const d = await this.rpc(`/api/stacks/${encodeURIComponent(name)}/remove`, { mode });
if (d.job_id) this.pollJob(d.job_id, () => this.loadStacks());
if (!d.job_id) {
this.removeBusy = false;
this.toast(d.msg || d.error || this.t('remove_failed'), 'er');
return;
}
this.modal = null;
this.pollJob(d.job_id, async (status) => {
this.removeBusy = false;
await this.loadStacks();
this.toast(this.t(status === 'done' ? 'remove_done' : 'remove_failed'),
status === 'done' ? 'ok' : 'er');
});
},
async openCredentials(stack) {
const d = await this.rpc(`/api/stacks/${encodeURIComponent(stack)}/credentials`);
@ -3179,6 +3468,54 @@ function app() {
this.appNets = d.networks || [];
if (!this.newNetName) this.newNetName = d.suggested || '';
},
// ── Eigen mappen en apparaten ────────────────────────────────────────────
async loadDevices() {
const d = await this.rpc('/api/devices');
this.devices = (d && d.devices) || [];
},
_eersteDienst() {
const info = this.install.service_info || [];
const echte = info.find(s => !s.helper) || info[0];
return (echte || {}).name || '';
},
voegMapToe() {
if (!Array.isArray(this.install.extras.volumes)) this.install.extras.volumes = [];
this.install.extras.volumes.push(
{ service: this._eersteDienst(), host: '', container: '', ro: false });
},
voegApparaatToe(pad = '') {
if (!Array.isArray(this.install.extras.devices)) this.install.extras.devices = [];
if (pad && this.install.extras.devices.some(a => a.host === pad)) return;
this.install.extras.devices.push(
{ service: this._eersteDienst(), host: pad, container: '' });
},
// Bestaat dit pad op de host, en komt Server Up er überhaupt bij? Een map
// die alleen binnen de container bestaat levert een lege bind-mount op.
async checkExtraPad(v) {
v.fout = '';
if (!v.host) return;
if (!v.container) v.container = v.host;
const d = await this.rpc('/api/paths/check?path=' + encodeURIComponent(v.host));
if (d && !d.ok) v.fout = d.msg || this.t('path_unreachable');
},
// Zonder eigen keuze bepaalt de server het: alles behalve databases en
// zoekindexen. Dat spiegelen we hier, zodat de vinkjes kloppen met wat er
// straks in het compose-bestand belandt.
gedeeldeDienstAan(svc) {
const gekozen = this.install.app_services;
if (Array.isArray(gekozen) && gekozen.length) return gekozen.includes(svc.name);
return !svc.helper && !svc.host_network;
},
toggleGedeeldeDienst(naam, aan) {
const huidig = (Array.isArray(this.install.app_services) && this.install.app_services.length)
? this.install.app_services
: (this.install.service_info || [])
.filter(s => !s.helper && !s.host_network).map(s => s.name);
const lijst = new Set(huidig);
aan ? lijst.add(naam) : lijst.delete(naam);
this.install.app_services = [...lijst];
},
toggleAppNet(naam, aan) {
const lijst = new Set(this.install.app_networks || []);
aan ? lijst.add(naam) : lijst.delete(naam);
@ -3637,6 +3974,21 @@ function app() {
this.toast('Verwijderd', 'ok');
this.loadAppStore();
},
openRepoEdit(repo) {
this.repoEdit = { id: repo.id, name: repo.name || '', url: repo.url || '',
branch: repo.branch || 'main', subdir: repo.subdir || '' };
this.modal = 'edit-repo';
},
async saveRepo() {
const d = await this.req(`/api/repos/${encodeURIComponent(this.repoEdit.id)}`,
{ method:'PUT', json: this.repoEdit });
if (!d.ok) { this.toast(d.msg || this.t('error'), 'er'); return; }
this.modal = null;
// Meteen ophalen: de cache staat nog op de vorige branch, en zonder deze
// stap zie je in de app store nog steeds de oude sjablonen.
await this.syncRepo(this.repoEdit.id);
await this.loadAppStore();
},
async addRepo() {
const r = await this.rpc('/api/repos', this.newRepo);
if (r.ok) { this.toast('Toegevoegd', 'ok'); this.modal=null; this.newRepo = { name:'', url:'', branch:'main', subdir:'apps' }; this.loadAppStore(); }
@ -3644,6 +3996,10 @@ function app() {
},
async openInstall(stack, rid) {
if (stack.compatibility && !stack.compatibility.supported) {
this.toast(stack.compatibility.reason || 'Deze app werkt niet op deze architectuur.', 'er');
return;
}
this.install = {
mode: 'new',
stack: stack.dir || stack.name, repo_id: rid,
@ -3653,7 +4009,12 @@ function app() {
envVars: [], images: [], envOverrides: {}, imageTags: {},
fields: [], values: {}, preview_files: {},
step: 0, tab: 'compose', editing: false, composeText: '', envText: '',
network: '', ip: '', ips: {}, services: [], depends_on: []
network: '', ip: '', ips: {}, services: [], service_info: [],
app_services: [], depends_on: [],
extras: { volumes: [], devices: [] },
image_tags: {},
compatibility: { known: false, supported: true, architecture: '', reason: '' },
architecture_note: ''
};
this.modal = 'install';
this.fieldInfo = null;
@ -3663,6 +4024,13 @@ function app() {
this.install.loading = false;
if (!d.ok) { this.toast(d.msg || 'Preview faalde', 'er'); this.modal = null; return; }
this.install.format = d.format || 'compose';
this.install.compatibility = d.compatibility || this.install.compatibility;
this.install.architecture_note = (d.metadata || {}).architecture_note || '';
if (!this.install.compatibility.supported) {
this.toast(this.install.compatibility.reason || 'Deze app werkt niet op deze architectuur.', 'er');
this.modal = null;
return;
}
if (d.format === 'boilerplate') {
this.install.fields = d.fields || [];
const init = {};
@ -3728,6 +4096,7 @@ function app() {
verbinden: { icon: 'mdi-lan-connect', key: 'step_network' },
instellingen: { icon: 'mdi-cog-outline', key: 'step_settings' },
geheimen: { icon: 'mdi-key-variant', key: 'step_secrets' },
extras: { icon: 'mdi-usb-flash-drive-outline', key: 'step_extras' },
controleren: { icon: 'mdi-file-check-outline', key: 'step_review' },
},
wizardActief() {
@ -3749,6 +4118,7 @@ function app() {
// 'basis' toont ook de instantienaam, dus die stap is nooit leeg.
if (id === 'basis' || this.stepFields(id).length) uit.push(this._stap(id));
}
uit.push(this._stap('extras'));
uit.push(this._stap('controleren'));
return uit;
},
@ -3802,7 +4172,7 @@ function app() {
stepMissing() {
if (!this.wizardActief()) return [];
const s = this.wizardSteps()[this.install.step];
if (!s || s.id === 'controleren' || s.id === 'verbinden') return [];
if (!s || ['controleren', 'verbinden', 'extras'].includes(s.id)) return [];
return this.stepFields(s.id).filter(f => {
if (!f.required || f.is_group_toggle) return false;
if (f.group_toggle && !this.install.values[f.group_toggle]) return false;
@ -3821,7 +4191,8 @@ function app() {
const stapId = stappen[this.install.step].id;
// Ook bij Verbinden: daar staat de lijst containers die uit de
// voorvertoning komt, en die verandert met elk onderdeel dat je aanzet.
if (['controleren', 'verbinden'].includes(stapId)) await this.refreshPreview();
if (['controleren', 'verbinden', 'extras'].includes(stapId)) await this.refreshPreview();
if (stapId === 'extras' && !this.devices.length) await this.loadDevices();
// Meteen laten zien of de poorten van deze stap vrij zijn.
this.checkPoortenVanStap(stapId);
},
@ -3840,6 +4211,7 @@ function app() {
if (!d || !d.ok) return;
this.install.preview_files = d.preview_files || {};
this.install.services = d.services || [];
this.install.service_info = d.service_info || [];
this.install.composeText = this.bpPreview();
this.install.envText = d.env_preview || '';
// Een onderdeel dat je hebt uitgezet levert geen container meer op; zijn
@ -3894,6 +4266,11 @@ function app() {
// Bij wijzigen is de bron nodig om opnieuw te kunnen renderen.
repo_id: d.repo_id || '',
app_networks: d.app_networks || (d.connected ? [this.sharedNetwork] : []),
app_services: d.app_services || [], service_info: [],
source_repo: d.source_repo || {},
extras: { volumes: (d.extras||{}).volumes || [],
devices: (d.extras||{}).devices || [] },
image_tags: { ...(d.image_tags || {}) },
};
await this.loadAppNets(stack);
this.fieldInfo = null;
@ -3915,6 +4292,9 @@ function app() {
ips: this.install.ips || {},
connect_apps: this.install.connect_apps !== false,
app_networks: this.install.app_networks || [],
app_services: this.install.app_services || [],
extras: this.install.extras || {},
image_tags: this.install.image_tags || {},
});
if (!d.job_id) { this.toast(d.msg || this.t('error'), 'er'); return; }
this.modal = null;
@ -3931,6 +4311,10 @@ function app() {
},
async doInstall() {
if (this.install.compatibility && !this.install.compatibility.supported) {
this.toast(this.install.compatibility.reason || 'Deze app werkt niet op deze architectuur.', 'er');
return;
}
const body = {
stack: this.install.stack,
repo_id: this.install.repo_id,
@ -3944,6 +4328,12 @@ function app() {
}
body.connect_apps = this.install.connect_apps !== false;
body.app_networks = this.install.app_networks || [];
body.app_services = this.install.app_services || [];
body.extras = this.install.extras || {};
// Boilerplate-apps sturen hun versiekeuze hier mee; het klassieke
// formaat gebruikt hiervoor het imageTags-blok verderop.
if (this.install.format === 'boilerplate')
body.image_tags = this.install.image_tags || {};
if (this.install.format === 'boilerplate') body.values = this.install.values;
else { body.env_overrides = this.install.envOverrides; body.image_tags = this.install.imageTags; }
// Heb je het resultaat in de laatste stap zelf bewerkt, dan telt jouw
@ -4074,11 +4464,22 @@ function app() {
if (!ts) return '';
return new Date(ts * 1000).toLocaleString();
},
// "3 dagen geleden" leest bij een synchronisatie prettiger dan een datum:
// de vraag is niet wannéér het was, maar of het lang geleden is.
geleden(ts) {
if (!ts) return '';
const sec = Math.max(0, Date.now() / 1000 - ts);
const stappen = [[86400, 'd'], [3600, 'u'], [60, 'm']];
for (const [n, letter] of stappen) {
if (sec >= n) return Math.floor(sec / n) + letter;
}
return this.t('just_now');
},
// Bijwerken: de container vervangt zichzelf, dus na het startsein is deze
// pagina zijn server kwijt. We pollen /healthz tot de nieuwe versie leeft.
async applyUpdate() {
const naar = this.update.latest_tag || this.update.latest;
const naar = this.update.image_tag || this.update.latest || this.update.latest_tag;
if (!confirm(this.t('update_confirm', {tag: naar}))) return;
this.updating = true;
const d = await this.rpc('/api/update/apply', { tag: naar });
@ -4141,6 +4542,17 @@ function app() {
this.wizard.BACKUP_DIR = d.defaults?.BACKUP_DIR || '/opt/serverup/backups';
this.wizard.open = true;
},
// De wizard is volledig optioneel. Ook voortijdig sluiten wordt op
// de server onthouden, zodat hij niet na elke paginalading terugkomt.
async dismissWizard() {
const d = await this.rpc('/api/wizard/complete', {});
if (!d.ok) {
this.toast(d.msg || d.error || this.t('error'), 'er');
return false;
}
this.wizard.open = false;
return true;
},
// Opnieuw uitvoeren: markeer als niet-afgerond en open hem.
async resetWizard() {
await this.rpc('/api/wizard/reset', {});
@ -4201,9 +4613,7 @@ function app() {
} else {
const d = await this.rpc('/api/wizard/sync', {});
if (d.job_id) this.pollJob(d.job_id, async () => {
await this.rpc('/api/wizard/complete', {});
this.wizard.open = false;
this.loadDashboard();
if (await this.dismissWizard()) this.loadDashboard();
});
}
},

View file

@ -50,6 +50,27 @@
"no_stacks": "No apps found",
"no_images": "No images found",
"no_repos": "No repository added yet — add one to see apps.",
"edit_repo": "Edit repository",
"repo_branch_hint": "The branch decides which version of the templates you get. Saving re-fetches the repo right away; existing apps only change once you save their settings.",
"template_from": "Template from {repo} ({branch})",
"synced_ago": "fetched {ago} ago",
"cache_outdated": "This repo is on {version} while Server Up runs {current}. Sync it or pick another branch — otherwise saving renders an older template over your app.",
"cache_outdated_hint": "The templates in this repo lag behind the running version of Server Up.",
"just_now": "just now",
"step_extras": "Extras",
"extra_volumes": "Folders from this host",
"extra_volumes_hint": "Make a folder from the server available inside the app — your media library, a disk with photos. Survives later settings changes.",
"extra_devices": "Devices",
"extra_devices_hint": "Pass through a USB stick or graphics card, for example a Zigbee stick or a GPU for transcoding.",
"add_volume": "Add folder",
"add_device": "Add device",
"host_path": "/srv/media",
"read_only": "read only",
"device_stable": "stable",
"no_devices": "No devices found on this host. You can type the path yourself, for example /dev/ttyUSB0.",
"path_unreachable": "Server Up cannot reach this path; check that the folder is mounted.",
"image_versions": "Versions",
"image_versions_hint": "Leave empty to follow the template (usually 'latest'). Enter a version to pin it, so updating cannot silently carry you across a major release.",
"add_repo": "Add repository",
"repo_name": "Name",
"repo_url": "Git URL",
@ -59,8 +80,8 @@
"instance_name": "Instance name",
"instance_hint": "Change for multiple installations",
"remove_stack": "Remove app",
"remove_stack_only": "Stop containers + volumes",
"remove_all": "Stop + remove all files",
"remove_stack_only": "Remove app · keep data",
"remove_all": "Remove app and all data",
"prune_images": "Remove all unused images?",
"confirm_remove_image": "Remove image?",
"force_remove": "Force removal?",
@ -303,6 +324,7 @@
"manual_edit_warning": "You are editing the result yourself. If you change a field after this, your edit will be overwritten.",
"no_credentials_hint": "This app has no preset credentials: you create an account yourself on first visit.",
"nothing_here": "(empty)",
"open_web": "Open web interface",
"nothing_to_choose": "Nothing to configure here.",
"one_page": "All on one page",
"preview": "View",
@ -316,6 +338,9 @@
"step_secrets": "Access",
"step_review": "Review",
"address_available": "This address is free",
"shared_services": "Parts on the shared network",
"shared_services_hint": "Databases and search indexes stay inside this app by default. Tick one to deliberately share it with another app.",
"database_service": "database",
"app_networks": "Shared networks",
"app_networks_hint": "Apps on the same network can reach each other by container name. Not everything needs to be connected to everything: feel free to make a separate network for a group.",
"check_availability": "Check availability",
@ -376,6 +401,7 @@
"audit_act_add": "Added",
"audit_act_app_create": "Network created",
"audit_act_app_delete": "Network deleted",
"audit_act_edit": "Repo edited",
"audit_act_apply": "Applied",
"audit_act_backup": "Backed up",
"audit_act_boot_repo": "Repo at startup",
@ -471,5 +497,9 @@
"adopt_confirm": "Adopt '{naam}' as a Server Up app?\n\nNothing is moved or copied — the folder stays where it is. Server Up will simply manage it too.",
"remove_container_confirm": "Remove container '{naam}'?\n\nThis removes only the container, not its data or its image.",
"remove_container_project": "Note: this container belongs to compose project '{project}'. It will come back as soon as that project is started elsewhere. Adopting is probably what you want.",
"audit_act_adopt": "Adopted"
"audit_act_adopt": "Adopted",
"remove_keep_data_hint": "Stops the containers and removes the app configuration. App data and named volumes are preserved.",
"remove_all_hint": "Also removes app data and non-external named volumes. This cannot be undone.",
"remove_done": "App removed",
"remove_failed": "Failed to remove app"
}

View file

@ -50,6 +50,27 @@
"no_stacks": "Geen apps gevonden",
"no_images": "Geen images gevonden",
"no_repos": "Nog geen repository toegevoegd — voeg er een toe om apps te zien.",
"edit_repo": "Repository bewerken",
"repo_branch_hint": "De branch bepaalt welke versie van de sjablonen je krijgt. Na opslaan wordt de repo meteen opnieuw opgehaald; bestaande apps veranderen pas als je hun instellingen opslaat.",
"template_from": "Sjabloon uit {repo} ({branch})",
"synced_ago": "{ago} geleden opgehaald",
"cache_outdated": "Deze repo staat op {version} terwijl Server Up {current} draait. Synchroniseer hem of kies een andere branch — anders rendert opslaan een ouder sjabloon over je app heen.",
"cache_outdated_hint": "De sjablonen in deze repo lopen achter op de draaiende versie van Server Up.",
"just_now": "zojuist",
"step_extras": "Extra's",
"extra_volumes": "Mappen van deze host",
"extra_volumes_hint": "Een map van de server binnen de app beschikbaar maken — je mediabibliotheek, een schijf met foto's. Blijft staan als je later instellingen wijzigt.",
"extra_devices": "Apparaten",
"extra_devices_hint": "Een USB-stick of videokaart doorgeven, bijvoorbeeld een Zigbee-stick of een GPU voor transcoderen.",
"add_volume": "Map toevoegen",
"add_device": "Apparaat toevoegen",
"host_path": "/srv/media",
"read_only": "alleen lezen",
"device_stable": "vast",
"no_devices": "Geen apparaten gevonden op deze host. Je kunt het pad zelf intikken, bijvoorbeeld /dev/ttyUSB0.",
"path_unreachable": "Server Up kan hier niet bij; controleer of deze map gemount is.",
"image_versions": "Versies",
"image_versions_hint": "Leeg laten volgt het sjabloon (meestal 'latest'). Vul een versie in om die vast te houden, zodat Bijwerken je niet ongemerkt over een grote versiesprong tilt.",
"add_repo": "Repository toevoegen",
"repo_name": "Naam",
"repo_url": "Git URL",
@ -59,8 +80,8 @@
"instance_name": "Instantie naam",
"instance_hint": "Wijzig voor meerdere installaties",
"remove_stack": "App verwijderen",
"remove_stack_only": "Stop containers + volumes",
"remove_all": "Stop + verwijder alle bestanden",
"remove_stack_only": "App verwijderen · data bewaren",
"remove_all": "App en alle data verwijderen",
"prune_images": "Alle ongebruikte images verwijderen?",
"confirm_remove_image": "Image verwijderen?",
"force_remove": "Forceer verwijdering?",
@ -303,6 +324,7 @@
"manual_edit_warning": "Je bewerkt het resultaat zelf. Wijzig je hierna nog een veld, dan wordt je aanpassing overschreven.",
"no_credentials_hint": "Deze app heeft geen vooraf ingestelde inloggegevens: je maakt bij het eerste bezoek zelf een account aan.",
"nothing_here": "(leeg)",
"open_web": "Webinterface openen",
"nothing_to_choose": "Hier valt niets in te stellen.",
"one_page": "Alles op één pagina",
"preview": "Bekijken",
@ -316,6 +338,9 @@
"step_secrets": "Toegang",
"step_review": "Controleren",
"address_available": "Dit adres is vrij",
"shared_services": "Onderdelen op het gedeelde netwerk",
"shared_services_hint": "Databases en zoekindexen blijven standaard binnen deze app. Zet er een aan als je hem bewust met een andere app wilt delen.",
"database_service": "database",
"app_networks": "Gedeelde netwerken",
"app_networks_hint": "Apps in hetzelfde netwerk kunnen elkaar op containernaam bereiken. Niet alles hoeft aan alles vast: maak gerust een apart netwerk voor een groepje.",
"check_availability": "Beschikbaarheid controleren",
@ -376,6 +401,7 @@
"audit_act_add": "Toegevoegd",
"audit_act_app_create": "Netwerk gemaakt",
"audit_act_app_delete": "Netwerk verwijderd",
"audit_act_edit": "Repo bewerkt",
"audit_act_apply": "Toegepast",
"audit_act_backup": "Backup gemaakt",
"audit_act_boot_repo": "Repo bij opstarten",
@ -471,5 +497,9 @@
"adopt_confirm": "'{naam}' overnemen als app van Server Up?\n\nEr wordt niets verplaatst of gekopieerd — de map blijft staan waar hij staat. Server Up gaat hem alleen mee beheren.",
"remove_container_confirm": "Container '{naam}' verwijderen?\n\nDit verwijdert alleen de container, niet zijn gegevens of zijn image.",
"remove_container_project": "Let op: deze container hoort bij het compose-project '{project}'. Hij komt terug zodra dat project ergens anders wordt gestart. Overnemen is dan waarschijnlijk wat je zoekt.",
"audit_act_adopt": "Overgenomen"
"audit_act_adopt": "Overgenomen",
"remove_keep_data_hint": "Stopt de containers en verwijdert de appconfiguratie. Appdata en named volumes blijven bewaard.",
"remove_all_hint": "Verwijdert ook appdata en niet-externe named volumes. Dit kan niet ongedaan worden gemaakt.",
"remove_done": "App verwijderd",
"remove_failed": "App verwijderen mislukt"
}

View file

@ -23,6 +23,7 @@ def env(tmp_path, monkeypatch):
monkeypatch.setenv("SU_AUDIT", str(data / "audit.db"))
monkeypatch.setenv("SU_GIT_CACHE", str(data / "git"))
monkeypatch.setenv("SU_SECRET", str(data / "secret.key"))
monkeypatch.setenv("SU_JOBS", str(data / "jobs"))
monkeypatch.setenv("LIBRARY_DIR", str(lib))
monkeypatch.setenv("DATA_DIR", str(tmp_path / "appdata"))
monkeypatch.setenv("BACKUP_DIR", str(tmp_path / "backups"))

66
tests/js/joblog.test.js Normal file
View file

@ -0,0 +1,66 @@
// Draait de echte Alpine-component uit index.html en toetst dat pollJob niet
// twee keer tegelijk vraagt. Wordt aangeroepen door tests/test_ui_logica.py;
// los draaien kan met: node tests/js/joblog.test.js
const fs = require('fs');
const html = fs.readFileSync(__dirname + '/../../server-up/templates/index.html', 'utf8');
const start = html.indexOf('function app() {');
const eind = html.indexOf('</script>', start);
const bron = html.slice(start, eind);
global.localStorage = { getItem: () => null, setItem: () => {} };
global.location = { hash: '', search: '' };
global.window = { matchMedia: () => ({matches:false, addEventListener(){}}),
addEventListener(){}, innerWidth: 1200 };
global.document = { documentElement: {classList:{add(){},remove(){}}},
addEventListener(){}, querySelector: () => null };
global.navigator = { clipboard: { writeText: async () => {} }, language: 'nl' };
global.crypto = require('crypto').webcrypto;
eval(bron);
let fouten = 0;
const check = (naam, waar) => { if (!waar) { fouten++; console.log(' ✖ ' + naam); }
else console.log(' ✓ ' + naam); };
const rustig = () => new Promise(r => setTimeout(r, 0));
(async () => {
const c = app();
c.t = (k) => k;
c.$nextTick = (fn) => fn();
c.$refs = {};
// De tikken van setInterval zelf in de hand houden: zo kunnen we een tweede
// tik afvuren terwijl het antwoord op de eerste nog onderweg is — precies
// wat er gebeurt bij een trage stap zoals een backup terugzetten.
let tik = null;
global.setInterval = (fn) => { tik = fn; return 1; };
global.clearInterval = () => {};
const openstaand = [];
c.rpc = () => new Promise(res => openstaand.push(res));
c.pollJob('abc123');
tik(); await rustig();
tik(); await rustig();
tik(); await rustig();
check('maar één verzoek tegelijk onderweg', openstaand.length === 1);
openstaand.forEach(res => res({lines: [{level: 'dim', text: 'Backup wordt teruggezet…'}],
status: 'running'}));
await rustig();
check('de regel staat er één keer',
c.job.lines.filter(l => l.text === 'Backup wordt teruggezet…').length === 1);
// Daarna schuift de offset op en mag er weer gevraagd worden.
tik(); await rustig();
check('volgende tik vraagt verder vanaf de nieuwe offset', openstaand.length === 2);
let klaar = '';
c.job.timer = 1;
openstaand[1]({lines: [], status: 'done'});
await rustig();
check('afgeronde job stopt de poll', c.job.status === 'done');
console.log(fouten === 0 ? '\nAlle joblogcontroles geslaagd' : `\n${fouten} FOUTEN`);
process.exit(fouten ? 1 : 0);
})();

View file

@ -40,8 +40,8 @@ c.install = {format:'boilerplate', loading:false, fields:[
], values:{}, step:0};
c.onePage = false; c.showAdvanced = false;
let s = c.wizardSteps().map(x => x.id);
check('eenvoudige app: basis, verbinden, controleren',
JSON.stringify(s) === '["basis","verbinden","controleren"]');
check('eenvoudige app: basis, verbinden, extra\'s, controleren',
JSON.stringify(s) === '["basis","verbinden","extras","controleren"]');
// ── App met geheimen ────────────────────────────────────────────────────────
c.install.fields.push(veld('db_password','geheimen',{required:true, secret:true}));
@ -349,6 +349,79 @@ check('balk staat halverwege', c.job.progress.pct === 50);
// De poortcontrole praat met de backend, dus die checks zijn asynchroon.
// Top-level await kan niet in een CommonJS-script; vandaar deze afsluiting.
(async () => {
// De wizard is optioneel. Ook "Sluiten" moet daarom blijvend onthouden dat
// de gebruiker hem niet bij iedere paginalading opnieuw wil zien.
let wizardVerzoek = null;
c.wizard.open = true;
c.rpc = async (url, body) => {
wizardVerzoek = {url, body};
return {ok: true};
};
await c.dismissWizard();
check('wizard sluiten wordt op de server bewaard',
wizardVerzoek.url === '/api/wizard/complete' &&
c.wizard.open === false);
// ── App verwijderen ───────────────────────────────────────────────────────
// De veilige keuze hoort de appconfiguratie weg te halen en data te bewaren.
let verwijderVerzoek = null;
let verwijderKlaar = null;
let verwijderMelding = null;
let herladenNaVerwijderen = 0;
c.rpc = async (url, body) => {
verwijderVerzoek = {url, body};
return {job_id: 'remove-1'};
};
c.pollJob = (id, callback) => { verwijderKlaar = callback; };
c.loadStacks = async () => { herladenNaVerwijderen++; };
c.toast = (msg, kind) => { verwijderMelding = {msg, kind}; };
c.confirmRemove('mijn app');
check('verwijdervenster onthoudt de juiste app',
c.modal === 'remove' && c.removeTarget === 'mijn app');
await c.doRemove('full');
check('veilige verwijdering gebruikt full en een gecodeerde appnaam',
verwijderVerzoek.url === '/api/stacks/mijn%20app/remove' &&
verwijderVerzoek.body.mode === 'full');
check('verwijdervenster sluit zodra de taak start', c.modal === null);
await verwijderKlaar('done');
check('na verwijderen wordt de applijst opnieuw geladen',
herladenNaVerwijderen === 1 && c.removeBusy === false);
check('succesvolle verwijdering wordt gemeld',
verwijderMelding.msg === 'remove_done' && verwijderMelding.kind === 'ok');
c.confirmRemove('kapotte-app');
c.rpc = async () => ({ok: false, msg: 'kan niet verwijderen'});
await c.doRemove('all');
check('backendfout blijft zichtbaar en ontgrendelt de knop',
c.modal === 'remove' && c.removeBusy === false &&
verwijderMelding.msg === 'kan niet verwijderen' &&
verwijderMelding.kind === 'er');
// Een expliciet ongeschikte architectuur opent zelfs het formulier niet.
let previewGevraagd = false;
let platformMelding = '';
c.rpc = async () => { previewGevraagd = true; return {ok: true}; };
c.toast = (m) => { platformMelding = m; };
await c.openInstall({
dir: 'arm-app',
compatibility: {supported: false, reason: 'Alleen een 64-bits OS.'}
}, 'server-up');
check('ongeschikte app opent geen installatie', previewGevraagd === false);
check('architectuurreden wordt getoond', platformMelding.includes('64-bits'));
// Forgejo noemt de release v0.8.13-beta, maar de registry-workflow pusht de
// image-tag zonder v. De updateknop moet de expliciete image-tag gebruiken.
let updateBody = null;
global.confirm = () => true;
c.update = { current: '0.8.12-beta', latest: '0.8.13-beta',
latest_tag: 'v0.8.13-beta', image_tag: '0.8.13-beta' };
c.rpc = async (url, body) => { updateBody = body; return { job_id: 'upd1' }; };
c.pollJob = () => {};
await c.applyUpdate();
check('update gebruikt registry-tag zonder v',
updateBody && updateBody.tag === '0.8.13-beta');
// ── De hoofdmap ───────────────────────────────────────────────────────────
let gevraagdPad = null;
c.rpc = async (url) => {

View file

@ -1,7 +1,8 @@
// Rendert de echte install-modal met Alpine in een DOM en klikt erop.
// Vangt bindingsfouten die je met losse logica niet ziet: :disabled="0"
// schakelt een knop juist UIT, want Alpine haalt een boolean-attribuut
// alleen weg bij null, undefined of false.
// Rendert de echte installatiemodal en appkaart met Alpine in een DOM en klikt
// de bedieningen daadwerkelijk aan. Vangt bindingsfouten die je met losse
// logica niet ziet: een ontbrekende eigenschap in een x-for-item liet hier het
// boolean-attribuut disabled staan. Alleen de gerenderde DOM laat vervolgens
// zien dat de knop geen klikevent meer ontvangt.
const fs = require('fs'), path = require('path');
// jsdom en Alpine zijn geen onderdeel van dit project; zonder die twee slaat
@ -38,6 +39,11 @@ w.app = function () {
c.pickIp = () => {};
c.netList = [];
c.sharedNetwork = 'serverup';
c.page = 'stacks';
c.stacks = [{
name: 'mijn-app', has_compose: true, running: true, containers: [],
categories: [], depends_missing: [], editable: true, connected: false
}];
c.modal = 'install';
c.install = {
format: 'boilerplate', loading: false, stack: 'test', repo_id: 'r', instance: 'test',
@ -55,7 +61,9 @@ w.app = function () {
};
const m = html.match(/<div x-show="modal==='install'"[\s\S]*?\n <\/div>\n/);
w.document.body.innerHTML = `<div x-data="app()">${m[0]}</div>`;
const rm = html.match(/<!-- Remove confirm -->[\s\S]*?<!-- Editor -->/);
const stacks = html.match(/<section x-show="page === 'stacks'"[\s\S]*?<\/section>/);
w.document.body.innerHTML = `<div x-data="app()">${stacks[0]}${m[0]}${rm[0]}</div>`;
w.eval(fs.readFileSync(alpinePad, 'utf8'));
const wacht = ms => new Promise(r => setTimeout(r, ms));
@ -96,6 +104,63 @@ const wacht = ms => new Promise(r => setTimeout(r, ms));
await wacht(80);
check('knop weer bruikbaar na invullen', volgende.disabled === false);
// De verwijderknop testen we via de echte Alpine @click-binding. De losse
// menu-test roept doRemove() direct aan en mist fouten in de DOM.
let verwijderVerzoek = null;
let verwijderd = false;
comp.rpc = async (url, body) => {
verwijderVerzoek = {url, body};
return {job_id: 'remove-1'};
};
comp.pollJob = (id, klaar) => klaar('done');
comp.loadStacks = async () => { verwijderd = true; };
comp.toast = () => {};
const kaartknop = [...w.document.querySelectorAll('button')].find(
b => b.getAttribute('@click') === 'confirmRemove(s.name)');
check('verwijderknop op de appkaart is gerenderd', !!kaartknop);
if (!kaartknop) {
console.log('stacks:', JSON.stringify(comp.stacks));
console.log('filtered:', JSON.stringify(comp.filteredStacks));
console.log('click-bindings:', [...w.document.querySelectorAll('button')]
.map(b => b.getAttribute('@click')).filter(Boolean).join(' | '));
process.exit(1);
}
kaartknop.click();
await wacht(60);
const verwijderModal = [...w.document.querySelectorAll('.modal')].find(
e => e.getAttribute('x-show') === "modal==='remove'");
check('verwijdermodal opent met de juiste app',
comp.modal === 'remove' && comp.removeTarget === 'mijn-app');
const veilige = [...verwijderModal.querySelectorAll('button')].find(
b => b.getAttribute('@click') === "doRemove('full')");
check('verwijderknop is actief', veilige && !veilige.disabled);
veilige.click();
await wacht(80);
check('klik verstuurt de verwijderaanvraag', verwijderVerzoek &&
verwijderVerzoek.url === '/api/stacks/mijn-app/remove' &&
verwijderVerzoek.body.mode === 'full');
check('voltooide verwijdering herlaadt de apps', verwijderd);
// Op een telefoon loopt verwijderen via een functie in het x-for-actiemenu.
comp.modal = null;
comp.removeTarget = '';
const menuActies = [...w.document.querySelectorAll('button')].filter(
b => b.getAttribute('@click') === "a.doe(); stackMenu = ''");
comp.stackMenu = 'mijn-app';
await wacht(40);
const mobielVerwijderen = menuActies.find(
b => (w.Alpine.$data(b).a || {}).id === 'remove');
check('mobiele verwijderactie is gerenderd', !!mobielVerwijderen);
if (!mobielVerwijderen) process.exit(1);
check('mobiele verwijderactie is actief',
mobielVerwijderen && !mobielVerwijderen.disabled);
mobielVerwijderen.click();
await wacht(60);
if (comp.modal !== 'remove')
console.log('mobiele status:', comp.modal, comp.removeTarget, comp.stackMenu);
check('mobiele verwijderactie opent de modal',
comp.modal === 'remove' && comp.removeTarget === 'mijn-app');
console.log(fouten === 0 ? '\nAlle DOM-controles geslaagd' : `\n${fouten} FOUTEN`);
process.exit(fouten ? 1 : 0);
})();

View file

@ -3,6 +3,8 @@
Deze suite draait over de echte templates, niet over voorbeelden. Zo valt een
kapot template op vóórdat iemand het probeert te installeren.
"""
import re
import subprocess
import sys
import tempfile
from pathlib import Path
@ -374,3 +376,188 @@ def test_huidige_versie_heeft_een_changelog_sectie():
versie = (WORTEL / "VERSION").read_text(encoding="utf-8").strip()
changelog = (WORTEL / "CHANGELOG.md").read_text(encoding="utf-8")
assert f"# v{versie}" in changelog, f"geen changelog-sectie voor {versie}"
def _controleer_javascript_van_initscript(script: str):
"""`bash -n` ziet de JavaScript in de heredoc niet — node wel.
Een typefout daarin blijkt anders pas op de server, wanneer de reparatie
van de database stilletjes niets doet.
"""
import shutil
node = shutil.which("node")
if not node:
pytest.skip("node niet beschikbaar")
binnen = script.split("<<EOF\n", 1)[1].split("\nEOF", 1)[0]
# De shell vult ${...} in vóór het script de mongo-shell in gaat.
js = re.sub(r"\$\{[^}]+\}", "x", binnen)
r = subprocess.run([node, "--check", "-"], input=js, text=True,
capture_output=True)
assert r.returncode == 0, r.stderr
def test_unifi_heeft_pi_geschikte_en_correct_geinitialiseerde_mongodb():
app = WORTEL / "apps" / "unifi-network"
with tempfile.TemporaryDirectory() as t:
tmp = Path(t)
doc = _render(app, tmp)
script = (tmp / "init-mongo.sh").read_text(encoding="utf-8")
mongo = doc["services"]["unifi-mongo"]
reparatie = doc["services"]["unifi-mongo-init"]
unifi = doc["services"]["unifi"]
assert mongo["image"] == "mongo:4.4.29"
assert "./init-mongo.sh:/docker-entrypoint-initdb.d/init-mongo.sh:ro" in mongo["volumes"]
assert any(v == "MONGO_AUTHSOURCE=admin" for v in unifi["environment"])
assert any(v == "MONGO_AUTHSOURCE=admin" for v in mongo["environment"])
assert unifi["depends_on"]["unifi-mongo"]["condition"] == "service_healthy"
assert unifi["depends_on"]["unifi-mongo-init"]["condition"] == \
"service_completed_successfully"
assert reparatie["image"] == "mongo:4.4.29"
assert reparatie["depends_on"]["unifi-mongo"]["condition"] == "service_healthy"
assert reparatie["restart"] == "no"
assert "./init-mongo.sh:/init-mongo.sh:ro" in reparatie["volumes"]
assert any(v == "MONGO_HOST=unifi-mongo" for v in reparatie["environment"])
# De beheerdersnaam van het allereerste sjabloon. Een volume van toen kent
# geen 'root', en dan mislukte elke reparatie én elke mongodump.
assert any(v == "MONGO_INITDB_ROOT_USERNAME=unifi" for v in mongo["environment"])
assert any(v == "MONGO_INITDB_ROOT_USERNAME=unifi" for v in reparatie["environment"])
# De healthcheck mag geen inloggegevens gebruiken: bestaat de beheerder
# niet, dan blijft de container ongezond en start de reparatie nooit.
assert mongo["healthcheck"]["test"][0] == "CMD-SHELL"
assert isinstance(mongo["healthcheck"]["test"][1], str)
assert "--username" not in mongo["healthcheck"]["test"][1]
assert "ping" in mongo["healthcheck"]["test"][1]
assert "createUser" in script
assert "clusterMonitor" in script
assert 'role: "dbOwner"' in script
assert "getUser" in script and "updateUser" in script
assert "grantRolesToUser" in script
# Rollen die de backup nodig heeft voor mongodump/mongorestore.
assert 'role: "backup"' in script and 'role: "restore"' in script
# Meerdere beheerders proberen, inclusief de naam uit het eerste sjabloon.
assert '"root", "unifi"' in script
assert "ensureUser(authBron)" in script
assert "ensureUser(appDatabase)" in script
syntaxis = subprocess.run(["bash", "-n"], input=script, text=True,
capture_output=True)
assert syntaxis.returncode == 0, syntaxis.stderr
_controleer_javascript_van_initscript(script)
assert bp.metadata(app)["architectures"] == ["amd64", "arm64"]
assert bp.metadata(app)["web"] == {"scheme": "https", "port": 8443}
def test_unifi_zet_zijn_system_properties_bij_elke_start_goed(tmp_path):
"""Het image schrijft /config/data/system.properties alleen bij de eerste
start ("Only evaluated on first run"). Een later toegevoegde authSource of
een gewijzigd wachtwoord kwam daar dus nooit in, en UniFi bleef met de oude
gegevens verbinden met UserNotFound tot gevolg.
"""
app = WORTEL / "apps" / "unifi-network"
doc = _render(app, tmp_path)
reparatie = doc["services"]["unifi-mongo-init"]
assert any(v.endswith(":/unifi-config") for v in reparatie["volumes"])
# De reparatie draaien tegen een bestaand bestand van vóór de authSource.
config = tmp_path / "unifi-config" / "data"
config.mkdir(parents=True)
props = config / "system.properties"
props.write_text(
"unifi.https.port=8443\n"
"db.mongo.local=false\n"
"db.mongo.uri=mongodb://unifi:oudgeheim@unifi-mongo:27017/unifi?tls=false\n"
"statdb.mongo.uri=mongodb://unifi:oudgeheim@unifi-mongo:27017/unifi_stat?tls=false\n"
"unifi.db.name=unifi\n", encoding="utf-8")
nep = tmp_path / "nepbin"
nep.mkdir()
(nep / "mongo").write_text("#!/bin/bash\ncat > /dev/null\n", encoding="utf-8")
(nep / "mongo").chmod(0o755)
omgeving = {
"PATH": f"{nep}:/usr/bin:/bin",
"MONGO_INITDB_ROOT_USERNAME": "unifi", "MONGO_INITDB_ROOT_PASSWORD": "nieuw@geheim",
"MONGO_USER": "unifi", "MONGO_PASS": "nieuw@geheim",
"MONGO_HOST": "unifi-mongo", "MONGO_PORT": "27017",
"MONGO_DBNAME": "unifi", "MONGO_AUTHSOURCE": "admin",
"UNIFI_CONFIG_DIR": str(tmp_path / "unifi-config"),
}
r = subprocess.run(["bash", str(tmp_path / "init-mongo.sh")],
capture_output=True, text=True, env=omgeving, timeout=30)
assert r.returncode == 0, r.stdout + r.stderr
regels = dict(l.split("=", 1) for l in props.read_text().strip().splitlines())
assert "authSource=admin" in regels["db.mongo.uri"]
assert "authSource=admin" in regels["statdb.mongo.uri"]
# Bijzondere tekens horen ge-url-codeerd te zijn, anders knipt de @ de URI
# doormidden.
assert "nieuw%40geheim" in regels["db.mongo.uri"]
assert "oudgeheim" not in props.read_text()
assert regels["statdb.mongo.uri"].split("?")[0].endswith("/unifi_stat")
# Instellingen die niet over de database gaan blijven staan.
assert regels["unifi.https.port"] == "8443"
def test_unifi_probeert_ook_het_wachtwoord_uit_system_properties(tmp_path):
"""Wijkt het wachtwoord in .env af van dat waarmee de database ooit is
aangemaakt, dan is het wachtwoord in system.properties het enige waarmee we
nog binnenkomen en dus de enige weg om alles weer gelijk te trekken."""
_render(WORTEL / "apps" / "unifi-network", tmp_path)
config = tmp_path / "unifi-config" / "data"
config.mkdir(parents=True)
(config / "system.properties").write_text(
"db.mongo.uri=mongodb://unifi:oud%40geheim@unifi-mongo:27017/unifi?tls=false\n",
encoding="utf-8")
nep = tmp_path / "nepbin"
nep.mkdir()
# De 'mongo-shell' schrijft op wat hij binnenkrijgt, zodat we kunnen zien
# welke wachtwoorden geprobeerd worden.
(nep / "mongo").write_text(
f"#!/bin/bash\ncat > {tmp_path / 'ontvangen.js'}\n", encoding="utf-8")
(nep / "mongo").chmod(0o755)
r = subprocess.run(
["bash", str(tmp_path / "init-mongo.sh")], capture_output=True, text=True,
env={"PATH": f"{nep}:/usr/bin:/bin", "MONGO_INITDB_ROOT_USERNAME": "unifi",
"MONGO_INITDB_ROOT_PASSWORD": "nieuwgeheim", "MONGO_USER": "unifi",
"MONGO_PASS": "nieuwgeheim", "MONGO_HOST": "unifi-mongo",
"MONGO_PORT": "27017", "MONGO_DBNAME": "unifi",
"MONGO_AUTHSOURCE": "admin",
"UNIFI_CONFIG_DIR": str(tmp_path / "unifi-config")},
timeout=30)
assert r.returncode == 0, r.stdout + r.stderr
js = (tmp_path / "ontvangen.js").read_text()
# %40 hoort weer een @ te zijn voordat MongoDB hem te zien krijgt.
assert 'const oudWachtwoord = "oud@geheim";' in js
assert 'const beheerWachtwoord = "nieuwgeheim";' in js
def test_unifi_reparatie_werkt_ook_zonder_bestaande_system_properties(tmp_path):
"""Bij een verse installatie schrijft het image het bestand zelf; de
reparatie mag daar niet over struikelen."""
_render(WORTEL / "apps" / "unifi-network", tmp_path)
nep = tmp_path / "nepbin"
nep.mkdir()
(nep / "mongo").write_text("#!/bin/bash\ncat > /dev/null\n", encoding="utf-8")
(nep / "mongo").chmod(0o755)
r = subprocess.run(
["bash", str(tmp_path / "init-mongo.sh")], capture_output=True, text=True,
env={"PATH": f"{nep}:/usr/bin:/bin", "MONGO_INITDB_ROOT_USERNAME": "unifi",
"MONGO_INITDB_ROOT_PASSWORD": "x", "MONGO_USER": "unifi", "MONGO_PASS": "x",
"MONGO_HOST": "unifi-mongo", "MONGO_DBNAME": "unifi",
"MONGO_AUTHSOURCE": "admin",
"UNIFI_CONFIG_DIR": str(tmp_path / "bestaat-niet")},
timeout=30)
assert r.returncode == 0, r.stdout + r.stderr
AMD64_ALLEEN = {
"cal-com", "comfyui", "dawarich", "focalboard", "formbricks",
"mattermost", "pinchflat", "pixelfed", "tubearchivist", "unbound", "wazuh",
}
def test_catalogus_markeert_aantoonbaar_amd64_only_apps():
for naam in AMD64_ALLEEN:
meta = bp.metadata(WORTEL / "apps" / naam)
assert meta["architectures"] == ["amd64"], naam
assert "Raspberry Pi" in meta["architecture_note"], naam

View file

@ -279,6 +279,56 @@ def test_databasedienst_wordt_herkend_met_wachtwoord_uit_env(env, monkeypatch):
assert d["wachtwoord"] == "geheim123"
def test_mongodump_gebruikt_de_beheerder_uit_compose(env, monkeypatch):
"""De naam van de MongoDB-beheerder stond niet in de tabel met dumpvelden.
Het commando viel dan terug op de verzonnen gebruiker 'app' en elke dump
van bijvoorbeeld UniFi mislukte met "Authentication failed".
"""
import json as _json
from core import backups, docker
monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt())
d = env["lib"] / "unifi-network"
d.mkdir(parents=True, exist_ok=True)
(d / "docker-compose.yml").write_text(
"services:\n"
" unifi-mongo:\n"
" image: mongo:4.4.29\n"
" container_name: unifi-mongo\n"
" environment:\n"
" - MONGO_INITDB_ROOT_USERNAME=unifi\n"
" - MONGO_INITDB_ROOT_PASSWORD=${MONGO_PASSWORD}\n")
(d / ".env").write_text("MONGO_PASSWORD=geheim123\n")
(d / ".serverup.json").write_text(_json.dumps({"source": "unifi-network"}))
dienst = backups.databasediensten("unifi-network")[0]
assert dienst["gebruiker"] == "unifi"
assert dienst["wachtwoord"] == "geheim123"
cmd, _env = backups._dump_commando(dienst)
assert "--username='unifi'" in cmd[-1]
assert "--username='app'" not in cmd[-1]
def test_mongo_zonder_authenticatie_wordt_zonder_inlog_gedumpt(env):
"""Het Graylog-sjabloon draait MongoDB zonder wachtwoord. Toch inloggegevens
meesturen gaf gegarandeerd "Authentication failed" en dus een archief
zonder database, terwijl de melding erna 'Backup klaar' was."""
from core import backups
dienst = {"soort": "mongo", "gebruiker": "", "database": "", "wachtwoord": "",
"service": "graylog-mongo", "container": "graylog-mongo"}
cmd, _ = backups._dump_commando(dienst)
assert "--username" not in cmd[-1]
assert "--password" not in cmd[-1]
assert cmd[-1].strip() == "mongodump --archive"
assert "--username" not in backups._herstel_commando(dienst)[-1]
# Mét wachtwoord blijven de inloggegevens er gewoon in staan.
met = {**dienst, "gebruiker": "unifi", "wachtwoord": "geheim"}
assert "--username='unifi'" in backups._dump_commando(met)[0][-1]
assert "--username='unifi'" in backups._herstel_commando(met)[-1]
def test_dump_die_mislukt_wordt_gemeld_en_niet_stil_overgeslagen(env, monkeypatch):
"""Een archief dat compleet lijkt terwijl de database ontbreekt is het
ergste dat een backup kan doen."""

View file

@ -1,6 +1,7 @@
"""Boilerplate-rendering: de sandbox, en de YAML-opruiming die conditionele
blokken achterlaten."""
import json
import stat
import pytest
@ -103,3 +104,69 @@ def test_is_boilerplate_herkent_layout(tmp_path):
src = _maak_boilerplate(tmp_path, "services: {}\n")
assert bp.is_boilerplate(src)
assert not bp.is_boilerplate(tmp_path)
def test_architectuurmetadata_wordt_genormaliseerd(tmp_path):
src = _maak_boilerplate(tmp_path, "services: {}\n")
pad = src / "template.json"
data = json.loads(pad.read_text(encoding="utf-8"))
data["metadata"]["architectures"] = ["x86_64", "aarch64", "arm64", "onbekend"]
data["metadata"]["architecture_note"] = "Gebruik een 64-bits OS."
pad.write_text(json.dumps(data), encoding="utf-8")
meta = bp.metadata(src)
assert meta["architectures"] == ["amd64", "arm64"]
assert meta["architecture_note"] == "Gebruik een 64-bits OS."
def test_explicitiete_architectuur_mismatch_wordt_geweigerd():
meta = {"architectures": ["amd64", "arm64"],
"architecture_note": "Gebruik een 64-bits OS."}
assert bp.compatibility(meta, "aarch64")["supported"] is True
fout = bp.compatibility(meta, "armv7l")
assert fout["known"] is True
assert fout["supported"] is False
assert "ARM 32-bit" in fout["reason"]
assert "64-bits OS" in fout["reason"]
def test_onbekende_architectuurmetadata_blokkeert_niet():
assert bp.compatibility({}, "armv7l")["supported"] is True
assert bp.compatibility({"architectures": ["vreemd"]}, "armv7l")["supported"] is True
def test_render_behoudt_uitvoerbaar_recht_van_tekstscript(tmp_path):
src = _maak_boilerplate(tmp_path, "services: {}\n")
script = src / "files" / "init.sh"
script.write_text("#!/bin/sh\necho << service_name >>\n", encoding="utf-8")
script.chmod(0o755)
dest = tmp_path / "out"
bp.render_to_dir(src, dest, {"service_name": "test"})
assert (dest / "init.sh").stat().st_mode & stat.S_IXUSR
def test_webmetadata_wordt_genormaliseerd(tmp_path):
src = _maak_boilerplate(tmp_path, "services: {}\n")
pad = src / "template.json"
data = json.loads(pad.read_text(encoding="utf-8"))
data["metadata"]["web"] = {"scheme": "HTTPS", "port": "8443"}
pad.write_text(json.dumps(data), encoding="utf-8")
assert bp.metadata(src)["web"] == {"scheme": "https", "port": 8443}
@pytest.mark.parametrize("web", [
{"scheme": "ftp", "port": 21},
{"scheme": "https", "port": 70000},
{"scheme": "https", "port": "niet-een-poort"},
])
def test_ongeldige_webmetadata_wordt_genegeerd(tmp_path, web):
src = _maak_boilerplate(tmp_path, "services: {}\n")
pad = src / "template.json"
data = json.loads(pad.read_text(encoding="utf-8"))
data["metadata"]["web"] = web
pad.write_text(json.dumps(data), encoding="utf-8")
assert bp.metadata(src)["web"] == {}

View file

@ -0,0 +1,91 @@
"""Regressietests voor de infrastructuur- en beheerapps uit v0.8.17."""
import json
from pathlib import Path
import pytest
WORTEL = Path(__file__).resolve().parent.parent
APPS = WORTEL / "apps"
NIEUWE_APPS = {
"activepieces",
"consul",
"guacamole",
"lldap",
"meshcentral",
"nomad-server",
"openobserve",
"pairdrop",
"rustdesk-server",
"victoriametrics",
"windmill",
}
def _template(app: str) -> dict:
return json.loads((APPS / app / "template.json").read_text(encoding="utf-8"))
def test_alle_geselecteerde_apps_staan_in_de_catalogus():
aanwezig = {pad.name for pad in APPS.iterdir() if pad.is_dir()}
assert NIEUWE_APPS <= aanwezig
@pytest.mark.parametrize("app", sorted(NIEUWE_APPS))
def test_nieuwe_apps_ondersteunen_pi_en_x86(app):
architecturen = set(_template(app)["metadata"]["architectures"])
assert {"amd64", "arm64"} <= architecturen
def test_nomad_container_is_alleen_control_plane():
compose = (APPS / "nomad-server" / "files" / "compose.yaml").read_text()
config = (APPS / "nomad-server" / "files" / "server.hcl").read_text()
assert "-config=/nomad/config/server.hcl" in compose
assert "enabled = false" in config
assert "enabled = true" in config
regels = {" ".join(regel.split()) for regel in config.splitlines()}
for adres in ("http", "rpc", "serf"):
assert any(regel.startswith(f'{adres} = "<< advertise_ip >>:') for regel in regels)
assert "/var/run/docker.sock" not in compose
assert "privileged:" not in compose
def test_clusterapps_adverteren_het_bereikbare_hostadres():
for app in ("nomad-server", "consul"):
velden = {
item["name"]: item
for groep in _template(app)["variables"]
for item in groep["items"]
}
assert velden["advertise_ip"]["required"] is True
assert velden["advertise_ip"]["default"] == ""
if app == "nomad-server":
bron = (APPS / app / "files" / "server.hcl").read_text()
else:
bron = (APPS / app / "files" / "compose.yaml").read_text()
assert "<< advertise_ip >>" in bron
def test_interne_databasewachtwoorden_worden_automatisch_gemaakt():
for app in ("guacamole", "windmill"):
velden = [item for groep in _template(app)["variables"] for item in groep["items"]]
assert next(item for item in velden if item["name"] == "db_password")["generate"] == "auto"
def test_guacamole_initialiseert_database_voor_de_webapp_start():
compose = (APPS / "guacamole" / "files" / "compose.yaml").read_text()
assert "initdb.sh --postgresql" in compose
assert "condition: service_completed_successfully" in compose
assert "condition: service_healthy" in compose
def test_meshcentral_krijgt_een_bereikbare_https_configuratie():
config = (APPS / "meshcentral" / "files" / "config.json").read_text()
config = json.loads(config.replace("<< hostname >>", "mesh.example.nl"))
assert config["settings"]["cert"] == "mesh.example.nl"
assert config["settings"]["port"] == 443
assert config["domains"][""]["newAccounts"] is True
compose = (APPS / "meshcentral" / "files" / "compose.yaml").read_text()
assert '"<< port >>:443"' in compose

View file

@ -158,3 +158,137 @@ def test_onbekende_service_geeft_fout():
def test_published_ports():
assert ct.published_ports(BASIS) == ["8222:80"]
assert ct.published_ports("services:\n a:\n image: x\n") == []
# ── Eigen mappen en apparaten ────────────────────────────────────────────────
EXTRA_BASIS = """\
services:
app:
image: nginx
volumes:
- /opt/serverup/appdata/app:/config
db:
image: postgres:16-alpine
"""
def test_datamap_wordt_toegevoegd():
"""Een mediamap die ergens anders staat kon alleen via het compose-bestand,
en dat wordt bij de eerstvolgende wijziging overschreven."""
uit = ct.add_extras(EXTRA_BASIS, {"volumes": [
{"service": "app", "host": "/srv/media", "container": "/media"}]})
doc = yaml.safe_load(uit)
assert "/srv/media:/media" in doc["services"]["app"]["volumes"]
# Wat er al stond blijft staan.
assert "/opt/serverup/appdata/app:/config" in doc["services"]["app"]["volumes"]
def test_datamap_alleen_lezen():
uit = ct.add_extras(EXTRA_BASIS, {"volumes": [
{"service": "app", "host": "/srv/foto", "container": "/foto", "ro": True}]})
assert "/srv/foto:/foto:ro" in yaml.safe_load(uit)["services"]["app"]["volumes"]
def test_apparaat_wordt_toegevoegd():
uit = ct.add_extras(EXTRA_BASIS, {"devices": [
{"service": "app", "host": "/dev/ttyUSB0", "container": "/dev/ttyACM0"}]})
assert yaml.safe_load(uit)["services"]["app"]["devices"] == \
["/dev/ttyUSB0:/dev/ttyACM0"]
def test_apparaat_zonder_pad_binnen_de_container():
uit = ct.add_extras(EXTRA_BASIS, {"devices": [
{"service": "app", "host": "/dev/ttyUSB0"}]})
assert yaml.safe_load(uit)["services"]["app"]["devices"] == \
["/dev/ttyUSB0:/dev/ttyUSB0"]
def test_extras_zijn_idempotent():
"""Elke wijziging rendert opnieuw en past dit opnieuw toe; twee mounts op
hetzelfde bronpad laat compose stuklopen."""
extras = {"volumes": [{"service": "app", "host": "/srv/media",
"container": "/media"}]}
een = ct.add_extras(EXTRA_BASIS, extras)
twee = ct.add_extras(een, extras)
volumes = yaml.safe_load(twee)["services"]["app"]["volumes"]
assert volumes.count("/srv/media:/media") == 1
def test_onbekende_service_wordt_overgeslagen():
"""Een hernoemd onderdeel mag geen kapot compose-bestand opleveren."""
uit = ct.add_extras(EXTRA_BASIS, {"volumes": [
{"service": "bestaatniet", "host": "/srv/x", "container": "/x"}]})
assert yaml.safe_load(uit)["services"].keys() == {"app", "db"}
def test_gevaarlijke_mounts_worden_geweigerd():
"""Een container die /etc of de Docker-socket mag lezen, is de host."""
for pad in ("/", "/etc", "/etc/ssh", "/var/run/docker.sock", "/proc/self",
"/boot", "relatief/pad", "/srv/../etc"):
assert ct.mount_bezwaar(pad), pad
for pad in ("/srv/media", "/mnt/schijf/films", "/opt/data"):
assert ct.mount_bezwaar(pad) == "", pad
def test_alleen_echte_apparaten():
for pad in ("/dev", "/srv/media", "/dev/../etc", "ttyUSB0", ""):
assert ct.apparaat_bezwaar(pad), pad
for pad in ("/dev/ttyUSB0", "/dev/serial/by-id/usb-abc", "/dev/dri/renderD128"):
assert ct.apparaat_bezwaar(pad) == "", pad
def test_geweigerd_pad_belandt_niet_in_compose():
"""De controle in de API is de eerste zeef; deze is de laatste."""
uit = ct.add_extras(EXTRA_BASIS, {
"volumes": [{"service": "app", "host": "/var/run/docker.sock",
"container": "/var/run/docker.sock"}],
"devices": [{"service": "app", "host": "/etc/shadow"}]})
doc = yaml.safe_load(uit)
assert "docker.sock" not in str(doc["services"]["app"].get("volumes"))
assert "devices" not in doc["services"]["app"]
def test_zonder_extras_verandert_er_niets():
assert ct.add_extras(EXTRA_BASIS, {}) == EXTRA_BASIS
assert ct.add_extras(EXTRA_BASIS, {"volumes": [], "devices": []}) == EXTRA_BASIS
# ── Versietags ───────────────────────────────────────────────────────────────
TAG_BASIS = """\
services:
app:
image: lscr.io/linuxserver/unifi-network-application:latest
db:
image: registry.local:5000/mongo
var:
image: ${IMAGE}
"""
def test_images_per_service_splitst_repo_en_tag():
per_naam = {i["service"]: i for i in ct.images_per_service(TAG_BASIS)}
assert per_naam["app"]["repo"] == "lscr.io/linuxserver/unifi-network-application"
assert per_naam["app"]["tag"] == "latest"
# De dubbele punt in registry.local:5000 is een poort, geen tag.
assert per_naam["db"]["repo"] == "registry.local:5000/mongo"
assert per_naam["db"]["tag"] == "latest"
# Een image uit een variabele gaat over de .env, niet over dit scherm.
assert "var" not in per_naam
def test_versietag_zetten():
uit = ct.zet_image_tags(TAG_BASIS, {"app": "10.4.57", "db": "7.0"})
doc = yaml.safe_load(uit)
assert doc["services"]["app"]["image"].endswith(":10.4.57")
assert doc["services"]["db"]["image"] == "registry.local:5000/mongo:7.0"
assert doc["services"]["var"]["image"] == "${IMAGE}"
def test_lege_of_onbekende_tag_verandert_niets():
assert ct.zet_image_tags(TAG_BASIS, {}) == TAG_BASIS
assert ct.zet_image_tags(TAG_BASIS, {"app": " "}) == TAG_BASIS
assert ct.zet_image_tags(TAG_BASIS, {"bestaatniet": "1"}) == TAG_BASIS
# Dezelfde tag nog eens zetten laat het bestand ongemoeid.
assert ct.zet_image_tags(TAG_BASIS, {"app": "latest"}) == TAG_BASIS

View file

@ -27,15 +27,40 @@ services:
# ── Gedeeld netwerk ──────────────────────────────────────────────────────────
def test_gedeeld_netwerk_komt_bij_elke_service():
def test_gedeeld_netwerk_komt_bij_de_app_maar_niet_bij_de_database():
"""Zonder gedeeld netwerk zit elke stack in zijn eigen compose-netwerk en
kan geen enkele app een andere op naam bereiken."""
kan geen enkele app een andere op naam bereiken.
De database van een stack heeft daarbuiten niets te zoeken: die stond met
één druk op 'koppelen' open voor elke andere app op de host, en lang niet
elk sjabloon zet er een wachtwoord op.
"""
doc = yaml.safe_load(ct.add_shared_network(BASIS, "serverup"))
for naam, svc in doc["services"].items():
assert "serverup" in svc["networks"], naam
assert "serverup" in doc["services"]["web"]["networks"]
assert "networks" not in doc["services"]["db"]
assert doc["networks"]["serverup"] == {"external": True}
def test_database_kan_er_bewust_wel_bij():
"""Eén database delen tussen twee stacks is een geldige keuze — hij mag
alleen niet stilzwijgend gebeuren."""
doc = yaml.safe_load(ct.add_shared_network(BASIS, "serverup",
services=["web", "db"]))
assert "serverup" in doc["services"]["db"]["networks"]
def test_diensten_worden_herkend():
lijst = {d["name"]: d for d in ct.diensten(BASIS)}
assert lijst["db"]["helper"] is True
assert lijst["web"]["helper"] is False
assert ct.deelbare_diensten(BASIS) == ["web"]
# Een broker delen is juist de reden dat het gedeelde netwerk bestaat.
assert not ct.is_hulpdienst("eclipse-mosquitto:2")
assert not ct.is_hulpdienst("rabbitmq:3-management")
assert ct.is_hulpdienst("docker.io/library/postgres:16-alpine")
assert ct.is_hulpdienst("opensearchproject/opensearch:2")
def test_gedeeld_netwerk_naast_een_eigen_ip():
"""Een stack met een macvlan-adres moet het gedeelde netwerk erbij krijgen,
niet in plaats daarvan."""
@ -302,3 +327,46 @@ def test_store_ziet_geen_installatie_van_een_andere_app(env, tmp_path):
stacks = {s["dir"]: s for s in git._scan_compose_dirs(sjablonen, lib)}
assert stacks["radarr"]["instances"] == []
def test_installeren_koppelt_de_database_niet_mee(client, env, monkeypatch):
"""Het echte pad: installeren met koppeling aan, en dan hoort de database
in zijn eigen netwerk te blijven."""
import json as _json
import time as _time
from core import git
csrf = login(client)
wortel = git.cache_dir("server-up") / "apps" / "metdb"
(wortel / "files").mkdir(parents=True, exist_ok=True)
(wortel / "template.json").write_text(_json.dumps({
"kind": "compose", "metadata": {"name": "metdb"},
"variables": [{"title": "Algemeen", "items": [
{"name": "service_name", "type": "str", "title": "Naam",
"default": "metdb", "required": True}]}]}), encoding="utf-8")
(wortel / "files" / "compose.yaml").write_text(
'services:\n << service_name >>:\n image: nginx\n'
' << service_name >>-db:\n image: postgres:16-alpine\n', encoding="utf-8")
import app as app_module
monkeypatch.setattr(app_module.networks, "ensure_shared", lambda n: (True, ""))
r = client.post("/api/store/install",
json={"stack": "metdb", "repo_id": "server-up",
"instance": "mdb", "connect_apps": True},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
jid = r.get_json()["job_id"]
einde = _time.time() + 10
while _time.time() < einde:
if (client.get(f"/api/jobs/{jid}").get_json() or {}).get("status") in ("done", "error"):
break
_time.sleep(0.05)
compose = next(p for p in (env["lib"] / "mdb").iterdir()
if p.name.startswith(("compose", "docker-compose")))
doc = yaml.safe_load(compose.read_text())
app_svc = next(n for n in doc["services"] if not n.endswith("-db"))
db_svc = next(n for n in doc["services"] if n.endswith("-db"))
assert "serverup" in doc["services"][app_svc]["networks"]
assert "networks" not in doc["services"][db_svc], \
"de database hoort niet op het gedeelde netwerk"

View file

@ -240,3 +240,71 @@ def test_server_up_verwijdert_zichzelf_niet(client, env, monkeypatch):
r = client.post(f"/api/containers/{app_mod.CONTAINER_NAME}/remove",
headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
def test_compose_ps_toont_alleen_werkelijk_gepubliceerde_poorten(host):
container = {
"Ports": "8443/tcp, 8843/tcp",
"Publishers": [
{"TargetPort": 8443, "PublishedPort": 8443, "Protocol": "tcp"},
{"TargetPort": 8443, "PublishedPort": 8443, "Protocol": "tcp"},
{"TargetPort": 8843, "PublishedPort": 0, "Protocol": "tcp"},
],
}
assert host._published_ports(container) == "8443->8443/tcp"
def test_compose_ps_valt_bij_oude_compose_terug_op_ports(host):
assert host._published_ports(
{"Ports": "0.0.0.0:8080->80/tcp"}) == "0.0.0.0:8080->80/tcp"
# ── Eén docker-aanroep voor de hele lijst ────────────────────────────────────
def test_stacklijst_doet_geen_aanroep_per_app(client, env, monkeypatch):
"""De lijst deed een `docker compose ps` per stackmap: bij vijftien apps
vijftien processen per keer verversen, en nog eens zoveel bij elk openen
van de installatiemodal."""
from core import docker
for naam in ("een", "twee", "drie"):
d = env["lib"] / naam
d.mkdir(parents=True, exist_ok=True)
(d / "docker-compose.yml").write_text(f"services:\n {naam}:\n image: nginx\n")
aanroepen = {"ps": 0}
def _lijst(alle=True):
aanroepen["ps"] += 1
return [
{"id": "a", "name": "een-app-1", "image": "nginx", "state": "running",
"status": "Up", "ports": "0.0.0.0:8080->80/tcp", "project": "een",
"working_dir": str(env["lib"] / "een"), "service": "app",
"running": True},
{"id": "b", "name": "twee-app-1", "image": "nginx", "state": "exited",
"status": "Exited (0)", "ports": "", "project": "twee",
"working_dir": str(env["lib"] / "twee"), "service": "app",
"running": False},
]
monkeypatch.setattr(docker, "list_containers", _lijst)
monkeypatch.setattr(docker, "compose_ps", lambda *a, **k: pytest.fail(
"er werd alsnog een aanroep per app gedaan"))
login(client)
per_naam = {s["name"]: s for s in client.get("/api/stacks").get_json()}
assert aanroepen["ps"] == 1, "meer dan één docker-aanroep voor de hele lijst"
assert per_naam["een"]["running"] is True
assert per_naam["een"]["containers"][0]["service"] == "app"
assert per_naam["twee"]["running"] is False
# Een app zonder containers blijft gewoon in de lijst staan.
assert per_naam["drie"]["running"] is False
assert per_naam["drie"]["containers"] == []
def test_containers_worden_op_project_gegroepeerd(host):
per_project = host.containers_per_project()
assert set(per_project) == {"vaultwarden", "forgejo", "server-up"}
assert per_project["vaultwarden"][0]["name"] == "vaultwarden"
# Containers zonder compose-label horen bij geen enkele app.
assert all("act_runner" not in [c["name"] for c in v]
for v in per_project.values())

285
tests/test_dbwachtwoord.py Normal file
View file

@ -0,0 +1,285 @@
"""Een gewijzigd databasewachtwoord moet ook in de database terechtkomen.
`POSTGRES_PASSWORD` en verwanten worden door de officiële images alleen bij een
lege datamap gebruikt. Wie dat veld in de instellingen wijzigde, kreeg een app
met een nieuw wachtwoord en een database met het oude en dus een stack die
niet meer kon inloggen, met een foutmelding die nergens naar het wachtwoord
verwees.
"""
import json
import sys
import time
from pathlib import Path
import pytest
import yaml
WORTEL = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(WORTEL / "server-up"))
from conftest import login
COMPOSE = """\
services:
web:
image: nginx
environment:
- DB_PASS=${DB_PASSWORD}
web-db:
image: postgres:16-alpine
container_name: web-db
environment:
- POSTGRES_USER=app
- POSTGRES_DB=app
- POSTGRES_PASSWORD=${DB_PASSWORD}
"""
def _diensten(env_waarden):
from core import backups
return backups.diensten_uit_compose(COMPOSE, env_waarden)
# ── Herkennen ────────────────────────────────────────────────────────────────
def test_gewijzigd_wachtwoord_wordt_herkend(env):
from core import dbwachtwoord
voor = _diensten({"DB_PASSWORD": "oud"})
na = _diensten({"DB_PASSWORD": "nieuw"})
veranderd = dbwachtwoord.wijzigingen(voor, na)
assert len(veranderd) == 1
assert veranderd[0]["service"] == "web-db"
assert veranderd[0]["oud_wachtwoord"] == "oud"
assert veranderd[0]["wachtwoord"] == "nieuw"
def test_gelijk_wachtwoord_levert_geen_werk_op(env):
from core import dbwachtwoord
zelfde = _diensten({"DB_PASSWORD": "geheim"})
assert dbwachtwoord.wijzigingen(zelfde, zelfde) == []
def test_nieuwe_database_telt_niet_mee(env):
"""Een dienst die er nog niet was heeft geen oud wachtwoord om mee in te
loggen; die krijgt zijn wachtwoord gewoon bij de eerste start."""
from core import dbwachtwoord
assert dbwachtwoord.wijzigingen([], _diensten({"DB_PASSWORD": "x"})) == []
# ── De commando's ────────────────────────────────────────────────────────────
def _cmd(soort, oud="oud", nieuw="nieuw", gebruiker="app"):
from core import dbwachtwoord
dienst = {"soort": soort, "gebruiker": gebruiker, "database": "app",
"container": f"web-{soort}", "wachtwoord": nieuw,
"oud_wachtwoord": oud}
return dbwachtwoord.commando(dienst, nieuw)
def test_postgres_zet_het_wachtwoord_om():
cmd, omgeving = _cmd("postgres", oud="Ou6-oudgeheim", nieuw="Zg3-nieuwgeheim")
assert "ALTER USER app WITH PASSWORD" in omgeving["SU_SQL"]
assert omgeving["PGPASSWORD"] == "Ou6-oudgeheim"
assert "| psql -v ON_ERROR_STOP=1 -U app -d app" in cmd[-1]
# Geen van beide wachtwoorden staat in het commando zelf: argumenten zijn
# op de host zichtbaar in de procestabel.
assert "Zg3-nieuwgeheim" not in cmd[-1]
assert "Ou6-oudgeheim" not in cmd[-1]
def test_postgres_laat_een_backslash_met_rust():
"""Postgres neemt een backslash letterlijk; verdubbelen zou het wachtwoord
veranderen en de app alsnog buitensluiten."""
_, omgeving = _cmd("postgres", nieuw="pas\\woord")
assert omgeving["SU_SQL"] == "ALTER USER app WITH PASSWORD 'pas\\woord';"
_, mysql_omgeving = _cmd("mysql", nieuw="pas\\woord")
assert "pas\\\\woord" in mysql_omgeving["SU_SQL"]
def test_mysql_en_mariadb():
for soort, client in (("mysql", "mysql"), ("mariadb", "mariadb")):
cmd, omgeving = _cmd(soort, oud="Ou6-oudgeheim", nieuw="Zg3-nieuwgeheim")
assert f"| {client} -u app" in cmd[-1]
assert "ALTER USER USER() IDENTIFIED BY" in omgeving["SU_SQL"]
assert omgeving["SU_OUD"] == "Ou6-oudgeheim"
# Via stdin, dus geen wachtwoord in de procestabel van de host.
assert "Zg3-nieuwgeheim" not in cmd[-1]
assert "Ou6-oudgeheim" not in cmd[-1]
def test_mongo_werkt_met_mongo_en_mongosh():
cmd, omgeving = _cmd("mongo", gebruiker="root", oud="Ou6-oud",
nieuw="Zg3-nieuw")
assert "updateUser" in omgeving["SU_JS"]
assert "mongosh" in cmd[-1] and "bin=mongo" in cmd[-1]
assert "Zg3-nieuw" not in cmd[-1] and "Ou6-oud" not in cmd[-1]
def test_aanhalingstekens_breken_het_statement_niet():
"""Een wachtwoord met een quote erin mag geen SQL of JavaScript openbreken."""
_, omgeving = _cmd("mysql", nieuw="pas'woord\\raar")
assert "pas''woord" in omgeving["SU_SQL"]
assert "\\\\raar" in omgeving["SU_SQL"]
_, mongo_omgeving = _cmd("mongo", nieuw='pas"woord\\raar')
assert '\\"woord' in mongo_omgeving["SU_JS"]
def test_onbekend_soort_geeft_een_duidelijke_fout():
from core import dbwachtwoord
with pytest.raises(dbwachtwoord.WachtwoordFout):
dbwachtwoord.commando({"soort": "sqlite", "gebruiker": "",
"oud_wachtwoord": "x", "container": "c"}, "y")
# ── Omzetten in de container ─────────────────────────────────────────────────
def test_gestopte_database_wordt_gemeld(env, monkeypatch):
from core import dbwachtwoord, docker
monkeypatch.setattr(docker, "container_draait", lambda n: False)
ok, msg = dbwachtwoord.omzetten(
{"soort": "postgres", "gebruiker": "app", "database": "app",
"container": "web-db", "oud_wachtwoord": "oud"}, "nieuw")
assert ok is False
assert "draait niet" in msg
def test_wachtwoord_lekt_niet_in_de_foutmelding(env, monkeypatch):
"""Een joblog is zichtbaar voor iedereen die de app mag bedienen."""
from core import dbwachtwoord, docker
class Mislukt:
returncode = 1
stdout = ""
stderr = 'FATAL: password authentication failed voor "supergeheim123"'
monkeypatch.setattr(docker, "container_draait", lambda n: True)
monkeypatch.setattr(docker, "_run", lambda *a, **k: Mislukt())
ok, msg = dbwachtwoord.omzetten(
{"soort": "postgres", "gebruiker": "app", "database": "app",
"container": "web-db", "oud_wachtwoord": "supergeheim123"}, "nieuw")
assert ok is False
assert "supergeheim123" not in msg
assert "***" in msg
# ── De hele weg door de wijziging heen ───────────────────────────────────────
def _sjabloon(client, csrf):
from core import git
wortel = git.cache_dir("server-up") / "apps" / "dbapp"
(wortel / "files").mkdir(parents=True, exist_ok=True)
(wortel / "template.json").write_text(json.dumps({
"kind": "compose", "metadata": {"name": "dbapp"},
"variables": [{"title": "Algemeen", "items": [
{"name": "service_name", "type": "str", "title": "Naam",
"default": "dbapp", "required": True},
{"name": "db_password", "type": "str", "title": "Wachtwoord",
"default": "", "required": True, "secret": True,
"generate": "auto"},
]}]}), encoding="utf-8")
(wortel / "files" / "compose.yaml").write_text(
'services:\n'
' << service_name >>:\n image: nginx\n'
' << service_name >>-db:\n'
' image: postgres:16-alpine\n'
' container_name: << service_name >>-db\n'
' environment:\n'
' - POSTGRES_USER=app\n'
' - POSTGRES_DB=app\n'
' - POSTGRES_PASSWORD=<< db_password >>\n', encoding="utf-8")
r = client.post("/api/store/install",
json={"stack": "dbapp", "repo_id": "server-up",
"instance": "dbapp", "connect_apps": False,
"values": {"service_name": "dbapp",
"db_password": "eerstewachtwoord"}},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
_wacht(client, r.get_json()["job_id"])
def _wacht(client, jid, tijdslimiet=10):
einde = time.time() + tijdslimiet
while time.time() < einde:
d = client.get(f"/api/jobs/{jid}").get_json() or {}
if d.get("status") in ("done", "error"):
return d
time.sleep(0.05)
return {}
def _stub_docker(monkeypatch, app_module, omzetten_lukt=True):
monkeypatch.setattr(app_module.docker, "validate_compose", lambda *a, **k: (True, ""))
monkeypatch.setattr(app_module.docker, "compose_down", lambda *a, **k: 0)
monkeypatch.setattr(app_module.docker, "compose_up", lambda *a, **k: 0)
monkeypatch.setattr(app_module.backups, "create", lambda *a, **k: {"file": "x.tar.gz"})
gedaan = []
def _omzetten(dienst, nieuw, log_fn=None):
gedaan.append((dienst["container"], dienst["oud_wachtwoord"], nieuw))
if omzetten_lukt:
if log_fn:
log_fn("Wachtwoord omgezet")
return True, ""
return False, "Wachtwoord van web-db niet gewijzigd: verbinding geweigerd"
monkeypatch.setattr(app_module.dbwachtwoord, "omzetten", _omzetten)
return gedaan
def test_wijziging_zet_het_wachtwoord_ook_in_de_database(client, env, monkeypatch):
import app as app_module
csrf = login(client)
_sjabloon(client, csrf)
gedaan = _stub_docker(monkeypatch, app_module)
r = client.post("/api/stacks/dbapp/reconfigure",
json={"values": {"service_name": "dbapp",
"db_password": "tweedewachtwoord"},
"connect_apps": False},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
d = _wacht(client, r.get_json()["job_id"])
assert d.get("status") == "done", d.get("lines")
assert gedaan == [("dbapp-db", "eerstewachtwoord", "tweedewachtwoord")]
def test_zonder_wachtwoordwijziging_gebeurt_er_niets_met_de_database(
client, env, monkeypatch):
import app as app_module
csrf = login(client)
_sjabloon(client, csrf)
gedaan = _stub_docker(monkeypatch, app_module)
r = client.post("/api/stacks/dbapp/reconfigure",
json={"values": {"service_name": "dbapp",
"db_password": "eerstewachtwoord"},
"connect_apps": False},
headers={"X-CSRF-Token": csrf})
_wacht(client, r.get_json()["job_id"])
assert gedaan == []
def test_mislukte_omzetting_breekt_de_wijziging_af(client, env, monkeypatch):
"""Een instelling die niet doorgaat is beter dan een app die niet meer bij
zijn gegevens kan."""
import app as app_module
csrf = login(client)
_sjabloon(client, csrf)
_stub_docker(monkeypatch, app_module, omzetten_lukt=False)
hersteld = []
monkeypatch.setattr(app_module.backups, "restore",
lambda *a, **k: hersteld.append(a) or (True, "ok"))
r = client.post("/api/stacks/dbapp/reconfigure",
json={"values": {"service_name": "dbapp",
"db_password": "derdewachtwoord"},
"connect_apps": False},
headers={"X-CSRF-Token": csrf})
d = _wacht(client, r.get_json()["job_id"])
assert d.get("status") == "error"
tekst = "\n".join(x["text"] for x in d.get("lines", []))
assert "niet meer bij zijn database" in tekst
assert hersteld, "de backup hoort teruggezet te worden"

View file

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

View file

@ -339,3 +339,25 @@ def test_voorvertoning_gebruikt_de_ingevulde_waarden(client, env):
# Het geheim hoort al in de .env-voorvertoning te staan, niet in compose.
assert "MijnWachtwoord1" not in d["compose_preview"]
assert "MijnWachtwoord1" in d["env_preview"]
def test_webadres_gebruikt_https_en_de_juiste_containerpoort(client):
import app as app_module
compose = ("services:\n database:\n image: mongo\n"
" ports:\n - \"27017:27017\"\n"
" unifi:\n image: unifi\n"
" ports:\n - \"9443:8443\"\n - \"8080:8080\"\n")
bron = {"web": {"scheme": "https", "port": 8443}}
assert app_module._stack_webadres({}, bron, compose, "server.local") == \
"https://server.local:9443"
assert app_module._stack_webadres({"ip": "192.168.1.50"}, bron, compose,
"server.local") == \
"https://192.168.1.50:8443"
def test_webadres_zonder_metadata_blijft_http(client):
import app as app_module
compose = "services:\n app:\n image: nginx\n ports:\n - \"8123:80\"\n"
assert app_module._stack_webadres({}, {}, compose, "server.local") == \
"http://server.local:8123"

91
tests/test_jobs.py Normal file
View file

@ -0,0 +1,91 @@
"""Het joblog moet een herstart overleven.
Jobs stonden alleen in het geheugen. Juist bij het bijwerken herstart de
container middenin de taak, en dan gaf `/api/jobs/<id>` alleen nog "unknown":
de interface bleef pollen zonder ooit te vertellen hoe het afliep.
"""
import json
import sys
from pathlib import Path
WORTEL = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(WORTEL / "server-up"))
def _job_met_regels(jobs, tag="backup:web", regels=("bezig", "klaar")):
jid, q = jobs.create(tag)
for r in regels:
jobs.log(q, "dim", r)
jobs.stream(jid) # regels uit de queue in de job trekken
return jid
def test_afgeronde_job_blijft_op_schijf_staan(env):
from core import jobs
jid = _job_met_regels(jobs)
jobs.finish(jid, "done")
bestand = Path(env["data"]) / "jobs" / f"{jid}.json"
assert bestand.is_file()
bewaard = json.loads(bestand.read_text())
assert bewaard["status"] == "done"
assert [r["text"] for r in bewaard["lines"]] == ["bezig", "klaar"]
def test_log_blijft_leesbaar_nadat_de_job_uit_het_geheugen_is(env):
"""Na de TTL ruimt Server Up jobs op; het log hoort dan nog te bestaan."""
from core import jobs
jid = _job_met_regels(jobs)
jobs.finish(jid, "done")
jobs._jobs.clear() # zoals _cleanup() na een uur doet
d = jobs.stream(jid)
assert d["status"] == "done"
assert [r["text"] for r in d["lines"]] == ["bezig", "klaar"]
assert jobs.stream(jid, offset=1)["lines"][0]["text"] == "klaar"
def test_onderbroken_job_wordt_als_zodanig_teruggelezen(env):
"""Een taak die liep toen de container omviel mag niet als 'running'
terugkomen: dan blijft de interface eeuwig wachten."""
from core import jobs
# Geen finish(): de job loopt nog. Het pollen van de interface schrijft
# tussentijds weg, en dat is precies wat er van hem overblijft.
jid = _job_met_regels(jobs, regels=("backup wordt gemaakt",))
assert (Path(env["data"]) / "jobs" / f"{jid}.json").is_file()
jobs._jobs.clear() # de herstart
assert jobs.herstel() >= 1
d = jobs.stream(jid)
assert d["status"] == "error"
tekst = " ".join(r["text"] for r in d["lines"])
assert "backup wordt gemaakt" in tekst
assert "herstart" in tekst
def test_onbekende_job_blijft_onbekend(env):
from core import jobs
assert jobs.stream("bestaatniet")["status"] == "unknown"
def test_er_blijven_niet_eindeloos_joblogs_liggen(env):
from core import jobs
for i in range(jobs._BEWAAR + 5):
jobs.finish(_job_met_regels(jobs, tag=f"taak-{i}"), "done")
bestanden = list((Path(env["data"]) / "jobs").glob("*.json"))
assert len(bestanden) <= jobs._BEWAAR
def test_joblog_van_de_api_komt_ook_van_schijf(client, env):
"""De route zelf moet het bewaarde log teruggeven, niet alleen de module."""
from conftest import login
from core import jobs
login(client)
jid = _job_met_regels(jobs, regels=("stap 1",))
jobs.finish(jid, "error")
jobs._jobs.clear()
d = client.get(f"/api/jobs/{jid}").get_json()
assert d["status"] == "error"
assert d["lines"][0]["text"] == "stap 1"

View file

@ -0,0 +1,66 @@
"""Architectuurcontrole voor apps met expliciete platformmetadata."""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
from conftest import login
def _arm_app(env):
from core import git
d = git.cache_dir("server-up") / "apps" / "alleen-arm64"
(d / "files").mkdir(parents=True, exist_ok=True)
(d / "template.json").write_text(json.dumps({
"kind": "compose",
"metadata": {
"name": "Alleen ARM64",
"description": "Testapp",
"tags": ["test"],
"architectures": ["arm64"],
"architecture_note": "Gebruik een 64-bits OS.",
},
"variables": [{"title": "Algemeen", "items": [{
"name": "service_name", "type": "str", "title": "Naam",
"default": "alleen-arm64", "required": True,
}]}],
}), encoding="utf-8")
(d / "files" / "compose.yaml").write_text(
"services:\n << service_name >>:\n image: nginx\n", encoding="utf-8")
return d
def test_preview_meldt_architectuur_mismatch(client, env, monkeypatch):
csrf = login(client)
_arm_app(env)
import app as app_module
monkeypatch.setattr(app_module.boilerplates.platform, "machine", lambda: "armv7l")
d = client.post("/api/store/preview", json={
"stack": "alleen-arm64", "repo_id": "server-up",
}, headers={"X-CSRF-Token": csrf}).get_json()
assert d["ok"] is True
assert d["compatibility"]["architecture"] == "arm/v7"
assert d["compatibility"]["supported"] is False
assert "64-bits OS" in d["compatibility"]["reason"]
def test_installatie_weigert_aantoonbaar_verkeerde_architectuur(
client, env, monkeypatch):
csrf = login(client)
_arm_app(env)
import app as app_module
monkeypatch.setattr(app_module.boilerplates.platform, "machine", lambda: "armv7l")
r = client.post("/api/store/install", json={
"stack": "alleen-arm64",
"repo_id": "server-up",
"instance": "past-niet",
"values": {"service_name": "past-niet"},
}, headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
assert r.get_json()["compatibility"]["supported"] is False
assert not (env["lib"] / "past-niet").exists()

View file

@ -109,6 +109,97 @@ def test_installatie_bewaart_de_gemaakte_keuzes(client, env, monkeypatch):
assert meta["source"] == "keuzeapp"
def _installeer_keuzeapp(client, csrf, poort=9123):
"""Zet een sjabloon in de git-cache en installeer het als stack 'kz'."""
import json as _json
from core import git
wortel = git.cache_dir("server-up") / "apps" / "keuzeapp"
(wortel / "files").mkdir(parents=True, exist_ok=True)
(wortel / "template.json").write_text(_json.dumps({
"kind": "compose", "metadata": {"name": "keuzeapp"},
"variables": [{"title": "Algemeen", "items": [
{"name": "service_name", "type": "str", "title": "Naam",
"default": "keuzeapp", "required": True},
{"name": "port", "type": "int", "title": "Poort", "default": 8099},
]}]}), encoding="utf-8")
(wortel / "files" / "compose.yaml").write_text(
'services:\n << service_name >>:\n image: nginx\n'
' ports:\n - "<< port >>:80"\n', encoding="utf-8")
r = client.post("/api/store/install",
json={"stack": "keuzeapp", "repo_id": "server-up",
"instance": "kz", "values": {"port": poort}},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
_wacht_op_job(client, r.get_json()["job_id"])
def test_geslaagde_wijziging_wordt_niet_teruggedraaid(client, env, monkeypatch):
"""De job draait in een eigen thread, waar `request` niet meer bestaat.
Het adres voor het audit-logboek werd daar alsnog uitgelezen: een
RuntimeError precies nádat de stack met de nieuwe instellingen al draaide.
De foutafhandeling zette vervolgens de backup terug, dus een geslaagde
wijziging werd alsnog ongedaan gemaakt.
"""
import app as app_module
csrf = login(client)
_installeer_keuzeapp(client, csrf)
monkeypatch.setattr(app_module.docker, "validate_compose",
lambda *a, **k: (True, ""))
monkeypatch.setattr(app_module.docker, "compose_down", lambda *a, **k: 0)
monkeypatch.setattr(app_module.docker, "compose_up", lambda *a, **k: 0)
monkeypatch.setattr(app_module.backups, "create",
lambda *a, **k: {"file": "kz_test.tar.gz"})
monkeypatch.setattr(app_module.backups, "restore",
lambda *a, **k: pytest.fail("backup werd teruggezet"))
r = client.post("/api/stacks/kz/reconfigure",
json={"values": {"service_name": "keuzeapp", "port": 9124},
"connect_apps": False},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
d = _wacht_op_job(client, r.get_json()["job_id"])
tekst = "\n".join(r["text"] for r in d.get("lines", []))
assert "RuntimeError" not in tekst, tekst
assert d.get("status") == "done", tekst
compose = next((p for p in (env["lib"] / "kz").iterdir()
if p.name in ("compose.yaml", "compose.yml",
"docker-compose.yml", "docker-compose.yaml")))
assert '"9124:80"' in compose.read_text()
def test_wijziging_wordt_vastgelegd_met_het_adres_van_de_aanvrager(
client, env, monkeypatch):
"""Het IP-adres hoort in het audit-logboek te belanden, niet verloren te
gaan doordat we het pas in de achtergrondthread opvragen."""
import app as app_module
from core import audit
audit.init()
csrf = login(client)
_installeer_keuzeapp(client, csrf)
monkeypatch.setattr(app_module.docker, "validate_compose",
lambda *a, **k: (True, ""))
monkeypatch.setattr(app_module.docker, "compose_down", lambda *a, **k: 0)
monkeypatch.setattr(app_module.docker, "compose_up", lambda *a, **k: 0)
monkeypatch.setattr(app_module.backups, "create",
lambda *a, **k: {"file": "kz_test.tar.gz"})
r = client.post("/api/stacks/kz/reconfigure",
json={"values": {"service_name": "keuzeapp", "port": 9125},
"connect_apps": False},
headers={"X-CSRF-Token": csrf})
_wacht_op_job(client, r.get_json()["job_id"])
regels = [x for x in audit.query(50, src="stacks")
if x["action"] == "reconfigure"]
assert regels and regels[0]["status"] == "ok"
assert regels[0]["ip"]
# ── Koppelen ─────────────────────────────────────────────────────────────────
def test_connect_van_onbekende_stack(client):
@ -199,3 +290,143 @@ def test_audit_endpoint_limiet_wordt_begrensd(client, env, gevuld):
login(client)
d = client.get("/api/audit?limit=99999").get_json()
assert d["limit"] <= 500
# ── Eigen mappen en apparaten ────────────────────────────────────────────────
def _wijzig(client, csrf, extras, poort=9200):
return client.post("/api/stacks/kz/reconfigure",
json={"values": {"service_name": "keuzeapp", "port": poort},
"connect_apps": False, "extras": extras},
headers={"X-CSRF-Token": csrf})
def test_datamap_en_apparaat_belanden_in_de_compose(client, env, monkeypatch):
"""Een mediamap of USB-stick toevoegen kon alleen via 'compose bewerken',
en dat werd bij de eerstvolgende wijziging overschreven."""
import yaml as _yaml
import app as app_module
csrf = login(client)
_installeer_keuzeapp(client, csrf)
monkeypatch.setattr(app_module.docker, "validate_compose", lambda *a, **k: (True, ""))
monkeypatch.setattr(app_module.docker, "compose_down", lambda *a, **k: 0)
monkeypatch.setattr(app_module.docker, "compose_up", lambda *a, **k: 0)
monkeypatch.setattr(app_module.backups, "create", lambda *a, **k: {"file": "x.tar.gz"})
r = _wijzig(client, csrf, {
"volumes": [{"service": "keuzeapp", "host": "/srv/media",
"container": "/media", "ro": True}],
"devices": [{"service": "keuzeapp", "host": "/dev/ttyUSB0"}]})
assert r.status_code == 200, r.get_json()
d = _wacht_op_job(client, r.get_json()["job_id"])
assert d.get("status") == "done", d.get("lines")
compose = next(p for p in (env["lib"] / "kz").iterdir()
if p.name.startswith(("compose", "docker-compose")))
svc = _yaml.safe_load(compose.read_text())["services"]["keuzeapp"]
assert "/srv/media:/media:ro" in svc["volumes"]
assert "/dev/ttyUSB0:/dev/ttyUSB0" in svc["devices"]
# En ze staan in de metadata, dus een volgende wijziging behoudt ze.
meta = json.loads((env["lib"] / "kz" / ".serverup.json").read_text())
assert meta["extras"]["volumes"][0]["host"] == "/srv/media"
def test_extras_overleven_een_volgende_wijziging(client, env, monkeypatch):
"""Het renderen begint elke keer opnieuw uit het sjabloon; zonder bewaarde
extra's zou je map er na één instellingswijziging weer af zijn."""
import yaml as _yaml
import app as app_module
csrf = login(client)
_installeer_keuzeapp(client, csrf)
for naam in ("validate_compose",):
monkeypatch.setattr(app_module.docker, naam, lambda *a, **k: (True, ""))
monkeypatch.setattr(app_module.docker, "compose_down", lambda *a, **k: 0)
monkeypatch.setattr(app_module.docker, "compose_up", lambda *a, **k: 0)
monkeypatch.setattr(app_module.backups, "create", lambda *a, **k: {"file": "x.tar.gz"})
_wacht_op_job(client, _wijzig(client, csrf, {
"volumes": [{"service": "keuzeapp", "host": "/srv/media",
"container": "/media"}]}).get_json()["job_id"])
# Nu een wijziging zonder extras mee te sturen: de bewaarde keuze telt.
r = client.post("/api/stacks/kz/reconfigure",
json={"values": {"service_name": "keuzeapp", "port": 9201},
"connect_apps": False},
headers={"X-CSRF-Token": csrf})
_wacht_op_job(client, r.get_json()["job_id"])
compose = next(p for p in (env["lib"] / "kz").iterdir()
if p.name.startswith(("compose", "docker-compose")))
svc = _yaml.safe_load(compose.read_text())["services"]["keuzeapp"]
assert "/srv/media:/media" in svc["volumes"]
def test_gevaarlijk_pad_wordt_geweigerd_met_uitleg(client, env):
"""Niet stil corrigeren: wie /etc intikt hoort te horen waarom dat niet gaat."""
csrf = login(client)
_installeer_keuzeapp(client, csrf)
r = _wijzig(client, csrf, {
"volumes": [{"service": "keuzeapp", "host": "/var/run/docker.sock",
"container": "/var/run/docker.sock"}]})
assert r.status_code == 400
assert "Docker-socket" in r.get_json()["msg"]
r = _wijzig(client, csrf, {
"devices": [{"service": "keuzeapp", "host": "/etc/shadow"}]})
assert r.status_code == 400
assert "/dev/" in r.get_json()["msg"]
def test_apparatenlijst_is_op_te_vragen(client, env):
login(client)
d = client.get("/api/devices").get_json()
assert "devices" in d and isinstance(d["devices"], list)
for dev in d["devices"]:
assert dev["path"].startswith("/dev/")
# ── Versietags ───────────────────────────────────────────────────────────────
def test_versietag_wordt_vastgehouden_over_wijzigingen_heen(client, env, monkeypatch):
"""Sjablonen staan meestal op `latest`; wie een versie kiest wil niet dat
de eerstvolgende instellingswijziging hem daar weer af haalt."""
import yaml as _yaml
import app as app_module
csrf = login(client)
_installeer_keuzeapp(client, csrf)
monkeypatch.setattr(app_module.docker, "validate_compose", lambda *a, **k: (True, ""))
monkeypatch.setattr(app_module.docker, "compose_down", lambda *a, **k: 0)
monkeypatch.setattr(app_module.docker, "compose_up", lambda *a, **k: 0)
monkeypatch.setattr(app_module.backups, "create", lambda *a, **k: {"file": "x.tar.gz"})
r = client.post("/api/stacks/kz/reconfigure",
json={"values": {"service_name": "keuzeapp", "port": 9300},
"connect_apps": False,
"image_tags": {"keuzeapp": "1.25-alpine"}},
headers={"X-CSRF-Token": csrf})
assert _wacht_op_job(client, r.get_json()["job_id"]).get("status") == "done"
def _image():
compose = next(p for p in (env["lib"] / "kz").iterdir()
if p.name.startswith(("compose", "docker-compose")))
return _yaml.safe_load(compose.read_text())["services"]["keuzeapp"]["image"]
assert _image() == "nginx:1.25-alpine"
# Een volgende wijziging zonder tags: de keuze staat in de metadata en
# hoort te blijven staan, ook al rendert het sjabloon weer `nginx`.
r = client.post("/api/stacks/kz/reconfigure",
json={"values": {"service_name": "keuzeapp", "port": 9301},
"connect_apps": False},
headers={"X-CSRF-Token": csrf})
_wacht_op_job(client, r.get_json()["job_id"])
assert _image() == "nginx:1.25-alpine"
def test_onzinnige_versietag_wordt_genegeerd(client, env, monkeypatch):
"""De tag belandt in het compose-bestand; een waarde met een spatie of een
dubbele punt erin zou dat bestand stukmaken."""
import app as app_module
assert app_module._gekozen_image_tags({"image_tags": {"a": "1.2.3"}}) == {"a": "1.2.3"}
for onzin in ("mijn tag", "tag:met:dubbelepunt", "-begintmetstreepje", "", " "):
assert app_module._gekozen_image_tags({"image_tags": {"a": onzin}}) == {}

View file

@ -280,3 +280,162 @@ def test_verwijderen_haalt_alle_regels_met_die_id_weg(client, env):
assert r.status_code == 200, r.get_json()
over = core.load()["APP_REPOS"]
assert [x.get("id") for x in over] == ["blijft", None], over
# ── Een bestaande repo aanpassen ─────────────────────────────────────────────
def test_branch_van_bestaande_repo_wijzigen(client, env):
"""De id van een repo staat in de metadata van elke stack die eruit komt.
Wie van branch wilde wisselen kon alleen verwijderen en opnieuw toevoegen;
de id kreeg er dan een volgnummer bij en het bronsjabloon van bestaande
apps was ineens onvindbaar.
"""
csrf = login(client)
client.post("/api/repos",
json={"url": "https://example.com/eigen-apps.git", "branch": "main",
"subdir": "apps"},
headers={"X-CSRF-Token": csrf})
r = client.put("/api/repos/eigen-apps", json={"branch": "dev"},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
repo = next(x for x in client.get("/api/repos").get_json()
if x["id"] == "eigen-apps")
assert repo["branch"] == "dev"
# De rest blijft staan; alleen wat je meestuurt verandert.
assert repo["subdir"] == "apps"
assert repo["url"] == "https://example.com/eigen-apps.git"
def test_bewerken_van_onbekende_repo(client, env):
csrf = login(client)
r = client.put("/api/repos/bestaatniet", json={"branch": "dev"},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 404
def test_bewerken_wist_een_bestaand_token_niet(client, env):
"""De interface krijgt het token nooit te zien, dus stuurt hij het leeg
terug. Dat mag geen repo zonder toegang opleveren."""
csrf = login(client)
client.post("/api/repos",
json={"url": "https://example.com/prive.git", "token": "s3cr3t-token"},
headers={"X-CSRF-Token": csrf})
r = client.put("/api/repos/prive", json={"branch": "dev", "token": ""},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200
assert "s3cr3t-token" not in r.get_data(as_text=True)
from core import config as _cfg # noqa: F401
import core
repo = next(x for x in core.load()["APP_REPOS"] if x["id"] == "prive")
assert repo["token"] == "s3cr3t-token"
assert repo["branch"] == "dev"
def test_bewerken_weigert_een_ongeldige_url(client, env):
csrf = login(client)
client.post("/api/repos", json={"url": "https://example.com/proef.git"},
headers={"X-CSRF-Token": csrf})
r = client.put("/api/repos/proef", json={"url": 'ext::sh -c "id"'},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
def test_viewer_mag_geen_repo_bewerken(client, env):
csrf = login(client)
client.post("/api/repos", json={"url": "https://example.com/proef.git"},
headers={"X-CSRF-Token": csrf})
client.post("/api/auth/users",
json={"username": "kijker", "password": "eenlangwachtwoord",
"role": "viewer"}, headers={"X-CSRF-Token": csrf})
kijker = client.post("/api/auth/login",
json={"username": "kijker", "password": "eenlangwachtwoord"}
).get_json()["csrf_token"]
assert client.put("/api/repos/proef", json={"branch": "dev"},
headers={"X-CSRF-Token": kijker}).status_code == 403
# ── Herkomst van een sjabloon ────────────────────────────────────────────────
def test_repolijst_toont_de_stand_van_de_cache(client, env, tmp_path, monkeypatch):
"""Sjablonen komen uit de git-cache, niet uit het image. Loopt die achter,
dan rendert een app een oude versie terwijl de code nieuw is en daar was
aan niets te zien."""
import shutil as _shutil
from core import git
if not _shutil.which("git"):
pytest.skip("git niet aanwezig")
csrf = login(client)
bron = _repo_met_twee_takken(tmp_path)
(git.cache_dir("proef")).parent.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr("core.valid_repo_url", lambda u: True)
client.post("/api/repos", json={"url": f"file://{bron}", "id": "proef",
"branch": "main"},
headers={"X-CSRF-Token": csrf})
ok, msg = git.clone_or_pull({"id": "proef", "url": f"file://{bron}",
"branch": "main"})
assert ok, msg
repo = next(r for r in client.get("/api/repos").get_json() if r["id"] == "proef")
assert repo["cache"]["cloned"] is True
assert repo["cache"]["commit"]
assert repo["cache"]["synced_at"] > 0
def test_achterlopende_repo_wordt_gemeld(client, env, tmp_path, monkeypatch):
"""Precies het geval dat drie releases lang onzichtbaar bleef: de repo staat
op een oudere versie dan Server Up zelf."""
import shutil as _shutil
from core import git
if not _shutil.which("git"):
pytest.skip("git niet aanwezig")
csrf = login(client)
bron = _repo_met_twee_takken(tmp_path)
(bron / "VERSION").write_text("0.0.01-beta\n")
import subprocess as sp
sp.run(["git", "add", "-A"], cwd=bron, check=True, capture_output=True)
sp.run(["git", "commit", "-qm", "versie"], cwd=bron, check=True,
capture_output=True)
monkeypatch.setattr("core.valid_repo_url", lambda u: True)
client.post("/api/repos", json={"url": f"file://{bron}", "id": "oud",
"branch": "main"},
headers={"X-CSRF-Token": csrf})
git.clone_or_pull({"id": "oud", "url": f"file://{bron}", "branch": "main"})
repo = next(r for r in client.get("/api/repos").get_json() if r["id"] == "oud")
assert repo["cache"]["version"] == "0.0.01-beta"
assert repo["cache"]["outdated"] is True
def test_repo_zonder_versiebestand_heet_niet_verouderd(env):
import app as app_module
assert app_module._cache_loopt_achter("") is False
assert app_module._cache_loopt_achter("geen-versie") is False
assert app_module._cache_loopt_achter("99.9.99") is False
def test_wijzigscherm_kent_de_herkomst_van_het_sjabloon(client, env):
"""Het scherm waar je op opslaan drukt hoort te zeggen waaruit hij rendert."""
import json as _json
csrf = 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\n")
(d / ".serverup.json").write_text(_json.dumps({
"source": "keuzeapp", "repo_id": "server-up", "values": {"port": 80}}))
from core import git
wortel = git.cache_dir("server-up") / "apps" / "keuzeapp"
(wortel / "files").mkdir(parents=True, exist_ok=True)
(wortel / "template.json").write_text(_json.dumps({
"kind": "compose", "metadata": {"name": "keuzeapp"}, "variables": []}))
(wortel / "files" / "compose.yaml").write_text("services:\n web:\n image: nginx\n")
d2 = client.get("/api/stacks/web/config").get_json()
assert d2["ok"] is True
assert d2["source_repo"]["id"] == "server-up"
assert d2["source_repo"]["branch"]

View file

@ -106,11 +106,25 @@ def test_pull_weigert_ongeldige_tag(su, monkeypatch, tag):
assert "Ongeldige tag" in msg
def test_pull_stript_de_v_van_een_forgejo_release(su, monkeypatch):
"""De workflow pusht :0.8.13-beta, niet :v0.8.13-beta."""
_fake_inspect(su, monkeypatch)
getrokken = []
monkeypatch.setattr(su, "_login", lambda log_fn=None: (True, ""))
monkeypatch.setattr(
su.docker, "_stream",
lambda cmd, **kw: getrokken.append(cmd[-1]) or 0,
)
ok, image = su.pull("v0.8.13-beta")
assert ok is True
assert image == "git.example.com/bes-r/server-up:0.8.13-beta"
assert getrokken == [image]
# ── SU_TAG wegschrijven ──────────────────────────────────────────────────────
# Dit gebeurde eerder in Python, hier in de container. Maar de installatiemap is
# hier nergens gemount — er zijn maar drie mounts: de docker-socket, su-data en
# BASE_DIR. Bijwerken vanuit de interface kon daardoor nooit werken. Het gebeurt
# nu in de helper, die de werkmap wél mount, dus toetsen we het shellfragment.
# De draaiende container kan de deploy-.env niet schrijven: de installatiemap
# is daar niet gemount. De helper mount de werkmap wel en houdt tag en versie
# bij elkaar, dus toetsen we het shellfragment rechtstreeks.
def _draai_tagscript(su, map_: Path, tag: str):
import subprocess
@ -121,14 +135,19 @@ def _draai_tagscript(su, map_: Path, tag: str):
def test_tagscript_maakt_env_aan(su, tmp_path):
assert _draai_tagscript(su, tmp_path, "0.5.10").strip() == "SU_TAG=0.5.10"
assert _draai_tagscript(su, tmp_path, "0.5.10").splitlines() == [
"SU_TAG=0.5.10", "SU_VERSION=0.5.10",
]
def test_tagscript_vervangt_bestaande_regel(su, tmp_path):
(tmp_path / ".env").write_text("BIND=0.0.0.0\nSU_TAG=0.5.00\nPORT=5000\n")
(tmp_path / ".env").write_text(
"BIND=0.0.0.0\nSU_TAG=0.5.00\nSU_VERSION=0.5.00\nPORT=5000\n")
regels = _draai_tagscript(su, tmp_path, "0.5.10").splitlines()
assert regels == ["BIND=0.0.0.0", "PORT=5000", "SU_TAG=0.5.10"]
assert regels == ["BIND=0.0.0.0", "PORT=5000", "SU_TAG=0.5.10",
"SU_VERSION=0.5.10"]
assert sum(1 for r in regels if r.startswith("SU_TAG=")) == 1
assert sum(1 for r in regels if r.startswith("SU_VERSION=")) == 1
def test_tagscript_behoudt_andere_instellingen(su, tmp_path):
@ -136,6 +155,7 @@ def test_tagscript_behoudt_andere_instellingen(su, tmp_path):
inhoud = _draai_tagscript(su, tmp_path, "0.5.10")
assert "SU_IMAGE=git.example.com/bes-r/server-up" in inhoud
assert "SU_TAG=0.5.10" in inhoud
assert "SU_VERSION=0.5.10" in inhoud
def test_tagscript_laat_zich_niet_uitbreiden(su, tmp_path):
@ -144,11 +164,11 @@ def test_tagscript_laat_zich_niet_uitbreiden(su, tmp_path):
inhoud = _draai_tagscript(su, tmp_path, "1.0.0'; touch /tmp/su-inbraak; echo '")
assert not Path("/tmp/su-inbraak").exists(), "de tag brak uit het commando"
assert inhoud.count("SU_TAG=") == 1
assert inhoud.count("SU_VERSION=") == 1
def test_de_helper_zet_de_tag_en_mount_de_werkmap(su, monkeypatch):
"""Zonder de mount schrijft de helper in zijn eigen overlay, en zonder het
tag-fragment start compose gewoon het oude image opnieuw."""
"""De helper gebruikt dezelfde projectnaam en bestanden als de container."""
aanroepen = []
def nep_run(cmd, **kw):
@ -156,14 +176,82 @@ def test_de_helper_zet_de_tag_en_mount_de_werkmap(su, monkeypatch):
return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
monkeypatch.setattr(su.docker, "_run", nep_run)
su._spawn_helper("git.example.com/bes-r/server-up:0.5.10",
"/opt/server-up", "server-up", "0.5.10")
su._spawn_helper(
"git.example.com/bes-r/server-up:0.5.10",
"/opt/server-up", "server-up", "0.5.10",
project="eigen-project",
config_files="/opt/server-up/compose.yml,/opt/server-up/extra.yml",
)
# De eerste aanroep ruimt een blijven hangen helper op; de tweede is de start.
cmd = next(c for c in aanroepen if "run" in c)
assert "-v" in cmd and "/opt/server-up:/opt/server-up" in cmd
script = cmd[-1]
assert "SU_TAG=0.5.10" in script, "de helper zet de tag niet"
assert "docker compose up -d" in script
assert "SU_VERSION=0.5.10" in script, "de helper zet de versie niet"
assert "--project-directory /opt/server-up" in script
assert "--project-name eigen-project" in script
assert "--file /opt/server-up/compose.yml" in script
assert "--file /opt/server-up/extra.yml" in script
assert "up -d --remove-orphans --no-build --pull never server-up" in script
def test_apply_endpoint_normaliseert_release_tag(client, monkeypatch):
"""Ook een oudere UI mag :v0.8.13 nooit aan Docker doorgeven."""
from core import jobs, selfupdate
csrf = login(client)
gezien = []
monkeypatch.setattr(selfupdate, "status", lambda: {
"can_apply": True, "reason": "", "previous_tag": "",
})
monkeypatch.setattr(
selfupdate, "apply",
lambda tag, log_fn=None: (gezien.append(tag) or True, "gestart"),
)
monkeypatch.setattr(jobs, "run", lambda fn, *args: fn(*args))
r = client.post("/api/update/apply", json={"tag": "v0.8.13-beta"},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200
assert r.get_json()["tag"] == "0.8.13-beta"
assert gezien == ["0.8.13-beta"]
def test_apply_onthoudt_versie_in_plaats_van_beweeglijke_alias(
su, env, monkeypatch):
_fake_inspect(su, monkeypatch, image="git.example.com/bes-r/server-up:beta",
version="0.8.12-beta")
monkeypatch.setattr(
su, "pull",
lambda tag, log_fn=None: (
True, "git.example.com/bes-r/server-up:0.8.13-beta"),
)
monkeypatch.setattr(su, "_spawn_helper", lambda *args, **kw: (True, "ok"))
ok, _ = su.apply("v0.8.13-beta")
assert ok is True
opgeslagen = env["core"].load()
assert opgeslagen["UPDATE_PREVIOUS_TAG"] == "0.8.12-beta"
assert opgeslagen["UPDATE_LAST_APPLIED"]["tag"] == "0.8.13-beta"
@pytest.mark.parametrize("workflow", [
".forgejo/workflows/deploy.yml",
".forgejo/workflows/deploy-prod.yml",
])
def test_deploy_schrijft_tag_en_versie_samen(workflow):
tekst = (Path(__file__).resolve().parent.parent / workflow).read_text()
assert "'/^SU_TAG=/d; /^SU_VERSION=/d'" in tekst
assert "SU_TAG=%s\\nSU_VERSION=%s\\n" in tekst
@pytest.mark.parametrize("workflow,pushregel", [
(".forgejo/workflows/build.yml", 'docker push "${IMAGE}:v${VERSION}"'),
(".forgejo/workflows/deploy-prod.yml",
'docker push "${SU_IMAGE}:v${VERSION}"'),
])
def test_release_publiceert_tag_voor_oude_updater(workflow, pushregel):
tekst = (Path(__file__).resolve().parent.parent / workflow).read_text()
assert pushregel in tekst
def test_rollback_zonder_vorige_versie(su):

View file

@ -117,3 +117,41 @@ def test_stack_meldt_wat_er_niet_meer_draait(client, env, monkeypatch):
(mq / ".serverup.json").write_text(_json.dumps({"source": "mosquitto"}))
st = {s["name"]: s for s in client.get("/api/stacks").get_json()}
assert st["z2m"]["depends_missing"] == []
def test_stacklijst_geeft_het_webadres_van_de_app(client, env):
"""Het adres stond alleen in de modal met inloggegevens; op de kaart hoort
een knop te staan die de app opent dus moet de lijst het adres kennen."""
import json
d = env["lib"] / "unifi-network"
d.mkdir(parents=True, exist_ok=True)
(d / "docker-compose.yml").write_text(
"services:\n"
" unifi-mongo:\n"
" image: mongo:4.4.29\n"
" unifi:\n"
" image: lscr.io/linuxserver/unifi-network-application:latest\n"
" ports:\n"
' - "8443:8443"\n'
' - "8080:8080"\n')
(d / ".serverup.json").write_text(json.dumps({
"source": "unifi-network", "values": {"port_ui": 8443},
"web": {"scheme": "https", "port": 8443}}))
login(client)
per_naam = {s["name"]: s for s in client.get("/api/stacks").get_json()}
assert per_naam["unifi-network"]["web_url"].startswith("https://")
assert per_naam["unifi-network"]["web_url"].endswith(":8443")
def test_stack_zonder_gepubliceerde_poort_heeft_geen_webadres(client, env):
import json
d = env["lib"] / "worker"
d.mkdir(parents=True, exist_ok=True)
(d / "docker-compose.yml").write_text(
"services:\n worker:\n image: busybox\n")
(d / ".serverup.json").write_text(json.dumps({"source": "worker"}))
login(client)
per_naam = {s["name"]: s for s in client.get("/api/stacks").get_json()}
assert per_naam["worker"]["web_url"] == ""

View file

@ -0,0 +1,91 @@
"""Apps verwijderen: veilige dataretentie, volledige wisactie en symlinks."""
import time
from conftest import login
def _wacht(client, jid, timeout=5):
einde = time.time() + timeout
while time.time() < einde:
data = client.get(f"/api/jobs/{jid}").get_json() or {}
if data.get("status") in ("done", "error"):
return data
time.sleep(0.03)
return {}
def _stack(env, naam):
pad = env["lib"] / naam
pad.mkdir()
(pad / "compose.yaml").write_text(
"services:\n web:\n image: nginx\n", encoding="utf-8")
data = env["tmp"] / "appdata" / naam
data.mkdir(parents=True)
(data / "blijft.txt").write_text("data", encoding="utf-8")
return pad, data
def test_app_verwijderen_haalt_app_weg_en_bewaart_data(
client, env, monkeypatch):
from core import docker
csrf = login(client)
env["core"].patch({"BACKUP_BEFORE_REMOVE": False})
stack, data = _stack(env, "veilig")
volumes = []
monkeypatch.setattr(
docker, "compose_down",
lambda *a, **kw: volumes.append(kw.get("volumes")) or 0)
r = client.post("/api/stacks/veilig/remove", json={"mode": "full"},
headers={"X-CSRF-Token": csrf})
job = _wacht(client, r.get_json()["job_id"])
assert job["status"] == "done"
assert not stack.exists()
assert data.is_dir()
assert volumes == [False]
def test_app_en_data_verwijderen_wist_ook_volumes_en_appdata(
client, env, monkeypatch):
from core import docker
csrf = login(client)
env["core"].patch({"BACKUP_BEFORE_REMOVE": False})
stack, data = _stack(env, "alles")
volumes = []
monkeypatch.setattr(
docker, "compose_down",
lambda *a, **kw: volumes.append(kw.get("volumes")) or 0)
r = client.post("/api/stacks/alles/remove", json={"mode": "all"},
headers={"X-CSRF-Token": csrf})
job = _wacht(client, r.get_json()["job_id"])
assert job["status"] == "done"
assert not stack.exists()
assert not data.exists()
assert volumes == [True]
def test_overgenomen_app_verwijderen_haalt_alleen_symlink_weg(
client, env, monkeypatch):
from core import docker
csrf = login(client)
env["core"].patch({"BACKUP_BEFORE_REMOVE": False})
bron = env["tmp"] / "eigen-compose"
bron.mkdir()
(bron / "compose.yaml").write_text(
"services:\n web:\n image: nginx\n", encoding="utf-8")
link = env["lib"] / "overgenomen"
link.symlink_to(bron)
monkeypatch.setattr(docker, "compose_down", lambda *a, **kw: 0)
r = client.post("/api/stacks/overgenomen/remove",
json={"mode": "full"},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
job = _wacht(client, r.get_json()["job_id"])
assert job["status"] == "done"
assert not link.exists() and not link.is_symlink()
assert (bron / "compose.yaml").is_file()

140
tests/test_stackslot.py Normal file
View file

@ -0,0 +1,140 @@
"""Eén taak tegelijk per app.
Zonder vergrendeling kan de nachtelijke backup starten terwijl een
instellingswijziging net `compose down` heeft gedaan en de bestanden
herschrijft. Het archief bevat dan een halve stack, terwijl de melding erna
'Backup klaar' is.
"""
import sys
import threading
import time
from pathlib import Path
WORTEL = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(WORTEL / "server-up"))
def test_tweede_taak_wacht_op_de_eerste(env):
from core import jobs
volgorde = []
eerste_bezig = threading.Event()
laat_los = threading.Event()
def taak_een(jid):
volgorde.append("een-start")
eerste_bezig.set()
laat_los.wait(5)
volgorde.append("een-klaar")
jobs.finish(jid, "done")
jobs.done(jobs.get_queue(jid))
def taak_twee(jid):
volgorde.append("twee-start")
jobs.finish(jid, "done")
jobs.done(jobs.get_queue(jid))
jid1, _ = jobs.create("update:web")
jid2, _ = jobs.create("backup:web")
jobs.run(jobs.met_slot("web", taak_een), jid1)
assert eerste_bezig.wait(5)
jobs.run(jobs.met_slot("web", taak_twee), jid2)
time.sleep(0.2)
assert volgorde == ["een-start"], "de tweede taak begon terwijl de eerste liep"
laat_los.set()
einde = time.time() + 5
while time.time() < einde and "twee-start" not in volgorde:
time.sleep(0.02)
assert volgorde == ["een-start", "een-klaar", "twee-start"]
# En de wachtende taak meldt dat ook in zijn eigen log.
tekst = " ".join(r["text"] for r in jobs.stream(jid2)["lines"])
assert "wacht" in tekst
def test_andere_app_hoeft_niet_te_wachten(env):
"""Twee verschillende apps mogen wél tegelijk."""
from core import jobs
bezig = threading.Event()
los = threading.Event()
gedraaid = []
def blokkeer(jid):
bezig.set()
los.wait(5)
jobs.finish(jid, "done")
jobs.done(jobs.get_queue(jid))
def snel(jid):
gedraaid.append(True)
jobs.finish(jid, "done")
jobs.done(jobs.get_queue(jid))
jid1, _ = jobs.create("update:web")
jid2, _ = jobs.create("update:andere")
jobs.run(jobs.met_slot("web", blokkeer), jid1)
assert bezig.wait(5)
jobs.run(jobs.met_slot("andere", snel), jid2)
einde = time.time() + 5
while time.time() < einde and not gedraaid:
time.sleep(0.02)
assert gedraaid, "een taak op een andere app werd onnodig geblokkeerd"
los.set()
def test_slot_komt_vrij_na_een_fout(env):
"""Een taak die klapt mag de app niet voor altijd op slot zetten, en mag
ook niet eeuwig op 'running' blijven staan met een interface die erop
blijft pollen."""
from core import jobs
def klapt(jid):
raise RuntimeError("boem")
jid, _ = jobs.create("update:web")
t = threading.Thread(target=jobs.met_slot("web", klapt), args=(jid,))
t.start(); t.join(5)
with jobs.stack_slot("web") as vrij:
assert vrij, "het slot bleef hangen na een mislukte taak"
d = jobs.stream(jid)
assert d["status"] == "error"
assert "boem" in " ".join(r["text"] for r in d["lines"])
def test_geplande_backup_slaat_een_bezette_app_over(env, monkeypatch):
"""Wachten heeft geen zin als er een lange update loopt; dan is overslaan
met een melding beter dan een archief van een halve stack."""
import app as app_module
from core import jobs
(env["lib"] / "web").mkdir(parents=True, exist_ok=True)
(env["lib"] / "web" / "docker-compose.yml").write_text(
"services:\n web:\n image: nginx\n")
env["core"].patch({"BACKUP_SCHEDULE": "daily"})
# Het tijdvenster zelf is elders getest; hier gaat het om de vergrendeling.
monkeypatch.setattr(app_module.scheduler, "binnen_venster",
lambda *a, **k: True)
gemaakt = []
monkeypatch.setattr(app_module.backups, "create",
lambda *a, **k: gemaakt.append(a) or {"file": "x.tar.gz"})
# Niet twee minuten wachten in een test; het gaat om de keuze die daarna
# gemaakt wordt. De originele functie eerst vastleggen, anders roept de
# vervanger zichzelf aan.
origineel = jobs.stack_slot
monkeypatch.setattr(app_module.jobs, "stack_slot",
lambda naam, wacht=0.0: origineel(naam))
slot = jobs._stackslot("web")
slot.acquire()
try:
app_module._geplande_backups()
finally:
slot.release()
assert gemaakt == [], "er werd een backup gemaakt van een bezette app"
# Zonder slot loopt hij gewoon.
app_module._geplande_backups()
assert gemaakt, "zonder bezetting hoort de backup gewoon te draaien"

View file

@ -21,6 +21,17 @@ def test_invulmenu_logica():
assert r.returncode == 0, r.stdout + r.stderr
@pytest.mark.skipif(not shutil.which("node"), reason="node ontbreekt")
def test_joblog_vraagt_niet_twee_keer_tegelijk():
"""Elke 400 ms een nieuwe poll starten terwijl de vorige nog loopt, laat
meerdere verzoeken met dezelfde offset vertrekken. Bij een trage stap
een backup terugzetten bijvoorbeeld kwam hetzelfde blok logregels dan
twee tot vijf keer in beeld."""
r = subprocess.run(["node", str(WORTEL / "tests" / "js" / "joblog.test.js")],
capture_output=True, text=True, timeout=60, cwd=str(WORTEL))
assert r.returncode == 0, r.stdout + r.stderr
@pytest.mark.skipif(not shutil.which("node"), reason="node ontbreekt")
def test_installatiemodal_in_een_echte_dom():
"""Rendert de modal met Alpine en klikt op Volgende.
@ -93,3 +104,12 @@ def test_de_html_van_de_pagina_is_gebalanceerd():
.read_text(encoding="utf-8"))
assert not p.fouten, p.fouten[:5]
assert not p.stapel, [f"<{t}> op regel {r} gaat nooit dicht" for t, r in p.stapel[:5]]
def test_modals_blijven_binnen_het_mobiele_scherm_en_scrollbaar():
html = (WORTEL / "server-up" / "templates" / "index.html").read_text(
encoding="utf-8")
assert "height: 100dvh" in html
assert "max-height: 100dvh" in html
assert ".modal-body" in html and "overflow-y-auto" in html
assert "-webkit-overflow-scrolling: touch" in html

View file

@ -27,6 +27,8 @@ def test_stable_negeert_prereleases(upd):
res = upd.check("0.4.60", force=True)
assert res["channel"] == "stable"
assert res["latest"] == "0.5.00"
assert res["latest_tag"] == "v0.5.00"
assert res["image_tag"] == "0.5.00"
assert res["update_available"] is True
@ -35,6 +37,7 @@ def test_beta_ziet_de_prerelease(upd, env):
res = upd.check("0.5.00", force=True)
assert res["channel"] == "beta"
assert res["latest"] == "0.5.10-beta1"
assert res["image_tag"] == "0.5.10-beta1"
assert res["update_available"] is True
assert res["prerelease"] is True
@ -107,6 +110,18 @@ def test_is_prerelease():
assert updater.is_prerelease("0.5.00") is False
@pytest.mark.parametrize("release_tag,image_tag", [
("v0.8.13", "0.8.13"),
("v0.8.13-beta", "0.8.13-beta"),
("0.8.13", "0.8.13"),
("beta", "beta"),
])
def test_registry_tag_past_bij_de_workflow(release_tag, image_tag):
"""Forgejo-tags beginnen met v; de registry-workflow stript die v."""
from core import updater
assert updater.image_tag(release_tag) == image_tag
def test_ongeldig_kanaal_wordt_geweigerd(client):
from conftest import login
csrf = login(client)

18
tests/test_wizard.py Normal file
View file

@ -0,0 +1,18 @@
"""De startwizard blijft gesloten nadat de gebruiker hem heeft afgerond."""
from conftest import login
def test_voltooide_wizard_blijft_voltooid_als_library_niet_bestaat(
client, env):
login(client)
ontbrekend = env["tmp"] / "nog-niet-gemounte-library"
env["core"].patch({
"LIBRARY_DIR": str(ontbrekend),
"WIZARD_DONE": True,
})
antwoord = client.get("/api/wizard")
assert antwoord.status_code == 200
assert antwoord.get_json()["done"] is True