Atreides Services 0.0.4: image 0.0.2 en het slot Backups

- image 0.0.2 met digest, via tools/release-bump.mjs; de digest komt
  overeen met wat het register anoniem onder die tag heeft.
- folderAccess-slot backups (Backups) op server:/data/backups, met als
  standaard ${APP_DATA_DIR}/data/app/backups en een .gitkeep. Zonder
  gekozen map verandert er niets: dezelfde plek als zonder het slot.
- test_appstore_vorm.py toetst elk folderAccess-slot: geldig en uniek id,
  een naam, een service die bestaat en een standaardmount op hetzelfde
  doelpad. Mutatie op id, doelpad en service valt elk op de juiste toets.

Het slot Price history volgt met fase 2c van het plan Netwerkopslag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HarmenandClaude Opus 5.5 committed 2026-10-09 16:16:33 +02:00
1 parent e86208ddfe
commit 519002902c
4 files changed
+172 -5

No files matched your search

+141
View File
@@ -388,6 +388,146 @@ def test_verplaatsbare_opslag(u, 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.
@@ -596,6 +736,7 @@ def main():
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)