diff --git a/docs/issues/012-vorlagen.md b/docs/issues/012-vorlagen.md index c9a428a..5241b72 100644 --- a/docs/issues/012-vorlagen.md +++ b/docs/issues/012-vorlagen.md @@ -1,20 +1,23 @@ # #012 Vorlagen (Presets) -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.3.0 **Labels:** feature -**Geschätzt:** 3 h **Tatsächlich:** – +**Geschätzt:** 3 h **Tatsächlich:** 0 h 11 min ## Ziel Gespeicherte Kombinationen, im Assistenten mit einem Klick vorbelegbar. ## Aufgaben -- [ ] CRUD, „aus diesem Auftrag als Vorlage speichern“, Beispiel „Docker-Host klein“ +- [x] CRUD, „Als Vorlage speichern“ im Assistenten, Beispiel „Docker-Host klein“ (einmalig angelegt) +- [x] Vorlagen-Auswahl im Assistenten, `?vorlage=` von der Vorlagenseite, `vorlage` in der API (für `--preset` der CLI) ## Akzeptanzkriterien -- [ ] `--preset` in API und Assistent identisch aufgelöst +- [x] `--preset` in API und Assistent identisch aufgelöst ## Abhängigkeiten #008 ## Notizen / Entscheidungen -– +- Eine Vorlage liegt **unter** der Anfrage: was die Anfrage sagt, gewinnt; netz/features/variablen feldweise, Listen ersetzt. +- Name, VMID, IP und SSH-Schlüssel gehören nicht in eine Vorlage (Schema `strict`). +- Operatoren dürfen Vorlagen pflegen (Recht `vorlagenPflegen`). diff --git a/prisma/migrations/20261012113243_vorlagen/migration.sql b/prisma/migrations/20261012113243_vorlagen/migration.sql new file mode 100644 index 0000000..2b50705 --- /dev/null +++ b/prisma/migrations/20261012113243_vorlagen/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "Vorlage" ( + "name" TEXT NOT NULL, + "titel" TEXT NOT NULL, + "beschreibung" TEXT, + "parameter" JSONB NOT NULL, + "erstelltVonId" TEXT, + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "geaendertAm" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Vorlage_pkey" PRIMARY KEY ("name") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 298b846..a774e0e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -277,3 +277,16 @@ model Template { @@unique([serverId, vmid]) @@index([serverId, distributionId]) } + +/// Vorlage (Preset): eine gespeicherte Kombination, die Assistent, CLI (--preset) und API vorbelegen. +model Vorlage { + /// Kurzname für die CLI: „docker-host-klein“ + name String @id + titel String + beschreibung String? + /// Teilmenge der Ausroll-Anfrage (typ, distribution, cores, speicherMb, diskGb, profile, variablen, tags …). + parameter Json + erstelltVonId String? + erstelltAm DateTime @default(now()) + geaendertAm DateTime @updatedAt +} diff --git a/src/app/(app)/ausrollen/assistent.tsx b/src/app/(app)/ausrollen/assistent.tsx index 1590218..f2919f6 100644 --- a/src/app/(app)/ausrollen/assistent.tsx +++ b/src/app/(app)/ausrollen/assistent.tsx @@ -11,6 +11,7 @@ import { Status } from "@/components/status"; import { TerminalKarte, type TerminalPhase } from "@/components/terminal-karte"; import { apiAufruf, ApiFehler } from "@/lib/api-client"; import { GAST_NAME, SSH_SCHLUESSEL, type AusrollEingabe } from "@/lib/schemas/ausrollen"; +import type { VorlagenParameter } from "@/lib/schemas/vorlage"; // ── Daten vom Server ──────────────────────────────────────────────────────────────────────────── @@ -47,6 +48,7 @@ export type AssistentDaten = { }>; benutzer: string; version: string; + vorlagen: Array<{ name: string; titel: string; beschreibung: string | null; parameter: VorlagenParameter }>; }; type Plan = { @@ -95,6 +97,7 @@ type Werte = { profile: string[]; variablen: Record>; templateBauen: boolean; + vorlage: string; }; const SCHRITTE = [ @@ -163,6 +166,52 @@ function anfrageAus(w: Werte): AusrollEingabe { profile: w.profile, variablen: Object.fromEntries(Object.entries(w.variablen).filter(([p]) => w.profile.includes(p))), templateBauen: w.templateBauen, + vorlage: w.vorlage || undefined, + }; +} + +/** Eine Vorlage in die Formularwerte übernehmen – nur, was sie festlegt. */ +function vorlageUebernehmen(w: Werte, name: string, p: VorlagenParameter, daten: AssistentDaten): Werte { + const s = (zahl: number | undefined, alt: string) => (zahl === undefined ? alt : String(zahl)); + const server = p.server && daten.server.some((x) => x.name === p.server) ? p.server : w.server; + return { + ...w, + vorlage: name, + server, + typ: p.typ ?? w.typ, + distribution: p.distribution ?? w.distribution, + cores: s(p.cores, w.cores), + speicherMb: s(p.speicherMb, w.speicherMb), + diskGb: s(p.diskGb, w.diskGb), + storage: p.storage ?? w.storage, + swapMb: s(p.swapMb, w.swapMb), + unprivilegiert: p.unprivilegiert ?? w.unprivilegiert, + nesting: p.features?.nesting ?? w.nesting, + bridge: p.netz?.bridge ?? w.bridge, + vlan: s(p.netz?.vlan, w.vlan), + profile: p.profile ?? w.profile, + variablen: (p.variablen as Werte["variablen"] | undefined) ?? w.variablen, + tags: p.tags ? p.tags.join(",") : w.tags, + }; +} + +function werteZuVorlage(w: Werte): VorlagenParameter { + const a = anfrageAus(w); + return { + typ: a.typ, + server: a.server, + distribution: a.distribution, + cores: Number(a.cores), + speicherMb: Number(a.speicherMb), + diskGb: Number(a.diskGb), + storage: a.storage, + ...(a.typ === "lxc" + ? { swapMb: Number(a.swapMb), unprivilegiert: a.unprivilegiert, features: a.features } + : {}), + netz: { bridge: a.netz.bridge, ...(a.netz.vlan ? { vlan: Number(a.netz.vlan) } : {}) }, + profile: a.profile, + variablen: a.variablen as VorlagenParameter["variablen"], + tags: a.tags, }; } @@ -210,10 +259,29 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus profile: ["base"], variablen: {}, templateBauen: false, + vorlage: "", }; }, [daten, vorauswahl]); - const [werte, setWerte] = useState(() => ({ ...anfang, ...dekodieren(parameter.get("d")) })); + const [werte, setWerte] = useState(() => { + const ausUrl = { ...anfang, ...dekodieren(parameter.get("d")) }; + // ?vorlage= von der Vorlagenseite: einmal anwenden, danach steht alles in d. + const vorlage = !parameter.get("d") + ? daten.vorlagen.find((v) => v.name === parameter.get("vorlage")) + : undefined; + return vorlage ? vorlageUebernehmen(ausUrl, vorlage.name, vorlage.parameter, daten) : ausUrl; + }); + const [vorlageSpeichern, setVorlageSpeichern] = useState<{ + name: string; + titel: string; + offen: boolean; + meldung: string | null; + }>({ + name: "", + titel: "", + offen: false, + meldung: null, + }); const [plan, setPlan] = useState(null); const [fehler, setFehler] = useState<{ text: string; @@ -388,6 +456,34 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
{SCHRITTE[schritt] === "Server" ? ( + {daten.vorlagen.length > 0 ? ( +
+ + + +
+ ) : null}
setVorlageSpeichern((v) => ({ ...v, name: e.target.value }))} + placeholder="docker-host-klein" + /> + + + setVorlageSpeichern((v) => ({ ...v, titel: e.target.value }))} + placeholder="Docker-Host klein" + /> + + { + try { + await apiAufruf("POST", "/api/v1/vorlagen", { + name: vorlageSpeichern.name, + titel: vorlageSpeichern.titel, + parameter: werteZuVorlage(werte), + }); + setVorlageSpeichern({ + name: "", + titel: "", + offen: false, + meldung: `Vorlage „${vorlageSpeichern.titel}“ gespeichert.`, + }); + } catch (ursache) { + setVorlageSpeichern((v) => ({ + ...v, + meldung: + ursache instanceof ApiFehler ? ursache.message : "Fehler beim Speichern.", + })); + } + }} + > + Speichern + +
+

+ Name, VMID, IP und SSH-Schlüssel gehören nicht in eine Vorlage. +

+
+ )} + {vorlageSpeichern.meldung ? ( + + {vorlageSpeichern.meldung} + + ) : null} +
) : null} diff --git a/src/app/(app)/ausrollen/page.tsx b/src/app/(app)/ausrollen/page.tsx index ab362f8..b405de1 100644 --- a/src/app/(app)/ausrollen/page.tsx +++ b/src/app/(app)/ausrollen/page.tsx @@ -8,6 +8,7 @@ import { aktiverServer, SERVER_COOKIE } from "@/lib/server-auswahl"; import { versionText } from "@/lib/version"; import { seiteSchuetzen } from "@/server/auth/zugang"; import { distributionenListe, profileListe } from "@/server/services/katalog"; +import { vorlagenListe } from "@/server/services/vorlagen"; import { Assistent, type AssistentDaten } from "./assistent"; export const metadata: Metadata = { title: "Ausrollen" }; @@ -73,6 +74,7 @@ export default async function Ausrollen() { })), benutzer: benutzer.benutzername, version: versionText(), + vorlagen: await vorlagenListe(), }; const vorauswahl = aktiverServer( daten.server.map((s) => s.name), diff --git a/src/app/(app)/vorlagen/page.tsx b/src/app/(app)/vorlagen/page.tsx new file mode 100644 index 0000000..72a621a --- /dev/null +++ b/src/app/(app)/vorlagen/page.tsx @@ -0,0 +1,68 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { Karte } from "@/components/karte"; +import { Status } from "@/components/status"; +import { darf } from "@/server/auth/rechte"; +import { seiteSchuetzen } from "@/server/auth/zugang"; +import { vorlagenListe } from "@/server/services/vorlagen"; +import { VorlageLoeschen } from "./vorlage-loeschen"; + +export const metadata: Metadata = { title: "Vorlagen" }; + +export default async function Vorlagen() { + const benutzer = await seiteSchuetzen("lesen", "/vorlagen"); + const liste = await vorlagenListe(); + const pflegen = darf(benutzer.rolle, "vorlagenPflegen"); + return ( +
+
+

Vorlagen

+

+ Gespeicherte Kombinationen für den Assistenten und die CLI ( + --preset). Anlegen: im Assistenten auf der Zusammenfassung „Als + Vorlage speichern“. +

+
+ {liste.length === 0 ? ( + + Noch keine Vorlage. + + ) : ( +
    + {liste.map((v) => { + const p = v.parameter; + const eckdaten = [ + p.typ?.toUpperCase(), + p.distribution, + p.cores ? `${p.cores} CPU` : null, + p.speicherMb ? `${p.speicherMb / 1024} GB RAM` : null, + p.diskGb ? `${p.diskGb} GB Disk` : null, + ].filter(Boolean); + return ( +
  • +
    +

    {v.titel}

    +

    --preset {v.name}

    +
    + {v.beschreibung ?

    {v.beschreibung}

    : null} +

    {eckdaten.join(" · ")}

    + {p.profile && p.profile.length > 0 ? ( +

    Profile: {p.profile.join(", ")}

    + ) : null} +
    + + Ausrollen mit dieser Vorlage + + {pflegen ? : null} +
    +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/src/app/(app)/vorlagen/vorlage-loeschen.tsx b/src/app/(app)/vorlagen/vorlage-loeschen.tsx new file mode 100644 index 0000000..38c4ccf --- /dev/null +++ b/src/app/(app)/vorlagen/vorlage-loeschen.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { Knopf } from "@/components/knopf"; +import { apiAufruf } from "@/lib/api-client"; + +export function VorlageLoeschen({ name }: { name: string }) { + const router = useRouter(); + return ( + { + if (!window.confirm(`Vorlage „${name}“ löschen?`)) return; + await apiAufruf("DELETE", `/api/v1/vorlagen/${name}`); + router.refresh(); + }} + > + Löschen + + ); +} diff --git a/src/app/api/v1/vorlagen/[name]/route.ts b/src/app/api/v1/vorlagen/[name]/route.ts new file mode 100644 index 0000000..8e8ed67 --- /dev/null +++ b/src/app/api/v1/vorlagen/[name]/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { aufruferAdresse } from "@/lib/adressen"; +import { api, jsonLesen } from "@/server/api"; +import { rechtErforderlich } from "@/server/auth/zugang"; +import { vorlageHolen, vorlageLoeschen, vorlageSpeichern } from "@/server/services/vorlagen"; + +type Kontext = { params: Promise<{ name: string }> }; + +export const GET = api(async (anfrage: Request, { params }: Kontext) => { + await rechtErforderlich(anfrage, "lesen"); + return NextResponse.json(await vorlageHolen((await params).name)); +}); + +export const PUT = api(async (anfrage: Request, { params }: Kontext) => { + const aufrufer = await rechtErforderlich(anfrage, "vorlagenPflegen"); + return NextResponse.json( + await vorlageSpeichern( + await jsonLesen(anfrage), + aufrufer, + aufruferAdresse(anfrage.headers), + (await params).name, + ), + ); +}); + +export const DELETE = api(async (anfrage: Request, { params }: Kontext) => { + const aufrufer = await rechtErforderlich(anfrage, "vorlagenPflegen"); + await vorlageLoeschen((await params).name, aufrufer, aufruferAdresse(anfrage.headers)); + return new NextResponse(null, { status: 204 }); +}); diff --git a/src/app/api/v1/vorlagen/route.ts b/src/app/api/v1/vorlagen/route.ts new file mode 100644 index 0000000..43ed690 --- /dev/null +++ b/src/app/api/v1/vorlagen/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { aufruferAdresse } from "@/lib/adressen"; +import { api, jsonLesen } from "@/server/api"; +import { rechtErforderlich } from "@/server/auth/zugang"; +import { vorlageSpeichern, vorlagenListe } from "@/server/services/vorlagen"; + +export const GET = api(async (anfrage: Request) => { + await rechtErforderlich(anfrage, "lesen"); + return NextResponse.json(await vorlagenListe()); +}); + +export const POST = api(async (anfrage: Request) => { + const aufrufer = await rechtErforderlich(anfrage, "vorlagenPflegen"); + return NextResponse.json( + await vorlageSpeichern(await jsonLesen(anfrage), aufrufer, aufruferAdresse(anfrage.headers)), + { status: 201 }, + ); +}); diff --git a/src/instrumentation.ts b/src/instrumentation.ts index f8eed48..e8a3b06 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -8,10 +8,12 @@ export async function register(): Promise { const { ersterAdminFallsLeer } = await import("@/server/services/anmeldung"); const { logger } = await import("@/lib/logger"); const { katalogSynchronisieren } = await import("@/server/services/katalog"); + const { beispielVorlageAnlegen } = await import("@/server/services/vorlagen"); try { schluesselPruefen(); await ersterAdminFallsLeer(); const sync = await katalogSynchronisieren(); + await beispielVorlageAnlegen(); logger.info( { distributionen: sync.distributionen, profile: sync.profile, fehler: sync.fehler.length }, "Katalog abgeglichen", diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts index a14563b..0503a8a 100644 --- a/src/lib/navigation.ts +++ b/src/lib/navigation.ts @@ -25,7 +25,7 @@ export const NAVIGATION: readonly NavigationsEintrag[] = [ { pfad: "/ausrollen", titel: "Ausrollen", symbol: "ausrollen", recht: "ausrollen" }, { pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen" }, { pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen" }, - { pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen", ab: "#012" }, + { pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen" }, { pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen" }, { pfad: "/server", titel: "Server", symbol: "server", recht: "lesen" }, { diff --git a/src/lib/schemas/ausrollen.ts b/src/lib/schemas/ausrollen.ts index 9c3b257..323e016 100644 --- a/src/lib/schemas/ausrollen.ts +++ b/src/lib/schemas/ausrollen.ts @@ -105,6 +105,8 @@ export const AusrollAnfrage = z /** Fehlt das Template, vorher bauen (sonst bricht die Prüfung mit Hinweis ab). */ templateBauen: z.boolean().default(false), startBeiBoot: z.boolean().default(true), + /** Name der Vorlage, die angewendet wurde (nur zur Nachverfolgung; angewendet wird vorher). */ + vorlage: z.string().optional(), }) .refine((a) => a.typ === "lxc" || a.diskGb >= 4, { message: "Mindestens 4 GB für eine VM.", @@ -120,6 +122,7 @@ export type AusrollEingabe = z.input; */ export function cliBefehl(a: AusrollAnfrage): string { const teile = [`proxdeploy ${a.typ} ${a.name}`, `--server ${a.server}`]; + if (a.vorlage) teile.push(`--preset ${a.vorlage}`); if (a.knoten) teile.push(`--node ${a.knoten}`); teile.push(`--distro ${a.distribution}`); if (a.profile.length > 0 && !(a.profile.length === 1 && a.profile[0] === "base")) diff --git a/src/lib/schemas/vorlage.ts b/src/lib/schemas/vorlage.ts new file mode 100644 index 0000000..7e1165e --- /dev/null +++ b/src/lib/schemas/vorlage.ts @@ -0,0 +1,78 @@ +import { z } from "@/lib/validierung"; + +/** + * Was eine Vorlage festlegen darf. Bewusst ohne Name, VMID, IP und Schlüssel – das ist je Gast + * verschieden. Server und Storage dürfen drin stehen, sind aber optional. + */ +export const VorlagenParameter = z + .object({ + typ: z.enum(["vm", "lxc"]).optional(), + server: z.string().trim().optional(), + distribution: z.string().trim().optional(), + cores: z.coerce.number().int().min(1).max(128).optional(), + speicherMb: z.coerce.number().int().min(256).optional(), + diskGb: z.coerce.number().int().min(1).optional(), + storage: z.string().trim().optional(), + swapMb: z.coerce.number().int().min(0).optional(), + unprivilegiert: z.boolean().optional(), + features: z.object({ nesting: z.boolean(), keyctl: z.boolean() }).partial().optional(), + netz: z + .object({ bridge: z.string().trim(), vlan: z.coerce.number().int().min(1).max(4094) }) + .partial() + .optional(), + profile: z.array(z.string()).optional(), + variablen: z + .record(z.string(), z.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))) + .optional(), + tags: z.array(z.string()).optional(), + }) + .strict(); + +export type VorlagenParameter = z.infer; + +export const VorlageEingabe = z.object({ + name: z + .string() + .trim() + .toLowerCase() + .regex(/^[a-z0-9][a-z0-9-]{0,47}$/, "Kurzname: Kleinbuchstaben, Ziffern und Bindestrich."), + titel: z.string().trim().min(1, "Titel fehlt.").max(80), + beschreibung: z.string().trim().max(500).optional(), + parameter: VorlagenParameter, +}); + +/** + * Legt eine Vorlage unter eine Anfrage: was die Anfrage selbst angibt, gewinnt. Objekte (netz, + * features, variablen) werden feldweise gemischt, Listen ersetzt. + */ +export function vorlageAnwenden( + vorlage: VorlagenParameter, + anfrage: Record, +): Record { + const ergebnis: Record = { ...vorlage }; + for (const [schluessel, wert] of Object.entries(anfrage)) { + if (wert === undefined) continue; + const basis = ergebnis[schluessel]; + if ( + basis && + typeof basis === "object" && + !Array.isArray(basis) && + wert && + typeof wert === "object" && + !Array.isArray(wert) + ) { + ergebnis[schluessel] = + schluessel === "variablen" + ? Object.fromEntries( + [...new Set([...Object.keys(basis), ...Object.keys(wert)])].map((p) => [ + p, + { ...(basis as Record)[p], ...(wert as Record)[p] }, + ]), + ) + : { ...basis, ...wert }; + } else { + ergebnis[schluessel] = wert; + } + } + return ergebnis; +} diff --git a/src/server/services/ausrollen.ts b/src/server/services/ausrollen.ts index 407b3af..bc2e9f9 100644 --- a/src/server/services/ausrollen.ts +++ b/src/server/services/ausrollen.ts @@ -8,6 +8,8 @@ import { profileAufloesen, type AufgeloestesProfil } from "@/engine/profile/aufl import { ProxmoxClient, type Aufruf } from "@/engine/proxmox/client"; import { prisma } from "@/lib/db"; import { AusrollAnfrage, cliBefehl } from "@/lib/schemas/ausrollen"; +import { vorlageAnwenden } from "@/lib/schemas/vorlage"; +import { vorlageHolen } from "@/server/services/vorlagen"; import type { Aufrufer } from "@/server/auth/zugang"; import { AnwendungsFehler } from "@/server/fehler"; import { auftragAnlegen } from "@/server/services/auftraege"; @@ -29,7 +31,14 @@ export type Vorbereitet = { }; export async function ausrollenVorbereiten(roh: unknown): Promise { - const anfrage = AusrollAnfrage.parse(roh); + // Eine Vorlage (Preset) liegt unter der Anfrage: was die Anfrage selbst sagt, gewinnt. + const vorlageName = + roh && typeof roh === "object" && "vorlage" in roh ? (roh as { vorlage?: unknown }).vorlage : undefined; + const mitVorlage = + typeof vorlageName === "string" && vorlageName + ? vorlageAnwenden((await vorlageHolen(vorlageName)).parameter, roh as Record) + : roh; + const anfrage = AusrollAnfrage.parse(mitVorlage); const server = await serverHolen(anfrage.server); const zeile = await prisma.distribution.findUnique({ where: { id: anfrage.distribution } }); if (!zeile || zeile.entferntAm || !zeile.aktiv) { diff --git a/src/server/services/vorlagen.ts b/src/server/services/vorlagen.ts new file mode 100644 index 0000000..1ae3aed --- /dev/null +++ b/src/server/services/vorlagen.ts @@ -0,0 +1,128 @@ +import type { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { VorlageEingabe, VorlagenParameter } from "@/lib/schemas/vorlage"; +import type { Aufrufer } from "@/server/auth/zugang"; +import { AnwendungsFehler, nichtGefunden } from "@/server/fehler"; +import { protokollieren } from "@/server/services/protokoll"; + +export type VorlageSicht = { + name: string; + titel: string; + beschreibung: string | null; + parameter: VorlagenParameter; + geaendertAm: string; +}; + +const sicht = (v: { + name: string; + titel: string; + beschreibung: string | null; + parameter: unknown; + geaendertAm: Date; +}): VorlageSicht => ({ + name: v.name, + titel: v.titel, + beschreibung: v.beschreibung, + parameter: v.parameter as VorlagenParameter, + geaendertAm: v.geaendertAm.toISOString(), +}); + +export async function vorlagenListe(): Promise { + return (await prisma.vorlage.findMany({ orderBy: { titel: "asc" } })).map(sicht); +} + +export async function vorlageHolen(name: string): Promise { + const v = await prisma.vorlage.findUnique({ where: { name } }); + if (!v) throw nichtGefunden(`Die Vorlage „${name}“`); + return sicht(v); +} + +export async function vorlageSpeichern( + roh: unknown, + aufrufer: Aufrufer, + ip: string | null, + bisher?: string, +): Promise { + const eingabe = VorlageEingabe.parse(roh); + if (bisher && bisher !== eingabe.name) { + throw new AnwendungsFehler(400, "ungueltig", "Der Kurzname einer Vorlage lässt sich nicht ändern."); + } + const vorhanden = await prisma.vorlage.findUnique({ where: { name: eingabe.name } }); + if (vorhanden && !bisher) + throw new AnwendungsFehler(409, "vergeben", `Die Vorlage „${eingabe.name}“ gibt es schon.`); + if (!vorhanden && bisher) throw nichtGefunden(`Die Vorlage „${bisher}“`); + const daten = { + titel: eingabe.titel, + beschreibung: eingabe.beschreibung ?? null, + parameter: eingabe.parameter as Prisma.InputJsonValue, + }; + const v = await prisma.$transaction(async (tx) => { + const gespeichert = await tx.vorlage.upsert({ + where: { name: eingabe.name }, + create: { name: eingabe.name, ...daten, erstelltVonId: aufrufer.benutzer.id }, + update: daten, + }); + await protokollieren( + { + benutzerId: aufrufer.benutzer.id, + quelle: aufrufer.quelle, + aktion: bisher ? "Vorlage geändert" : "Vorlage angelegt", + ziel: `vorlage:${eingabe.name}`, + ip, + }, + tx, + ); + return gespeichert; + }); + return sicht(v); +} + +export async function vorlageLoeschen(name: string, aufrufer: Aufrufer, ip: string | null): Promise { + await vorlageHolen(name); + await prisma.$transaction(async (tx) => { + await tx.vorlage.delete({ where: { name } }); + await protokollieren( + { + benutzerId: aufrufer.benutzer.id, + quelle: aufrufer.quelle, + aktion: "Vorlage gelöscht", + ziel: `vorlage:${name}`, + ip, + }, + tx, + ); + }); +} + +/** + * Beim ersten Start ein Beispiel aus der Vorgabe: „Docker-Host klein“. Nur einmal – wer es löscht, + * bekommt es nicht zurück (gemerkt in den Einstellungen). + */ +export async function beispielVorlageAnlegen(): Promise { + const merker = "vorlagen.beispielAngelegt"; + if (await prisma.einstellung.findUnique({ where: { schluessel: merker } })) return; + await prisma.$transaction([ + prisma.vorlage.upsert({ + where: { name: "docker-host-klein" }, + create: { + name: "docker-host-klein", + titel: "Docker-Host klein", + beschreibung: "Debian 13 als VM mit 2 CPU, 4 GB RAM, 40 GB Disk – base und docker", + parameter: { + typ: "vm", + distribution: "debian-13", + cores: 2, + speicherMb: 4096, + diskGb: 40, + profile: ["base", "docker"], + }, + }, + update: {}, + }), + prisma.einstellung.upsert({ + where: { schluessel: merker }, + create: { schluessel: merker, wert: true }, + update: {}, + }), + ]); +} diff --git a/tests/e2e/vorlagen.spec.ts b/tests/e2e/vorlagen.spec.ts new file mode 100644 index 0000000..8cba462 --- /dev/null +++ b/tests/e2e/vorlagen.spec.ts @@ -0,0 +1,49 @@ +import { expect, test } from "@playwright/test"; +import { anmelden, E2E_PRAEFIX, keinQuerscrollen } from "./helfer"; + +/** + * Braucht einen Server mit Verbindungstest (der Assistent zeigt sonst nur einen Hinweis). Gibt es + * keinen, wird der Ablauf übersprungen und sagt das. + */ +test("Vorlage im Assistenten anwenden und als neue Vorlage speichern", async ({ page }) => { + const name = `${E2E_PRAEFIX}-${test.info().project.name}`; + await anmelden(page, "/vorlagen"); + await expect(page.getByText("Docker-Host klein")).toBeVisible(); + await keinQuerscrollen(page); + await page.getByRole("link", { name: "Ausrollen mit dieser Vorlage" }).first().click(); + if (await page.getByText("Erst einen Server anlegen").isVisible()) + test.skip(true, "Kein getesteter Server vorhanden"); + await expect(page.getByLabel("Vorlage")).toHaveValue("docker-host-klein"); + + // Vorbelegung: Ressourcen aus der Vorlage + for (let i = 0; i < 3; i++) await page.getByRole("button", { name: "Weiter" }).click(); + await page.getByLabel("Name (Hostname)").fill("pd-vorlage"); + await page.getByRole("button", { name: "Weiter" }).click(); + await expect(page.getByLabel("RAM (MB)")).toHaveValue("4096"); + await expect(page.getByLabel("Disk (GB)")).toHaveValue("40"); + + // Zur Zusammenfassung springen geht nicht ohne Schlüssel – Schritt 7 ausfüllen. + await page.getByRole("button", { name: /07 Zugang/ }).click(); + await page + .getByLabel("Öffentliche SSH-Schlüssel") + .fill("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEFTdkCuqQ6mF7I8QzmkNaQpKj7S/I2+yuxWeYasJjLd e2e@test"); + await page.getByRole("button", { name: "Weiter" }).click(); + await expect(page.getByLabel("Docker CE")).toBeChecked(); + await page.getByRole("button", { name: "Weiter" }).click(); + await expect(page.getByText(/--preset docker-host-klein/)).toBeVisible({ timeout: 30_000 }); + + await page.getByRole("button", { name: "Als Vorlage speichern" }).click(); + await page.getByLabel("Kurzname (für --preset)").fill(name); + await page.getByLabel("Titel").fill(`E2E ${test.info().project.name}`); + await page.getByRole("button", { name: "Speichern" }).click(); + await expect(page.getByText(/gespeichert/)).toBeVisible(); + + await page.goto("/vorlagen"); + await expect(page.getByText(`--preset ${name}`)).toBeVisible(); + page.once("dialog", (d) => void d.accept()); + await page + .locator("li", { hasText: `--preset ${name}` }) + .getByRole("button", { name: "Löschen" }) + .click(); + await expect(page.getByText(`--preset ${name}`)).toHaveCount(0); +}); diff --git a/tests/vorlagen.test.ts b/tests/vorlagen.test.ts new file mode 100644 index 0000000..ffaa9c5 --- /dev/null +++ b/tests/vorlagen.test.ts @@ -0,0 +1,130 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import * as liste from "@/app/api/v1/vorlagen/route"; +import * as einzeln from "@/app/api/v1/vorlagen/[name]/route"; +import { verschluesseln } from "@/lib/geheimnisse"; +import { vorlageAnwenden } from "@/lib/schemas/vorlage"; +import { ausrollenVorbereiten } from "@/server/services/ausrollen"; +import { katalogSynchronisieren } from "@/server/services/katalog"; +import { beispielVorlageAnlegen } from "@/server/services/vorlagen"; +import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; +import { angemeldet, anfrage, kontext } from "./helpers/anfragen"; + +const SCHLUESSEL = + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEFTdkCuqQ6mF7I8QzmkNaQpKj7S/I2+yuxWeYasJjLd florian@test"; + +describe("Vorlage anwenden", () => { + it("Anfrage gewinnt; netz und variablen werden feldweise gemischt, Listen ersetzt", () => { + const ergebnis = vorlageAnwenden( + { + cores: 2, + speicherMb: 4096, + profile: ["base", "docker"], + netz: { bridge: "vmbr1", vlan: 20 }, + variablen: { "zabbix-agent": { server: "zbx" } }, + }, + { + cores: 4, + netz: { modus: "dhcp" }, + profile: ["base"], + variablen: { "zabbix-agent": { server_active: "zbx2" } }, + name: undefined, + }, + ); + expect(ergebnis).toEqual({ + cores: 4, + speicherMb: 4096, + profile: ["base"], + netz: { bridge: "vmbr1", vlan: 20, modus: "dhcp" }, + variablen: { "zabbix-agent": { server: "zbx", server_active: "zbx2" } }, + }); + }); +}); + +describe.runIf(datenbankVerfuegbar)("Vorlagen", () => { + beforeEach(async () => { + await datenbankLeeren(); + await katalogSynchronisieren(); + }); + + it("Beispiel „Docker-Host klein“ wird einmal angelegt und kommt nach dem Löschen nicht wieder", async () => { + await beispielVorlageAnlegen(); + expect( + (await testPrisma.vorlage.findUniqueOrThrow({ where: { name: "docker-host-klein" } })).parameter, + ).toMatchObject({ + distribution: "debian-13", + cores: 2, + speicherMb: 4096, + profile: ["base", "docker"], + }); + await testPrisma.vorlage.delete({ where: { name: "docker-host-klein" } }); + await beispielVorlageAnlegen(); + expect(await testPrisma.vorlage.count()).toBe(0); + }); + + it("CRUD über die API; Operator darf pflegen; Name, IP und Schlüssel gehören nicht hinein", async () => { + const operator = (await angemeldet("OPERATOR")).cookie; + const neu = { + name: "Web-Klein", + titel: "Web klein", + parameter: { typ: "vm", distribution: "debian-13", cores: 1 }, + }; + const angelegt = await liste.POST(anfrage("POST", "/api/v1/vorlagen", operator, neu)); + expect(angelegt.status).toBe(201); + expect((await angelegt.json()).name).toBe("web-klein"); + expect((await liste.POST(anfrage("POST", "/api/v1/vorlagen", operator, neu))).status).toBe(409); + const mitName = await liste.POST( + anfrage("POST", "/api/v1/vorlagen", operator, { ...neu, name: "x", parameter: { name: "web01" } }), + ); + expect(mitName.status).toBe(400); + const geaendert = await einzeln.PUT( + anfrage("PUT", "/api/v1/vorlagen/web-klein", operator, { + ...neu, + titel: "Web, klein", + parameter: { cores: 2 }, + }), + kontext({ name: "web-klein" }), + ); + expect((await geaendert.json()).parameter).toEqual({ cores: 2 }); + expect( + ( + await einzeln.DELETE( + anfrage("DELETE", "/api/v1/vorlagen/web-klein", operator), + kontext({ name: "web-klein" }), + ) + ).status, + ).toBe(204); + expect(await testPrisma.protokoll.count({ where: { ziel: "vorlage:web-klein" } })).toBe(3); + }); + + it("Ausrollen mit vorlage: Werte der Vorlage, Anfrage gewinnt, Name im CLI-Befehl", async () => { + await beispielVorlageAnlegen(); + await testPrisma.server.create({ + data: { + name: "rz1", + titel: "RZ 1", + url: "https://127.0.0.1:8006", + tokenId: "a@pve!b", + tokenGeheimnis: verschluesseln("x"), + fingerabdruck: "AA", + }, + }); + const v = await ausrollenVorbereiten({ + vorlage: "docker-host-klein", + server: "rz1", + name: "web01", + storage: "local-zfs", + speicherMb: 8192, + netz: { bridge: "vmbr0" }, + zugang: { benutzer: "florian", sshSchluessel: [SCHLUESSEL] }, + }); + expect(v.anfrage).toMatchObject({ + typ: "vm", + distribution: "debian-13", + cores: 2, + speicherMb: 8192, + diskGb: 40, + vorlage: "docker-host-klein", + }); + expect(v.profile.map((p) => p.profil.id)).toEqual(["base", "docker"]); + }); +});