diff --git a/docs/PORT.md b/docs/PORT.md index 7f5b1db..84d1fb8 100644 --- a/docs/PORT.md +++ b/docs/PORT.md @@ -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: # 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. diff --git a/fixtures/image-version/cases.json b/fixtures/image-version/cases.json new file mode 100644 index 0000000..9b8b2e6 --- /dev/null +++ b/fixtures/image-version/cases.json @@ -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" + } + } + ] +} \ No newline at end of file diff --git a/fixtures/image-version/generate.py b/fixtures/image-version/generate.py new file mode 100644 index 0000000..27ebc9f --- /dev/null +++ b/fixtures/image-version/generate.py @@ -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 + +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"])) diff --git a/src/app/dry-run/handlers.test.ts b/src/app/dry-run/handlers.test.ts index 67ba5eb..c701891 100644 --- a/src/app/dry-run/handlers.test.ts +++ b/src/app/dry-run/handlers.test.ts @@ -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) => { + 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 } }], diff --git a/src/app/dry-run/handlers.ts b/src/app/dry-run/handlers.ts index f042ba2..bd24d81 100644 --- a/src/app/dry-run/handlers.ts +++ b/src/app/dry-run/handlers.ts @@ -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) => { diff --git a/src/app/ops/containers.test.ts b/src/app/ops/containers.test.ts index efbf5c0..b51495b 100644 --- a/src/app/ops/containers.test.ts +++ b/src/app/ops/containers.test.ts @@ -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 () => { diff --git a/src/app/ops/containers.ts b/src/app/ops/containers.ts index ee5d17c..ef5921f 100644 --- a/src/app/ops/containers.ts +++ b/src/app/ops/containers.ts @@ -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>; 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 })), })), ); diff --git a/src/domain/image-version.test.ts b/src/domain/image-version.test.ts new file mode 100644 index 0000000..8f0b916 --- /dev/null +++ b/src/domain/image-version.test.ts @@ -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; + expected: Record; +} +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 | 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']); + }); +}); diff --git a/src/domain/image-version.ts b/src/domain/image-version.ts new file mode 100644 index 0000000..e57d756 --- /dev/null +++ b/src/domain/image-version.ts @@ -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:@`). +// 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>; + readonly detected_version: string | null; + /** `repo:@`; 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>): 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> | 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 = {}; + 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', + }; +}