image_version: name the version of a digest-only pin from the container's labels
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>
This commit is contained in:
1 parent
20daf1816c
commit
e9cb72a59c
9 files changed
+601
-8
No files matched your search
@@ -11,6 +11,7 @@ Zasada z DESIGN.md: po jednym skrypcie, z testem kontraktowym (wynik TS zgodny z
|
||||
| `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).
|
||||
@@ -41,6 +42,9 @@ python3 fixtures/image-tags/generate.py <ścieżka do starego image_tags.sh> fix
|
||||
- **Tam, gdzie Python i JS się różnią, port odmawia.** Digest z końcowym `\n` (Pythonowe `$` go przepuszczało) to `malformed_digest`. Manifest z BOM, samotnym `\r`, `\v`, `\f`, `\x1c`–`\x1f`, `\x85` albo U+2028/9 daje `unsupported_characters`, bo `splitlines`/`strip` w Pythonie dzielą i obcinają inaczej niż JS. W diffie `1` i `1.0` są w JS równe (Python widział zmianę typu); `true` i `1` różnią się w obu. Rozwinięty compose, który nie jest obiektem, to zmiana korzenia (ścieżka `""`), a nie wyjątek.
|
||||
- **`rewriteImage` gubi cudzysłowy wokół starej wartości**, a resztę linii (np. komentarz) zostawia: `image: "nginx:1.0" # x` staje 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` → STOP `version_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. Tu `image_version` tylko nazywa wersję działającego obrazu i przekazuje ją do `image_tags`; najbliższa aktualizacja i tak wpisze do manifestu `repo: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ą), tylko `https` dla adresu tokenu, limit stron i czasu.
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
{
|
||||
"provenance": {
|
||||
"generatedBy": "fixtures/image-version/generate.py running the Python of the OLD image_version.sh",
|
||||
"scriptSha256": "ab2a255560d59ddd2dc8432c87ea0b0dc7984974c1dcd1516d3bf5b7b918fd88"
|
||||
},
|
||||
"cases": [
|
||||
{
|
||||
"name": "digest only, OCI version label (dockge)",
|
||||
"image": "louislam/dockge@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"labels": {
|
||||
"org.opencontainers.image.version": "1.5.0",
|
||||
"maintainer": "x"
|
||||
},
|
||||
"expected": {
|
||||
"project": "",
|
||||
"service": "demo",
|
||||
"container": "demo-demo-1",
|
||||
"image": "louislam/dockge@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"repository": "louislam/dockge",
|
||||
"tag": null,
|
||||
"digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"labels": {
|
||||
"org.opencontainers.image.version": "1.5.0"
|
||||
},
|
||||
"detected_version": "1.5.0",
|
||||
"proposed_image": "louislam/dockge:1.5.0@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"state": "ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "latest with digest, OCI label (grafana)",
|
||||
"image": "grafana/grafana:latest@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"labels": {
|
||||
"org.opencontainers.image.version": "13.2.2"
|
||||
},
|
||||
"expected": {
|
||||
"project": "",
|
||||
"service": "demo",
|
||||
"container": "demo-demo-1",
|
||||
"image": "grafana/grafana:latest@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"repository": "grafana/grafana",
|
||||
"tag": "latest",
|
||||
"digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"labels": {
|
||||
"org.opencontainers.image.version": "13.2.2"
|
||||
},
|
||||
"detected_version": "13.2.2",
|
||||
"proposed_image": "grafana/grafana:13.2.2@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"state": "ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "linuxserver build_version only",
|
||||
"image": "linuxserver/bazarr@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"labels": {
|
||||
"build_version": "Linuxserver.io version:- 4.0.15-ls289 Build-date:- 2026-09-01T00:00:00+00:00"
|
||||
},
|
||||
"expected": {
|
||||
"project": "",
|
||||
"service": "demo",
|
||||
"container": "demo-demo-1",
|
||||
"image": "linuxserver/bazarr@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"repository": "linuxserver/bazarr",
|
||||
"tag": null,
|
||||
"digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"labels": {
|
||||
"build_version": "Linuxserver.io version:- 4.0.15-ls289 Build-date:- 2026-09-01T00:00:00+00:00"
|
||||
},
|
||||
"detected_version": "4.0.15-ls289",
|
||||
"proposed_image": "linuxserver/bazarr:4.0.15-ls289@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"state": "ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "OCI label wins over build_version",
|
||||
"image": "linuxserver/bazarr@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"labels": {
|
||||
"org.opencontainers.image.version": "v1.5.3-ls301",
|
||||
"build_version": "Linuxserver.io version:- 4.0.15-ls289 Build-date:- 2026-09-01T00:00:00+00:00"
|
||||
},
|
||||
"expected": {
|
||||
"project": "",
|
||||
"service": "demo",
|
||||
"container": "demo-demo-1",
|
||||
"image": "linuxserver/bazarr@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"repository": "linuxserver/bazarr",
|
||||
"tag": null,
|
||||
"digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"labels": {
|
||||
"org.opencontainers.image.version": "v1.5.3-ls301",
|
||||
"build_version": "Linuxserver.io version:- 4.0.15-ls289 Build-date:- 2026-09-01T00:00:00+00:00"
|
||||
},
|
||||
"detected_version": "v1.5.3-ls301",
|
||||
"proposed_image": "linuxserver/bazarr:v1.5.3-ls301@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"state": "ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "label-schema version is reported but not used",
|
||||
"image": "org/app@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"labels": {
|
||||
"org.label-schema.version": "2.1.0"
|
||||
},
|
||||
"expected": {
|
||||
"project": "",
|
||||
"service": "demo",
|
||||
"container": "demo-demo-1",
|
||||
"image": "org/app@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"repository": "org/app",
|
||||
"tag": null,
|
||||
"digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"labels": {
|
||||
"org.label-schema.version": "2.1.0"
|
||||
},
|
||||
"detected_version": null,
|
||||
"proposed_image": null,
|
||||
"state": "no_version_label"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "empty OCI label falls back to build_version",
|
||||
"image": "linuxserver/x@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"labels": {
|
||||
"org.opencontainers.image.version": "",
|
||||
"build_version": "Linuxserver.io version:- 4.0.15-ls289 Build-date:- 2026-09-01T00:00:00+00:00"
|
||||
},
|
||||
"expected": {
|
||||
"project": "",
|
||||
"service": "demo",
|
||||
"container": "demo-demo-1",
|
||||
"image": "linuxserver/x@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"repository": "linuxserver/x",
|
||||
"tag": null,
|
||||
"digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"labels": {
|
||||
"build_version": "Linuxserver.io version:- 4.0.15-ls289 Build-date:- 2026-09-01T00:00:00+00:00"
|
||||
},
|
||||
"detected_version": "4.0.15-ls289",
|
||||
"proposed_image": "linuxserver/x:4.0.15-ls289@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"state": "ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "build_version without the marker",
|
||||
"image": "linuxserver/x@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"labels": {
|
||||
"build_version": "custom build 7"
|
||||
},
|
||||
"expected": {
|
||||
"project": "",
|
||||
"service": "demo",
|
||||
"container": "demo-demo-1",
|
||||
"image": "linuxserver/x@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"repository": "linuxserver/x",
|
||||
"tag": null,
|
||||
"digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"labels": {
|
||||
"build_version": "custom build 7"
|
||||
},
|
||||
"detected_version": null,
|
||||
"proposed_image": null,
|
||||
"state": "no_version_label"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "no labels at all",
|
||||
"image": "org/app@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"labels": {},
|
||||
"expected": {
|
||||
"project": "",
|
||||
"service": "demo",
|
||||
"container": "demo-demo-1",
|
||||
"image": "org/app@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"repository": "org/app",
|
||||
"tag": null,
|
||||
"digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"labels": {},
|
||||
"detected_version": null,
|
||||
"proposed_image": null,
|
||||
"state": "no_version_label"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "labels are null (docker prints null)",
|
||||
"image": "org/app@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"labels": "null",
|
||||
"expected": {
|
||||
"project": "",
|
||||
"service": "demo",
|
||||
"container": "demo-demo-1",
|
||||
"image": "org/app@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"repository": "org/app",
|
||||
"tag": null,
|
||||
"digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"labels": {},
|
||||
"detected_version": null,
|
||||
"proposed_image": null,
|
||||
"state": "no_version_label"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "labels unreadable",
|
||||
"image": "org/app@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"labels": "not json",
|
||||
"expected": {
|
||||
"project": "",
|
||||
"service": "demo",
|
||||
"container": "demo-demo-1",
|
||||
"image": "org/app@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"repository": "org/app",
|
||||
"tag": null,
|
||||
"digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"labels": {},
|
||||
"detected_version": null,
|
||||
"proposed_image": null,
|
||||
"state": "no_version_label"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "no digest",
|
||||
"image": "org/app:1.0",
|
||||
"labels": {
|
||||
"org.opencontainers.image.version": "1.0"
|
||||
},
|
||||
"expected": {
|
||||
"project": "",
|
||||
"service": "demo",
|
||||
"container": "demo-demo-1",
|
||||
"image": "org/app:1.0",
|
||||
"repository": "org/app",
|
||||
"tag": "1.0",
|
||||
"digest": null,
|
||||
"labels": {
|
||||
"org.opencontainers.image.version": "1.0"
|
||||
},
|
||||
"detected_version": "1.0",
|
||||
"proposed_image": null,
|
||||
"state": "no_digest"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "no digest and no labels",
|
||||
"image": "org/app",
|
||||
"labels": {},
|
||||
"expected": {
|
||||
"project": "",
|
||||
"service": "demo",
|
||||
"container": "demo-demo-1",
|
||||
"image": "org/app",
|
||||
"repository": "org/app",
|
||||
"tag": null,
|
||||
"digest": null,
|
||||
"labels": {},
|
||||
"detected_version": null,
|
||||
"proposed_image": null,
|
||||
"state": "no_digest"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "registry with a port",
|
||||
"image": "registry.local:5000/team/app@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"labels": {
|
||||
"org.opencontainers.image.version": "3.2.1"
|
||||
},
|
||||
"expected": {
|
||||
"project": "",
|
||||
"service": "demo",
|
||||
"container": "demo-demo-1",
|
||||
"image": "registry.local:5000/team/app@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"repository": "registry.local:5000/team/app",
|
||||
"tag": null,
|
||||
"digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"labels": {
|
||||
"org.opencontainers.image.version": "3.2.1"
|
||||
},
|
||||
"detected_version": "3.2.1",
|
||||
"proposed_image": "registry.local:5000/team/app:3.2.1@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"state": "ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "registry with a port and a tag",
|
||||
"image": "registry.local:5000/team/app:latest@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"labels": {
|
||||
"org.opencontainers.image.version": "3.2.1"
|
||||
},
|
||||
"expected": {
|
||||
"project": "",
|
||||
"service": "demo",
|
||||
"container": "demo-demo-1",
|
||||
"image": "registry.local:5000/team/app:latest@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"repository": "registry.local:5000/team/app",
|
||||
"tag": "latest",
|
||||
"digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"labels": {
|
||||
"org.opencontainers.image.version": "3.2.1"
|
||||
},
|
||||
"detected_version": "3.2.1",
|
||||
"proposed_image": "registry.local:5000/team/app:3.2.1@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"state": "ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "already versioned",
|
||||
"image": "ghcr.io/linuxserver/jackett:v0.24.2601-ls30@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"labels": {
|
||||
"org.opencontainers.image.version": "v0.24.2601-ls30"
|
||||
},
|
||||
"expected": {
|
||||
"project": "",
|
||||
"service": "demo",
|
||||
"container": "demo-demo-1",
|
||||
"image": "ghcr.io/linuxserver/jackett:v0.24.2601-ls30@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"repository": "ghcr.io/linuxserver/jackett",
|
||||
"tag": "v0.24.2601-ls30",
|
||||
"digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"labels": {
|
||||
"org.opencontainers.image.version": "v0.24.2601-ls30"
|
||||
},
|
||||
"detected_version": "v0.24.2601-ls30",
|
||||
"proposed_image": "ghcr.io/linuxserver/jackett:v0.24.2601-ls30@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"state": "ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "version label with spaces around",
|
||||
"image": "org/app@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"labels": {
|
||||
"org.opencontainers.image.version": " 1.0 "
|
||||
},
|
||||
"expected": {
|
||||
"project": "",
|
||||
"service": "demo",
|
||||
"container": "demo-demo-1",
|
||||
"image": "org/app@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"repository": "org/app",
|
||||
"tag": null,
|
||||
"digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"labels": {
|
||||
"org.opencontainers.image.version": " 1.0 "
|
||||
},
|
||||
"detected_version": " 1.0 ",
|
||||
"proposed_image": "org/app: 1.0 @sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"state": "ready"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerates cases.json: the expected results of the OLD image_version.sh on fixed inputs.
|
||||
|
||||
usage: generate.py <path to old image_version.sh>
|
||||
|
||||
The old script runs its Python on VM103 inside a bash heredoc and asks Docker twice (`docker compose config` for the image,
|
||||
`docker inspect` for the labels). This extracts that Python, swaps ONLY those two calls for values injected from the
|
||||
environment, and runs it. Nothing in the old repo is modified and nothing talks to Docker.
|
||||
"""
|
||||
import hashlib, json, os, subprocess, sys
|
||||
|
||||
script_path = sys.argv[1]
|
||||
source = open(script_path).read()
|
||||
body = source.split("<<'PY'\n", 1)[1].rsplit("\nPY", 1)[0]
|
||||
cfg_line = next(line for line in body.splitlines() if line.startswith("cfg = "))
|
||||
image_line = next(line for line in body.splitlines() if line.startswith("image = "))
|
||||
labels_line = next(line for line in body.splitlines() if line.startswith("raw_labels = "))
|
||||
program = (body.replace(cfg_line, "cfg = {}")
|
||||
.replace(image_line, "image = os.environ['IMAGE']")
|
||||
.replace(labels_line, "raw_labels = os.environ['LABELS']"))
|
||||
|
||||
def old_result(image, labels):
|
||||
env = {"IMAGE": image, "LABELS": labels if isinstance(labels, str) else json.dumps(labels), "CONTAINER": "demo-demo-1",
|
||||
"SERVICE": "demo", "COMPOSE": "/dev/null", "PATH": os.environ["PATH"]}
|
||||
out = subprocess.run([sys.executable, "-c", program], env=env, capture_output=True, text=True, check=True)
|
||||
return json.loads(out.stdout)
|
||||
|
||||
D = "sha256:" + "d" * 64
|
||||
LSIO = "Linuxserver.io version:- 4.0.15-ls289 Build-date:- 2026-09-01T00:00:00+00:00"
|
||||
CASES = {
|
||||
"digest only, OCI version label (dockge)": dict(image=f"louislam/dockge@{D}", labels={"org.opencontainers.image.version": "1.5.0", "maintainer": "x"}),
|
||||
"latest with digest, OCI label (grafana)": dict(image=f"grafana/grafana:latest@{D}", labels={"org.opencontainers.image.version": "13.2.2"}),
|
||||
"linuxserver build_version only": dict(image=f"linuxserver/bazarr@{D}", labels={"build_version": LSIO}),
|
||||
"OCI label wins over build_version": dict(image=f"linuxserver/bazarr@{D}", labels={"org.opencontainers.image.version": "v1.5.3-ls301", "build_version": LSIO}),
|
||||
"label-schema version is reported but not used": dict(image=f"org/app@{D}", labels={"org.label-schema.version": "2.1.0"}),
|
||||
"empty OCI label falls back to build_version": dict(image=f"linuxserver/x@{D}", labels={"org.opencontainers.image.version": "", "build_version": LSIO}),
|
||||
"build_version without the marker": dict(image=f"linuxserver/x@{D}", labels={"build_version": "custom build 7"}),
|
||||
"no labels at all": dict(image=f"org/app@{D}", labels={}),
|
||||
"labels are null (docker prints null)": dict(image=f"org/app@{D}", labels="null"),
|
||||
"labels unreadable": dict(image=f"org/app@{D}", labels="not json"),
|
||||
"no digest": dict(image="org/app:1.0", labels={"org.opencontainers.image.version": "1.0"}),
|
||||
"no digest and no labels": dict(image="org/app", labels={}),
|
||||
"registry with a port": dict(image=f"registry.local:5000/team/app@{D}", labels={"org.opencontainers.image.version": "3.2.1"}),
|
||||
"registry with a port and a tag": dict(image=f"registry.local:5000/team/app:latest@{D}", labels={"org.opencontainers.image.version": "3.2.1"}),
|
||||
"already versioned": dict(image=f"ghcr.io/linuxserver/jackett:v0.24.2601-ls30@{D}", labels={"org.opencontainers.image.version": "v0.24.2601-ls30"}),
|
||||
"version label with spaces around": dict(image=f"org/app@{D}", labels={"org.opencontainers.image.version": " 1.0 "}),
|
||||
}
|
||||
|
||||
doc = {"provenance": {"generatedBy": "fixtures/image-version/generate.py running the Python of the OLD image_version.sh",
|
||||
"scriptSha256": hashlib.sha256(source.encode()).hexdigest()},
|
||||
"cases": [{"name": n, "image": c["image"], "labels": c["labels"], "expected": old_result(**c)} for n, c in CASES.items()]}
|
||||
out = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cases.json")
|
||||
json.dump(doc, open(out, "w"), indent=1, ensure_ascii=False)
|
||||
print("wrote", out, "cases:", len(doc["cases"]))
|
||||
@@ -83,8 +83,8 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('the dry-run chain registers only the read-only steps', () => {
|
||||
it('has plan, status, image_tags and image_resolve, and nothing that changes anything', () => {
|
||||
expect([...handlers.keys()].sort()).toEqual(['image_resolve', 'image_tags', 'plan', 'status']);
|
||||
it('has plan, status, image_version, image_tags and image_resolve, and nothing that changes anything', () => {
|
||||
expect([...handlers.keys()].sort()).toEqual(['image_resolve', 'image_tags', 'image_version', 'plan', 'status']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -283,12 +283,25 @@ describe('with real updates on for the app (apply = on) and an executor', () =>
|
||||
dockerPs(runner, 'bazarr', 'bazarr', [{ State: 'running', Image: 'linuxserver/bazarr:v1.5.2-ls300' }]);
|
||||
expect(await run('status', 'bazarr')).toEqual({
|
||||
kind: 'ok',
|
||||
result: { image: 'linuxserver/bazarr:v1.5.2-ls300', state: 'running', health: null, current: 'v1.5.2-ls300', manifest_sha256: MANIFEST },
|
||||
result: { image: 'linuxserver/bazarr:v1.5.2-ls300', state: 'running', health: null, current: 'v1.5.2-ls300', manifest_image: 'linuxserver/bazarr:v1.5.2-ls300', manifest_sha256: MANIFEST },
|
||||
followUps: [{ app: 'bazarr', step: 'image_tags', input: { current: 'v1.5.2-ls300', manifest_sha256: MANIFEST } }],
|
||||
});
|
||||
expect(executor.calls).toEqual([{ op: 'inspect', request: { project: 'bazarr', service: 'bazarr' } }]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a digest only (dockge)', `louislam/dockge@${DIGEST}`],
|
||||
['a floating tag with a digest (grafana)', `grafana/grafana:latest@${DIGEST}`],
|
||||
])('status sends a manifest that pins %s to image_version', async (_label, image) => {
|
||||
dockerPs(runner, 'bazarr', 'bazarr', [{ State: 'running', Image: 'linuxserver/bazarr:v1.5.2-ls300' }]);
|
||||
executor.on('inspect', { outcome: 'done', result: { project: 'bazarr', service: 'bazarr', manifest_sha256: MANIFEST, env_sha256: null, manifest_image: image, container: null } });
|
||||
expect(await run('status', 'bazarr')).toMatchObject({
|
||||
kind: 'ok',
|
||||
result: { manifest_image: image, manifest_sha256: MANIFEST },
|
||||
followUps: [{ app: 'bazarr', step: 'image_version', input: { manifest_image: image, manifest_sha256: MANIFEST } }],
|
||||
});
|
||||
});
|
||||
|
||||
it('status stops when the executor may not touch the project', async () => {
|
||||
dockerPs(runner, 'bazarr', 'bazarr', [{ State: 'running', Image: 'linuxserver/bazarr:v1.5.2-ls300' }]);
|
||||
executor.on('inspect', { outcome: 'refused', reason: 'not_allowed', detail: 'project "bazarr" is not in EXECUTOR_PROJECTS' });
|
||||
@@ -299,6 +312,32 @@ describe('with real updates on for the app (apply = on) and an executor', () =>
|
||||
});
|
||||
});
|
||||
|
||||
describe('image_version', () => {
|
||||
const inspect = (labels: Record<string, string>) => {
|
||||
const id = 'e'.repeat(64);
|
||||
runner.on('docker ps -a -q --no-trunc --filter label=com.docker.compose.project=bazarr --filter label=com.docker.compose.service=bazarr', { exitCode: 0, stdout: `${id}\n`, stderr: '' });
|
||||
runner.on(`docker inspect ${id}`, { exitCode: 0, stdout: JSON.stringify([{ Id: id, State: { Status: 'running' }, Config: { Image: `louislam/dockge@${DIGEST}`, Labels: labels } }]), stderr: '' });
|
||||
};
|
||||
|
||||
it('names the version from the container label and hands it to image_tags', async () => {
|
||||
inspect({ 'org.opencontainers.image.version': '1.5.0' });
|
||||
expect(await run('image_version', 'bazarr', { manifest_image: `louislam/dockge@${DIGEST}`, manifest_sha256: MANIFEST })).toMatchObject({
|
||||
kind: 'ok',
|
||||
result: { detected_version: '1.5.0', proposed_image: `louislam/dockge:1.5.0@${DIGEST}`, state: 'ready' },
|
||||
followUps: [{ app: 'bazarr', step: 'image_tags', input: { current: '1.5.0', manifest_sha256: MANIFEST } }],
|
||||
});
|
||||
});
|
||||
|
||||
it('without a version label the version is unknown: STOP', async () => {
|
||||
inspect({});
|
||||
expect(await run('image_version', 'bazarr', { manifest_image: `louislam/dockge@${DIGEST}`, manifest_sha256: MANIFEST })).toMatchObject({
|
||||
kind: 'stop',
|
||||
reasonCode: 'version_unknown',
|
||||
detail: `louislam/dockge@${DIGEST} names no version and its container has no version label`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('image_tags carries the checksum to image_resolve, also in the proceed of a STOP', async () => {
|
||||
expect(await run('image_tags', 'bazarr', { current: 'v1.5.2-ls300', manifest_sha256: MANIFEST })).toMatchObject({
|
||||
followUps: [{ app: 'bazarr', step: 'image_resolve', input: { current: 'v1.5.2-ls300', candidate: 'v1.5.3-ls301', manifest_sha256: MANIFEST } }],
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { validateImage } from '../../domain/compose-edit.ts';
|
||||
import { splitImage } from '../../domain/image-ref.ts';
|
||||
import { imageVersion } from '../../domain/image-version.ts';
|
||||
import { error, ok, stop } from '../../domain/outcome.ts';
|
||||
import type { AppConfigStore, Clock, ExecutorClient, RegistryClient } from '../../domain/ports.ts';
|
||||
import { Sha256 } from '../../domain/preflight.ts';
|
||||
@@ -10,7 +12,7 @@ import { createRegistry } from '../handlers.ts';
|
||||
import type { HandlerRegistry } from '../handlers.ts';
|
||||
import { fetchImageTags } from '../ops/image-tags.ts';
|
||||
import { resolveImageRef } from '../ops/image-resolve.ts';
|
||||
import { serviceContainers } from '../ops/containers.ts';
|
||||
import { inspectServiceContainers, serviceContainers } from '../ops/containers.ts';
|
||||
import type { OpsDeps } from '../ops/result.ts';
|
||||
import { executorProblem } from '../update/executor-outcome.ts';
|
||||
|
||||
@@ -31,6 +33,13 @@ export interface DryRunDeps extends OpsDeps {
|
||||
const Checksum = Sha256.nullable().optional();
|
||||
const CurrentInput = z.object({ current: z.string().min(1), manifest_sha256: Checksum });
|
||||
const CandidateInput = z.object({ current: z.string().min(1), candidate: z.string().min(1), manifest_sha256: Checksum });
|
||||
const VersionInput = z.object({ manifest_image: z.string().min(1), manifest_sha256: Checksum });
|
||||
|
||||
/** A manifest that pins a digest only, or a floating tag, does not say which version runs: image_version reads it. */
|
||||
const namesNoVersion = (image: string): boolean => {
|
||||
const checked = validateImage(image);
|
||||
return !checked.valid && (checked.reason === 'missing_tag' || checked.reason === 'floating_tag');
|
||||
};
|
||||
|
||||
export function createDryRunHandlers(deps: DryRunDeps): HandlerRegistry {
|
||||
const appStep = appStepFor(deps.appConfig);
|
||||
@@ -73,8 +82,29 @@ export function createDryRunHandlers(deps: DryRunDeps): HandlerRegistry {
|
||||
|
||||
const inspected = await executor.inspect({ project: app, service: config.service }, signal);
|
||||
if (inspected.outcome !== 'done') return executorProblem(inspected, { app, now: now() });
|
||||
const checksum = inspected.result.manifest_sha256;
|
||||
return ok({ result: { ...base, manifest_sha256: checksum }, followUps: [{ app, step: 'image_tags', input: { current, manifest_sha256: checksum } }] });
|
||||
const { manifest_sha256: checksum, manifest_image: manifestImage } = inspected.result;
|
||||
const result = { ...base, manifest_image: manifestImage, manifest_sha256: checksum };
|
||||
if (manifestImage !== null && namesNoVersion(manifestImage)) {
|
||||
return ok({ result, followUps: [{ app, step: 'image_version', input: { manifest_image: manifestImage, manifest_sha256: checksum } }] });
|
||||
}
|
||||
return ok({ result, followUps: [{ app, step: 'image_tags', input: { current, manifest_sha256: checksum } }] });
|
||||
}),
|
||||
|
||||
// Port of image_version.sh: the version of an image the manifest pins by digest only (or by a floating tag), from the
|
||||
// labels of the running container. Read-only; the next update writes `repo:tag@digest` into the manifest anyway.
|
||||
image_version: appStep(async ({ app, config, step }, signal) => {
|
||||
const input = inputOf(VersionInput, step);
|
||||
if (!input.ok) return input.outcome;
|
||||
const { manifest_image: image, manifest_sha256: checksum } = input.value;
|
||||
const containers = await inspectServiceContainers(deps, app, config.service, signal);
|
||||
if (!containers.ok) return error(containers.reasonCode, containers.detail);
|
||||
const container = containers.value[0];
|
||||
if (container === undefined) return error('unexpected', `no container of service "${config.service}" in compose project "${app}"`);
|
||||
const version = imageVersion(image, container.versionLabels);
|
||||
const result = toJson(version);
|
||||
if (version.state === 'no_digest') return error('unexpected', `${image} has no digest, so it needs no version from labels`, { result });
|
||||
if (version.detected_version === null) return stop('version_unknown', `${image} names no version and its container has no version label`, { result });
|
||||
return ok({ result, followUps: [{ app, step: 'image_tags', input: withChecksum({ current: version.detected_version }, checksum) }] });
|
||||
}),
|
||||
|
||||
image_tags: appStep(async ({ app, config, step }, signal) => {
|
||||
|
||||
@@ -92,7 +92,11 @@ const INSPECTED = {
|
||||
Id: ID,
|
||||
Name: '/jackett-jackett-1',
|
||||
State: { Status: 'running', Running: true, Restarting: false, ExitCode: 0 },
|
||||
Config: { Image: PINNED, Env: ['API_KEY=secret-value', 'TZ=Europe/Warsaw'], Labels: { 'com.docker.compose.project': 'jackett' } },
|
||||
Config: {
|
||||
Image: PINNED,
|
||||
Env: ['API_KEY=secret-value', 'TZ=Europe/Warsaw'],
|
||||
Labels: { 'com.docker.compose.project': 'jackett', 'org.opencontainers.image.version': 'v0.24.2601-ls30', 'traefik.http.middlewares.auth.basicauth.users': 'secret-hash' },
|
||||
},
|
||||
Mounts: [
|
||||
{ Type: 'bind', Source: '/srv/appdata/jackett/config', Destination: '/config', Mode: '', RW: true, Propagation: 'rprivate' },
|
||||
{ Type: 'volume', Name: 'f'.repeat(64), Source: `/var/lib/docker/volumes/${'f'.repeat(64)}/_data`, Destination: '/cache', Driver: 'local', RW: true },
|
||||
@@ -115,6 +119,7 @@ describe('inspectServiceContainers', () => {
|
||||
state: 'running',
|
||||
health: null,
|
||||
image: PINNED,
|
||||
versionLabels: { 'org.opencontainers.image.version': 'v0.24.2601-ls30' },
|
||||
mounts: [
|
||||
{ type: 'bind', source: '/srv/appdata/jackett/config', target: '/config', name: null },
|
||||
{ type: 'volume', source: `/var/lib/docker/volumes/${'f'.repeat(64)}/_data`, target: '/cache', name: 'f'.repeat(64) },
|
||||
@@ -123,6 +128,7 @@ describe('inspectServiceContainers', () => {
|
||||
],
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain('secret-value');
|
||||
expect(JSON.stringify(result)).not.toContain('secret-hash');
|
||||
});
|
||||
|
||||
it('reads the health check status when there is one', async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { ComposePsError, parseDockerPs, parseProjectName } from '../../domain/compose.ts';
|
||||
import { VERSION_LABELS } from '../../domain/image-version.ts';
|
||||
import type { ComposeContainer } from '../../domain/compose.ts';
|
||||
import { describeError } from '../../domain/mask.ts';
|
||||
import { describeCommandFailure, fail, runCommand, succeed } from './result.ts';
|
||||
@@ -41,6 +42,8 @@ export interface InspectedContainer {
|
||||
readonly health: string | null;
|
||||
/** Config.Image: the image as the manifest wrote it (`repo:tag@sha256:…`), unlike `docker ps`, which drops the digest. */
|
||||
readonly image: string | null;
|
||||
/** Only the image version labels: other labels can hold secrets (e.g. traefik basic auth hashes). */
|
||||
readonly versionLabels: Readonly<Record<string, string>>;
|
||||
readonly mounts: readonly { readonly type: string; readonly source: string; readonly target: string; readonly name: string | null }[];
|
||||
}
|
||||
|
||||
@@ -49,7 +52,7 @@ const InspectRows = z.array(
|
||||
z.object({
|
||||
Id: z.string(),
|
||||
State: z.object({ Status: z.string().optional(), Health: z.object({ Status: z.string() }).nullish() }).optional(),
|
||||
Config: z.object({ Image: z.string().optional() }).optional(),
|
||||
Config: z.object({ Image: z.string().optional(), Labels: z.record(z.string(), z.string()).nullish() }).optional(),
|
||||
Mounts: z.array(z.object({ Type: z.string(), Source: z.string().optional(), Destination: z.string(), Name: z.string().optional() })).nullish(),
|
||||
}),
|
||||
);
|
||||
@@ -93,6 +96,10 @@ export async function inspectServiceContainers(deps: OpsDeps, projectName: strin
|
||||
state: row.State?.Status ?? null,
|
||||
health: row.State?.Health?.Status ?? null,
|
||||
image: row.Config?.Image ?? null,
|
||||
versionLabels: Object.fromEntries(VERSION_LABELS.flatMap((key) => {
|
||||
const value = row.Config?.Labels?.[key];
|
||||
return value === undefined ? [] : [[key, value]];
|
||||
})),
|
||||
mounts: (row.Mounts ?? []).map((mount) => ({ type: mount.Type, source: mount.Source ?? '', target: mount.Destination, name: mount.Name ?? null })),
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { imageVersion, VERSION_LABELS } from './image-version.ts';
|
||||
|
||||
// Contract test: the expected values in fixtures/image-version/cases.json were produced by the Python of the OLD
|
||||
// image_version.sh with its two Docker calls replaced by fixed inputs (see "provenance" there).
|
||||
interface Case {
|
||||
name: string;
|
||||
image: string;
|
||||
labels: Record<string, string> | string;
|
||||
expected: Record<string, unknown>;
|
||||
}
|
||||
const { cases } = JSON.parse(readFileSync(join(import.meta.dirname, '../../fixtures/image-version/cases.json'), 'utf8')) as { cases: Case[] };
|
||||
|
||||
/** The old script read labels as JSON text; `null` or unreadable text was no labels. */
|
||||
const labelsOf = (labels: Case['labels']): Record<string, string> | null => (typeof labels === 'string' ? null : labels);
|
||||
const DEVIATIONS = new Set(['version label with spaces around']);
|
||||
|
||||
describe('imageVersion: same result as the old image_version.sh', () => {
|
||||
for (const testCase of cases.filter((c) => !DEVIATIONS.has(c.name))) {
|
||||
it(testCase.name, () => {
|
||||
const { project: _project, service: _service, container: _container, ...expected } = testCase.expected;
|
||||
expect(imageVersion(testCase.image, labelsOf(testCase.labels))).toEqual(expected);
|
||||
});
|
||||
}
|
||||
|
||||
it('covers the interesting inputs (guards against an emptied fixture)', () => {
|
||||
expect(cases.length).toBeGreaterThanOrEqual(15);
|
||||
expect(cases.some((c) => c.expected['state'] === 'no_version_label')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deliberate differences', () => {
|
||||
it('a label that cannot be a tag is no version (the old script proposed `org/app: 1.0 @…`)', () => {
|
||||
const digest = `sha256:${'d'.repeat(64)}`;
|
||||
expect(imageVersion(`org/app@${digest}`, { 'org.opencontainers.image.version': ' 1.0 ' })).toMatchObject({ detected_version: null, proposed_image: null, state: 'no_version_label' });
|
||||
expect(DEVIATIONS.size).toBe(1);
|
||||
});
|
||||
|
||||
it('reads only the three version labels', () => {
|
||||
expect(VERSION_LABELS).toEqual(['org.opencontainers.image.version', 'build_version', 'org.label-schema.version']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
// image_version, ported from image_version.sh: the version of an image pinned by digest only (or by a floating tag), read
|
||||
// from the labels of the running container, and the reference that names it (`repo:<version>@<digest>`).
|
||||
// Contract-tested against the old script. Result shape keeps the old JSON (snake_case).
|
||||
|
||||
/** The labels the old script looked at, in its order of trust. */
|
||||
export const VERSION_LABELS = ['org.opencontainers.image.version', 'build_version', 'org.label-schema.version'] as const;
|
||||
|
||||
export interface ImageVersion {
|
||||
readonly image: string;
|
||||
readonly repository: string;
|
||||
readonly tag: string | null;
|
||||
readonly digest: string | null;
|
||||
/** The version labels the container has (non-empty ones only). */
|
||||
readonly labels: Readonly<Record<string, string>>;
|
||||
readonly detected_version: string | null;
|
||||
/** `repo:<version>@<digest>`; null without a version or a digest. */
|
||||
readonly proposed_image: string | null;
|
||||
readonly state: 'ready' | 'no_version_label' | 'no_digest';
|
||||
}
|
||||
|
||||
/** Stricter than the old script: a version must be usable as a tag, or it is no version. */
|
||||
const TAG = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/;
|
||||
const LINUXSERVER_MARKER = 'version:-';
|
||||
|
||||
function detectVersion(labels: Readonly<Record<string, string>>): string | null {
|
||||
const oci = labels['org.opencontainers.image.version'];
|
||||
if (oci !== undefined && oci !== '') return oci;
|
||||
// LinuxServer: "Linuxserver.io version:- 4.0.15-ls289 Build-date:- …"
|
||||
const build = labels['build_version'] ?? '';
|
||||
const at = build.indexOf(LINUXSERVER_MARKER);
|
||||
if (at === -1) return null;
|
||||
return build.slice(at + LINUXSERVER_MARKER.length).trim().split(/\s+/)[0] ?? null;
|
||||
}
|
||||
|
||||
export function imageVersion(image: string, allLabels: Readonly<Record<string, string>> | null): ImageVersion {
|
||||
const at = image.indexOf('@');
|
||||
const nameTag = at === -1 ? image : image.slice(0, at);
|
||||
const digest = at === -1 ? '' : image.slice(at + 1);
|
||||
const last = nameTag.slice(nameTag.lastIndexOf('/') + 1);
|
||||
const tag = last.includes(':') ? last.slice(last.lastIndexOf(':') + 1) : '';
|
||||
const repository = tag === '' ? nameTag : nameTag.slice(0, -(tag.length + 1));
|
||||
|
||||
const labels: Record<string, string> = {};
|
||||
for (const key of VERSION_LABELS) {
|
||||
const value = allLabels?.[key];
|
||||
if (value !== undefined && value !== '') labels[key] = value;
|
||||
}
|
||||
const detected = detectVersion(labels);
|
||||
const version = detected !== null && TAG.test(detected) ? detected : null;
|
||||
const proposed = version !== null && digest !== '' ? `${repository}:${version}@${digest}` : null;
|
||||
return {
|
||||
image,
|
||||
repository,
|
||||
tag: tag === '' ? null : tag,
|
||||
digest: digest === '' ? null : digest,
|
||||
labels,
|
||||
detected_version: version,
|
||||
proposed_image: proposed,
|
||||
state: proposed !== null ? 'ready' : digest !== '' ? 'no_version_label' : 'no_digest',
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user