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.
- katalon_cli-0.1.0/PKG-INFO +58 -0
- katalon_cli-0.1.0/README.md +44 -0
- katalon_cli-0.1.0/pyproject.toml +27 -0
- katalon_cli-0.1.0/pyproject.toml.orig +28 -0
- katalon_cli-0.1.0/src/katalon_cli/__init__.py +2 -0
- katalon_cli-0.1.0/src/katalon_cli/core/__init__.py +0 -0
- katalon_cli-0.1.0/src/katalon_cli/core/backup.py +71 -0
- katalon_cli-0.1.0/src/katalon_cli/core/checks.py +61 -0
- katalon_cli-0.1.0/src/katalon_cli/core/compose_gen.py +37 -0
- katalon_cli-0.1.0/src/katalon_cli/core/docker.py +44 -0
- katalon_cli-0.1.0/src/katalon_cli/core/paths.py +17 -0
- katalon_cli-0.1.0/src/katalon_cli/core/release.py +68 -0
- katalon_cli-0.1.0/src/katalon_cli/core/state.py +67 -0
- katalon_cli-0.1.0/src/katalon_cli/main.py +300 -0
- katalon_cli-0.1.0/src/katalon_cli/py.typed +0 -0
- katalon_cli-0.1.0/src/katalon_cli/templates/Caddyfile.j2 +11 -0
- katalon_cli-0.1.0/src/katalon_cli/templates/compose.yaml.j2 +99 -0
|
@@ -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
|
+
]
|
|
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,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 %}
|