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.
- katalon_cli-0.2.1/PKG-INFO +146 -0
- katalon_cli-0.2.1/README.md +132 -0
- {katalon_cli-0.1.19 → katalon_cli-0.2.1}/pyproject.toml +1 -1
- {katalon_cli-0.1.19 → katalon_cli-0.2.1}/pyproject.toml.orig +1 -1
- katalon_cli-0.2.1/src/katalon_cli/core/backup.py +132 -0
- {katalon_cli-0.1.19 → katalon_cli-0.2.1}/src/katalon_cli/core/compose_gen.py +31 -4
- katalon_cli-0.2.1/src/katalon_cli/core/permissions.py +113 -0
- katalon_cli-0.2.1/src/katalon_cli/core/reindex.py +171 -0
- {katalon_cli-0.1.19 → katalon_cli-0.2.1}/src/katalon_cli/core/state.py +1 -1
- {katalon_cli-0.1.19 → katalon_cli-0.2.1}/src/katalon_cli/main.py +217 -11
- katalon_cli-0.2.1/src/katalon_cli/templates/Caddyfile.j2 +3 -0
- {katalon_cli-0.1.19 → katalon_cli-0.2.1}/src/katalon_cli/templates/compose.yaml.j2 +21 -0
- {katalon_cli-0.1.19 → katalon_cli-0.2.1}/src/katalon_cli/templates/nginx.conf.j2 +6 -6
- katalon_cli-0.1.19/PKG-INFO +0 -86
- katalon_cli-0.1.19/README.md +0 -72
- katalon_cli-0.1.19/src/katalon_cli/core/backup.py +0 -71
- {katalon_cli-0.1.19 → katalon_cli-0.2.1}/src/katalon_cli/__init__.py +0 -0
- {katalon_cli-0.1.19 → katalon_cli-0.2.1}/src/katalon_cli/core/__init__.py +0 -0
- {katalon_cli-0.1.19 → katalon_cli-0.2.1}/src/katalon_cli/core/checks.py +0 -0
- {katalon_cli-0.1.19 → katalon_cli-0.2.1}/src/katalon_cli/core/docker.py +0 -0
- {katalon_cli-0.1.19 → katalon_cli-0.2.1}/src/katalon_cli/core/paths.py +0 -0
- {katalon_cli-0.1.19 → katalon_cli-0.2.1}/src/katalon_cli/core/release.py +0 -0
- {katalon_cli-0.1.19 → katalon_cli-0.2.1}/src/katalon_cli/core/selfupdate.py +0 -0
- {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`.
|
|
@@ -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
|
-
|
|
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
|
|
83
|
-
|
|
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}"
|