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
|
2026-09-07 20:20:42 +02:00
|
|
|
je hem ziet zonder erover te struikelen. Wat wél getoetst wordt, sinds 07-09-2026:
|
|
|
|
|
dat de tag van een eigen image gelijk is aan VERSION in het bijbehorende
|
|
|
|
|
tools/<app>/build.sh, want een tag die nooit gebouwd is faalt pas op het apparaat.
|
2026-08-25 17:18:22 +02:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-09-07 20:20:42 +02:00
|
|
|
def recept_map(app):
|
|
|
|
|
"""Waar het bouwrecept van een app staat: tools/<app-id zonder store-id>/.
|
|
|
|
|
|
|
|
|
|
Dat is de afspraak uit CLAUDE.md: een Dockerfile hoort niet in de app-map,
|
|
|
|
|
want die staat niet in de update-whitelist. De mapnaam is het app-id zonder
|
|
|
|
|
het store-voorvoegsel, dus whatsnext-evolu-relay bouwt in tools/evolu-relay/.
|
|
|
|
|
"""
|
|
|
|
|
kort = app.split("-", 1)[1] if "-" in app else app
|
|
|
|
|
return os.path.join(REPO, "tools", kort)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_image_tag_is_de_gebouwde(u, app):
|
|
|
|
|
"""De tag in de compose is VERSION uit build.sh, voor elke eigen image.
|
|
|
|
|
|
|
|
|
|
Een tag die in de compose staat maar nooit gebouwd is, faalt pas op het
|
|
|
|
|
apparaat met "pull access denied". En andersom, een VERSION die verhoogd is
|
|
|
|
|
zonder de compose mee te nemen, levert een nieuwe image en een oude
|
|
|
|
|
installatie zonder dat iets dat meldt. Beide staan als regel in CLAUDE.md;
|
|
|
|
|
dit is de toets erop.
|
|
|
|
|
|
|
|
|
|
Alleen images uit het eigen recept: een vreemde image (postgres, nginx) heeft
|
|
|
|
|
geen build.sh en valt hier buiten.
|
|
|
|
|
"""
|
|
|
|
|
build = os.path.join(recept_map(app), "build.sh")
|
|
|
|
|
if not os.path.isfile(build):
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
versie = None
|
|
|
|
|
for regel in lees(build).splitlines():
|
|
|
|
|
if regel.startswith("VERSION="):
|
|
|
|
|
versie = regel.split("=", 1)[1].strip().strip('"')
|
|
|
|
|
u.check("%s: build.sh heeft een VERSION" % app, bool(versie))
|
|
|
|
|
if not versie:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
kort = os.path.basename(recept_map(app))
|
|
|
|
|
compose = lees(os.path.join(REPO, app, "docker-compose.yml"))
|
|
|
|
|
eigen = []
|
|
|
|
|
for regel in compose.splitlines():
|
|
|
|
|
kaal = regel.strip()
|
|
|
|
|
if not kaal.startswith("image:"):
|
|
|
|
|
continue
|
|
|
|
|
image = kaal.split(":", 1)[1].strip()
|
|
|
|
|
repo_en_tag = image.split("@", 1)[0]
|
|
|
|
|
repo_naam = repo_en_tag.rsplit(":", 1)[0]
|
|
|
|
|
if repo_naam.endswith("/" + kort):
|
|
|
|
|
eigen.append(repo_en_tag)
|
|
|
|
|
|
|
|
|
|
u.check("%s: de compose gebruikt de eigen image" % app, bool(eigen),
|
|
|
|
|
"geen image op .../%s gevonden" % kort)
|
|
|
|
|
verkeerd = [i for i in eigen if not i.endswith(":" + versie)]
|
|
|
|
|
u.check("%s: elke eigen image heeft tag %s uit build.sh" % (app, versie),
|
|
|
|
|
not verkeerd, "gevonden: %r" % verkeerd)
|
|
|
|
|
|
|
|
|
|
|
2026-08-25 17:18:22 +02:00
|
|
|
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
|
2026-09-07 20:20:42 +02:00
|
|
|
de omgevingsvariabelen die een hostnaam dragen. Een nginx-config kan in de
|
|
|
|
|
app-map staan (als *.template) of in het bouwrecept onder tools/ (als hij in
|
|
|
|
|
een eigen image zit); beide worden gelezen.
|
2026-08-28 11:41:58 +02:00
|
|
|
"""
|
|
|
|
|
fout = []
|
|
|
|
|
|
2026-09-07 20:20:42 +02:00
|
|
|
bestanden = []
|
2026-08-28 11:41:58 +02:00
|
|
|
for bestandsnaam in sorted(os.listdir(os.path.join(REPO, app))):
|
2026-09-07 20:20:42 +02:00
|
|
|
if bestandsnaam.endswith(".template") or bestandsnaam == "docker-compose.yml":
|
|
|
|
|
bestanden.append(os.path.join(REPO, app, bestandsnaam))
|
|
|
|
|
recept = recept_map(app)
|
|
|
|
|
if os.path.isdir(recept):
|
|
|
|
|
for bestandsnaam in sorted(os.listdir(recept)):
|
|
|
|
|
if bestandsnaam.endswith(".conf"):
|
|
|
|
|
bestanden.append(os.path.join(recept, bestandsnaam))
|
|
|
|
|
|
|
|
|
|
for pad in bestanden:
|
|
|
|
|
bestandsnaam = os.path.relpath(pad, REPO).replace(os.sep, "/")
|
|
|
|
|
tekst = lees(pad)
|
2026-08-28 11:41:58 +02:00
|
|
|
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-30 12:26:56 +02:00
|
|
|
def test_geen_werkbestanden_in_de_app_map(u, app):
|
|
|
|
|
"""In een app-map staat alleen wat umbreld nodig heeft.
|
|
|
|
|
|
|
|
|
|
umbreld kopieert bij een installatie de héle app-map naar
|
|
|
|
|
`~/umbrel/app-data/<app-id>/` met `rsync --archive`. Alles wat daar staat
|
|
|
|
|
belandt dus op het apparaat en in de back-up: een CLAUDE.md, een gelaagd
|
|
|
|
|
bewerkbestand van een icoon, een testscript, een aantekening.
|
|
|
|
|
|
|
|
|
|
Dat is geen theorie. Op 30-08-2026 stond er een `icon.pdn` van 175 kB in de map
|
|
|
|
|
van Evolu Relay, een Paint.NET-bestand van een icoon dat nog niet af was. Het
|
|
|
|
|
apparaat kan er niets mee en het maakt elke back-up groter. Verplaatst naar
|
|
|
|
|
`tools/icons/`; deze toets is wat voorkomt dat de volgende terugkomt.
|
|
|
|
|
|
|
|
|
|
De lijst hieronder is een whitelist en geen blacklist, en dat is met opzet: bij
|
|
|
|
|
een nieuw soort bestand hoort iemand na te denken of het daar hoort, en een
|
|
|
|
|
blacklist stelt die vraag nooit.
|
|
|
|
|
"""
|
|
|
|
|
TOEGESTAAN_EXACT = {
|
|
|
|
|
"umbrel-app.yml", "docker-compose.yml", "exports.sh", "torrc",
|
|
|
|
|
"icon.png", "icon.svg", ".gitkeep",
|
|
|
|
|
}
|
|
|
|
|
TOEGESTAANE_MAPPEN = {"data", "hooks"}
|
|
|
|
|
|
|
|
|
|
fout = []
|
|
|
|
|
for naam in sorted(os.listdir(os.path.join(REPO, app))):
|
|
|
|
|
pad = os.path.join(REPO, app, naam)
|
|
|
|
|
|
|
|
|
|
if os.path.isdir(pad):
|
|
|
|
|
# __pycache__ is niet gecommit (het staat in .gitignore) en komt dus
|
|
|
|
|
# nooit in de kloon die umbreld ophaalt. Het staat er in een werkboom
|
|
|
|
|
# zodra een test de agent-template importeert, en daarover klagen zou
|
|
|
|
|
# de toets rood zetten op iets dat het apparaat niet bereikt.
|
|
|
|
|
if naam == "__pycache__":
|
|
|
|
|
continue
|
|
|
|
|
if naam not in TOEGESTAANE_MAPPEN:
|
|
|
|
|
fout.append(naam + "/")
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if naam in TOEGESTAAN_EXACT:
|
|
|
|
|
continue
|
|
|
|
|
# Alles wat umbreld bij een update ververst is een template, en die horen
|
|
|
|
|
# er dus per definitie.
|
|
|
|
|
if naam.endswith(".template"):
|
|
|
|
|
continue
|
|
|
|
|
fout.append(naam)
|
|
|
|
|
|
|
|
|
|
u.check("%s: er staan geen werkbestanden in de app-map" % app,
|
|
|
|
|
not fout,
|
|
|
|
|
"deze horen buiten de app-map: %r" % fout)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# De variabelen die umbrelOS werkelijk invult. Alleen deze mogen in een template
|
|
|
|
|
# staan; zie test_templates_hebben_geen_losse_dollars. Overgenomen uit de tabel in
|
|
|
|
|
# Docs/Referenties/Umbrel-appstore-spec.md §3.
|
|
|
|
|
UMBREL_VARIABELEN = {
|
|
|
|
|
"APP_ID", "APP_VERSION", "APP_DATA_DIR", "APP_MANIFEST_FILE", "UMBREL_ROOT",
|
|
|
|
|
"DEVICE_HOSTNAME", "DEVICE_DOMAIN_NAME", "APP_DOMAIN",
|
|
|
|
|
"APP_PROXY_HOSTNAME", "APP_PROXY_PORT", "NETWORK_IP",
|
|
|
|
|
"TOR_PROXY_IP", "TOR_PROXY_PORT", "TOR_DATA_DIR",
|
|
|
|
|
"APP_HIDDEN_SERVICE", "APP_SEED", "APP_PASSWORD",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Een app krijgt daarnaast de exports van zijn afhankelijkheden, en die heten
|
|
|
|
|
# APP_<AFHANKELIJKHEID>_<IETS>. Electrum Gate gebruikt APP_ELECTRS_NODE_IP en
|
|
|
|
|
# APP_ELECTRS_NODE_PORT. Die kunnen niet in een vaste lijst staan, want welke er
|
|
|
|
|
# zijn hangt af van de afhankelijkheid; vandaar een vorm in plaats van een naam.
|
|
|
|
|
EXPORT_VORM = r"^APP_[A-Z0-9]+_[A-Z0-9_]+$"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_templates_hebben_geen_losse_dollars(u, app):
|
|
|
|
|
"""In een *.template staat geen dollarteken dat umbrelOS niet invult.
|
|
|
|
|
|
|
|
|
|
Dit is de valstrik van dit hele project, en tot 30-08-2026 beweerde het
|
|
|
|
|
commentaar in drie bestanden dat déze test hem dichthield terwijl dat niet zo
|
|
|
|
|
was.
|
|
|
|
|
|
|
|
|
|
Wat er gebeurt: umbreld haalt elke `*.template` bij het starten door envsubst.
|
|
|
|
|
Dat vervangt élke accolade-vorm, ook een variabele die niet bestaat, en die
|
|
|
|
|
wordt dan leeg. Gevolgen per bestandstype:
|
|
|
|
|
|
|
|
|
|
- in een pagina sloopt het een JavaScript-template-literal, want die gebruikt
|
|
|
|
|
accolades achter een dollarteken. Vandaar dat de pagina's overal strings met
|
|
|
|
|
een plus aan elkaar plakken;
|
|
|
|
|
- in een nginx-config verdwijnen `host`, `log_format` en elke variabele in een
|
|
|
|
|
access_log;
|
|
|
|
|
- in Python-code verdwijnt stilzwijgend een stuk code.
|
|
|
|
|
|
|
|
|
|
Er komt geen foutmelding. Het bestand wordt gewoon anders dan je schreef.
|
|
|
|
|
|
|
|
|
|
Beide vormen die envsubst kent worden gevlagd: `${NAAM}` en het kale `$NAAM`.
|
|
|
|
|
Dat tweede is de vorm waarin een nginx-variabele als `$host` geschreven wordt,
|
|
|
|
|
en die is hier dus net zo fout als de eerste.
|
|
|
|
|
|
|
|
|
|
Wat NIET gevlagd wordt is een los dollarteken zonder naam erachter. Dat laat
|
|
|
|
|
envsubst staan, en het staat in dit project in de commentaarregels die deze
|
|
|
|
|
regel juist uitleggen: een toets die zijn eigen uitleg rood zet, wordt
|
|
|
|
|
uitgezet.
|
|
|
|
|
"""
|
|
|
|
|
import re
|
|
|
|
|
|
|
|
|
|
for bestandsnaam in sorted(os.listdir(os.path.join(REPO, app))):
|
|
|
|
|
if not bestandsnaam.endswith(".template"):
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
tekst = lees(os.path.join(REPO, app, bestandsnaam))
|
|
|
|
|
onbekend = []
|
|
|
|
|
for regelnummer, regel in enumerate(tekst.splitlines(), start=1):
|
|
|
|
|
for treffer in re.finditer(r"\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?", regel):
|
|
|
|
|
naam = treffer.group(1)
|
|
|
|
|
if naam in UMBREL_VARIABELEN:
|
|
|
|
|
continue
|
|
|
|
|
if re.match(EXPORT_VORM, naam):
|
|
|
|
|
continue
|
|
|
|
|
onbekend.append("regel %d: %r" % (regelnummer, treffer.group(0)))
|
|
|
|
|
|
|
|
|
|
u.check("%s/%s: geen dollartekens die umbrelOS leegmaakt" % (app, bestandsnaam),
|
|
|
|
|
not onbekend,
|
|
|
|
|
"gevonden: %r" % onbekend[:4])
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-09-07 20:20:42 +02:00
|
|
|
test_image_tag_is_de_gebouwde(u, app)
|
2026-08-25 17:18:22 +02:00
|
|
|
test_data_onder_data(u, app)
|
2026-08-28 11:41:58 +02:00
|
|
|
test_containernamen_zijn_volledig(u, app)
|
2026-08-30 12:26:56 +02:00
|
|
|
test_geen_werkbestanden_in_de_app_map(u, app)
|
|
|
|
|
test_templates_hebben_geen_losse_dollars(u, app)
|
2026-08-25 17:18:22 +02:00
|
|
|
|
|
|
|
|
rapporteer_pinstatus(apps)
|
|
|
|
|
return u.rapport()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
sys.exit(main())
|