katalon-cli 0.1.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.
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.3
2
+ Name: katalon-cli
3
+ Version: 0.1.0
4
+ Summary: Installer & Updater CLI für Katalon Production-Instanzen
5
+ Author: Karl Krägelin
6
+ Author-email: Karl Krägelin <karlkraegelin@gmail.com>
7
+ Requires-Dist: typer>=0.15
8
+ Requires-Dist: rich>=13.9
9
+ Requires-Dist: httpx>=0.28
10
+ Requires-Dist: pydantic>=2.9
11
+ Requires-Dist: jinja2>=3.1
12
+ Requires-Python: >=3.12
13
+ Description-Content-Type: text/markdown
14
+
15
+ # katalon-cli
16
+
17
+ Installer & Updater für [Katalon](https://github.com/karkraeg/Katalon) Production-Instanzen.
18
+ Verwaltet eine Instanz unter einem Zielverzeichnis (z.B. `/opt/katalon/`) — pullt gepinnte
19
+ Release-Images, generiert `compose.yaml`, macht Backups vor jedem Update, kann rollbacken.
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ curl -LsSf https://astral.sh/uv/install.sh | sh
25
+ uv tool install katalon-cli
26
+ ```
27
+
28
+ ## Nutzung
29
+
30
+ ```bash
31
+ katalon install # interaktiver Setup-Wizard (Rich-Prompts + Progress)
32
+ katalon start / stop / status
33
+ katalon update # interaktives Update, Backup zuerst immer
34
+ katalon rollback # letztes Backup einspielen
35
+ katalon doctor # Docker, Diskspace, Ports prüfen
36
+ katalon backup # manuelles Backup
37
+ katalon logs [service]
38
+ ```
39
+
40
+ Alle Befehle: `--dir PATH` (default `/opt/katalon`). `install`/`update` fragen interaktiv
41
+ (Auswahl über `rich.prompt`), `update`/`rollback` haben `--yes` zum Überspringen der Rückfrage.
42
+
43
+ ## Entwicklung
44
+
45
+ ```bash
46
+ uv sync
47
+ uv run katalon --help
48
+ uv run pytest
49
+ ```
50
+
51
+ ## Architektur
52
+
53
+ - `core/state.py` — `installation.json`, Single Source of Truth für laufende Version.
54
+ - `core/release.py` — GitHub-Release-Metadaten (`katalon-release.json` Asset), 1h Cache.
55
+ - `core/compose_gen.py` + `templates/compose.yaml.j2` — rendert `compose.yaml` aus Version + TLS-Modus.
56
+ - `core/backup.py` — `pg_dump` vor jedem Update; Rollback restauriert Dump statt `alembic downgrade`.
57
+ - `core/docker.py` — dünner `docker compose`-Subprocess-Wrapper.
58
+ - `main.py` — Typer-Commands, interaktive Teile über `rich.prompt`/`rich.progress`.
@@ -0,0 +1,44 @@
1
+ # katalon-cli
2
+
3
+ Installer & Updater für [Katalon](https://github.com/karkraeg/Katalon) Production-Instanzen.
4
+ Verwaltet eine Instanz unter einem Zielverzeichnis (z.B. `/opt/katalon/`) — pullt gepinnte
5
+ Release-Images, generiert `compose.yaml`, macht Backups vor jedem Update, kann rollbacken.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ curl -LsSf https://astral.sh/uv/install.sh | sh
11
+ uv tool install katalon-cli
12
+ ```
13
+
14
+ ## Nutzung
15
+
16
+ ```bash
17
+ katalon install # interaktiver Setup-Wizard (Rich-Prompts + Progress)
18
+ katalon start / stop / status
19
+ katalon update # interaktives Update, Backup zuerst immer
20
+ katalon rollback # letztes Backup einspielen
21
+ katalon doctor # Docker, Diskspace, Ports prüfen
22
+ katalon backup # manuelles Backup
23
+ katalon logs [service]
24
+ ```
25
+
26
+ Alle Befehle: `--dir PATH` (default `/opt/katalon`). `install`/`update` fragen interaktiv
27
+ (Auswahl über `rich.prompt`), `update`/`rollback` haben `--yes` zum Überspringen der Rückfrage.
28
+
29
+ ## Entwicklung
30
+
31
+ ```bash
32
+ uv sync
33
+ uv run katalon --help
34
+ uv run pytest
35
+ ```
36
+
37
+ ## Architektur
38
+
39
+ - `core/state.py` — `installation.json`, Single Source of Truth für laufende Version.
40
+ - `core/release.py` — GitHub-Release-Metadaten (`katalon-release.json` Asset), 1h Cache.
41
+ - `core/compose_gen.py` + `templates/compose.yaml.j2` — rendert `compose.yaml` aus Version + TLS-Modus.
42
+ - `core/backup.py` — `pg_dump` vor jedem Update; Rollback restauriert Dump statt `alembic downgrade`.
43
+ - `core/docker.py` — dünner `docker compose`-Subprocess-Wrapper.
44
+ - `main.py` — Typer-Commands, interaktive Teile über `rich.prompt`/`rich.progress`.
@@ -0,0 +1,27 @@
1
+ [project]
2
+ name = "katalon-cli"
3
+ version = "0.1.0"
4
+ description = "Installer & Updater CLI für Katalon Production-Instanzen"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "typer>=0.15",
9
+ "rich>=13.9",
10
+ "httpx>=0.28",
11
+ "pydantic>=2.9",
12
+ "jinja2>=3.1",
13
+ ]
14
+
15
+ [[project.authors]]
16
+ name = "Karl Krägelin"
17
+ email = "karlkraegelin@gmail.com"
18
+
19
+ [project.scripts]
20
+ katalon = "katalon_cli.main:main"
21
+
22
+ [build-system]
23
+ requires = ["uv_build>=0.12.3,<0.13.0"]
24
+ build-backend = "uv_build"
25
+
26
+ [dependency-groups]
27
+ dev = ["pytest>=8.3"]
@@ -0,0 +1,28 @@
1
+ [project]
2
+ name = "katalon-cli"
3
+ version = "0.1.0"
4
+ description = "Installer & Updater CLI für Katalon Production-Instanzen"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Karl Krägelin", email = "karlkraegelin@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "typer>=0.15",
12
+ "rich>=13.9",
13
+ "httpx>=0.28",
14
+ "pydantic>=2.9",
15
+ "jinja2>=3.1",
16
+ ]
17
+
18
+ [project.scripts]
19
+ katalon = "katalon_cli.main:main"
20
+
21
+ [build-system]
22
+ requires = ["uv_build>=0.12.3,<0.13.0"]
23
+ build-backend = "uv_build"
24
+
25
+ [dependency-groups]
26
+ dev = [
27
+ "pytest>=8.3",
28
+ ]
@@ -0,0 +1,2 @@
1
+ def hello() -> str:
2
+ return "Hello from katalon-cli!"
File without changes
@@ -0,0 +1,71 @@
1
+ """Backup vor jedem Update, Restore für Rollback. Kein Alembic-Downgrade — s. Plan."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+
10
+ from . import docker
11
+
12
+
13
+ class BackupError(RuntimeError):
14
+ pass
15
+
16
+
17
+ def create_backup(instance_dir: Path) -> Path:
18
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M")
19
+ backup_dir = instance_dir / "backups" / timestamp
20
+ backup_dir.mkdir(parents=True, exist_ok=True)
21
+
22
+ dump = docker.compose(
23
+ instance_dir,
24
+ "exec",
25
+ "-T",
26
+ "db",
27
+ "pg_dump",
28
+ "-U",
29
+ "katalon",
30
+ "katalon",
31
+ check=True,
32
+ capture=True,
33
+ )
34
+ (backup_dir / "pg_dump.sql").write_text(dump.stdout)
35
+
36
+ for name in (".env", "installation.json"):
37
+ src = instance_dir / name
38
+ if src.exists():
39
+ shutil.copy2(src, backup_dir / name)
40
+
41
+ return backup_dir
42
+
43
+
44
+ def restore_backup(instance_dir: Path, backup_dir: Path) -> None:
45
+ dump_file = backup_dir / "pg_dump.sql"
46
+ if not dump_file.exists():
47
+ raise BackupError(f"Kein pg_dump.sql in {backup_dir}")
48
+
49
+ docker.compose(instance_dir, "exec", "-T", "db", "dropdb", "-U", "katalon", "katalon")
50
+ docker.compose(instance_dir, "exec", "-T", "db", "createdb", "-U", "katalon", "katalon")
51
+ subprocess.run(
52
+ [*docker.compose_command(), "-f", str(instance_dir / "compose.yaml"),
53
+ "exec", "-T", "db", "psql", "-U", "katalon", "katalon"],
54
+ input=dump_file.read_text(),
55
+ cwd=instance_dir,
56
+ text=True,
57
+ check=True,
58
+ )
59
+
60
+ for name in (".env", "installation.json"):
61
+ src = backup_dir / name
62
+ if src.exists():
63
+ shutil.copy2(src, instance_dir / name)
64
+
65
+
66
+ def latest_backup(instance_dir: Path) -> Path | None:
67
+ backups_dir = instance_dir / "backups"
68
+ if not backups_dir.exists():
69
+ return None
70
+ candidates = sorted(backups_dir.iterdir(), reverse=True)
71
+ return candidates[0] if candidates else None
@@ -0,0 +1,61 @@
1
+ """Preflight-Checks für `katalon install` und `katalon doctor`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import socket
7
+ import subprocess
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ MIN_FREE_DISK_GB = 5
12
+
13
+
14
+ @dataclass
15
+ class CheckResult:
16
+ name: str
17
+ ok: bool
18
+ detail: str
19
+
20
+
21
+ def check_docker() -> CheckResult:
22
+ if not shutil.which("docker"):
23
+ return CheckResult("Docker", False, "nicht gefunden — https://docs.docker.com/get-docker/")
24
+ version = subprocess.run(["docker", "--version"], capture_output=True, text=True)
25
+ return CheckResult("Docker", True, version.stdout.strip())
26
+
27
+
28
+ def check_compose() -> CheckResult:
29
+ probe = subprocess.run(["docker", "compose", "version"], capture_output=True, text=True)
30
+ if probe.returncode == 0:
31
+ return CheckResult("Docker Compose", True, probe.stdout.strip())
32
+ if shutil.which("docker-compose"):
33
+ return CheckResult("Docker Compose", True, "docker-compose (standalone)")
34
+ return CheckResult("Docker Compose", False, "nicht gefunden")
35
+
36
+
37
+ def check_disk_space(path: Path, min_gb: int = MIN_FREE_DISK_GB) -> CheckResult:
38
+ path.mkdir(parents=True, exist_ok=True)
39
+ free_gb = shutil.disk_usage(path).free / (1024**3)
40
+ ok = free_gb >= min_gb
41
+ return CheckResult(
42
+ "Diskspace", ok, f"{free_gb:.1f} GB frei (min. {min_gb} GB) in {path}"
43
+ )
44
+
45
+ def check_ports(ports: list[int]) -> CheckResult:
46
+ busy = []
47
+ for port in ports:
48
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
49
+ s.settimeout(0.5)
50
+ if s.connect_ex(("127.0.0.1", port)) == 0:
51
+ busy.append(port)
52
+ if busy:
53
+ return CheckResult("Ports", False, f"belegt: {', '.join(map(str, busy))}")
54
+ return CheckResult("Ports", True, f"frei: {', '.join(map(str, ports))}")
55
+
56
+
57
+ def run_all(instance_dir: Path, ports: list[int] | None = None) -> list[CheckResult]:
58
+ results = [check_docker(), check_compose(), check_disk_space(instance_dir)]
59
+ if ports:
60
+ results.append(check_ports(ports))
61
+ return results
@@ -0,0 +1,37 @@
1
+ """Rendert compose.yaml aus Template + Release-Version + TLS-Modus."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from jinja2 import Environment, PackageLoader, select_autoescape
8
+
9
+ _env = Environment(
10
+ loader=PackageLoader("katalon_cli", "templates"),
11
+ autoescape=select_autoescape(disabled_extensions=(".yaml", ".j2")),
12
+ trim_blocks=True,
13
+ lstrip_blocks=True,
14
+ )
15
+
16
+
17
+ def render_compose(
18
+ *,
19
+ version: str,
20
+ base_url: str,
21
+ tls_mode: str,
22
+ registry: str = "ghcr.io/karkraeg/katalon",
23
+ ) -> str:
24
+ template = _env.get_template("compose.yaml.j2")
25
+ return template.render(
26
+ version=version,
27
+ base_url=base_url,
28
+ tls_mode=tls_mode,
29
+ registry=registry,
30
+ )
31
+
32
+
33
+ def write_compose(instance_dir: Path, **kwargs) -> Path:
34
+ content = render_compose(**kwargs)
35
+ path = instance_dir / "compose.yaml"
36
+ path.write_text(content)
37
+ return path
@@ -0,0 +1,44 @@
1
+ """Dünner Wrapper um `docker compose` — kein docker-py nötig, CLI reicht."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+
10
+ class DockerError(RuntimeError):
11
+ pass
12
+
13
+
14
+ def compose_command() -> list[str]:
15
+ if shutil.which("docker") is None:
16
+ raise DockerError("Docker ist nicht installiert oder nicht im PATH.")
17
+ probe = subprocess.run(
18
+ ["docker", "compose", "version"], capture_output=True, text=True
19
+ )
20
+ if probe.returncode == 0:
21
+ return ["docker", "compose"]
22
+ if shutil.which("docker-compose"):
23
+ return ["docker-compose"]
24
+ raise DockerError("Docker Compose ist nicht installiert.")
25
+
26
+
27
+ def compose(
28
+ instance_dir: Path, *args: str, check: bool = True, capture: bool = False
29
+ ) -> subprocess.CompletedProcess:
30
+ cmd = [*compose_command(), "-f", str(instance_dir / "compose.yaml"), *args]
31
+ return subprocess.run(
32
+ cmd,
33
+ cwd=instance_dir,
34
+ check=check,
35
+ text=True,
36
+ capture_output=capture,
37
+ )
38
+
39
+
40
+ def is_healthy(instance_dir: Path, service: str = "api") -> bool:
41
+ result = compose(
42
+ instance_dir, "ps", "--format", "json", service, check=False, capture=True
43
+ )
44
+ return result.returncode == 0 and '"Health":"healthy"' in (result.stdout or "")
@@ -0,0 +1,17 @@
1
+ """Plattformabhängiger Default-Pfad für Instanzverzeichnisse.
2
+
3
+ /opt ist Linux-Server-Konvention für selbstverwaltete Dienste und dort meist
4
+ nur mit sudo beschreibbar. macOS (lokales Testen/Entwickeln) hat keine
5
+ vergleichbare Konvention mit Schreibrechten ohne sudo — dort ins Home.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import platform
11
+ from pathlib import Path
12
+
13
+
14
+ def default_instance_dir() -> Path:
15
+ if platform.system() == "Darwin":
16
+ return Path.home() / "katalon"
17
+ return Path("/opt/katalon")
@@ -0,0 +1,68 @@
1
+ """GitHub-Release-Metadata für Katalon (nicht katalon-cli selbst)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from pathlib import Path
8
+
9
+ import httpx
10
+ from pydantic import BaseModel
11
+
12
+ GITHUB_REPO = "karkraeg/Katalon"
13
+ CACHE_TTL_SECONDS = 3600
14
+ CACHE_PATH = Path.home() / ".cache" / "katalon-cli" / "releases.json"
15
+
16
+
17
+ class ReleaseRequirements(BaseModel):
18
+ postgres: str | None = None
19
+ elasticsearch: str | None = None
20
+
21
+
22
+ class ReleaseMetadata(BaseModel):
23
+ version: str
24
+ minimum_installer_version: str
25
+ migration_required: bool
26
+ breaking: bool
27
+ compose_revision: int
28
+ requires: ReleaseRequirements = ReleaseRequirements()
29
+
30
+
31
+ def _fetch_release_asset(tag: str | None) -> dict:
32
+ """Lädt katalon-release.json vom GitHub-Release (tag=None → latest)."""
33
+ ref = "latest" if tag is None else f"tags/{tag}"
34
+ with httpx.Client(timeout=15) as client:
35
+ release = client.get(
36
+ f"https://api.github.com/repos/{GITHUB_REPO}/releases/{ref}"
37
+ )
38
+ release.raise_for_status()
39
+ data = release.json()
40
+
41
+ asset = next(
42
+ (a for a in data["assets"] if a["name"] == "katalon-release.json"), None
43
+ )
44
+ if asset is None:
45
+ raise ValueError(
46
+ f"Release {data.get('tag_name', tag)} hat kein katalon-release.json Asset."
47
+ )
48
+ with httpx.Client(timeout=15) as client:
49
+ asset_resp = client.get(asset["browser_download_url"])
50
+ asset_resp.raise_for_status()
51
+ return asset_resp.json()
52
+
53
+
54
+ def get_latest_release(*, use_cache: bool = True) -> ReleaseMetadata:
55
+ if use_cache and CACHE_PATH.exists():
56
+ age = time.time() - CACHE_PATH.stat().st_mtime
57
+ if age < CACHE_TTL_SECONDS:
58
+ return ReleaseMetadata.model_validate_json(CACHE_PATH.read_text())
59
+
60
+ payload = _fetch_release_asset(tag=None)
61
+ CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
62
+ CACHE_PATH.write_text(json.dumps(payload))
63
+ return ReleaseMetadata.model_validate(payload)
64
+
65
+
66
+ def get_release(version: str) -> ReleaseMetadata:
67
+ payload = _fetch_release_asset(tag=f"v{version.lstrip('v')}")
68
+ return ReleaseMetadata.model_validate(payload)
@@ -0,0 +1,67 @@
1
+ """installation.json — Single Source of Truth für die laufende Instanz."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+ STATE_FILENAME = "installation.json"
12
+
13
+
14
+ class HistoryEntry(BaseModel):
15
+ version: str
16
+ compose_revision: int
17
+ action: str # "install" | "update" | "rollback"
18
+ at: datetime
19
+
20
+
21
+ class InstallationState(BaseModel):
22
+ version: str
23
+ compose_revision: int
24
+ base_url: str
25
+ tls_mode: str # "standalone" | "behind-proxy" | "none"
26
+ installed_at: datetime
27
+ history: list[HistoryEntry] = Field(default_factory=list)
28
+
29
+ @classmethod
30
+ def load(cls, instance_dir: Path) -> "InstallationState":
31
+ path = instance_dir / STATE_FILENAME
32
+ if not path.exists():
33
+ raise FileNotFoundError(
34
+ f"{path} nicht gefunden — ist dies ein Katalon-Instanzverzeichnis?"
35
+ )
36
+ return cls.model_validate_json(path.read_text())
37
+
38
+ def save(self, instance_dir: Path) -> None:
39
+ path = instance_dir / STATE_FILENAME
40
+ path.write_text(self.model_dump_json(indent=2))
41
+
42
+ def record(self, version: str, compose_revision: int, action: str) -> None:
43
+ self.history.append(
44
+ HistoryEntry(
45
+ version=version,
46
+ compose_revision=compose_revision,
47
+ action=action,
48
+ at=datetime.now(timezone.utc),
49
+ )
50
+ )
51
+ self.version = version
52
+ self.compose_revision = compose_revision
53
+
54
+ def previous(self) -> HistoryEntry | None:
55
+ """Letzter History-Eintrag vor dem aktuellen Stand, für Rollback."""
56
+ if len(self.history) < 2:
57
+ return None
58
+ return self.history[-2]
59
+
60
+
61
+ def instance_dir_or_raise(path: Path) -> Path:
62
+ if not (path / STATE_FILENAME).exists():
63
+ raise FileNotFoundError(
64
+ f"Kein Katalon in {path} gefunden (installation.json fehlt). "
65
+ "Erst `katalon install` ausführen."
66
+ )
67
+ return path
@@ -0,0 +1,300 @@
1
+ """katalon — Installer & Updater CLI für Production-Instanzen."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import platform
6
+ import secrets
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from urllib.parse import urlparse
10
+
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.progress import Progress, SpinnerColumn, TextColumn
14
+ from rich.prompt import Confirm, Prompt
15
+ from rich.table import Table
16
+
17
+ from .core import backup as backup_mod
18
+ from .core import checks, docker, release
19
+ from .core.compose_gen import write_compose
20
+ from .core.paths import default_instance_dir
21
+ from .core.state import InstallationState, instance_dir_or_raise
22
+
23
+ app = typer.Typer(add_completion=False, help="Installer & Updater für Katalon.")
24
+ console = Console()
25
+
26
+ DEFAULT_DIR = default_instance_dir()
27
+
28
+ TLS_CHOICES = {
29
+ "standalone": "Standalone — Katalon verwaltet HTTPS via Caddy (Ports 80+443)",
30
+ "behind-proxy": "Hinter eigenem Reverse-Proxy — kein TLS hier",
31
+ "none": "Kein TLS (lokal / IP)",
32
+ }
33
+
34
+
35
+ def _run_checks(dir: Path, ports: list[int] | None = None) -> bool:
36
+ ok = True
37
+ for result in checks.run_all(dir, ports=ports):
38
+ icon, style = ("✔", "green") if result.ok else ("✖", "red")
39
+ console.print(f"[{style}]{icon}[/] {result.name}: {result.detail}")
40
+ ok = ok and result.ok
41
+ return ok
42
+
43
+
44
+ @app.command()
45
+ def install(
46
+ dir: Path = typer.Option(
47
+ None, "--dir", help=f"Zielverzeichnis der Instanz (Default für dieses OS: {DEFAULT_DIR})"
48
+ ),
49
+ ):
50
+ """Interaktiver Setup-Wizard für eine neue Instanz."""
51
+ console.rule("[bold]Katalon Setup[/]")
52
+
53
+ if dir is None:
54
+ console.print(f"Zielverzeichnis — Standard für {platform.system()}: [cyan]{DEFAULT_DIR}[/]")
55
+ dir = Path(Prompt.ask("Zielverzeichnis", default=str(DEFAULT_DIR)))
56
+
57
+ if (dir / "installation.json").exists():
58
+ console.print(f"[red]✖[/] {dir} ist bereits eine Katalon-Instanz.")
59
+ raise typer.Exit(1)
60
+
61
+ console.print()
62
+ console.print(
63
+ "KATALON_BASE_URL — [cyan]http://localhost[/] zum lokalen Testen (kein TLS), "
64
+ "sonst echte Domain (dann folgt TLS-Auswahl)."
65
+ )
66
+ base_url = Prompt.ask("KATALON_BASE_URL", default="http://localhost")
67
+
68
+ tls_mode = "none"
69
+ if urlparse(base_url).hostname not in (None, "localhost", "127.0.0.1"):
70
+ console.print()
71
+ for key, label in TLS_CHOICES.items():
72
+ console.print(f" [cyan]{key}[/] — {label}")
73
+ tls_mode = Prompt.ask("TLS-Modus", choices=list(TLS_CHOICES), default="standalone")
74
+
75
+ console.print()
76
+ console.rule("Preflight-Checks")
77
+ ports = [80, 443] if tls_mode == "standalone" else None
78
+ if not _run_checks(dir, ports=ports):
79
+ console.print("[red]Abgebrochen — Check fehlgeschlagen.[/]")
80
+ raise typer.Exit(1)
81
+
82
+ console.print()
83
+ with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), console=console) as progress:
84
+ task = progress.add_task("Lade Release-Metadaten …", total=None)
85
+ try:
86
+ meta = release.get_latest_release()
87
+ except Exception as exc: # noqa: BLE001
88
+ progress.stop()
89
+ console.print(f"[red]✖ Release-Metadaten konnten nicht geladen werden: {exc}[/]")
90
+ raise typer.Exit(1) from exc
91
+ progress.update(task, description=f"Release {meta.version} gefunden")
92
+
93
+ progress.add_task("Schreibe compose.yaml + .env …", total=None)
94
+ dir.mkdir(parents=True, exist_ok=True)
95
+ write_compose(dir, version=meta.version, base_url=base_url, tls_mode=tls_mode)
96
+
97
+ env_path = dir / ".env"
98
+ if not env_path.exists():
99
+ env_path.write_text(
100
+ f"KATALON_BASE_URL={base_url}\n"
101
+ f"POSTGRES_PASSWORD={secrets.token_urlsafe(24)}\n"
102
+ f"SECRET_KEY={secrets.token_urlsafe(32)}\n"
103
+ )
104
+
105
+ state = InstallationState(
106
+ version=meta.version,
107
+ compose_revision=meta.compose_revision,
108
+ base_url=base_url,
109
+ tls_mode=tls_mode,
110
+ installed_at=datetime.now(timezone.utc),
111
+ )
112
+ state.record(meta.version, meta.compose_revision, "install")
113
+ state.save(dir)
114
+
115
+ console.print(f"[green]✔[/] Instanz eingerichtet in [bold]{dir}[/]")
116
+ console.print("Starten mit: [bold]katalon start --dir " + str(dir) + "[/]")
117
+
118
+
119
+ @app.command()
120
+ def start(dir: Path = typer.Option(DEFAULT_DIR, "--dir")):
121
+ """Stack starten."""
122
+ instance_dir_or_raise(dir)
123
+ docker.compose(dir, "up", "-d")
124
+ console.print("[green]✔[/] Stack gestartet.")
125
+
126
+
127
+ @app.command()
128
+ def stop(dir: Path = typer.Option(DEFAULT_DIR, "--dir")):
129
+ """Stack stoppen."""
130
+ instance_dir_or_raise(dir)
131
+ docker.compose(dir, "stop")
132
+ console.print("[green]✔[/] Stack gestoppt.")
133
+
134
+
135
+ @app.command()
136
+ def restart(dir: Path = typer.Option(DEFAULT_DIR, "--dir")):
137
+ """Stack neu starten."""
138
+ instance_dir_or_raise(dir)
139
+ docker.compose(dir, "restart")
140
+ console.print("[green]✔[/] Stack neu gestartet.")
141
+
142
+
143
+ @app.command()
144
+ def status(dir: Path = typer.Option(DEFAULT_DIR, "--dir")):
145
+ """Versionen, Container-Health, Diskspace."""
146
+ instance_dir_or_raise(dir)
147
+ state = InstallationState.load(dir)
148
+
149
+ table = Table(show_header=False)
150
+ table.add_row("Version", state.version)
151
+ table.add_row("Compose-Revision", str(state.compose_revision))
152
+ table.add_row("Base URL", state.base_url)
153
+ table.add_row("TLS-Modus", state.tls_mode)
154
+ table.add_row("Installiert", str(state.installed_at))
155
+ console.print(table)
156
+
157
+ docker.compose(dir, "ps")
158
+
159
+
160
+ @app.command()
161
+ def logs(
162
+ service: str = typer.Argument(None, help="Service-Name, leer = alle"),
163
+ dir: Path = typer.Option(DEFAULT_DIR, "--dir"),
164
+ follow: bool = typer.Option(True, "--follow/--no-follow"),
165
+ ):
166
+ """Wrapper um docker compose logs."""
167
+ instance_dir_or_raise(dir)
168
+ args = ["logs"]
169
+ if follow:
170
+ args.append("-f")
171
+ if service:
172
+ args.append(service)
173
+ docker.compose(dir, *args)
174
+
175
+
176
+ @app.command()
177
+ def doctor(dir: Path = typer.Option(DEFAULT_DIR, "--dir")):
178
+ """Diagnose: Docker, Diskspace, Ports, DB-Connectivity."""
179
+ _run_checks(dir, ports=[80, 443])
180
+
181
+
182
+ @app.command()
183
+ def backup(dir: Path = typer.Option(DEFAULT_DIR, "--dir")):
184
+ """Manuelles Backup (Postgres-Dump + .env + installation.json)."""
185
+ instance_dir_or_raise(dir)
186
+ path = backup_mod.create_backup(dir)
187
+ console.print(f"[green]✔[/] Backup erstellt: {path}")
188
+
189
+
190
+ @app.command()
191
+ def update(
192
+ dir: Path = typer.Option(DEFAULT_DIR, "--dir"),
193
+ target: str = typer.Option(None, "--target", help="Zielversion, default = latest"),
194
+ yes: bool = typer.Option(False, "--yes", help="Ohne Rückfrage"),
195
+ ):
196
+ """Update auf neue Version — Backup zuerst, immer."""
197
+ instance_dir_or_raise(dir)
198
+ state = InstallationState.load(dir)
199
+ meta = release.get_release(target) if target else release.get_latest_release()
200
+
201
+ if meta.version == state.version:
202
+ console.print(f"[green]✔[/] Bereits auf aktueller Version {meta.version}.")
203
+ return
204
+
205
+ console.rule("Update")
206
+ console.print(f"Aktuell: [bold]{state.version}[/] → Ziel: [bold]{meta.version}[/]")
207
+ if meta.breaking:
208
+ console.print("[red]⚠ Breaking Change in diesem Release.[/]")
209
+ if meta.migration_required:
210
+ console.print("[yellow]⚠ DB-Migration erforderlich.[/]")
211
+ if meta.compose_revision != state.compose_revision:
212
+ console.print(
213
+ f"[yellow]⚠ compose.yaml wird neu generiert "
214
+ f"(Revision {state.compose_revision} → {meta.compose_revision})[/]"
215
+ )
216
+
217
+ if not yes and not Confirm.ask("Update durchführen?"):
218
+ raise typer.Exit(0)
219
+
220
+ steps = [
221
+ ("Backup erstellen", lambda: backup_mod.create_backup(dir)),
222
+ (
223
+ "compose.yaml rendern",
224
+ lambda: write_compose(dir, version=meta.version, base_url=state.base_url, tls_mode=state.tls_mode),
225
+ ),
226
+ ("Images pullen", lambda: docker.compose(dir, "pull")),
227
+ ("Container neu starten", lambda: docker.compose(dir, "up", "-d")),
228
+ ]
229
+ if meta.migration_required:
230
+ steps.append(
231
+ ("Migration ausführen", lambda: docker.compose(dir, "exec", "api", "alembic", "upgrade", "head"))
232
+ )
233
+
234
+ with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), console=console) as progress:
235
+ for description, action in steps:
236
+ task = progress.add_task(description, total=None)
237
+ action()
238
+ progress.update(task, description=f"[green]✔[/] {description}")
239
+
240
+ task = progress.add_task("Warte auf Healthcheck …", total=None)
241
+ healthy = False
242
+ import time
243
+
244
+ for _ in range(30):
245
+ if docker.is_healthy(dir):
246
+ healthy = True
247
+ break
248
+ time.sleep(2)
249
+ progress.update(
250
+ task,
251
+ description="[green]✔ Healthcheck OK[/]" if healthy else "[red]✖ Healthcheck fehlgeschlagen[/]",
252
+ )
253
+
254
+ if not healthy:
255
+ console.print("[red]✖ Healthcheck fehlgeschlagen — `katalon rollback` erwägen.[/]")
256
+ raise typer.Exit(1)
257
+
258
+ state.record(meta.version, meta.compose_revision, "update")
259
+ state.save(dir)
260
+ console.print(f"[green]✔[/] Update auf {meta.version} abgeschlossen.")
261
+
262
+
263
+ @app.command()
264
+ def rollback(
265
+ dir: Path = typer.Option(DEFAULT_DIR, "--dir"),
266
+ yes: bool = typer.Option(False, "--yes"),
267
+ ):
268
+ """Letztes Backup einspielen (kein Alembic-Downgrade)."""
269
+ instance_dir_or_raise(dir)
270
+ backup_dir = backup_mod.latest_backup(dir)
271
+ if backup_dir is None:
272
+ console.print("[red]✖[/] Kein Backup vorhanden.")
273
+ raise typer.Exit(1)
274
+
275
+ console.print(f"[yellow]⚠[/] Rollback auf Backup {backup_dir.name} — Daten seit diesem Backup gehen verloren.")
276
+ if not yes and not Confirm.ask("Fortfahren?"):
277
+ raise typer.Exit(0)
278
+
279
+ state = InstallationState.load(dir)
280
+ previous = state.previous()
281
+
282
+ backup_mod.restore_backup(dir, backup_dir)
283
+ if previous:
284
+ write_compose(dir, version=previous.version, base_url=state.base_url, tls_mode=state.tls_mode)
285
+ docker.compose(dir, "pull")
286
+
287
+ docker.compose(dir, "up", "-d")
288
+ console.print("[green]✔[/] Rollback abgeschlossen.")
289
+
290
+
291
+ def main() -> None:
292
+ try:
293
+ app()
294
+ except (FileNotFoundError, docker.DockerError, backup_mod.BackupError) as exc:
295
+ console.print(f"[red]✖[/] {exc}")
296
+ raise SystemExit(1) from exc
297
+
298
+
299
+ if __name__ == "__main__":
300
+ main()
File without changes
@@ -0,0 +1,11 @@
1
+ {{ base_url_host }} {
2
+ handle_path /api/* {
3
+ reverse_proxy api:8000
4
+ }
5
+ handle_path /admin/* {
6
+ reverse_proxy admin:80
7
+ }
8
+ handle {
9
+ reverse_proxy portal:80
10
+ }
11
+ }
@@ -0,0 +1,99 @@
1
+
2
+ services:
3
+ db:
4
+ image: postgis/postgis:16-3.4
5
+ restart: unless-stopped
6
+ environment:
7
+ POSTGRES_USER: katalon
8
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
9
+ POSTGRES_DB: katalon
10
+ volumes:
11
+ - db_data:/var/lib/postgresql/data
12
+ healthcheck:
13
+ test: ["CMD-SHELL", "pg_isready -U katalon"]
14
+ interval: 5s
15
+ timeout: 5s
16
+ retries: 10
17
+
18
+ redis:
19
+ image: redis:7-alpine
20
+ restart: unless-stopped
21
+
22
+ elasticsearch:
23
+ image: docker.elastic.co/elasticsearch/elasticsearch:8.15.0
24
+ restart: unless-stopped
25
+ environment:
26
+ discovery.type: single-node
27
+ xpack.security.enabled: "false"
28
+ ES_JAVA_OPTS: "-Xms512m -Xmx512m"
29
+ volumes:
30
+ - es_data:/usr/share/elasticsearch/data
31
+
32
+ cantaloupe:
33
+ image: {{ registry }}-cantaloupe:{{ version }}
34
+ restart: unless-stopped
35
+ volumes:
36
+ - ./media:/media:ro
37
+
38
+ api:
39
+ image: {{ registry }}-api:{{ version }}
40
+ restart: unless-stopped
41
+ env_file: .env
42
+ depends_on:
43
+ db: { condition: service_healthy }
44
+ volumes:
45
+ - ./media:/var/lib/katalon/media
46
+ healthcheck:
47
+ test: ["CMD", "curl", "-sf", "http://localhost:8000/health"]
48
+ interval: 10s
49
+ timeout: 5s
50
+ retries: 10
51
+
52
+ admin:
53
+ image: {{ registry }}-web:{{ version }}
54
+ restart: unless-stopped
55
+ environment:
56
+ KATALON_WEB_MODE: admin
57
+
58
+ portal:
59
+ image: {{ registry }}-web:{{ version }}
60
+ restart: unless-stopped
61
+ environment:
62
+ KATALON_WEB_MODE: portal
63
+
64
+ {% if tls_mode == "standalone" %}
65
+ proxy:
66
+ image: caddy:2-alpine
67
+ restart: unless-stopped
68
+ ports:
69
+ - "80:80"
70
+ - "443:443"
71
+ environment:
72
+ KATALON_BASE_URL: {{ base_url }}
73
+ volumes:
74
+ - ./Caddyfile:/etc/caddy/Caddyfile:ro
75
+ - caddy_data:/data
76
+ depends_on:
77
+ - api
78
+ - admin
79
+ - portal
80
+ {% elif tls_mode == "behind-proxy" %}
81
+ proxy:
82
+ image: nginx:alpine
83
+ restart: unless-stopped
84
+ ports:
85
+ - "127.0.0.1:8080:80"
86
+ volumes:
87
+ - ./nginx.conf:/etc/nginx/nginx.conf:ro
88
+ depends_on:
89
+ - api
90
+ - admin
91
+ - portal
92
+ {% endif %}
93
+
94
+ volumes:
95
+ db_data:
96
+ es_data:
97
+ {% if tls_mode == "standalone" %}
98
+ caddy_data:
99
+ {% endif %}