Compare commits
2
Commits
v0.1.0
...
acae1bc2a4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acae1bc2a4 | ||
|
|
69cf8eb784 |
@@ -8,6 +8,9 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
## [Unveröffentlicht]
|
||||
|
||||
### Hinzugefügt
|
||||
- Katalog als Daten: Debian 12/13 und Ubuntu 22.04/24.04/26.04 sowie die Profile base, docker und zabbix-agent als YAML unter `catalog/`, beim Start in die Datenbank abgeglichen (#004)
|
||||
- Katalogseite mit Aktiv-Schaltern für Distributionen und Profile; `GET /api/v1/distributionen?typ=vm|lxc`, `GET /api/v1/profile` (#004)
|
||||
- README mit Stand, Fähigkeiten und Entwicklungsumgebung (#018)
|
||||
|
||||
### Geändert
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Zeitübersicht
|
||||
|
||||
**Gesamt:** 1 h 15 min (laufende Sitzung)
|
||||
**Gesamt:** 1 h 24 min (laufende Sitzung)
|
||||
|
||||
Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeldungen ist enthalten.
|
||||
|
||||
@@ -11,10 +11,12 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
|
||||
| #001 Projekt-Setup | 0 h 50 min |
|
||||
| #002 Server-Verwaltung | 0 h 11 min |
|
||||
| #003 Verbindungstest | 0 h 25 min |
|
||||
| #004 Katalog | 0 h 09 min |
|
||||
|
||||
| Meilenstein | Zeit |
|
||||
|---|---|
|
||||
| v0.1.0 | 1 h 15 min | **abgeschlossen**
|
||||
| v0.2.0 | – | in Arbeit
|
||||
|
||||
---
|
||||
|
||||
@@ -32,6 +34,8 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
|
||||
- #003 abgeschlossen: Verbindungstest mit Fähigkeitserkennung und SSH-Host-Key-Pinning; 127 Tests
|
||||
- Feldtest auf dem Testhost `pve-demo` (PVE 9.2.2): Rolle/Token nach Doku per SSH angelegt, Fingerprint und Host-Key gegen den Node verglichen, Test mit und ohne SSH – alles wie erwartet
|
||||
- Meilenstein v0.1.0 abgeschlossen, getaggt und nach Gitea gepusht
|
||||
- README als Zwischenstand angelegt (Florian: „readme im projekt fehlt“)
|
||||
- #004 abgeschlossen: Katalog mit fünf Distributionen und drei Profilen, Abgleich, Katalogseite; 150 Tests, 12 Playwright-Abläufe
|
||||
- #002 abgeschlossen: Server-Verwaltung mit Fingerprint-Pinning, Proxmox-Client, Proxmox-Mock (HTTPS, selbst signiert); 99 Tests, 10 Playwright-Abläufe
|
||||
- Testhost 10.0.10.228 von Florian bereitgestellt (Installation läuft)
|
||||
- #001 abgeschlossen: Grundgerüst mit Anmeldung, Seitenleiste, beiden Themes; 60 Tests, 8 Playwright-Abläufe (Desktop + 390 px)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# ProxDeploy
|
||||
|
||||
**VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI.**
|
||||
|
||||
ProxDeploy ist eine selbst gehostete Webanwendung mit einer CLI (`proxdeploy`). Damit wählt man einen von
|
||||
mehreren Proxmox-VE-Servern (8.x/9.x, Einzelhost oder Cluster) und rollt darauf vollständig unattended
|
||||
eine VM oder einen LXC-Container mit Debian oder Ubuntu aus, inklusive Profilen wie `base`, `docker`
|
||||
oder `zabbix-agent`. Weitere Distributionen und Profile sind YAML-Dateien im Katalog, kein Code.
|
||||
|
||||
> ProxDeploy ist ein unabhängiges Werkzeug und stammt nicht von Proxmox Server Solutions GmbH.
|
||||
> „Proxmox“ ist eine Marke der Proxmox Server Solutions GmbH.
|
||||
|
||||
## Stand
|
||||
|
||||
In Entwicklung. Version und Fortschritt stehen im [CHANGELOG](CHANGELOG.md), der Plan in
|
||||
[docs/architektur.md](docs/architektur.md), die Aufgaben in [docs/issues/](docs/issues/README.md).
|
||||
|
||||
| Etappe | Inhalt | Stand |
|
||||
|---|---|---|
|
||||
| 1 · v0.1.0 | Server-Verwaltung, Fingerprint-Pinning, Verbindungstest mit Fähigkeitserkennung | ✓ fertig, auf PVE 9.2 geprüft |
|
||||
| 2 · v0.2.0 | Katalog, Template-Bau, Debian-13-VM, Live-Fortschritt | ◐ in Arbeit |
|
||||
| 3 · v0.3.0 | LXC, Ubuntu, Profile, Vorlagen | ○ geplant |
|
||||
| 4 · v0.4.0 | CLI `proxdeploy` (Go) | ○ geplant |
|
||||
| 5 · v0.5.0 | Verlauf, Audit-Log, Rollen, OIDC | ○ geplant |
|
||||
| 6 · v0.6.0 | Branding, README mit Screenshots, Installer | ○ geplant |
|
||||
|
||||
## Was ProxDeploy auf einem Server kann
|
||||
|
||||
Der Verbindungstest prüft jeden Server und zeigt, welcher Vorgang per API, per SSH oder gar nicht geht.
|
||||
Maßgeblich ist die Proxmox-API (geprüft gegen PVE 8.x und 9.2):
|
||||
|
||||
- **Template bauen:** per API ab PVE 8.4 (`download-url` + `import-from`), davor per SSH
|
||||
- **VM provisionieren:** über den Guest-Agent, ohne SSH
|
||||
- **LXC provisionieren:** braucht SSH (Proxmox hat keine Exec-API für Container)
|
||||
- **cloud-init-Snippets:** nur per SSH (keine API)
|
||||
|
||||
Das API-Token braucht eine eigene Rolle; die Befehle stehen in
|
||||
[docs/proxmox-rechte.md](docs/proxmox-rechte.md).
|
||||
|
||||
## Entwicklung
|
||||
|
||||
Voraussetzungen: Node 24 (z. B. über nvm), pnpm 12 (corepack), Docker für PostgreSQL.
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
cp .env.example .env # Schlüssel erzeugen: openssl rand -base64 32 / 48, ADMIN_PASSWORT setzen
|
||||
pnpm db:up # PostgreSQL 17 auf 127.0.0.1:5437 (inkl. Testdatenbank)
|
||||
pnpm db:migrate
|
||||
pnpm build && PORT=3310 APP_URL=http://127.0.0.1:3310 pnpm start
|
||||
```
|
||||
|
||||
Prüfen vor jedem Commit:
|
||||
|
||||
```bash
|
||||
pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test
|
||||
pnpm test:e2e # Browser-Abläufe gegen den gebauten Stand (Desktop und 390 px)
|
||||
```
|
||||
|
||||
Konventionen (deutsche Bezeichner, Schichtung, Versionen) stehen in [CLAUDE.md](CLAUDE.md).
|
||||
|
||||
## Betrieb
|
||||
|
||||
Docker Compose (app, worker, postgres) und ein Installer für einen Debian-LXC auf Proxmox folgen mit
|
||||
Etappe 6. ProxDeploy ist für den Betrieb hinter einem Reverse-Proxy mit TLS gedacht
|
||||
(`TRUST_PROXY=true`, `APP_URL` auf die öffentliche Adresse).
|
||||
|
||||
## Lizenz
|
||||
|
||||
Noch nicht festgelegt.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Geprüft am 2026-10-12: Image, SHA512SUMS (Zeilenformat "<hash> <datei>") und LXC-Template
|
||||
# existieren. Der Guest-Agent ist im Image nicht enthalten – er wird beim Template-Bau eingebaut.
|
||||
id: debian-12
|
||||
name: Debian 12 „Bookworm“
|
||||
family: debian
|
||||
os:
|
||||
id: debian
|
||||
version_id: "12"
|
||||
codename: bookworm
|
||||
arch: amd64
|
||||
vm:
|
||||
image_url: https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2
|
||||
checksum_url: https://cloud.debian.org/images/cloud/bookworm/latest/SHA512SUMS
|
||||
checksum_type: sha512
|
||||
format: qcow2
|
||||
default_user: debian
|
||||
guest_agent_preinstalled: false
|
||||
bios: ovmf
|
||||
min_disk_gb: 4
|
||||
lxc:
|
||||
template_pattern: "debian-12-standard_*_amd64.tar.zst" # aus pveam available
|
||||
ostype: debian
|
||||
@@ -0,0 +1,22 @@
|
||||
# Geprüft am 2026-10-12: Image, SHA512SUMS (Zeilenformat "<hash> <datei>") und LXC-Template
|
||||
# existieren. Der Guest-Agent ist im Image nicht enthalten – er wird beim Template-Bau eingebaut.
|
||||
id: debian-13
|
||||
name: Debian 13 „Trixie“
|
||||
family: debian
|
||||
os:
|
||||
id: debian
|
||||
version_id: "13"
|
||||
codename: trixie
|
||||
arch: amd64
|
||||
vm:
|
||||
image_url: https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2
|
||||
checksum_url: https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS
|
||||
checksum_type: sha512
|
||||
format: qcow2
|
||||
default_user: debian
|
||||
guest_agent_preinstalled: false
|
||||
bios: ovmf
|
||||
min_disk_gb: 4
|
||||
lxc:
|
||||
template_pattern: "debian-13-standard_*_amd64.tar.zst" # aus pveam available
|
||||
ostype: debian
|
||||
@@ -0,0 +1,22 @@
|
||||
# Geprüft am 2026-10-12: Image (.img ist qcow2), SHA256SUMS (Zeilenformat "<hash> *<datei>")
|
||||
# und LXC-Template existieren. Guest-Agent nicht im Image – wird beim Template-Bau eingebaut.
|
||||
id: ubuntu-22.04
|
||||
name: Ubuntu 22.04 LTS „Jammy Jellyfish“
|
||||
family: debian
|
||||
os:
|
||||
id: ubuntu
|
||||
version_id: "22.04"
|
||||
codename: jammy
|
||||
arch: amd64
|
||||
vm:
|
||||
image_url: https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img
|
||||
checksum_url: https://cloud-images.ubuntu.com/jammy/current/SHA256SUMS
|
||||
checksum_type: sha256
|
||||
format: qcow2
|
||||
default_user: ubuntu
|
||||
guest_agent_preinstalled: false
|
||||
bios: ovmf
|
||||
min_disk_gb: 4
|
||||
lxc:
|
||||
template_pattern: "ubuntu-22.04-standard_*_amd64.tar.zst"
|
||||
ostype: ubuntu
|
||||
@@ -0,0 +1,22 @@
|
||||
# Geprüft am 2026-10-12: Image (.img ist qcow2), SHA256SUMS (Zeilenformat "<hash> *<datei>")
|
||||
# und LXC-Template existieren. Guest-Agent nicht im Image – wird beim Template-Bau eingebaut.
|
||||
id: ubuntu-24.04
|
||||
name: Ubuntu 24.04 LTS „Noble Numbat“
|
||||
family: debian
|
||||
os:
|
||||
id: ubuntu
|
||||
version_id: "24.04"
|
||||
codename: noble
|
||||
arch: amd64
|
||||
vm:
|
||||
image_url: https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
|
||||
checksum_url: https://cloud-images.ubuntu.com/noble/current/SHA256SUMS
|
||||
checksum_type: sha256
|
||||
format: qcow2
|
||||
default_user: ubuntu
|
||||
guest_agent_preinstalled: false
|
||||
bios: ovmf
|
||||
min_disk_gb: 4
|
||||
lxc:
|
||||
template_pattern: "ubuntu-24.04-standard_*_amd64.tar.zst"
|
||||
ostype: ubuntu
|
||||
@@ -0,0 +1,22 @@
|
||||
# Geprüft am 2026-10-12: Image (.img ist qcow2), SHA256SUMS (Zeilenformat "<hash> *<datei>")
|
||||
# und LXC-Template existieren. Guest-Agent nicht im Image – wird beim Template-Bau eingebaut.
|
||||
id: ubuntu-26.04
|
||||
name: Ubuntu 26.04 LTS „Resolute Raccoon“
|
||||
family: debian
|
||||
os:
|
||||
id: ubuntu
|
||||
version_id: "26.04"
|
||||
codename: resolute
|
||||
arch: amd64
|
||||
vm:
|
||||
image_url: https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-amd64.img
|
||||
checksum_url: https://cloud-images.ubuntu.com/resolute/current/SHA256SUMS
|
||||
checksum_type: sha256
|
||||
format: qcow2
|
||||
default_user: ubuntu
|
||||
guest_agent_preinstalled: false
|
||||
bios: ovmf
|
||||
min_disk_gb: 4
|
||||
lxc:
|
||||
template_pattern: "ubuntu-26.04-standard_*_amd64.tar.zst"
|
||||
ostype: ubuntu
|
||||
@@ -0,0 +1,26 @@
|
||||
# Grundausstattung für jeden Gast. Platzhalter: {{ var.<name> }} aus den Variablen unten,
|
||||
# {{ os.id }}, {{ os.version_id }}, {{ os.codename }} aus der Distribution,
|
||||
# {{ guest.name }}, {{ guest.user }} aus dem Deployment. `a ?? b` nimmt b, wenn a leer ist.
|
||||
id: base
|
||||
name: Basis
|
||||
description: Updates, Guest-Agent, Zeitzone, Locale, Tastatur, automatische Sicherheitsupdates, Zeitsync, SSH-Härtung
|
||||
variables:
|
||||
- { name: zeitzone, label: Zeitzone, default: Europe/Berlin }
|
||||
- { name: locale, label: Locale, default: de_DE.UTF-8 }
|
||||
- { name: tastatur, label: Tastaturbelegung, default: de }
|
||||
- { name: fail2ban, label: fail2ban installieren, type: boolean, default: false, help: Sperrt Adressen nach wiederholten SSH-Fehlversuchen. }
|
||||
families:
|
||||
debian:
|
||||
- update: true
|
||||
- guest_agent: true
|
||||
- timezone: "{{ var.zeitzone }}"
|
||||
- locale: "{{ var.locale }}"
|
||||
- keyboard: "{{ var.tastatur }}"
|
||||
- packages: [chrony, sudo, curl, ca-certificates]
|
||||
- service: { name: chrony, enable: true, start: true }
|
||||
- unattended_upgrades: true
|
||||
- ssh_harden: { password_auth: false, root_login: false }
|
||||
- packages: [fail2ban]
|
||||
if: fail2ban
|
||||
- service: { name: fail2ban, enable: true, start: true }
|
||||
if: fail2ban
|
||||
@@ -0,0 +1,22 @@
|
||||
# Docker CE aus dem offiziellen Repository nach docs.docker.com (geprüft 2026-10-12):
|
||||
# Schlüssel unter /etc/apt/keyrings/docker.asc, Quelle im deb822-Format.
|
||||
id: docker
|
||||
name: Docker CE
|
||||
description: Docker Engine mit compose- und buildx-Plugin aus dem offiziellen Repository
|
||||
requires: [base]
|
||||
variables:
|
||||
- { name: benutzer_in_gruppe, label: Benutzer in die Gruppe docker aufnehmen, type: boolean, default: true, help: Erlaubt docker ohne sudo – entspricht root-Rechten. }
|
||||
lxc:
|
||||
features: { nesting: true }
|
||||
families:
|
||||
debian:
|
||||
- repo:
|
||||
name: docker
|
||||
key_url: "https://download.docker.com/linux/{{ os.id }}/gpg"
|
||||
uris: "https://download.docker.com/linux/{{ os.id }}"
|
||||
suites: "{{ os.codename }}"
|
||||
components: stable
|
||||
- packages: [docker-ce, docker-ce-cli, containerd.io, docker-buildx-plugin, docker-compose-plugin]
|
||||
- service: { name: docker, enable: true, start: true }
|
||||
- run: usermod -aG docker {{ guest.user }}
|
||||
if: benutzer_in_gruppe
|
||||
@@ -0,0 +1,21 @@
|
||||
# Zabbix Agent 2 aus dem offiziellen Repository, 7.0 LTS (8.0 hatte am 2026-10-12 noch keine
|
||||
# Agent-Pakete). Das Release-Paket legt Repository und Schlüssel an.
|
||||
id: zabbix-agent
|
||||
name: Zabbix Agent 2
|
||||
description: Zabbix Agent 2 (7.0 LTS), Server und ServerActive als Variablen
|
||||
requires: [base]
|
||||
variables:
|
||||
- { name: server, label: Zabbix-Server, required: true, help: "Adresse(n) des Servers, kommagetrennt" }
|
||||
- { name: server_active, label: ServerActive, help: Leer lassen = wie Zabbix-Server }
|
||||
families:
|
||||
debian:
|
||||
- repo_package: "https://repo.zabbix.com/zabbix/7.0/{{ os.id }}/pool/main/z/zabbix-release/zabbix-release_latest_7.0+{{ os.id }}{{ os.version_id }}_all.deb"
|
||||
- packages: [zabbix-agent2]
|
||||
- file:
|
||||
path: /etc/zabbix/zabbix_agent2.d/proxdeploy.conf
|
||||
content: |
|
||||
# Von ProxDeploy angelegt
|
||||
Server={{ var.server }}
|
||||
ServerActive={{ var.server_active ?? var.server }}
|
||||
Hostname={{ guest.name }}
|
||||
- service: { name: zabbix-agent2, enable: true, start: true, restart: true }
|
||||
@@ -1,24 +1,29 @@
|
||||
# #004 Katalog: Distributionen und Profile als Daten
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** feature, katalog
|
||||
**Geschätzt:** 5 h **Tatsächlich:** –
|
||||
**Geschätzt:** 5 h **Tatsächlich:** 0 h 09 min
|
||||
|
||||
## Ziel
|
||||
YAML-Katalog laden, validieren, in die DB synchronisieren, in der GUI ansehen und deaktivieren.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] zod-Schemas für `catalog/distros` und `catalog/profiles`
|
||||
- [ ] Debian 12/13, Ubuntu 22.04/24.04 (URLs/Prüfsummen geprüft am 2026-10-12)
|
||||
- [ ] Prüfsummen-Parser für beide Zeilenformate; LXC-Musterauflösung mit Versionssortierung
|
||||
- [ ] Sync beim worker-Start (Upsert, Aktiv-Schalter bleibt), fehlerhafte Datei überspringen + melden
|
||||
- [ ] Katalog-Seite (Distributionen, Profile, aktiv/inaktiv)
|
||||
- [x] zod-Schemas für `catalog/distros` und `catalog/profiles`
|
||||
- [x] Debian 12/13, Ubuntu 22.04/24.04/26.04 (URLs/Prüfsummen geprüft am 2026-10-12)
|
||||
- [x] Prüfsummen-Parser für beide Zeilenformate; LXC-Musterauflösung mit Versionssortierung
|
||||
- [x] Sync beim worker-Start (Upsert, Aktiv-Schalter bleibt), fehlerhafte Datei überspringen + melden
|
||||
- [x] Katalog-Seite (Distributionen, Profile, aktiv/inaktiv)
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] Unit-Tests für Schema, Sync und Muster
|
||||
- [x] Unit-Tests für Schema, Sync und Muster
|
||||
|
||||
## Abhängigkeiten
|
||||
#001
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
–
|
||||
- Profile `base`, `docker`, `zabbix-agent` liegen als Daten schon im Katalog; ihre Umsetzung in Shell folgt mit dem Adapter (#005).
|
||||
- Zusätzliche Felder gegenüber der Vorgabe: `os` (id, version_id, codename – für Repo-URLs in Profilen), `arch`, `vm.bios`, `vm.min_disk_gb`.
|
||||
- Unbekannte YAML-Schlüssel sind ein Fehler; Dateiname und `id` müssen übereinstimmen.
|
||||
- Verschwundene Einträge werden markiert (`entferntAm`), nicht gelöscht – Aufträge verweisen später darauf.
|
||||
- Ein eigenes Profil mit gleicher id wie ein Katalogprofil gewinnt; der Abgleich meldet den Konflikt.
|
||||
- Abgleich unter Advisory-Lock, weil app und worker beim Start gleichzeitig abgleichen.
|
||||
|
||||
@@ -7,7 +7,7 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
||||
| [#001](001-projekt-setup.md) | Projekt-Setup | erledigt | v0.1.0 | 4 h | 0 h 50 min |
|
||||
| [#002](002-verschluesselung-server.md) | Server-Verwaltung und Verschlüsselung | erledigt | v0.1.0 | 6 h | 0 h 11 min |
|
||||
| [#003](003-verbindungstest.md) | Verbindungstest und Fähigkeitserkennung | erledigt | v0.1.0 | 6 h | 0 h 25 min |
|
||||
| [#004](004-katalog.md) | Katalog: Distributionen und Profile als Daten | offen | v0.2.0 | 5 h | – |
|
||||
| [#004](004-katalog.md) | Katalog: Distributionen und Profile als Daten | erledigt | v0.2.0 | 5 h | 0 h 09 min |
|
||||
| [#005](005-adapter-profile.md) | Adapter debian und Profil-Engine | offen | v0.2.0 | 6 h | – |
|
||||
| [#006](006-auftraege-sse.md) | Aufträge, Phasen, worker und Live-Fortschritt | offen | v0.2.0 | 8 h | – |
|
||||
| [#007](007-template-bau.md) | Template-Bau für VMs | offen | v0.2.0 | 8 h | – |
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"react-dom": "19.3.0",
|
||||
"ssh2": "1.17.0",
|
||||
"undici": "8.11.2",
|
||||
"yaml": "2.9.1",
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Generated
+4
-2
@@ -211,6 +211,9 @@ importers:
|
||||
undici:
|
||||
specifier: 8.11.2
|
||||
version: 8.11.2
|
||||
yaml:
|
||||
specifier: 2.9.1
|
||||
version: 2.9.1
|
||||
zod:
|
||||
specifier: ^4.6.5
|
||||
version: 4.6.5
|
||||
@@ -7549,8 +7552,7 @@ snapshots:
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yaml@2.9.1:
|
||||
optional: true
|
||||
yaml@2.9.1: {}
|
||||
|
||||
yargs-parser@22.0.0: {}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Herkunft" AS ENUM ('KATALOG', 'EIGEN');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Distribution" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"familie" TEXT NOT NULL,
|
||||
"definition" JSONB NOT NULL,
|
||||
"quelltext" TEXT NOT NULL,
|
||||
"dateiHash" TEXT NOT NULL,
|
||||
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||
"entferntAm" TIMESTAMP(3),
|
||||
"synchronisiertAm" TIMESTAMP(3) NOT NULL,
|
||||
"geaendertVonId" TEXT,
|
||||
|
||||
CONSTRAINT "Distribution_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Profil" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"beschreibung" TEXT,
|
||||
"familien" TEXT[],
|
||||
"herkunft" "Herkunft" NOT NULL,
|
||||
"definition" JSONB NOT NULL,
|
||||
"quelltext" TEXT NOT NULL,
|
||||
"dateiHash" TEXT,
|
||||
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||
"entferntAm" TIMESTAMP(3),
|
||||
"synchronisiertAm" TIMESTAMP(3),
|
||||
"geaendertVonId" TEXT,
|
||||
"geaendertAm" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Profil_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -100,3 +100,42 @@ model Server {
|
||||
erstelltAm DateTime @default(now())
|
||||
geaendertAm DateTime @updatedAt
|
||||
}
|
||||
|
||||
enum Herkunft {
|
||||
KATALOG
|
||||
EIGEN
|
||||
}
|
||||
|
||||
/// Spiegel von catalog/distros/*.yaml. Die Datei ist maßgeblich; nur `aktiv` gehört der Datenbank.
|
||||
model Distribution {
|
||||
id String @id
|
||||
name String
|
||||
familie String
|
||||
/// Validierter Inhalt der YAML-Datei.
|
||||
definition Json
|
||||
quelltext String
|
||||
dateiHash String
|
||||
aktiv Boolean @default(true)
|
||||
/// Gesetzt, wenn die Datei aus dem Katalog verschwunden ist – die Zeile bleibt für den Verlauf.
|
||||
entferntAm DateTime?
|
||||
synchronisiertAm DateTime
|
||||
geaendertVonId String?
|
||||
}
|
||||
|
||||
/// Profile aus catalog/profiles/*.yaml (KATALOG, schreibgeschützt) und eigene aus der GUI (EIGEN).
|
||||
model Profil {
|
||||
id String @id
|
||||
name String
|
||||
beschreibung String?
|
||||
familien String[]
|
||||
herkunft Herkunft
|
||||
definition Json
|
||||
/// YAML wie eingegeben – Kommentare bleiben erhalten.
|
||||
quelltext String
|
||||
dateiHash String?
|
||||
aktiv Boolean @default(true)
|
||||
entferntAm DateTime?
|
||||
synchronisiertAm DateTime?
|
||||
geaendertVonId String?
|
||||
geaendertAm DateTime @updatedAt
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { apiAufruf, ApiFehler } from "@/lib/api-client";
|
||||
|
||||
/** Aktiv/inaktiv als echter Schalter (role=switch) mit sichtbarem Zustandstext. */
|
||||
export function AktivSchalter({
|
||||
art,
|
||||
id,
|
||||
aktiv,
|
||||
name,
|
||||
}: {
|
||||
art: "distributionen" | "profile";
|
||||
id: string;
|
||||
aktiv: boolean;
|
||||
name: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [wert, setWert] = useState(aktiv);
|
||||
const [laeuft, setLaeuft] = useState(false);
|
||||
const [fehler, setFehler] = useState<string | null>(null);
|
||||
|
||||
async function umschalten() {
|
||||
setLaeuft(true);
|
||||
setFehler(null);
|
||||
try {
|
||||
await apiAufruf("PATCH", `/api/v1/${art}/${id}`, { aktiv: !wert });
|
||||
setWert(!wert);
|
||||
router.refresh();
|
||||
} catch (ursache) {
|
||||
setFehler(ursache instanceof ApiFehler ? ursache.message : "Fehler");
|
||||
} finally {
|
||||
setLaeuft(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={wert}
|
||||
aria-label={`${name} ${wert ? "deaktivieren" : "aktivieren"}`}
|
||||
disabled={laeuft}
|
||||
onClick={umschalten}
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition ${
|
||||
wert ? "bg-akzent border-akzent" : "bg-panel-hoch border-linie"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`inline-block h-4 w-4 rounded-full transition ${wert ? "bg-akzent-text translate-x-6" : "bg-text translate-x-1"}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-muted w-14 text-sm">{fehler ?? (wert ? "aktiv" : "inaktiv")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Karte } from "@/components/karte";
|
||||
import { Status } from "@/components/status";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||
import { distributionenListe, profileListe } from "@/server/services/katalog";
|
||||
import { AktivSchalter } from "./aktiv-schalter";
|
||||
|
||||
export const metadata: Metadata = { title: "Katalog" };
|
||||
|
||||
export default async function KatalogSeite() {
|
||||
const benutzer = await seiteSchuetzen("lesen", "/katalog");
|
||||
const verwalten = darf(benutzer.rolle, "katalogVerwalten");
|
||||
const [distributionen, profile] = await Promise.all([
|
||||
distributionenListe({ nurAktive: !verwalten }),
|
||||
profileListe({ nurAktive: !verwalten }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Katalog</h1>
|
||||
<p className="text-muted text-sm">
|
||||
Distributionen und Profile kommen aus <span className="font-mono">catalog/*.yaml</span> und werden
|
||||
beim Start abgeglichen.{" "}
|
||||
{verwalten ? "Deaktivierte Einträge erscheinen nicht im Assistenten." : null}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Karte titel="Distributionen">
|
||||
<ul className="divide-linie flex flex-col divide-y">
|
||||
{distributionen.map((d) => (
|
||||
<li
|
||||
key={d.id}
|
||||
className="flex flex-col gap-2 py-3 first:pt-0 last:pb-0 sm:flex-row sm:items-center"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">{d.name}</p>
|
||||
<p className="text-muted font-mono text-xs">
|
||||
{d.id} · Familie {d.familie}
|
||||
{d.definition.vm
|
||||
? ` · Image ${d.definition.vm.format}, Benutzer ${d.definition.vm.default_user}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<Status art={d.vm ? "erledigt" : "offen"}>VM</Status>
|
||||
<Status art={d.lxc ? "erledigt" : "offen"}>LXC</Status>
|
||||
{verwalten ? (
|
||||
<AktivSchalter art="distributionen" id={d.id} aktiv={d.aktiv} name={d.name} />
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Karte>
|
||||
|
||||
<Karte titel="Profile">
|
||||
<ul className="divide-linie flex flex-col divide-y">
|
||||
{profile.map((p) => (
|
||||
<li
|
||||
key={p.id}
|
||||
className="flex flex-col gap-2 py-3 first:pt-0 last:pb-0 sm:flex-row sm:items-center"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">
|
||||
{p.name}{" "}
|
||||
<span className="text-muted font-mono text-xs">
|
||||
{p.id} · {p.herkunft === "KATALOG" ? "Katalog" : "eigenes"}
|
||||
</span>
|
||||
</p>
|
||||
{p.beschreibung ? <p className="text-muted text-sm">{p.beschreibung}</p> : null}
|
||||
<p className="text-muted mt-1 text-xs">
|
||||
Familien: <span className="font-mono">{p.familien.join(", ")}</span>
|
||||
{p.definition.requires.length > 0 ? (
|
||||
<>
|
||||
{" "}
|
||||
· setzt voraus: <span className="font-mono">{p.definition.requires.join(", ")}</span>
|
||||
</>
|
||||
) : null}
|
||||
{p.definition.variables.length > 0 ? (
|
||||
<>
|
||||
{" "}
|
||||
· Variablen:{" "}
|
||||
<span className="font-mono">
|
||||
{p.definition.variables.map((v) => v.name).join(", ")}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
{verwalten ? <AktivSchalter art="profile" id={p.id} aktiv={p.aktiv} name={p.name} /> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Karte>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { aufruferAdresse } from "@/lib/adressen";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { api, jsonLesen } from "@/server/api";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { distributionAktivSetzen } from "@/server/services/katalog";
|
||||
|
||||
type Kontext = { params: Promise<{ id: string }> };
|
||||
|
||||
/** Nur `aktiv` ist änderbar – der Inhalt gehört der YAML-Datei im Katalog. */
|
||||
export const PATCH = api(async (anfrage: Request, { params }: Kontext) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "katalogVerwalten");
|
||||
const { aktiv } = z.object({ aktiv: z.boolean() }).parse(await jsonLesen(anfrage));
|
||||
await distributionAktivSetzen((await params).id, aktiv, aufrufer, aufruferAdresse(anfrage.headers));
|
||||
return NextResponse.json({ ok: true });
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { api } from "@/server/api";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { distributionenListe } from "@/server/services/katalog";
|
||||
|
||||
/**
|
||||
* `?typ=vm|lxc` filtert auf Distributionen, die den Typ können. Deaktivierte sehen nur Admins
|
||||
* (mit `?alle=1`), alle anderen bekommen nur, was ausgerollt werden darf.
|
||||
*/
|
||||
export const GET = api(async (anfrage: Request) => {
|
||||
const { benutzer } = await rechtErforderlich(anfrage, "lesen");
|
||||
const abfrage = new URL(anfrage.url).searchParams;
|
||||
const typ = abfrage.get("typ");
|
||||
const alle = abfrage.get("alle") === "1" && darf(benutzer.rolle, "katalogVerwalten");
|
||||
return NextResponse.json(
|
||||
await distributionenListe({ typ: typ === "vm" || typ === "lxc" ? typ : undefined, nurAktive: !alle }),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { aufruferAdresse } from "@/lib/adressen";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { api, jsonLesen } from "@/server/api";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { profilAktivSetzen } from "@/server/services/katalog";
|
||||
|
||||
type Kontext = { params: Promise<{ id: string }> };
|
||||
|
||||
export const PATCH = api(async (anfrage: Request, { params }: Kontext) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "katalogVerwalten");
|
||||
const { aktiv } = z.object({ aktiv: z.boolean() }).parse(await jsonLesen(anfrage));
|
||||
await profilAktivSetzen((await params).id, aktiv, aufrufer, aufruferAdresse(anfrage.headers));
|
||||
return NextResponse.json({ ok: true });
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { api } from "@/server/api";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { profileListe } from "@/server/services/katalog";
|
||||
|
||||
export const GET = api(async (anfrage: Request) => {
|
||||
const { benutzer } = await rechtErforderlich(anfrage, "lesen");
|
||||
const alle =
|
||||
new URL(anfrage.url).searchParams.get("alle") === "1" && darf(benutzer.rolle, "katalogVerwalten");
|
||||
return NextResponse.json(await profileListe({ nurAktive: !alle }));
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { parse, YAMLParseError } from "yaml";
|
||||
import type { ZodType } from "@/lib/validierung";
|
||||
import { DistributionSchema, ProfilSchema, type Distribution, type Profil } from "./schema";
|
||||
|
||||
/**
|
||||
* Liest `catalog/distros` und `catalog/profiles`. Eine fehlerhafte Datei wird mit Dateiname und
|
||||
* Pfad gemeldet und übersprungen – ein Tippfehler in einem Profil soll nicht den Start verhindern.
|
||||
*/
|
||||
export type KatalogFehler = { datei: string; meldung: string };
|
||||
|
||||
export type Geladen<T> = { eintrag: T; datei: string; quelltext: string; hash: string };
|
||||
|
||||
export type Katalog = {
|
||||
distributionen: Geladen<Distribution>[];
|
||||
profile: Geladen<Profil>[];
|
||||
fehler: KatalogFehler[];
|
||||
};
|
||||
|
||||
/** Prüft YAML-Text gegen ein Schema; liefert Eintrag oder lesbare Fehlerliste. */
|
||||
export function yamlPruefen<T>(
|
||||
quelltext: string,
|
||||
schema: ZodType<T>,
|
||||
): { ok: true; eintrag: T } | { ok: false; fehler: string[] } {
|
||||
let roh: unknown;
|
||||
try {
|
||||
roh = parse(quelltext, { prettyErrors: true });
|
||||
} catch (fehler) {
|
||||
const text = fehler instanceof YAMLParseError ? fehler.message.split("\n")[0]! : String(fehler);
|
||||
return { ok: false, fehler: [`YAML: ${text}`] };
|
||||
}
|
||||
const ergebnis = schema.safeParse(roh);
|
||||
if (ergebnis.success) return { ok: true, eintrag: ergebnis.data };
|
||||
return {
|
||||
ok: false,
|
||||
fehler: ergebnis.error.issues.map(
|
||||
(p) => `${p.path.length > 0 ? p.path.join(".") : "(Wurzel)"}: ${p.message}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function verzeichnisLesen<T extends { id: string }>(
|
||||
verzeichnis: string,
|
||||
schema: ZodType<T>,
|
||||
fehler: KatalogFehler[],
|
||||
): Promise<Geladen<T>[]> {
|
||||
let dateien: string[];
|
||||
try {
|
||||
dateien = (await readdir(verzeichnis)).filter((d) => /\.ya?ml$/.test(d)).sort();
|
||||
} catch {
|
||||
fehler.push({ datei: verzeichnis, meldung: "Verzeichnis fehlt." });
|
||||
return [];
|
||||
}
|
||||
const ergebnis: Geladen<T>[] = [];
|
||||
const gesehen = new Set<string>();
|
||||
for (const datei of dateien) {
|
||||
const quelltext = await readFile(join(verzeichnis, datei), "utf8");
|
||||
const geprueft = yamlPruefen(quelltext, schema);
|
||||
if (!geprueft.ok) {
|
||||
fehler.push({ datei, meldung: geprueft.fehler.join("; ") });
|
||||
continue;
|
||||
}
|
||||
// Dateiname und id sollen übereinstimmen – sonst sucht man ewig, woher ein Eintrag kommt.
|
||||
if (datei.replace(/\.ya?ml$/, "") !== geprueft.eintrag.id) {
|
||||
fehler.push({ datei, meldung: `id „${geprueft.eintrag.id}“ passt nicht zum Dateinamen.` });
|
||||
continue;
|
||||
}
|
||||
if (gesehen.has(geprueft.eintrag.id)) {
|
||||
fehler.push({ datei, meldung: `id „${geprueft.eintrag.id}“ gibt es doppelt.` });
|
||||
continue;
|
||||
}
|
||||
gesehen.add(geprueft.eintrag.id);
|
||||
ergebnis.push({
|
||||
eintrag: geprueft.eintrag,
|
||||
datei,
|
||||
quelltext,
|
||||
hash: createHash("sha256").update(quelltext).digest("hex"),
|
||||
});
|
||||
}
|
||||
return ergebnis;
|
||||
}
|
||||
|
||||
export async function katalogLaden(wurzel: string): Promise<Katalog> {
|
||||
const fehler: KatalogFehler[] = [];
|
||||
const distributionen = await verzeichnisLesen(join(wurzel, "distros"), DistributionSchema, fehler);
|
||||
const profile = await verzeichnisLesen(join(wurzel, "profiles"), ProfilSchema, fehler);
|
||||
|
||||
// Verweise prüfen: requires auf unbekannte Profile ist ein Fehler des verweisenden Profils.
|
||||
const bekannt = new Set(profile.map((p) => p.eintrag.id));
|
||||
const gueltig = profile.filter((p) => {
|
||||
const fehlt = p.eintrag.requires.filter((r) => !bekannt.has(r));
|
||||
if (fehlt.length > 0)
|
||||
fehler.push({
|
||||
datei: p.datei,
|
||||
meldung: `requires verweist auf unbekannte Profile: ${fehlt.join(", ")}`,
|
||||
});
|
||||
return fehlt.length === 0;
|
||||
});
|
||||
return { distributionen, profile: gueltig, fehler };
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Wählt aus der Liste verfügbarer Container-Templates (pveam available bzw. GET aplinfo) das
|
||||
* passende zum Muster des Katalogs. Bei mehreren Treffern gewinnt die höchste Version – für
|
||||
* Debian 13 gab es am 2026-10-12 sowohl 13.1-2 als auch 13.6-1.
|
||||
*/
|
||||
export function musterZuRegex(muster: string): RegExp {
|
||||
const maskiert = muster.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*");
|
||||
return new RegExp(`^${maskiert}$`);
|
||||
}
|
||||
|
||||
/** Vergleicht wie `sort -V`: Zahlenfolgen numerisch, Rest lexikalisch. */
|
||||
export function versionVergleichen(a: string, b: string): number {
|
||||
const teile = (s: string) => s.match(/\d+|\D+/g) ?? [];
|
||||
const x = teile(a);
|
||||
const y = teile(b);
|
||||
for (let i = 0; i < Math.max(x.length, y.length); i += 1) {
|
||||
const p = x[i];
|
||||
const q = y[i];
|
||||
if (p === undefined) return -1;
|
||||
if (q === undefined) return 1;
|
||||
const zahlen = /^\d+$/.test(p) && /^\d+$/.test(q);
|
||||
const unterschied = zahlen ? Number(p) - Number(q) : p.localeCompare(q);
|
||||
if (unterschied !== 0) return unterschied;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function templateWaehlen(muster: string, verfuegbar: readonly string[]): string | null {
|
||||
const regex = musterZuRegex(muster);
|
||||
const treffer = verfuegbar.filter((name) => regex.test(name));
|
||||
if (treffer.length === 0) return null;
|
||||
return [...treffer].sort(versionVergleichen).at(-1)!;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Prüfsummen-Dateien der Cloud-Images. Zwei Zeilenformate kommen vor (geprüft 2026-10-12):
|
||||
* Debian `<hash>␣␣<datei>`, Ubuntu `<hash>␣*<datei>` (Binärmodus). Dazu das BSD-Format
|
||||
* `SHA256 (<datei>) = <hash>`, das manche Spiegel liefern.
|
||||
*/
|
||||
export function pruefsummeFinden(inhalt: string, datei: string, typ: "sha256" | "sha512"): string | null {
|
||||
const laenge = typ === "sha256" ? 64 : 128;
|
||||
for (const zeile of inhalt.split(/\r?\n/)) {
|
||||
const gnu = /^([0-9a-fA-F]+)\s+\*?(.+)$/.exec(zeile.trim());
|
||||
if (gnu && gnu[2] === datei && gnu[1]!.length === laenge) return gnu[1]!.toLowerCase();
|
||||
const bsd = /^(SHA256|SHA512) \((.+)\) = ([0-9a-fA-F]+)$/.exec(zeile.trim());
|
||||
if (bsd && bsd[2] === datei && bsd[3]!.length === laenge) return bsd[3]!.toLowerCase();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Der Dateiname am Ende einer Image-URL. */
|
||||
export function dateiAusUrl(url: string): string {
|
||||
return decodeURIComponent(new URL(url).pathname.split("/").pop() ?? "");
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { z } from "@/lib/validierung";
|
||||
|
||||
/**
|
||||
* Der Katalog ist Daten, kein Code: jede Distribution und jedes Profil ist eine YAML-Datei unter
|
||||
* `catalog/`. Die Schlüssel bleiben englisch wie in der Vorgabe – es ist ein Austauschformat.
|
||||
* Unbekannte Schlüssel sind ein Fehler (`strict`), damit ein Tippfehler nicht still ignoriert wird.
|
||||
*/
|
||||
|
||||
const KENNUNG = /^[a-z0-9][a-z0-9.-]{0,47}$/;
|
||||
const Kennung = z.string().regex(KENNUNG, "Nur Kleinbuchstaben, Ziffern, Punkt und Bindestrich.");
|
||||
|
||||
// ── Distributionen ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const DistributionSchema = z
|
||||
.object({
|
||||
id: Kennung,
|
||||
name: z.string().min(1),
|
||||
family: z.string().regex(/^[a-z][a-z0-9]*$/, "Familie: Kleinbuchstaben, z. B. debian, rhel, alpine."),
|
||||
os: z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
version_id: z.string().min(1),
|
||||
codename: z.string().optional(),
|
||||
})
|
||||
.strict(),
|
||||
arch: z.enum(["amd64", "arm64"]).default("amd64"),
|
||||
vm: z
|
||||
.object({
|
||||
image_url: z.url({ protocol: /^https$/, message: "Nur HTTPS-Quellen." }),
|
||||
checksum_url: z.url({ protocol: /^https$/, message: "Nur HTTPS-Quellen." }),
|
||||
checksum_type: z.enum(["sha256", "sha512"]),
|
||||
format: z.enum(["qcow2", "raw", "vmdk"]),
|
||||
default_user: z.string().min(1),
|
||||
guest_agent_preinstalled: z.boolean().default(false),
|
||||
bios: z.enum(["ovmf", "seabios"]).default("ovmf"),
|
||||
min_disk_gb: z.number().int().min(1).default(4),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
lxc: z
|
||||
.object({
|
||||
template_pattern: z
|
||||
.string()
|
||||
.regex(/^[\w.*-]+\.tar\.(zst|gz|xz)$/, "Muster wie debian-13-standard_*_amd64.tar.zst"),
|
||||
ostype: z.string().min(1),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((d) => d.vm || d.lxc, { message: "Mindestens vm oder lxc angeben." });
|
||||
|
||||
export type Distribution = z.infer<typeof DistributionSchema>;
|
||||
|
||||
// ── Profile ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const VariableSchema = z
|
||||
.object({
|
||||
name: z.string().regex(/^[a-z][a-z0-9_]*$/, "Variablennamen: klein, mit Unterstrich."),
|
||||
label: z.string().min(1),
|
||||
type: z.enum(["string", "number", "boolean", "select"]).default("string"),
|
||||
required: z.boolean().default(false),
|
||||
default: z.union([z.string(), z.number(), z.boolean()]).optional(),
|
||||
options: z.array(z.string()).optional(),
|
||||
help: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((v) => v.type !== "select" || (v.options && v.options.length > 0), {
|
||||
message: "Eine Auswahl braucht options.",
|
||||
});
|
||||
|
||||
export type ProfilVariable = z.infer<typeof VariableSchema>;
|
||||
|
||||
/** Optional an jedem Schritt: nur ausführen, wenn die Variable wahr ist (`if: fail2ban`). */
|
||||
const Bedingung = {
|
||||
if: z
|
||||
.string()
|
||||
.regex(/^!?[a-z][a-z0-9_]*$/)
|
||||
.optional(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Ein Schritt ist deklarativ; der Adapter der Familie übersetzt ihn in Shell. Genau ein
|
||||
* Aktionsschlüssel je Schritt.
|
||||
*/
|
||||
export const SchrittSchema = z.union([
|
||||
z.object({ update: z.literal(true), ...Bedingung }).strict(),
|
||||
z.object({ packages: z.array(z.string().min(1)).min(1), ...Bedingung }).strict(),
|
||||
z
|
||||
.object({
|
||||
service: z
|
||||
.object({
|
||||
name: z.string().min(1),
|
||||
enable: z.boolean().default(true),
|
||||
start: z.boolean().default(true),
|
||||
restart: z.boolean().default(false),
|
||||
})
|
||||
.strict(),
|
||||
...Bedingung,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
repo: z
|
||||
.object({
|
||||
name: z.string().regex(/^[a-z0-9-]+$/),
|
||||
key_url: z.url({ protocol: /^https$/ }),
|
||||
uris: z.string().min(1),
|
||||
suites: z.string().min(1),
|
||||
components: z.string().min(1),
|
||||
})
|
||||
.strict(),
|
||||
...Bedingung,
|
||||
})
|
||||
.strict(),
|
||||
z.object({ repo_package: z.string().min(1), ...Bedingung }).strict(),
|
||||
z
|
||||
.object({
|
||||
file: z
|
||||
.object({
|
||||
path: z.string().regex(/^\/[^\0]+$/, "Absoluter Pfad."),
|
||||
content: z.string(),
|
||||
mode: z
|
||||
.string()
|
||||
.regex(/^[0-7]{3,4}$/)
|
||||
.default("0644"),
|
||||
})
|
||||
.strict(),
|
||||
...Bedingung,
|
||||
})
|
||||
.strict(),
|
||||
z.object({ timezone: z.string().min(1), ...Bedingung }).strict(),
|
||||
z.object({ locale: z.string().min(1), ...Bedingung }).strict(),
|
||||
z.object({ keyboard: z.string().min(1), ...Bedingung }).strict(),
|
||||
z
|
||||
.object({
|
||||
ssh_harden: z
|
||||
.object({ password_auth: z.boolean().default(false), root_login: z.boolean().default(false) })
|
||||
.strict(),
|
||||
...Bedingung,
|
||||
})
|
||||
.strict(),
|
||||
z.object({ guest_agent: z.literal(true), ...Bedingung }).strict(),
|
||||
z.object({ unattended_upgrades: z.boolean(), ...Bedingung }).strict(),
|
||||
z.object({ run: z.string().min(1), ...Bedingung }).strict(),
|
||||
]);
|
||||
|
||||
export type ProfilSchritt = z.infer<typeof SchrittSchema>;
|
||||
|
||||
export const ProfilSchema = z
|
||||
.object({
|
||||
id: Kennung,
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
requires: z.array(Kennung).default([]),
|
||||
variables: z.array(VariableSchema).default([]),
|
||||
lxc: z
|
||||
.object({
|
||||
features: z
|
||||
.object({ nesting: z.boolean().optional(), keyctl: z.boolean().optional() })
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
.default({}),
|
||||
families: z.record(z.string().regex(/^[a-z][a-z0-9]*$/), z.array(SchrittSchema).min(1)),
|
||||
})
|
||||
.strict()
|
||||
.refine((p) => new Set(p.variables.map((v) => v.name)).size === p.variables.length, {
|
||||
message: "Variablennamen müssen eindeutig sein.",
|
||||
path: ["variables"],
|
||||
});
|
||||
|
||||
export type Profil = z.infer<typeof ProfilSchema>;
|
||||
@@ -1,15 +1,21 @@
|
||||
/**
|
||||
* Läuft einmal beim Start des Next-Servers. Prüft die Konfiguration früh und legt den ersten
|
||||
* Administrator an – statt beim ersten Seitenaufruf mit einer unklaren Meldung zu scheitern.
|
||||
* Administrator an, gleicht den Katalog ab – statt beim ersten Seitenaufruf mit einer unklaren Meldung zu scheitern.
|
||||
*/
|
||||
export async function register(): Promise<void> {
|
||||
if (process.env["NEXT_RUNTIME"] !== "nodejs") return;
|
||||
const { schluesselPruefen } = await import("@/lib/geheimnisse");
|
||||
const { ersterAdminFallsLeer } = await import("@/server/services/anmeldung");
|
||||
const { logger } = await import("@/lib/logger");
|
||||
const { katalogSynchronisieren } = await import("@/server/services/katalog");
|
||||
try {
|
||||
schluesselPruefen();
|
||||
await ersterAdminFallsLeer();
|
||||
const sync = await katalogSynchronisieren();
|
||||
logger.info(
|
||||
{ distributionen: sync.distributionen, profile: sync.profile, fehler: sync.fehler.length },
|
||||
"Katalog abgeglichen",
|
||||
);
|
||||
} catch (fehler) {
|
||||
logger.error({ err: fehler }, "Start-Prüfung fehlgeschlagen");
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export const NAVIGATION: readonly NavigationsEintrag[] = [
|
||||
{ pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen", ab: "#006" },
|
||||
{ pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen", ab: "#007" },
|
||||
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen", ab: "#012" },
|
||||
{ pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen", ab: "#004" },
|
||||
{ pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen" },
|
||||
{ pfad: "/server", titel: "Server", symbol: "server", recht: "lesen" },
|
||||
{
|
||||
pfad: "/einstellungen",
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { join } from "node:path";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { katalogLaden, type KatalogFehler } from "@/engine/katalog/laden";
|
||||
import type { Distribution, Profil } from "@/engine/katalog/schema";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import type { Aufrufer } from "@/server/auth/zugang";
|
||||
import { nichtGefunden } from "@/server/fehler";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
|
||||
/** Wo der Katalog liegt: im Repo und im Image unter ./catalog, überschreibbar für Tests. */
|
||||
export function katalogWurzel(): string {
|
||||
return process.env["PROXDEPLOY_KATALOG"] ?? join(process.cwd(), "catalog");
|
||||
}
|
||||
|
||||
export type SyncErgebnis = {
|
||||
distributionen: { neu: number; geaendert: number; entfernt: number };
|
||||
profile: { neu: number; geaendert: number; entfernt: number };
|
||||
fehler: KatalogFehler[];
|
||||
};
|
||||
|
||||
const json = (wert: unknown) => wert as Prisma.InputJsonValue;
|
||||
|
||||
/**
|
||||
* Gleicht catalog/ mit der Datenbank ab. Die Datei ist maßgeblich für den Inhalt, die Datenbank
|
||||
* für `aktiv`. Verschwundene Einträge werden markiert statt gelöscht – Aufträge verweisen darauf.
|
||||
* Idempotent: app und worker dürfen es beide beim Start aufrufen.
|
||||
*/
|
||||
export async function katalogSynchronisieren(wurzel = katalogWurzel()): Promise<SyncErgebnis> {
|
||||
const katalog = await katalogLaden(wurzel);
|
||||
const jetzt = new Date();
|
||||
const ergebnis: SyncErgebnis = {
|
||||
distributionen: { neu: 0, geaendert: 0, entfernt: 0 },
|
||||
profile: { neu: 0, geaendert: 0, entfernt: 0 },
|
||||
fehler: [...katalog.fehler],
|
||||
};
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Gleichzeitiger Start von app und worker: nur einer gleicht ab, der andere wartet.
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext('proxdeploy:katalog'))`;
|
||||
|
||||
const bisherD = new Map((await tx.distribution.findMany()).map((d) => [d.id, d]));
|
||||
for (const { eintrag, quelltext, hash } of katalog.distributionen) {
|
||||
const alt = bisherD.get(eintrag.id);
|
||||
const daten = {
|
||||
name: eintrag.name,
|
||||
familie: eintrag.family,
|
||||
definition: json(eintrag),
|
||||
quelltext,
|
||||
dateiHash: hash,
|
||||
entferntAm: null,
|
||||
synchronisiertAm: jetzt,
|
||||
};
|
||||
if (!alt) {
|
||||
await tx.distribution.create({ data: { id: eintrag.id, ...daten } });
|
||||
ergebnis.distributionen.neu += 1;
|
||||
} else if (alt.dateiHash !== hash || alt.entferntAm) {
|
||||
await tx.distribution.update({ where: { id: eintrag.id }, data: daten });
|
||||
ergebnis.distributionen.geaendert += 1;
|
||||
}
|
||||
bisherD.delete(eintrag.id);
|
||||
}
|
||||
for (const verschwunden of bisherD.values()) {
|
||||
if (verschwunden.entferntAm) continue;
|
||||
await tx.distribution.update({ where: { id: verschwunden.id }, data: { entferntAm: jetzt } });
|
||||
ergebnis.distributionen.entfernt += 1;
|
||||
}
|
||||
|
||||
const bisherP = new Map((await tx.profil.findMany()).map((p) => [p.id, p]));
|
||||
for (const { eintrag, quelltext, hash, datei } of katalog.profile) {
|
||||
const alt = bisherP.get(eintrag.id);
|
||||
if (alt?.herkunft === "EIGEN") {
|
||||
ergebnis.fehler.push({
|
||||
datei,
|
||||
meldung: `Ein eigenes Profil „${eintrag.id}“ gibt es schon – das Katalogprofil wird nicht übernommen.`,
|
||||
});
|
||||
bisherP.delete(eintrag.id);
|
||||
continue;
|
||||
}
|
||||
const daten = {
|
||||
name: eintrag.name,
|
||||
beschreibung: eintrag.description ?? null,
|
||||
familien: Object.keys(eintrag.families),
|
||||
herkunft: "KATALOG" as const,
|
||||
definition: json(eintrag),
|
||||
quelltext,
|
||||
dateiHash: hash,
|
||||
entferntAm: null,
|
||||
synchronisiertAm: jetzt,
|
||||
};
|
||||
if (!alt) {
|
||||
await tx.profil.create({ data: { id: eintrag.id, ...daten } });
|
||||
ergebnis.profile.neu += 1;
|
||||
} else if (alt.dateiHash !== hash || alt.entferntAm) {
|
||||
await tx.profil.update({ where: { id: eintrag.id }, data: daten });
|
||||
ergebnis.profile.geaendert += 1;
|
||||
}
|
||||
bisherP.delete(eintrag.id);
|
||||
}
|
||||
for (const verschwunden of bisherP.values()) {
|
||||
if (verschwunden.herkunft !== "KATALOG" || verschwunden.entferntAm) continue;
|
||||
await tx.profil.update({ where: { id: verschwunden.id }, data: { entferntAm: jetzt } });
|
||||
ergebnis.profile.entfernt += 1;
|
||||
}
|
||||
});
|
||||
|
||||
for (const f of ergebnis.fehler) logger.warn({ datei: f.datei }, `Katalog: ${f.meldung}`);
|
||||
return ergebnis;
|
||||
}
|
||||
|
||||
// ── Lesen ───────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type DistributionSicht = {
|
||||
id: string;
|
||||
name: string;
|
||||
familie: string;
|
||||
aktiv: boolean;
|
||||
vm: boolean;
|
||||
lxc: boolean;
|
||||
definition: Distribution;
|
||||
};
|
||||
|
||||
export async function distributionenListe(filter: { typ?: "vm" | "lxc"; nurAktive?: boolean } = {}) {
|
||||
const zeilen = await prisma.distribution.findMany({
|
||||
where: { entferntAm: null, ...(filter.nurAktive ? { aktiv: true } : {}) },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
return zeilen
|
||||
.map((d): DistributionSicht => {
|
||||
const definition = d.definition as Distribution;
|
||||
return {
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
familie: d.familie,
|
||||
aktiv: d.aktiv,
|
||||
vm: Boolean(definition.vm),
|
||||
lxc: Boolean(definition.lxc),
|
||||
definition,
|
||||
};
|
||||
})
|
||||
.filter((d) => !filter.typ || d[filter.typ]);
|
||||
}
|
||||
|
||||
export type ProfilSicht = {
|
||||
id: string;
|
||||
name: string;
|
||||
beschreibung: string | null;
|
||||
familien: string[];
|
||||
herkunft: "KATALOG" | "EIGEN";
|
||||
aktiv: boolean;
|
||||
definition: Profil;
|
||||
quelltext: string;
|
||||
};
|
||||
|
||||
export async function profileListe(filter: { nurAktive?: boolean } = {}): Promise<ProfilSicht[]> {
|
||||
const zeilen = await prisma.profil.findMany({
|
||||
where: { entferntAm: null, ...(filter.nurAktive ? { aktiv: true } : {}) },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
return zeilen.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
beschreibung: p.beschreibung,
|
||||
familien: p.familien,
|
||||
herkunft: p.herkunft,
|
||||
aktiv: p.aktiv,
|
||||
definition: p.definition as Profil,
|
||||
quelltext: p.quelltext,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function distributionAktivSetzen(
|
||||
id: string,
|
||||
aktiv: boolean,
|
||||
aufrufer: Aufrufer,
|
||||
ip: string | null,
|
||||
): Promise<void> {
|
||||
const vorhanden = await prisma.distribution.findUnique({ where: { id } });
|
||||
if (!vorhanden || vorhanden.entferntAm) throw nichtGefunden(`Die Distribution „${id}“`);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.distribution.update({ where: { id }, data: { aktiv, geaendertVonId: aufrufer.benutzer.id } });
|
||||
await protokollieren(
|
||||
{
|
||||
benutzerId: aufrufer.benutzer.id,
|
||||
quelle: aufrufer.quelle,
|
||||
aktion: aktiv ? "Distribution aktiviert" : "Distribution deaktiviert",
|
||||
ziel: `distribution:${id}`,
|
||||
ip,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function profilAktivSetzen(id: string, aktiv: boolean, aufrufer: Aufrufer, ip: string | null) {
|
||||
const vorhanden = await prisma.profil.findUnique({ where: { id } });
|
||||
if (!vorhanden || vorhanden.entferntAm) throw nichtGefunden(`Das Profil „${id}“`);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.profil.update({ where: { id }, data: { aktiv, geaendertVonId: aufrufer.benutzer.id } });
|
||||
await protokollieren(
|
||||
{
|
||||
benutzerId: aufrufer.benutzer.id,
|
||||
quelle: aufrufer.quelle,
|
||||
aktion: aktiv ? "Profil aktiviert" : "Profil deaktiviert",
|
||||
ziel: `profil:${id}`,
|
||||
ip,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { anmelden, keinQuerscrollen } from "./helfer";
|
||||
|
||||
test("Katalog: Distributionen und Profile sehen, Distribution ab- und wieder einschalten", async ({
|
||||
page,
|
||||
}) => {
|
||||
await anmelden(page, "/katalog");
|
||||
await expect(page.getByRole("heading", { name: "Katalog", level: 1 })).toBeVisible();
|
||||
await expect(page.getByText("Debian 13 „Trixie“")).toBeVisible();
|
||||
await expect(page.getByText("Docker CE")).toBeVisible();
|
||||
await keinQuerscrollen(page);
|
||||
|
||||
const schalter = page.getByRole("switch", { name: /Ubuntu 22\.04 LTS/ });
|
||||
await expect(schalter).toHaveAttribute("aria-checked", "true");
|
||||
await schalter.click();
|
||||
await expect(schalter).toHaveAttribute("aria-checked", "false");
|
||||
await page.reload();
|
||||
await expect(page.getByRole("switch", { name: /Ubuntu 22\.04 LTS/ })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
await page.screenshot({ path: `test-results/katalog-${test.info().project.name}.png`, fullPage: true });
|
||||
await page.getByRole("switch", { name: /Ubuntu 22\.04 LTS/ }).click();
|
||||
await expect(page.getByRole("switch", { name: /Ubuntu 22\.04 LTS/ })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { katalogLaden, yamlPruefen } from "@/engine/katalog/laden";
|
||||
import { templateWaehlen, versionVergleichen } from "@/engine/katalog/lxc-template";
|
||||
import { dateiAusUrl, pruefsummeFinden } from "@/engine/katalog/pruefsumme";
|
||||
import { DistributionSchema, ProfilSchema } from "@/engine/katalog/schema";
|
||||
|
||||
describe("Mitgelieferter Katalog", () => {
|
||||
it("lädt ohne Fehler: fünf Distributionen, drei Profile", async () => {
|
||||
const katalog = await katalogLaden("catalog");
|
||||
expect(katalog.fehler).toEqual([]);
|
||||
expect(katalog.distributionen.map((d) => d.eintrag.id)).toEqual([
|
||||
"debian-12",
|
||||
"debian-13",
|
||||
"ubuntu-22.04",
|
||||
"ubuntu-24.04",
|
||||
"ubuntu-26.04",
|
||||
]);
|
||||
expect(katalog.profile.map((p) => p.eintrag.id)).toEqual(["base", "docker", "zabbix-agent"]);
|
||||
});
|
||||
|
||||
it("jede Distribution kann VM und LXC und gehört zur Familie debian", async () => {
|
||||
const katalog = await katalogLaden("catalog");
|
||||
for (const { eintrag } of katalog.distributionen) {
|
||||
expect(eintrag.family).toBe("debian");
|
||||
expect(eintrag.vm?.guest_agent_preinstalled).toBe(false);
|
||||
expect(eintrag.vm?.bios).toBe("ovmf");
|
||||
expect(dateiAusUrl(eintrag.vm!.image_url)).toMatch(/\.(qcow2|img)$/);
|
||||
expect(eintrag.lxc?.template_pattern).toContain(eintrag.os.version_id);
|
||||
}
|
||||
});
|
||||
|
||||
it("jedes Profil hat eine Umsetzung für debian", async () => {
|
||||
const katalog = await katalogLaden("catalog");
|
||||
for (const { eintrag } of katalog.profile) expect(Object.keys(eintrag.families)).toContain("debian");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Schema", () => {
|
||||
const debian = {
|
||||
id: "debian-13",
|
||||
name: "Debian 13",
|
||||
family: "debian",
|
||||
os: { id: "debian", version_id: "13" },
|
||||
lxc: { template_pattern: "debian-13-standard_*_amd64.tar.zst", ostype: "debian" },
|
||||
};
|
||||
|
||||
it("verlangt vm oder lxc", () => {
|
||||
expect(DistributionSchema.safeParse({ ...debian, lxc: undefined }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("lehnt unbekannte Schlüssel und HTTP-Quellen ab", () => {
|
||||
expect(DistributionSchema.safeParse({ ...debian, imgae_url: "x" }).success).toBe(false);
|
||||
const http = yamlPruefen(
|
||||
`id: x\nname: X\nfamily: debian\nos: { id: debian, version_id: "1" }\nvm: { image_url: "http://a/b.qcow2", checksum_url: "https://a/S", checksum_type: sha256, format: qcow2, default_user: u }`,
|
||||
DistributionSchema,
|
||||
);
|
||||
expect(http.ok).toBe(false);
|
||||
if (!http.ok) expect(http.fehler.join()).toContain("vm.image_url");
|
||||
});
|
||||
|
||||
it("meldet kaputtes YAML mit Zeile", () => {
|
||||
const ergebnis = yamlPruefen("id: [kaputt", DistributionSchema);
|
||||
expect(ergebnis.ok).toBe(false);
|
||||
if (!ergebnis.ok) expect(ergebnis.fehler[0]).toMatch(/^YAML:/);
|
||||
});
|
||||
|
||||
it("Profil: genau eine Aktion je Schritt, Bedingungen, eindeutige Variablen", () => {
|
||||
const basis = { id: "p", name: "P", families: { debian: [{ packages: ["htop"] }] } };
|
||||
expect(ProfilSchema.safeParse(basis).success).toBe(true);
|
||||
expect(
|
||||
ProfilSchema.safeParse({ ...basis, families: { debian: [{ packages: ["a"], update: true }] } }).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
ProfilSchema.safeParse({ ...basis, families: { debian: [{ packages: ["a"], if: "x" }] } }).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
ProfilSchema.safeParse({
|
||||
...basis,
|
||||
variables: [
|
||||
{ name: "a", label: "A" },
|
||||
{ name: "a", label: "B" },
|
||||
],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
ProfilSchema.safeParse({ ...basis, variables: [{ name: "a", label: "A", type: "select" }] }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Katalog laden: Fehler einzeln", () => {
|
||||
function katalog(dateien: Record<string, string>): string {
|
||||
const wurzel = mkdtempSync(join(tmpdir(), "pd-katalog-"));
|
||||
mkdirSync(join(wurzel, "distros"));
|
||||
mkdirSync(join(wurzel, "profiles"));
|
||||
for (const [pfad, inhalt] of Object.entries(dateien)) writeFileSync(join(wurzel, pfad), inhalt);
|
||||
return wurzel;
|
||||
}
|
||||
|
||||
it("überspringt fehlerhafte Dateien, lädt den Rest und nennt Dateiname und Pfad", async () => {
|
||||
const wurzel = katalog({
|
||||
"profiles/gut.yaml": "id: gut\nname: Gut\nfamilies:\n debian:\n - update: true\n",
|
||||
"profiles/kaputt.yaml": "id: kaputt\nname: K\nfamilies:\n debian:\n - pakete: [a]\n",
|
||||
"profiles/anders.yaml": "id: falsch\nname: F\nfamilies:\n debian:\n - update: true\n",
|
||||
"profiles/haengt.yaml":
|
||||
"id: haengt\nname: H\nrequires: [gibtsnicht]\nfamilies:\n debian:\n - update: true\n",
|
||||
});
|
||||
const ergebnis = await katalogLaden(wurzel);
|
||||
expect(ergebnis.profile.map((p) => p.eintrag.id)).toEqual(["gut"]);
|
||||
expect(ergebnis.fehler.map((f) => f.datei).sort()).toEqual(["anders.yaml", "haengt.yaml", "kaputt.yaml"]);
|
||||
expect(ergebnis.fehler.find((f) => f.datei === "anders.yaml")!.meldung).toMatch(/Dateinamen/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Prüfsummen", () => {
|
||||
const sha512 = "a".repeat(128);
|
||||
const sha256 = "b".repeat(64);
|
||||
|
||||
it("liest das Debian-Format (zwei Leerzeichen)", () => {
|
||||
expect(
|
||||
pruefsummeFinden(
|
||||
`${sha512} debian-13-genericcloud-amd64.qcow2\n${"c".repeat(128)} andere.raw`,
|
||||
"debian-13-genericcloud-amd64.qcow2",
|
||||
"sha512",
|
||||
),
|
||||
).toBe(sha512);
|
||||
});
|
||||
|
||||
it("liest das Ubuntu-Format (Binärmodus mit *)", () => {
|
||||
expect(
|
||||
pruefsummeFinden(
|
||||
`${sha256} *noble-server-cloudimg-amd64.img`,
|
||||
"noble-server-cloudimg-amd64.img",
|
||||
"sha256",
|
||||
),
|
||||
).toBe(sha256);
|
||||
});
|
||||
|
||||
it("liest das BSD-Format und ignoriert falsche Längen", () => {
|
||||
expect(pruefsummeFinden(`SHA256 (x.img) = ${sha256.toUpperCase()}`, "x.img", "sha256")).toBe(sha256);
|
||||
expect(pruefsummeFinden(`${sha256} x.img`, "x.img", "sha512")).toBeNull();
|
||||
expect(pruefsummeFinden(`${sha256} y.img`, "x.img", "sha256")).toBeNull();
|
||||
});
|
||||
|
||||
it("nimmt den Dateinamen aus der URL", () => {
|
||||
expect(
|
||||
dateiAusUrl("https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2"),
|
||||
).toBe("debian-13-genericcloud-amd64.qcow2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("LXC-Template wählen", () => {
|
||||
const verfuegbar = [
|
||||
"debian-13-standard_13.1-2_amd64.tar.zst",
|
||||
"debian-13-standard_13.6-1_amd64.tar.zst",
|
||||
"debian-13-standard_13.6-1_arm64.tar.zst",
|
||||
"debian-12-standard_12.12-1_amd64.tar.zst",
|
||||
"debian-12-standard_12.2-1_amd64.tar.zst",
|
||||
];
|
||||
|
||||
it("nimmt bei mehreren Treffern die höchste Version (wie sort -V)", () => {
|
||||
expect(templateWaehlen("debian-13-standard_*_amd64.tar.zst", verfuegbar)).toBe(
|
||||
"debian-13-standard_13.6-1_amd64.tar.zst",
|
||||
);
|
||||
expect(templateWaehlen("debian-12-standard_*_amd64.tar.zst", verfuegbar)).toBe(
|
||||
"debian-12-standard_12.12-1_amd64.tar.zst",
|
||||
);
|
||||
});
|
||||
|
||||
it("findet nichts Falsches", () => {
|
||||
expect(templateWaehlen("ubuntu-24.04-standard_*_amd64.tar.zst", verfuegbar)).toBeNull();
|
||||
expect(versionVergleichen("12.2-1", "12.12-1")).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { cpSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { GET as distributionenGet } from "@/app/api/v1/distributionen/route";
|
||||
import { PATCH as distributionPatch } from "@/app/api/v1/distributionen/[id]/route";
|
||||
import { GET as profileGet } from "@/app/api/v1/profile/route";
|
||||
import { katalogSynchronisieren } from "@/server/services/katalog";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
import { angemeldet, anfrage, kontext } from "./helpers/anfragen";
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Katalog-Abgleich und API", () => {
|
||||
let wurzel: string;
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
wurzel = mkdtempSync(join(tmpdir(), "pd-sync-"));
|
||||
cpSync("catalog", wurzel, { recursive: true });
|
||||
});
|
||||
|
||||
it("legt an, erkennt Änderungen über den Hash und ist idempotent", async () => {
|
||||
const erst = await katalogSynchronisieren(wurzel);
|
||||
expect(erst.distributionen).toEqual({ neu: 5, geaendert: 0, entfernt: 0 });
|
||||
expect(erst.profile).toEqual({ neu: 3, geaendert: 0, entfernt: 0 });
|
||||
|
||||
const nochmal = await katalogSynchronisieren(wurzel);
|
||||
expect(nochmal.distributionen).toEqual({ neu: 0, geaendert: 0, entfernt: 0 });
|
||||
|
||||
writeFileSync(
|
||||
join(wurzel, "distros/debian-12.yaml"),
|
||||
(await import("node:fs"))
|
||||
.readFileSync(join(wurzel, "distros/debian-12.yaml"), "utf8")
|
||||
.replace("Bookworm", "Bookworm (alt)"),
|
||||
);
|
||||
const geaendert = await katalogSynchronisieren(wurzel);
|
||||
expect(geaendert.distributionen.geaendert).toBe(1);
|
||||
expect((await testPrisma.distribution.findUniqueOrThrow({ where: { id: "debian-12" } })).name).toContain(
|
||||
"(alt)",
|
||||
);
|
||||
});
|
||||
|
||||
it("behält „aktiv“ über Änderungen hinweg und markiert entfernte Dateien statt zu löschen", async () => {
|
||||
await katalogSynchronisieren(wurzel);
|
||||
await testPrisma.distribution.update({ where: { id: "ubuntu-22.04" }, data: { aktiv: false } });
|
||||
rmSync(join(wurzel, "distros/ubuntu-22.04.yaml"));
|
||||
const ergebnis = await katalogSynchronisieren(wurzel);
|
||||
expect(ergebnis.distributionen.entfernt).toBe(1);
|
||||
const zeile = await testPrisma.distribution.findUniqueOrThrow({ where: { id: "ubuntu-22.04" } });
|
||||
expect(zeile.entferntAm).not.toBeNull();
|
||||
expect(zeile.aktiv).toBe(false);
|
||||
});
|
||||
|
||||
it("überschreibt kein eigenes Profil mit gleicher id", async () => {
|
||||
await testPrisma.profil.create({
|
||||
data: {
|
||||
id: "docker",
|
||||
name: "Mein Docker",
|
||||
familien: ["debian"],
|
||||
herkunft: "EIGEN",
|
||||
definition: {},
|
||||
quelltext: "x",
|
||||
},
|
||||
});
|
||||
const ergebnis = await katalogSynchronisieren(wurzel);
|
||||
expect(ergebnis.fehler.map((f) => f.meldung).join()).toMatch(/eigenes Profil „docker“/);
|
||||
expect((await testPrisma.profil.findUniqueOrThrow({ where: { id: "docker" } })).name).toBe("Mein Docker");
|
||||
});
|
||||
|
||||
it("API: Operator sieht nur aktive; Admin schaltet ab und sieht mit ?alle=1 alles", async () => {
|
||||
await katalogSynchronisieren(wurzel);
|
||||
const admin = (await angemeldet("ADMIN")).cookie;
|
||||
const operator = (await angemeldet("OPERATOR")).cookie;
|
||||
|
||||
expect(
|
||||
(
|
||||
await distributionPatch(
|
||||
anfrage("PATCH", "/api/v1/distributionen/debian-12", operator, { aktiv: false }),
|
||||
kontext({ id: "debian-12" }),
|
||||
)
|
||||
).status,
|
||||
).toBe(403);
|
||||
expect(
|
||||
(
|
||||
await distributionPatch(
|
||||
anfrage("PATCH", "/api/v1/distributionen/debian-12", admin, { aktiv: false }),
|
||||
kontext({ id: "debian-12" }),
|
||||
)
|
||||
).status,
|
||||
).toBe(200);
|
||||
|
||||
const fuerOperator = await (
|
||||
await distributionenGet(anfrage("GET", "/api/v1/distributionen?typ=vm&alle=1", operator))
|
||||
).json();
|
||||
expect(fuerOperator.map((d: { id: string }) => d.id)).not.toContain("debian-12");
|
||||
const fuerAdmin = await (
|
||||
await distributionenGet(anfrage("GET", "/api/v1/distributionen?alle=1", admin))
|
||||
).json();
|
||||
expect(fuerAdmin.find((d: { id: string }) => d.id === "debian-12").aktiv).toBe(false);
|
||||
|
||||
const profile = await (await profileGet(anfrage("GET", "/api/v1/profile", operator))).json();
|
||||
expect(profile.map((p: { id: string }) => p.id)).toEqual(["base", "docker", "zabbix-agent"]);
|
||||
expect(await testPrisma.protokoll.count({ where: { aktion: "Distribution deaktiviert" } })).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user