2 Commits
Author SHA1 Message Date
florian.wildeandClaude Opus 5.5 acae1bc2a4 feat(katalog): Distributionen und Profile als YAML-Daten
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 <noreply@anthropic.com>
2026-10-12 10:11:14 +02:00
florian.wildeandClaude Opus 5.5 69cf8eb784 docs: README mit Stand, Fähigkeiten und Entwicklung
Ein Zwischenstand bis zum vollständigen README mit Banner und
Screenshots in Etappe 6 (#018).

Refs #018

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:08:42 +02:00
33 changed files with 1428 additions and 15 deletions
+3
View File
@@ -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
+5 -1
View File
@@ -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)
+69
View File
@@ -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.
+22
View File
@@ -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
+22
View File
@@ -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
+22
View File
@@ -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
+22
View File
@@ -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
+22
View File
@@ -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
+26
View File
@@ -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
+22
View File
@@ -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
+21
View File
@@ -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 }
+14 -9
View File
@@ -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.
+1 -1
View File
@@ -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 | – |
+1
View File
@@ -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": {
+4 -2
View File
@@ -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")
);
+39
View File
@@ -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
}
+59
View File
@@ -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>
);
}
+99
View File
@@ -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 });
});
+19
View File
@@ -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 }),
);
});
+15
View File
@@ -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 });
});
+12
View File
@@ -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 }));
});
+102
View File
@@ -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 };
}
+33
View File
@@ -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)!;
}
+20
View File
@@ -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() ?? "");
}
+174
View File
@@ -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>;
+7 -1
View File
@@ -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");
}
+1 -1
View File
@@ -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",
+211
View File
@@ -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,
);
});
}
+28
View File
@@ -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",
);
});
+177
View File
@@ -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);
});
});
+103
View File
@@ -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);
});
});