Port of image_version.sh with a contract test (16 cases computed by the old script's Python). For apps with real updates, status sends a manifest that pins a digest only or a floating tag (dockge, grafana) to image_version, which takes the version from the OCI or LinuxServer label and hands it to image_tags; no usable label is STOP version_unknown. inspect exposes only the version labels. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8.2 KiB
Port skryptów ze starego repo (etap 2)
Zasada z DESIGN.md: po jednym skrypcie, z testem kontraktowym (wynik TS zgodny ze starym skryptem na zapisanym wejściu). Bash służy tylko jako wzorzec; niczego mutującego ze starego repo nie uruchamiamy.
| Skrypt | Stan | Gdzie | Test kontraktowy |
|---|---|---|---|
status.sh + compose_ps_json.py |
gotowe | domain/compose.ts, app/ops/status.ts |
fixtures/compose-ps/cases.json: 15 wejść (2 prawdziwe wyjścia compose ps z VM103 + przypadki brzegowe); oczekiwania to wynik starego parsera |
status na sucho (bez plików projektu) |
gotowe | domain/compose.ts (parseDockerPs, healthFromStatus), app/ops/containers.ts |
Nowe, bez odpowiednika w starym repo: docker ps -a z filtrami etykiet compose zamiast docker compose ps, bo compose.yaml/.env są root:root 600. Testy na wierszach nagranych z VM103 (Docker 28). manifest_sha256 = null; status.sh zostaje dla kroków zmieniających |
image_tags.sh |
gotowe | domain/tags.ts, domain/registry.ts, infra/http-registry.ts, app/ops/image-tags.ts |
fixtures/image-tags/cases.json: 25 przypadków (16 syntetycznych + 9 nagranych z prawdziwych rejestrów); oczekiwania liczy czysta część starego skryptu (generate.py). Dodatkowo raz sprawdzone na żywo: stary skrypt dał ten sam JSON co port dla wszystkich 9 repozytoriów |
image_resolve.sh |
gotowe (przez HTTP, bez Dockera) | domain/image-ref.ts, infra/http-registry.ts (resolveImage), app/ops/image-resolve.ts |
Bez testu kontraktowego względem starego skryptu: docker buildx nie ma na hoście dev. Zamiast tego testy jednostkowe na fałszywym rejestrze i sprawdzenie na żywo: digest z portu równy Docker-Content-Digest z curl dla nextcloud:31.0.12-apache i ghcr.io/linuxserver/radarr:latest |
preflight.sh, verify.sh |
reguły gotowe; obserwacje w executorze/driverze do zrobienia | domain/preflight.ts (preflightVerdict), domain/verify.ts (verifyChecks, nextVerifyAction) |
Testy jednostkowe reguł. Dryf manifestu: obraz działającego kontenera = obraz w manifeście, mounty zadeklarowane = zaobserwowane. Nie porównujemy docker compose config --hash z etykietą config-hash: na VM103 (compose 2.37.3) różnią się dla każdej usługi z env_file zaraz po up (sprawdzone 2026-09-19 na jackett i ombi; orcaslicer bez env_file się zgadza) |
backup_check (nowe, z DESIGN.md) |
reguła gotowa | domain/backup-check.ts (HostBackupRecord, backupVerdict) |
Testy jednostkowe: kopia VM103 młodsza niż 48 h, rekord innej VM, brak kopii, data z przyszłości (tolerancja 5 min) |
apply_support.py (bramki apply.sh) |
gotowe | domain/compose-edit.ts (validateImage, rewriteImage, diffExpanded) |
fixtures/apply-gates/cases.json: 55 przypadków (obraz, podmiana linii image:, diff rozwiniętego compose); oczekiwania liczą czyste funkcje starego modułu importowane przez generate.py |
image_version.sh |
gotowe | domain/image-version.ts (imageVersion), krok image_version w app/dry-run/handlers.ts |
fixtures/image-version/cases.json: 16 przypadków; oczekiwania liczy Python starego skryptu z dwoma wywołaniami Dockera podmienionymi na dane (generate.py). Krok działa tylko dla aplikacji z apply = on: obraz z manifestu daje executor (/inspect), etykiety wersji docker inspect przez proxy |
backup.sh, apply.sh, rollback.sh |
do zrobienia jako ostatnie (mutacje) |
Infrastruktura pod porty: infra/process-runner.ts (prawdziwy CommandRunner: bez shella, czyste środowisko, AbortSignal, limit wyjścia).
Regeneracja fixture'ów
node fixtures/image-tags/record.ts fixtures/image-tags/recorded # nagranie z rejestrów (sieć)
python3 fixtures/image-tags/generate.py <ścieżka do starego image_tags.sh> fixtures/image-tags/recorded
cases.json zawiera hash skryptu, z którego pochodzą oczekiwania (provenance). Nagrania są zredukowane: wszystkie tagi pasujące do wzorca skilla plus 50 innych.
Zachowania starego kodu przeniesione świadomie (i pułapki)
-
candidateto najwyższy tag, nie kolejny krok.stepsto ścieżka (najwyższy tag każdej nowszej linii major.minor, rosnąco). DESIGN.md mówi „bramka:version_changewyższy niżauto→ STOP” i jednocześnie „wersje krok po kroku”. Trzeba rozstrzygnąć, czy bramka patrzy nacandidateczy nasteps[0](demo używasteps[0]). -
Starszy „kandydat” jest raportowany jak zmiana. Gdy
currentjest wyższy niż każdy tag pasujący do wzorca, skrypt i tak zwracacandidatei niezeroweversion_change(np.major). Przypięte testem wtags.test.ts. Handler musi to odrzucić, inaczejauto: majormógłby „zaktualizować” w dół. -
Nowszy build tej samej wersji (
-ls364→-ls365) dajeversion_change: none. To celowe: zmienia się obraz, nie wersja aplikacji. -
Wzorzec
^[0-9.]+$jest zbyt luźny. W prawdziwym rejestrzegrafana/grafanapasuje do niego tag9799770991(to nie wersja), który stary skrypt uznaje zacandidate(major). To samo dotyczy każdej aplikacji z takim wzorcem. Wzorce ze skilli trzeba zawęzić (np.^[0-9]+\.[0-9]+\.[0-9]+$) przy imporcie skilli. -
Wersje to liczby całkowite dowolnej długości (
BigInt), bo tagi mogą nieść znaczniki czasu. -
Wyrażenia regularne: wszystkie 11 wzorców ze skilli działa tak samo w Pythonie i JS. Wzorzec spoza tego zbioru (np.
(?P<n>…)lub(?i)) nie skompiluje się w JS i zostanie zgłoszony jakoinvalid tag pattern. -
image_resolve: brak etykiety wersji tonull, nie błąd.nextcloudigrafana/grafananie mająorg.opencontainers.image.version. Decyzja, czy to STOP (no_version_labelprzy repin), należy do handlera kroku, nie do portu. -
platformspowtarza wpisy (linux/armdwa razy: warianty v6 i v7), tak jak stary skrypt. Lista nie jest zbiorem. -
Skrypt czytał przez
docker buildxna VM, port czyta rejestr po HTTP. Ten krok nie potrzebuje więcdocker.sock. Port dodatkowo sprawdza, że bajty manifestu i configu zgadzają się z digestem, i odrzuca odpowiedzi większe niż 4 MiB. -
Bramki
applyprzepisane wiernie, z dziwactwami.a/b:c:d@sha256:…przechodzi jako obraza/b:cz tagiemd; taglatest-1.0nie jest „pływający”. Ścisły format referencji sprawdza dodatkowo executor na wejściu (zod) i porównuje ją zpinnedzimage_resolve. -
Tam, gdzie Python i JS się różnią, port odmawia. Digest z końcowym
\n(Pythonowe$go przepuszczało) tomalformed_digest. Manifest z BOM, samotnym\r,\v,\f,\x1c–\x1f,\x85albo U+2028/9 dajeunsupported_characters, bosplitlines/stripw Pythonie dzielą i obcinają inaczej niż JS. W diffie1i1.0są w JS równe (Python widział zmianę typu);truei1różnią się w obu. Rozwinięty compose, który nie jest obiektem, to zmiana korzenia (ścieżka""), a nie wyjątek. -
rewriteImagegubi cudzysłowy wokół starej wartości, a resztę linii (np. komentarz) zostawia:image: "nginx:1.0" # xstaje sięimage: <nowy> # x. -
image_version: wersja musi dać się użyć jako tag. Stary skrypt z etykiety" 1.0 "proponowałorg/app: 1.0 @sha256:…; port uznaje ją za brak wersji (no_version_label→ STOPversion_unknown). Z etykiet kontenera czytamy tylko trzy etykiety wersji (inne potrafią nieść hasze haseł, np. basic auth traefika). -
Repin bez nowej wersji nie jest osobnym
apply. DESIGN.md opisuje łańcuch repin → backup → apply. Tuimage_versiontylko nazywa wersję działającego obrazu i przekazuje ją doimage_tags; najbliższa aktualizacja i tak wpisze do manifesturepo:tag@digest.
Co port dodaje względem skryptów
RegistryClient: typowane błędy (rate_limited,unauthorized→auth_failed, …), walidacja odpowiedzi zod, kolejne strony tylko z domeny rejestru (token nigdy nie wychodzi poza nią), tylkohttpsdla adresu tokenu, limit stron i czasu.CommandRunner: polecenie widzi tylkoPATH/LANG/TZ(i to, co przekaże wywołujący), więc tokeny drivera nie wyciekają do skryptów.- Błędy poleceń mają postać wymaganą przez DESIGN.md: polecenie, kod wyjścia, pierwsza linia stderr (maskowanie robi driver).