2026-08-25 17:18:22 +02:00
|
|
|
"""Toetst de vorm die umbrelOS van deze app store eist, voor élke app erin.
|
|
|
|
|
|
|
|
|
|
Waarom deze test bestaat. De twee bestaande testbestanden gaan over Electrum
|
|
|
|
|
Gate; ze noemen die app-map bij naam. Sinds 25-08-2026 zit er een tweede app in
|
|
|
|
|
deze repo en zijn er dus regels die niet over één app gaan maar over alle:
|
|
|
|
|
|
|
|
|
|
- het `id` in `umbrel-app.yml` moet gelijk zijn aan de mapnaam;
|
|
|
|
|
- dat id moet beginnen met het store-id uit `umbrel-app-store.yml`;
|
|
|
|
|
- de manifestvelden staan in de volgorde die de packaging-documentatie
|
|
|
|
|
voorschrijft;
|
|
|
|
|
- er is een `docker-compose.yml`, en de `app_proxy` daarin wijst naar een
|
|
|
|
|
service die in datzelfde bestand bestaat.
|
|
|
|
|
|
|
|
|
|
Dat zijn precies de fouten die je op het apparaat pas merkt: de app verschijnt
|
|
|
|
|
niet in de store, of hij verschijnt en start niet. Er komt geen nette
|
|
|
|
|
foutmelding, want de runtime-validatie van het manifest staat in umbreld
|
|
|
|
|
uitgecommentarieerd.
|
|
|
|
|
|
|
|
|
|
Wat deze toetsen expres NIET doen: eisen dat elke image op een digest gepind is.
|
|
|
|
|
Dat is wél de regel, maar op 25-08-2026 haalt geen van de twee apps hem, en een
|
|
|
|
|
suite die altijd rood staat wordt niet gelezen. Het staat als taak in de plannen
|
|
|
|
|
Umbrelapp en Publicatie-Gate. Wat hier wel gebeurt is de pinstatus afdrukken, zodat
|
|
|
|
|
je hem ziet zonder erover te struikelen.
|
|
|
|
|
|
|
|
|
|
Deze toetsen vinden hun apps zelf. Komt er een derde app bij, dan valt die
|
|
|
|
|
automatisch onder alles hierboven en hoeft hier niets bij.
|
|
|
|
|
|
|
|
|
|
Draaien:
|
|
|
|
|
|
|
|
|
|
python tests/test_appstore_vorm.py
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import sys
|
|
|
|
|
|
|
|
|
|
sys.dont_write_bytecode = True
|
|
|
|
|
|
|
|
|
|
import os # noqa: E402
|
|
|
|
|
|
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
|
REPO = os.path.abspath(os.path.join(HERE, os.pardir))
|
|
|
|
|
STORE = os.path.join(REPO, "umbrel-app-store.yml")
|
|
|
|
|
|
|
|
|
|
# De volgorde uit de packaging-documentatie. Wat de spec niet noemt (icon,
|
|
|
|
|
# backupIgnore, submitter, submission) mag erachter, niet ertussen.
|
|
|
|
|
VOORGESCHREVEN = [
|
|
|
|
|
"manifestVersion", "id", "category", "name", "version", "tagline",
|
|
|
|
|
"description", "releaseNotes", "developer", "website", "dependencies",
|
|
|
|
|
"repo", "support", "port", "gallery", "path",
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
# Velden die er per se moeten staan, ook al is het schema soepeler: zonder deze
|
|
|
|
|
# is de winkelpagina leeg of start de app niet.
|
|
|
|
|
VERPLICHT = ["manifestVersion", "id", "category", "name", "version", "tagline",
|
|
|
|
|
"description", "port"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Uitslag:
|
|
|
|
|
def __init__(self):
|
|
|
|
|
self.goed = 0
|
|
|
|
|
self.fout = []
|
|
|
|
|
|
|
|
|
|
def check(self, naam, gelukt, uitleg=""):
|
|
|
|
|
if gelukt:
|
|
|
|
|
self.goed += 1
|
|
|
|
|
else:
|
|
|
|
|
self.fout.append(naam + ((" - " + uitleg) if uitleg else ""))
|
|
|
|
|
|
|
|
|
|
def rapport(self):
|
|
|
|
|
print()
|
|
|
|
|
print("%d goed, %d fout" % (self.goed, len(self.fout)))
|
|
|
|
|
for f in self.fout:
|
|
|
|
|
print(" FOUT: " + f)
|
|
|
|
|
return 0 if not self.fout else 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def lees(pad):
|
|
|
|
|
with open(pad, "r", encoding="utf-8") as f:
|
|
|
|
|
return f.read()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def veld(tekst, naam):
|
|
|
|
|
"""De waarde van een veld op het eerste niveau, of None.
|
|
|
|
|
|
|
|
|
|
Geen YAML-lezer: de suite heeft geen afhankelijkheden en dat is een
|
|
|
|
|
projectregel. Daarom alleen velden die aan het begin van een regel staan.
|
|
|
|
|
"""
|
|
|
|
|
for regel in tekst.splitlines():
|
|
|
|
|
if regel.startswith(naam + ":"):
|
|
|
|
|
return regel.split(":", 1)[1].strip().strip('"').strip("'")
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def velden_in_volgorde(tekst):
|
|
|
|
|
"""De veldnamen op het eerste niveau, in de volgorde waarin ze staan."""
|
|
|
|
|
namen = []
|
|
|
|
|
for regel in tekst.splitlines():
|
|
|
|
|
if not regel or regel[0] in " \t#-":
|
|
|
|
|
continue
|
|
|
|
|
if ":" not in regel:
|
|
|
|
|
continue
|
|
|
|
|
namen.append(regel.split(":", 1)[0].strip())
|
|
|
|
|
return namen
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def app_mappen():
|
|
|
|
|
"""Elke map in de repo-root met een umbrel-app.yml erin."""
|
|
|
|
|
gevonden = []
|
|
|
|
|
for naam in sorted(os.listdir(REPO)):
|
|
|
|
|
pad = os.path.join(REPO, naam)
|
|
|
|
|
if not os.path.isdir(pad):
|
|
|
|
|
continue
|
|
|
|
|
if os.path.isfile(os.path.join(pad, "umbrel-app.yml")):
|
|
|
|
|
gevonden.append(naam)
|
|
|
|
|
return gevonden
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_er_is_een_store_met_apps(u, store_id, apps):
|
|
|
|
|
u.check("umbrel-app-store.yml heeft een id", bool(store_id))
|
|
|
|
|
u.check("en er is minstens een app-map gevonden", bool(apps),
|
|
|
|
|
"geen enkele map met een umbrel-app.yml")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_id_en_mapnaam(u, store_id, app):
|
|
|
|
|
"""Mapnaam == id == store-prefix + rest. Alle drie of de app bestaat niet."""
|
|
|
|
|
tekst = lees(os.path.join(REPO, app, "umbrel-app.yml"))
|
|
|
|
|
app_id = veld(tekst, "id")
|
|
|
|
|
|
|
|
|
|
u.check("%s: het manifest heeft een id" % app, bool(app_id))
|
|
|
|
|
if not app_id:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
u.check("%s: id is gelijk aan de mapnaam" % app, app_id == app,
|
|
|
|
|
"manifest zegt %r" % app_id)
|
|
|
|
|
u.check("%s: id begint met het store-id %r" % (app, store_id),
|
|
|
|
|
app_id.startswith(store_id + "-"),
|
|
|
|
|
"id is %r" % app_id)
|
|
|
|
|
u.check("%s: id is lowercase kebab-case" % app,
|
|
|
|
|
app_id == app_id.lower() and " " not in app_id and "_" not in app_id,
|
|
|
|
|
"id is %r" % app_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_verplichte_velden(u, app):
|
|
|
|
|
tekst = lees(os.path.join(REPO, app, "umbrel-app.yml"))
|
|
|
|
|
ontbreekt = [naam for naam in VERPLICHT if veld(tekst, naam) is None]
|
|
|
|
|
u.check("%s: alle verplichte manifestvelden staan erin" % app,
|
|
|
|
|
not ontbreekt, "ontbreekt: %r" % ontbreekt)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_manifest_volgorde(u, app):
|
|
|
|
|
"""De voorgeschreven velden in de voorgeschreven onderlinge orde.
|
|
|
|
|
|
|
|
|
|
Niet dat ze er alle zestien zijn: `dependencies` hoort weg te blijven bij een
|
|
|
|
|
app zonder afhankelijkheden. Wel dat wat er staat niet door elkaar loopt, want
|
|
|
|
|
dat is de eis waar een review op valt.
|
|
|
|
|
"""
|
|
|
|
|
aanwezig = velden_in_volgorde(lees(os.path.join(REPO, app, "umbrel-app.yml")))
|
|
|
|
|
volgens_spec = [n for n in aanwezig if n in VOORGESCHREVEN]
|
|
|
|
|
verwacht = [n for n in VOORGESCHREVEN if n in volgens_spec]
|
|
|
|
|
|
|
|
|
|
u.check("%s: de manifestvelden staan in de voorgeschreven volgorde" % app,
|
|
|
|
|
volgens_spec == verwacht,
|
|
|
|
|
"gevonden %r, verwacht %r" % (volgens_spec, verwacht))
|
|
|
|
|
|
|
|
|
|
# Wat de spec niet noemt hoort erachter, niet ertussen. Anders is de kop niet
|
|
|
|
|
# letterlijk goed en moet er bij inlevering geschoven worden.
|
|
|
|
|
laatste_spec = -1
|
|
|
|
|
for i, naam in enumerate(aanwezig):
|
|
|
|
|
if naam in VOORGESCHREVEN:
|
|
|
|
|
laatste_spec = i
|
|
|
|
|
extra_ertussen = [n for n in aanwezig[:laatste_spec] if n not in VOORGESCHREVEN]
|
|
|
|
|
u.check("%s: velden buiten de spec staan achteraan" % app,
|
|
|
|
|
not extra_ertussen, "ertussen: %r" % extra_ertussen)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_compose_bestaat_en_hangt_samen(u, app):
|
|
|
|
|
"""De app_proxy moet naar een service wijzen die bestaat.
|
|
|
|
|
|
|
|
|
|
Dit is een echte klasse fouten en niet een formaliteit: de hostnaam is
|
|
|
|
|
`<app-id>_<service>_1`, dus hij bevat het app-id én de servicenaam. Verandert
|
|
|
|
|
een van de twee, dan wijst de proxy naar niets en meldt umbrelOS alleen dat de
|
|
|
|
|
server onbereikbaar is. Precies het symptoom dat bij Electrum Gate 0.0.3 een
|
|
|
|
|
dag kostte.
|
|
|
|
|
"""
|
|
|
|
|
pad = os.path.join(REPO, app, "docker-compose.yml")
|
|
|
|
|
u.check("%s: er is een docker-compose.yml" % app, os.path.isfile(pad))
|
|
|
|
|
if not os.path.isfile(pad):
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
tekst = lees(pad)
|
|
|
|
|
|
|
|
|
|
# Servicenamen: twee spaties diep onder services:, eindigend op een dubbele
|
|
|
|
|
# punt. Geen YAML-lezer, zie veld().
|
|
|
|
|
services = []
|
|
|
|
|
in_services = False
|
|
|
|
|
for regel in tekst.splitlines():
|
|
|
|
|
if regel.startswith("services:"):
|
|
|
|
|
in_services = True
|
|
|
|
|
continue
|
|
|
|
|
if in_services and regel and not regel[0].isspace():
|
|
|
|
|
break
|
|
|
|
|
if in_services and regel.startswith(" ") and not regel.startswith(" "):
|
|
|
|
|
kaal = regel.strip()
|
|
|
|
|
if kaal.endswith(":") and not kaal.startswith("#"):
|
|
|
|
|
services.append(kaal[:-1])
|
|
|
|
|
|
|
|
|
|
u.check("%s: de compose heeft een app_proxy" % app,
|
|
|
|
|
"app_proxy" in services, "services: %r" % services)
|
|
|
|
|
|
|
|
|
|
app_host = None
|
|
|
|
|
for regel in tekst.splitlines():
|
|
|
|
|
if regel.strip().startswith("APP_HOST:"):
|
|
|
|
|
app_host = regel.split(":", 1)[1].strip()
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
u.check("%s: de app_proxy heeft een APP_HOST" % app, bool(app_host))
|
|
|
|
|
if not app_host:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
u.check("%s: APP_HOST begint met het app-id" % app,
|
|
|
|
|
app_host.startswith(app + "_"),
|
|
|
|
|
"APP_HOST is %r maar de map heet %r" % (app_host, app))
|
|
|
|
|
|
|
|
|
|
# De vorm is <app-id>_<service>_1; haal de servicenaam eruit en kijk of die
|
|
|
|
|
# bestaat.
|
|
|
|
|
rest = app_host[len(app) + 1:]
|
|
|
|
|
u.check("%s: APP_HOST eindigt op _1" % app, rest.endswith("_1"),
|
|
|
|
|
"APP_HOST is %r" % app_host)
|
|
|
|
|
service = rest[:-2] if rest.endswith("_1") else rest
|
|
|
|
|
u.check("%s: APP_HOST wijst naar een service die bestaat" % app,
|
|
|
|
|
service in services,
|
|
|
|
|
"wijst naar %r, aanwezig: %r" % (service, services))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_data_onder_data(u, app):
|
|
|
|
|
"""Wat de app bewaart staat onder data/, en er is een .gitkeep per map.
|
|
|
|
|
|
|
|
|
|
De appstore-eis is dat gebruikersstaat onder ${APP_DATA_DIR}/data/... valt en
|
|
|
|
|
dat elke map die bij de eerste start moet bestaan in de repo staat. Een mount
|
|
|
|
|
naar een map die er niet is, maakt Docker aan als root, en dan kan de app er
|
|
|
|
|
niet in schrijven.
|
|
|
|
|
"""
|
|
|
|
|
pad = os.path.join(REPO, app, "docker-compose.yml")
|
|
|
|
|
if not os.path.isfile(pad):
|
|
|
|
|
return
|
|
|
|
|
tekst = lees(pad)
|
|
|
|
|
|
|
|
|
|
buiten = []
|
|
|
|
|
ontbreekt = []
|
|
|
|
|
for regel in tekst.splitlines():
|
|
|
|
|
kaal = regel.strip()
|
|
|
|
|
if not kaal.startswith("- ${APP_DATA_DIR}/"):
|
|
|
|
|
continue
|
|
|
|
|
pad_in_app = kaal[len("- ${APP_DATA_DIR}/"):].split(":", 1)[0]
|
|
|
|
|
# Losse bestanden die uit een *.template komen zijn geen gebruikersstaat.
|
|
|
|
|
if "/" not in pad_in_app:
|
|
|
|
|
continue
|
|
|
|
|
if not pad_in_app.startswith("data/"):
|
|
|
|
|
buiten.append(pad_in_app)
|
|
|
|
|
continue
|
|
|
|
|
op_schijf = os.path.join(REPO, app, pad_in_app)
|
|
|
|
|
if not os.path.exists(op_schijf):
|
|
|
|
|
ontbreekt.append(pad_in_app)
|
|
|
|
|
elif os.path.isdir(op_schijf) and not os.path.isfile(
|
|
|
|
|
os.path.join(op_schijf, ".gitkeep")):
|
|
|
|
|
ontbreekt.append(pad_in_app + "/.gitkeep")
|
|
|
|
|
|
|
|
|
|
u.check("%s: alle mounts met een pad staan onder data/" % app,
|
|
|
|
|
not buiten, "erbuiten: %r" % buiten)
|
|
|
|
|
u.check("%s: elke gemounte map bestaat in de repo" % app,
|
|
|
|
|
not ontbreekt, "ontbreekt: %r" % ontbreekt)
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 11:41:58 +02:00
|
|
|
def test_containernamen_zijn_volledig(u, app):
|
|
|
|
|
"""Verwijzingen tussen containers gebruiken de volledige naam.
|
|
|
|
|
|
|
|
|
|
Alle apps van umbrelOS delen één Docker-netwerk, dus een servicenaam als
|
|
|
|
|
'agent' of 'server' is daar niet uniek. Heeft een tweede app dezelfde naam,
|
|
|
|
|
dan verdeelt Docker de naam over beide containers en komt ongeveer de helft
|
|
|
|
|
van het verkeer bij de verkeerde app uit.
|
|
|
|
|
|
|
|
|
|
Dat is geen theorie: op 28-08-2026 gaf de statuspagina van Evolu Relay
|
|
|
|
|
afwisselend goede gegevens en een 404, omdat Electrum Gate óók een 'agent' op
|
|
|
|
|
poort 8000 heeft. Van tien verzoeken kwamen er vijf verkeerd uit. Het maakte
|
|
|
|
|
bovendien de pagina van Electrum Gate kapot, een app die tot dat moment werkte.
|
|
|
|
|
|
|
|
|
|
De vorm <app-id>_<service>_1 is wél uniek. Deze toets kijkt naar de plekken
|
|
|
|
|
waar een naam als hostnaam wordt gebruikt: proxy_pass in een nginx-config, en
|
|
|
|
|
de omgevingsvariabelen die een hostnaam dragen.
|
|
|
|
|
"""
|
|
|
|
|
fout = []
|
|
|
|
|
|
|
|
|
|
for bestandsnaam in sorted(os.listdir(os.path.join(REPO, app))):
|
|
|
|
|
if not bestandsnaam.endswith(".template") and bestandsnaam != "docker-compose.yml":
|
|
|
|
|
continue
|
|
|
|
|
tekst = lees(os.path.join(REPO, app, bestandsnaam))
|
|
|
|
|
for regel in tekst.splitlines():
|
|
|
|
|
kaal = regel.strip()
|
|
|
|
|
if kaal.startswith("#"):
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
naam = None
|
|
|
|
|
if kaal.startswith("proxy_pass http://"):
|
|
|
|
|
naam = kaal[len("proxy_pass http://"):].split(":", 1)[0].split("/", 1)[0]
|
|
|
|
|
elif "_HOST:" in kaal and not kaal.startswith("- "):
|
|
|
|
|
waarde = kaal.split(":", 1)[1].strip().strip('"')
|
|
|
|
|
# Een variabele van umbrelOS of een IP-adres is geen containernaam.
|
|
|
|
|
if waarde and not waarde.startswith("${") and not waarde[0].isdigit():
|
|
|
|
|
naam = waarde
|
|
|
|
|
|
|
|
|
|
if naam is None or naam in ("localhost", "127.0.0.1"):
|
|
|
|
|
continue
|
|
|
|
|
if not naam.startswith(app + "_"):
|
|
|
|
|
fout.append("%s: %s" % (bestandsnaam, naam))
|
|
|
|
|
|
|
|
|
|
u.check("%s: verwijzingen naar containers gebruiken de volledige naam" % app,
|
|
|
|
|
not fout, "korte namen: %r" % fout)
|
|
|
|
|
|
|
|
|
|
|
2026-08-25 17:18:22 +02:00
|
|
|
def rapporteer_pinstatus(apps):
|
|
|
|
|
"""Afdrukken, niet toetsen. Zie de uitleg bovenaan dit bestand."""
|
|
|
|
|
print()
|
|
|
|
|
print("Pinstatus van de images (informatief, geen toets):")
|
|
|
|
|
for app in apps:
|
|
|
|
|
pad = os.path.join(REPO, app, "docker-compose.yml")
|
|
|
|
|
if not os.path.isfile(pad):
|
|
|
|
|
continue
|
|
|
|
|
for regel in lees(pad).splitlines():
|
|
|
|
|
kaal = regel.strip()
|
|
|
|
|
if not kaal.startswith("image:"):
|
|
|
|
|
continue
|
|
|
|
|
image = kaal.split(":", 1)[1].strip()
|
|
|
|
|
merk = "gepind" if "@sha256:" in image else "NIET GEPIND"
|
|
|
|
|
print(" %-24s %-12s %s" % (app, merk, image))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
u = Uitslag()
|
|
|
|
|
store_id = veld(lees(STORE), "id")
|
|
|
|
|
apps = app_mappen()
|
|
|
|
|
|
|
|
|
|
test_er_is_een_store_met_apps(u, store_id, apps)
|
|
|
|
|
for app in apps:
|
|
|
|
|
test_id_en_mapnaam(u, store_id, app)
|
|
|
|
|
test_verplichte_velden(u, app)
|
|
|
|
|
test_manifest_volgorde(u, app)
|
|
|
|
|
test_compose_bestaat_en_hangt_samen(u, app)
|
|
|
|
|
test_data_onder_data(u, app)
|
2026-08-28 11:41:58 +02:00
|
|
|
test_containernamen_zijn_volledig(u, app)
|
2026-08-25 17:18:22 +02:00
|
|
|
|
|
|
|
|
rapporteer_pinstatus(apps)
|
|
|
|
|
return u.rapport()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
sys.exit(main())
|