image_version: name the version of a digest-only pin from the container's labels
ci / check (push) Successful in 37s
ci / publish (push) Successful in 19s

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:
homelab-updaterandClaude Opus 5 committed 2026-09-19 15:15:18 +02:00
1 parent 20daf1816c
commit e9cb72a59c
9 files changed
+601 -8

No files matched your search

+4
View File
@@ -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.
+348
View File
@@ -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"
}
}
]
}
+54
View File
@@ -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"]))
+42 -3
View File
@@ -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 } }],
+33 -3
View File
@@ -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) => {
+7 -1
View File
@@ -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 () => {
+8 -1
View File
@@ -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 })),
})),
);
+44
View File
@@ -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']);
});
});
+61
View File
@@ -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',
};
}