feat(vorlagen): Vorlagen (Presets) für Assistent, API und CLI
Gespeicherte Kombinationen aus Distribution, Ressourcen und Profilen. Im Assistenten mit einem Klick vorbelegt oder dort gespeichert; die API nimmt „vorlage“ entgegen (Grundlage für --preset der CLI). Eine Vorlage liegt unter der Anfrage – was die Anfrage selbst sagt, gewinnt. Beim ersten Start wird das Beispiel „Docker-Host klein“ aus der Vorgabe angelegt, einmalig. Closes #012 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,20 +1,23 @@
|
|||||||
# #012 Vorlagen (Presets)
|
# #012 Vorlagen (Presets)
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.3.0
|
**Meilenstein:** v0.3.0
|
||||||
**Labels:** feature
|
**Labels:** feature
|
||||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 11 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Gespeicherte Kombinationen, im Assistenten mit einem Klick vorbelegbar.
|
Gespeicherte Kombinationen, im Assistenten mit einem Klick vorbelegbar.
|
||||||
|
|
||||||
## Aufgaben
|
## 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
|
## Akzeptanzkriterien
|
||||||
- [ ] `--preset` in API und Assistent identisch aufgelöst
|
- [x] `--preset` in API und Assistent identisch aufgelöst
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#008
|
#008
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## 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`).
|
||||||
|
|||||||
@@ -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")
|
||||||
|
);
|
||||||
@@ -277,3 +277,16 @@ model Template {
|
|||||||
@@unique([serverId, vmid])
|
@@unique([serverId, vmid])
|
||||||
@@index([serverId, distributionId])
|
@@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
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Status } from "@/components/status";
|
|||||||
import { TerminalKarte, type TerminalPhase } from "@/components/terminal-karte";
|
import { TerminalKarte, type TerminalPhase } from "@/components/terminal-karte";
|
||||||
import { apiAufruf, ApiFehler } from "@/lib/api-client";
|
import { apiAufruf, ApiFehler } from "@/lib/api-client";
|
||||||
import { GAST_NAME, SSH_SCHLUESSEL, type AusrollEingabe } from "@/lib/schemas/ausrollen";
|
import { GAST_NAME, SSH_SCHLUESSEL, type AusrollEingabe } from "@/lib/schemas/ausrollen";
|
||||||
|
import type { VorlagenParameter } from "@/lib/schemas/vorlage";
|
||||||
|
|
||||||
// ── Daten vom Server ────────────────────────────────────────────────────────────────────────────
|
// ── Daten vom Server ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ export type AssistentDaten = {
|
|||||||
}>;
|
}>;
|
||||||
benutzer: string;
|
benutzer: string;
|
||||||
version: string;
|
version: string;
|
||||||
|
vorlagen: Array<{ name: string; titel: string; beschreibung: string | null; parameter: VorlagenParameter }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Plan = {
|
type Plan = {
|
||||||
@@ -95,6 +97,7 @@ type Werte = {
|
|||||||
profile: string[];
|
profile: string[];
|
||||||
variablen: Record<string, Record<string, string | boolean>>;
|
variablen: Record<string, Record<string, string | boolean>>;
|
||||||
templateBauen: boolean;
|
templateBauen: boolean;
|
||||||
|
vorlage: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SCHRITTE = [
|
const SCHRITTE = [
|
||||||
@@ -163,6 +166,52 @@ function anfrageAus(w: Werte): AusrollEingabe {
|
|||||||
profile: w.profile,
|
profile: w.profile,
|
||||||
variablen: Object.fromEntries(Object.entries(w.variablen).filter(([p]) => w.profile.includes(p))),
|
variablen: Object.fromEntries(Object.entries(w.variablen).filter(([p]) => w.profile.includes(p))),
|
||||||
templateBauen: w.templateBauen,
|
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"],
|
profile: ["base"],
|
||||||
variablen: {},
|
variablen: {},
|
||||||
templateBauen: false,
|
templateBauen: false,
|
||||||
|
vorlage: "",
|
||||||
};
|
};
|
||||||
}, [daten, vorauswahl]);
|
}, [daten, vorauswahl]);
|
||||||
|
|
||||||
const [werte, setWerte] = useState<Werte>(() => ({ ...anfang, ...dekodieren(parameter.get("d")) }));
|
const [werte, setWerte] = useState<Werte>(() => {
|
||||||
|
const ausUrl = { ...anfang, ...dekodieren(parameter.get("d")) };
|
||||||
|
// ?vorlage=<name> 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<Plan | null>(null);
|
const [plan, setPlan] = useState<Plan | null>(null);
|
||||||
const [fehler, setFehler] = useState<{
|
const [fehler, setFehler] = useState<{
|
||||||
text: string;
|
text: string;
|
||||||
@@ -388,6 +456,34 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
|||||||
<div className="flex min-w-0 flex-col gap-6">
|
<div className="flex min-w-0 flex-col gap-6">
|
||||||
{SCHRITTE[schritt] === "Server" ? (
|
{SCHRITTE[schritt] === "Server" ? (
|
||||||
<Karte titel="Server und Node">
|
<Karte titel="Server und Node">
|
||||||
|
{daten.vorlagen.length > 0 ? (
|
||||||
|
<div className="mb-5">
|
||||||
|
<Feld
|
||||||
|
id="vorlage"
|
||||||
|
label="Vorlage"
|
||||||
|
hilfe="Belegt Distribution, Ressourcen und Profile vor – alles bleibt änderbar."
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="vorlage"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={werte.vorlage}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = daten.vorlagen.find((x) => x.name === e.target.value);
|
||||||
|
if (v) setWerte((alt) => vorlageUebernehmen(alt, v.name, v.parameter, daten));
|
||||||
|
else setze({ vorlage: "" });
|
||||||
|
setPlan(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">keine</option>
|
||||||
|
{daten.vorlagen.map((v) => (
|
||||||
|
<option key={v.name} value={v.name}>
|
||||||
|
{v.titel}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Feld>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<Feld id="server" label="Server">
|
<Feld id="server" label="Server">
|
||||||
<select
|
<select
|
||||||
@@ -977,6 +1073,73 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
|||||||
</Knopf>
|
</Knopf>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{!vorlageSpeichern.offen ? (
|
||||||
|
<Knopf
|
||||||
|
variante="sekundaer"
|
||||||
|
className="self-start"
|
||||||
|
onClick={() => setVorlageSpeichern((v) => ({ ...v, offen: true, meldung: null }))}
|
||||||
|
>
|
||||||
|
Als Vorlage speichern
|
||||||
|
</Knopf>
|
||||||
|
) : (
|
||||||
|
<Karte titel="Als Vorlage speichern">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-[1fr_1fr_auto] sm:items-end">
|
||||||
|
<Feld id="vorlage-name" label="Kurzname (für --preset)">
|
||||||
|
<input
|
||||||
|
id="vorlage-name"
|
||||||
|
className={`${EINGABE_KLASSE} font-mono`}
|
||||||
|
value={vorlageSpeichern.name}
|
||||||
|
onChange={(e) => setVorlageSpeichern((v) => ({ ...v, name: e.target.value }))}
|
||||||
|
placeholder="docker-host-klein"
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Feld id="vorlage-titel" label="Titel">
|
||||||
|
<input
|
||||||
|
id="vorlage-titel"
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={vorlageSpeichern.titel}
|
||||||
|
onChange={(e) => setVorlageSpeichern((v) => ({ ...v, titel: e.target.value }))}
|
||||||
|
placeholder="Docker-Host klein"
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Knopf
|
||||||
|
onClick={async () => {
|
||||||
|
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
|
||||||
|
</Knopf>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted mt-3 text-sm">
|
||||||
|
Name, VMID, IP und SSH-Schlüssel gehören nicht in eine Vorlage.
|
||||||
|
</p>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
{vorlageSpeichern.meldung ? (
|
||||||
|
<Status art={vorlageSpeichern.meldung.startsWith("Vorlage") ? "erledigt" : "fehler"}>
|
||||||
|
{vorlageSpeichern.meldung}
|
||||||
|
</Status>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { aktiverServer, SERVER_COOKIE } from "@/lib/server-auswahl";
|
|||||||
import { versionText } from "@/lib/version";
|
import { versionText } from "@/lib/version";
|
||||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||||
import { distributionenListe, profileListe } from "@/server/services/katalog";
|
import { distributionenListe, profileListe } from "@/server/services/katalog";
|
||||||
|
import { vorlagenListe } from "@/server/services/vorlagen";
|
||||||
import { Assistent, type AssistentDaten } from "./assistent";
|
import { Assistent, type AssistentDaten } from "./assistent";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Ausrollen" };
|
export const metadata: Metadata = { title: "Ausrollen" };
|
||||||
@@ -73,6 +74,7 @@ export default async function Ausrollen() {
|
|||||||
})),
|
})),
|
||||||
benutzer: benutzer.benutzername,
|
benutzer: benutzer.benutzername,
|
||||||
version: versionText(),
|
version: versionText(),
|
||||||
|
vorlagen: await vorlagenListe(),
|
||||||
};
|
};
|
||||||
const vorauswahl = aktiverServer(
|
const vorauswahl = aktiverServer(
|
||||||
daten.server.map((s) => s.name),
|
daten.server.map((s) => s.name),
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Vorlagen</h1>
|
||||||
|
<p className="text-muted text-sm">
|
||||||
|
Gespeicherte Kombinationen für den Assistenten und die CLI (
|
||||||
|
<span className="font-mono">--preset</span>). Anlegen: im Assistenten auf der Zusammenfassung „Als
|
||||||
|
Vorlage speichern“.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
{liste.length === 0 ? (
|
||||||
|
<Karte>
|
||||||
|
<Status art="offen">Noch keine Vorlage.</Status>
|
||||||
|
</Karte>
|
||||||
|
) : (
|
||||||
|
<ul className="grid gap-4 md:grid-cols-2">
|
||||||
|
{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 (
|
||||||
|
<li key={v.name} className="bg-panel border-linie rounded-karte flex flex-col gap-3 border p-5">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">{v.titel}</p>
|
||||||
|
<p className="text-muted font-mono text-xs">--preset {v.name}</p>
|
||||||
|
</div>
|
||||||
|
{v.beschreibung ? <p className="text-muted text-sm">{v.beschreibung}</p> : null}
|
||||||
|
<p className="font-mono text-xs">{eckdaten.join(" · ")}</p>
|
||||||
|
{p.profile && p.profile.length > 0 ? (
|
||||||
|
<p className="text-muted text-xs">Profile: {p.profile.join(", ")}</p>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-auto flex flex-wrap items-center gap-3 pt-2">
|
||||||
|
<Link
|
||||||
|
href={`/ausrollen?vorlage=${v.name}`}
|
||||||
|
className="bg-akzent text-akzent-text inline-flex h-11 items-center rounded-lg px-4 text-sm font-semibold sm:h-10"
|
||||||
|
>
|
||||||
|
Ausrollen mit dieser Vorlage
|
||||||
|
</Link>
|
||||||
|
{pflegen ? <VorlageLoeschen name={v.name} /> : null}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Knopf
|
||||||
|
variante="gefahr"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!window.confirm(`Vorlage „${name}“ löschen?`)) return;
|
||||||
|
await apiAufruf("DELETE", `/api/v1/vorlagen/${name}`);
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</Knopf>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -8,10 +8,12 @@ export async function register(): Promise<void> {
|
|||||||
const { ersterAdminFallsLeer } = await import("@/server/services/anmeldung");
|
const { ersterAdminFallsLeer } = await import("@/server/services/anmeldung");
|
||||||
const { logger } = await import("@/lib/logger");
|
const { logger } = await import("@/lib/logger");
|
||||||
const { katalogSynchronisieren } = await import("@/server/services/katalog");
|
const { katalogSynchronisieren } = await import("@/server/services/katalog");
|
||||||
|
const { beispielVorlageAnlegen } = await import("@/server/services/vorlagen");
|
||||||
try {
|
try {
|
||||||
schluesselPruefen();
|
schluesselPruefen();
|
||||||
await ersterAdminFallsLeer();
|
await ersterAdminFallsLeer();
|
||||||
const sync = await katalogSynchronisieren();
|
const sync = await katalogSynchronisieren();
|
||||||
|
await beispielVorlageAnlegen();
|
||||||
logger.info(
|
logger.info(
|
||||||
{ distributionen: sync.distributionen, profile: sync.profile, fehler: sync.fehler.length },
|
{ distributionen: sync.distributionen, profile: sync.profile, fehler: sync.fehler.length },
|
||||||
"Katalog abgeglichen",
|
"Katalog abgeglichen",
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const NAVIGATION: readonly NavigationsEintrag[] = [
|
|||||||
{ pfad: "/ausrollen", titel: "Ausrollen", symbol: "ausrollen", recht: "ausrollen" },
|
{ pfad: "/ausrollen", titel: "Ausrollen", symbol: "ausrollen", recht: "ausrollen" },
|
||||||
{ pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen" },
|
{ pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen" },
|
||||||
{ pfad: "/templates", titel: "Templates", symbol: "templates", 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: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen" },
|
||||||
{ pfad: "/server", titel: "Server", symbol: "server", recht: "lesen" },
|
{ pfad: "/server", titel: "Server", symbol: "server", recht: "lesen" },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ export const AusrollAnfrage = z
|
|||||||
/** Fehlt das Template, vorher bauen (sonst bricht die Prüfung mit Hinweis ab). */
|
/** Fehlt das Template, vorher bauen (sonst bricht die Prüfung mit Hinweis ab). */
|
||||||
templateBauen: z.boolean().default(false),
|
templateBauen: z.boolean().default(false),
|
||||||
startBeiBoot: z.boolean().default(true),
|
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, {
|
.refine((a) => a.typ === "lxc" || a.diskGb >= 4, {
|
||||||
message: "Mindestens 4 GB für eine VM.",
|
message: "Mindestens 4 GB für eine VM.",
|
||||||
@@ -120,6 +122,7 @@ export type AusrollEingabe = z.input<typeof AusrollAnfrage>;
|
|||||||
*/
|
*/
|
||||||
export function cliBefehl(a: AusrollAnfrage): string {
|
export function cliBefehl(a: AusrollAnfrage): string {
|
||||||
const teile = [`proxdeploy ${a.typ} ${a.name}`, `--server ${a.server}`];
|
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}`);
|
if (a.knoten) teile.push(`--node ${a.knoten}`);
|
||||||
teile.push(`--distro ${a.distribution}`);
|
teile.push(`--distro ${a.distribution}`);
|
||||||
if (a.profile.length > 0 && !(a.profile.length === 1 && a.profile[0] === "base"))
|
if (a.profile.length > 0 && !(a.profile.length === 1 && a.profile[0] === "base"))
|
||||||
|
|||||||
@@ -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<typeof VorlagenParameter>;
|
||||||
|
|
||||||
|
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<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const ergebnis: Record<string, unknown> = { ...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<string, object>)[p], ...(wert as Record<string, object>)[p] },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
: { ...basis, ...wert };
|
||||||
|
} else {
|
||||||
|
ergebnis[schluessel] = wert;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ergebnis;
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import { profileAufloesen, type AufgeloestesProfil } from "@/engine/profile/aufl
|
|||||||
import { ProxmoxClient, type Aufruf } from "@/engine/proxmox/client";
|
import { ProxmoxClient, type Aufruf } from "@/engine/proxmox/client";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { AusrollAnfrage, cliBefehl } from "@/lib/schemas/ausrollen";
|
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 type { Aufrufer } from "@/server/auth/zugang";
|
||||||
import { AnwendungsFehler } from "@/server/fehler";
|
import { AnwendungsFehler } from "@/server/fehler";
|
||||||
import { auftragAnlegen } from "@/server/services/auftraege";
|
import { auftragAnlegen } from "@/server/services/auftraege";
|
||||||
@@ -29,7 +31,14 @@ export type Vorbereitet = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function ausrollenVorbereiten(roh: unknown): Promise<Vorbereitet> {
|
export async function ausrollenVorbereiten(roh: unknown): Promise<Vorbereitet> {
|
||||||
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<string, unknown>)
|
||||||
|
: roh;
|
||||||
|
const anfrage = AusrollAnfrage.parse(mitVorlage);
|
||||||
const server = await serverHolen(anfrage.server);
|
const server = await serverHolen(anfrage.server);
|
||||||
const zeile = await prisma.distribution.findUnique({ where: { id: anfrage.distribution } });
|
const zeile = await prisma.distribution.findUnique({ where: { id: anfrage.distribution } });
|
||||||
if (!zeile || zeile.entferntAm || !zeile.aktiv) {
|
if (!zeile || zeile.entferntAm || !zeile.aktiv) {
|
||||||
|
|||||||
@@ -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<VorlageSicht[]> {
|
||||||
|
return (await prisma.vorlage.findMany({ orderBy: { titel: "asc" } })).map(sicht);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function vorlageHolen(name: string): Promise<VorlageSicht> {
|
||||||
|
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<VorlageSicht> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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: {},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user