v0.8.60-beta - Backups bevatten nu je gegevens, Pangolin en Newt erbij
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 15m31s
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 15m31s
- Een backup bevatte alleen de stackmap: compose, .env en metadata. LIBRARY_DIR en de appdata-map staan naast elkaar, geen genestelde mappen, dus je maakte een backup, kreeg een groen vinkje en hield bij terugzetten een lege app over. Een archief heeft nu drie takken: de stackmap, de appdata van die stack, en een dump per database - De dump is geen dubbelop: een tar van een draaiende PostgreSQL is een momentopname van bestanden die tijdens het inpakken veranderden. Bij terugzetten komt de dump erin nadat de container draait, met een wachtlus. Stond de container tijdens de backup uit, dan is dat een waarschuwing in het log en geen stille overslag - Nieuwe instelling BACKUP_APPDATA (standaard aan), uit te zetten bij een mediabibliotheek waar dit in de honderden gigabytes loopt - apps/newt en apps/pangolin. Server Up had al een Pangolin-integratie met een publiceerknop per stack, maar je moest Pangolin zelf elders vandaan halen - Nieuwe stap in de setup-wizard na git: koppelen aan een bestaande Pangolin, hier installeren, of overslaan. De wizard hing zijn afhandeling aan stapnummers, dus met een stap ertussen ging de git-stap mis; dat gaat nu op de naam van de stap - depends_on waarschuwt nu ook op de stackpagina. Haal je Mosquitto later weg, dan viel Zigbee2MQTT stil zonder dat iets zei waarom Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
This commit is contained in:
parent
1db3d3e8a0
commit
0500e8945b
21 changed files with 1257 additions and 55 deletions
40
CHANGELOG.md
40
CHANGELOG.md
|
|
@ -1,3 +1,43 @@
|
|||
# v0.8.60-beta — Backups bevatten nu je gegevens, en Pangolin hoort erbij
|
||||
|
||||
**Een backup gaf je niets terug.** `backups.create()` maakte een tar van de
|
||||
stackmap: de compose, de `.env` en de metadata. `LIBRARY_DIR` is
|
||||
`/opt/serverup/stacks` en de appdata staat in `/opt/serverup/appdata` — buren,
|
||||
geen genestelde mappen. Je maakte dus een backup, kreeg een groen vinkje, en
|
||||
hield bij terugzetten een lege app over. De documentatie noemde als uitweg dat je
|
||||
data wél meegaat "als die map binnen je stackmap zit", maar met de
|
||||
standaardinstellingen zit hij er juist buiten.
|
||||
|
||||
Een archief heeft nu drie takken: de stackmap, de appdata-mappen van die stack,
|
||||
en een dump per database. Die dump is geen dubbelop — een tar van een draaiende
|
||||
PostgreSQL is een momentopname van bestanden die tijdens het inpakken
|
||||
veranderden. Bij terugzetten komt de dump erin nádat de container draait, met een
|
||||
wachtlus omdat een database na het starten niet meteen verbindingen aanneemt.
|
||||
Stond de container tijdens de backup uit, dan staat dat als waarschuwing in het
|
||||
log; stil overslaan zou het ergste geval opleveren, een archief dat compleet
|
||||
lijkt.
|
||||
|
||||
Nieuwe instelling **Appdata meenemen** (`BACKUP_APPDATA`, standaard aan). Uit
|
||||
zetten is nodig bij een mediabibliotheek of fotoarchief: daar loopt dit in de
|
||||
honderden gigabytes en is restic of borg het betere middel.
|
||||
|
||||
**Pangolin en Newt staan nu in de store.** Server Up had al een complete
|
||||
Pangolin-integratie — een instellingenpagina, een publiceerknop per stack — maar
|
||||
je moest Pangolin zelf ergens anders vandaan halen. Nu staan de tunnelclient
|
||||
(Newt) en de server zelf (pangolin + gerbil + traefik, met de configuratie erbij)
|
||||
in de catalogus.
|
||||
|
||||
**En er is een stap voor in de setup-wizard**, na de git-stap: koppelen aan een
|
||||
Pangolin die je al hebt, er hier een neerzetten, of overslaan. Bij de eerste
|
||||
keuze kun je meteen Newt erbij installeren. Terzijde: de wizard hing zijn
|
||||
afhandeling aan stapnummers, dus met een stap ertussen ging de git-stap mis. Die
|
||||
vergelijkingen gaan nu op de naam van de stap.
|
||||
|
||||
**Waarschuwing op de stackpagina.** `depends_on` meldde alleen bij het
|
||||
installeren wat er ontbrak. Haal je Mosquitto later weg, dan viel Zigbee2MQTT
|
||||
stil zonder dat iets zei waarom. Dat staat nu als badge op de stackkaart, met de
|
||||
reden in de tooltip en een doorklik naar de store.
|
||||
|
||||
# v0.8.50-beta — Leesbare namen, zichtbaar wat al draait, en bruikbaar op een telefoon
|
||||
|
||||
**Het auditlog toonde ruwe id's.** In de kolommen stond `store`, `reconfigure`,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ IP-adres in je netwerk geven.
|
|||
|
||||
| | |
|
||||
|---|---|
|
||||
| **App-catalogus** | 256 kant-en-klare apps, ingedeeld in categorieën met kleur. Zoeken, filteren en installeren met een formulier in plaats van YAML. |
|
||||
| **App-catalogus** | 258 kant-en-klare apps, ingedeeld in categorieën met kleur. Zoeken, filteren en installeren met een formulier in plaats van YAML. |
|
||||
| **Stacks beheren** | Starten, stoppen, bijwerken en herstarten — per stack of per losse container, met logs en live CPU-/geheugengebruik. |
|
||||
| **Apps koppelen** | Stacks komen op een gedeeld netwerk, zodat ze elkaar op naam bereiken. Een veld dat naar een andere app verwijst toont een keuzelijst met wat je al draait. |
|
||||
| **Eigen IP per stack** | Met macvlan of ipvlan krijgt een app een eigen adres in je LAN in plaats van poorten op de host. |
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.8.50-beta
|
||||
0.8.60-beta
|
||||
|
|
|
|||
12
apps/newt/files/compose.yaml
Normal file
12
apps/newt/files/compose.yaml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Newt maakt zelf verbinding naar buiten en publiceert dus geen poorten. Dat is
|
||||
# het hele idee: je router blijft dicht.
|
||||
services:
|
||||
<< service_name >>:
|
||||
image: fosrl/newt:latest
|
||||
container_name: << service_name >>
|
||||
environment:
|
||||
- TZ=<< timezone >>
|
||||
- PANGOLIN_ENDPOINT=<< pangolin_endpoint >>
|
||||
- NEWT_ID=<< newt_id >>
|
||||
- NEWT_SECRET=<< newt_secret >>
|
||||
restart: unless-stopped
|
||||
46
apps/newt/template.json
Normal file
46
apps/newt/template.json
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"kind": "compose",
|
||||
"metadata": {
|
||||
"name": "Newt",
|
||||
"description": "Tunnelclient voor Pangolin. Draait op de machine waar je apps staan en maakt zelf verbinding naar buiten, zodat je geen poort hoeft open te zetten in je router.",
|
||||
"tags": ["tunnel", "pangolin", "netwerk", "proxy"],
|
||||
"icon": {"provider": "selfhst", "id": "pangolin"},
|
||||
"version": {"name": "latest"},
|
||||
"categories": ["netwerk", "beveiliging"],
|
||||
"depends_on": [
|
||||
{"app": "pangolin",
|
||||
"reason": "Newt maakt verbinding mét een Pangolin-instantie; zonder die kant heeft de tunnel geen eindpunt. Draait je Pangolin elders, dan kun je deze melding negeren."}
|
||||
]
|
||||
},
|
||||
"variables": [
|
||||
{
|
||||
"title": "Algemeen",
|
||||
"items": [
|
||||
{"name": "service_name", "type": "str", "title": "Servicenaam",
|
||||
"default": "newt", "required": true},
|
||||
{"name": "timezone", "type": "str", "title": "Tijdzone",
|
||||
"default": "Europe/Amsterdam", "required": true}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Verbinding",
|
||||
"items": [
|
||||
{"name": "pangolin_endpoint", "type": "str", "title": "Adres van Pangolin",
|
||||
"default": "https://pangolin.example.com", "required": true,
|
||||
"connect": {"app": "pangolin", "scheme": "https", "port": 443},
|
||||
"help": "Het adres van je Pangolin-dashboard. Newt belt daar naar buiten; er hoeft niets binnen te komen."}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Toegang",
|
||||
"items": [
|
||||
{"name": "newt_id", "type": "str", "title": "Site-id",
|
||||
"default": "", "required": true, "secret": true, "eigen_invoer": true,
|
||||
"description": "Krijg je van Pangolin bij het aanmaken van een site"},
|
||||
{"name": "newt_secret", "type": "str", "title": "Sitesleutel",
|
||||
"default": "", "required": true, "secret": true, "eigen_invoer": true,
|
||||
"description": "Wordt één keer getoond bij het aanmaken van de site"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
61
apps/pangolin/files/compose.yaml
Normal file
61
apps/pangolin/files/compose.yaml
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Pangolin bestaat uit drie samenwerkende delen:
|
||||
#
|
||||
# pangolin het dashboard en de API — publiceert zelf niets
|
||||
# gerbil de WireGuard-kant waar de tunnels van Newt binnenkomen; die heeft
|
||||
# de poorten, en Traefik deelt zijn netwerknamespace
|
||||
# traefik de reverse proxy die de certificaten regelt
|
||||
#
|
||||
# Traefik draait met `network_mode: service:gerbil`. Dat is geen stijlkeuze: het
|
||||
# verkeer moet door de WireGuard-interface van Gerbil heen, en die bestaat
|
||||
# alleen binnen diens namespace.
|
||||
services:
|
||||
<< service_name >>:
|
||||
image: fosrl/pangolin:latest
|
||||
container_name: << service_name >>
|
||||
environment:
|
||||
- TZ=<< timezone >>
|
||||
volumes:
|
||||
- ./config:/app/config
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3001/api/v1/"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 15
|
||||
restart: unless-stopped
|
||||
|
||||
<< service_name >>-gerbil:
|
||||
image: fosrl/gerbil:latest
|
||||
container_name: << service_name >>-gerbil
|
||||
depends_on:
|
||||
<< service_name >>:
|
||||
condition: service_healthy
|
||||
command:
|
||||
- --reachableAt=http://<< service_name >>-gerbil:3003
|
||||
- --generateAndSaveKeyTo=/var/config/key
|
||||
- --remoteConfig=http://<< service_name >>:3001/api/v1/gerbil/get-config
|
||||
- --reportBandwidthTo=http://<< service_name >>:3001/api/v1/gerbil/receive-bandwidth
|
||||
volumes:
|
||||
- ./config/:/var/config
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- SYS_MODULE
|
||||
ports:
|
||||
- "<< port_wireguard >>:51820/udp"
|
||||
- "<< port_gerbil >>:21820/udp"
|
||||
- "<< port_http >>:80"
|
||||
- "<< port_https >>:443"
|
||||
restart: unless-stopped
|
||||
|
||||
<< service_name >>-traefik:
|
||||
image: traefik:v3.6
|
||||
container_name: << service_name >>-traefik
|
||||
network_mode: service:<< service_name >>-gerbil
|
||||
depends_on:
|
||||
<< service_name >>:
|
||||
condition: service_healthy
|
||||
command:
|
||||
- --configFile=/etc/traefik/traefik_config.yml
|
||||
volumes:
|
||||
- ./config/traefik:/etc/traefik:ro
|
||||
- ./config/letsencrypt:/letsencrypt
|
||||
restart: unless-stopped
|
||||
52
apps/pangolin/files/config/config.yml
Normal file
52
apps/pangolin/files/config/config.yml
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
app:
|
||||
dashboard_url: "https://<< dashboard_domein >>"
|
||||
log_level: "info"
|
||||
|
||||
domains:
|
||||
domain1:
|
||||
base_domain: "<< basis_domein >>"
|
||||
cert_resolver: "letsencrypt"
|
||||
|
||||
server:
|
||||
external_port: 3000
|
||||
internal_port: 3001
|
||||
next_port: 3002
|
||||
internal_hostname: "<< service_name >>"
|
||||
session_cookie_name: "p_session_token"
|
||||
resource_access_token_param: "p_token"
|
||||
resource_session_request_param: "p_session_request"
|
||||
cors:
|
||||
origins: ["https://<< dashboard_domein >>"]
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
|
||||
headers: ["X-CSRF-Token", "Content-Type"]
|
||||
credentials: false
|
||||
secret: "<< server_secret >>"
|
||||
|
||||
traefik:
|
||||
cert_resolver: "letsencrypt"
|
||||
http_entrypoint: "web"
|
||||
https_entrypoint: "websecure"
|
||||
|
||||
gerbil:
|
||||
start_port: 51820
|
||||
base_endpoint: "<< dashboard_domein >>"
|
||||
use_subdomain: false
|
||||
block_size: 24
|
||||
site_block_size: 30
|
||||
subnet_group: 100.89.137.0/20
|
||||
|
||||
rate_limits:
|
||||
global:
|
||||
window_minutes: 1
|
||||
max_requests: 500
|
||||
|
||||
users:
|
||||
server_admin:
|
||||
email: "<< admin_email >>"
|
||||
|
||||
flags:
|
||||
require_email_verification: false
|
||||
disable_signup_without_invite: true
|
||||
disable_user_create_org: false
|
||||
allow_raw_resources: true
|
||||
allow_base_domain_resources: true
|
||||
42
apps/pangolin/files/config/traefik/dynamic_config.yml
Normal file
42
apps/pangolin/files/config/traefik/dynamic_config.yml
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
http:
|
||||
middlewares:
|
||||
redirect-to-https:
|
||||
redirectScheme:
|
||||
scheme: https
|
||||
|
||||
routers:
|
||||
# Het dashboard van Pangolin zelf.
|
||||
main-app-router-redirect:
|
||||
rule: "Host(`<< dashboard_domein >>`)"
|
||||
service: next-service
|
||||
entryPoints:
|
||||
- web
|
||||
middlewares:
|
||||
- redirect-to-https
|
||||
|
||||
next-router:
|
||||
rule: "Host(`<< dashboard_domein >>`) && !PathPrefix(`/api/v1`)"
|
||||
service: next-service
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
api-router:
|
||||
rule: "Host(`<< dashboard_domein >>`) && PathPrefix(`/api/v1`)"
|
||||
service: api-service
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
services:
|
||||
next-service:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://<< service_name >>:3002"
|
||||
|
||||
api-service:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://<< service_name >>:3000"
|
||||
44
apps/pangolin/files/config/traefik/traefik_config.yml
Normal file
44
apps/pangolin/files/config/traefik/traefik_config.yml
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
api:
|
||||
insecure: false
|
||||
dashboard: false
|
||||
|
||||
providers:
|
||||
http:
|
||||
endpoint: "http://<< service_name >>:3001/api/v1/traefik-config"
|
||||
pollInterval: "5s"
|
||||
file:
|
||||
filename: "/etc/traefik/dynamic_config.yml"
|
||||
|
||||
experimental:
|
||||
plugins:
|
||||
badger:
|
||||
moduleName: "github.com/fosrl/badger"
|
||||
version: "v1.2.0"
|
||||
|
||||
log:
|
||||
level: "INFO"
|
||||
format: "common"
|
||||
|
||||
certificatesResolvers:
|
||||
letsencrypt:
|
||||
acme:
|
||||
httpChallenge:
|
||||
entryPoint: web
|
||||
email: "<< admin_email >>"
|
||||
storage: "/letsencrypt/acme.json"
|
||||
caServer: "https://acme-v02.api.letsencrypt.org/directory"
|
||||
|
||||
entryPoints:
|
||||
web:
|
||||
address: ":80"
|
||||
websecure:
|
||||
address: ":443"
|
||||
transport:
|
||||
respondingTimeouts:
|
||||
readTimeout: "30m"
|
||||
http:
|
||||
tls:
|
||||
certResolver: "letsencrypt"
|
||||
|
||||
serversTransport:
|
||||
insecureSkipVerify: true
|
||||
130
apps/pangolin/template.json
Normal file
130
apps/pangolin/template.json
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
{
|
||||
"kind": "compose",
|
||||
"metadata": {
|
||||
"name": "Pangolin",
|
||||
"description": "Reverse proxy met tunnels: je apps blijven thuis draaien en krijgen toch een adres op internet, zonder poorten open te zetten in je router. Server Up kan er zelf publieke URL's in aanmaken zodra je hem koppelt onder Instellingen. Let op: hiervoor heb je een domein nodig dat naar deze server wijst, en poort 80 en 443 moeten vrij zijn.",
|
||||
"tags": [
|
||||
"proxy",
|
||||
"tunnel",
|
||||
"https",
|
||||
"netwerk"
|
||||
],
|
||||
"icon": {
|
||||
"provider": "selfhst",
|
||||
"id": "pangolin"
|
||||
},
|
||||
"version": {
|
||||
"name": "latest"
|
||||
},
|
||||
"categories": [
|
||||
"netwerk",
|
||||
"beveiliging"
|
||||
]
|
||||
},
|
||||
"variables": [
|
||||
{
|
||||
"title": "Algemeen",
|
||||
"items": [
|
||||
{
|
||||
"name": "service_name",
|
||||
"type": "str",
|
||||
"title": "Servicenaam",
|
||||
"default": "pangolin",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "timezone",
|
||||
"type": "str",
|
||||
"title": "Tijdzone",
|
||||
"default": "Europe/Amsterdam",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Domein",
|
||||
"items": [
|
||||
{
|
||||
"name": "basis_domein",
|
||||
"type": "str",
|
||||
"title": "Basisdomein",
|
||||
"default": "example.com",
|
||||
"required": true,
|
||||
"description": "Het domein waaronder je apps komen te staan",
|
||||
"help": "Hier hangen straks de adressen van je apps onder, bijvoorbeeld foto.example.com. Dit domein moet met een A-record naar het IP-adres van deze server wijzen, anders komt er geen certificaat."
|
||||
},
|
||||
{
|
||||
"name": "dashboard_domein",
|
||||
"type": "str",
|
||||
"title": "Dashboarddomein",
|
||||
"default": "pangolin.example.com",
|
||||
"required": true,
|
||||
"description": "Waar je de Pangolin-interface zelf opent"
|
||||
},
|
||||
{
|
||||
"name": "admin_email",
|
||||
"type": "str",
|
||||
"title": "E-mailadres beheerder",
|
||||
"default": "admin@example.com",
|
||||
"required": true,
|
||||
"description": "Voor het eerste account en voor Let's Encrypt"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Poorten",
|
||||
"items": [
|
||||
{
|
||||
"name": "port_http",
|
||||
"type": "int",
|
||||
"title": "HTTP",
|
||||
"default": 80,
|
||||
"required": true,
|
||||
"advanced": true
|
||||
},
|
||||
{
|
||||
"name": "port_https",
|
||||
"type": "int",
|
||||
"title": "HTTPS",
|
||||
"default": 443,
|
||||
"required": true,
|
||||
"advanced": true
|
||||
},
|
||||
{
|
||||
"name": "port_wireguard",
|
||||
"type": "int",
|
||||
"title": "WireGuard",
|
||||
"default": 51820,
|
||||
"required": true,
|
||||
"advanced": true,
|
||||
"description": "Hierop komen de tunnels van Newt binnen"
|
||||
},
|
||||
{
|
||||
"name": "port_gerbil",
|
||||
"type": "int",
|
||||
"title": "Gerbil",
|
||||
"default": 21820,
|
||||
"required": true,
|
||||
"advanced": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Toegang",
|
||||
"items": [
|
||||
{
|
||||
"name": "server_secret",
|
||||
"type": "str",
|
||||
"title": "Serversleutel",
|
||||
"default": "",
|
||||
"required": true,
|
||||
"secret": true,
|
||||
"generate": "auto",
|
||||
"secret_format": "hex",
|
||||
"secret_bytes": 32,
|
||||
"description": "64 tekens; ondertekent de sessies van je gebruikers"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -5,36 +5,50 @@ acties, of op een schema. Terugzetten kan met één klik.
|
|||
|
||||
---
|
||||
|
||||
## ⚠️ Lees dit eerst: wat zit er wél en niet in
|
||||
## Wat zit er in een backup
|
||||
|
||||
Een backup is een `.tar.gz` van de **stackmap** in je library. Daar zitten in:
|
||||
Een backup is een `.tar.gz` met drie takken:
|
||||
|
||||
- het compose-bestand
|
||||
- het `.env`-bestand (met je wachtwoorden en tokens)
|
||||
- eventuele configuratiebestanden die naast de compose staan
|
||||
- `.serverup.json` met de metadata van de installatie
|
||||
|
||||
Wat er **niet** in zit: de Docker-volumes met de eigenlijke gegevens van de app.
|
||||
```
|
||||
<stack>/ de compose, de .env en .serverup.json
|
||||
appdata/ de mappen waar de app zijn gegevens bewaart
|
||||
dumps/ een dump per database die de stack meebrengt
|
||||
```
|
||||
|
||||
| Voorbeeld | In de backup? |
|
||||
|---|---|
|
||||
| De compose van Vaultwarden | ✅ |
|
||||
| Je `ADMIN_TOKEN` uit `.env` | ✅ |
|
||||
| De wachtwoordkluis van Vaultwarden zelf | ❌ |
|
||||
| De database van Paperless | ❌ |
|
||||
| Je foto's in Immich | ❌ |
|
||||
| De wachtwoordkluis van Vaultwarden zelf | ✅ |
|
||||
| De database van Paperless | ✅ (als dump én als bestanden) |
|
||||
| Je foto's in Immich | ✅ — maar zie hieronder |
|
||||
|
||||
Je kunt hiermee dus een stack terugzetten zoals hij geconfigureerd stond, maar
|
||||
niet de inhoud ervan herstellen. Wil je dat ook, dan zijn er twee routes:
|
||||
**Waarom een dump én de bestanden.** Een tar van een draaiende database is geen
|
||||
betrouwbare kopie: er wordt tijdens het inpakken doorgeschreven, dus je hebt een
|
||||
momentopname van een bestand dat halverwege veranderde. Daarom haalt Server Up
|
||||
er ook een `pg_dump`, `mariadb-dump` of `mongodump` uit. Bij terugzetten wordt de
|
||||
dump ingespeeld nádat de container draait; de bestanden zijn het vangnet voor het
|
||||
geval de container tijdens de backup uit stond. Stond hij uit, dan staat dat als
|
||||
waarschuwing in het log — niet als stille overslag.
|
||||
|
||||
1. **Bind mounts in plaats van named volumes.** Staat je data in
|
||||
`/opt/serverup/appdata/<app>` en zit die map binnen je stackmap, dan gaat hij
|
||||
wél mee. De meeste templates in `apps/` gebruiken `appdata_dir` daarvoor.
|
||||
2. **Een aparte volume-backup** met bijvoorbeeld `restic` of `borg` naast Server
|
||||
Up. Voor grote datasets is dat sowieso beter dan een tar per keer.
|
||||
> Dit is niet altijd zo geweest. Tot v0.8.60 bevatte een backup alleen de
|
||||
> stackmap. `LIBRARY_DIR` en de appdata-map staan náást elkaar, dus je maakte een
|
||||
> backup, kreeg een groen vinkje, en hield bij terugzetten een lege app over.
|
||||
|
||||
> Omdat een backup je `.env` bevat, staan er wachtwoorden in. `BACKUP_DIR` hoort
|
||||
> dus dezelfde bescherming te hebben als de rest van `/data`.
|
||||
## Wanneer je de appdata er juist uit wilt
|
||||
|
||||
Bij een mediaserver, een fotoarchief of een downloadmap loopt dit in de honderden
|
||||
gigabytes, en dan is een tar per keer geen zinnig middel — daar wil je `restic`
|
||||
of `borg` voor, met deduplicatie en incrementele runs.
|
||||
|
||||
Zet daarvoor **Instellingen → Backups → Appdata meenemen** uit
|
||||
(`BACKUP_APPDATA`). Je backup bevat dan weer alleen de stackmap: genoeg om een
|
||||
stack terug te zetten zoals hij geconfigureerd stond, niet om de inhoud te
|
||||
herstellen.
|
||||
|
||||
> Omdat een backup je `.env` bevat, staan er wachtwoorden in — en met de appdata
|
||||
> erbij ook je eigenlijke gegevens. `BACKUP_DIR` hoort dus dezelfde bescherming
|
||||
> te hebben als de rest van `/data`.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
131
server-up/app.py
131
server-up/app.py
|
|
@ -584,6 +584,8 @@ def api_stacks():
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
bronnen = _geinstalleerde_bronnen()
|
||||
|
||||
def _stack_meta(d) -> dict:
|
||||
mf = d / ".serverup.json"
|
||||
if mf.exists():
|
||||
|
|
@ -625,12 +627,34 @@ def api_stacks():
|
|||
# dan leiden we het alsnog af uit de opgeslagen naam.
|
||||
"connected": bool(meta.get("connected")),
|
||||
"editable": bool(meta.get("values")),
|
||||
# Wat deze stack nodig heeft en niet (meer) draait. De melding bij
|
||||
# het installeren was eenmalig; haal je de broker later weg, dan
|
||||
# valt Zigbee2MQTT stil zonder dat iets zegt waarom.
|
||||
"depends_missing": _ontbrekende_afhankelijkheden(meta, bronnen),
|
||||
"categories": categories.normalize(
|
||||
meta.get("categories"), meta.get("tags"), meta.get("name", "")),
|
||||
})
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
def _ontbrekende_afhankelijkheden(meta: dict, bronnen: dict) -> list[dict]:
|
||||
"""De `depends_on` van het bronsjabloon die nergens geïnstalleerd staan."""
|
||||
bron_naam = meta.get("source") or ""
|
||||
if not bron_naam:
|
||||
return []
|
||||
try:
|
||||
src = _find_stack_src(bron_naam, meta.get("repo_id", ""))
|
||||
except Exception:
|
||||
return []
|
||||
if not src or not boilerplates.is_boilerplate(src):
|
||||
return []
|
||||
uit = []
|
||||
for dep in boilerplates.metadata(src).get("depends_on") or []:
|
||||
if not bronnen.get(dep["app"]):
|
||||
uit.append(dep)
|
||||
return uit
|
||||
|
||||
|
||||
def _auto_backup(qq, stack: str, config_key: str, reden: str):
|
||||
"""Backup vóór een actie die bestanden overschrijft of weggooit.
|
||||
|
||||
|
|
@ -3215,6 +3239,113 @@ def api_wizard_git():
|
|||
return jsonify(ok=True)
|
||||
|
||||
|
||||
def _wizard_app_installeren(app_dir: str, values: dict, label: str,
|
||||
audit_actie: str):
|
||||
"""Installeer een app uit de ingebouwde catalogus vanuit de wizard.
|
||||
|
||||
Dezelfde stappen als bij Forgejo: bronmap uit de git-cache halen (en zo
|
||||
nodig eerst syncen), renderen naar de library en starten. Retourneert
|
||||
(jsonify-antwoord, status) zodat de aanroeper alleen hoeft door te geven.
|
||||
"""
|
||||
su_repo = next((r for r in cfg.load().get("APP_REPOS", [])
|
||||
if r["id"] == "server-up"), None)
|
||||
if not su_repo:
|
||||
return jsonify(ok=False, msg="server-up repo niet geconfigureerd"), 200
|
||||
cache = git.cache_dir("server-up")
|
||||
subdir = su_repo.get("subdir", "apps")
|
||||
src = (cache / subdir / app_dir).resolve() if subdir else (cache / app_dir).resolve()
|
||||
if not src.is_dir():
|
||||
ok_sync, _ = git.clone_or_pull(su_repo)
|
||||
if not ok_sync or not src.is_dir():
|
||||
return jsonify(ok=False,
|
||||
msg=f"Sjabloon '{app_dir}' niet gevonden — sync mislukt"), 200
|
||||
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
|
||||
dest = (lib / app_dir).resolve()
|
||||
if dest.exists():
|
||||
return jsonify(ok=False, msg=f"'{app_dir}' bestaat al in de library"), 200
|
||||
try:
|
||||
lib.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
return jsonify(ok=False, msg=str(e)), 200
|
||||
|
||||
jid, q = jobs.create(f"wizard:install-{app_dir}")
|
||||
|
||||
def _run(jid):
|
||||
qq = jobs.get_queue(jid)
|
||||
try:
|
||||
jobs.log(qq, "section", f"{label} installeren")
|
||||
boilerplates.render_to_dir(src, dest, values)
|
||||
jobs.log(qq, "ok", f"Gerenderd naar {dest}")
|
||||
rc = docker.compose_up(dest, log_fn=lambda m: jobs.log(qq, "dim", m),
|
||||
name=app_dir)
|
||||
ok = rc == 0
|
||||
jobs.log(qq, "ok" if ok else "error",
|
||||
f"{label} gestart" if ok else f"{label} startte niet")
|
||||
audit.log("wizard", audit_actie, "ok" if ok else "error", ref=app_dir)
|
||||
jobs.finish(jid, "done" if ok else "error")
|
||||
except Exception as e:
|
||||
jobs.log(qq, "error", str(e))
|
||||
jobs.finish(jid, "error")
|
||||
jobs.done(qq)
|
||||
|
||||
jobs.run(_run, jid)
|
||||
return jsonify(ok=True, job_id=jid), 200
|
||||
|
||||
|
||||
@app.route("/api/wizard/pangolin", methods=["POST"])
|
||||
def api_wizard_pangolin():
|
||||
"""De Pangolin-stap: koppelen aan een bestaande, of er hier een neerzetten.
|
||||
|
||||
Server Up kan al publieke URL's aanmaken in Pangolin (zie core/pangolin.py),
|
||||
maar je moest Pangolin zelf ergens anders vandaan halen. Deze stap sluit dat
|
||||
gat aan het begin, waar je erover nadenkt.
|
||||
"""
|
||||
d = request.json or {}
|
||||
actie = d.get("action", "skip")
|
||||
|
||||
if actie == "settings":
|
||||
url = (d.get("url") or "").strip().rstrip("/")
|
||||
if not url:
|
||||
return jsonify(ok=False, msg="Adres vereist")
|
||||
cfg.patch({"PANGOLIN_URL": url,
|
||||
"PANGOLIN_API_KEY": (d.get("api_key") or "").strip(),
|
||||
"PANGOLIN_ORG_ID": (d.get("org_id") or "").strip()})
|
||||
audit.log("wizard", "pangolin", "ok", ref=url)
|
||||
return jsonify(ok=True)
|
||||
|
||||
if actie == "install_newt":
|
||||
antwoord, _ = _wizard_app_installeren("newt", {
|
||||
"service_name": "newt",
|
||||
"timezone": cfg.tijdzone(),
|
||||
"pangolin_endpoint": (d.get("endpoint") or "").strip(),
|
||||
"newt_id": (d.get("newt_id") or "").strip(),
|
||||
"newt_secret": (d.get("newt_secret") or "").strip(),
|
||||
}, "Newt", "install_newt")
|
||||
return antwoord
|
||||
|
||||
if actie == "install_pangolin":
|
||||
basis = (d.get("base_domain") or "").strip()
|
||||
if not basis:
|
||||
return jsonify(ok=False, msg="Basisdomein vereist")
|
||||
dashboard = (d.get("dashboard_domain") or "").strip() or f"pangolin.{basis}"
|
||||
antwoord, _ = _wizard_app_installeren("pangolin", {
|
||||
"service_name": "pangolin",
|
||||
"timezone": cfg.tijdzone(),
|
||||
"basis_domein": basis,
|
||||
"dashboard_domein": dashboard,
|
||||
"admin_email": (d.get("admin_email") or "").strip() or f"admin@{basis}",
|
||||
"server_secret": secrets.token_hex(32),
|
||||
"port_http": 80, "port_https": 443,
|
||||
"port_wireguard": 51820, "port_gerbil": 21820,
|
||||
}, "Pangolin", "install_pangolin")
|
||||
# Meteen koppelen: de publiceerknop per stack werkt dan zonder dat je
|
||||
# het nog een keer in Instellingen hoeft in te vullen.
|
||||
cfg.patch({"PANGOLIN_URL": f"https://{dashboard}"})
|
||||
return antwoord
|
||||
|
||||
return jsonify(ok=True)
|
||||
|
||||
|
||||
@app.route("/api/wizard/complete", methods=["POST"])
|
||||
def api_wizard_done():
|
||||
cfg.patch({"WIZARD_DONE": True})
|
||||
|
|
|
|||
|
|
@ -84,6 +84,11 @@ DEFAULTS = {
|
|||
# Controleer elk nieuw archief meteen na het maken. Kost tijd evenredig aan
|
||||
# de grootte, maar een backup die je nooit leest is een aanname.
|
||||
"BACKUP_VERIFY": True,
|
||||
# De appdata van een stack mee in het archief. Zonder dit bevat een backup
|
||||
# alleen de compose en de .env — je zet hem terug en houdt een lege app
|
||||
# over. Uitzetten is nodig bij een mediabibliotheek of fotoarchief, waar dit
|
||||
# in de honderden gigabytes loopt en een tar per keer geen zinnig middel is.
|
||||
"BACKUP_APPDATA": True,
|
||||
# Tweede bestemming voor backups: een gemounte schijf, NFS- of SMB-share.
|
||||
# Leeg = uit. Backups op dezelfde schijf als de data overleven geen defect.
|
||||
"BACKUP_OFFSITE_DIR": "",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,20 @@
|
|||
"""Backups van stacks: maken, tonen, terugzetten en opruimen.
|
||||
|
||||
Een backup is een `.tar.gz` van de stackmap plus een `.json` met metadata
|
||||
ernaast. Dat betekent: **de compose- en `.env`-bestanden zitten erin, de
|
||||
Docker-volumes met de eigenlijke appdata niet.** Zie docs/backups.md — dat
|
||||
verschil is het belangrijkste dat een gebruiker moet weten.
|
||||
Een backup is een `.tar.gz` met drie takken, plus een `.json` met metadata
|
||||
ernaast:
|
||||
|
||||
<stack>/ de compose, de .env en .serverup.json
|
||||
appdata/ de mappen waar de app zijn gegevens bewaart
|
||||
dumps/ een dump per database die de stack meebrengt
|
||||
|
||||
Die tweede en derde tak zijn er niet altijd geweest. Eerder bevatte een backup
|
||||
alleen de stackmap, en die staat náást de appdata in plaats van eromheen — je
|
||||
maakte dus een backup, kreeg een groen vinkje, en hield bij terugzetten een lege
|
||||
app over. `BACKUP_APPDATA` kan de appdata er weer uit laten voor stacks waar dat
|
||||
in de honderden gigabytes loopt.
|
||||
|
||||
De dumps zijn geen dubbelop: een tar van een draaiende database is een
|
||||
inconsistente kopie omdat er tijdens het inpakken doorgeschreven wordt.
|
||||
|
||||
Terugzetten gebeurt met Pythons `tarfile` en `filter="data"` (PEP 706): dat
|
||||
weigert absolute paden en `..` in archiefingangen. Met een kaal `tar xzf` kan
|
||||
|
|
@ -68,19 +79,196 @@ def _stack_dir(stack: str) -> Path:
|
|||
return d
|
||||
|
||||
|
||||
# ── Wat er naast de stackmap in moet ─────────────────────────────────────────
|
||||
|
||||
def _stack_meta(stack: str) -> dict:
|
||||
mf = _stack_dir(stack) / ".serverup.json"
|
||||
if not mf.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(mf.read_text("utf-8")) or {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def appdata_mappen(stack: str) -> list[Path]:
|
||||
"""De mappen waar deze stack zijn gegevens in bewaart.
|
||||
|
||||
De stackmap bevat alleen compose, .env en de metadata. De eigenlijke
|
||||
gegevens staan in `appdata_dir` — standaard `/opt/serverup/appdata`, een
|
||||
buurmap van de library en dus niet in het archief. Een backup zag er dus
|
||||
compleet uit en gaf bij terugzetten een lege app.
|
||||
|
||||
Welke mappen dat zijn weten we uit de waarden die bij het installeren zijn
|
||||
bewaard: `<appdata_dir>/<service_name>` en de sidecars daarnaast
|
||||
(`…-db`, `…-redis`), het patroon uit docs/apps-maken.md.
|
||||
"""
|
||||
waarden = (_stack_meta(stack).get("values") or {})
|
||||
basis_ruw = str(waarden.get("appdata_dir") or cfg.load().get("DATA_DIR") or "")
|
||||
if not basis_ruw:
|
||||
return []
|
||||
basis = Path(basis_ruw).resolve()
|
||||
# Een sjabloon met `appdata_dir: /` zou anders de hele schijf inpakken.
|
||||
if basis == Path("/") or not basis.is_dir():
|
||||
return []
|
||||
naam = str(waarden.get("service_name") or stack).strip()
|
||||
if not naam or "/" in naam:
|
||||
return []
|
||||
uit = []
|
||||
for d in sorted(basis.iterdir()):
|
||||
if d.is_dir() and (d.name == naam or d.name.startswith(naam + "-")):
|
||||
uit.append(d)
|
||||
return uit
|
||||
|
||||
|
||||
# Databases waarvan een tar van de datamap geen bruikbare kopie is: die
|
||||
# schrijven tijdens het inpakken door. Een dump wél.
|
||||
_DUMPERS = {
|
||||
"postgres": ("POSTGRES_USER", "POSTGRES_DB", "POSTGRES_PASSWORD"),
|
||||
"postgis": ("POSTGRES_USER", "POSTGRES_DB", "POSTGRES_PASSWORD"),
|
||||
"mariadb": ("MARIADB_USER", "MARIADB_DATABASE", "MARIADB_PASSWORD"),
|
||||
"mysql": ("MYSQL_USER", "MYSQL_DATABASE", "MYSQL_PASSWORD"),
|
||||
"mongo": (None, None, "MONGO_INITDB_ROOT_PASSWORD"),
|
||||
}
|
||||
|
||||
|
||||
def _soort(image: str) -> str:
|
||||
kaal = (image or "").split("/")[-1].split(":")[0].lower()
|
||||
for s in _DUMPERS:
|
||||
if kaal.startswith(s):
|
||||
return s
|
||||
return ""
|
||||
|
||||
|
||||
def _env_waarden(d: Path) -> dict[str, str]:
|
||||
"""De waarden uit .env, om `${DB_PASSWORD}` uit compose op te lossen."""
|
||||
uit = {}
|
||||
for regel in docker.read_env(d).splitlines():
|
||||
regel = regel.strip()
|
||||
if regel and not regel.startswith("#") and "=" in regel:
|
||||
k, v = regel.split("=", 1)
|
||||
uit[k.strip()] = v.strip()
|
||||
return uit
|
||||
|
||||
|
||||
def _vul_in(waarde: str, env: dict[str, str]) -> str:
|
||||
"""`${DB_PASSWORD}` of `$DB_PASSWORD` vervangen door de waarde uit .env."""
|
||||
s = str(waarde or "")
|
||||
for k, v in env.items():
|
||||
s = s.replace("${" + k + "}", v).replace("$" + k, v)
|
||||
return s
|
||||
|
||||
|
||||
def databasediensten(stack: str) -> list[dict]:
|
||||
"""De databasecontainers van deze stack, met wat er nodig is voor een dump."""
|
||||
import yaml
|
||||
d = _stack_dir(stack)
|
||||
try:
|
||||
doc = yaml.safe_load(docker.read_compose(d)) or {}
|
||||
except Exception:
|
||||
return []
|
||||
env = _env_waarden(d)
|
||||
uit = []
|
||||
for snaam, svc in (doc.get("services") or {}).items():
|
||||
if not isinstance(svc, dict):
|
||||
continue
|
||||
soort = _soort(svc.get("image", ""))
|
||||
if not soort:
|
||||
continue
|
||||
omgeving = {}
|
||||
for e in svc.get("environment") or []:
|
||||
if isinstance(e, str) and "=" in e:
|
||||
k, v = e.split("=", 1)
|
||||
omgeving[k] = _vul_in(v, env)
|
||||
gebruiker_k, db_k, wachtwoord_k = _DUMPERS[soort]
|
||||
uit.append({
|
||||
"service": snaam,
|
||||
"container": svc.get("container_name") or snaam,
|
||||
"soort": soort,
|
||||
"gebruiker": omgeving.get(gebruiker_k, "") if gebruiker_k else "",
|
||||
"database": omgeving.get(db_k, "") if db_k else "",
|
||||
"wachtwoord": omgeving.get(wachtwoord_k, ""),
|
||||
})
|
||||
return uit
|
||||
|
||||
|
||||
def _dump_commando(dienst: dict) -> tuple[list[str], dict]:
|
||||
"""Het commando binnen de container, plus de omgeving ervoor."""
|
||||
s, gb, db, ww = (dienst["soort"], dienst["gebruiker"],
|
||||
dienst["database"], dienst["wachtwoord"])
|
||||
if s in ("postgres", "postgis"):
|
||||
return (["pg_dump", "--clean", "--if-exists", "-U", gb or "postgres",
|
||||
"-d", db or gb or "postgres"], {"PGPASSWORD": ww})
|
||||
if s in ("mariadb", "mysql"):
|
||||
hulp = "mariadb-dump" if s == "mariadb" else "mysqldump"
|
||||
return ([hulp, "-u", gb or "root", f"-p{ww}", "--single-transaction",
|
||||
db or gb], {})
|
||||
return (["sh", "-c",
|
||||
f"mongodump --archive --username='{gb or 'app'}' "
|
||||
f"--password='{ww}' --authenticationDatabase=admin"], {})
|
||||
|
||||
|
||||
def maak_dumps(stack: str, tmp: Path, log_fn=None) -> list[Path]:
|
||||
"""Schrijf per databasecontainer een dump naar `tmp`.
|
||||
|
||||
Draait de container niet, dan komt er geen dump én een waarschuwing. Stil
|
||||
overslaan zou het ergste geval opleveren: een archief dat compleet lijkt.
|
||||
"""
|
||||
uit = []
|
||||
for dienst in databasediensten(stack):
|
||||
naam = dienst["container"]
|
||||
cmd, extra_env = _dump_commando(dienst)
|
||||
volledig = [docker._bin(), "exec"]
|
||||
for k, v in extra_env.items():
|
||||
volledig += ["-e", f"{k}={v}"]
|
||||
volledig += [naam] + cmd
|
||||
# text=False: een mongodump-archief is binair en zou in tekstmodus
|
||||
# stukgaan op de decodering.
|
||||
r = docker._run(volledig, capture_output=True, text=False, timeout=900)
|
||||
if r.returncode != 0:
|
||||
if log_fn:
|
||||
rauw = r.stderr or b""
|
||||
tekst = rauw.decode("utf-8", "replace") if isinstance(rauw, bytes) else str(rauw)
|
||||
regels = tekst.strip().splitlines() or ["onbekende fout"]
|
||||
log_fn(f"Geen dump van {naam}: {regels[-1]}")
|
||||
continue
|
||||
ext = "archive" if dienst["soort"] == "mongo" else "sql"
|
||||
pad = tmp / f"{dienst['service']}.{ext}"
|
||||
data = r.stdout if isinstance(r.stdout, bytes) else (r.stdout or "").encode()
|
||||
if not data:
|
||||
if log_fn:
|
||||
log_fn(f"Geen dump van {naam}: lege uitvoer")
|
||||
continue
|
||||
pad.write_bytes(data)
|
||||
uit.append(pad)
|
||||
if log_fn:
|
||||
log_fn(f"Dump van {naam}: {_leesbaar(len(data))}")
|
||||
return uit
|
||||
|
||||
|
||||
# ── Maken ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def create(stack: str, reden: str = "handmatig", log_fn=None) -> dict:
|
||||
"""Maak een backup van de stackmap. Retourneert de metadata."""
|
||||
def create(stack: str, reden: str = "handmatig", log_fn=None,
|
||||
appdata: bool | None = None) -> dict:
|
||||
"""Maak een backup van de stackmap, de appdata en de databases.
|
||||
|
||||
`appdata=False` beperkt het tot de stackmap zoals vroeger — nodig bij een
|
||||
mediabibliotheek of een fotoarchief, waar de gegevens in de honderden
|
||||
gigabytes lopen en een tar per keer geen zinnig middel is.
|
||||
"""
|
||||
bron = _stack_dir(stack)
|
||||
if not bron.is_dir():
|
||||
raise BackupError(f"Stack '{stack}' bestaat niet.")
|
||||
bd = backup_dir()
|
||||
bd.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
neem_appdata = (cfg.load().get("BACKUP_APPDATA", True)
|
||||
if appdata is None else bool(appdata))
|
||||
data_mappen = appdata_mappen(stack) if neem_appdata else []
|
||||
|
||||
# Vóórdat we beginnen: past het er redelijkerwijs in? Loopt de schijf
|
||||
# halverwege vol, dan blijft er een archief achter dat compleet lijkt.
|
||||
genoeg, waarom = diskspace.ruimte_voor_backup(bron, bd)
|
||||
genoeg, waarom = diskspace.ruimte_voor_backup([bron, *data_mappen], bd)
|
||||
if not genoeg:
|
||||
raise BackupError(waarom)
|
||||
|
||||
|
|
@ -101,14 +289,27 @@ def create(stack: str, reden: str = "handmatig", log_fn=None) -> dict:
|
|||
# Python's tarfile in plaats van het tar-commando: de returncode van dat
|
||||
# commando werd eerder genegeerd, zodat een mislukte backup als succes
|
||||
# gold.
|
||||
with tempfile.TemporaryDirectory() as t:
|
||||
# Een tar van een draaiende database levert een inconsistente
|
||||
# kopie; een dump wel een bruikbare. Beide gaan mee — de dump om
|
||||
# terug te zetten, de datamap voor het geval de dump ontbreekt.
|
||||
dumps = maak_dumps(stack, Path(t), log_fn=log_fn)
|
||||
with tarfile.open(doel, "w:gz") as tar:
|
||||
tar.add(bron, arcname=stack)
|
||||
for m in data_mappen:
|
||||
if log_fn:
|
||||
log_fn(f"Appdata: {m.name}")
|
||||
tar.add(m, arcname=f"appdata/{m.name}")
|
||||
for dp in dumps:
|
||||
tar.add(dp, arcname=f"dumps/{dp.name}")
|
||||
except Exception as e:
|
||||
doel.unlink(missing_ok=True)
|
||||
raise BackupError(f"Backup mislukt: {e}") from e
|
||||
|
||||
meta = {"stack": stack, "file": naam, "ts": time.time(),
|
||||
"size": doel.stat().st_size, "reason": reden}
|
||||
"size": doel.stat().st_size, "reason": reden,
|
||||
"appdata": [m.name for m in data_mappen],
|
||||
"dumps": [dp.name for dp in dumps]}
|
||||
_schrijf_meta(doel, meta)
|
||||
if log_fn:
|
||||
log_fn(f"Backup klaar: {naam} ({_leesbaar(meta['size'])})")
|
||||
|
|
@ -378,12 +579,21 @@ def restore(stack: str, bestandsnaam: str, log_fn=None) -> tuple[bool, str]:
|
|||
log_fn(f"Huidige map opzijgezet als {opzij.name}")
|
||||
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as t:
|
||||
tmp = Path(t)
|
||||
with tarfile.open(archief, "r:gz") as tar:
|
||||
# filter="data" weigert absolute paden, '..' en apparaatbestanden.
|
||||
tar.extractall(doel.parent, filter="data")
|
||||
if not doel.is_dir():
|
||||
tar.extractall(tmp, filter="data")
|
||||
if not (tmp / stack).is_dir():
|
||||
raise BackupError(
|
||||
"Het archief bevatte geen map met de naam van de stack.")
|
||||
shutil.move(str(tmp / stack), str(doel))
|
||||
# De appdata gaat terug naar waar hij vandaan kwam; dat pad staat in
|
||||
# de metadata die we net hebben teruggezet.
|
||||
_zet_appdata_terug(stack, tmp / "appdata", log_fn=log_fn)
|
||||
dumps = sorted((tmp / "dumps").glob("*")) if (tmp / "dumps").is_dir() else []
|
||||
for dp in dumps:
|
||||
shutil.copy2(dp, doel / f".restore-{dp.name}")
|
||||
except Exception as e:
|
||||
# Terug naar de oude situatie.
|
||||
if doel.exists():
|
||||
|
|
@ -403,9 +613,108 @@ def restore(stack: str, bestandsnaam: str, log_fn=None) -> tuple[bool, str]:
|
|||
if rc != 0:
|
||||
return False, ("Bestanden zijn teruggezet, maar de stack startte niet. "
|
||||
"Bekijk de logs.")
|
||||
|
||||
# Pas nu de dumps: een dump inspelen vóór de database draait doet niets.
|
||||
fouten = _speel_dumps_in(stack, doel, log_fn=log_fn)
|
||||
if fouten:
|
||||
return False, ("De stack draait, maar het terugzetten van de database "
|
||||
"mislukte: " + "; ".join(fouten))
|
||||
return True, f"'{stack}' teruggezet uit {bestandsnaam}."
|
||||
|
||||
|
||||
def _zet_appdata_terug(stack: str, bron: Path, log_fn=None):
|
||||
"""De appdata-tak uit het archief terug naar de appdata-map."""
|
||||
if not bron.is_dir():
|
||||
return
|
||||
waarden = (_stack_meta(stack).get("values") or {})
|
||||
basis = Path(str(waarden.get("appdata_dir")
|
||||
or cfg.load().get("DATA_DIR") or "")).resolve()
|
||||
if basis == Path("/") or not basis.parent.exists():
|
||||
if log_fn:
|
||||
log_fn("Appdata overgeslagen: geen bruikbaar doelpad in de metadata.")
|
||||
return
|
||||
basis.mkdir(parents=True, exist_ok=True)
|
||||
for m in sorted(bron.iterdir()):
|
||||
if not m.is_dir() or "/" in m.name or m.name in ("", ".", ".."):
|
||||
continue
|
||||
doel = basis / m.name
|
||||
if doel.exists():
|
||||
shutil.rmtree(doel, ignore_errors=True)
|
||||
shutil.move(str(m), str(doel))
|
||||
if log_fn:
|
||||
log_fn(f"Appdata teruggezet: {m.name}")
|
||||
|
||||
|
||||
def _speel_dumps_in(stack: str, stackmap: Path, log_fn=None) -> list[str]:
|
||||
"""De meegeleverde dumps in de draaiende databases zetten.
|
||||
|
||||
Retourneert de fouten; een lege lijst betekent dat alles gelukt is, óók als
|
||||
er geen dumps in het archief zaten.
|
||||
"""
|
||||
dumps = sorted(stackmap.glob(".restore-*"))
|
||||
if not dumps:
|
||||
return []
|
||||
diensten = {d["service"]: d for d in databasediensten(stack)}
|
||||
fouten = []
|
||||
for dp in dumps:
|
||||
naam = dp.name[len(".restore-"):]
|
||||
service = naam.rsplit(".", 1)[0]
|
||||
dienst = diensten.get(service)
|
||||
if not dienst:
|
||||
fouten.append(f"{service}: hoort niet bij deze stack")
|
||||
dp.unlink(missing_ok=True)
|
||||
continue
|
||||
if not _wacht_op_database(dienst, log_fn=log_fn):
|
||||
fouten.append(f"{dienst['container']}: reageerde niet op tijd")
|
||||
continue
|
||||
cmd = _herstel_commando(dienst)
|
||||
r = docker._run([docker._bin(), "exec", "-i", dienst["container"]] + cmd,
|
||||
input=dp.read_bytes(), capture_output=True,
|
||||
text=False, timeout=900)
|
||||
if r.returncode != 0:
|
||||
rauw = (r.stderr or b"").decode("utf-8", "replace")
|
||||
fouten.append(f"{dienst['container']}: {rauw.strip().splitlines()[-1:] or ['fout']}")
|
||||
elif log_fn:
|
||||
log_fn(f"Database teruggezet in {dienst['container']}")
|
||||
dp.unlink(missing_ok=True)
|
||||
return fouten
|
||||
|
||||
|
||||
def _herstel_commando(dienst: dict) -> list[str]:
|
||||
s, gb, db, ww = (dienst["soort"], dienst["gebruiker"],
|
||||
dienst["database"], dienst["wachtwoord"])
|
||||
if s in ("postgres", "postgis"):
|
||||
return ["sh", "-c",
|
||||
f"PGPASSWORD='{ww}' psql -U '{gb or 'postgres'}' "
|
||||
f"-d '{db or gb or 'postgres'}'"]
|
||||
if s in ("mariadb", "mysql"):
|
||||
hulp = "mariadb" if s == "mariadb" else "mysql"
|
||||
return ["sh", "-c", f"{hulp} -u '{gb or 'root'}' -p'{ww}' '{db or gb}'"]
|
||||
return ["sh", "-c",
|
||||
f"mongorestore --archive --drop --username='{gb or 'app'}' "
|
||||
f"--password='{ww}' --authenticationDatabase=admin"]
|
||||
|
||||
|
||||
def _wacht_op_database(dienst: dict, pogingen: int = 30, log_fn=None) -> bool:
|
||||
"""Een database heeft na het starten even nodig voor hij verbindingen aanneemt."""
|
||||
controle = {
|
||||
"postgres": ["pg_isready", "-U", dienst["gebruiker"] or "postgres"],
|
||||
"postgis": ["pg_isready", "-U", dienst["gebruiker"] or "postgres"],
|
||||
"mariadb": ["sh", "-c", f"mariadb-admin ping -p'{dienst['wachtwoord']}'"],
|
||||
"mysql": ["sh", "-c", f"mysqladmin ping -p'{dienst['wachtwoord']}'"],
|
||||
"mongo": ["sh", "-c", "mongosh --quiet --eval 'db.runCommand(1)'"],
|
||||
}[dienst["soort"]]
|
||||
for poging in range(pogingen):
|
||||
r = docker._run([docker._bin(), "exec", dienst["container"]] + controle,
|
||||
timeout=15)
|
||||
if r.returncode == 0:
|
||||
return True
|
||||
if poging == 0 and log_fn:
|
||||
log_fn(f"Wachten tot {dienst['container']} verbindingen aanneemt…")
|
||||
time.sleep(2)
|
||||
return False
|
||||
|
||||
|
||||
# ── Opruimen ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def delete(bestandsnaam: str) -> tuple[bool, str]:
|
||||
|
|
|
|||
|
|
@ -88,6 +88,10 @@ def mapgrootte(map_, limiet_bytes: int = 0) -> int:
|
|||
def ruimte_voor_backup(bron, doelmap) -> tuple[bool, str]:
|
||||
"""Past een backup van `bron` redelijkerwijs in `doelmap`?
|
||||
|
||||
`bron` mag één map zijn of een lijst — sinds de appdata meegaat in het
|
||||
archief is het er meer dan één, en dan moet de schatting over het geheel
|
||||
gaan. Anders keurt hij een backup goed die halverwege de schijf volmaakt.
|
||||
|
||||
De schatting is de ongecomprimeerde grootte van de bron. Dat is bewust aan
|
||||
de veilige kant: gzip maakt het archief kleiner, dus wie deze test haalt
|
||||
heeft ruimte over. Daar bovenop moet de ingestelde ondergrens vrij blijven.
|
||||
|
|
@ -98,7 +102,10 @@ def ruimte_voor_backup(bron, doelmap) -> tuple[bool, str]:
|
|||
|
||||
min_gb, _ = drempels()
|
||||
reserve = min_gb * GB
|
||||
nodig = mapgrootte(bron, limiet_bytes=u["free"])
|
||||
bronnen = [bron] if isinstance(bron, (str, Path)) else list(bron)
|
||||
nodig = 0
|
||||
for b in bronnen:
|
||||
nodig += mapgrootte(b, limiet_bytes=max(0, u["free"] - nodig))
|
||||
|
||||
if nodig + reserve > u["free"]:
|
||||
return False, (
|
||||
|
|
|
|||
|
|
@ -311,6 +311,15 @@ tailwind.config = {
|
|||
<span x-show="stackUpd[s.name] && stackUpd[s.name].update_available"
|
||||
class="badge badge-ok" :title="stackUpdTitle(s.name)"
|
||||
x-text="t('update_available')">update</span>
|
||||
<!-- Iets wat deze stack nodig heeft draait niet. Klikken brengt
|
||||
je naar de store om het alsnog te installeren. -->
|
||||
<button type="button" x-show="(s.depends_missing || []).length"
|
||||
class="badge badge-warn inline-flex items-center gap-1 cursor-pointer"
|
||||
:title="(s.depends_missing || []).map(d => d.app + ': ' + d.reason).join('\n')"
|
||||
@click="goto('app_store'); storeQuery = (s.depends_missing[0] || {}).app || ''">
|
||||
<span class="mdi mdi-link-variant-off text-[11px]"></span>
|
||||
<span x-text="(s.depends_missing || []).map(d => d.app).join(', ')"></span>
|
||||
</button>
|
||||
<span class="badge" :class="s.running ? 'badge-ok' : !s.has_compose ? 'badge-warn' : 'badge-dim'" x-text="!s.has_compose ? '—' : s.running ? t('running') : t('stopped')"></span>
|
||||
</div>
|
||||
<div class="p-4 flex flex-col gap-3 flex-1">
|
||||
|
|
@ -937,6 +946,13 @@ tailwind.config = {
|
|||
<input type="checkbox" :checked="!!settings.BACKUP_VERIFY" @change="settings.BACKUP_VERIFY=$event.target.checked; autoSave()" class="w-5 h-5 rounded border-bd dark:border-bd-dark text-ac">
|
||||
<span class="text-sm text-tx dark:text-tx-dark" x-text="t('backup_verify')"></span>
|
||||
</label>
|
||||
<div>
|
||||
<label class="flex items-center gap-2.5 cursor-pointer">
|
||||
<input type="checkbox" :checked="!!settings.BACKUP_APPDATA" @change="settings.BACKUP_APPDATA=$event.target.checked; autoSave()" class="w-5 h-5 rounded border-bd dark:border-bd-dark text-ac">
|
||||
<span class="text-sm text-tx dark:text-tx-dark" x-text="t('backup_appdata')"></span>
|
||||
</label>
|
||||
<p class="text-[11px] text-dm dark:text-dm-dark mt-1 ml-8" x-text="t('backup_appdata_hint')"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" x-text="t('backup_offsite_dir')">Tweede bestemming</label>
|
||||
<input class="form-input" placeholder="/mnt/backup" x-model="settings.BACKUP_OFFSITE_DIR" @change="autoSave()">
|
||||
|
|
@ -2167,7 +2183,7 @@ tailwind.config = {
|
|||
<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="wizard.step === 0">
|
||||
<div x-show="wizardStapId() === 'welkom'">
|
||||
<p class="text-sm">Doorloop deze korte wizard om Server Up te configureren.</p>
|
||||
<ul class="text-sm mt-4 space-y-2 text-t2 dark:text-t2-dark">
|
||||
<li class="flex gap-2"><span class="mdi mdi-check-circle text-ok"></span> Beheer Docker stacks via één UI</li>
|
||||
|
|
@ -2175,19 +2191,19 @@ tailwind.config = {
|
|||
<li class="flex gap-2"><span class="mdi mdi-check-circle text-ok"></span> Modules toevoegen voor MQTT, Home Assistant, etc.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div x-show="wizard.step === 1" class="space-y-3">
|
||||
<div x-show="wizardStapId() === 'paden'" class="space-y-3">
|
||||
<div><label class="form-label">Library map</label><input class="form-input" x-model="wizard.LIBRARY_DIR"></div>
|
||||
<div><label class="form-label">Data map</label><input class="form-input" x-model="wizard.DATA_DIR"></div>
|
||||
<div><label class="form-label">Backup map</label><input class="form-input" x-model="wizard.BACKUP_DIR"></div>
|
||||
</div>
|
||||
<div x-show="wizard.step === 2">
|
||||
<div x-show="wizardStapId() === 'repos'">
|
||||
<p class="text-sm mb-3">Server Up start met deze repos. Je kunt later meer toevoegen via Instellingen → Git Repositories.</p>
|
||||
<ul class="text-sm space-y-2.5">
|
||||
<li class="flex items-start gap-2"><span class="mdi mdi-check-circle text-ok mt-0.5"></span><span><strong>bes-r/server-up</strong> — eigen stacks & modules <span class="text-dm dark:text-dm-dark">(submap: apps / modules)</span></span></li>
|
||||
<li class="flex items-start gap-2"><span class="mdi mdi-check-circle text-ok mt-0.5"></span><span><strong>ChristianLempa/boilerplates-library</strong> — community templates <span class="text-dm dark:text-dm-dark">(submap: compose)</span></span></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div x-show="wizard.step === 3" class="space-y-3">
|
||||
<div x-show="wizardStapId() === 'git'" class="space-y-3">
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<button @click="wizard.git.choice = 'existing'" :class="wizard.git.choice === 'existing' ? 'border-ac text-ac dark:text-ac-dark bg-ac/5' : 'border-bd dark:border-bd-dark text-t2 dark:text-t2-dark'" class="flex flex-col items-center gap-1.5 rounded-xl border p-3 text-xs transition cursor-pointer hover:border-ac">
|
||||
<span class="mdi mdi-server text-2xl"></span><span class="font-semibold">Al een server</span><span class="text-[10px] text-center opacity-70">Gitea / Forgejo of andere git</span>
|
||||
|
|
@ -2212,7 +2228,63 @@ tailwind.config = {
|
|||
<p class="text-[11px] text-t2 dark:text-t2-dark">Forgejo wordt geïnstalleerd en gestart. Daarna kun je er zelf repos in aanmaken.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="wizard.step === 4"><p class="text-sm">We synchroniseren nu de repos en zijn klaar voor gebruik.</p></div>
|
||||
<!-- Bereikbaar van buitenaf. Server Up kan zelf publieke URL's aanmaken
|
||||
in Pangolin; deze stap zet die koppeling klaar, of installeert
|
||||
Pangolin hier. -->
|
||||
<div x-show="wizardStapId() === 'pangolin'" class="space-y-3">
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<button @click="wizard.pangolin.choice = 'existing'"
|
||||
:class="wizard.pangolin.choice === 'existing' ? 'border-ac text-ac dark:text-ac-dark bg-ac/5' : 'border-bd dark:border-bd-dark text-t2 dark:text-t2-dark'"
|
||||
class="flex flex-col items-center gap-1.5 rounded-xl border p-3 text-xs transition cursor-pointer hover:border-ac">
|
||||
<span class="mdi mdi-lan-connect text-xl"></span>
|
||||
<span x-text="t('wizard_pangolin_existing')">Ik heb er al een</span>
|
||||
</button>
|
||||
<button @click="wizard.pangolin.choice = 'install'"
|
||||
:class="wizard.pangolin.choice === 'install' ? 'border-ac text-ac dark:text-ac-dark bg-ac/5' : 'border-bd dark:border-bd-dark text-t2 dark:text-t2-dark'"
|
||||
class="flex flex-col items-center gap-1.5 rounded-xl border p-3 text-xs transition cursor-pointer hover:border-ac">
|
||||
<span class="mdi mdi-download-outline text-xl"></span>
|
||||
<span x-text="t('wizard_pangolin_install')">Hier installeren</span>
|
||||
</button>
|
||||
<button @click="wizard.pangolin.choice = 'skip'"
|
||||
:class="wizard.pangolin.choice === 'skip' ? 'border-ac text-ac dark:text-ac-dark bg-ac/5' : 'border-bd dark:border-bd-dark text-t2 dark:text-t2-dark'"
|
||||
class="flex flex-col items-center gap-1.5 rounded-xl border p-3 text-xs transition cursor-pointer hover:border-ac">
|
||||
<span class="mdi mdi-close text-xl"></span>
|
||||
<span x-text="t('skip')">Overslaan</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div x-show="wizard.pangolin.choice === 'existing'" class="space-y-2 pt-1">
|
||||
<div><label class="form-label" x-text="t('pangolin_url')">Adres</label>
|
||||
<input class="form-input" x-model="wizard.pangolin.url" placeholder="https://pangolin.example.com"></div>
|
||||
<div><label class="form-label" x-text="t('pangolin_api_key')">API-sleutel</label>
|
||||
<input class="form-input" type="password" x-model="wizard.pangolin.api_key"></div>
|
||||
<div><label class="form-label" x-text="t('pangolin_org')">Organisatie</label>
|
||||
<input class="form-input" x-model="wizard.pangolin.org_id" placeholder="mijn-org"></div>
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer pt-1">
|
||||
<input type="checkbox" x-model="wizard.pangolin.newt"
|
||||
class="rounded border-bd dark:border-bd-dark text-ac w-5 h-5">
|
||||
<span class="text-xs text-t2 dark:text-t2-dark" x-text="t('wizard_newt_too')"></span>
|
||||
</label>
|
||||
<div x-show="wizard.pangolin.newt" class="grid sm:grid-cols-2 gap-2">
|
||||
<div><label class="form-label">Site-id</label>
|
||||
<input class="form-input font-mono !text-xs" x-model="wizard.pangolin.newt_id"></div>
|
||||
<div><label class="form-label">Sitesleutel</label>
|
||||
<input class="form-input font-mono !text-xs" type="password" x-model="wizard.pangolin.newt_secret"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="wizard.pangolin.choice === 'install'" class="space-y-2 pt-1">
|
||||
<div><label class="form-label" x-text="t('base_domain')">Basisdomein</label>
|
||||
<input class="form-input" x-model="wizard.pangolin.base_domain" placeholder="example.com"></div>
|
||||
<div><label class="form-label" x-text="t('dashboard_domain')">Dashboarddomein</label>
|
||||
<input class="form-input" x-model="wizard.pangolin.dashboard_domain" placeholder="pangolin.example.com"></div>
|
||||
<div><label class="form-label" x-text="t('admin_email')">E-mailadres</label>
|
||||
<input class="form-input" x-model="wizard.pangolin.admin_email" placeholder="admin@example.com"></div>
|
||||
<p class="text-[11px] text-t2 dark:text-t2-dark" x-text="t('wizard_pangolin_hint')"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="wizardStapId() === 'klaar'"><p class="text-sm">We synchroniseren nu de repos en zijn klaar voor gebruik.</p></div>
|
||||
</div>
|
||||
<div class="px-7 py-4 border-t border-bd dark:border-bd-dark flex justify-end gap-2">
|
||||
<button class="btn" @click="wizard.open = false" x-text="t('close')">Sluiten</button>
|
||||
|
|
@ -2345,6 +2417,9 @@ function app() {
|
|||
logs: { title:'', content:'' },
|
||||
langEdit: { isNew:true, code:'', name:'', flag:'', keys:[], ref:{}, values:{}, query:'' },
|
||||
wizard: { open:false, step:0, steps: [], LIBRARY_DIR:'', DATA_DIR:'', BACKUP_DIR:'',
|
||||
pangolin: { choice:'skip', url:'', api_key:'', org_id:'',
|
||||
newt:false, newt_id:'', newt_secret:'',
|
||||
base_domain:'', dashboard_domain:'', admin_email:'' },
|
||||
git: { choice:'skip', url:'', token:'', subdir:'', port_web:3000, port_ssh:2222 } },
|
||||
|
||||
navItems: [
|
||||
|
|
@ -3665,12 +3740,15 @@ function app() {
|
|||
// Handmatig openen vanuit Instellingen, ongeacht of hij al gedraaid heeft.
|
||||
async openWizard() {
|
||||
const d = await this.rpc('/api/wizard');
|
||||
// Elke stap heeft een id. De afhandeling hing eerder aan het stapnummer,
|
||||
// en dat schuift op zodra er eentje tussenkomt.
|
||||
this.wizard.steps = [
|
||||
{ title: this.t('wizard_welcome') + ' 🚀', subtitle: this.t('wizard_welcome_sub') },
|
||||
{ title: this.t('wizard_paths'), subtitle: this.t('wizard_paths_sub') },
|
||||
{ title: this.t('module_repos'), subtitle: 'Server Up start met een ingebouwde stacks-repo en de ChristianLempa Boilerplates.' },
|
||||
{ title: this.t('wizard_git'), subtitle: this.t('wizard_git_sub') },
|
||||
{ title: this.t('wizard_done'), subtitle: this.t('wizard_done_sub') },
|
||||
{ id: 'welkom', title: this.t('wizard_welcome') + ' 🚀', subtitle: this.t('wizard_welcome_sub') },
|
||||
{ id: 'paden', title: this.t('wizard_paths'), subtitle: this.t('wizard_paths_sub') },
|
||||
{ id: 'repos', title: this.t('module_repos'), subtitle: 'Server Up start met een ingebouwde stacks-repo en de ChristianLempa Boilerplates.' },
|
||||
{ id: 'git', title: this.t('wizard_git'), subtitle: this.t('wizard_git_sub') },
|
||||
{ id: 'pangolin', title: this.t('wizard_pangolin'), subtitle: this.t('wizard_pangolin_sub') },
|
||||
{ id: 'klaar', title: this.t('wizard_done'), subtitle: this.t('wizard_done_sub') },
|
||||
];
|
||||
this.wizard.step = 0;
|
||||
this.wizard.LIBRARY_DIR = d.defaults?.LIBRARY_DIR || '/opt/serverup/stacks';
|
||||
|
|
@ -3683,16 +3761,44 @@ function app() {
|
|||
await this.rpc('/api/wizard/reset', {});
|
||||
await this.openWizard();
|
||||
},
|
||||
// Welke stap staat er nu? Op id, niet op nummer.
|
||||
wizardStapId() {
|
||||
return (this.wizard.steps[this.wizard.step] || {}).id || '';
|
||||
},
|
||||
async wizardNext() {
|
||||
if (this.wizard.step < this.wizard.steps.length - 1) {
|
||||
if (this.wizard.step === 1) {
|
||||
const stap = this.wizardStapId();
|
||||
if (stap === 'paden') {
|
||||
await this.rpc('/api/wizard/paths', {
|
||||
LIBRARY_DIR: this.wizard.LIBRARY_DIR,
|
||||
DATA_DIR: this.wizard.DATA_DIR,
|
||||
BACKUP_DIR: this.wizard.BACKUP_DIR,
|
||||
});
|
||||
}
|
||||
if (this.wizard.step === 3) {
|
||||
if (stap === 'pangolin') {
|
||||
const pg = this.wizard.pangolin;
|
||||
if (pg.choice === 'existing') {
|
||||
await this.rpc('/api/wizard/pangolin', {
|
||||
action: 'settings', url: pg.url.trim(),
|
||||
api_key: pg.api_key.trim(), org_id: pg.org_id.trim(),
|
||||
});
|
||||
if (pg.newt && pg.newt_id.trim()) {
|
||||
const d = await this.rpc('/api/wizard/pangolin', {
|
||||
action: 'install_newt', endpoint: pg.url.trim(),
|
||||
newt_id: pg.newt_id.trim(), newt_secret: pg.newt_secret.trim(),
|
||||
});
|
||||
if (d.job_id) this.pollJob(d.job_id);
|
||||
}
|
||||
} else if (pg.choice === 'install') {
|
||||
const d = await this.rpc('/api/wizard/pangolin', {
|
||||
action: 'install_pangolin', base_domain: pg.base_domain.trim(),
|
||||
dashboard_domain: pg.dashboard_domain.trim(),
|
||||
admin_email: pg.admin_email.trim(),
|
||||
});
|
||||
if (d.job_id) this.pollJob(d.job_id);
|
||||
}
|
||||
}
|
||||
if (stap === 'git') {
|
||||
const g = this.wizard.git;
|
||||
if (g.choice === 'existing' && g.url.trim()) {
|
||||
await this.rpc('/api/wizard/git', { action: 'add', url: g.url.trim(), token: g.token.trim(), subdir: g.subdir.trim() });
|
||||
|
|
|
|||
|
|
@ -433,5 +433,21 @@
|
|||
"more": "More",
|
||||
"ok": "OK",
|
||||
"failed": "Failed",
|
||||
"install_again": "Install another"
|
||||
"install_again": "Install another",
|
||||
"wizard_pangolin": "Reachable from outside",
|
||||
"wizard_pangolin_sub": "Pangolin gives your apps an address on the internet without opening a port in your router.",
|
||||
"wizard_pangolin_existing": "I already have one",
|
||||
"wizard_pangolin_install": "Install it here",
|
||||
"wizard_newt_too": "Also install Newt (the tunnel to this server)",
|
||||
"wizard_pangolin_hint": "Your domain must point at this IP address and ports 80 and 443 must be free, or no certificate will be issued.",
|
||||
"pangolin_api_key": "API key",
|
||||
"base_domain": "Base domain",
|
||||
"dashboard_domain": "Dashboard domain",
|
||||
"admin_email": "Administrator e-mail",
|
||||
"skip": "Skip",
|
||||
"audit_act_pangolin": "Pangolin connected",
|
||||
"audit_act_install_newt": "Newt installed",
|
||||
"audit_act_install_pangolin": "Pangolin installed",
|
||||
"backup_appdata": "Include app data",
|
||||
"backup_appdata_hint": "The app's own data, plus a dump of every database. Turn this off for a media library or photo archive — there it runs into hundreds of gigabytes and restic or borg is the better tool."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -433,5 +433,21 @@
|
|||
"more": "Meer",
|
||||
"ok": "Gelukt",
|
||||
"failed": "Mislukt",
|
||||
"install_again": "Nog een keer"
|
||||
"install_again": "Nog een keer",
|
||||
"wizard_pangolin": "Bereikbaar van buitenaf",
|
||||
"wizard_pangolin_sub": "Met Pangolin krijgen je apps een adres op internet zonder dat je een poort openzet in je router.",
|
||||
"wizard_pangolin_existing": "Ik heb er al een",
|
||||
"wizard_pangolin_install": "Hier installeren",
|
||||
"wizard_newt_too": "Ook Newt installeren (de tunnel naar deze server)",
|
||||
"wizard_pangolin_hint": "Je domein moet naar dit IP-adres wijzen en poort 80 en 443 moeten vrij zijn, anders komt er geen certificaat.",
|
||||
"pangolin_api_key": "API-sleutel",
|
||||
"base_domain": "Basisdomein",
|
||||
"dashboard_domain": "Dashboarddomein",
|
||||
"admin_email": "E-mailadres beheerder",
|
||||
"skip": "Overslaan",
|
||||
"audit_act_pangolin": "Pangolin gekoppeld",
|
||||
"audit_act_install_newt": "Newt geïnstalleerd",
|
||||
"audit_act_install_pangolin": "Pangolin geïnstalleerd",
|
||||
"backup_appdata": "Appdata meenemen",
|
||||
"backup_appdata_hint": "De gegevens van de app zelf, plus een dump van elke database. Zet dit uit bij een mediabibliotheek of fotoarchief — daar loopt het in de honderden gigabytes en is restic of borg een beter middel."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -152,9 +152,11 @@ def test_bijgeleverde_database_wordt_afgewacht(app):
|
|||
# Poorten die meerdere apps bewust delen: alternatieven voor dezelfde taak,
|
||||
# die je nooit tegelijk draait. Alles daarbuiten hoort uniek te zijn.
|
||||
GEDEELDE_POORTEN = {
|
||||
80: {"nginx-proxy-manager", "traefik", "caddy", "zoraxy"}, # reverse proxies
|
||||
443: {"nginx-proxy-manager", "traefik", "caddy", "zoraxy"},
|
||||
80: {"nginx-proxy-manager", "traefik", "caddy", "zoraxy", "pangolin"}, # reverse proxies
|
||||
443: {"nginx-proxy-manager", "traefik", "caddy", "zoraxy", "pangolin"},
|
||||
53: {"adguard-home", "pihole"}, # dns-blokkers
|
||||
# Twee manieren om een WireGuard-tunnel te draaien; je gebruikt er één.
|
||||
51820: {"pangolin", "wg-easy"},
|
||||
# Hieronder: botsingen die er al waren vóór deze testsuite. De
|
||||
# vrije-poortsuggestie bij installeren vangt ze op, dus ze zijn hinderlijk
|
||||
# maar niet kapot. Nieuwe apps horen hier niet bij te komen.
|
||||
|
|
|
|||
|
|
@ -184,3 +184,135 @@ def test_endpoint_restore_zonder_bestand(client, bk):
|
|||
r = client.post("/api/backups/vaultwarden/restore", json={},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# ── De appdata en de databases ───────────────────────────────────────────────
|
||||
# Een backup bevatte alleen de stackmap. LIBRARY_DIR en de appdata zijn buren,
|
||||
# geen genestelde mappen, dus je zette een backup terug en hield een lege app
|
||||
# over — met een groen vinkje erbij.
|
||||
|
||||
def _stack_met_appdata(env, naam="umami", inhoud=b"gegevens"):
|
||||
"""Een geïnstalleerde stack met een appdata-map ernaast."""
|
||||
import json as _json
|
||||
d = env["lib"] / naam
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / "docker-compose.yml").write_text(
|
||||
"services:\n"
|
||||
f" {naam}:\n"
|
||||
" image: nginx\n"
|
||||
f" {naam}-db:\n"
|
||||
" image: postgres:16-alpine\n"
|
||||
f" container_name: {naam}-db\n"
|
||||
" environment:\n"
|
||||
" - POSTGRES_USER=umami\n"
|
||||
" - POSTGRES_DB=umami\n"
|
||||
" - POSTGRES_PASSWORD=${DB_PASSWORD}\n")
|
||||
(d / ".env").write_text("DB_PASSWORD=geheim123\n")
|
||||
appdata = Path(str(env["tmp"])) / "appdata"
|
||||
(appdata / naam).mkdir(parents=True, exist_ok=True)
|
||||
(appdata / f"{naam}-db").mkdir(parents=True, exist_ok=True)
|
||||
(appdata / naam / "bestand.txt").write_bytes(inhoud)
|
||||
(d / ".serverup.json").write_text(_json.dumps({
|
||||
"source": naam,
|
||||
"values": {"appdata_dir": str(appdata), "service_name": naam},
|
||||
}))
|
||||
return d, appdata
|
||||
|
||||
|
||||
def test_appdata_gaat_mee_in_het_archief(env, monkeypatch):
|
||||
import tarfile
|
||||
from core import backups, docker
|
||||
monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt())
|
||||
_stack_met_appdata(env)
|
||||
|
||||
meta = backups.create("umami")
|
||||
with tarfile.open(backups.backup_dir() / meta["file"]) as tar:
|
||||
namen = tar.getnames()
|
||||
assert "umami/docker-compose.yml" in namen
|
||||
assert "appdata/umami/bestand.txt" in namen
|
||||
assert "appdata/umami-db" in namen
|
||||
assert meta["appdata"] == ["umami", "umami-db"]
|
||||
|
||||
|
||||
def test_appdata_kan_uit(env, monkeypatch):
|
||||
"""Bij een mediabibliotheek loopt dit in de honderden gigabytes."""
|
||||
import tarfile
|
||||
from core import backups, docker
|
||||
monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt())
|
||||
_stack_met_appdata(env)
|
||||
|
||||
meta = backups.create("umami", appdata=False)
|
||||
with tarfile.open(backups.backup_dir() / meta["file"]) as tar:
|
||||
namen = tar.getnames()
|
||||
assert not [n for n in namen if n.startswith("appdata/")]
|
||||
assert meta["appdata"] == []
|
||||
|
||||
|
||||
def test_terugzetten_herstelt_de_appdata(env, monkeypatch):
|
||||
from core import backups, docker
|
||||
monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt())
|
||||
monkeypatch.setattr(docker, "compose_up", lambda *a, **k: 0)
|
||||
monkeypatch.setattr(docker, "compose_down", lambda *a, **k: 0)
|
||||
_d, appdata = _stack_met_appdata(env)
|
||||
|
||||
meta = backups.create("umami")
|
||||
(appdata / "umami" / "bestand.txt").write_bytes(b"kapot")
|
||||
|
||||
ok, msg = backups.restore("umami", meta["file"])
|
||||
assert ok, msg
|
||||
assert (appdata / "umami" / "bestand.txt").read_bytes() == b"gegevens"
|
||||
|
||||
|
||||
def test_databasedienst_wordt_herkend_met_wachtwoord_uit_env(env, monkeypatch):
|
||||
"""Het wachtwoord staat als ${DB_PASSWORD} in compose en met de waarde in
|
||||
.env — zonder die stap draait pg_dump met een lege sleutel."""
|
||||
from core import backups, docker
|
||||
monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt())
|
||||
_stack_met_appdata(env)
|
||||
|
||||
diensten = backups.databasediensten("umami")
|
||||
assert len(diensten) == 1
|
||||
d = diensten[0]
|
||||
assert d["container"] == "umami-db"
|
||||
assert d["soort"] == "postgres"
|
||||
assert d["gebruiker"] == "umami"
|
||||
assert d["wachtwoord"] == "geheim123"
|
||||
|
||||
|
||||
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."""
|
||||
from core import backups, docker
|
||||
monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt(b"container niet gestart"))
|
||||
_stack_met_appdata(env)
|
||||
|
||||
regels = []
|
||||
meta = backups.create("umami", log_fn=regels.append)
|
||||
assert meta["dumps"] == []
|
||||
assert any("Geen dump van umami-db" in r for r in regels), regels
|
||||
|
||||
|
||||
def test_dump_belandt_in_het_archief(env, monkeypatch):
|
||||
import tarfile
|
||||
from core import backups, docker
|
||||
monkeypatch.setattr(docker, "_run",
|
||||
lambda *a, **k: _gelukt(b"-- pg_dump\nCREATE TABLE x;\n"))
|
||||
_stack_met_appdata(env)
|
||||
|
||||
meta = backups.create("umami")
|
||||
with tarfile.open(backups.backup_dir() / meta["file"]) as tar:
|
||||
namen = tar.getnames()
|
||||
inhoud = tar.extractfile("dumps/umami-db.sql").read()
|
||||
assert "dumps/umami-db.sql" in namen
|
||||
assert b"CREATE TABLE x;" in inhoud
|
||||
assert meta["dumps"] == ["umami-db.sql"]
|
||||
|
||||
|
||||
def _mislukt(stderr=b"fout"):
|
||||
import subprocess
|
||||
return subprocess.CompletedProcess([], 1, b"", stderr)
|
||||
|
||||
|
||||
def _gelukt(stdout=b""):
|
||||
import subprocess
|
||||
return subprocess.CompletedProcess([], 0, stdout, b"")
|
||||
|
|
|
|||
|
|
@ -80,3 +80,40 @@ def test_gereserveerde_ips_worden_gelezen_uit_stacks(client, env):
|
|||
with app_module.app.test_request_context():
|
||||
assert "192.168.1.241" in app_module._reserved_ips()
|
||||
assert "192.168.1.241" not in app_module._reserved_ips(exclude_stack="vaultwarden")
|
||||
|
||||
|
||||
def test_stack_meldt_wat_er_niet_meer_draait(client, env, monkeypatch):
|
||||
"""De melding bij het installeren was eenmalig. Haal je Mosquitto later weg,
|
||||
dan valt Zigbee2MQTT stil zonder dat iets zegt waarom."""
|
||||
import json as _json
|
||||
from core import docker, git
|
||||
login(client)
|
||||
|
||||
bron = git.cache_dir("server-up") / "apps" / "z2m"
|
||||
(bron / "files").mkdir(parents=True)
|
||||
(bron / "template.json").write_text(_json.dumps({
|
||||
"kind": "compose",
|
||||
"metadata": {"name": "Zigbee2MQTT", "description": "d", "tags": ["mqtt"],
|
||||
"depends_on": [{"app": "mosquitto", "reason": "broker nodig"}]},
|
||||
"variables": [],
|
||||
}), encoding="utf-8")
|
||||
(bron / "files" / "compose.yaml").write_text("services: {}\n", encoding="utf-8")
|
||||
|
||||
d = env["lib"] / "z2m"
|
||||
d.mkdir(parents=True)
|
||||
(d / "docker-compose.yml").write_text("services:\n a:\n image: x\n")
|
||||
(d / ".serverup.json").write_text(_json.dumps(
|
||||
{"source": "z2m", "repo_id": "server-up"}))
|
||||
monkeypatch.setattr(docker, "compose_ps", lambda *a, **k: [])
|
||||
|
||||
st = {s["name"]: s for s in client.get("/api/stacks").get_json()}
|
||||
assert st["z2m"]["depends_missing"] == [
|
||||
{"app": "mosquitto", "reason": "broker nodig"}]
|
||||
|
||||
# Zodra de broker er staat, is de melding weg.
|
||||
mq = env["lib"] / "mqtt"
|
||||
mq.mkdir()
|
||||
(mq / "docker-compose.yml").write_text("services:\n a:\n image: x\n")
|
||||
(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"] == []
|
||||
|
|
|
|||
Loading…
Reference in a new issue