471 lines
16 KiB
Markdown
471 lines
16 KiB
Markdown
# Eigen apps toevoegen
|
||
|
||
Een app in Server Up is een map met twee dingen: `template.json` beschrijft wat
|
||
de app is en welke vragen je bij het installeren krijgt, en `files/compose.yaml`
|
||
is het compose-bestand met plaatshouders erin.
|
||
|
||
```
|
||
apps/mijn-app/
|
||
├── template.json
|
||
└── files/
|
||
└── compose.yaml
|
||
```
|
||
|
||
Zet die map in `apps/`, of in je eigen git-repo die je toevoegt bij
|
||
**Instellingen → App Store → Repository**.
|
||
|
||
---
|
||
|
||
## Het kortste voorbeeld dat werkt
|
||
|
||
**`template.json`**
|
||
|
||
```json
|
||
{
|
||
"kind": "compose",
|
||
"metadata": {
|
||
"name": "Mijn App",
|
||
"description": "Wat de app doet, in één zin.",
|
||
"tags": ["voorbeeld"],
|
||
"categories": ["productiviteit"],
|
||
"icon": {"provider": "selfhst", "id": "mijn-app"},
|
||
"version": {"name": "latest"}
|
||
},
|
||
"variables": [
|
||
{
|
||
"title": "Algemeen",
|
||
"items": [
|
||
{"name": "service_name", "type": "str", "title": "Servicenaam",
|
||
"default": "mijn-app", "required": true},
|
||
{"name": "port", "type": "int", "title": "Web poort",
|
||
"default": 8200, "required": true},
|
||
{"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}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
**`files/compose.yaml`**
|
||
|
||
```yaml
|
||
services:
|
||
<< service_name >>:
|
||
image: voorbeeld/mijn-app:latest
|
||
container_name: << service_name >>
|
||
ports:
|
||
- "<< port >>:8080"
|
||
environment:
|
||
- TZ=<< timezone >>
|
||
volumes:
|
||
- << appdata_dir >>/<< service_name >>:/config
|
||
restart: unless-stopped
|
||
```
|
||
|
||
Dat is genoeg. De rest van dit document beschrijft wat je er nog meer mee kunt.
|
||
|
||
---
|
||
|
||
## Compose-syntaxis
|
||
|
||
De sjablonen gebruiken Jinja met afwijkende haakjes, zodat ze niet botsen met
|
||
de `${VAR}` van docker compose zelf:
|
||
|
||
| | |
|
||
|---|---|
|
||
| `<< variabele >>` | waarde invullen |
|
||
| `<%- if variabele %>` … `<%- endif %>` | blok alleen opnemen als de waarde waar is |
|
||
| `<< waarde \| lower >>` | filters van Jinja werken gewoon |
|
||
|
||
Het renderen gebeurt in een sandbox: je kunt niet bij Python-internals.
|
||
|
||
### Named volumes moeten gedeclareerd worden
|
||
|
||
Gebruik je een named volume, zet het dan óók in het top-level `volumes:`-blok.
|
||
Vergeet je dat, dan weigert docker compose met *"refers to undefined volume"*:
|
||
|
||
```yaml
|
||
services:
|
||
<< service_name >>-db:
|
||
image: postgres:16-alpine
|
||
volumes:
|
||
- << service_name >>_pgdata:/var/lib/postgresql/data
|
||
|
||
volumes:
|
||
<< service_name >>_pgdata:
|
||
```
|
||
|
||
De testsuite controleert dit voor elke app.
|
||
|
||
---
|
||
|
||
## Metadata
|
||
|
||
| Veld | Betekenis |
|
||
|---|---|
|
||
| `name` | Naam zoals getoond in de catalogus |
|
||
| `description` | Eén zin: wat is het, en waarvoor gebruik je het |
|
||
| `tags` | Vrije trefwoorden, gebruikt voor zoeken en filteren |
|
||
| `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
|
||
|
||
Een app mag in meerdere categorieën zitten. De ids:
|
||
|
||
`media` · `fotos` · `downloaden` · `domotica` · `3d-printen` · `netwerk` ·
|
||
`beveiliging` · `opslag` · `documenten` · `productiviteit` · `communicatie` ·
|
||
`financien` · `gezin` · `monitoring` · `ontwikkeling` · `ai` · `beheer` ·
|
||
`overig`
|
||
|
||
Elke categorie heeft een vaste kleur die overal in de interface terugkomt. De
|
||
definities staan in `server-up/core/categories.py`.
|
||
|
||
---
|
||
|
||
## Velden
|
||
|
||
Elk item onder `items` wordt één vraag in het installatieformulier.
|
||
|
||
```json
|
||
{
|
||
"name": "port",
|
||
"type": "int",
|
||
"title": "Web poort",
|
||
"default": 8200,
|
||
"required": true,
|
||
"description": "Korte tekst onder het veld",
|
||
"help": "Langere uitleg, zichtbaar achter het info-icoon",
|
||
"advanced": false,
|
||
"config": {"placeholder": "bv. 8200", "options": ["a", "b"]}
|
||
}
|
||
```
|
||
|
||
| Type | Wordt |
|
||
|---|---|
|
||
| `str` | tekstveld |
|
||
| `int` | getalveld |
|
||
| `bool` | aanvinkvakje |
|
||
| `enum` | keuzelijst, met de waarden uit `config.options` |
|
||
|
||
### Vaste namen
|
||
|
||
Deze namen worden speciaal behandeld:
|
||
|
||
- **`service_name`** — verplicht. Wordt de containernaam en het voorvoegsel van
|
||
alles in de stack. Wordt automatisch gevuld met de instantienaam.
|
||
- **Velden met `port` in de naam en type `int`** — krijgen bij het installeren
|
||
automatisch een vrij poortnummer voorgesteld als de standaard al bezet is.
|
||
- **Velden met `token`, `password`, `secret`, `wachtwoord`, `jwt` of `key` in de
|
||
naam** — worden als geheim behandeld: de waarde wordt gemaskeerd in het
|
||
installatielog en het veld krijgt een eigen weergave. Je kunt het ook
|
||
afdwingen met `"secret": true`.
|
||
|
||
### Waarden laten genereren
|
||
|
||
Voor een databasewachtwoord of een JWT-sleutel heeft de gebruiker geen zinnige
|
||
keuze; die moet gewoon willekeurig zijn. Zet daarvoor `generate`:
|
||
|
||
```json
|
||
{"name": "db_password", "type": "str", "title": "Databasewachtwoord",
|
||
"default": "", "required": true, "generate": "auto"}
|
||
```
|
||
|
||
| Waarde | Wat er gebeurt |
|
||
|---|---|
|
||
| `"auto"` | Het veld wordt bij het openen van het formulier gevuld met 32 willekeurige tekens. Voor waarden die de gebruiker nooit hoeft te kennen. |
|
||
| `true` | Alleen een knop naast het veld. Voor wachtwoorden waarmee de gebruiker zélf inlogt: die moet hij noteren, dus vul ze niet ongevraagd in. |
|
||
| weglaten | Geen knop. Voor waarden met een eigen formaat of herkomst — een bcrypt-hash, een token uit een andere app, een sleutel die bij iets anders moet passen. Iets verzonnens is daar juist fout. |
|
||
|
||
> **Zet nooit een wachtwoord als `default`.** Dan krijgt iedereen die de app
|
||
> installeert hetzelfde, en de meesten klikken erdoorheen. Een test weigert
|
||
> sjablonen met een vaste waarde op een geheim veld.
|
||
|
||
### In welke stap komt een veld?
|
||
|
||
Het installatieformulier is een invulmenu met vaste stappen. Server Up leidt af
|
||
waar een veld hoort, en meestal klopt dat:
|
||
|
||
| Stap | Wat er terechtkomt |
|
||
|---|---|
|
||
| **Basis** | `service_name`, poortvelden (type `int` met `port` in de naam), `appdata_dir`, `data_root`, `timezone`, `puid`/`pgid` |
|
||
| **Verbinden** | velden met `connect` |
|
||
| **Toegang** | alles wat als geheim geldt, plus gebruikersnamen en e-mailadressen |
|
||
| **Instellingen** | de rest |
|
||
|
||
Klopt dat voor jouw veld niet, zet het dan zelf:
|
||
|
||
```json
|
||
{"name": "retention", "type": "str", "title": "Bewaartermijn", "step": "basis"}
|
||
```
|
||
|
||
Geldige waarden: `basis`, `verbinden`, `instellingen`, `geheimen`. Een onbekende
|
||
waarde valt terug op `instellingen`.
|
||
|
||
### Inloggegevens
|
||
|
||
Velden met `credential` komen terug in het paneel **Inloggegevens** op de
|
||
stackkaart, zodat iemand na de installatie kan opzoeken waarmee hij moet
|
||
inloggen — handig bij een gegenereerd wachtwoord.
|
||
|
||
```json
|
||
{"name": "admin_user", "type": "str", "title": "Gebruikersnaam",
|
||
"default": "admin", "credential": "username"}
|
||
```
|
||
|
||
Meestal hoef je dit niet te zetten: een veld met `user`, `login` of `email` in de
|
||
naam geldt als `username`, en een geheim veld met `password` erin als `password`.
|
||
Tokens en sleutels tellen bewust níét mee — daar log je niet mee in.
|
||
|
||
### Verplichte velden
|
||
|
||
`"required": true` wordt afgedwongen: een lege waarde levert een foutmelding op
|
||
in plaats van een container die niet start of met een leeg geheim draait. Zit
|
||
het veld achter een groepsschakelaar die uitstaat, dan telt het niet mee.
|
||
|
||
### Geavanceerde velden
|
||
|
||
`"advanced": true` zet een veld achter het uitklapblok *Geavanceerde opties*.
|
||
Bedoeld voor wat je meestal met rust laat: database-wachtwoorden, PUID/PGID,
|
||
USB-paden, bewaartermijnen. Zet niet álles op geavanceerd — een test controleert
|
||
dat er per app zichtbare velden overblijven.
|
||
|
||
Je kunt het ook op een hele groep zetten: `{"title": "…", "advanced": true, …}`.
|
||
|
||
### Uitleg
|
||
|
||
`description` staat als kleine tekst onder het veld. `help` is de langere uitleg
|
||
achter het info-icoon; ontbreekt die, dan wordt `description` getoond. Gebruik
|
||
`help` voor dingen die een gebruiker echt moet weten:
|
||
|
||
```json
|
||
{
|
||
"name": "downloads_dir",
|
||
"type": "str",
|
||
"title": "Downloadmap",
|
||
"default": "/mnt/downloads",
|
||
"help": "Zet deze map op hetzelfde bestandssysteem als je mediamap. Staan ze op verschillende volumes, dan kopieert elke voltooide download in plaats van te verplaatsen."
|
||
}
|
||
```
|
||
|
||
### Velden afhankelijk maken
|
||
|
||
`needs` verbergt een veld tot een ander veld een bepaalde waarde heeft:
|
||
|
||
```json
|
||
{"name": "smtp_host", "type": "str", "title": "SMTP-server",
|
||
"needs": ["mail_enabled == true"]}
|
||
```
|
||
|
||
---
|
||
|
||
## Apps aan elkaar koppelen
|
||
|
||
Verwijst een veld naar een andere app, zet er dan `connect` op. De interface
|
||
toont dan een keuzelijst met wat er al geïnstalleerd is, in plaats van een leeg
|
||
tekstveld:
|
||
|
||
```json
|
||
{
|
||
"name": "mqtt_server",
|
||
"type": "str",
|
||
"title": "MQTT-server",
|
||
"default": "mqtt://mosquitto:1883",
|
||
"connect": {"app": "mosquitto", "scheme": "mqtt", "port": 1883},
|
||
"help": "Heb je de Mosquitto-app geïnstalleerd, kies die dan hier."
|
||
}
|
||
```
|
||
|
||
| Sleutel | Betekenis |
|
||
|---|---|
|
||
| `app` | De mapnaam van de app waarnaar verwezen wordt (`apps/mosquitto`) |
|
||
| `scheme` | Wat voor het adres komt: `http`, `mqtt`, … |
|
||
| `port` | Poort binnen het gedeelde netwerk (de interne poort, niet die op de host) |
|
||
| `path` | Optioneel pad achter het adres |
|
||
|
||
Dat levert bijvoorbeeld `mqtt://mosquitto:1883` op. Staat de app er nog niet, dan
|
||
meldt het veld dat en kun je alsnog handmatig invullen.
|
||
|
||
**Let op de poort:** dit gaat over de poort *binnen* de container, niet de poort
|
||
die op de host gepubliceerd is. De stacks zitten samen op een gedeeld netwerk en
|
||
bereiken elkaar op containernaam.
|
||
|
||
---
|
||
|
||
## Onderdelen optioneel maken
|
||
|
||
Voor stacks met meerdere componenten kun je per groep een schakelaar zetten:
|
||
|
||
```json
|
||
{
|
||
"title": "Bazarr",
|
||
"section": "Ondertitels en verwerking",
|
||
"toggle": "enable_bazarr",
|
||
"toggle_default": true,
|
||
"description": "Haalt automatisch ondertitels op",
|
||
"items": [
|
||
{"name": "port_bazarr", "type": "int", "title": "Poort", "default": 6767,
|
||
"advanced": true, "step": "instellingen"}
|
||
]
|
||
}
|
||
```
|
||
|
||
In het compose-bestand gebruik je die schakelaar als voorwaarde:
|
||
|
||
```yaml
|
||
<%- if enable_bazarr %>
|
||
<< service_name >>-bazarr:
|
||
image: lscr.io/linuxserver/bazarr:latest
|
||
ports:
|
||
- "<< port_bazarr >>:6767"
|
||
<%- endif %>
|
||
```
|
||
|
||
`toggle_default` bepaalt of het onderdeel standaard aan staat. Zonder die sleutel
|
||
staat hij aan.
|
||
|
||
**Vanaf twee schakelbare groepen krijgt het kiezen een eigen eerste stap.** Alle
|
||
schakelaars staan dan bij elkaar in *Onderdelen*, en `section` bepaalt onder welk
|
||
kopje ze daar terechtkomen — bij vijftig onderdelen is dat het verschil tussen
|
||
een overzicht en een lijst waar je in verdwaalt. Bij één schakelbare groep blijft
|
||
de schakelaar gewoon bij zijn eigen velden staan.
|
||
|
||
De velden van een onderdeel zet je op `"step": "instellingen"` en meestal ook op
|
||
`"advanced": true`: de poorten kloppen al, en ze horen niet in het eerste scherm
|
||
te staan als je door vijftig onderdelen loopt. De schakelaar zélf wordt nooit
|
||
geavanceerd — dat is juist wat je eerst kiest. Wil je een héél onderdeel achter
|
||
het uitklapblok, gebruik dan `toggle_advanced`.
|
||
|
||
De ARR-stack (`apps/arr-stack`) gebruikt dit voor negenenveertig onderdelen en is
|
||
het beste voorbeeld om van af te kijken. Let op: die wordt gegenereerd — pas
|
||
`tools/arr_sjablonen.py` aan en draai dat script, anders draait
|
||
`tests/test_arr_sjablonen.py` je wijziging terug.
|
||
|
||
---
|
||
|
||
## Waar je op moet letten
|
||
|
||
**Poorten.** Kies een standaardpoort die nog niet door een andere app gebruikt
|
||
wordt. De testsuite controleert dat, en meldt welke botsen. Alternatieven voor
|
||
dezelfde taak (twee reverse proxies, twee DNS-blokkers) mogen wel dezelfde poort
|
||
delen; die staan in een allowlist in `tests/test_apps.py`. Een onderdeel van de
|
||
ARR-stack mag dezelfde poort hebben als zijn losse sjabloon — dat is dezelfde
|
||
container, alleen anders verpakt.
|
||
|
||
**Een app met een eigen database.** Zet de database als tweede service in
|
||
hetzelfde compose-bestand, met het voorvoegsel van de app ervoor:
|
||
|
||
```yaml
|
||
<< service_name >>-db:
|
||
image: postgres:16-alpine
|
||
container_name: << service_name >>-db
|
||
environment:
|
||
- POSTGRES_PASSWORD=<< db_password >>
|
||
```
|
||
|
||
Dat voorvoegsel is geen opmaak: installeer je de app twee keer, dan botsen de
|
||
containernamen zonder. Het wachtwoord is een veld met `"secret": true` en
|
||
`"generate": "auto"`, zodat het formulier het invult en de waarde in `.env`
|
||
belandt in plaats van in het compose-bestand. Vergeet `depends_on` niet, anders
|
||
start de app vóór zijn database en valt hij om op de eerste verbinding.
|
||
|
||
**Een app die een andere nodig heeft.** Een `connect`-veld wijst naar een andere
|
||
app en vult een adres in, maar zegt niet dát je die nodig hebt. Zigbee2MQTT
|
||
zonder MQTT-broker start prima en doet niets. Zeg het daarom expliciet, in
|
||
`metadata`:
|
||
|
||
```json
|
||
"depends_on": [
|
||
{"app": "mosquitto",
|
||
"reason": "Zigbee2MQTT publiceert alles naar een MQTT-broker. Zonder broker start het wel, maar komt er niets aan."}
|
||
]
|
||
```
|
||
|
||
Het installatiescherm toont vóór het invullen wat er nog ontbreekt, met een knop
|
||
die je meteen naar dat sjabloon brengt. **Het is advies, geen blokkade** — je kunt
|
||
de broker net zo goed op een andere machine hebben staan.
|
||
|
||
De reden is verplicht: een waarschuwing zonder uitleg is een waarschuwing die
|
||
mensen wegklikken. Gebruik het alleen waar het écht klopt. `depends_on` kent geen
|
||
"een van deze", dus bij een app die met Nextcloud óf Seafile werkt laat je het
|
||
weg; een halve waarheid is daar erger dan geen.
|
||
|
||
`["mosquitto"]` zonder reden mag ook — die korte vorm is er voor sjablonen uit
|
||
externe repo's, die niets van deze sleutel hoeven te weten.
|
||
|
||
**Sleutels in een bepaald formaat.** `"generate": "auto"` maakt url-veilige
|
||
base64 van 24 bytes — 32 tekens. Wil de app iets anders, zeg dat er dan bij:
|
||
|
||
| `secret_format` | Levert | Voor |
|
||
|---|---|---|
|
||
| *(weglaten)* | url-veilige base64, 32 tekens | het meeste |
|
||
| `hex` | hexadecimaal, `secret_bytes` × 2 tekens | Homarr, LibreChat |
|
||
| `laravel` | `base64:` + standaard-base64 | Snipe-IT, Invoice Ninja, Pixelfed |
|
||
|
||
```json
|
||
{"name": "app_key", "type": "str", "title": "Applicatiesleutel",
|
||
"default": "", "required": true, "secret": true, "generate": "auto",
|
||
"secret_format": "laravel", "secret_bytes": 32}
|
||
```
|
||
|
||
Dit is geen kosmetiek: een sleutel in het verkeerde formaat levert een container
|
||
op die niet start, met een foutmelding die nergens naar de sleutel wijst.
|
||
|
||
**Een geheim dat je nergens vandaan kunt genereren** — het client-secret van een
|
||
OIDC-toepassing, een wachtwoord dat de gebruiker zelf kiest — markeer je met
|
||
`"eigen_invoer": true`. Anders klaagt de testsuite terecht dat er een verplicht
|
||
geheim zonder waarde en zonder generator in staat.
|
||
|
||
**De appdata-map heet `appdata_dir`, de gedeelde datamap `data_root`.** De eerste
|
||
is de plek waar déze app zijn instellingen bewaart (`/config`), de tweede is de
|
||
gedeelde berg met media en downloads die als `/data` in elke container komt. Zit
|
||
je app aan dezelfde bibliotheek als de *arr-apps, gebruik dan `data_root` — en
|
||
géén losse `/downloads`- of `/tv`-mount, want daarmee sneuvelen hardlinks.
|
||
|
||
**`network_mode`.** Heeft een service `network_mode: host` of
|
||
`network_mode: service:…`, dan kan hij niet aan het gedeelde netwerk hangen.
|
||
Server Up slaat die services over, dus het werkt — maar zo'n app kan andere apps
|
||
niet op naam bereiken.
|
||
|
||
**Images.** Gebruik een image waarvan je zeker weet dat hij bestaat en welke
|
||
architecturen hij ondersteunt. De testsuite controleert wel de vorm van je
|
||
compose, maar haalt geen images op.
|
||
|
||
---
|
||
|
||
## Testen
|
||
|
||
Elke app in `apps/` wordt automatisch meegenomen in de testsuite:
|
||
|
||
```bash
|
||
python -m pytest tests/test_apps.py -q
|
||
```
|
||
|
||
Gecontroleerd wordt: geldige metadata, renderen naar geldige YAML, elke service
|
||
heeft een `image` of `build`, named volumes zijn gedeclareerd, geen onvervangen
|
||
`<< variabelen >>`, geen onbedoeld dubbele poorten, geldige categorieën, en dat
|
||
`network_mode` niet botst met het gedeelde netwerk.
|
||
|
||
Wil je alleen jouw app zien:
|
||
|
||
```bash
|
||
python -m pytest tests/test_apps.py -q -k mijn-app
|
||
```
|