From acae1bc2a4a8bffaee42ad156816029905b0636b Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 10:11:14 +0200 Subject: [PATCH] feat(katalog): Distributionen und Profile als YAML-Daten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fünf Distributionen (Debian 12/13, Ubuntu 22.04/24.04/26.04) und drei Profile liegen unter catalog/ und werden beim Start abgeglichen. Die Datei ist maßgeblich für den Inhalt, die Datenbank nur für „aktiv“; verschwundene Einträge werden markiert, nicht gelöscht. Unbekannte Schlüssel sind Fehler, damit Tippfehler nicht still verschwinden; eine kaputte Datei wird mit Pfad gemeldet und übersprungen, statt den Start zu verhindern. Prüfsummen-Parser kennt das Debian-, Ubuntu- und BSD-Format; bei mehreren LXC-Templates gewinnt die höchste Version. Closes #004 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + DEVLOG.md | 6 +- catalog/distros/debian-12.yaml | 22 ++ catalog/distros/debian-13.yaml | 22 ++ catalog/distros/ubuntu-22.04.yaml | 22 ++ catalog/distros/ubuntu-24.04.yaml | 22 ++ catalog/distros/ubuntu-26.04.yaml | 22 ++ catalog/profiles/base.yaml | 26 +++ catalog/profiles/docker.yaml | 22 ++ catalog/profiles/zabbix-agent.yaml | 21 ++ docs/issues/004-katalog.md | 23 +- docs/issues/README.md | 2 +- package.json | 1 + pnpm-lock.yaml | 6 +- .../20261012080700_katalog/migration.sql | 37 +++ prisma/schema.prisma | 39 ++++ src/app/(app)/katalog/aktiv-schalter.tsx | 59 +++++ src/app/(app)/katalog/page.tsx | 99 ++++++++ src/app/api/v1/distributionen/[id]/route.ts | 16 ++ src/app/api/v1/distributionen/route.ts | 19 ++ src/app/api/v1/profile/[id]/route.ts | 15 ++ src/app/api/v1/profile/route.ts | 12 + src/engine/katalog/laden.ts | 102 +++++++++ src/engine/katalog/lxc-template.ts | 33 +++ src/engine/katalog/pruefsumme.ts | 20 ++ src/engine/katalog/schema.ts | 174 +++++++++++++++ src/instrumentation.ts | 8 +- src/lib/navigation.ts | 2 +- src/server/services/katalog.ts | 211 ++++++++++++++++++ tests/e2e/katalog.spec.ts | 28 +++ tests/engine/katalog.test.ts | 177 +++++++++++++++ tests/katalog-sync.test.ts | 103 +++++++++ 32 files changed, 1359 insertions(+), 15 deletions(-) create mode 100644 catalog/distros/debian-12.yaml create mode 100644 catalog/distros/debian-13.yaml create mode 100644 catalog/distros/ubuntu-22.04.yaml create mode 100644 catalog/distros/ubuntu-24.04.yaml create mode 100644 catalog/distros/ubuntu-26.04.yaml create mode 100644 catalog/profiles/base.yaml create mode 100644 catalog/profiles/docker.yaml create mode 100644 catalog/profiles/zabbix-agent.yaml create mode 100644 prisma/migrations/20261012080700_katalog/migration.sql create mode 100644 src/app/(app)/katalog/aktiv-schalter.tsx create mode 100644 src/app/(app)/katalog/page.tsx create mode 100644 src/app/api/v1/distributionen/[id]/route.ts create mode 100644 src/app/api/v1/distributionen/route.ts create mode 100644 src/app/api/v1/profile/[id]/route.ts create mode 100644 src/app/api/v1/profile/route.ts create mode 100644 src/engine/katalog/laden.ts create mode 100644 src/engine/katalog/lxc-template.ts create mode 100644 src/engine/katalog/pruefsumme.ts create mode 100644 src/engine/katalog/schema.ts create mode 100644 src/server/services/katalog.ts create mode 100644 tests/e2e/katalog.spec.ts create mode 100644 tests/engine/katalog.test.ts create mode 100644 tests/katalog-sync.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 74a21b8..2c1f6a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/DEVLOG.md b/DEVLOG.md index f1912cc..7888868 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -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) diff --git a/catalog/distros/debian-12.yaml b/catalog/distros/debian-12.yaml new file mode 100644 index 0000000..55c9c93 --- /dev/null +++ b/catalog/distros/debian-12.yaml @@ -0,0 +1,22 @@ +# Geprüft am 2026-10-12: Image, SHA512SUMS (Zeilenformat " ") 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 diff --git a/catalog/distros/debian-13.yaml b/catalog/distros/debian-13.yaml new file mode 100644 index 0000000..67c0f00 --- /dev/null +++ b/catalog/distros/debian-13.yaml @@ -0,0 +1,22 @@ +# Geprüft am 2026-10-12: Image, SHA512SUMS (Zeilenformat " ") 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 diff --git a/catalog/distros/ubuntu-22.04.yaml b/catalog/distros/ubuntu-22.04.yaml new file mode 100644 index 0000000..abd129b --- /dev/null +++ b/catalog/distros/ubuntu-22.04.yaml @@ -0,0 +1,22 @@ +# Geprüft am 2026-10-12: Image (.img ist qcow2), SHA256SUMS (Zeilenformat " *") +# 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 diff --git a/catalog/distros/ubuntu-24.04.yaml b/catalog/distros/ubuntu-24.04.yaml new file mode 100644 index 0000000..96b6fb8 --- /dev/null +++ b/catalog/distros/ubuntu-24.04.yaml @@ -0,0 +1,22 @@ +# Geprüft am 2026-10-12: Image (.img ist qcow2), SHA256SUMS (Zeilenformat " *") +# 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 diff --git a/catalog/distros/ubuntu-26.04.yaml b/catalog/distros/ubuntu-26.04.yaml new file mode 100644 index 0000000..de73ce8 --- /dev/null +++ b/catalog/distros/ubuntu-26.04.yaml @@ -0,0 +1,22 @@ +# Geprüft am 2026-10-12: Image (.img ist qcow2), SHA256SUMS (Zeilenformat " *") +# 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 diff --git a/catalog/profiles/base.yaml b/catalog/profiles/base.yaml new file mode 100644 index 0000000..0a4f358 --- /dev/null +++ b/catalog/profiles/base.yaml @@ -0,0 +1,26 @@ +# Grundausstattung für jeden Gast. Platzhalter: {{ var. }} 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 diff --git a/catalog/profiles/docker.yaml b/catalog/profiles/docker.yaml new file mode 100644 index 0000000..8171627 --- /dev/null +++ b/catalog/profiles/docker.yaml @@ -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 diff --git a/catalog/profiles/zabbix-agent.yaml b/catalog/profiles/zabbix-agent.yaml new file mode 100644 index 0000000..c720755 --- /dev/null +++ b/catalog/profiles/zabbix-agent.yaml @@ -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 } diff --git a/docs/issues/004-katalog.md b/docs/issues/004-katalog.md index 0fc61d5..d748286 100644 --- a/docs/issues/004-katalog.md +++ b/docs/issues/004-katalog.md @@ -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. diff --git a/docs/issues/README.md b/docs/issues/README.md index 75a0e51..cc1efdc 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -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 | – | diff --git a/package.json b/package.json index a7455fe..53ee96b 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11255ad..3574d7d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/prisma/migrations/20261012080700_katalog/migration.sql b/prisma/migrations/20261012080700_katalog/migration.sql new file mode 100644 index 0000000..ed27561 --- /dev/null +++ b/prisma/migrations/20261012080700_katalog/migration.sql @@ -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") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4bab628..3c76596 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 +} diff --git a/src/app/(app)/katalog/aktiv-schalter.tsx b/src/app/(app)/katalog/aktiv-schalter.tsx new file mode 100644 index 0000000..2312723 --- /dev/null +++ b/src/app/(app)/katalog/aktiv-schalter.tsx @@ -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(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 ( +
+ + {fehler ?? (wert ? "aktiv" : "inaktiv")} +
+ ); +} diff --git a/src/app/(app)/katalog/page.tsx b/src/app/(app)/katalog/page.tsx new file mode 100644 index 0000000..057b32b --- /dev/null +++ b/src/app/(app)/katalog/page.tsx @@ -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 ( +
+
+

Katalog

+

+ Distributionen und Profile kommen aus catalog/*.yaml und werden + beim Start abgeglichen.{" "} + {verwalten ? "Deaktivierte Einträge erscheinen nicht im Assistenten." : null} +

+
+ + +
    + {distributionen.map((d) => ( +
  • +
    +

    {d.name}

    +

    + {d.id} · Familie {d.familie} + {d.definition.vm + ? ` · Image ${d.definition.vm.format}, Benutzer ${d.definition.vm.default_user}` + : ""} +

    +
    +
    + VM + LXC + {verwalten ? ( + + ) : null} +
    +
  • + ))} +
+
+ + +
    + {profile.map((p) => ( +
  • +
    +

    + {p.name}{" "} + + {p.id} · {p.herkunft === "KATALOG" ? "Katalog" : "eigenes"} + +

    + {p.beschreibung ?

    {p.beschreibung}

    : null} +

    + Familien: {p.familien.join(", ")} + {p.definition.requires.length > 0 ? ( + <> + {" "} + · setzt voraus: {p.definition.requires.join(", ")} + + ) : null} + {p.definition.variables.length > 0 ? ( + <> + {" "} + · Variablen:{" "} + + {p.definition.variables.map((v) => v.name).join(", ")} + + + ) : null} +

    +
    + {verwalten ? : null} +
  • + ))} +
+
+
+ ); +} diff --git a/src/app/api/v1/distributionen/[id]/route.ts b/src/app/api/v1/distributionen/[id]/route.ts new file mode 100644 index 0000000..b48afaa --- /dev/null +++ b/src/app/api/v1/distributionen/[id]/route.ts @@ -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 }); +}); diff --git a/src/app/api/v1/distributionen/route.ts b/src/app/api/v1/distributionen/route.ts new file mode 100644 index 0000000..ba1ac12 --- /dev/null +++ b/src/app/api/v1/distributionen/route.ts @@ -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 }), + ); +}); diff --git a/src/app/api/v1/profile/[id]/route.ts b/src/app/api/v1/profile/[id]/route.ts new file mode 100644 index 0000000..88ef099 --- /dev/null +++ b/src/app/api/v1/profile/[id]/route.ts @@ -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 }); +}); diff --git a/src/app/api/v1/profile/route.ts b/src/app/api/v1/profile/route.ts new file mode 100644 index 0000000..5752120 --- /dev/null +++ b/src/app/api/v1/profile/route.ts @@ -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 })); +}); diff --git a/src/engine/katalog/laden.ts b/src/engine/katalog/laden.ts new file mode 100644 index 0000000..826674e --- /dev/null +++ b/src/engine/katalog/laden.ts @@ -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 = { eintrag: T; datei: string; quelltext: string; hash: string }; + +export type Katalog = { + distributionen: Geladen[]; + profile: Geladen[]; + fehler: KatalogFehler[]; +}; + +/** Prüft YAML-Text gegen ein Schema; liefert Eintrag oder lesbare Fehlerliste. */ +export function yamlPruefen( + quelltext: string, + schema: ZodType, +): { 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( + verzeichnis: string, + schema: ZodType, + fehler: KatalogFehler[], +): Promise[]> { + 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[] = []; + const gesehen = new Set(); + 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 { + 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 }; +} diff --git a/src/engine/katalog/lxc-template.ts b/src/engine/katalog/lxc-template.ts new file mode 100644 index 0000000..a01d7d3 --- /dev/null +++ b/src/engine/katalog/lxc-template.ts @@ -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)!; +} diff --git a/src/engine/katalog/pruefsumme.ts b/src/engine/katalog/pruefsumme.ts new file mode 100644 index 0000000..e6c9ce3 --- /dev/null +++ b/src/engine/katalog/pruefsumme.ts @@ -0,0 +1,20 @@ +/** + * Prüfsummen-Dateien der Cloud-Images. Zwei Zeilenformate kommen vor (geprüft 2026-10-12): + * Debian `␣␣`, Ubuntu `␣*` (Binärmodus). Dazu das BSD-Format + * `SHA256 () = `, 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() ?? ""); +} diff --git a/src/engine/katalog/schema.ts b/src/engine/katalog/schema.ts new file mode 100644 index 0000000..0be73ae --- /dev/null +++ b/src/engine/katalog/schema.ts @@ -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; + +// ── 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; + +/** 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; + +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; diff --git a/src/instrumentation.ts b/src/instrumentation.ts index e1bb780..f8eed48 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -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 { 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"); } diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts index 90f2c70..f1a5b07 100644 --- a/src/lib/navigation.ts +++ b/src/lib/navigation.ts @@ -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", diff --git a/src/server/services/katalog.ts b/src/server/services/katalog.ts new file mode 100644 index 0000000..9545271 --- /dev/null +++ b/src/server/services/katalog.ts @@ -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 { + 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 { + 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 { + 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, + ); + }); +} diff --git a/tests/e2e/katalog.spec.ts b/tests/e2e/katalog.spec.ts new file mode 100644 index 0000000..23b2816 --- /dev/null +++ b/tests/e2e/katalog.spec.ts @@ -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", + ); +}); diff --git a/tests/engine/katalog.test.ts b/tests/engine/katalog.test.ts new file mode 100644 index 0000000..280420a --- /dev/null +++ b/tests/engine/katalog.test.ts @@ -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 { + 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); + }); +}); diff --git a/tests/katalog-sync.test.ts b/tests/katalog-sync.test.ts new file mode 100644 index 0000000..118018c --- /dev/null +++ b/tests/katalog-sync.test.ts @@ -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); + }); +});