feat(katalog): Profil-Editor mit Live-Prüfung und Skriptvorschau
Admins legen eigene Profile als YAML an; geprüft wird beim Tippen gegen dasselbe Schema wie der Katalog, dazu eine Vorschau des erzeugten Skripts. Katalogprofile bleiben schreibgeschützt und lassen sich als Kopie bearbeiten. Ein Profil, das ein anderes voraussetzt, lässt sich nicht löschen. Closes #011 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,22 +1,25 @@
|
||||
# #011 Profile docker und zabbix-agent, Variablen, Editor
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.3.0
|
||||
**Labels:** feature
|
||||
**Geschätzt:** 6 h **Tatsächlich:** –
|
||||
**Geschätzt:** 6 h **Tatsächlich:** 0 h 10 min
|
||||
|
||||
## Ziel
|
||||
Kombinierbare Profile mit Variablen; eigene Profile im YAML-Editor.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] docker (deb822, nesting bei LXC automatisch), zabbix-agent (7.0 LTS, Server/ServerActive)
|
||||
- [ ] Variablen als Formularfelder; Ausgrauen ohne Umsetzung für die Familie
|
||||
- [ ] YAML-Editor mit Validierung und Fehlerstelle
|
||||
- [x] docker (deb822, nesting bei LXC automatisch), zabbix-agent (7.0 LTS, Server/ServerActive)
|
||||
- [x] Variablen als Formularfelder; Ausgrauen ohne Umsetzung für die Familie
|
||||
- [x] YAML-Editor mit Validierung und Fehlerstelle
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] docker run hello-world im ausgerollten Gast erfolgreich
|
||||
- [x] docker hello-world in pd-test01 (VM) und pd-ct01 (LXC); zabbix-agent2 aktiv in pd-test02
|
||||
|
||||
## Abhängigkeiten
|
||||
#005
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
–
|
||||
- Editor prüft beim Tippen (400 ms Pause) gegen dasselbe Schema wie der Katalog und zeigt das erzeugte Skript.
|
||||
- Katalogprofile sind schreibgeschützt; „Als Kopie bearbeiten“ legt `<id>-eigen` an. Eigene Profile mit Katalog-id werden abgewiesen.
|
||||
- Profile anlegen dürfen nur Admins – sie führen Befehle als root aus.
|
||||
- Löschen verweigert, solange ein anderes Profil es per `requires` braucht.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# #022 Templates mit eingebackenen Profilen
|
||||
**Status:** offen
|
||||
**Meilenstein:** v0.3.0
|
||||
**Labels:** feature, proxmox
|
||||
**Geschätzt:** 4 h **Tatsächlich:** –
|
||||
|
||||
## Ziel
|
||||
Beim Template-Bau Profile (z. B. base, docker) gleich mit installieren, damit Deployments schneller
|
||||
fertig sind. Wunsch von Florian am 2026-10-12.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] Template-Bau-Anfrage um `profile` erweitern (nur Profile ohne Pflichtvariablen bzw. mit Standardwerten)
|
||||
- [ ] Nach dem Vorbereitungsstart: VM erneut starten, Profile über den Guest-Agent ausführen (gleicher Code wie beim Deployment)
|
||||
- [ ] Aufräumen vor dem Template: Maschinen-ID leeren, `cloud-init clean`, SSH-Host-Keys löschen, `apt-get clean`, Logdatei entfernen
|
||||
- [ ] Name/Tags: `tpl-debian-13-docker`, Tags `proxdeploy-template;debian-13;profil-docker`
|
||||
- [ ] Ausrollen: passendes Template bevorzugen (eingebackene Profile ⊆ gewählte); eingebackene Profile beim Deploy überspringen, wenn ihre Variablen den Standardwerten entsprechen
|
||||
- [ ] Optional: zusätzlicher Admin-/Notfall-Schlüssel im Template (Standard bleibt: Schlüssel je Deployment, siehe #015)
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] Template debian-13 + docker auf pve-demo; Deploy daraus mit docker deutlich schneller als ohne
|
||||
|
||||
## Abhängigkeiten
|
||||
#007, #008
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- SSH-Schlüssel gehören grundsätzlich nicht ins Template: beim Wechsel müsste jedes Template und jeder Klon angefasst werden. Dafür kommen Standard-Schlüssel je Benutzer/Team (aus #015 vorgezogen).
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -55,7 +56,16 @@ export default async function KatalogSeite() {
|
||||
</ul>
|
||||
</Karte>
|
||||
|
||||
<Karte titel="Profile">
|
||||
<Karte
|
||||
titel="Profile"
|
||||
aktion={
|
||||
verwalten ? (
|
||||
<Link href="/katalog/profile/neu" className="text-akzent text-sm font-semibold">
|
||||
Profil anlegen
|
||||
</Link>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<ul className="divide-linie flex flex-col divide-y">
|
||||
{profile.map((p) => (
|
||||
<li
|
||||
@@ -64,7 +74,9 @@ export default async function KatalogSeite() {
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">
|
||||
{p.name}{" "}
|
||||
<Link href={`/katalog/profile/${p.id}`} className="hover:underline">
|
||||
{p.name}
|
||||
</Link>{" "}
|
||||
<span className="text-muted font-mono text-xs">
|
||||
{p.id} · {p.herkunft === "KATALOG" ? "Katalog" : "eigenes"}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||
import { ProfilEditor } from "../profil-editor";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
||||
return { title: `Profil ${(await params).id}` };
|
||||
}
|
||||
|
||||
/** Katalogprofile nur ansehen (bearbeiten als Kopie), eigene bearbeiten. */
|
||||
export default async function ProfilSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const benutzer = await seiteSchuetzen("lesen", `/katalog/profile/${id}`);
|
||||
const profil = await prisma.profil.findUnique({ where: { id } });
|
||||
if (!profil || profil.entferntAm) notFound();
|
||||
const bearbeitbar = profil.herkunft === "EIGEN" && darf(benutzer.rolle, "katalogVerwalten");
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-muted font-mono text-sm">
|
||||
{profil.id} · {profil.herkunft === "KATALOG" ? "Katalog (schreibgeschützt)" : "eigenes Profil"}
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">{profil.name}</h1>
|
||||
</div>
|
||||
{profil.herkunft === "KATALOG" && darf(benutzer.rolle, "katalogVerwalten") ? (
|
||||
<Link
|
||||
href={`/katalog/profile/neu?von=${profil.id}`}
|
||||
className="border-linie bg-panel-hoch hover:border-muted inline-flex h-11 items-center rounded-lg border px-4 text-sm font-semibold sm:h-10"
|
||||
>
|
||||
Als Kopie bearbeiten
|
||||
</Link>
|
||||
) : null}
|
||||
</header>
|
||||
<ProfilEditor
|
||||
id={bearbeitbar ? profil.id : undefined}
|
||||
anfang={profil.quelltext}
|
||||
schreibgeschuetzt={!bearbeitbar}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Metadata } from "next";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||
import { ProfilEditor } from "../profil-editor";
|
||||
|
||||
export const metadata: Metadata = { title: "Profil anlegen" };
|
||||
|
||||
const VORLAGE = `# Eigenes Profil – Format und Schritte: docs/katalog.md
|
||||
id: mein-profil
|
||||
name: Mein Profil
|
||||
description: Was das Profil tut
|
||||
requires: [base]
|
||||
variables:
|
||||
- { name: beispiel, label: Beispielwert, default: hallo }
|
||||
families:
|
||||
debian:
|
||||
- packages: [htop]
|
||||
- file:
|
||||
path: /etc/motd
|
||||
content: |
|
||||
{{ guest.name }} – {{ var.beispiel }}
|
||||
`;
|
||||
|
||||
/** Neues Profil, leer oder als Kopie (`?von=<id>`). */
|
||||
export default async function ProfilNeu({ searchParams }: { searchParams: Promise<{ von?: string }> }) {
|
||||
await seiteSchuetzen("katalogVerwalten", "/katalog/profile/neu");
|
||||
const von = (await searchParams).von;
|
||||
const quelle = von ? await prisma.profil.findUnique({ where: { id: von } }) : null;
|
||||
const anfang = quelle
|
||||
? quelle.quelltext
|
||||
.replace(/^id:\s*.+$/m, `id: ${quelle.id}-eigen`)
|
||||
.replace(/^name:\s*(.+)$/m, "name: $1 (eigen)")
|
||||
: VORLAGE;
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||
{quelle ? `${quelle.name} als Kopie` : "Profil anlegen"}
|
||||
</h1>
|
||||
<ProfilEditor anfang={anfang} schreibgeschuetzt={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Knopf } from "@/components/knopf";
|
||||
import { Karte } from "@/components/karte";
|
||||
import { Status } from "@/components/status";
|
||||
import { apiAufruf, ApiFehler } from "@/lib/api-client";
|
||||
|
||||
type Pruefung = { ok: true; vorschau: string | null } | { ok: false; fehler: string[] };
|
||||
|
||||
/**
|
||||
* YAML-Editor für Profile mit Prüfung beim Tippen (nach kurzer Pause) und Vorschau des Skripts.
|
||||
* Gespeichert wird nur, was der Server für gültig hält.
|
||||
*/
|
||||
export function ProfilEditor({
|
||||
id,
|
||||
anfang,
|
||||
schreibgeschuetzt,
|
||||
}: {
|
||||
id?: string;
|
||||
anfang: string;
|
||||
schreibgeschuetzt: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [text, setText] = useState(anfang);
|
||||
const [pruefung, setPruefung] = useState<Pruefung | null>(null);
|
||||
const [meldung, setMeldung] = useState<{ text: string; hinweis?: string } | null>(null);
|
||||
const [speichert, setSpeichert] = useState(false);
|
||||
const zaehler = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const nummer = ++zaehler.current;
|
||||
const uhr = setTimeout(async () => {
|
||||
try {
|
||||
const ergebnis = await apiAufruf<Pruefung>("POST", "/api/v1/profile/pruefen", { quelltext: text });
|
||||
// Eine ältere Antwort darf eine neuere nicht überschreiben.
|
||||
if (nummer === zaehler.current) setPruefung(ergebnis);
|
||||
} catch {
|
||||
// Prüfung ist Komfort – beim Speichern prüft der Server ohnehin.
|
||||
}
|
||||
}, 400);
|
||||
return () => clearTimeout(uhr);
|
||||
}, [text]);
|
||||
|
||||
async function speichern() {
|
||||
setSpeichert(true);
|
||||
setMeldung(null);
|
||||
try {
|
||||
const gespeichert = id
|
||||
? await apiAufruf<{ id: string }>("PUT", `/api/v1/profile/${id}`, { quelltext: text })
|
||||
: await apiAufruf<{ id: string }>("POST", "/api/v1/profile", { quelltext: text });
|
||||
router.push(`/katalog/profile/${gespeichert.id}`);
|
||||
router.refresh();
|
||||
} catch (fehler) {
|
||||
setMeldung(
|
||||
fehler instanceof ApiFehler
|
||||
? { text: fehler.message, hinweis: fehler.hinweis }
|
||||
: { text: "Unerwarteter Fehler." },
|
||||
);
|
||||
} finally {
|
||||
setSpeichert(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loeschen() {
|
||||
if (!id || !window.confirm(`Profil „${id}“ wirklich löschen?`)) return;
|
||||
try {
|
||||
await apiAufruf("DELETE", `/api/v1/profile/${id}`);
|
||||
router.push("/katalog");
|
||||
router.refresh();
|
||||
} catch (fehler) {
|
||||
setMeldung(
|
||||
fehler instanceof ApiFehler
|
||||
? { text: fehler.message, hinweis: fehler.hinweis }
|
||||
: { text: "Unerwarteter Fehler." },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Karte titel="YAML">
|
||||
<label htmlFor="quelltext" className="sr-only">
|
||||
Profil als YAML
|
||||
</label>
|
||||
<textarea
|
||||
id="quelltext"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
readOnly={schreibgeschuetzt}
|
||||
spellCheck={false}
|
||||
rows={Math.min(40, Math.max(14, text.split("\n").length + 2))}
|
||||
className="bg-grund border-linie text-text w-full rounded-lg border p-3 font-mono text-xs leading-5 sm:text-sm"
|
||||
aria-describedby="pruefung"
|
||||
/>
|
||||
<div id="pruefung" aria-live="polite" className="mt-3 text-sm">
|
||||
{pruefung === null ? (
|
||||
<Status art="laeuft">Prüft …</Status>
|
||||
) : pruefung.ok ? (
|
||||
<Status art="erledigt">Gültig</Status>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{pruefung.fehler.map((f) => (
|
||||
<li key={f}>
|
||||
<Status art="fehler">{f}</Status>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Karte>
|
||||
|
||||
{pruefung?.ok && pruefung.vorschau ? (
|
||||
<details className="bg-panel border-linie rounded-karte border">
|
||||
<summary className="cursor-pointer px-5 py-3.5 font-semibold">Vorschau: erzeugtes Skript</summary>
|
||||
<pre className="border-linie max-h-[50vh] overflow-auto border-t p-5 font-mono text-xs">
|
||||
{pruefung.vorschau}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
{!schreibgeschuetzt ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div aria-live="polite" className="text-sm">
|
||||
{meldung ? (
|
||||
<div>
|
||||
<Status art="fehler">{meldung.text}</Status>
|
||||
{meldung.hinweis ? <p className="text-muted">{meldung.hinweis}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{id ? (
|
||||
<Knopf variante="gefahr" onClick={loeschen}>
|
||||
Löschen
|
||||
</Knopf>
|
||||
) : null}
|
||||
<Knopf onClick={speichern} disabled={speichert || !pruefung?.ok}>
|
||||
{speichert ? "Speichert …" : "Speichern"}
|
||||
</Knopf>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ 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";
|
||||
import { profilAktivSetzen, profilLoeschen, profilSpeichern } from "@/server/services/katalog";
|
||||
|
||||
type Kontext = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -13,3 +13,19 @@ export const PATCH = api(async (anfrage: Request, { params }: Kontext) => {
|
||||
await profilAktivSetzen((await params).id, aktiv, aufrufer, aufruferAdresse(anfrage.headers));
|
||||
return NextResponse.json({ ok: true });
|
||||
});
|
||||
|
||||
export const PUT = api(async (anfrage: Request, { params }: Kontext) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "katalogVerwalten");
|
||||
const { quelltext } = z
|
||||
.object({ quelltext: z.string().min(1).max(200_000) })
|
||||
.parse(await jsonLesen(anfrage));
|
||||
return NextResponse.json(
|
||||
await profilSpeichern(quelltext, aufrufer, aufruferAdresse(anfrage.headers), (await params).id),
|
||||
);
|
||||
});
|
||||
|
||||
export const DELETE = api(async (anfrage: Request, { params }: Kontext) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "katalogVerwalten");
|
||||
await profilLoeschen((await params).id, aufrufer, aufruferAdresse(anfrage.headers));
|
||||
return new NextResponse(null, { status: 204 });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { api, jsonLesen } from "@/server/api";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { profilPruefen } from "@/server/services/katalog";
|
||||
|
||||
/** Live-Prüfung im Editor: Fehlerliste oder Vorschau des Skripts. Speichert nichts. */
|
||||
export const POST = api(async (anfrage: Request) => {
|
||||
await rechtErforderlich(anfrage, "katalogVerwalten");
|
||||
const { quelltext } = z.object({ quelltext: z.string().max(200_000) }).parse(await jsonLesen(anfrage));
|
||||
return NextResponse.json(await profilPruefen(quelltext));
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { api } from "@/server/api";
|
||||
import { aufruferAdresse } from "@/lib/adressen";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { api, jsonLesen } from "@/server/api";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
import { profileListe } from "@/server/services/katalog";
|
||||
import { profileListe, profilSpeichern } from "@/server/services/katalog";
|
||||
|
||||
export const GET = api(async (anfrage: Request) => {
|
||||
const { benutzer } = await rechtErforderlich(anfrage, "lesen");
|
||||
@@ -10,3 +12,14 @@ export const GET = api(async (anfrage: Request) => {
|
||||
new URL(anfrage.url).searchParams.get("alle") === "1" && darf(benutzer.rolle, "katalogVerwalten");
|
||||
return NextResponse.json(await profileListe({ nurAktive: !alle }));
|
||||
});
|
||||
|
||||
/** Eigenes Profil anlegen (YAML im Feld `quelltext`). Nur Admins – Profile führen Befehle als root aus. */
|
||||
export const POST = api(async (anfrage: Request) => {
|
||||
const aufrufer = await rechtErforderlich(anfrage, "katalogVerwalten");
|
||||
const { quelltext } = z
|
||||
.object({ quelltext: z.string().min(1).max(200_000) })
|
||||
.parse(await jsonLesen(anfrage));
|
||||
return NextResponse.json(await profilSpeichern(quelltext, aufrufer, aufruferAdresse(anfrage.headers)), {
|
||||
status: 201,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { join } from "node:path";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { katalogLaden, type KatalogFehler } from "@/engine/katalog/laden";
|
||||
import { adapterFuer, bekannteFamilien } from "@/engine/adapter";
|
||||
import { katalogLaden, yamlPruefen, type KatalogFehler } from "@/engine/katalog/laden";
|
||||
import { ProfilSchema } from "@/engine/katalog/schema";
|
||||
import { profileAufloesen } from "@/engine/profile/aufloesen";
|
||||
import { profileRendern } from "@/engine/profile/rendern";
|
||||
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 { AnwendungsFehler, 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. */
|
||||
@@ -209,3 +213,160 @@ export async function profilAktivSetzen(id: string, aktiv: boolean, aufrufer: Au
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Eigene Profile (#011) ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ProfilPruefung =
|
||||
{ ok: true; profil: Profil; vorschau: string | null } | { ok: false; fehler: string[] };
|
||||
|
||||
/**
|
||||
* Prüft ein Profil aus dem Editor: YAML, Schema, Voraussetzungen. Mit Vorschau des erzeugten
|
||||
* Skripts für die erste Distribution der Familie, damit man sieht, was im Gast ankommt.
|
||||
*/
|
||||
export async function profilPruefen(quelltext: string): Promise<ProfilPruefung> {
|
||||
const geprueft = yamlPruefen(quelltext, ProfilSchema);
|
||||
if (!geprueft.ok) return { ok: false, fehler: geprueft.fehler };
|
||||
const profil = geprueft.eintrag;
|
||||
const alle = new Map(
|
||||
(await prisma.profil.findMany({ where: { entferntAm: null } })).map((p) => [
|
||||
p.id,
|
||||
p.definition as Profil,
|
||||
]),
|
||||
);
|
||||
alle.set(profil.id, profil);
|
||||
const fehler: string[] = [];
|
||||
for (const familie of Object.keys(profil.families)) {
|
||||
if (!adapterFuer(familie))
|
||||
fehler.push(
|
||||
`families.${familie}: Für diese Familie gibt es keinen Adapter (bekannt: ${bekannteFamilien().join(", ")}).`,
|
||||
);
|
||||
}
|
||||
const familie = Object.keys(profil.families).find((f) => adapterFuer(f));
|
||||
let vorschau: string | null = null;
|
||||
if (familie) {
|
||||
const aufgeloest = profileAufloesen([profil.id], alle, familie, {
|
||||
// Für die Vorschau Pflichtvariablen mit einem Platzhalter füllen.
|
||||
[profil.id]: Object.fromEntries(
|
||||
profil.variables.filter((v) => v.required).map((v) => [v.name, `<${v.name}>`]),
|
||||
),
|
||||
});
|
||||
if (!aufgeloest.ok) {
|
||||
fehler.push(...aufgeloest.fehler.map((f) => `${f.profil}: ${f.meldung}`));
|
||||
} else {
|
||||
const distro = (await distributionenListe({ nurAktive: true })).find((d) => d.familie === familie);
|
||||
try {
|
||||
vorschau =
|
||||
profileRendern(
|
||||
aufgeloest.profile.filter((p) => p.profil.id === profil.id),
|
||||
adapterFuer(familie)!,
|
||||
{
|
||||
os: distro?.definition.os ?? { id: familie, version_id: "0" },
|
||||
gast: { name: "beispiel", benutzer: "benutzer", typ: "vm" },
|
||||
},
|
||||
).abschnitte[0]?.skript ?? null;
|
||||
} catch (ursache) {
|
||||
fehler.push(ursache instanceof Error ? ursache.message : String(ursache));
|
||||
}
|
||||
}
|
||||
}
|
||||
return fehler.length > 0 ? { ok: false, fehler } : { ok: true, profil, vorschau };
|
||||
}
|
||||
|
||||
export async function profilSpeichern(
|
||||
quelltext: string,
|
||||
aufrufer: Aufrufer,
|
||||
ip: string | null,
|
||||
bisherigeId?: string,
|
||||
): Promise<ProfilSicht> {
|
||||
const pruefung = await profilPruefen(quelltext);
|
||||
if (!pruefung.ok)
|
||||
throw new AnwendungsFehler(
|
||||
400,
|
||||
"ungueltig",
|
||||
pruefung.fehler.join(" "),
|
||||
"Fehlerstellen im Editor beheben.",
|
||||
);
|
||||
const { profil } = pruefung;
|
||||
const vorhanden = await prisma.profil.findUnique({ where: { id: profil.id } });
|
||||
if (bisherigeId && bisherigeId !== profil.id) {
|
||||
throw new AnwendungsFehler(
|
||||
400,
|
||||
"ungueltig",
|
||||
"Die id eines Profils lässt sich nicht ändern.",
|
||||
"Als neues Profil mit anderer id speichern.",
|
||||
);
|
||||
}
|
||||
if (vorhanden && (vorhanden.herkunft === "KATALOG" || !bisherigeId) && !vorhanden.entferntAm) {
|
||||
throw new AnwendungsFehler(
|
||||
409,
|
||||
"vergeben",
|
||||
vorhanden.herkunft === "KATALOG"
|
||||
? `„${profil.id}“ ist ein Katalogprofil.`
|
||||
: `Ein Profil „${profil.id}“ gibt es schon.`,
|
||||
vorhanden.herkunft === "KATALOG"
|
||||
? "Für eine eigene Fassung eine andere id wählen, z. B. docker-eigen."
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
const daten = {
|
||||
name: profil.name,
|
||||
beschreibung: profil.description ?? null,
|
||||
familien: Object.keys(profil.families),
|
||||
herkunft: "EIGEN" as const,
|
||||
definition: profil as unknown as Prisma.InputJsonValue,
|
||||
quelltext,
|
||||
entferntAm: null,
|
||||
geaendertVonId: aufrufer.benutzer.id,
|
||||
};
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.profil.upsert({ where: { id: profil.id }, create: { id: profil.id, ...daten }, update: daten });
|
||||
await protokollieren(
|
||||
{
|
||||
benutzerId: aufrufer.benutzer.id,
|
||||
quelle: aufrufer.quelle,
|
||||
aktion: bisherigeId ? "Profil geändert" : "Profil angelegt",
|
||||
ziel: `profil:${profil.id}`,
|
||||
ip,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
return (await profileListe()).find((p) => p.id === profil.id)!;
|
||||
}
|
||||
|
||||
export async function profilLoeschen(id: string, aufrufer: Aufrufer, ip: string | null): Promise<void> {
|
||||
const profil = await prisma.profil.findUnique({ where: { id } });
|
||||
if (!profil || profil.entferntAm) throw nichtGefunden(`Das Profil „${id}“`);
|
||||
if (profil.herkunft === "KATALOG") {
|
||||
throw new AnwendungsFehler(
|
||||
409,
|
||||
"katalog",
|
||||
"Katalogprofile lassen sich nicht löschen.",
|
||||
"Deaktivieren oder die YAML-Datei aus catalog/profiles entfernen.",
|
||||
);
|
||||
}
|
||||
const abhaengig = (await prisma.profil.findMany({ where: { entferntAm: null } })).filter((p) =>
|
||||
(p.definition as Profil).requires?.includes(id),
|
||||
);
|
||||
if (abhaengig.length > 0) {
|
||||
throw new AnwendungsFehler(
|
||||
409,
|
||||
"benutzt",
|
||||
`${abhaengig.map((p) => p.id).join(", ")} setzt „${id}“ voraus.`,
|
||||
);
|
||||
}
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Aufträge nennen Profile nur beim Namen – löschen ist sicher, der Verlauf bleibt lesbar.
|
||||
await tx.profil.delete({ where: { id } });
|
||||
await protokollieren(
|
||||
{
|
||||
benutzerId: aufrufer.benutzer.id,
|
||||
quelle: aufrufer.quelle,
|
||||
aktion: "Profil gelöscht",
|
||||
ziel: `profil:${id}`,
|
||||
ip,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { anmelden, E2E_PRAEFIX, keinQuerscrollen } from "./helfer";
|
||||
|
||||
test("Profil-Editor: Kopie eines Katalogprofils, Live-Prüfung, speichern, löschen", async ({ page }) => {
|
||||
const id = `${E2E_PRAEFIX}-${test.info().project.name}`;
|
||||
await anmelden(page, "/katalog/profile/docker");
|
||||
await expect(page.getByText("Katalog (schreibgeschützt)")).toBeVisible();
|
||||
await expect(page.getByLabel("Profil als YAML")).not.toBeEditable();
|
||||
await page.getByRole("link", { name: "Als Kopie bearbeiten" }).click();
|
||||
await expect(page).toHaveURL(/\/katalog\/profile\/neu\?von=docker/);
|
||||
|
||||
const editor = page.getByLabel("Profil als YAML");
|
||||
await editor.fill(
|
||||
(await editor.inputValue())
|
||||
.replace("id: docker-eigen", `id: ${id}`)
|
||||
.replace("- packages: [", "- pakete: ["),
|
||||
);
|
||||
await expect(page.getByText(/families\.debian\.1/)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Speichern" })).toBeDisabled();
|
||||
|
||||
await editor.fill((await editor.inputValue()).replace("- pakete: [", "- packages: ["));
|
||||
await expect(page.getByText("Gültig", { exact: true })).toBeVisible();
|
||||
await page.getByText("Vorschau: erzeugtes Skript").click();
|
||||
await expect(page.getByText(/docker-compose-plugin/).first()).toBeVisible();
|
||||
await keinQuerscrollen(page);
|
||||
await page.screenshot({
|
||||
path: `test-results/profil-editor-${test.info().project.name}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Speichern" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/katalog/profile/${id}$`));
|
||||
await expect(page.getByText("eigenes Profil")).toBeVisible();
|
||||
|
||||
page.once("dialog", (d) => void d.accept());
|
||||
await page.getByRole("button", { name: "Löschen" }).click();
|
||||
await expect(page).toHaveURL(/\/katalog$/);
|
||||
});
|
||||
Reference in New Issue
Block a user