From 519002902c5b3ed54ae55c81d42327cb06630482 Mon Sep 17 00:00:00 2001 From: Harmen Date: Fri, 9 Oct 2026 16:16:33 +0200 Subject: [PATCH] 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 --- tests/test_appstore_vorm.py | 141 ++++++++++++++++++ .../data/app/backups/.gitkeep | 0 .../docker-compose.yml | 9 +- whatsnext-atreides-services/umbrel-app.yml | 27 +++- 4 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 whatsnext-atreides-services/data/app/backups/.gitkeep diff --git a/tests/test_appstore_vorm.py b/tests/test_appstore_vorm.py index f998e6b..fb42a83 100644 --- a/tests/test_appstore_vorm.py +++ b/tests/test_appstore_vorm.py @@ -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) diff --git a/whatsnext-atreides-services/data/app/backups/.gitkeep b/whatsnext-atreides-services/data/app/backups/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/whatsnext-atreides-services/docker-compose.yml b/whatsnext-atreides-services/docker-compose.yml index cda93a5..a10b7a2 100644 --- a/whatsnext-atreides-services/docker-compose.yml +++ b/whatsnext-atreides-services/docker-compose.yml @@ -34,7 +34,7 @@ services: # DE TAG IS DE VERSIE VAN DE IMAGE EN NIET DIE VAN DE APP, net als bij Atreides # Assets: `version` in umbrel-app.yml moet bij élke wijziging omhoog, ook als # alleen dit bestand verandert. - image: sc.kamenier-hamer.nl/sysop/atreides-services:0.0.1@sha256:57e509700bed543048544eb6d1fece9386c2e0a715dfb901e381d4f9247b2815 + image: sc.kamenier-hamer.nl/sysop/atreides-services:0.0.2@sha256:f46330027f8289ba263dcbca15bda5ea00de8c2f64bcb17b71974ab7497c60ad restart: on-failure # Geen `user:` nodig: de image zet zelf `USER node` (uid 1000). umbrelOS # maakt de datamap aan als `umbrel`, ook uid 1000, dus node mag erin @@ -64,3 +64,10 @@ services: # (.gitkeep), en mag hier geen mount-optie bij die umbreld niet kent. # Staat de map op een USB-schijf, dan gaat hij NIET mee in de back-up. - ${APP_DATA_DIR}/data/app:/data + # De standaard van het folderAccess-slot "backups" (umbrel-app.yml): zelfde + # service en zelfde doelpad, en precies de map die de app zonder deze regel + # ook zou gebruiken. Wijst de gebruiker het slot naar een NAS-map, dan + # vervangt umbreld alleen deze mount; de rest van /data blijft lokaal. + # Gemeten in het plan Netwerkopslag: een mount binnen /data verbergt de rest + # niet. + - ${APP_DATA_DIR}/data/app/backups:/data/backups diff --git a/whatsnext-atreides-services/umbrel-app.yml b/whatsnext-atreides-services/umbrel-app.yml index 079f1fa..7e9dee8 100644 --- a/whatsnext-atreides-services/umbrel-app.yml +++ b/whatsnext-atreides-services/umbrel-app.yml @@ -14,9 +14,27 @@ id: whatsnext-atreides-services # start dan niet meer. Zie Umbrel-appstore-spec.md §7. storage: dataRoot: data +# umbrelOS 2.0: een map die de gebruiker in de app-instellingen naar zijn +# Home-map, een USB-schijf of een netwerkshare kan wijzen. Er wordt niets +# gekopieerd; umbreld vervangt alleen de mount. De standaard is de mount op +# dezelfde service en hetzelfde doelpad in docker-compose.yml, en die wijst naar +# dezelfde plek als zonder dit slot, dus voor een bestaande installatie +# verandert er niets. De app schrijft in een eigen submap backups/ met een +# merkbestand, zodat dit slot en het latere "Price history" naar dezelfde +# NAS-map mogen wijzen. +# +# Het `id` is na de eerste release vast: umbrelOS bewaart de keuze van de +# gebruiker per id, dus hernoemen gooit die weg. Op een netwerkshare valt de map +# buiten de back-up van umbrelOS, en dat is hier juist de bedoeling. +folderAccess: + - id: backups + name: Backups + mounts: + - service: server + targetPath: /data/backups category: finance name: Atreides Services -version: "0.0.3" +version: "0.0.4" tagline: Your own price feed, recorded around the clock description: >- A price service that runs on your Umbrel and keeps what it records. It fetches prices day @@ -34,9 +52,10 @@ description: >- mempool.space, and brings the administration app with its System page and an optional login of its own. The API for other apps follows in a later version. releaseNotes: >- - On umbrelOS 2.0 the app's storage can now be moved to a USB drive (ext4) in the app - settings. Keep in mind that umbrelOS does not back up an app whose storage is on a USB - drive. + The app now makes a checked backup every night, and keeps each closed month once. On + umbrelOS 2.0 you can point the new Backups folder at a folder on your NAS in the app + settings, so the backups live on another disk. Without that, they stay in the app's own + storage. The app writes them in a subfolder backups/ of the folder you pick. developer: "WhatsNext?" website: https://sc.kamenier-hamer.nl/sysop/UmbrelApps dependencies: []