katalon-cli 0.1.2__tar.gz → 0.1.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: katalon-cli
3
- Version: 0.1.2
3
+ Version: 0.1.4
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,7 +66,13 @@ uv run pytest
66
66
 
67
67
  - `core/state.py` — `installation.json`, Single Source of Truth für laufende Version.
68
68
  - `core/release.py` — GitHub-Release-Metadaten (`katalon-release.json` Asset), 1h Cache.
69
- - `core/compose_gen.py` + `templates/compose.yaml.j2` — rendert `compose.yaml` aus Version + TLS-Modus.
69
+ - `core/compose_gen.py` + `templates/compose.yaml.j2` + `templates/nginx.conf.j2` — rendert `compose.yaml`
70
+ + `nginx.conf` aus Version + TLS-Modus; erzeugt bei `tls_mode=standalone` ein selbstsigniertes
71
+ Zertifikat unter `<dir>/certs/` (eigenes Zertifikat dort ablegen, um es zu ersetzen).
72
+ `install` fragt zusätzlich `MEDIA_ROOT` ab und legt `<dir>/compose.override.yaml.example` an
73
+ (instanzspezifische Overrides — umbenennen zu `compose.override.yaml`, wird automatisch eingebunden
74
+ und bei Updates nicht angefasst). Optionale Env-Vars (Admin-Login, SMTP, Geonames, …) landet
75
+ auskommentiert in `.env`.
70
76
  - `core/backup.py` — `pg_dump` vor jedem Update; Rollback restauriert Dump statt `alembic downgrade`.
71
77
  - `core/docker.py` — dünner `docker compose`-Subprocess-Wrapper.
72
78
  - `main.py` — Typer-Commands, interaktive Teile über `rich.prompt`/`rich.progress`.
@@ -52,7 +52,13 @@ uv run pytest
52
52
 
53
53
  - `core/state.py` — `installation.json`, Single Source of Truth für laufende Version.
54
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.
55
+ - `core/compose_gen.py` + `templates/compose.yaml.j2` + `templates/nginx.conf.j2` — rendert `compose.yaml`
56
+ + `nginx.conf` aus Version + TLS-Modus; erzeugt bei `tls_mode=standalone` ein selbstsigniertes
57
+ Zertifikat unter `<dir>/certs/` (eigenes Zertifikat dort ablegen, um es zu ersetzen).
58
+ `install` fragt zusätzlich `MEDIA_ROOT` ab und legt `<dir>/compose.override.yaml.example` an
59
+ (instanzspezifische Overrides — umbenennen zu `compose.override.yaml`, wird automatisch eingebunden
60
+ und bei Updates nicht angefasst). Optionale Env-Vars (Admin-Login, SMTP, Geonames, …) landet
61
+ auskommentiert in `.env`.
56
62
  - `core/backup.py` — `pg_dump` vor jedem Update; Rollback restauriert Dump statt `alembic downgrade`.
57
63
  - `core/docker.py` — dünner `docker compose`-Subprocess-Wrapper.
58
64
  - `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.1.2"
3
+ version = "0.1.4"
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.1.2"
3
+ version = "0.1.4"
4
4
  description = "Installer & Updater CLI für Katalon Production-Instanzen"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -49,7 +49,7 @@ def restore_backup(instance_dir: Path, backup_dir: Path) -> None:
49
49
  docker.compose(instance_dir, "exec", "-T", "db", "dropdb", "-U", "katalon", "katalon")
50
50
  docker.compose(instance_dir, "exec", "-T", "db", "createdb", "-U", "katalon", "katalon")
51
51
  subprocess.run(
52
- [*docker.compose_command(), "-f", str(instance_dir / "compose.yaml"),
52
+ [*docker.compose_command(), *docker.compose_files(instance_dir),
53
53
  "exec", "-T", "db", "psql", "-U", "katalon", "katalon"],
54
54
  input=dump_file.read_text(),
55
55
  cwd=instance_dir,
@@ -42,20 +42,31 @@ def check_disk_space(path: Path, min_gb: int = MIN_FREE_DISK_GB) -> CheckResult:
42
42
  "Diskspace", ok, f"{free_gb:.1f} GB frei (min. {min_gb} GB) in {path}"
43
43
  )
44
44
 
45
+ def check_openssl() -> CheckResult:
46
+ if not shutil.which("openssl"):
47
+ return CheckResult("OpenSSL", False, "nicht gefunden — für TLS-Modus 'standalone' erforderlich")
48
+ return CheckResult("OpenSSL", True, "gefunden")
49
+
50
+
51
+ def is_port_in_use(port: int) -> bool:
52
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
53
+ s.settimeout(0.5)
54
+ return s.connect_ex(("127.0.0.1", port)) == 0
55
+
56
+
45
57
  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)
58
+ busy = [port for port in ports if is_port_in_use(port)]
52
59
  if busy:
53
60
  return CheckResult("Ports", False, f"belegt: {', '.join(map(str, busy))}")
54
61
  return CheckResult("Ports", True, f"frei: {', '.join(map(str, ports))}")
55
62
 
56
63
 
57
- def run_all(instance_dir: Path, ports: list[int] | None = None) -> list[CheckResult]:
64
+ def run_all(
65
+ instance_dir: Path, ports: list[int] | None = None, need_openssl: bool = False
66
+ ) -> list[CheckResult]:
58
67
  results = [check_docker(), check_compose(), check_disk_space(instance_dir)]
59
68
  if ports:
60
69
  results.append(check_ports(ports))
70
+ if need_openssl:
71
+ results.append(check_openssl())
61
72
  return results
@@ -0,0 +1,85 @@
1
+ """Rendert compose.yaml + nginx.conf aus Template + Release-Version + TLS-Modus."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ from jinja2 import Environment, PackageLoader, select_autoescape
9
+
10
+ _env = Environment(
11
+ loader=PackageLoader("katalon_cli", "templates"),
12
+ autoescape=select_autoescape(disabled_extensions=(".yaml", ".j2", ".conf")),
13
+ trim_blocks=True,
14
+ lstrip_blocks=True,
15
+ )
16
+
17
+
18
+ def render_compose(
19
+ *,
20
+ version: str,
21
+ base_url: str,
22
+ tls_mode: str,
23
+ registry: str = "ghcr.io/katalon-collections/katalon",
24
+ ) -> str:
25
+ from urllib.parse import urlparse
26
+
27
+ parsed_port = urlparse(base_url).port
28
+ template = _env.get_template("compose.yaml.j2")
29
+ return template.render(
30
+ version=version,
31
+ base_url=base_url,
32
+ tls_mode=tls_mode,
33
+ registry=registry,
34
+ port=parsed_port,
35
+ )
36
+
37
+ def render_nginx_conf(*, tls_mode: str) -> str:
38
+ template = _env.get_template("nginx.conf.j2")
39
+ return template.render(tls_mode=tls_mode)
40
+
41
+
42
+ def write_compose(instance_dir: Path, **kwargs) -> Path:
43
+ content = render_compose(**kwargs)
44
+ path = instance_dir / "compose.yaml"
45
+ path.write_text(content)
46
+
47
+ nginx_path = instance_dir / "nginx.conf"
48
+ nginx_path.write_text(render_nginx_conf(tls_mode=kwargs["tls_mode"]))
49
+
50
+ if kwargs["tls_mode"] == "standalone":
51
+ ensure_self_signed_cert(instance_dir, host=_host_of(kwargs["base_url"]))
52
+
53
+ return path
54
+
55
+
56
+ def ensure_self_signed_cert(instance_dir: Path, *, host: str) -> None:
57
+ """Legt ein selbstsigniertes Zertifikat an, falls keins vorhanden ist.
58
+
59
+ Für echtes TLS eigenes Zertifikat (z.B. Let's Encrypt) unter
60
+ <instance_dir>/certs/fullchain.pem + privkey.pem ablegen — wird nicht überschrieben.
61
+ """
62
+ certs_dir = instance_dir / "certs"
63
+ fullchain = certs_dir / "fullchain.pem"
64
+ privkey = certs_dir / "privkey.pem"
65
+ if fullchain.exists() and privkey.exists():
66
+ return
67
+
68
+ certs_dir.mkdir(parents=True, exist_ok=True)
69
+ subprocess.run(
70
+ [
71
+ "openssl", "req", "-x509", "-nodes", "-newkey", "rsa:2048",
72
+ "-days", "825",
73
+ "-keyout", str(privkey),
74
+ "-out", str(fullchain),
75
+ "-subj", f"/CN={host}",
76
+ ],
77
+ check=True,
78
+ capture_output=True,
79
+ )
80
+
81
+
82
+ def _host_of(base_url: str) -> str:
83
+ from urllib.parse import urlparse
84
+
85
+ return urlparse(base_url).hostname or "localhost"
@@ -4,9 +4,9 @@ from __future__ import annotations
4
4
 
5
5
  import shutil
6
6
  import subprocess
7
+ import time
7
8
  from pathlib import Path
8
9
 
9
-
10
10
  class DockerError(RuntimeError):
11
11
  pass
12
12
 
@@ -24,10 +24,18 @@ def compose_command() -> list[str]:
24
24
  raise DockerError("Docker Compose ist nicht installiert.")
25
25
 
26
26
 
27
+ def compose_files(instance_dir: Path) -> list[str]:
28
+ files = ["-f", str(instance_dir / "compose.yaml")]
29
+ override = instance_dir / "compose.override.yaml"
30
+ if override.exists():
31
+ files += ["-f", str(override)]
32
+ return files
33
+
34
+
27
35
  def compose(
28
36
  instance_dir: Path, *args: str, check: bool = True, capture: bool = False
29
37
  ) -> subprocess.CompletedProcess:
30
- cmd = [*compose_command(), "-f", str(instance_dir / "compose.yaml"), *args]
38
+ cmd = [*compose_command(), *compose_files(instance_dir), *args]
31
39
  return subprocess.run(
32
40
  cmd,
33
41
  cwd=instance_dir,
@@ -42,3 +50,27 @@ def is_healthy(instance_dir: Path, service: str = "api") -> bool:
42
50
  instance_dir, "ps", "--format", "json", service, check=False, capture=True
43
51
  )
44
52
  return result.returncode == 0 and '"Health":"healthy"' in (result.stdout or "")
53
+
54
+
55
+ def is_running(instance_dir: Path, service: str = "db") -> bool:
56
+ result = compose(
57
+ instance_dir, "ps", "--status", "running", "-q", service, check=False, capture=True
58
+ )
59
+ return result.returncode == 0 and bool(result.stdout.strip())
60
+
61
+
62
+ def wait_for_db(instance_dir: Path, timeout: int = 30) -> bool:
63
+ for _ in range(timeout):
64
+ result = compose(
65
+ instance_dir,
66
+ "exec",
67
+ "-T",
68
+ "db",
69
+ "pg_isready",
70
+ check=False,
71
+ capture=True,
72
+ )
73
+ if result.returncode == 0:
74
+ return True
75
+ time.sleep(1)
76
+ return False