Evolu Relay als tweede app in de store, plus het bouwrecept
De gebruiker koos ervoor het pakket meteen te maken en een installatie te proberen, met de image lokaal gebouwd en het recept in de repo. Dit is dat pakket. Er is nog niets gebouwd en niets geinstalleerd; "gebouwd" is hier nadrukkelijk niet "werkend". De zwaarste ontwerpvraag is met een precedent beslecht en niet met een gok. Trezor Suite is geen browser met een sessiecookie en kan dus niet achter de inlog van umbrelOS; het was onduidelijk of PROXY_AUTH_ADD "false" dan verantwoord is of een omweg. De eigen nostr-relay-app van Umbrel doet exact hetzelfde, om precies dezelfde reden, en heeft ook geen eigen ports:. De prijs staat in de compose en in het plan: wie die poort bereikt, bereikt de relay. Wat de schade beperkt is dat de relay elke eigenaar zonder limietenrij weigert. Daarom gaat de quota-manager mee, en dat is geen restje van Trezor's betaalde hosting: hij is wat die rijen aanmaakt. Relay en quota-manager komen uit dezelfde image met een ander command, want bovenstrooms is het een codebase met meerdere startscripts. Het command staat expliciet en leunt niet op de CMD van de Dockerfile, waar yarn start staat met bovenstrooms zelf een twijfel erbij. Het bouwrecept staat in tools/ en niet in de app-map. Dat is geen netheid: een Dockerfile staat niet in de update-whitelist, dus bouwen-in-de-app zou elke nieuwe versie een deinstallatie plus herinstallatie kosten. Onder tools/ en niet onder build/, want dat laatste staat in .gitignore als bouwselmap en het recept zou stilzwijgend buiten de repo zijn gebleven. Dat kwam pas bij git status aan het licht. De poort is 3851 en niet 4000. 4000 is de eigen poort van de relay maar ook een veelgebruikte poort, en een botsing op de host merk je pas als de app niet start. Die les komt van 50002 tegen Fulcrum. Nieuw testbestand test_appstore_vorm.py, en het gaat over de store en niet over een app: id gelijk aan mapnaam, store-voorvoegsel, veldvolgorde, app_proxy die naar een bestaande service wijst, en elke gemounte map die in de repo bestaat. Het vindt zijn apps zelf, dus een derde app valt er automatisch onder. Digests toetst het expres niet: geen van de twee apps haalt die regel vandaag en een suite die altijd rood staat wordt niet gelezen. Mutatie-getest met drie ingrepen: het app-id laten afwijken van de mapnaam, APP_HOST naar een niet-bestaande service laten wijzen, en de .gitkeep weghalen. Alle drie vielen om bij de juiste toets, en git diff was daarna leeg. Umbrelapp is gepromoveerd naar Actief als 008, tussen Proefopstelling en Appstore, en het masterplan is naar het archief. Wat er in de plannen als open blijft staan is niet klein: of Trezor Suite dit adres accepteert, of het databaseschema zichzelf aanmaakt, en hoe je een eigenaar registreert. Tests: 32 goed 0 fout, 39 goed 0 fout en 54 goed 0 fout, niets overgeslagen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
"""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 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)
|
||||
|
||||
rapporteer_pinstatus(apps)
|
||||
return u.rapport()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user