Files
UmbrelApps/tests/test_appstore_vorm.py
T
HarmenandClaude Opus 5 2b17f79181 De korte containernaam 'agent' wees naar de verkeerde app
Alle apps van umbrelOS delen een Docker-netwerk, dus een servicenaam als 'agent'
is daar niet uniek. Electrum Gate heeft er een op poort 8000 en Evolu Relay sinds
vandaag ook, en Docker verdeelde de naam netjes over allebei. Gemeten op het
apparaat: van tien verzoeken vanuit de nginx-container kwamen er vijf bij de
verkeerde app uit, met een 404 tot gevolg. Op de statuspagina zag dat eruit als
een status die wisselde tussen "running" en "agent unreachable".

Dit raakte twee apps, en de tweede is de vervelende: de pagina van Electrum Gate
proxyde ook naar http://agent:8000 en werkte alleen omdat die app tot vandaag de
enige met die naam was. Het installeren van Evolu Relay heeft die pagina dus
kapotgemaakt. Beide gaan nu naar <app-id>_agent_1, en beide manifesten gaan
omhoog, want zonder verhoging bereikt een gewijzigde template geen bestaande
installatie.

Dezelfde les stond al in de compose van Electrum Gate, over de servicenaam
'server', en die is bij het schrijven van de nginx-config genegeerd. Daarom nu een
toets erop: test_appstore_vorm controleert voor elke app dat een proxy_pass en
elke *_HOST-variabele een naam gebruiken die met het app-id begint. Muteertest
gedaan, de toets viel om op precies de korte naam.

Bijgewerkt in build.sh: VERSION daar is het etiket op de image, en `version` in
het manifest is een ander nummer dat erop vooruit mag lopen. Ze lopen uiteen zodra
er een reparatie in de app-map zit zonder dat de image wijzigt, en dat is nu het
geval.

Tests: alle vier groen (34, 54, 39 en 60 goed, 0 fout).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:41:58 +02:00

356 lines
13 KiB
Python

"""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)
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)
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)
test_containernamen_zijn_volledig(u, app)
rapporteer_pinstatus(apps)
return u.rapport()
if __name__ == "__main__":
sys.exit(main())