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: []