feat(apps): eigen mappen en apparaten per app instelbaar
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 6m57s
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 6m57s
Een mediamap koppelen of een USB-stick doorgeven kon alleen door het compose-bestand met de hand te bewerken, en dat werd bij de eerstvolgende 'Instellingen wijzigen' overschreven. - Nieuwe stap 'Extra's' in het invulmenu: per onderdeel een map van de host (optioneel alleen lezen) of een apparaat. - De keuzes staan in .serverup.json en worden ná het renderen toegepast, dus ze overleven elke wijziging. - /api/devices vindt seriële poorten (/dev/serial/by-id, ttyUSB*, ttyACM*) en videokaarten, met een 'vast'-label voor by-id-paden. - Het hostpad wordt vooraf gecontroleerd via /api/paths/check. - Docker-socket, /etc, /proc, /sys en /boot worden geweigerd met uitleg, in compose_transform en nog eens in de API. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
This commit is contained in:
parent
b40a6a5a4c
commit
07d5effcec
10 changed files with 588 additions and 6 deletions
22
CHANGELOG.md
22
CHANGELOG.md
|
|
@ -1,3 +1,25 @@
|
|||
# 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
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.8.25-beta
|
||||
0.8.26-beta
|
||||
|
|
|
|||
120
server-up/app.py
120
server-up/app.py
|
|
@ -1,6 +1,6 @@
|
|||
"""Server Up — Docker Manager."""
|
||||
from __future__ import annotations
|
||||
import inspect, json, os, re, secrets, shutil, socket, subprocess, sys, time
|
||||
import glob, inspect, json, os, re, secrets, shutil, socket, subprocess, sys, time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -1114,6 +1114,7 @@ def api_stack_config(name):
|
|||
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": []},
|
||||
source=meta.get("source", ""), repo_id=meta.get("repo_id", ""),
|
||||
name=meta.get("name", name),
|
||||
# Waar dit sjabloon vandaan komt. Opslaan rendert de app
|
||||
|
|
@ -1177,6 +1178,9 @@ def api_stack_reconfigure(name):
|
|||
# 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
|
||||
|
||||
# Ook hier: een veld leegmaken mag niet stilzwijgend een kapotte stack
|
||||
# opleveren — en er ligt al een werkende versie die we niet moeten slopen.
|
||||
|
|
@ -1261,6 +1265,21 @@ def api_stack_reconfigure(name):
|
|||
+ ", ".join(f"'{n}'" for n in klaar)
|
||||
+ " (" + ", ".join(diensten_nu) + ")")
|
||||
|
||||
# Eigen mappen en apparaten als laatste: die horen ook op een
|
||||
# service die net een eigen IP of netwerk kreeg.
|
||||
if cf and (extras.get("volumes") or extras.get("devices")):
|
||||
try:
|
||||
cf.write_text(compose_transform.add_extras(
|
||||
cf.read_text(encoding="utf-8"), extras), encoding="utf-8")
|
||||
for v in extras.get("volumes") or []:
|
||||
jobs.log(qq, "ok", f"Map gekoppeld: {v['host']} → "
|
||||
f"{v['service']}:{v['container']}")
|
||||
for a in extras.get("devices") or []:
|
||||
jobs.log(qq, "ok", f"Apparaat gekoppeld: {a['host']} → "
|
||||
f"{a['service']}")
|
||||
except compose_transform.TransformError as e:
|
||||
jobs.log(qq, "warn", f"Extra's niet toegepast: {e}")
|
||||
|
||||
ok_cfg, err = docker.validate_compose(d)
|
||||
if not ok_cfg:
|
||||
jobs.log(qq, "error", f"Compose is ongeldig:\n{err}")
|
||||
|
|
@ -1275,6 +1294,7 @@ def api_stack_reconfigure(name):
|
|||
"network": net_name, "ip": net_ip, "ips": net_ips,
|
||||
"app_networks": app_netten,
|
||||
"app_services": app_diensten,
|
||||
"extras": extras,
|
||||
"service_ports": poorten_meta,
|
||||
"web": boilerplates.metadata(bron).get("web") or {},
|
||||
"connected": bool(app_netten)})
|
||||
|
|
@ -1648,6 +1668,52 @@ def _gekozen_app_netwerken(body: dict, standaard: str) -> list[str]:
|
|||
return [standaard] if verbinden else []
|
||||
|
||||
|
||||
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?
|
||||
|
||||
|
|
@ -1976,6 +2042,9 @@ def api_store_install():
|
|||
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
|
||||
verbinden = bool(app_netten)
|
||||
if not stack:
|
||||
return jsonify(ok=False, msg="stack vereist")
|
||||
|
|
@ -2250,6 +2319,21 @@ def api_store_install():
|
|||
except compose_transform.TransformError as e:
|
||||
jobs.log(qq, "warn", f"Koppelen mislukt: {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)
|
||||
|
|
@ -2288,6 +2372,7 @@ def api_store_install():
|
|||
"connected": bool(app_netten),
|
||||
"app_networks": app_netten,
|
||||
"app_services": app_diensten,
|
||||
"extras": extras,
|
||||
"service_ports": poorten_meta,
|
||||
"categories": meta.get("categories") or [],
|
||||
"tags": meta.get("tags") or [],
|
||||
|
|
@ -3826,6 +3911,39 @@ def api_path_check():
|
|||
return jsonify(**uitslag)
|
||||
|
||||
|
||||
@app.route("/api/devices")
|
||||
def api_devices():
|
||||
"""Welke apparaten kun je aan een app doorgeven?
|
||||
|
||||
Zelf `/dev/serial/by-id/` uitpluizen via ssh is precies het soort werk dat
|
||||
deze interface hoort weg te nemen. Seriële poorten (USB-sticks voor Zigbee,
|
||||
Z-Wave, P1) en videokaarten voor transcoderen.
|
||||
"""
|
||||
uit = []
|
||||
# by-id blijft gelijk als je hem in een andere poort steekt; ttyUSB0 niet.
|
||||
for map_ in ("/dev/serial/by-id", "/dev/serial/by-path"):
|
||||
p = Path(map_)
|
||||
if not p.is_dir():
|
||||
continue
|
||||
for kind in sorted(p.iterdir()):
|
||||
try:
|
||||
doel = os.path.realpath(kind)
|
||||
except OSError:
|
||||
doel = ""
|
||||
uit.append({"path": str(kind), "kind": "serieel",
|
||||
"target": doel, "stable": map_.endswith("by-id")})
|
||||
if uit:
|
||||
break
|
||||
for patroon, soort in (("/dev/ttyUSB*", "serieel"), ("/dev/ttyACM*", "serieel"),
|
||||
("/dev/dri/render*", "gpu"), ("/dev/dri/card*", "gpu"),
|
||||
("/dev/video*", "video"), ("/dev/bus/usb/*/*", "usb")):
|
||||
for pad in sorted(glob.glob(patroon))[:40]:
|
||||
if any(x["path"] == pad or x.get("target") == pad for x in uit):
|
||||
continue
|
||||
uit.append({"path": pad, "kind": soort, "target": "", "stable": False})
|
||||
return jsonify(devices=uit[:80], found=bool(uit))
|
||||
|
||||
|
||||
@app.route("/api/paths/move", methods=["POST"])
|
||||
def api_paths_move():
|
||||
"""Verplaats de mappen van Server Up, met de stacks erbij.
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ Voorbeeld:
|
|||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
|
|
@ -438,3 +440,102 @@ 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)
|
||||
|
|
|
|||
|
|
@ -2012,6 +2012,96 @@ 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">
|
||||
<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">
|
||||
|
|
@ -2720,6 +2810,7 @@ function app() {
|
|||
|
||||
newRepo: { name:'', url:'', branch:'main', subdir:'apps' },
|
||||
repoEdit: { id:'', name:'', url:'', branch:'', subdir:'' },
|
||||
devices: [],
|
||||
install: {
|
||||
stack: '', repo_id: '', instance: '',
|
||||
format: 'compose', loading: false,
|
||||
|
|
@ -3356,6 +3447,37 @@ 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.
|
||||
|
|
@ -3868,6 +3990,7 @@ function app() {
|
|||
step: 0, tab: 'compose', editing: false, composeText: '', envText: '',
|
||||
network: '', ip: '', ips: {}, services: [], service_info: [],
|
||||
app_services: [], depends_on: [],
|
||||
extras: { volumes: [], devices: [] },
|
||||
compatibility: { known: false, supported: true, architecture: '', reason: '' },
|
||||
architecture_note: ''
|
||||
};
|
||||
|
|
@ -3951,6 +4074,7 @@ function app() {
|
|||
verbinden: { icon: 'mdi-lan-connect', key: 'step_network' },
|
||||
instellingen: { icon: 'mdi-cog-outline', key: 'step_settings' },
|
||||
geheimen: { icon: 'mdi-key-variant', key: 'step_secrets' },
|
||||
extras: { icon: 'mdi-usb-flash-drive-outline', key: 'step_extras' },
|
||||
controleren: { icon: 'mdi-file-check-outline', key: 'step_review' },
|
||||
},
|
||||
wizardActief() {
|
||||
|
|
@ -3972,6 +4096,7 @@ function app() {
|
|||
// 'basis' toont ook de instantienaam, dus die stap is nooit leeg.
|
||||
if (id === 'basis' || this.stepFields(id).length) uit.push(this._stap(id));
|
||||
}
|
||||
uit.push(this._stap('extras'));
|
||||
uit.push(this._stap('controleren'));
|
||||
return uit;
|
||||
},
|
||||
|
|
@ -4025,7 +4150,7 @@ function app() {
|
|||
stepMissing() {
|
||||
if (!this.wizardActief()) return [];
|
||||
const s = this.wizardSteps()[this.install.step];
|
||||
if (!s || s.id === 'controleren' || s.id === 'verbinden') return [];
|
||||
if (!s || ['controleren', 'verbinden', 'extras'].includes(s.id)) return [];
|
||||
return this.stepFields(s.id).filter(f => {
|
||||
if (!f.required || f.is_group_toggle) return false;
|
||||
if (f.group_toggle && !this.install.values[f.group_toggle]) return false;
|
||||
|
|
@ -4044,7 +4169,8 @@ function app() {
|
|||
const stapId = stappen[this.install.step].id;
|
||||
// Ook bij Verbinden: daar staat de lijst containers die uit de
|
||||
// voorvertoning komt, en die verandert met elk onderdeel dat je aanzet.
|
||||
if (['controleren', 'verbinden'].includes(stapId)) await this.refreshPreview();
|
||||
if (['controleren', 'verbinden', 'extras'].includes(stapId)) await this.refreshPreview();
|
||||
if (stapId === 'extras' && !this.devices.length) await this.loadDevices();
|
||||
// Meteen laten zien of de poorten van deze stap vrij zijn.
|
||||
this.checkPoortenVanStap(stapId);
|
||||
},
|
||||
|
|
@ -4120,6 +4246,8 @@ function app() {
|
|||
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 || [] },
|
||||
};
|
||||
await this.loadAppNets(stack);
|
||||
this.fieldInfo = null;
|
||||
|
|
@ -4142,6 +4270,7 @@ function app() {
|
|||
connect_apps: this.install.connect_apps !== false,
|
||||
app_networks: this.install.app_networks || [],
|
||||
app_services: this.install.app_services || [],
|
||||
extras: this.install.extras || {},
|
||||
});
|
||||
if (!d.job_id) { this.toast(d.msg || this.t('error'), 'er'); return; }
|
||||
this.modal = null;
|
||||
|
|
@ -4176,6 +4305,7 @@ 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 || {};
|
||||
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
|
||||
|
|
|
|||
|
|
@ -57,6 +57,18 @@
|
|||
"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.",
|
||||
"add_repo": "Add repository",
|
||||
"repo_name": "Name",
|
||||
"repo_url": "Git URL",
|
||||
|
|
|
|||
|
|
@ -57,6 +57,18 @@
|
|||
"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.",
|
||||
"add_repo": "Repository toevoegen",
|
||||
"repo_name": "Naam",
|
||||
"repo_url": "Git URL",
|
||||
|
|
|
|||
|
|
@ -40,8 +40,8 @@ c.install = {format:'boilerplate', loading:false, fields:[
|
|||
], values:{}, step:0};
|
||||
c.onePage = false; c.showAdvanced = false;
|
||||
let s = c.wizardSteps().map(x => x.id);
|
||||
check('eenvoudige app: basis, verbinden, controleren',
|
||||
JSON.stringify(s) === '["basis","verbinden","controleren"]');
|
||||
check('eenvoudige app: basis, verbinden, extra\'s, controleren',
|
||||
JSON.stringify(s) === '["basis","verbinden","extras","controleren"]');
|
||||
|
||||
// ── App met geheimen ────────────────────────────────────────────────────────
|
||||
c.install.fields.push(veld('db_password','geheimen',{required:true, secret:true}));
|
||||
|
|
|
|||
|
|
@ -158,3 +158,97 @@ 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
|
||||
|
|
|
|||
|
|
@ -290,3 +290,96 @@ 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/")
|
||||
|
|
|
|||
Loading…
Reference in a new issue