"""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. Wat wél getoetst wordt, sinds 07-09-2026: dat de tag van een eigen image gelijk is aan VERSION in het bijbehorende tools//build.sh, want een tag die nooit gebouwd is faalt pas op het apparaat. 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. # # `storage`, `folderAccess` en `environment` zijn er sinds umbrelOS 2.0 bij en # staan direct na `id` (skill umbrel-package-app, opgehaald 09-10-2026). VOORGESCHREVEN = [ "manifestVersion", "id", "storage", "folderAccess", "environment", "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 recept_map(app): """Waar het bouwrecept van een app staat: tools//. Dat is de afspraak uit Werkafspraken.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 Werkafspraken.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) 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 `__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 __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) # De mount-opties die umbreld toestaat op een bron onder ${APP_DATA_DIR}/data # zodra de datamap verplaatsbaar is. Elke andere optie laat de compose falen met # [apps-data-root-invalid-compose]. Uit app.ts van umbreld, 09-10-2026. DATAROOT_OPTIES = { "ro", "rw", "private", "rprivate", "shared", "rshared", "slave", "rslave", "z", "Z", "consistent", "cached", "delegated", } def test_verplaatsbare_opslag(u, app): """Een app met `storage:` heeft precies `dataRoot: data`, en mounts die umbreld kan omleiden. Dit is het enige manifestveld dat umbreld wél streng valideert: elke andere waarde dan `data` maakt het manifest ongeldig, en dan verschijnt de app niet. Met het veld erin herschrijft umbreld elke bron onder ${APP_DATA_DIR}/data naar de plek die de gebruiker kiest, en weigert dan een pad dat uit data/ ontsnapt of een mount-optie die niet in DATAROOT_OPTIES staat. Een app zonder `storage:` valt hier buiten; het veld is optioneel. Maar het kan er niet meer uit als het er eenmaal in staat: een installatie die al verplaatst is, start dan niet meer. """ tekst = lees(os.path.join(REPO, app, "umbrel-app.yml")) regels = tekst.splitlines() if not any(r.startswith("storage:") for r in regels): return i = next(i for i, r in enumerate(regels) if r.startswith("storage:")) blok = [] for r in regels[i + 1:]: if r and not r[0].isspace(): break if r.strip() and not r.strip().startswith("#"): blok.append(r.strip()) u.check("%s: storage heeft precies dataRoot: data" % app, blok == ["dataRoot: data"], "gevonden: %r" % blok) pad = os.path.join(REPO, app, "docker-compose.yml") if not os.path.isfile(pad): return fout = [] for regel in lees(pad).splitlines(): kaal = regel.strip() if not kaal.startswith("- ${APP_DATA_DIR}/data"): continue delen = kaal[2:].strip().strip('"').split(":") bron = delen[0][len("${APP_DATA_DIR}/"):] if ".." in bron.split("/"): fout.append(kaal) if len(delen) > 2: opties = delen[2].split(",") if any(o not in DATAROOT_OPTIES for o in opties): fout.append(kaal) u.check("%s: elke mount onder data/ kan umbreld omleiden" % app, not fout, "geweigerd: %r" % fout) def folder_slots(tekst): """De slots onder `folderAccess:` als lijst van dicts, elk met een lijst `mounts`. Geen YAML-lezer, zie veld(). Wat telt is de inspringing: een streepje op de diepte van het eerste streepje begint een slot, een dieper streepje een mount in dat slot. Een sleutel zonder streepje hoort bij de mount zolang hij dieper staat dan de sleutels van het slot. """ regels = tekst.splitlines() start = next((i for i, r in enumerate(regels) if r.startswith("folderAccess:")), None) if start is None: return None slots = [] slot_streep = None huidig = doel = None for r in regels[start + 1:]: if r and not r[0].isspace(): break kaal = r.strip() if not kaal or kaal.startswith("#"): continue inspring = len(r) - len(r.lstrip()) if kaal.startswith("- "): kaal = kaal[2:].strip() if slot_streep is None or inspring <= slot_streep: slot_streep = inspring huidig = doel = {"mounts": []} slots.append(huidig) elif huidig is not None: doel = {} huidig["mounts"].append(doel) elif huidig is not None and doel is not huidig and inspring <= slot_streep + 2: doel = huidig if doel is None or ":" not in kaal: continue sleutel, waarde = kaal.split(":", 1) waarde = waarde.strip().strip('"').strip("'") if waarde: doel[sleutel.strip()] = waarde return slots def doelpaden_per_service(tekst): """Per service in de compose de doelpaden van de korte `volumes:`-regels.""" uit = {} in_services = False service = None in_volumes = False volumes_inspring = 0 for regel in tekst.splitlines(): if regel.startswith("services:"): in_services = True continue if not in_services: continue if regel and not regel[0].isspace(): break kaal = regel.strip() if not kaal or kaal.startswith("#"): continue inspring = len(regel) - len(regel.lstrip()) if inspring == 2 and kaal.endswith(":"): service = kaal[:-1] uit[service] = [] in_volumes = False continue if service is None: continue if kaal == "volumes:": in_volumes = True volumes_inspring = inspring continue if in_volumes: if kaal.startswith("- "): delen = kaal[2:].strip().strip('"').split(":") if len(delen) >= 2: uit[service].append(delen[1]) elif inspring <= volumes_inspring: in_volumes = False return uit def test_folder_access(u, app): """Elk `folderAccess`-slot wijst naar een service die bestaat, met een standaardmount. umbrelOS 2.0 bewaart de keuze van de gebruiker per `id`, en zoekt de standaard van een slot in de compose: een mount op dezelfde `service` en hetzelfde `targetPath`. Klopt een van de twee niet, dan meldt niemand iets: het slot is dan "Optional" met een lege standaard, en tot de gebruiker een map kiest schrijft de app in wat er toevallig onder dat pad ligt, in het slechtste geval zijn eigen containerlaag, die bij de volgende update weg is. In deze store heeft elk slot daarom een standaardmount onder data/; dat die map in de repo staat, toetst test_data_onder_data. `umbrel-downloads` is gereserveerd voor de gedeelde Downloads-map. Zie Umbrel-appstore-spec.md §7. """ slots = folder_slots(lees(os.path.join(REPO, app, "umbrel-app.yml"))) if slots is None: return u.check("%s: folderAccess heeft minstens een slot" % app, bool(slots)) ids = [s.get("id") for s in slots] fout_id = [i for i in ids if not i or i != i.lower() or " " in i or "_" in i or i == "umbrel-downloads"] u.check("%s: elk slot-id is lowercase kebab-case en niet gereserveerd" % app, not fout_id, "ids: %r" % ids) u.check("%s: de slot-ids zijn uniek" % app, len(set(ids)) == len(ids), "ids: %r" % ids) zonder_naam = [s.get("id") for s in slots if not s.get("name")] u.check("%s: elk slot heeft een naam" % app, not zonder_naam, "zonder naam: %r" % zonder_naam) pad = os.path.join(REPO, app, "docker-compose.yml") if not os.path.isfile(pad): return doelen = doelpaden_per_service(lees(pad)) zonder_mount = [s.get("id") for s in slots if not s["mounts"]] u.check("%s: elk slot heeft minstens een mount" % app, not zonder_mount, "zonder mount: %r" % zonder_mount) onbekend = [] zonder_standaard = [] for s in slots: for m in s["mounts"]: service = m.get("service") doel = m.get("targetPath", "") if service not in doelen: onbekend.append("%s: %r" % (s.get("id"), service)) elif doel not in doelen[service]: zonder_standaard.append("%s: %s op %s" % (s.get("id"), doel, service)) u.check("%s: elke slot-mount wijst naar een service die bestaat" % app, not onbekend, "onbekend: %r, aanwezig: %r" % (onbekend, sorted(doelen))) u.check("%s: elke slot-mount heeft een standaardmount in de compose" % app, not zonder_standaard, "ontbreekt: %r" % zonder_standaard) 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 __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. 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. """ fout = [] bestanden = [] for bestandsnaam in sorted(os.listdir(os.path.join(REPO, app))): 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) 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 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//` 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 # HomeGit/Docs/Referenties/UmbrelApps/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__. 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]) 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_image_tag_is_de_gebouwde(u, app) test_data_onder_data(u, app) test_verplaatsbare_opslag(u, app) test_folder_access(u, app) test_containernamen_zijn_volledig(u, app) test_geen_werkbestanden_in_de_app_map(u, app) test_templates_hebben_geen_losse_dollars(u, app) rapporteer_pinstatus(apps) return u.rapport() if __name__ == "__main__": sys.exit(main())