Compare commits
No commits in common. "850a80f86b8144afcd5e8d54d498bdeacc19469d" and "331475415a16e464dc97471952700cecf5a1911c" have entirely different histories.
850a80f86b
...
331475415a
90 changed files with 226 additions and 5413 deletions
|
|
@ -23,10 +23,6 @@ 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.
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ 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"
|
||||
|
|
@ -132,7 +131,6 @@ jobs:
|
|||
--pull \
|
||||
--build-arg "SU_VERSION=${VERSION}" \
|
||||
-t "${IMAGE}:${VERSION}" \
|
||||
-t "${IMAGE}:v${VERSION}" \
|
||||
-t "${IMAGE}:${EXTRA_TAG}" \
|
||||
.
|
||||
|
||||
|
|
@ -140,11 +138,8 @@ 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}, :v${VERSION} en :${EXTRA_TAG}"
|
||||
echo "Gepusht: ${IMAGE}:${VERSION} en ${IMAGE}:${EXTRA_TAG}"
|
||||
|
||||
- name: Uitloggen
|
||||
if: always()
|
||||
|
|
|
|||
|
|
@ -146,8 +146,8 @@ jobs:
|
|||
|
||||
if [ -w .env ] || { [ ! -e .env ] && [ -w . ]; }; then
|
||||
touch .env
|
||||
sed -i '/^SU_TAG=/d; /^SU_VERSION=/d' .env
|
||||
printf 'SU_TAG=%s\nSU_VERSION=%s\n' "${VERSION}" "${VERSION}" >> .env
|
||||
sed -i '/^SU_TAG=/d' .env
|
||||
echo "SU_TAG=${VERSION}" >> .env
|
||||
echo "Inhoud van $(pwd)/.env:"
|
||||
grep -vE '^\s*(#|$)' .env | sed 's/^/ /'
|
||||
else
|
||||
|
|
@ -172,11 +172,6 @@ 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"
|
||||
|
|
|
|||
|
|
@ -149,8 +149,8 @@ jobs:
|
|||
|
||||
if [ -w .env ] || { [ ! -e .env ] && [ -w . ]; }; then
|
||||
touch .env
|
||||
sed -i '/^SU_TAG=/d; /^SU_VERSION=/d' .env
|
||||
printf 'SU_TAG=%s\nSU_VERSION=%s\n' "${VERSION}" "${VERSION}" >> .env
|
||||
sed -i '/^SU_TAG=/d' .env
|
||||
echo "SU_TAG=${VERSION}" >> .env
|
||||
echo "Inhoud van $(pwd)/.env:"
|
||||
grep -vE '^\s*(#|$)' .env | sed 's/^/ /'
|
||||
else
|
||||
|
|
|
|||
11
AGENTS.md
11
AGENTS.md
|
|
@ -1,11 +0,0 @@
|
|||
# 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
|
||||
345
CHANGELOG.md
345
CHANGELOG.md
|
|
@ -1,348 +1,3 @@
|
|||
# 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
13
CLAUDE.md
|
|
@ -1,13 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -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_JOBS=/data/jobs \
|
||||
SU_SECRET=/data/secret.key \
|
||||
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
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.8.28
|
||||
0.8.12-beta
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
{
|
||||
"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."}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -18,11 +18,7 @@
|
|||
"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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -17,11 +17,7 @@
|
|||
},
|
||||
"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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
{
|
||||
"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}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -18,11 +18,7 @@
|
|||
"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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -17,11 +17,7 @@
|
|||
},
|
||||
"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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -18,11 +18,7 @@
|
|||
"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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,58 +0,0 @@
|
|||
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:
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
{
|
||||
"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"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
{
|
||||
"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}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -18,11 +18,7 @@
|
|||
"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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"$schema": "https://raw.githubusercontent.com/Ylianst/MeshCentral/master/meshcentral-config-schema.json",
|
||||
"settings": {
|
||||
"cert": "<< hostname >>",
|
||||
"port": 443,
|
||||
"WANonly": true
|
||||
},
|
||||
"domains": {
|
||||
"": {
|
||||
"title": "MeshCentral",
|
||||
"newAccounts": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
{
|
||||
"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}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
{
|
||||
"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}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
{
|
||||
"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"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
{
|
||||
"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."}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -18,11 +18,7 @@
|
|||
"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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -18,11 +18,7 @@
|
|||
"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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"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}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -18,11 +18,7 @@
|
|||
"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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -18,11 +18,7 @@
|
|||
"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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ services:
|
|||
- MONGO_HOST=<< service_name >>-mongo
|
||||
- MONGO_PORT=27017
|
||||
- MONGO_DBNAME=unifi
|
||||
- MONGO_AUTHSOURCE=admin
|
||||
volumes:
|
||||
- << appdata_dir >>/<< service_name >>/config:/config
|
||||
ports:
|
||||
|
|
@ -20,64 +19,19 @@ services:
|
|||
- "<< port_stun >>:3478/udp"
|
||||
- "<< port_discovery >>:10001/udp"
|
||||
depends_on:
|
||||
<< service_name >>-mongo:
|
||||
condition: service_healthy
|
||||
<< service_name >>-mongo-init:
|
||||
condition: service_completed_successfully
|
||||
- << service_name >>-mongo
|
||||
restart: unless-stopped
|
||||
|
||||
<< service_name >>-mongo:
|
||||
image: mongo:4.4.29
|
||||
image: mongo:4.4
|
||||
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
|
||||
- MONGO_INITDB_DATABASE=unifi
|
||||
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_USER=unifi
|
||||
- MONGO_PASS=<< mongo_password >>
|
||||
- MONGO_HOST=<< service_name >>-mongo
|
||||
- MONGO_PORT=27017
|
||||
- MONGO_DBNAME=unifi
|
||||
- MONGO_AUTHSOURCE=admin
|
||||
volumes:
|
||||
- ./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:
|
||||
|
|
|
|||
|
|
@ -1,181 +0,0 @@
|
|||
<% 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 %>
|
||||
|
|
@ -19,16 +19,7 @@
|
|||
"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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
{
|
||||
"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}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -18,11 +18,7 @@
|
|||
"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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
{
|
||||
"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"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -3,8 +3,8 @@ services:
|
|||
build:
|
||||
context: .
|
||||
args:
|
||||
# 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'.
|
||||
# Bakt de versie in het image. De deploy-workflow zet SU_VERSION
|
||||
# (git-tag of main-<sha>); 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,9 +39,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -112,20 +112,6 @@ 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
|
||||
|
||||
|
|
|
|||
|
|
@ -56,17 +56,15 @@ aangeboden.
|
|||
### Wat er gebeurt als je op "Nu bijwerken" klikt
|
||||
|
||||
```
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
Stap 4 is nodig omdat een container zichzelf niet kan hercreëren: het commando
|
||||
Stap 3 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
|
||||
|
|
@ -172,7 +170,6 @@ 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
|
||||
```
|
||||
|
|
@ -181,16 +178,10 @@ 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.
|
||||
|
||||
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.
|
||||
> 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.
|
||||
|
||||
Eerste keer overstappen van lokaal bouwen naar de registry:
|
||||
|
||||
|
|
|
|||
45
install.sh
45
install.sh
|
|
@ -15,8 +15,6 @@ set -eu
|
|||
|
||||
SCRIPT_VERSIE="1.2"
|
||||
GIT_HOST="git.ramonbesselink.nl"
|
||||
GIT_SCHEMA="https"
|
||||
HOST_EXPLICIET=0
|
||||
GIT_PAD="bes-r/server-up"
|
||||
BRANCH="main"
|
||||
DOEL="/opt/server-up"
|
||||
|
|
@ -120,15 +118,7 @@ Gebruik: install.sh [opties]
|
|||
--bind ADRES Waarop de poort luistert (standaard: ${BIND})
|
||||
Gebruik 0.0.0.0 om er van buiten de server bij te kunnen,
|
||||
maar zet er dan wel een reverse proxy met TLS voor.
|
||||
--branch NAAM Branch of tag om te installeren (standaard: ${BRANCH})
|
||||
--host ADRES Waar de broncode staat, als ${GIT_HOST} daar niet
|
||||
oplost of niet bereikbaar is. Een naam, een IP of een
|
||||
tailnet-adres; een schema mag mee omdat een IP over https
|
||||
een certificaatfout geeft:
|
||||
--host 10.0.20.22
|
||||
--host http://10.0.20.22:3000
|
||||
Bij een bestaande installatie wordt de git-remote meteen
|
||||
omgezet, zodat bijwerken daarna via dit adres gaat.
|
||||
--branch NAAM Branch om te installeren (standaard: ${BRANCH})
|
||||
--token TOKEN Toegangstoken, als je git-repo niet openbaar is
|
||||
--base-dir PAD Hoofdmap voor stacks, appdata en backups
|
||||
(standaard: /opt/serverup). Later wijzigen kan via
|
||||
|
|
@ -167,27 +157,6 @@ Voorbeelden:
|
|||
UITLEG
|
||||
}
|
||||
|
||||
# Waar de broncode vandaan komt. Standaard de naam hierboven, maar die is niet
|
||||
# overal te gebruiken: staat je Forgejo achter een naam die alleen thuis
|
||||
# oplost, dan kom je er vanaf een andere locatie (of door een tunnel) niet uit.
|
||||
# Met `--host` geef je dan het adres op dat daar wél werkt — een IP, een
|
||||
# tailnet-adres, een andere naam — zonder aan /etc/hosts te komen.
|
||||
#
|
||||
# Een IP over https levert een certificaatfout op, dus een schema mag mee:
|
||||
# --host 10.0.20.22 → https://10.0.20.22
|
||||
# --host http://10.0.20.22:3000 → precies dat
|
||||
zet_host() {
|
||||
_waarde="${1%/}"
|
||||
case "$_waarde" in
|
||||
http://*) GIT_SCHEMA="http"; _waarde="${_waarde#http://}" ;;
|
||||
https://*) GIT_SCHEMA="https"; _waarde="${_waarde#https://}" ;;
|
||||
esac
|
||||
# Alles achter de eerste schuine streep is een pad; dat komt uit GIT_PAD.
|
||||
GIT_HOST="${_waarde%%/*}"
|
||||
[ -n "$GIT_HOST" ] || fout "--host vereist een adres, bijvoorbeeld 10.0.20.22:3000"
|
||||
HOST_EXPLICIET=1
|
||||
}
|
||||
|
||||
# ── Argumenten ───────────────────────────────────────────────────────────────
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
|
|
@ -195,7 +164,6 @@ while [ $# -gt 0 ]; do
|
|||
--port) POORT="${2:?--port vereist een nummer}"; POORT_EXPLICIET=1; shift 2 ;;
|
||||
--bind) BIND="${2:?--bind vereist een adres}"; BIND_EXPLICIET=1; shift 2 ;;
|
||||
--branch) BRANCH="${2:?--branch vereist een naam}"; shift 2 ;;
|
||||
--host) zet_host "${2:?--host vereist een adres}"; shift 2 ;;
|
||||
--token) TOKEN="${2:?--token vereist een waarde}"; shift 2 ;;
|
||||
--user) GEBRUIKER="${2:?--user vereist een accountnaam}"
|
||||
GEBRUIKER_EXPLICIET=1; shift 2 ;;
|
||||
|
|
@ -356,7 +324,7 @@ controleer_docker() {
|
|||
# In plaats daarvan: een bestand met modus 0600 dat na afloop weer weg is. Git
|
||||
# leest het via een credential-helper, curl via --config; in beide gevallen
|
||||
# staat alleen het pád op de opdrachtregel.
|
||||
bron_url() { printf '%s://%s/%s.git' "$GIT_SCHEMA" "$GIT_HOST" "$GIT_PAD"; }
|
||||
bron_url() { printf 'https://%s/%s.git' "$GIT_HOST" "$GIT_PAD"; }
|
||||
|
||||
TOKEN_BESTAND=""
|
||||
CURL_BESTAND=""
|
||||
|
|
@ -405,13 +373,6 @@ haal_broncode() {
|
|||
# installatie is er niets om naar terug te gaan.
|
||||
VORIGE_COMMIT="$($SUDO git -C "$DOEL" rev-parse HEAD 2>/dev/null || echo '')"
|
||||
[ -n "$VORIGE_COMMIT" ] && log "Huidige versie: $(printf '%.9s' "$VORIGE_COMMIT")"
|
||||
# Een bestaande kloon haalt op via de remote die erin staat, en die
|
||||
# wijst naar de naam van toen. Juist bij --host is dat het adres dat het
|
||||
# niet doet — dus eerst omzetten, anders heeft de optie geen effect.
|
||||
if [ "$HOST_EXPLICIET" -eq 1 ]; then
|
||||
log "Bron omgezet naar $(bron_url)"
|
||||
doe $SUDO git -C "$DOEL" remote set-url origin "$(bron_url)"
|
||||
fi
|
||||
git_doe -C "$DOEL" fetch --depth 1 origin "$BRANCH"
|
||||
# FETCH_HEAD en niet origin/$BRANCH: een tag krijgt geen origin/-ref,
|
||||
# dus `--branch v0.7.92-beta` installeerde prima maar kon daarna nooit
|
||||
|
|
@ -435,7 +396,7 @@ haal_broncode() {
|
|||
waarschuw "Klonen mislukte; ik probeer het archief."
|
||||
fi
|
||||
|
||||
archief="${GIT_SCHEMA}://${GIT_HOST}/${GIT_PAD}/archive/${BRANCH}.tar.gz"
|
||||
archief="https://${GIT_HOST}/${GIT_PAD}/archive/${BRANCH}.tar.gz"
|
||||
log "Downloaden: $archief"
|
||||
if [ "$DROOG" -eq 0 ]; then
|
||||
tijdelijk="$(mktemp -d)"
|
||||
|
|
|
|||
566
server-up/app.py
566
server-up/app.py
|
|
@ -1,6 +1,6 @@
|
|||
"""Server Up — Docker Manager."""
|
||||
from __future__ import annotations
|
||||
import glob, inspect, json, os, re, secrets, shutil, socket, subprocess, sys, time
|
||||
import inspect, json, os, re, secrets, shutil, socket, subprocess, sys, time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -15,8 +15,7 @@ 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, dbwachtwoord
|
||||
from core import diskspace, networks
|
||||
from core import backups, categories, compose_transform, diskspace, networks
|
||||
from core import notify, paden, pangolin
|
||||
from core import scheduler, selfupdate
|
||||
from core import stackupdates
|
||||
|
|
@ -408,11 +407,10 @@ def api_update_apply():
|
|||
Kort na afloop verdwijnt deze container; de front-end pollt daarna /healthz.
|
||||
"""
|
||||
d = request.json or {}
|
||||
tag = updater.image_tag(d.get("tag") or "")
|
||||
tag = (d.get("tag") or "").strip()
|
||||
if not tag:
|
||||
info = updater.check(VERSION)
|
||||
tag = (info.get("image_tag")
|
||||
or updater.image_tag(info.get("latest_tag") or ""))
|
||||
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()
|
||||
|
|
@ -450,15 +448,13 @@ 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=ip)
|
||||
ref=st.get("previous_tag", ""), ip=request.remote_addr)
|
||||
jobs.finish(jid, "done" if ok else "error")
|
||||
jobs.done(qq)
|
||||
|
||||
|
|
@ -674,36 +670,6 @@ 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("."):
|
||||
|
|
@ -717,7 +683,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 = per_project.get(d.name, []) if heeft_compose else []
|
||||
ct = docker.compose_ps(d, name=d.name) if heeft_compose else []
|
||||
logo_url, icon = _stack_logo(d)
|
||||
meta = _stack_meta(d)
|
||||
out.append({
|
||||
|
|
@ -726,10 +692,6 @@ 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")),
|
||||
|
|
@ -789,15 +751,7 @@ 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()
|
||||
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()
|
||||
d = (lib / name).resolve()
|
||||
if not d.is_relative_to(lib) or d == lib:
|
||||
return None, None
|
||||
return lib, d
|
||||
|
|
@ -808,20 +762,17 @@ 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() and not (action == "remove" and d.is_symlink()):
|
||||
if not d.exists():
|
||||
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 = d
|
||||
pdir = Path(cfg.load()["LIBRARY_DIR"]).resolve() / name
|
||||
lf = lambda m: jobs.log(qq, "dim", m)
|
||||
try:
|
||||
jobs.log(qq, "section", f"{action}: {name}")
|
||||
|
|
@ -838,35 +789,27 @@ 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 = remove_mode
|
||||
mode = body.get("mode", "stack")
|
||||
if mode in ("full", "all"):
|
||||
_auto_backup(qq, name, "BACKUP_BEFORE_REMOVE", "voor-remove")
|
||||
if docker.has_compose(pdir):
|
||||
# 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()):
|
||||
docker.compose_down(pdir, log_fn=lf, volumes=True, name=name)
|
||||
if mode in ("full", "all") and pdir.is_dir():
|
||||
# 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.
|
||||
# verwijzing halen we weg. `shutil.rmtree` zou hier
|
||||
# bovendien weigeren ("Cannot call rmtree on a symbolic
|
||||
# link") en de verwijdering halverwege laten stranden.
|
||||
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", "Appbestanden verwijderd")
|
||||
jobs.log(qq, "ok", "Bestanden verwijderd")
|
||||
if mode == "all":
|
||||
dd = Path(cfg.load()["DATA_DIR"]).resolve() / name
|
||||
if dd.is_symlink():
|
||||
dd.unlink()
|
||||
jobs.log(qq, "ok", "Dataverwijzing verwijderd")
|
||||
elif dd.is_dir():
|
||||
if dd.is_dir():
|
||||
shutil.rmtree(dd)
|
||||
jobs.log(qq, "ok", "Data verwijderd")
|
||||
elif action == "backup":
|
||||
|
|
@ -885,9 +828,7 @@ def api_stack_action(name, action):
|
|||
jobs.finish(jid, "error")
|
||||
jobs.done(qq)
|
||||
|
||||
# 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)
|
||||
jobs.run(_run, jid)
|
||||
return jsonify(ok=True, job_id=jid)
|
||||
|
||||
|
||||
|
|
@ -949,15 +890,18 @@ 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. Sommige apps (waaronder UniFi) luisteren
|
||||
# uitsluitend via HTTPS; het oude vaste http://-adres maakte hun link stuk.
|
||||
# Het adres waarop de app draait: eigen IP, anders de gepubliceerde poort.
|
||||
adres = ""
|
||||
try:
|
||||
compose_text = docker.read_compose(d)
|
||||
poorten = compose_transform.published_ports(docker.read_compose(d))
|
||||
except Exception:
|
||||
compose_text = ""
|
||||
bron_meta = boilerplates.metadata(bron) if bron else {}
|
||||
adres = _stack_webadres(meta, bron_meta, compose_text,
|
||||
request.host.split(":")[0])
|
||||
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]}"
|
||||
|
||||
if items:
|
||||
audit.log("stacks", "credentials_view", "ok", ref=name,
|
||||
|
|
@ -969,43 +913,6 @@ 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)
|
||||
|
|
@ -1120,29 +1027,8 @@ 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),
|
||||
# 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}
|
||||
name=meta.get("name", name))
|
||||
|
||||
|
||||
@app.route("/api/stacks/<name>/reconfigure", methods=["POST"])
|
||||
|
|
@ -1183,15 +1069,6 @@ 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.
|
||||
|
|
@ -1201,11 +1078,6 @@ 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)
|
||||
|
|
@ -1220,12 +1092,6 @@ 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")
|
||||
|
||||
|
|
@ -1273,42 +1139,9 @@ 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(
|
||||
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}")
|
||||
cf.read_text(encoding="utf-8"), klaar), encoding="utf-8")
|
||||
jobs.log(qq, "ok", "Gekoppeld aan " + ", ".join(f"'{n}'" for n in klaar))
|
||||
|
||||
ok_cfg, err = docker.validate_compose(d)
|
||||
if not ok_cfg:
|
||||
|
|
@ -1317,36 +1150,13 @@ 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)
|
||||
|
|
@ -1355,7 +1165,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=ip)
|
||||
ref=name, ip=request.remote_addr)
|
||||
jobs.finish(jid, "done" if rc == 0 else "error")
|
||||
except Exception as e:
|
||||
jobs.log(qq, "error", f"{type(e).__name__}: {e}")
|
||||
|
|
@ -1368,9 +1178,7 @@ def api_stack_reconfigure(name):
|
|||
jobs.finish(jid, "error")
|
||||
jobs.done(qq)
|
||||
|
||||
# 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)
|
||||
jobs.run(_run, jid)
|
||||
return jsonify(ok=True, job_id=jid)
|
||||
|
||||
|
||||
|
|
@ -1384,14 +1192,10 @@ 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
|
||||
body = request.json or {}
|
||||
aan = bool(body.get("connect", True))
|
||||
aan = bool((request.json or {}).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)
|
||||
|
|
@ -1411,32 +1215,27 @@ def api_stack_connect(name):
|
|||
|
||||
cf = docker.find_compose(d)
|
||||
origineel = cf.read_text(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")
|
||||
cf.write_text(compose_transform.add_shared_network(origineel, gedeeld_net),
|
||||
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,
|
||||
"app_services": diensten_nu})
|
||||
_stack_meta_schrijven(d, {"connected": True})
|
||||
docker.compose_up(d, log_fn=lf, name=name)
|
||||
jobs.log(qq, "ok",
|
||||
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)
|
||||
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)
|
||||
jobs.finish(jid, "done")
|
||||
except Exception as e:
|
||||
jobs.log(qq, "error", str(e))
|
||||
jobs.finish(jid, "error")
|
||||
jobs.done(qq)
|
||||
|
||||
# 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)
|
||||
jobs.run(_run, jid)
|
||||
return jsonify(ok=True, job_id=jid)
|
||||
|
||||
|
||||
|
|
@ -1524,28 +1323,10 @@ def api_repos():
|
|||
if r["id"] in gezien:
|
||||
continue
|
||||
gezien.add(r["id"])
|
||||
# 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})
|
||||
result.append({**r, "cloned": git.cache_dir(r["id"]).exists()})
|
||||
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 {}
|
||||
|
|
@ -1570,49 +1351,6 @@ 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 —
|
||||
|
|
@ -1652,10 +1390,7 @@ 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=[])
|
||||
stacks = git.scan_stacks(rid, lib, subdir=repo.get("subdir", ""))
|
||||
for stack in stacks:
|
||||
stack["compatibility"] = boilerplates.compatibility(stack)
|
||||
return jsonify(stacks=stacks)
|
||||
return jsonify(stacks=git.scan_stacks(rid, lib, subdir=repo.get("subdir", "")))
|
||||
|
||||
|
||||
def _is_stack_dir(p: Path) -> bool:
|
||||
|
|
@ -1722,83 +1457,6 @@ 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.
|
||||
|
||||
|
|
@ -1984,22 +1642,10 @@ 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:
|
||||
compose_tekst = (preview_files.get("compose.yaml")
|
||||
or preview_files.get("docker-compose.yml") or "")
|
||||
doc = yaml.safe_load(compose_tekst) or {}
|
||||
doc = yaml.safe_load(preview_files.get("compose.yaml")
|
||||
or preview_files.get("docker-compose.yml") or "") 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(
|
||||
|
|
@ -2008,10 +1654,8 @@ 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:
|
||||
|
|
@ -2120,11 +1764,6 @@ 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")
|
||||
|
|
@ -2164,11 +1803,6 @@ 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
|
||||
|
|
@ -2387,45 +2021,14 @@ 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(
|
||||
tekst, klaar, services=diensten_nu), encoding="utf-8")
|
||||
cf.read_text(encoding="utf-8"), klaar), encoding="utf-8")
|
||||
jobs.log(qq, "ok",
|
||||
"Gekoppeld aan " + ", ".join(f"'{n}'" for n in klaar)
|
||||
+ " (" + ", ".join(diensten_nu) + ") — bereikbaar "
|
||||
"op hun containernaam.")
|
||||
+ " — apps daarin zijn 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)
|
||||
|
|
@ -2463,13 +2066,9 @@ 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",
|
||||
|
|
@ -2497,9 +2096,7 @@ def api_store_install():
|
|||
jobs.finish(jid, "error")
|
||||
jobs.done(qq)
|
||||
|
||||
# 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)
|
||||
jobs.run(_run, jid)
|
||||
return jsonify(ok=True, job_id=jid)
|
||||
|
||||
|
||||
|
|
@ -2551,8 +2148,6 @@ 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)
|
||||
|
|
@ -2562,7 +2157,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=ip)
|
||||
audit.log("backups", "create", "ok", ref=stack, ip=request.remote_addr)
|
||||
jobs.finish(jid, "done")
|
||||
except backups.BackupError as e:
|
||||
jobs.log(qq, "error", str(e))
|
||||
|
|
@ -2570,9 +2165,7 @@ def api_backup_create(stack):
|
|||
jobs.finish(jid, "error")
|
||||
jobs.done(qq)
|
||||
|
||||
# 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)
|
||||
jobs.run(_run, jid)
|
||||
return jsonify(ok=True, job_id=jid)
|
||||
|
||||
|
||||
|
|
@ -2603,9 +2196,7 @@ def api_backup_restore(stack):
|
|||
jobs.finish(jid, "error")
|
||||
jobs.done(qq)
|
||||
|
||||
# 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)
|
||||
jobs.run(_run, jid)
|
||||
return jsonify(ok=True, job_id=jid)
|
||||
|
||||
|
||||
|
|
@ -3030,9 +2621,6 @@ 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
|
||||
|
|
@ -3045,7 +2633,7 @@ def api_connectable():
|
|||
meta = {}
|
||||
containers = [{"name": c["name"], "service": c.get("service", ""),
|
||||
"ports": c.get("ports", "")}
|
||||
for c in per_project.get(d.name, []) if c.get("name")]
|
||||
for c in docker.compose_ps(d, name=d.name) if c.get("name")]
|
||||
uit.append({
|
||||
"stack": d.name,
|
||||
"source": meta.get("source", ""), # uit welke app-template
|
||||
|
|
@ -4013,39 +3601,6 @@ 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.
|
||||
|
|
@ -4233,17 +3788,6 @@ 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)
|
||||
|
|
@ -4416,12 +3960,6 @@ 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()
|
||||
|
|
|
|||
|
|
@ -325,6 +325,9 @@ 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():
|
||||
|
|
|
|||
|
|
@ -159,10 +159,7 @@ _DUMPERS = {
|
|||
"postgis": ("POSTGRES_USER", "POSTGRES_DB", "POSTGRES_PASSWORD"),
|
||||
"mariadb": ("MARIADB_USER", "MARIADB_DATABASE", "MARIADB_PASSWORD"),
|
||||
"mysql": ("MYSQL_USER", "MYSQL_DATABASE", "MYSQL_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"),
|
||||
"mongo": (None, None, "MONGO_INITDB_ROOT_PASSWORD"),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -195,24 +192,13 @@ 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:
|
||||
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 {}
|
||||
doc = yaml.safe_load(docker.read_compose(d)) or {}
|
||||
except Exception:
|
||||
return []
|
||||
env = _env_waarden(d)
|
||||
uit = []
|
||||
for snaam, svc in (doc.get("services") or {}).items():
|
||||
if not isinstance(svc, dict):
|
||||
|
|
@ -248,12 +234,9 @@ 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], {})
|
||||
# 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()], {})
|
||||
return (["sh", "-c",
|
||||
f"mongodump --archive --username='{gb or 'app'}' "
|
||||
f"--password='{ww}' --authenticationDatabase=admin"], {})
|
||||
|
||||
|
||||
def maak_dumps(stack: str, tmp: Path, log_fn=None) -> list[Path]:
|
||||
|
|
@ -814,11 +797,9 @@ 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}'"]
|
||||
# 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()]
|
||||
return ["sh", "-c",
|
||||
f"mongorestore --archive --drop --username='{gb or 'app'}' "
|
||||
f"--password='{ww}' --authenticationDatabase=admin"]
|
||||
|
||||
|
||||
def _wacht_op_database(dienst: dict, pogingen: int = 30, log_fn=None) -> bool:
|
||||
|
|
|
|||
|
|
@ -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, platform, re, shutil
|
||||
import json, re, shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -72,77 +72,9 @@ 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?
|
||||
|
||||
|
|
@ -505,12 +437,6 @@ 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))
|
||||
|
|
|
|||
|
|
@ -18,8 +18,6 @@ Voorbeeld:
|
|||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
|
|
@ -118,52 +116,8 @@ def _laad(compose_text: str) -> dict:
|
|||
return doc
|
||||
|
||||
|
||||
# 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.
|
||||
def add_shared_network(compose_text: str, network) -> str:
|
||||
"""Hang elke service 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.
|
||||
|
|
@ -171,13 +125,8 @@ def add_shared_network(compose_text: str, network, services=None) -> 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 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.
|
||||
Door ze allemaal aan één gedeeld bridge-netwerk te hangen, lost de
|
||||
container-naam wél op tussen stacks.
|
||||
|
||||
Bestaande netwerken (zoals een macvlan met vast IP) blijven staan; dit komt
|
||||
er naast.
|
||||
|
|
@ -194,17 +143,10 @@ def add_shared_network(compose_text: str, network, services=None) -> 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 snaam, svc in doc["services"].items():
|
||||
for svc in doc["services"].values():
|
||||
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
|
||||
|
|
@ -440,167 +382,3 @@ 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)
|
||||
|
|
|
|||
|
|
@ -1,130 +0,0 @@
|
|||
"""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, ""
|
||||
|
|
@ -147,8 +147,7 @@ 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.service\"}}")
|
||||
"\t{{.Label \"com.docker.compose.project.working_dir\"}}")
|
||||
cmd = [_bin(), "ps", "--format", fmt]
|
||||
if alle:
|
||||
cmd.insert(2, "-a")
|
||||
|
|
@ -166,31 +165,16 @@ def list_containers(alle: bool = True) -> list[dict]:
|
|||
d = regel.split("\t")
|
||||
if len(d) < 4:
|
||||
continue
|
||||
d += [""] * (9 - len(d))
|
||||
d += [""] * (8 - 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], "service": d[8],
|
||||
"project": d[6], "working_dir": d[7],
|
||||
"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.
|
||||
|
||||
|
|
@ -245,26 +229,6 @@ 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:
|
||||
|
|
@ -283,12 +247,9 @@ 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": _published_ports(c),
|
||||
"ports": c.get("Ports", ""),
|
||||
"running": c.get("State", "").lower() in ("running", "up"),
|
||||
})
|
||||
except Exception:
|
||||
|
|
@ -416,18 +377,6 @@ 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -48,44 +48,6 @@ 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()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"""Background job runner with log streaming."""
|
||||
import contextlib, json, os, threading, time, uuid, queue
|
||||
from pathlib import Path
|
||||
import threading, time, uuid, queue
|
||||
|
||||
_jobs: dict[str, dict] = {}
|
||||
_queues: dict[str, queue.Queue] = {}
|
||||
|
|
@ -12,82 +11,9 @@ _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.
|
||||
|
||||
Op schijf blijven ze staan; `stream` haalt ze daar weer vandaan.
|
||||
"""
|
||||
"""Verwijder voltooide jobs ouder dan TTL uit geheugen."""
|
||||
cutoff = time.time() - _TTL
|
||||
with _lock:
|
||||
stale = [jid for jid, j in _jobs.items()
|
||||
|
|
@ -139,8 +65,6 @@ def finish(jid: str, status="done"):
|
|||
with _lock:
|
||||
if jid in _jobs:
|
||||
_jobs[jid]["status"] = status
|
||||
_bewaar(jid)
|
||||
_opruimen_schijf()
|
||||
|
||||
|
||||
def done(q):
|
||||
|
|
@ -151,15 +75,7 @@ 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:
|
||||
|
|
@ -178,80 +94,9 @@ 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()
|
||||
|
|
|
|||
|
|
@ -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>` en `SU_VERSION=<tag>` in de deploy-.env schrijven
|
||||
3. helper starten met exact hetzelfde Compose-project en -configuratie
|
||||
2. `SU_TAG=<tag>` in het .env van de deploy-map schrijven
|
||||
3. helper starten: sleep 5 && docker compose up -d server-up
|
||||
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, updater
|
||||
from core import docker
|
||||
|
||||
# Labels die `docker compose` op elke container zet.
|
||||
_LBL_PROJECT = "com.docker.compose.project"
|
||||
|
|
@ -70,7 +70,6 @@ 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", ""),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -160,10 +159,6 @@ 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()
|
||||
|
|
@ -185,19 +180,22 @@ def pull(tag: str, log_fn=None) -> tuple[bool, str]:
|
|||
# ── Toepassen ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _tag_script(tag: str) -> str:
|
||||
"""Sh-regels die de image-tag én versie in de deploy-.env vastleggen.
|
||||
"""Sh-regels die SU_TAG in het .env van de werkmap zetten.
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
tag_regel = shlex.quote(f"SU_TAG={tag}")
|
||||
versie_regel = shlex.quote(f"SU_VERSION={tag}")
|
||||
q = shlex.quote(f"SU_TAG={tag}")
|
||||
return (
|
||||
"touch .env; "
|
||||
"sed -i '/^SU_TAG=/d; /^SU_VERSION=/d' .env; "
|
||||
f"printf '%s\\n' {tag_regel} {versie_regel} >> .env; "
|
||||
"sed -i '/^SU_TAG=/d' .env; "
|
||||
f"printf '%s\\n' {q} >> .env; "
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -218,7 +216,6 @@ 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"]
|
||||
|
|
@ -232,66 +229,39 @@ def apply(tag: str, log_fn=None) -> tuple[bool, str]:
|
|||
return False, image_of_fout
|
||||
image = image_of_fout
|
||||
|
||||
# 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", ""))
|
||||
# Onthoud waar we vandaan komen, zodat terugrollen mogelijk blijft als het
|
||||
# nieuwe image niet start.
|
||||
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 en SU_VERSION worden in {werkmap}/.env bijgewerkt")
|
||||
log_fn(f"SU_TAG={tag} wordt door de helper in {werkmap}/.env gezet")
|
||||
log_fn("Helper starten die de container hercreëert…")
|
||||
|
||||
ok, melding = _spawn_helper(
|
||||
image, werkmap, service, tag,
|
||||
project=info.get("project", ""),
|
||||
config_files=info.get("config_files", ""),
|
||||
log_fn=log_fn,
|
||||
)
|
||||
ok, melding = _spawn_helper(image, werkmap, service, tag, 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 helper die het bestaande Compose-project exact hercreëert.
|
||||
"""Start een korte container die `docker compose up -d <service>` uitvoert.
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
compose_up = _compose_up(working_dir, project, config_files, service)
|
||||
script = f"sleep 5; {_tag_script(tag)}{compose_up}"
|
||||
script = (f"sleep 5; "
|
||||
f"{_tag_script(tag)}"
|
||||
f"docker compose up -d --remove-orphans {shlex.quote(service)}")
|
||||
cmd = [docker._bin(), "run", "--rm", "--detach",
|
||||
"--name", "server-up-updater",
|
||||
"-v", "/var/run/docker.sock:/var/run/docker.sock",
|
||||
|
|
|
|||
|
|
@ -67,17 +67,6 @@ 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"})
|
||||
|
|
@ -102,8 +91,7 @@ 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": "", "image_tag": "",
|
||||
"update_available": False,
|
||||
"latest": None, "latest_tag": "", "update_available": False,
|
||||
"url": "", "notes": "", "prerelease": False, "error": "",
|
||||
"checked_at": time.time()}
|
||||
if not url:
|
||||
|
|
@ -133,8 +121,7 @@ def check(current: str, force: bool = False) -> dict:
|
|||
|
||||
tag = (best.get("tag_name") or best.get("name") or "")
|
||||
res["latest_tag"] = tag
|
||||
res["image_tag"] = image_tag(tag)
|
||||
res["latest"] = res["image_tag"]
|
||||
res["latest"] = tag.lstrip("v")
|
||||
res["url"] = best.get("html_url") or best.get("url") or ""
|
||||
res["notes"] = best.get("body") or ""
|
||||
res["prerelease"] = bool(best.get("prerelease"))
|
||||
|
|
|
|||
|
|
@ -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; height: 100vh; height: 100dvh; }
|
||||
body { font-family: 'Inter', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
|
||||
[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 overflow-y-auto">
|
||||
class="fixed inset-0 bg-bg dark:bg-bg-dark z-[90] grid place-items-center p-4">
|
||||
<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 min-w-0">
|
||||
<div class="p-4 sm:p-7 max-w-6xl mx-auto pb-48">
|
||||
|
||||
<!-- ══ Dashboard ══ -->
|
||||
<section x-show="page === 'dashboard'" x-cloak class="anim">
|
||||
|
|
@ -381,9 +381,6 @@ 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>
|
||||
|
|
@ -398,13 +395,6 @@ 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>
|
||||
|
|
@ -436,8 +426,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 === true" :style="a.uit ? 'opacity:.4' : ''"
|
||||
@click="a.doe(); stackMenu = ''">
|
||||
:disabled="a.uit" :style="a.uit ? 'opacity:.4' : ''"
|
||||
@click="stackMenu = ''; a.doe()">
|
||||
<span class="mdi text-base w-5 text-center" :class="a.icon"></span>
|
||||
<span x-text="a.label"></span>
|
||||
</button>
|
||||
|
|
@ -660,13 +650,6 @@ 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. -->
|
||||
|
|
@ -701,8 +684,6 @@ 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>
|
||||
|
|
@ -826,7 +807,7 @@ tailwind.config = {
|
|||
</section>
|
||||
|
||||
<!-- ══ Settings ══ -->
|
||||
<section x-show="page === 'settings'" x-cloak class="anim space-y-5 min-w-0">
|
||||
<section x-show="page === 'settings'" x-cloak class="anim space-y-5">
|
||||
<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>
|
||||
|
|
@ -835,7 +816,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 max-w-full overflow-x-auto rounded-xl bg-s2 dark:bg-s2-dark p-1 gap-1">
|
||||
<div class="inline-flex 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>
|
||||
|
|
@ -851,7 +832,7 @@ tailwind.config = {
|
|||
|
||||
<!-- Setup wizard opnieuw doorlopen -->
|
||||
<div class="card">
|
||||
<div class="px-5 py-4 flex flex-wrap items-center gap-2.5 border-b border-bd dark:border-bd-dark">
|
||||
<div class="px-5 py-4 flex 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>
|
||||
|
|
@ -868,10 +849,10 @@ tailwind.config = {
|
|||
|
||||
<!-- Beveiliging: accounts + wachtwoord -->
|
||||
<div class="card">
|
||||
<div class="px-5 py-4 flex flex-wrap items-center gap-2.5 border-b border-bd dark:border-bd-dark">
|
||||
<div class="px-5 py-4 flex 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 sm:ml-auto max-w-full truncate" x-text="t('logged_in_as', {user: auth.user})"></span>
|
||||
<span class="badge badge-dim ml-auto" x-text="t('logged_in_as', {user: auth.user})"></span>
|
||||
</div>
|
||||
<div class="p-5 grid md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
|
|
@ -917,7 +898,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 max-w-full overflow-x-auto rounded-xl bg-s2 dark:bg-s2-dark p-1 gap-1 mb-2">
|
||||
<div class="inline-flex 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' : ''"
|
||||
|
|
@ -954,7 +935,7 @@ tailwind.config = {
|
|||
|
||||
<!-- Backups: bewaarbeleid en planning -->
|
||||
<div class="card">
|
||||
<div class="px-5 py-4 flex flex-wrap items-center gap-2.5 border-b border-bd dark:border-bd-dark">
|
||||
<div class="px-5 py-4 flex 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>
|
||||
|
|
@ -1028,7 +1009,7 @@ tailwind.config = {
|
|||
|
||||
<!-- Meldingen: je hoort het als er 's nachts iets misgaat -->
|
||||
<div class="card">
|
||||
<div class="px-5 py-4 flex flex-wrap items-center gap-2.5 border-b border-bd dark:border-bd-dark">
|
||||
<div class="px-5 py-4 flex 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>
|
||||
|
|
@ -1123,7 +1104,7 @@ tailwind.config = {
|
|||
|
||||
<!-- Pangolin: publieke URL's zonder poorten open te zetten -->
|
||||
<div class="card">
|
||||
<div class="px-5 py-4 flex flex-wrap items-center gap-2.5 border-b border-bd dark:border-bd-dark">
|
||||
<div class="px-5 py-4 flex 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>
|
||||
|
|
@ -1172,7 +1153,7 @@ tailwind.config = {
|
|||
|
||||
<!-- Netwerken: stacks een eigen IP-adres geven -->
|
||||
<div class="card">
|
||||
<div class="px-5 py-4 flex flex-wrap items-center gap-2.5 border-b border-bd dark:border-bd-dark">
|
||||
<div class="px-5 py-4 flex 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>
|
||||
|
|
@ -1197,7 +1178,7 @@ tailwind.config = {
|
|||
|
||||
<!-- Updates -->
|
||||
<div class="card">
|
||||
<div class="px-5 py-4 flex flex-wrap items-center gap-2.5 border-b border-bd dark:border-bd-dark">
|
||||
<div class="px-5 py-4 flex 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>
|
||||
|
|
@ -1241,7 +1222,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 max-w-full overflow-x-auto rounded-xl bg-s2 dark:bg-s2-dark p-1 gap-1">
|
||||
<div class="inline-flex 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' : ''"
|
||||
|
|
@ -1288,7 +1269,7 @@ tailwind.config = {
|
|||
|
||||
<!-- Taalmodule (add-on) -->
|
||||
<div class="card">
|
||||
<div class="px-5 py-4 flex flex-wrap items-center gap-2.5 border-b border-bd dark:border-bd-dark">
|
||||
<div class="px-5 py-4 flex 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>
|
||||
|
|
@ -1395,23 +1376,10 @@ 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 flex-wrap">
|
||||
<div class="flex items-center gap-2 py-2 border-b border-bd dark:border-bd-dark last:border-0">
|
||||
<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>
|
||||
|
|
@ -1476,30 +1444,7 @@ 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 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>
|
||||
<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">
|
||||
|
||||
<!-- Add repo -->
|
||||
<div x-show="modal==='add-repo'" class="modal max-w-md">
|
||||
|
|
@ -1776,27 +1721,6 @@ 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">
|
||||
|
|
@ -1822,13 +1746,6 @@ 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. -->
|
||||
|
|
@ -1927,30 +1844,6 @@ 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()">
|
||||
|
|
@ -2015,114 +1908,6 @@ 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">
|
||||
|
|
@ -2396,7 +2181,6 @@ 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>
|
||||
|
|
@ -2406,38 +2190,15 @@ 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')">App verwijderen</span>
|
||||
</div>
|
||||
<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-body">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn" :disabled="removeBusy" @click="modal=null" x-text="t('cancel')">Annuleren</button>
|
||||
</div>
|
||||
<div class="modal-foot"><button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button></div>
|
||||
</div>
|
||||
|
||||
<!-- Editor -->
|
||||
|
|
@ -2447,7 +2208,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 max-w-full flex items-center gap-1 overflow-x-auto">
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
<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' : ''"
|
||||
|
|
@ -2566,10 +2327,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-0 sm:p-4 overflow-hidden">
|
||||
<div class="modal max-w-xl">
|
||||
<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 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="modal-body !p-5 sm:!p-7 min-h-[270px]">
|
||||
<div class="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'">
|
||||
|
|
@ -2675,8 +2436,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="modal-foot !px-5 sm:!px-7">
|
||||
<button class="btn" @click="dismissWizard()" x-text="t('close')">Sluiten</button>
|
||||
<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>
|
||||
<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>
|
||||
|
|
@ -2694,7 +2455,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 min-w-0 bg-s1 dark:bg-s1-dark border border-bd dark:border-bd-dark rounded-2xl shadow-soft; }
|
||||
.card { @apply 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; }
|
||||
|
|
@ -2704,7 +2465,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 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; }
|
||||
.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; }
|
||||
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; }
|
||||
|
|
@ -2715,31 +2476,10 @@ 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 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; }
|
||||
.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; }
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
@ -2797,7 +2537,6 @@ 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: {},
|
||||
|
|
@ -2830,8 +2569,6 @@ 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,
|
||||
|
|
@ -3127,18 +2864,8 @@ 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 () => {
|
||||
if (bezig) return;
|
||||
bezig = true;
|
||||
let d;
|
||||
try {
|
||||
d = await this.rpc(`/api/jobs/${jid}?offset=${off}`);
|
||||
} finally { bezig = false; }
|
||||
if (!d) return;
|
||||
const d = await this.rpc(`/api/jobs/${jid}?offset=${off}`);
|
||||
(d.lines || []).forEach(l => { this.job.lines.push(l); off++; });
|
||||
if (d.status && d.status !== 'running') {
|
||||
clearInterval(this.job.timer); this.job.timer = null;
|
||||
|
|
@ -3354,28 +3081,12 @@ 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.removeTarget = name;
|
||||
this.removeBusy = false;
|
||||
this.modal = 'remove';
|
||||
},
|
||||
confirmRemove(name) { this.install.stack = name; this.modal = 'remove'; },
|
||||
async doRemove(mode) {
|
||||
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.removeBusy = false;
|
||||
this.toast(d.msg || d.error || this.t('remove_failed'), 'er');
|
||||
return;
|
||||
}
|
||||
const name = this.install.stack;
|
||||
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');
|
||||
});
|
||||
const d = await this.rpc(`/api/stacks/${encodeURIComponent(name)}/remove`, { mode });
|
||||
if (d.job_id) this.pollJob(d.job_id, () => this.loadStacks());
|
||||
},
|
||||
async openCredentials(stack) {
|
||||
const d = await this.rpc(`/api/stacks/${encodeURIComponent(stack)}/credentials`);
|
||||
|
|
@ -3468,54 +3179,6 @@ 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);
|
||||
|
|
@ -3974,21 +3637,6 @@ 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(); }
|
||||
|
|
@ -3996,10 +3644,6 @@ 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,
|
||||
|
|
@ -4009,12 +3653,7 @@ function app() {
|
|||
envVars: [], images: [], envOverrides: {}, imageTags: {},
|
||||
fields: [], values: {}, preview_files: {},
|
||||
step: 0, tab: 'compose', editing: false, composeText: '', envText: '',
|
||||
network: '', ip: '', ips: {}, services: [], service_info: [],
|
||||
app_services: [], depends_on: [],
|
||||
extras: { volumes: [], devices: [] },
|
||||
image_tags: {},
|
||||
compatibility: { known: false, supported: true, architecture: '', reason: '' },
|
||||
architecture_note: ''
|
||||
network: '', ip: '', ips: {}, services: [], depends_on: []
|
||||
};
|
||||
this.modal = 'install';
|
||||
this.fieldInfo = null;
|
||||
|
|
@ -4024,13 +3663,6 @@ 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 = {};
|
||||
|
|
@ -4096,7 +3728,6 @@ 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() {
|
||||
|
|
@ -4118,7 +3749,6 @@ 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;
|
||||
},
|
||||
|
|
@ -4172,7 +3802,7 @@ function app() {
|
|||
stepMissing() {
|
||||
if (!this.wizardActief()) return [];
|
||||
const s = this.wizardSteps()[this.install.step];
|
||||
if (!s || ['controleren', 'verbinden', 'extras'].includes(s.id)) return [];
|
||||
if (!s || s.id === 'controleren' || s.id === 'verbinden') 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;
|
||||
|
|
@ -4191,8 +3821,7 @@ 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', 'extras'].includes(stapId)) await this.refreshPreview();
|
||||
if (stapId === 'extras' && !this.devices.length) await this.loadDevices();
|
||||
if (['controleren', 'verbinden'].includes(stapId)) await this.refreshPreview();
|
||||
// Meteen laten zien of de poorten van deze stap vrij zijn.
|
||||
this.checkPoortenVanStap(stapId);
|
||||
},
|
||||
|
|
@ -4211,7 +3840,6 @@ 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
|
||||
|
|
@ -4266,11 +3894,6 @@ 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;
|
||||
|
|
@ -4292,9 +3915,6 @@ 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;
|
||||
|
|
@ -4311,10 +3931,6 @@ 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,
|
||||
|
|
@ -4328,12 +3944,6 @@ 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
|
||||
|
|
@ -4464,22 +4074,11 @@ 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.image_tag || this.update.latest || this.update.latest_tag;
|
||||
const naar = this.update.latest_tag || this.update.latest;
|
||||
if (!confirm(this.t('update_confirm', {tag: naar}))) return;
|
||||
this.updating = true;
|
||||
const d = await this.rpc('/api/update/apply', { tag: naar });
|
||||
|
|
@ -4542,17 +4141,6 @@ 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', {});
|
||||
|
|
@ -4613,7 +4201,9 @@ function app() {
|
|||
} else {
|
||||
const d = await this.rpc('/api/wizard/sync', {});
|
||||
if (d.job_id) this.pollJob(d.job_id, async () => {
|
||||
if (await this.dismissWizard()) this.loadDashboard();
|
||||
await this.rpc('/api/wizard/complete', {});
|
||||
this.wizard.open = false;
|
||||
this.loadDashboard();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -50,27 +50,6 @@
|
|||
"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",
|
||||
|
|
@ -80,8 +59,8 @@
|
|||
"instance_name": "Instance name",
|
||||
"instance_hint": "Change for multiple installations",
|
||||
"remove_stack": "Remove app",
|
||||
"remove_stack_only": "Remove app · keep data",
|
||||
"remove_all": "Remove app and all data",
|
||||
"remove_stack_only": "Stop containers + volumes",
|
||||
"remove_all": "Stop + remove all files",
|
||||
"prune_images": "Remove all unused images?",
|
||||
"confirm_remove_image": "Remove image?",
|
||||
"force_remove": "Force removal?",
|
||||
|
|
@ -324,7 +303,6 @@
|
|||
"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",
|
||||
|
|
@ -338,9 +316,6 @@
|
|||
"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",
|
||||
|
|
@ -401,7 +376,6 @@
|
|||
"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",
|
||||
|
|
@ -497,9 +471,5 @@
|
|||
"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",
|
||||
"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"
|
||||
"audit_act_adopt": "Adopted"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,27 +50,6 @@
|
|||
"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",
|
||||
|
|
@ -80,8 +59,8 @@
|
|||
"instance_name": "Instantie naam",
|
||||
"instance_hint": "Wijzig voor meerdere installaties",
|
||||
"remove_stack": "App verwijderen",
|
||||
"remove_stack_only": "App verwijderen · data bewaren",
|
||||
"remove_all": "App en alle data verwijderen",
|
||||
"remove_stack_only": "Stop containers + volumes",
|
||||
"remove_all": "Stop + verwijder alle bestanden",
|
||||
"prune_images": "Alle ongebruikte images verwijderen?",
|
||||
"confirm_remove_image": "Image verwijderen?",
|
||||
"force_remove": "Forceer verwijdering?",
|
||||
|
|
@ -324,7 +303,6 @@
|
|||
"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",
|
||||
|
|
@ -338,9 +316,6 @@
|
|||
"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",
|
||||
|
|
@ -401,7 +376,6 @@
|
|||
"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",
|
||||
|
|
@ -497,9 +471,5 @@
|
|||
"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",
|
||||
"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"
|
||||
"audit_act_adopt": "Overgenomen"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ 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"))
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
// 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);
|
||||
})();
|
||||
|
|
@ -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, extra\'s, controleren',
|
||||
JSON.stringify(s) === '["basis","verbinden","extras","controleren"]');
|
||||
check('eenvoudige app: basis, verbinden, controleren',
|
||||
JSON.stringify(s) === '["basis","verbinden","controleren"]');
|
||||
|
||||
// ── App met geheimen ────────────────────────────────────────────────────────
|
||||
c.install.fields.push(veld('db_password','geheimen',{required:true, secret:true}));
|
||||
|
|
@ -349,79 +349,6 @@ 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) => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
// 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.
|
||||
// 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.
|
||||
const fs = require('fs'), path = require('path');
|
||||
|
||||
// jsdom en Alpine zijn geen onderdeel van dit project; zonder die twee slaat
|
||||
|
|
@ -39,11 +38,6 @@ 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',
|
||||
|
|
@ -61,9 +55,7 @@ w.app = function () {
|
|||
};
|
||||
|
||||
const m = html.match(/<div x-show="modal==='install'"[\s\S]*?\n <\/div>\n/);
|
||||
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.document.body.innerHTML = `<div x-data="app()">${m[0]}</div>`;
|
||||
w.eval(fs.readFileSync(alpinePad, 'utf8'));
|
||||
|
||||
const wacht = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
|
@ -104,63 +96,6 @@ 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);
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@
|
|||
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
|
||||
|
|
@ -376,188 +374,3 @@ 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
|
||||
|
|
|
|||
|
|
@ -279,56 +279,6 @@ 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."""
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"""Boilerplate-rendering: de sandbox, en de YAML-opruiming die conditionele
|
||||
blokken achterlaten."""
|
||||
import json
|
||||
import stat
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -104,69 +103,3 @@ 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"] == {}
|
||||
|
|
|
|||
|
|
@ -1,91 +0,0 @@
|
|||
"""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
|
||||
|
|
@ -158,137 +158,3 @@ 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
|
||||
|
|
|
|||
|
|
@ -27,40 +27,15 @@ services:
|
|||
|
||||
# ── Gedeeld netwerk ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_gedeeld_netwerk_komt_bij_de_app_maar_niet_bij_de_database():
|
||||
def test_gedeeld_netwerk_komt_bij_elke_service():
|
||||
"""Zonder gedeeld netwerk zit elke stack in zijn eigen compose-netwerk en
|
||||
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.
|
||||
"""
|
||||
kan geen enkele app een andere op naam bereiken."""
|
||||
doc = yaml.safe_load(ct.add_shared_network(BASIS, "serverup"))
|
||||
assert "serverup" in doc["services"]["web"]["networks"]
|
||||
assert "networks" not in doc["services"]["db"]
|
||||
for naam, svc in doc["services"].items():
|
||||
assert "serverup" in svc["networks"], naam
|
||||
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."""
|
||||
|
|
@ -327,46 +302,3 @@ 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"
|
||||
|
|
|
|||
|
|
@ -240,71 +240,3 @@ 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())
|
||||
|
|
|
|||
|
|
@ -1,285 +0,0 @@
|
|||
"""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"
|
||||
|
|
@ -53,7 +53,7 @@ def test_geen_bashismen():
|
|||
def test_help_werkt_en_noemt_alle_opties():
|
||||
r = _draai("--help")
|
||||
assert r.returncode == 0
|
||||
for optie in ("--dir", "--port", "--bind", "--branch", "--host", "--token",
|
||||
for optie in ("--dir", "--port", "--bind", "--branch", "--token",
|
||||
"--update", "--uninstall", "--yes", "--dry-run",
|
||||
"--admin", "--admin-password-file", "--create-admin"):
|
||||
assert optie in r.stdout, f"{optie} niet gedocumenteerd in --help"
|
||||
|
|
@ -1213,51 +1213,3 @@ def test_doctor_kijkt_naar_koppelingen_onder_base_dir():
|
|||
assert "eigen koppeling, maar niet in de container" in blok
|
||||
# De uitweg hoort erbij te staan, anders weet je nog niets.
|
||||
assert "docker-compose.yml" in blok
|
||||
|
||||
|
||||
# ── Een andere bron opgeven ──────────────────────────────────────────────────
|
||||
# De ingebouwde hostnaam lost niet overal op: staat je Forgejo achter een naam
|
||||
# die alleen thuis werkt, dan kom je er vanaf een andere locatie niet uit en
|
||||
# strandt het bijwerken op "could not resolve host".
|
||||
|
||||
def test_host_wordt_in_de_bron_url_gebruikt(tmp_path):
|
||||
r = _draai("--dry-run", "--yes", "--dir", str(tmp_path / "su"),
|
||||
"--host", "10.0.20.22")
|
||||
assert "https://10.0.20.22/bes-r/server-up" in r.stdout, r.stdout[-800:]
|
||||
assert "git.ramonbesselink.nl" not in r.stdout
|
||||
|
||||
|
||||
def test_host_met_schema_en_poort(tmp_path):
|
||||
"""Een IP over https geeft een certificaatfout, dus http moet kunnen."""
|
||||
r = _draai("--dry-run", "--yes", "--dir", str(tmp_path / "su"),
|
||||
"--host", "http://10.0.20.22:3000")
|
||||
assert "http://10.0.20.22:3000/bes-r/server-up" in r.stdout
|
||||
|
||||
|
||||
def test_host_negeert_een_pad_erachter(tmp_path):
|
||||
r = _draai("--dry-run", "--yes", "--dir", str(tmp_path / "su"),
|
||||
"--host", "https://voorbeeld.nl/iets/anders/")
|
||||
assert "https://voorbeeld.nl/bes-r/server-up" in r.stdout
|
||||
|
||||
|
||||
def test_host_zonder_waarde_stopt(tmp_path):
|
||||
r = _draai("--dry-run", "--host")
|
||||
assert r.returncode != 0
|
||||
assert "--host" in (r.stdout + r.stderr)
|
||||
|
||||
|
||||
def test_zonder_host_blijft_de_standaard_staan(tmp_path):
|
||||
r = _draai("--dry-run", "--yes", "--dir", str(tmp_path / "su"))
|
||||
assert "git.ramonbesselink.nl" in r.stdout
|
||||
|
||||
|
||||
def test_bestaande_installatie_krijgt_de_nieuwe_remote(tmp_path):
|
||||
"""Een bestaande kloon haalt op via de remote die erin staat — precies het
|
||||
adres dat het niet doet. Zonder omzetten heeft --host geen effect."""
|
||||
if not shutil.which("git"):
|
||||
pytest.skip("git niet aanwezig")
|
||||
doel = tmp_path / "su"
|
||||
(doel / ".git").mkdir(parents=True)
|
||||
r = _draai("--dry-run", "--yes", "--dir", str(doel),
|
||||
"--host", "100.64.0.5")
|
||||
assert "remote set-url origin https://100.64.0.5/bes-r/server-up.git" in r.stdout
|
||||
|
|
|
|||
|
|
@ -339,25 +339,3 @@ 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"
|
||||
|
|
|
|||
|
|
@ -1,91 +0,0 @@
|
|||
"""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"
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
"""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()
|
||||
|
|
@ -109,97 +109,6 @@ 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):
|
||||
|
|
@ -290,143 +199,3 @@ 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}}) == {}
|
||||
|
|
|
|||
|
|
@ -280,162 +280,3 @@ 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"]
|
||||
|
|
|
|||
|
|
@ -106,25 +106,11 @@ 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 ──────────────────────────────────────────────────────
|
||||
# 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.
|
||||
# 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.
|
||||
|
||||
def _draai_tagscript(su, map_: Path, tag: str):
|
||||
import subprocess
|
||||
|
|
@ -135,19 +121,14 @@ 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").splitlines() == [
|
||||
"SU_TAG=0.5.10", "SU_VERSION=0.5.10",
|
||||
]
|
||||
assert _draai_tagscript(su, tmp_path, "0.5.10").strip() == "SU_TAG=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\nSU_VERSION=0.5.00\nPORT=5000\n")
|
||||
(tmp_path / ".env").write_text("BIND=0.0.0.0\nSU_TAG=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",
|
||||
"SU_VERSION=0.5.10"]
|
||||
assert regels == ["BIND=0.0.0.0", "PORT=5000", "SU_TAG=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):
|
||||
|
|
@ -155,7 +136,6 @@ 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):
|
||||
|
|
@ -164,11 +144,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):
|
||||
"""De helper gebruikt dezelfde projectnaam en bestanden als de container."""
|
||||
"""Zonder de mount schrijft de helper in zijn eigen overlay, en zonder het
|
||||
tag-fragment start compose gewoon het oude image opnieuw."""
|
||||
aanroepen = []
|
||||
|
||||
def nep_run(cmd, **kw):
|
||||
|
|
@ -176,82 +156,14 @@ 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",
|
||||
project="eigen-project",
|
||||
config_files="/opt/server-up/compose.yml,/opt/server-up/extra.yml",
|
||||
)
|
||||
su._spawn_helper("git.example.com/bes-r/server-up:0.5.10",
|
||||
"/opt/server-up", "server-up", "0.5.10")
|
||||
# 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 "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
|
||||
assert "docker compose up -d" in script
|
||||
|
||||
|
||||
def test_rollback_zonder_vorige_versie(su):
|
||||
|
|
|
|||
|
|
@ -117,41 +117,3 @@ 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"] == ""
|
||||
|
|
|
|||
|
|
@ -1,91 +0,0 @@
|
|||
"""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()
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
"""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"
|
||||
|
|
@ -21,17 +21,6 @@ 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.
|
||||
|
|
@ -104,12 +93,3 @@ 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
|
||||
|
|
|
|||
|
|
@ -27,8 +27,6 @@ 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
|
||||
|
||||
|
||||
|
|
@ -37,7 +35,6 @@ 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
|
||||
|
||||
|
|
@ -110,18 +107,6 @@ 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)
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
"""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
|
||||
Loading…
Reference in a new issue