katalon-cli 0.1.19__tar.gz → 0.2.1__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 (24) hide show
  1. katalon_cli-0.2.1/PKG-INFO +146 -0
  2. katalon_cli-0.2.1/README.md +132 -0
  3. {katalon_cli-0.1.19 → katalon_cli-0.2.1}/pyproject.toml +1 -1
  4. {katalon_cli-0.1.19 → katalon_cli-0.2.1}/pyproject.toml.orig +1 -1
  5. katalon_cli-0.2.1/src/katalon_cli/core/backup.py +132 -0
  6. {katalon_cli-0.1.19 → katalon_cli-0.2.1}/src/katalon_cli/core/compose_gen.py +31 -4
  7. katalon_cli-0.2.1/src/katalon_cli/core/permissions.py +113 -0
  8. katalon_cli-0.2.1/src/katalon_cli/core/reindex.py +171 -0
  9. {katalon_cli-0.1.19 → katalon_cli-0.2.1}/src/katalon_cli/core/state.py +1 -1
  10. {katalon_cli-0.1.19 → katalon_cli-0.2.1}/src/katalon_cli/main.py +217 -11
  11. katalon_cli-0.2.1/src/katalon_cli/templates/Caddyfile.j2 +3 -0
  12. {katalon_cli-0.1.19 → katalon_cli-0.2.1}/src/katalon_cli/templates/compose.yaml.j2 +21 -0
  13. {katalon_cli-0.1.19 → katalon_cli-0.2.1}/src/katalon_cli/templates/nginx.conf.j2 +6 -6
  14. katalon_cli-0.1.19/PKG-INFO +0 -86
  15. katalon_cli-0.1.19/README.md +0 -72
  16. katalon_cli-0.1.19/src/katalon_cli/core/backup.py +0 -71
  17. {katalon_cli-0.1.19 → katalon_cli-0.2.1}/src/katalon_cli/__init__.py +0 -0
  18. {katalon_cli-0.1.19 → katalon_cli-0.2.1}/src/katalon_cli/core/__init__.py +0 -0
  19. {katalon_cli-0.1.19 → katalon_cli-0.2.1}/src/katalon_cli/core/checks.py +0 -0
  20. {katalon_cli-0.1.19 → katalon_cli-0.2.1}/src/katalon_cli/core/docker.py +0 -0
  21. {katalon_cli-0.1.19 → katalon_cli-0.2.1}/src/katalon_cli/core/paths.py +0 -0
  22. {katalon_cli-0.1.19 → katalon_cli-0.2.1}/src/katalon_cli/core/release.py +0 -0
  23. {katalon_cli-0.1.19 → katalon_cli-0.2.1}/src/katalon_cli/core/selfupdate.py +0 -0
  24. {katalon_cli-0.1.19 → katalon_cli-0.2.1}/src/katalon_cli/py.typed +0 -0
@@ -0,0 +1,146 @@
1
+ Metadata-Version: 2.3
2
+ Name: katalon-cli
3
+ Version: 0.2.1
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 Collections](https://github.com/katalon-collections/katalon) Production-Instanzen.
18
+ Verwaltet eine Instanz unter einem frei gewählten Zielverzeichnis — 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 check-updates # prüft nur, ob ein neues Release verfügbar ist
34
+ katalon update # interaktives Update, Backup zuerst immer
35
+ katalon rollback # letztes Backup einspielen
36
+ katalon doctor # Docker, Diskspace, Ports, Schreibrechte von MEDIA_ROOT prüfen
37
+ katalon backup # manuelles Backup
38
+ katalon logs [service]
39
+ katalon manage <args> # katalon-manage im api-Container (z.B. reset-admin, create-user)
40
+ katalon restore DUMP # DB durch pg_dump ersetzen (-Fc oder SQL), danach Reindex
41
+ katalon reindex # Suchindex neu aufbauen, mit Fortschrittsanzeige
42
+ ```
43
+
44
+ Ein DB-Dump enthält nicht den Suchindex (Elasticsearch). `restore` baut ihn deshalb nach dem
45
+ Einspielen neu auf (`--no-reindex` zum Überspringen, später `katalon reindex`). Bei großen
46
+ Beständen dauert das lange (ca. 45 Min. für 80k Datensätze). Außerdem prüft `katalon` einmal
47
+ täglich auf neue Versionen (abschalten: `KATALON_NO_UPDATE_CHECK=1`).
48
+
49
+ `install` ohne `--dir` fragt das Zielverzeichnis interaktiv ab. Nach erfolgreicher Einrichtung
50
+ wird der absolute Pfad benutzerweit in `~/.config/katalon/instance` gespeichert.
51
+ Bei gesetztem, absolutem `XDG_CONFIG_HOME` liegt die Datei stattdessen unter
52
+ `$XDG_CONFIG_HOME/katalon/instance`. Das Konfigurationsverzeichnis wird bei Bedarf angelegt.
53
+ Alle weiteren Befehle verwenden den gespeicherten Pfad, unabhängig vom Arbeitsverzeichnis.
54
+ Ohne gespeicherten Pfad ist der Default auf allen Plattformen `~/katalon`.
55
+
56
+ Alle Befehle unterstützen `--dir PATH`: überschreibt den Pfad nur für diesen Aufruf.
57
+ Eine erfolgreiche neue Installation speichert ihr Zielverzeichnis als neuen Default.
58
+ Bestehende Instanzen lassen sich weiterhin mit `--dir` verwenden; alternativ ihren absoluten
59
+ Pfad als einzige Zeile in die Konfigurationsdatei eintragen.
60
+ `install`/`update` fragen interaktiv (Auswahl über `rich.prompt`),
61
+ `update`/`rollback` haben `--yes` zum Überspringen der Rückfrage.
62
+
63
+ ```bash
64
+ katalon install --dir ~/meine-katalon-instanz
65
+ katalon start
66
+ katalon status
67
+ ```
68
+
69
+ ## API-Adresse
70
+
71
+ Die REST-API liegt direkt unter der Base-URL, ohne `/api`-Präfix: `<KATALON_BASE_URL>/v1/...`
72
+ (z. B. Login unter `http://127.0.0.1:8080/v1/...` bei `behind-proxy`). Unter `/api/` liegen nur
73
+ die OpenAPI-Dokumentation (`/api/docs`, `/api/openapi.json`) und interne Endpunkte;
74
+ `/api/v1/...` liefert für normale API-Aufrufe 404.
75
+
76
+ ## Medienverzeichnis und Rechte
77
+
78
+ API und Worker laufen im Container als unprivilegierter User (`USER` im Image). `MEDIA_ROOT`
79
+ ist ein Bind-Mount vom Host und muss für diesen User beschreibbar sein. `katalon start`
80
+ (auch beim Install), `update` und `rollback` lesen UID/GID per `id` aus dem api-Container und
81
+ setzen in einem kurzlebigen root-Container desselben Images den Eigentümer aller Einträge
82
+ unter `MEDIA_ROOT`, die noch einem anderen Eigentümer gehören. Dateiinhalte werden nicht
83
+ verändert; ein erneuter Lauf ändert nichts. `katalon doctor` meldet ein nicht beschreibbares
84
+ `MEDIA_ROOT`, bevor Uploads mit HTTP 500 scheitern.
85
+
86
+ ## Domain und HTTPS
87
+
88
+ Für eine lokale Installation bleibt `http://localhost` mit TLS-Modus `none` der Standard.
89
+ Bei einer öffentlichen Domain schlägt der Setup-Wizard `caddy` vor. Du kannst auch nur
90
+ `sammlung.example.org` eingeben; mit Caddy wird daraus `https://sammlung.example.org`.
91
+
92
+ Vor der Installation müssen die DNS-Einträge wirksam sein: Der A-Record zeigt auf die öffentliche
93
+ IPv4-Adresse des Servers. Ein vorhandener AAAA-Record muss auf dessen erreichbare IPv6-Adresse
94
+ zeigen. TCP-Ports 80 und 443 müssen frei und öffentlich erreichbar sein, auch durch Server- und
95
+ Provider-Firewalls sowie gegebenenfalls Portweiterleitungen. Docker und Docker Compose müssen
96
+ installiert sein. Die CLI prüft freie Ports lokal, aber keine öffentliche DNS-Auflösung oder
97
+ Erreichbarkeit von außen.
98
+
99
+ Die TLS-Modi:
100
+
101
+ - `caddy`: Caddy läuft als Docker-Container vor dem internen nginx, belegt Ports 80 und 443,
102
+ beantragt öffentlich vertrauenswürdige Zertifikate und erneuert sie automatisch.
103
+ HTTP wird auf HTTPS umgeleitet. Zertifikate und Zustand liegen dauerhaft in den Docker-Volumes
104
+ `caddy_data` und `caddy_config`, die bei Neustarts, Updates und Rollbacks erhalten bleiben.
105
+ Dafür muss Caddy weiterlaufen, DNS muss stimmen und der Server erreichbar bleiben.
106
+ - `standalone`: nginx verwendet ein selbstsigniertes Zertifikat. Browser zeigen eine Warnung;
107
+ es gibt keine automatische Erneuerung. Eigene Zertifikate können unter `<dir>/certs/`
108
+ als `fullchain.pem` und `privkey.pem` abgelegt werden. Ihre Erneuerung und das anschließende
109
+ Neuladen von nginx musst du selbst einrichten.
110
+ - `behind-proxy`: Ein eigener Reverse-Proxy übernimmt HTTPS und Zertifikate.
111
+ Katalon ist standardmäßig nur auf `127.0.0.1:8080` erreichbar.
112
+ - `none`: HTTP ohne TLS, für lokale Tests oder IP-Adressen.
113
+
114
+ Die erste Zertifikatsausstellung kann nach dem Start kurz dauern. Bei HTTPS-Problemen:
115
+ `katalon logs caddy`. Caddy-Konfiguration und Zertifikatsverwaltung sind in der
116
+ [Caddy-Dokumentation](https://caddyserver.com/docs/automatic-https) beschrieben.
117
+ Die CLI erzeugt `<dir>/Caddyfile` bei Installation, Update und Rollback neu;
118
+ instanzspezifische Compose-Anpassungen gehören in `compose.override.yaml`.
119
+
120
+ ## Entwicklung
121
+
122
+ ```bash
123
+ uv sync
124
+ uv run katalon --help
125
+ uv run pytest
126
+ ```
127
+
128
+ ## Architektur
129
+
130
+ - `core/state.py` — `installation.json`, Single Source of Truth für laufende Version.
131
+ - `core/release.py` — GitHub-Release-Metadaten (`katalon-release.json` Asset). Enthält
132
+ optional `env_vars`/`deprecated_env_vars`: `install`/`update` ergänzen neue Pflichtvariablen
133
+ automatisch (Secret generieren oder Default übernehmen), fragen bei fehlendem Default interaktiv
134
+ nach (`--yes` bricht dann ab), tragen neue optionale Variablen auskommentiert in `.env` ein und
135
+ warnen vor veralteten Variablen, die noch in der `.env` stehen.
136
+ - `core/compose_gen.py` + `templates/compose.yaml.j2` + `templates/nginx.conf.j2` — rendert `compose.yaml`
137
+ + `nginx.conf` und bei `tls_mode=caddy` das `Caddyfile` aus Version + TLS-Modus;
138
+ erzeugt bei `tls_mode=standalone` ein selbstsigniertes
139
+ Zertifikat unter `<dir>/certs/` (eigenes Zertifikat dort ablegen, um es zu ersetzen).
140
+ `install` fragt zusätzlich `MEDIA_ROOT` ab und legt `<dir>/compose.override.yaml.example` an
141
+ (instanzspezifische Overrides — umbenennen zu `compose.override.yaml`, wird automatisch eingebunden
142
+ und bei Updates nicht angefasst). Optionale Env-Vars (Admin-Login, SMTP, Geonames, …) landet
143
+ auskommentiert in `.env`.
144
+ - `core/backup.py` — `pg_dump` vor jedem Update; Rollback restauriert Dump statt `alembic downgrade`.
145
+ - `core/docker.py` — dünner `docker compose`-Subprocess-Wrapper.
146
+ - `main.py` — Typer-Commands, interaktive Teile über `rich.prompt`/`rich.progress`.
@@ -0,0 +1,132 @@
1
+ # katalon-cli
2
+
3
+ Installer & Updater für [Katalon Collections](https://github.com/katalon-collections/katalon) Production-Instanzen.
4
+ Verwaltet eine Instanz unter einem frei gewählten Zielverzeichnis — 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 check-updates # prüft nur, ob ein neues Release verfügbar ist
20
+ katalon update # interaktives Update, Backup zuerst immer
21
+ katalon rollback # letztes Backup einspielen
22
+ katalon doctor # Docker, Diskspace, Ports, Schreibrechte von MEDIA_ROOT prüfen
23
+ katalon backup # manuelles Backup
24
+ katalon logs [service]
25
+ katalon manage <args> # katalon-manage im api-Container (z.B. reset-admin, create-user)
26
+ katalon restore DUMP # DB durch pg_dump ersetzen (-Fc oder SQL), danach Reindex
27
+ katalon reindex # Suchindex neu aufbauen, mit Fortschrittsanzeige
28
+ ```
29
+
30
+ Ein DB-Dump enthält nicht den Suchindex (Elasticsearch). `restore` baut ihn deshalb nach dem
31
+ Einspielen neu auf (`--no-reindex` zum Überspringen, später `katalon reindex`). Bei großen
32
+ Beständen dauert das lange (ca. 45 Min. für 80k Datensätze). Außerdem prüft `katalon` einmal
33
+ täglich auf neue Versionen (abschalten: `KATALON_NO_UPDATE_CHECK=1`).
34
+
35
+ `install` ohne `--dir` fragt das Zielverzeichnis interaktiv ab. Nach erfolgreicher Einrichtung
36
+ wird der absolute Pfad benutzerweit in `~/.config/katalon/instance` gespeichert.
37
+ Bei gesetztem, absolutem `XDG_CONFIG_HOME` liegt die Datei stattdessen unter
38
+ `$XDG_CONFIG_HOME/katalon/instance`. Das Konfigurationsverzeichnis wird bei Bedarf angelegt.
39
+ Alle weiteren Befehle verwenden den gespeicherten Pfad, unabhängig vom Arbeitsverzeichnis.
40
+ Ohne gespeicherten Pfad ist der Default auf allen Plattformen `~/katalon`.
41
+
42
+ Alle Befehle unterstützen `--dir PATH`: überschreibt den Pfad nur für diesen Aufruf.
43
+ Eine erfolgreiche neue Installation speichert ihr Zielverzeichnis als neuen Default.
44
+ Bestehende Instanzen lassen sich weiterhin mit `--dir` verwenden; alternativ ihren absoluten
45
+ Pfad als einzige Zeile in die Konfigurationsdatei eintragen.
46
+ `install`/`update` fragen interaktiv (Auswahl über `rich.prompt`),
47
+ `update`/`rollback` haben `--yes` zum Überspringen der Rückfrage.
48
+
49
+ ```bash
50
+ katalon install --dir ~/meine-katalon-instanz
51
+ katalon start
52
+ katalon status
53
+ ```
54
+
55
+ ## API-Adresse
56
+
57
+ Die REST-API liegt direkt unter der Base-URL, ohne `/api`-Präfix: `<KATALON_BASE_URL>/v1/...`
58
+ (z. B. Login unter `http://127.0.0.1:8080/v1/...` bei `behind-proxy`). Unter `/api/` liegen nur
59
+ die OpenAPI-Dokumentation (`/api/docs`, `/api/openapi.json`) und interne Endpunkte;
60
+ `/api/v1/...` liefert für normale API-Aufrufe 404.
61
+
62
+ ## Medienverzeichnis und Rechte
63
+
64
+ API und Worker laufen im Container als unprivilegierter User (`USER` im Image). `MEDIA_ROOT`
65
+ ist ein Bind-Mount vom Host und muss für diesen User beschreibbar sein. `katalon start`
66
+ (auch beim Install), `update` und `rollback` lesen UID/GID per `id` aus dem api-Container und
67
+ setzen in einem kurzlebigen root-Container desselben Images den Eigentümer aller Einträge
68
+ unter `MEDIA_ROOT`, die noch einem anderen Eigentümer gehören. Dateiinhalte werden nicht
69
+ verändert; ein erneuter Lauf ändert nichts. `katalon doctor` meldet ein nicht beschreibbares
70
+ `MEDIA_ROOT`, bevor Uploads mit HTTP 500 scheitern.
71
+
72
+ ## Domain und HTTPS
73
+
74
+ Für eine lokale Installation bleibt `http://localhost` mit TLS-Modus `none` der Standard.
75
+ Bei einer öffentlichen Domain schlägt der Setup-Wizard `caddy` vor. Du kannst auch nur
76
+ `sammlung.example.org` eingeben; mit Caddy wird daraus `https://sammlung.example.org`.
77
+
78
+ Vor der Installation müssen die DNS-Einträge wirksam sein: Der A-Record zeigt auf die öffentliche
79
+ IPv4-Adresse des Servers. Ein vorhandener AAAA-Record muss auf dessen erreichbare IPv6-Adresse
80
+ zeigen. TCP-Ports 80 und 443 müssen frei und öffentlich erreichbar sein, auch durch Server- und
81
+ Provider-Firewalls sowie gegebenenfalls Portweiterleitungen. Docker und Docker Compose müssen
82
+ installiert sein. Die CLI prüft freie Ports lokal, aber keine öffentliche DNS-Auflösung oder
83
+ Erreichbarkeit von außen.
84
+
85
+ Die TLS-Modi:
86
+
87
+ - `caddy`: Caddy läuft als Docker-Container vor dem internen nginx, belegt Ports 80 und 443,
88
+ beantragt öffentlich vertrauenswürdige Zertifikate und erneuert sie automatisch.
89
+ HTTP wird auf HTTPS umgeleitet. Zertifikate und Zustand liegen dauerhaft in den Docker-Volumes
90
+ `caddy_data` und `caddy_config`, die bei Neustarts, Updates und Rollbacks erhalten bleiben.
91
+ Dafür muss Caddy weiterlaufen, DNS muss stimmen und der Server erreichbar bleiben.
92
+ - `standalone`: nginx verwendet ein selbstsigniertes Zertifikat. Browser zeigen eine Warnung;
93
+ es gibt keine automatische Erneuerung. Eigene Zertifikate können unter `<dir>/certs/`
94
+ als `fullchain.pem` und `privkey.pem` abgelegt werden. Ihre Erneuerung und das anschließende
95
+ Neuladen von nginx musst du selbst einrichten.
96
+ - `behind-proxy`: Ein eigener Reverse-Proxy übernimmt HTTPS und Zertifikate.
97
+ Katalon ist standardmäßig nur auf `127.0.0.1:8080` erreichbar.
98
+ - `none`: HTTP ohne TLS, für lokale Tests oder IP-Adressen.
99
+
100
+ Die erste Zertifikatsausstellung kann nach dem Start kurz dauern. Bei HTTPS-Problemen:
101
+ `katalon logs caddy`. Caddy-Konfiguration und Zertifikatsverwaltung sind in der
102
+ [Caddy-Dokumentation](https://caddyserver.com/docs/automatic-https) beschrieben.
103
+ Die CLI erzeugt `<dir>/Caddyfile` bei Installation, Update und Rollback neu;
104
+ instanzspezifische Compose-Anpassungen gehören in `compose.override.yaml`.
105
+
106
+ ## Entwicklung
107
+
108
+ ```bash
109
+ uv sync
110
+ uv run katalon --help
111
+ uv run pytest
112
+ ```
113
+
114
+ ## Architektur
115
+
116
+ - `core/state.py` — `installation.json`, Single Source of Truth für laufende Version.
117
+ - `core/release.py` — GitHub-Release-Metadaten (`katalon-release.json` Asset). Enthält
118
+ optional `env_vars`/`deprecated_env_vars`: `install`/`update` ergänzen neue Pflichtvariablen
119
+ automatisch (Secret generieren oder Default übernehmen), fragen bei fehlendem Default interaktiv
120
+ nach (`--yes` bricht dann ab), tragen neue optionale Variablen auskommentiert in `.env` ein und
121
+ warnen vor veralteten Variablen, die noch in der `.env` stehen.
122
+ - `core/compose_gen.py` + `templates/compose.yaml.j2` + `templates/nginx.conf.j2` — rendert `compose.yaml`
123
+ + `nginx.conf` und bei `tls_mode=caddy` das `Caddyfile` aus Version + TLS-Modus;
124
+ erzeugt bei `tls_mode=standalone` ein selbstsigniertes
125
+ Zertifikat unter `<dir>/certs/` (eigenes Zertifikat dort ablegen, um es zu ersetzen).
126
+ `install` fragt zusätzlich `MEDIA_ROOT` ab und legt `<dir>/compose.override.yaml.example` an
127
+ (instanzspezifische Overrides — umbenennen zu `compose.override.yaml`, wird automatisch eingebunden
128
+ und bei Updates nicht angefasst). Optionale Env-Vars (Admin-Login, SMTP, Geonames, …) landet
129
+ auskommentiert in `.env`.
130
+ - `core/backup.py` — `pg_dump` vor jedem Update; Rollback restauriert Dump statt `alembic downgrade`.
131
+ - `core/docker.py` — dünner `docker compose`-Subprocess-Wrapper.
132
+ - `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.19"
3
+ version = "0.2.1"
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.19"
3
+ version = "0.2.1"
4
4
  description = "Installer & Updater CLI für Katalon Production-Instanzen"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -0,0 +1,132 @@
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
+ import tempfile
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Callable
11
+
12
+ from . import docker
13
+
14
+
15
+ class BackupError(RuntimeError):
16
+ pass
17
+
18
+
19
+ def create_backup(instance_dir: Path) -> Path:
20
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M")
21
+ backup_dir = instance_dir / "backups" / timestamp
22
+ backup_dir.mkdir(parents=True, exist_ok=True)
23
+
24
+ dump = docker.compose(
25
+ instance_dir,
26
+ "exec",
27
+ "-T",
28
+ "db",
29
+ "pg_dump",
30
+ "-U",
31
+ "katalon",
32
+ "katalon",
33
+ check=True,
34
+ capture=True,
35
+ )
36
+ (backup_dir / "pg_dump.sql").write_text(dump.stdout)
37
+
38
+ for name in (".env", "installation.json"):
39
+ src = instance_dir / name
40
+ if src.exists():
41
+ shutil.copy2(src, backup_dir / name)
42
+
43
+ return backup_dir
44
+
45
+
46
+ def restore_backup(instance_dir: Path, backup_dir: Path) -> None:
47
+ dump_file = backup_dir / "pg_dump.sql"
48
+ if not dump_file.exists():
49
+ raise BackupError(f"Kein pg_dump.sql in {backup_dir}")
50
+
51
+ docker.compose(instance_dir, "exec", "-T", "db", "dropdb", "-U", "katalon", "katalon")
52
+ docker.compose(instance_dir, "exec", "-T", "db", "createdb", "-U", "katalon", "katalon")
53
+ subprocess.run(
54
+ [*docker.compose_command(), *docker.compose_files(instance_dir),
55
+ "exec", "-T", "db", "psql", "-U", "katalon", "katalon"],
56
+ input=dump_file.read_text(),
57
+ cwd=instance_dir,
58
+ text=True,
59
+ check=True,
60
+ )
61
+
62
+ for name in (".env", "installation.json"):
63
+ src = backup_dir / name
64
+ if src.exists():
65
+ shutil.copy2(src, instance_dir / name)
66
+
67
+
68
+ def latest_backup(instance_dir: Path) -> Path | None:
69
+ backups_dir = instance_dir / "backups"
70
+ if not backups_dir.exists():
71
+ return None
72
+ candidates = sorted(backups_dir.iterdir(), reverse=True)
73
+ return candidates[0] if candidates else None
74
+
75
+
76
+ def dump_format(dump: Path) -> str:
77
+ """'custom' für pg_dump -Fc (Magic `PGDMP`), sonst 'sql' (Plain-Text)."""
78
+ with dump.open("rb") as fh:
79
+ return "custom" if fh.read(5) == b"PGDMP" else "sql"
80
+
81
+
82
+ def restore_dump(
83
+ instance_dir: Path,
84
+ dump: Path,
85
+ on_progress: Callable[[int], None] | None = None,
86
+ chunk_size: int = 1024 * 1024,
87
+ ) -> str:
88
+ """Ersetzt die DB `katalon` durch den Dump. Gibt das erkannte Format zurück.
89
+
90
+ Die Datei wird in Blöcken an pg_restore/psql gestreamt; `on_progress` bekommt
91
+ die bisher gesendeten Bytes.
92
+ """
93
+ if not dump.is_file():
94
+ raise BackupError(f"Dump nicht gefunden: {dump}")
95
+ fmt = dump_format(dump)
96
+
97
+ docker.compose(
98
+ instance_dir, "exec", "-T", "db", "dropdb", "-U", "katalon", "--if-exists", "--force", "katalon"
99
+ )
100
+ docker.compose(instance_dir, "exec", "-T", "db", "createdb", "-U", "katalon", "katalon")
101
+
102
+ if fmt == "custom":
103
+ tool = ["pg_restore", "-U", "katalon", "-d", "katalon", "--no-owner", "--exit-on-error"]
104
+ else:
105
+ tool = ["psql", "-U", "katalon", "-d", "katalon", "-q", "-v", "ON_ERROR_STOP=1"]
106
+
107
+ sent = 0
108
+ with tempfile.TemporaryFile() as err, dump.open("rb") as src:
109
+ proc = subprocess.Popen(
110
+ [*docker.compose_command(), *docker.compose_files(instance_dir), "exec", "-T", "db", *tool],
111
+ stdin=subprocess.PIPE,
112
+ stdout=subprocess.DEVNULL,
113
+ stderr=err,
114
+ cwd=instance_dir,
115
+ )
116
+ assert proc.stdin is not None
117
+ try:
118
+ while chunk := src.read(chunk_size):
119
+ proc.stdin.write(chunk)
120
+ sent += len(chunk)
121
+ if on_progress:
122
+ on_progress(sent)
123
+ proc.stdin.close()
124
+ except BrokenPipeError:
125
+ pass # Tool ist vorzeitig beendet — Fehlertext steht unten in stderr
126
+ returncode = proc.wait()
127
+ err.seek(0)
128
+ message = err.read().decode(errors="replace").strip()
129
+
130
+ if returncode != 0:
131
+ raise BackupError(f"Restore fehlgeschlagen ({tool[0]}, Exit {returncode}):\n{message[-2000:]}")
132
+ return fmt
@@ -2,8 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import re
5
6
  import subprocess
7
+ from ipaddress import ip_address
6
8
  from pathlib import Path
9
+ from urllib.parse import urlparse
7
10
 
8
11
  from jinja2 import Environment, PackageLoader, select_autoescape
9
12
 
@@ -22,8 +25,8 @@ def render_compose(
22
25
  tls_mode: str,
23
26
  registry: str = "ghcr.io/katalon-collections/katalon",
24
27
  ) -> str:
25
- from urllib.parse import urlparse
26
-
28
+ if tls_mode == "caddy":
29
+ caddy_host(base_url)
27
30
  parsed_port = urlparse(base_url).port
28
31
  template = _env.get_template("compose.yaml.j2")
29
32
  return template.render(
@@ -47,6 +50,10 @@ def write_compose(instance_dir: Path, **kwargs) -> Path:
47
50
  nginx_path = instance_dir / "nginx.conf"
48
51
  nginx_path.write_text(render_nginx_conf(tls_mode=kwargs["tls_mode"]))
49
52
 
53
+ if kwargs["tls_mode"] == "caddy":
54
+ template = _env.get_template("Caddyfile.j2")
55
+ (instance_dir / "Caddyfile").write_text(template.render(host=caddy_host(kwargs["base_url"])))
56
+
50
57
  if kwargs["tls_mode"] == "standalone":
51
58
  ensure_self_signed_cert(instance_dir, host=_host_of(kwargs["base_url"]))
52
59
 
@@ -79,7 +86,27 @@ def ensure_self_signed_cert(instance_dir: Path, *, host: str) -> None:
79
86
  )
80
87
 
81
88
 
82
- def _host_of(base_url: str) -> str:
83
- from urllib.parse import urlparse
89
+ def caddy_host(base_url: str) -> str:
90
+ """Validiert die öffentliche HTTPS-Adresse vor dem Rendern der Caddy-Konfiguration."""
91
+ parsed = urlparse(base_url)
92
+ host = (parsed.hostname or "").encode("idna").decode("ascii")
93
+ try:
94
+ ip_address(host)
95
+ is_ip = True
96
+ except ValueError:
97
+ is_ip = False
98
+ label = r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"
99
+ if (
100
+ parsed.scheme != "https" or parsed.port not in (None, 443)
101
+ or parsed.username is not None or parsed.password is not None
102
+ or parsed.path not in ("", "/") or parsed.query or parsed.fragment
103
+ or is_ip or len(host) > 253
104
+ or not re.fullmatch(rf"{label}(?:\.{label})+", host)
105
+ or host.endswith((".localhost", ".local"))
106
+ ):
107
+ raise ValueError("Caddy benötigt eine öffentliche Domain als https://domain.tld ohne Pfad (Port 443).")
108
+ return host
84
109
 
110
+
111
+ def _host_of(base_url: str) -> str:
85
112
  return urlparse(base_url).hostname or "localhost"
@@ -0,0 +1,113 @@
1
+ """Schreibrechte für Bind-Mounts, in die API und Worker schreiben.
2
+
3
+ API- und Worker-Image laufen als unprivilegierter User (Dockerfile `USER`). Ein vom
4
+ Installer oder per Hand angelegtes Host-Verzeichnis gehört dagegen meist root. Die
5
+ UID/GID wird nicht im CLI hinterlegt, sondern zur Laufzeit aus dem Container gelesen
6
+ (`id` im api-Service, inkl. eventuellem `user:` aus compose.override.yaml).
7
+
8
+ Die Korrektur läuft als root in einem Wegwerf-Container desselben Images — dadurch
9
+ funktioniert sie auch, wenn die CLI selbst nicht als root läuft. Sie ändert nur Eigentümer
10
+ von Einträgen mit falschem Eigentümer, nie Inhalte, und ist damit idempotent.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ from . import docker
20
+
21
+ # Container-Pfad des MEDIA_ROOT-Bind-Mounts (compose.yaml.j2, api + worker).
22
+ CONTAINER_MEDIA_ROOT = "/var/lib/katalon/media"
23
+ # Services, die in CONTAINER_MEDIA_ROOT schreiben.
24
+ WRITER_SERVICES = ("api", "worker")
25
+ # Service, dessen Image-User als Eigentümer gesetzt wird.
26
+ OWNER_SERVICE = "api"
27
+
28
+
29
+ class PermissionFixError(RuntimeError):
30
+ pass
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ContainerUser:
35
+ uid: int
36
+ gid: int
37
+ name: str = ""
38
+
39
+ def __str__(self) -> str:
40
+ label = f"{self.uid}:{self.gid}"
41
+ return f"{label} ({self.name})" if self.name else label
42
+
43
+
44
+ _ID_RE = re.compile(r"uid=(\d+)(?:\(([^)]*)\))?\s+gid=(\d+)")
45
+
46
+
47
+ def parse_id_output(output: str) -> ContainerUser:
48
+ match = _ID_RE.search(output)
49
+ if not match:
50
+ raise PermissionFixError(f"Unerwartete Ausgabe von `id`: {output.strip()!r}")
51
+ return ContainerUser(uid=int(match.group(1)), gid=int(match.group(3)), name=match.group(2) or "")
52
+
53
+
54
+ def _run(instance_dir: Path, service: str, *args: str, root: bool = False):
55
+ user = ["--user", "0:0"] if root else []
56
+ return docker.compose(
57
+ instance_dir,
58
+ "run", "--rm", "--no-deps", "-T", *user, "--entrypoint", args[0], service, *args[1:],
59
+ check=False,
60
+ capture=True,
61
+ )
62
+
63
+
64
+ def container_user(instance_dir: Path, service: str = OWNER_SERVICE) -> ContainerUser:
65
+ """UID/GID, unter der `service` tatsächlich läuft (Image-USER bzw. compose `user:`)."""
66
+ result = _run(instance_dir, service, "id")
67
+ if result.returncode != 0:
68
+ raise PermissionFixError(
69
+ f"UID des {service}-Containers nicht ermittelbar: {(result.stderr or result.stdout).strip()}"
70
+ )
71
+ return parse_id_output(result.stdout)
72
+
73
+
74
+ def chown_args(path: str, user: ContainerUser) -> list[str]:
75
+ """`find`-Aufruf, der nur Einträge mit falschem Eigentümer umstellt (Inhalte bleiben unberührt)."""
76
+ owner = f"{user.uid}:{user.gid}"
77
+ return [
78
+ "find", path, "-xdev",
79
+ "(", "!", "-user", str(user.uid), "-o", "!", "-group", str(user.gid), ")",
80
+ "-exec", "chown", "-h", owner, "{}", "+",
81
+ ]
82
+
83
+
84
+ def is_writable(instance_dir: Path, service: str) -> bool:
85
+ result = _run(instance_dir, service, "test", "-w", CONTAINER_MEDIA_ROOT)
86
+ return result.returncode == 0
87
+
88
+
89
+ def unwritable_services(instance_dir: Path) -> list[str]:
90
+ return [service for service in WRITER_SERVICES if not is_writable(instance_dir, service)]
91
+
92
+
93
+ def fix_media_permissions(instance_dir: Path) -> ContainerUser:
94
+ """Setzt den Eigentümer von MEDIA_ROOT auf den Container-User und prüft danach die Schreibrechte."""
95
+ user = container_user(instance_dir)
96
+ result = _run(instance_dir, OWNER_SERVICE, *chown_args(CONTAINER_MEDIA_ROOT, user), root=True)
97
+ if result.returncode != 0:
98
+ raise PermissionFixError(
99
+ f"Eigentümer von MEDIA_ROOT konnte nicht auf {user} gesetzt werden: "
100
+ f"{(result.stderr or result.stdout).strip()}"
101
+ )
102
+ failing = unwritable_services(instance_dir)
103
+ if failing:
104
+ raise PermissionFixError(
105
+ f"MEDIA_ROOT ist nach der Korrektur für {', '.join(failing)} nicht beschreibbar "
106
+ f"(Eigentümer jetzt {user})."
107
+ )
108
+ return user
109
+
110
+
111
+ def manual_fix_hint(media_root: str, user: ContainerUser | None) -> str:
112
+ owner = f"{user.uid}:{user.gid}" if user else "<UID>:<GID>"
113
+ return f"sudo chown -R {owner} {media_root}"