katalon-cli 0.3.5__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {katalon_cli-0.3.5 → katalon_cli-0.5.0}/PKG-INFO +24 -2
  2. {katalon_cli-0.3.5 → katalon_cli-0.5.0}/README.md +23 -1
  3. {katalon_cli-0.3.5 → katalon_cli-0.5.0}/pyproject.toml +1 -1
  4. {katalon_cli-0.3.5 → katalon_cli-0.5.0}/pyproject.toml.orig +1 -1
  5. {katalon_cli-0.3.5 → katalon_cli-0.5.0}/src/katalon_cli/core/compose_gen.py +5 -1
  6. {katalon_cli-0.3.5 → katalon_cli-0.5.0}/src/katalon_cli/core/docker.py +18 -0
  7. katalon_cli-0.5.0/src/katalon_cli/core/images.py +72 -0
  8. {katalon_cli-0.3.5 → katalon_cli-0.5.0}/src/katalon_cli/core/release.py +26 -0
  9. {katalon_cli-0.3.5 → katalon_cli-0.5.0}/src/katalon_cli/core/state.py +6 -1
  10. {katalon_cli-0.3.5 → katalon_cli-0.5.0}/src/katalon_cli/main.py +51 -6
  11. {katalon_cli-0.3.5 → katalon_cli-0.5.0}/src/katalon_cli/templates/compose.yaml.j2 +9 -7
  12. {katalon_cli-0.3.5 → katalon_cli-0.5.0}/src/katalon_cli/templates/nginx.conf.j2 +19 -0
  13. {katalon_cli-0.3.5 → katalon_cli-0.5.0}/src/katalon_cli/__init__.py +0 -0
  14. {katalon_cli-0.3.5 → katalon_cli-0.5.0}/src/katalon_cli/core/__init__.py +0 -0
  15. {katalon_cli-0.3.5 → katalon_cli-0.5.0}/src/katalon_cli/core/backup.py +0 -0
  16. {katalon_cli-0.3.5 → katalon_cli-0.5.0}/src/katalon_cli/core/checks.py +0 -0
  17. {katalon_cli-0.3.5 → katalon_cli-0.5.0}/src/katalon_cli/core/paths.py +0 -0
  18. {katalon_cli-0.3.5 → katalon_cli-0.5.0}/src/katalon_cli/core/permissions.py +0 -0
  19. {katalon_cli-0.3.5 → katalon_cli-0.5.0}/src/katalon_cli/core/reindex.py +0 -0
  20. {katalon_cli-0.3.5 → katalon_cli-0.5.0}/src/katalon_cli/core/selfupdate.py +0 -0
  21. {katalon_cli-0.3.5 → katalon_cli-0.5.0}/src/katalon_cli/py.typed +0 -0
  22. {katalon_cli-0.3.5 → katalon_cli-0.5.0}/src/katalon_cli/templates/Caddyfile.j2 +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: katalon-cli
3
- Version: 0.3.5
3
+ Version: 0.5.0
4
4
  Summary: Installer & Updater CLI für Katalon Production-Instanzen
5
5
  Author: Karl Krägelin
6
6
  Author-email: Karl Krägelin <karlkraegelin@gmail.com>
@@ -66,6 +66,27 @@ katalon start
66
66
  katalon status
67
67
  ```
68
68
 
69
+ ## Images der Fremdservices
70
+
71
+ Welche Images Postgres, Redis, Elasticsearch, Cantaloupe, nginx und Caddy nutzen, legt das
72
+ Katalon-Release fest (`images` in `katalon-release.json`), nicht die CLI. `install` und `update`
73
+ schreiben sie in `compose.yaml`, `update` pullt sie mit. Releases ohne `images` (bis Katalon
74
+ 1.46.0) bekommen die Images, die bis katalon-cli 0.3.5 fest im Template standen.
75
+
76
+ Vor einem Update vergleicht `katalon update` die wirksamen Images (inklusive
77
+ `compose.override.yaml`) vor und nach dem Update, zeigt jeden Wechsel an und bricht ab, bevor
78
+ etwas geändert wird, wenn
79
+
80
+ - Postgres oder Elasticsearch die Major-Version wechseln würden (Postgres braucht dafür
81
+ Dump/Restore, Elasticsearch ein neues Datenverzeichnis und einen Reindex), oder
82
+ - ein Postgres- oder Elasticsearch-Image die Anforderung des Releases (`requires`, z. B.
83
+ `>=8.13,<9`) nicht erfüllt, etwa ein eigenes Image aus `compose.override.yaml`. `requires`
84
+ gilt erst ab Releases mit `images`; ältere Releases nannten dort Werte, die nie geprüft wurden.
85
+
86
+ `installation.json` merkt sich die Images je Version. `rollback` nimmt die der vorigen Version,
87
+ behält aber ein neueres Elasticsearch derselben Major-Version, weil Elasticsearch ein
88
+ Datenverzeichnis nicht mit einer älteren Version öffnet.
89
+
69
90
  ## API-Adresse
70
91
 
71
92
  Die REST-API liegt direkt unter der Base-URL, ohne `/api`-Präfix: `<KATALON_BASE_URL>/v1/...`
@@ -159,13 +180,14 @@ uv run pytest
159
180
  nach (`--yes` bricht dann ab), tragen neue optionale Variablen auskommentiert in `.env` ein und
160
181
  warnen vor veralteten Variablen, die noch in der `.env` stehen.
161
182
  - `core/compose_gen.py` + `templates/compose.yaml.j2` + `templates/nginx.conf.j2` — rendert `compose.yaml`
162
- + `nginx.conf` und bei `tls_mode=caddy` das `Caddyfile` aus Version + TLS-Modus;
183
+ + `nginx.conf` und bei `tls_mode=caddy` das `Caddyfile` aus Version, Images + TLS-Modus;
163
184
  erzeugt bei `tls_mode=standalone` ein selbstsigniertes
164
185
  Zertifikat unter `<dir>/certs/` (eigenes Zertifikat dort ablegen, um es zu ersetzen).
165
186
  `install` fragt zusätzlich `MEDIA_ROOT` ab und legt `<dir>/compose.override.yaml.example` an
166
187
  (instanzspezifische Overrides — umbenennen zu `compose.override.yaml`, wird automatisch eingebunden
167
188
  und bei Updates nicht angefasst). Optionale Env-Vars (Admin-Login, SMTP, Geonames, …) landet
168
189
  auskommentiert in `.env`.
190
+ - `core/images.py` — prüft die Images der Fremdservices vor einem Update (siehe „Images der Fremdservices“).
169
191
  - `core/backup.py` — `pg_dump` vor jedem Update; Rollback restauriert Dump statt `alembic downgrade`.
170
192
  - `core/docker.py` — dünner `docker compose`-Subprocess-Wrapper.
171
193
  - `main.py` — Typer-Commands, interaktive Teile über `rich.prompt`/`rich.progress`.
@@ -52,6 +52,27 @@ katalon start
52
52
  katalon status
53
53
  ```
54
54
 
55
+ ## Images der Fremdservices
56
+
57
+ Welche Images Postgres, Redis, Elasticsearch, Cantaloupe, nginx und Caddy nutzen, legt das
58
+ Katalon-Release fest (`images` in `katalon-release.json`), nicht die CLI. `install` und `update`
59
+ schreiben sie in `compose.yaml`, `update` pullt sie mit. Releases ohne `images` (bis Katalon
60
+ 1.46.0) bekommen die Images, die bis katalon-cli 0.3.5 fest im Template standen.
61
+
62
+ Vor einem Update vergleicht `katalon update` die wirksamen Images (inklusive
63
+ `compose.override.yaml`) vor und nach dem Update, zeigt jeden Wechsel an und bricht ab, bevor
64
+ etwas geändert wird, wenn
65
+
66
+ - Postgres oder Elasticsearch die Major-Version wechseln würden (Postgres braucht dafür
67
+ Dump/Restore, Elasticsearch ein neues Datenverzeichnis und einen Reindex), oder
68
+ - ein Postgres- oder Elasticsearch-Image die Anforderung des Releases (`requires`, z. B.
69
+ `>=8.13,<9`) nicht erfüllt, etwa ein eigenes Image aus `compose.override.yaml`. `requires`
70
+ gilt erst ab Releases mit `images`; ältere Releases nannten dort Werte, die nie geprüft wurden.
71
+
72
+ `installation.json` merkt sich die Images je Version. `rollback` nimmt die der vorigen Version,
73
+ behält aber ein neueres Elasticsearch derselben Major-Version, weil Elasticsearch ein
74
+ Datenverzeichnis nicht mit einer älteren Version öffnet.
75
+
55
76
  ## API-Adresse
56
77
 
57
78
  Die REST-API liegt direkt unter der Base-URL, ohne `/api`-Präfix: `<KATALON_BASE_URL>/v1/...`
@@ -145,13 +166,14 @@ uv run pytest
145
166
  nach (`--yes` bricht dann ab), tragen neue optionale Variablen auskommentiert in `.env` ein und
146
167
  warnen vor veralteten Variablen, die noch in der `.env` stehen.
147
168
  - `core/compose_gen.py` + `templates/compose.yaml.j2` + `templates/nginx.conf.j2` — rendert `compose.yaml`
148
- + `nginx.conf` und bei `tls_mode=caddy` das `Caddyfile` aus Version + TLS-Modus;
169
+ + `nginx.conf` und bei `tls_mode=caddy` das `Caddyfile` aus Version, Images + TLS-Modus;
149
170
  erzeugt bei `tls_mode=standalone` ein selbstsigniertes
150
171
  Zertifikat unter `<dir>/certs/` (eigenes Zertifikat dort ablegen, um es zu ersetzen).
151
172
  `install` fragt zusätzlich `MEDIA_ROOT` ab und legt `<dir>/compose.override.yaml.example` an
152
173
  (instanzspezifische Overrides — umbenennen zu `compose.override.yaml`, wird automatisch eingebunden
153
174
  und bei Updates nicht angefasst). Optionale Env-Vars (Admin-Login, SMTP, Geonames, …) landet
154
175
  auskommentiert in `.env`.
176
+ - `core/images.py` — prüft die Images der Fremdservices vor einem Update (siehe „Images der Fremdservices“).
155
177
  - `core/backup.py` — `pg_dump` vor jedem Update; Rollback restauriert Dump statt `alembic downgrade`.
156
178
  - `core/docker.py` — dünner `docker compose`-Subprocess-Wrapper.
157
179
  - `main.py` — Typer-Commands, interaktive Teile über `rich.prompt`/`rich.progress`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "katalon-cli"
3
- version = "0.3.5"
3
+ version = "0.5.0"
4
4
  description = "Installer & Updater CLI für Katalon Production-Instanzen"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "katalon-cli"
3
- version = "0.3.5"
3
+ version = "0.5.0"
4
4
  description = "Installer & Updater CLI für Katalon Production-Instanzen"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1,4 +1,4 @@
1
- """Rendert compose.yaml + nginx.conf aus Template + Release-Version + TLS-Modus."""
1
+ """Rendert compose.yaml + nginx.conf aus Template + Release (Version, Images) + TLS-Modus."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -10,6 +10,8 @@ from urllib.parse import urlparse
10
10
 
11
11
  from jinja2 import Environment, PackageLoader, select_autoescape
12
12
 
13
+ from .release import ReleaseImages
14
+
13
15
  _env = Environment(
14
16
  loader=PackageLoader("katalon_cli", "templates"),
15
17
  autoescape=select_autoescape(disabled_extensions=(".yaml", ".j2", ".conf")),
@@ -23,6 +25,7 @@ def render_compose(
23
25
  version: str,
24
26
  base_url: str,
25
27
  tls_mode: str,
28
+ images: ReleaseImages | None = None,
26
29
  registry: str = "ghcr.io/katalon-collections/katalon",
27
30
  ) -> str:
28
31
  if tls_mode == "caddy":
@@ -34,6 +37,7 @@ def render_compose(
34
37
  base_url=base_url,
35
38
  tls_mode=tls_mode,
36
39
  registry=registry,
40
+ images=images or ReleaseImages(),
37
41
  port=parsed_port,
38
42
  )
39
43
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import shutil
6
7
  import subprocess
7
8
  import time
@@ -45,6 +46,23 @@ def compose(
45
46
  )
46
47
 
47
48
 
49
+ def config_images(instance_dir: Path, compose_file: Path | None = None) -> dict[str, str]:
50
+ """Images je Service, wie Compose sie startet (inkl. compose.override.yaml)."""
51
+ files = ["-f", str(compose_file or instance_dir / "compose.yaml")]
52
+ override = instance_dir / "compose.override.yaml"
53
+ if override.exists():
54
+ files += ["-f", str(override)]
55
+ result = subprocess.run(
56
+ [*compose_command(), "--project-directory", str(instance_dir), *files, "config", "--format", "json"],
57
+ cwd=instance_dir,
58
+ check=True,
59
+ text=True,
60
+ capture_output=True,
61
+ )
62
+ services = json.loads(result.stdout).get("services", {})
63
+ return {name: svc["image"] for name, svc in services.items() if svc.get("image")}
64
+
65
+
48
66
  def reload_nginx(instance_dir: Path) -> None:
49
67
  """`up -d` erstellt nginx nicht neu, wenn nur die gemountete nginx.conf sich ändert."""
50
68
  compose(instance_dir, "exec", "-T", "nginx", "nginx", "-t")
@@ -0,0 +1,72 @@
1
+ """Prüft die Fremdservice-Images eines Updates: Major-Wechsel und `requires` des Releases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import operator
6
+ import re
7
+
8
+ from .release import ReleaseImages, ReleaseRequirements
9
+
10
+ # Compose-Service → Rolle in ReleaseImages/ReleaseRequirements, für die ein Major-Wechsel
11
+ # nicht per Image-Tausch geht: Postgres braucht Dump/Restore, Elasticsearch öffnet ein
12
+ # Datenverzeichnis einer anderen Major-Version nicht (Rückweg gar nicht).
13
+ DATA_SERVICES = {"db": "postgres", "elasticsearch": "elasticsearch"}
14
+
15
+ _OPS = {">=": operator.ge, "<=": operator.le, "==": operator.eq, ">": operator.gt, "<": operator.lt}
16
+
17
+
18
+ def image_version(image: str) -> tuple[int, ...] | None:
19
+ """Version aus dem Tag (`postgis/postgis:16-3.4` → (16,), `…:8.13.4` → (8, 13, 4))."""
20
+ name = image.rsplit("/", 1)[-1]
21
+ if ":" not in name:
22
+ return None
23
+ match = re.match(r"\d+(?:\.\d+)*", name.split(":", 1)[1])
24
+ return tuple(int(part) for part in match.group(0).split(".")) if match else None
25
+
26
+
27
+ def satisfies(version: tuple[int, ...], spec: str) -> bool:
28
+ """Minimaler Specifier-Vergleich für `requires` (z. B. ">=8.13,<9")."""
29
+ for clause in spec.split(","):
30
+ match = re.fullmatch(r"\s*(>=|<=|==|>|<)\s*(\d+(?:\.\d+)*)\s*", clause)
31
+ if not match:
32
+ raise ValueError(f"Ungültige Versionsanforderung: {spec}")
33
+ bound = tuple(int(part) for part in match.group(2).split("."))
34
+ if not _OPS[match.group(1)](version[: len(bound)], bound):
35
+ return False
36
+ return True
37
+
38
+
39
+ def update_problems(
40
+ current: dict[str, str], target: dict[str, str], requires: ReleaseRequirements
41
+ ) -> tuple[list[str], list[str]]:
42
+ """(Fehler, Hinweise) für den Wechsel der wirksamen Images `current` → `target`."""
43
+ errors: list[str] = []
44
+ notes: list[str] = []
45
+ for service, role in DATA_SERVICES.items():
46
+ old, new = current.get(service), target.get(service)
47
+ if not new:
48
+ continue
49
+ new_version = image_version(new)
50
+ spec = getattr(requires, role)
51
+ if spec and new_version and not satisfies(new_version, spec):
52
+ errors.append(f"{service}: {new} erfüllt die Anforderung des Releases nicht ({role} {spec}).")
53
+ elif spec and not new_version:
54
+ notes.append(f"{service}: Version von {new} nicht erkennbar, Anforderung {spec} ungeprüft.")
55
+ old_version = image_version(old) if old else None
56
+ if old_version and new_version and old_version[0] != new_version[0]:
57
+ errors.append(
58
+ f"{service}: Major-Wechsel {old} → {new} geht nicht per Image-Tausch "
59
+ "(Postgres: Dump/Restore, Elasticsearch: neues Datenverzeichnis und Reindex)."
60
+ )
61
+ return errors, notes
62
+
63
+
64
+ def rollback_images(current: ReleaseImages, previous: ReleaseImages) -> ReleaseImages:
65
+ """Images für den Rollback: die des vorigen Releases, außer einem neueren Elasticsearch.
66
+
67
+ Elasticsearch öffnet ein Datenverzeichnis nicht mehr mit einer älteren Version; die
68
+ Katalon-Version davor läuft mit dem neueren Minor-Release derselben Major-Version."""
69
+ cur, prev = image_version(current.elasticsearch), image_version(previous.elasticsearch)
70
+ if cur and prev and cur[0] == prev[0] and cur > prev:
71
+ return previous.model_copy(update={"elasticsearch": current.elasticsearch})
72
+ return previous
@@ -13,6 +13,20 @@ class ReleaseRequirements(BaseModel):
13
13
  elasticsearch: str | None = None
14
14
 
15
15
 
16
+ class ReleaseImages(BaseModel):
17
+ """Images der Fremdservices, die das Katalon-Release festlegt (`images` in katalon-release.json).
18
+
19
+ Die Defaults sind der Stand der Releases vor diesem Feld (bis Katalon 1.46.0) und
20
+ bleiben deshalb unverändert; neue Versionen kommen über das Release, nicht hierher."""
21
+
22
+ postgres: str = "postgis/postgis:16-3.4"
23
+ redis: str = "redis:7-alpine"
24
+ elasticsearch: str = "docker.elastic.co/elasticsearch/elasticsearch:8.13.4"
25
+ cantaloupe: str = "islandora/cantaloupe:7.0.22"
26
+ nginx: str = "nginx:alpine"
27
+ caddy: str = "caddy:2-alpine"
28
+
29
+
16
30
  class ReleaseEnvVar(BaseModel):
17
31
  """Neu hinzugekommene Umgebungsvariable eines Releases (katalon-release.json)."""
18
32
 
@@ -38,10 +52,22 @@ class ReleaseMetadata(BaseModel):
38
52
  reindex_required: bool = False
39
53
  breaking: bool
40
54
  compose_revision: int
55
+ # Fehlt bei Releases bis Katalon 1.46.0; dort galten die Images des Templates.
56
+ images: ReleaseImages | None = None
41
57
  requires: ReleaseRequirements = ReleaseRequirements()
42
58
  env_vars: list[ReleaseEnvVar] = Field(default_factory=list)
43
59
  deprecated_env_vars: list[DeprecatedEnvVar] = Field(default_factory=list)
44
60
 
61
+ @property
62
+ def service_images(self) -> ReleaseImages:
63
+ return self.images or ReleaseImages()
64
+
65
+ @property
66
+ def checked_requires(self) -> ReleaseRequirements:
67
+ """`requires` gilt erst ab Releases mit `images`; davor prüfte es niemand, und der
68
+ Wert war nicht verlässlich (1.46.0 nennt Elasticsearch >=8.15, läuft aber mit 8.13.4)."""
69
+ return self.requires if self.images else ReleaseRequirements()
70
+
45
71
 
46
72
  def _fetch_release_asset(tag: str | None) -> dict:
47
73
  """Lädt katalon-release.json vom GitHub-Release (tag=None → latest)."""
@@ -18,6 +18,8 @@ class HistoryEntry(BaseModel):
18
18
  compose_revision: int
19
19
  action: str # "install" | "update" | "rollback"
20
20
  at: datetime
21
+ # Images der Fremdservices aus dem Release (Rolle → Image); fehlt bei Einträgen vor katalon-cli 0.4.0.
22
+ images: dict[str, str] = Field(default_factory=dict)
21
23
 
22
24
 
23
25
  class InstallationState(BaseModel):
@@ -41,13 +43,16 @@ class InstallationState(BaseModel):
41
43
  path = instance_dir / STATE_FILENAME
42
44
  path.write_text(self.model_dump_json(indent=2))
43
45
 
44
- def record(self, version: str, compose_revision: int, action: str) -> None:
46
+ def record(
47
+ self, version: str, compose_revision: int, action: str, images: dict[str, str] | None = None
48
+ ) -> None:
45
49
  self.history.append(
46
50
  HistoryEntry(
47
51
  version=version,
48
52
  compose_revision=compose_revision,
49
53
  action=action,
50
54
  at=datetime.now(timezone.utc),
55
+ images=images or {},
51
56
  )
52
57
  )
53
58
  self.version = version
@@ -29,8 +29,9 @@ from rich.table import Table
29
29
 
30
30
  from .core import backup as backup_mod
31
31
  from .core import checks, docker, permissions, release, selfupdate
32
+ from .core import images as images_mod
32
33
  from .core import reindex as reindex_mod
33
- from .core.compose_gen import caddy_host, write_compose
34
+ from .core.compose_gen import caddy_host, render_compose, write_compose
34
35
  from .core.paths import default_instance_dir, save_instance_dir
35
36
  from .core.state import InstallationState, instance_dir_or_raise
36
37
 
@@ -76,6 +77,7 @@ OPTIONAL_ENV_BLOCK = f"""
76
77
  # SMTP_FROM=
77
78
  # CORS_ORIGINS=["http://localhost"]
78
79
  # MAX_UPLOAD_SIZE_MB=200
80
+ # ATTACHMENT_MAX_UPLOAD_MB=200
79
81
  """
80
82
 
81
83
 
@@ -369,7 +371,7 @@ def install(
369
371
  progress.add_task("Schreibe compose.yaml + .env …", total=None)
370
372
  dir.mkdir(parents=True, exist_ok=True)
371
373
  Path(media_root).mkdir(parents=True, exist_ok=True)
372
- write_compose(dir, version=meta.version, base_url=base_url, tls_mode=tls_mode)
374
+ write_compose(dir, version=meta.version, base_url=base_url, tls_mode=tls_mode, images=meta.service_images)
373
375
 
374
376
  _ensure_env_vars(dir, base_url, media_root=media_root, release_env_vars=meta.env_vars, provided=provided)
375
377
  _write_override_example(dir)
@@ -381,7 +383,7 @@ def install(
381
383
  tls_mode=tls_mode,
382
384
  installed_at=datetime.now(timezone.utc),
383
385
  )
384
- state.record(meta.version, meta.compose_revision, "install")
386
+ state.record(meta.version, meta.compose_revision, "install", images=meta.service_images.model_dump())
385
387
  state.save(dir)
386
388
 
387
389
  save_instance_dir(dir)
@@ -456,6 +458,40 @@ def _media_root(dir: Path) -> str:
456
458
  return str(path if path.is_absolute() else dir / path)
457
459
 
458
460
 
461
+ def _check_update_images(dir: Path, state: InstallationState, meta: release.ReleaseMetadata) -> None:
462
+ """Zeigt, welche Fremdservice-Images das Update wechselt, und bricht ab, wenn eins nicht passt.
463
+
464
+ Verglichen werden die wirksamen Images (inkl. compose.override.yaml) vor und nach dem
465
+ Update, damit auch ein eigenes Image aus dem Override gegen `requires` geprüft wird."""
466
+ preview = dir / "compose.yaml.next"
467
+ try:
468
+ preview.write_text(
469
+ render_compose(
470
+ version=meta.version, base_url=state.base_url, tls_mode=state.tls_mode, images=meta.service_images
471
+ )
472
+ )
473
+ current = docker.config_images(dir)
474
+ target = docker.config_images(dir, preview)
475
+ except (docker.DockerError, subprocess.CalledProcessError, ValueError) as exc:
476
+ console.print(f"[yellow]⚠ Images der Fremdservices nicht prüfbar: {exc}[/]")
477
+ return
478
+ finally:
479
+ preview.unlink(missing_ok=True)
480
+
481
+ for service in sorted(target):
482
+ old, new = current.get(service), target[service]
483
+ if old and old != new and "katalon-collections/katalon-" not in new:
484
+ console.print(f"[yellow]⚠ {service}: {old} → {new}[/]")
485
+ errors, notes = images_mod.update_problems(current, target, meta.checked_requires)
486
+ for note in notes:
487
+ console.print(f"[yellow]⚠ {note}[/]")
488
+ if errors:
489
+ for error in errors:
490
+ console.print(f"[red]✖ {error}[/]")
491
+ console.print("[red]Update abgebrochen, nichts geändert.[/]")
492
+ raise typer.Exit(1)
493
+
494
+
459
495
  def _require_installer_version(minimum: str) -> None:
460
496
  """Bricht ab, wenn das Release eine neuere CLI braucht (z. B. neues Compose-Template).
461
497
 
@@ -730,6 +766,8 @@ def update(
730
766
  f"(Revision {state.compose_revision} → {meta.compose_revision})[/]"
731
767
  )
732
768
 
769
+ _check_update_images(dir, state, meta)
770
+
733
771
  env_existing = _parse_env_file((dir / ".env").read_text() if (dir / ".env").exists() else "")
734
772
  _print_new_env_vars(env_existing, env_vars)
735
773
  _print_deprecated_env_vars(env_existing, deprecated_env_vars)
@@ -749,7 +787,9 @@ def update(
749
787
  ),
750
788
  (
751
789
  "compose.yaml rendern",
752
- lambda: write_compose(dir, version=meta.version, base_url=state.base_url, tls_mode=state.tls_mode),
790
+ lambda: write_compose(
791
+ dir, version=meta.version, base_url=state.base_url, tls_mode=state.tls_mode, images=meta.service_images
792
+ ),
753
793
  ),
754
794
  ("Images pullen", lambda: docker.compose(dir, "pull")),
755
795
  ("Medienrechte prüfen", lambda: _ensure_media_permissions(dir)),
@@ -782,7 +822,7 @@ def update(
782
822
  console.print("[red]✖ Healthcheck fehlgeschlagen — `katalon rollback` erwägen.[/]")
783
823
  raise typer.Exit(1)
784
824
 
785
- state.record(meta.version, meta.compose_revision, "update")
825
+ state.record(meta.version, meta.compose_revision, "update", images=meta.service_images.model_dump())
786
826
  state.save(dir)
787
827
  console.print(f"[green]✔[/] Update auf {meta.version} abgeschlossen.")
788
828
 
@@ -840,7 +880,12 @@ def rollback(
840
880
  )
841
881
 
842
882
  if previous:
843
- write_compose(dir, version=previous.version, base_url=state.base_url, tls_mode=state.tls_mode)
883
+ images = images_mod.rollback_images(
884
+ release.ReleaseImages(**state.history[-1].images), release.ReleaseImages(**previous.images)
885
+ )
886
+ write_compose(
887
+ dir, version=previous.version, base_url=state.base_url, tls_mode=state.tls_mode, images=images
888
+ )
844
889
  docker.compose(dir, "pull")
845
890
 
846
891
  _ensure_media_permissions(dir)
@@ -1,6 +1,6 @@
1
1
  services:
2
2
  db:
3
- image: postgis/postgis:16-3.4
3
+ image: {{ images.postgres }}
4
4
  platform: linux/amd64
5
5
  restart: unless-stopped
6
6
  environment:
@@ -16,7 +16,7 @@ services:
16
16
  retries: 10
17
17
 
18
18
  redis:
19
- image: redis:7-alpine
19
+ image: {{ images.redis }}
20
20
  restart: unless-stopped
21
21
  healthcheck:
22
22
  test: ["CMD", "redis-cli", "ping"]
@@ -25,7 +25,7 @@ services:
25
25
  retries: 10
26
26
 
27
27
  elasticsearch:
28
- image: docker.elastic.co/elasticsearch/elasticsearch:8.13.4
28
+ image: {{ images.elasticsearch }}
29
29
  restart: unless-stopped
30
30
  environment:
31
31
  discovery.type: single-node
@@ -42,7 +42,7 @@ services:
42
42
  # Legt den Derivat-Cache an und übergibt ihn dem Cantaloupe-User des Images.
43
43
  # Ändert nur Einträge mit falschem Eigentümer (idempotent, schnell bei vollem Cache).
44
44
  cantaloupe-cache-init:
45
- image: islandora/cantaloupe:7.0.22
45
+ image: {{ images.cantaloupe }}
46
46
  user: "0:0"
47
47
  entrypoint: ["sh", "-c", "mkdir -p /data/cache && find /data ! -user cantaloupe -exec chown cantaloupe:cantaloupe {} +"]
48
48
  volumes:
@@ -50,7 +50,7 @@ services:
50
50
  restart: "no"
51
51
 
52
52
  cantaloupe:
53
- image: islandora/cantaloupe:7.0.22
53
+ image: {{ images.cantaloupe }}
54
54
  restart: unless-stopped
55
55
  volumes:
56
56
  - ${MEDIA_ROOT:-./media}:/var/lib/cantaloupe/images:ro
@@ -109,6 +109,7 @@ services:
109
109
  CANTALOUPE_URL: ${CANTALOUPE_URL:-http://cantaloupe:8182}
110
110
  CANTALOUPE_PUBLIC_URL: ${CANTALOUPE_PUBLIC_URL:-http://localhost}
111
111
  MEDIA_ROOT: /var/lib/katalon/media
112
+ ATTACHMENT_MAX_UPLOAD_MB: ${ATTACHMENT_MAX_UPLOAD_MB:-200}
112
113
  FIRST_RUN_CREDENTIALS_PATH: ${FIRST_RUN_CREDENTIALS_PATH:-/var/lib/katalon/first-run-credentials.txt}
113
114
  DEBUG: "false"
114
115
  volumes:
@@ -136,6 +137,7 @@ services:
136
137
  CANTALOUPE_URL: ${CANTALOUPE_URL:-http://cantaloupe:8182}
137
138
  CANTALOUPE_PUBLIC_URL: ${CANTALOUPE_PUBLIC_URL:-http://localhost}
138
139
  MEDIA_ROOT: /var/lib/katalon/media
140
+ ATTACHMENT_MAX_UPLOAD_MB: ${ATTACHMENT_MAX_UPLOAD_MB:-200}
139
141
  volumes:
140
142
  - ${MEDIA_ROOT:-./media}:/var/lib/katalon/media
141
143
  depends_on:
@@ -185,7 +187,7 @@ services:
185
187
  api: { condition: service_healthy }
186
188
 
187
189
  nginx:
188
- image: nginx:alpine
190
+ image: {{ images.nginx }}
189
191
  restart: unless-stopped
190
192
  volumes:
191
193
  - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
@@ -210,7 +212,7 @@ services:
210
212
 
211
213
  {% if tls_mode == "caddy" %}
212
214
  caddy:
213
- image: caddy:2-alpine
215
+ image: {{ images.caddy }}
214
216
  restart: unless-stopped
215
217
  ports:
216
218
  - "80:80"
@@ -38,6 +38,25 @@ server {
38
38
  proxy_set_header X-Forwarded-Proto {{ '$http_x_forwarded_proto' if tls_mode == 'caddy' else '$scheme' }};
39
39
  }
40
40
 
41
+ # Attachment and media files bring their own CSP per content type from the API (sandbox for image/audio/video,
42
+ # PDF-capable for PDF). The server-level page CSP (object-src 'none') blocks the browser PDF viewer,
43
+ # so this location omits CSP and X-Content-Type-Options, which the API sets itself.
44
+ # An add_header here drops the server-level ones: the remaining headers are repeated.
45
+ location ~ "^/(portal/)?v1/(attachments/[0-9a-fA-F-]{36}/(file|stream)|objects/[0-9a-fA-F-]{36}/media/[0-9a-fA-F-]{36}/(file|stream)|media/[0-9a-fA-F-]{36}/(file|download))$" {
46
+ set $upstream_api api:8000;
47
+ proxy_pass http://$upstream_api;
48
+ proxy_set_header Host $host;
49
+ proxy_set_header X-Real-IP $remote_addr;
50
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
51
+ proxy_set_header X-Forwarded-Proto {{ '$http_x_forwarded_proto' if tls_mode == 'caddy' else '$scheme' }};
52
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
53
+ add_header X-Frame-Options "SAMEORIGIN" always;
54
+ add_header Permissions-Policy "camera=(), geolocation=(), microphone=()" always;
55
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
56
+ add_header Cross-Origin-Opener-Policy "same-origin" always;
57
+ add_header Cross-Origin-Resource-Policy "same-origin" always;
58
+ }
59
+
41
60
  location /v1/ {
42
61
  set $upstream_api api:8000;
43
62
  proxy_pass http://$upstream_api;