feat(hosts): Services, Lesemodelle und Aktionen fuer Hosts (#008)
Hosts und Hostgruppen brauchen eine Schreibschicht, bevor Host-Key-Pruefung (#009) und Bootstrap (#010) darauf aufsetzen. Die Regeln (Name, Adresse wie der DB-CHECK, Port, Tags, Anmeldeart) liegen framework-frei in src/lib/hosts.ts, damit Action (Feldmeldungen) und Service (Schutz fuer Seed und Assistenten) dieselben pruefen. Archivieren statt Loeschen haelt Protokollbezuege lesbar; dabei fallen die Gruppenmitgliedschaften weg, damit Zuweisungen an eine Gruppe nie einen archivierten Host treffen. Jede Aenderung landet mit den geaenderten Feldern im Protokoll, Eindeutigkeitsverletzungen kommen als deutsche HostFehler. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,155 @@
|
|||||||
|
import { z } from "@/lib/validierung";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eingaben und abgeleitete Zustaende fuer Hosts und Hostgruppen (#008).
|
||||||
|
*
|
||||||
|
* Framework-frei, damit Service, Action und Tests dieselben Regeln teilen: die Action
|
||||||
|
* prueft vorab fuer Feldmeldungen im Formular, der Service prueft noch einmal, weil er
|
||||||
|
* auch von Seed, Skripten und spaeteren Assistenten gerufen wird.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Anzeigename eines Hosts: landet in Dateinamen, Kommentaren und ssh-config-Aliassen. */
|
||||||
|
export const HOST_NAME = /^[A-Za-z0-9._-]{1,64}$/;
|
||||||
|
/** Wie der DB-CHECK `host_adresse_gueltig` – Hostname, IPv4 oder IPv6 ohne Klammern. */
|
||||||
|
export const HOST_ADRESSE = /^[A-Za-z0-9._:-]{1,253}$/;
|
||||||
|
export const TAG = /^[a-z0-9._-]{1,32}$/;
|
||||||
|
export const MAX_TAGS = 20;
|
||||||
|
/** Gruppen- und Kundennamen sind reine Anzeige; Leerzeichen und Umlaute sind erlaubt. */
|
||||||
|
export const ANZEIGE_NAME = /^[\p{L}\p{N}][\p{L}\p{N} ._&()/-]{0,63}$/u;
|
||||||
|
|
||||||
|
/** Freitext aus einem Formular: leer heisst „nicht gesetzt“. */
|
||||||
|
const optionalerText = (max: number) =>
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(max, `Höchstens ${max} Zeichen.`)
|
||||||
|
.nullish()
|
||||||
|
.transform((wert) => (wert ? wert : null));
|
||||||
|
|
||||||
|
/** Mehrfachauswahl kommt aus FormData als String oder Liste – beides wird zur Liste. */
|
||||||
|
const idListe = z.preprocess(
|
||||||
|
(wert) => (wert === undefined || wert === "" ? [] : Array.isArray(wert) ? wert : [wert]),
|
||||||
|
z.array(z.string().min(1).max(64)).max(500),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const hostEingabeSchema = z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(HOST_NAME, "1–64 Zeichen: Buchstaben, Ziffern, Punkt, Unterstrich, Bindestrich."),
|
||||||
|
adresse: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(HOST_ADRESSE, "Hostname oder IP-Adresse (ohne Leerzeichen, Klammern oder Benutzer@)."),
|
||||||
|
port: z.coerce
|
||||||
|
.number({ error: "Bitte eine Zahl angeben." })
|
||||||
|
.int("Bitte eine ganze Zahl angeben.")
|
||||||
|
.min(1, "Port 1–65535.")
|
||||||
|
.max(65535, "Port 1–65535.")
|
||||||
|
.default(22),
|
||||||
|
// Tags kommen im Formular als ein Feld („pve, produktion“); kleingeschrieben, damit
|
||||||
|
// „PVE“ und „pve“ nicht zwei Filter ergeben.
|
||||||
|
tags: z
|
||||||
|
.preprocess(
|
||||||
|
(wert) => (typeof wert === "string" ? wert.split(/[\s,]+/).filter(Boolean) : (wert ?? [])),
|
||||||
|
z.array(
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.regex(TAG, "Tags: 1–32 Zeichen aus a–z, 0–9, Punkt, Unterstrich, Bindestrich."),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.transform((tags) => [...new Set(tags)])
|
||||||
|
.refine((tags) => tags.length <= MAX_TAGS, `Höchstens ${MAX_TAGS} Tags.`),
|
||||||
|
beschreibung: optionalerText(500),
|
||||||
|
kundeName: optionalerText(64).refine(
|
||||||
|
(wert) => wert === null || ANZEIGE_NAME.test(wert),
|
||||||
|
"Kundenname: 1–64 Zeichen, beginnt mit Buchstabe oder Ziffer.",
|
||||||
|
),
|
||||||
|
anmeldeart: z.enum(["DIENSTBENUTZER", "ROOT"]).default("DIENSTBENUTZER"),
|
||||||
|
gruppenIds: idListe.transform((ids) => [...new Set(ids)]),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was Aufrufer uebergeben – von Hand statt `z.input`, weil die Vorverarbeitung (Tags als
|
||||||
|
* Text, Mehrfachauswahl) sonst nur `unknown` als Eingabetyp hinterliesse.
|
||||||
|
*/
|
||||||
|
export type HostEingabe = {
|
||||||
|
name: string;
|
||||||
|
adresse: string;
|
||||||
|
port?: number | string;
|
||||||
|
/** Liste oder Text „pve, produktion“. */
|
||||||
|
tags?: string | string[];
|
||||||
|
beschreibung?: string | null;
|
||||||
|
kundeName?: string | null;
|
||||||
|
anmeldeart?: "DIENSTBENUTZER" | "ROOT";
|
||||||
|
gruppenIds?: string[];
|
||||||
|
};
|
||||||
|
export type HostDaten = z.output<typeof hostEingabeSchema>;
|
||||||
|
|
||||||
|
export const hostgruppeEingabeSchema = z.object({
|
||||||
|
name: z.string().trim().regex(ANZEIGE_NAME, "1–64 Zeichen, beginnt mit Buchstabe oder Ziffer."),
|
||||||
|
beschreibung: optionalerText(500),
|
||||||
|
hostIds: idListe.transform((ids) => [...new Set(ids)]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type HostgruppeEingabe = {
|
||||||
|
name: string;
|
||||||
|
beschreibung?: string | null;
|
||||||
|
hostIds?: string[];
|
||||||
|
};
|
||||||
|
export type HostgruppeDaten = z.output<typeof hostgruppeEingabeSchema>;
|
||||||
|
|
||||||
|
/** Zod-Fehler als `{ feld: erste Meldung }` fuer die Anzeige unter dem Feld. */
|
||||||
|
export function feldfehlerAus(fehler: z.ZodError): Record<string, string> {
|
||||||
|
const ergebnis: Record<string, string> = {};
|
||||||
|
for (const problem of fehler.issues) {
|
||||||
|
const feld = String(problem.path[0] ?? "formular");
|
||||||
|
ergebnis[feld] ??= problem.message;
|
||||||
|
}
|
||||||
|
return ergebnis;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Abgeleitete Zustaende
|
||||||
|
|
||||||
|
export type HostStatusArt = "synchron" | "drift" | "unerreichbar" | "unbekannt";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Wort fuer die Liste. Unerreichbar schlaegt alles: ein Abgleichsergebnis von gestern
|
||||||
|
* sagt nichts, wenn der Host heute nicht antwortet. Offene Abweichungen zaehlen als Drift,
|
||||||
|
* auch wenn der letzte Abgleich den Zustand noch nicht nachgezogen hat.
|
||||||
|
*/
|
||||||
|
export function hostStatus(host: {
|
||||||
|
erreichbar: boolean | null;
|
||||||
|
driftZustand: "UNBEKANNT" | "SYNCHRON" | "DRIFT";
|
||||||
|
offeneAbweichungen: number;
|
||||||
|
}): HostStatusArt {
|
||||||
|
if (host.erreichbar === false) return "unerreichbar";
|
||||||
|
if (host.driftZustand === "DRIFT" || host.offeneAbweichungen > 0) return "drift";
|
||||||
|
if (host.driftZustand === "SYNCHRON") return "synchron";
|
||||||
|
return "unbekannt";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HostKeyZustand = "bestaetigt" | "ausstehend" | "keiner";
|
||||||
|
|
||||||
|
/** Zusammenfassung der Host-Keys eines Hosts – auch Props fuer HostKeyAktionen (#009). */
|
||||||
|
export type HostKeyZusammenfassung = {
|
||||||
|
readonly zustand: HostKeyZustand;
|
||||||
|
readonly vertraut: number;
|
||||||
|
readonly ausstehend: number;
|
||||||
|
readonly abgelehnt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein ausstehender Key verlangt eine Entscheidung und geht deshalb vor einem vertrauten –
|
||||||
|
* sonst verschwaende ein geaenderter Host-Key hinter dem gruenen „bestaetigt“.
|
||||||
|
*/
|
||||||
|
export function hostKeyZusammenfassen(stati: readonly string[]): HostKeyZusammenfassung {
|
||||||
|
const zaehlen = (s: string) => stati.filter((x) => x === s).length;
|
||||||
|
const vertraut = zaehlen("VERTRAUT");
|
||||||
|
const ausstehend = zaehlen("AUSSTEHEND");
|
||||||
|
const abgelehnt = zaehlen("ABGELEHNT");
|
||||||
|
const zustand: HostKeyZustand = ausstehend > 0 ? "ausstehend" : vertraut > 0 ? "bestaetigt" : "keiner";
|
||||||
|
return { zustand, vertraut, ausstehend, abgelehnt };
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Benutzer } from "@/generated/prisma/client";
|
||||||
|
import { BestaetigungFehler, RechteFehler } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { HostFehler, type Akteur } from "@/server/services/hosts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemeinsames fuer Formular-Aktionen (#008). Kein "use server": diese Datei exportiert
|
||||||
|
* Hilfen, keine Aktionen – sonst waeren sie vom Browser aus aufrufbar.
|
||||||
|
*/
|
||||||
|
export type FormularZustand = {
|
||||||
|
readonly fehler?: string;
|
||||||
|
readonly feldfehler?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Fachliche Fehler werden zur Meldung im Formular; alles andere bleibt ein echter Fehler. */
|
||||||
|
export function fehlerZustand(fehler: unknown): FormularZustand {
|
||||||
|
if (fehler instanceof HostFehler) {
|
||||||
|
return { fehler: fehler.message, feldfehler: fehler.feldfehler };
|
||||||
|
}
|
||||||
|
if (fehler instanceof RechteFehler || fehler instanceof BestaetigungFehler) {
|
||||||
|
return { fehler: fehler.message };
|
||||||
|
}
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function akteurVon(benutzer: Pick<Benutzer, "id" | "name">): Akteur {
|
||||||
|
return { id: benutzer.id, name: benutzer.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einzelnes Textfeld; fehlend bleibt `undefined`, damit Standardwerte des Schemas greifen. */
|
||||||
|
export function textfeld(formular: FormData, name: string): string | undefined {
|
||||||
|
const wert = formular.get(name);
|
||||||
|
return typeof wert === "string" && wert !== "" ? wert : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listenfeld(formular: FormData, name: string): string[] {
|
||||||
|
return formular.getAll(name).filter((w): w is string => typeof w === "string" && w !== "");
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { feldfehlerAus, hostgruppeEingabeSchema, type HostgruppeEingabe } from "@/lib/hosts";
|
||||||
|
import {
|
||||||
|
akteurVon,
|
||||||
|
fehlerZustand,
|
||||||
|
listenfeld,
|
||||||
|
textfeld,
|
||||||
|
type FormularZustand,
|
||||||
|
} from "@/server/actions/formular";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { hostgruppeAnlegen, hostgruppeBearbeiten } from "@/server/services/hostgruppen";
|
||||||
|
|
||||||
|
/** Formular-Aktionen fuer Hostgruppen (#008). Duenn wie die fuer Hosts. */
|
||||||
|
|
||||||
|
function gruppenFelder(formular: FormData): HostgruppeEingabe {
|
||||||
|
return {
|
||||||
|
name: textfeld(formular, "name") ?? "",
|
||||||
|
beschreibung: textfeld(formular, "beschreibung"),
|
||||||
|
hostIds: listenfeld(formular, "hostIds"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function vorpruefen(eingabe: HostgruppeEingabe): FormularZustand | undefined {
|
||||||
|
const ergebnis = hostgruppeEingabeSchema.safeParse(eingabe);
|
||||||
|
if (ergebnis.success) return undefined;
|
||||||
|
return { fehler: "Bitte die markierten Felder prüfen.", feldfehler: feldfehlerAus(ergebnis.error) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hostgruppeAnlegenAktion(
|
||||||
|
_vorher: FormularZustand,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<FormularZustand> {
|
||||||
|
let id: string;
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("hostsVerwalten");
|
||||||
|
const eingabe = gruppenFelder(formular);
|
||||||
|
const ungueltig = vorpruefen(eingabe);
|
||||||
|
if (ungueltig) return ungueltig;
|
||||||
|
({ id } = await hostgruppeAnlegen(eingabe, akteurVon(benutzer)));
|
||||||
|
} catch (fehler) {
|
||||||
|
return fehlerZustand(fehler);
|
||||||
|
}
|
||||||
|
revalidatePath("/hostgruppen");
|
||||||
|
revalidatePath("/hosts", "layout");
|
||||||
|
redirect(`/hostgruppen/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hostgruppeBearbeitenAktion(
|
||||||
|
_vorher: FormularZustand,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<FormularZustand> {
|
||||||
|
const id = textfeld(formular, "id") ?? "";
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("hostsVerwalten");
|
||||||
|
const eingabe = gruppenFelder(formular);
|
||||||
|
const ungueltig = vorpruefen(eingabe);
|
||||||
|
if (ungueltig) return ungueltig;
|
||||||
|
await hostgruppeBearbeiten(id, eingabe, akteurVon(benutzer));
|
||||||
|
} catch (fehler) {
|
||||||
|
return fehlerZustand(fehler);
|
||||||
|
}
|
||||||
|
revalidatePath("/hostgruppen", "layout");
|
||||||
|
revalidatePath("/hosts", "layout");
|
||||||
|
redirect(`/hostgruppen/${id}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { feldfehlerAus, hostEingabeSchema, type HostEingabe } from "@/lib/hosts";
|
||||||
|
import {
|
||||||
|
akteurVon,
|
||||||
|
fehlerZustand,
|
||||||
|
listenfeld,
|
||||||
|
textfeld,
|
||||||
|
type FormularZustand,
|
||||||
|
} from "@/server/actions/formular";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { hostAnlegen, hostArchivieren, hostBearbeiten } from "@/server/services/hosts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formular-Aktionen fuer Hosts (#008). Duenn: Recht pruefen, Eingabe pruefen, Service rufen,
|
||||||
|
* Seiten neu laden lassen, weiterleiten. `redirect` steht ausserhalb von try/catch, weil es
|
||||||
|
* per Ausnahme arbeitet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function hostFelder(formular: FormData): HostEingabe {
|
||||||
|
return {
|
||||||
|
name: textfeld(formular, "name") ?? "",
|
||||||
|
adresse: textfeld(formular, "adresse") ?? "",
|
||||||
|
port: textfeld(formular, "port"),
|
||||||
|
tags: textfeld(formular, "tags") ?? "",
|
||||||
|
beschreibung: textfeld(formular, "beschreibung"),
|
||||||
|
kundeName: textfeld(formular, "kundeName"),
|
||||||
|
anmeldeart: textfeld(formular, "anmeldeart") as HostEingabe["anmeldeart"],
|
||||||
|
gruppenIds: listenfeld(formular, "gruppenIds"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function vorpruefen(eingabe: HostEingabe): FormularZustand | undefined {
|
||||||
|
const ergebnis = hostEingabeSchema.safeParse(eingabe);
|
||||||
|
if (ergebnis.success) return undefined;
|
||||||
|
return { fehler: "Bitte die markierten Felder prüfen.", feldfehler: feldfehlerAus(ergebnis.error) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hostAnlegenAktion(
|
||||||
|
_vorher: FormularZustand,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<FormularZustand> {
|
||||||
|
let id: string;
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("hostsVerwalten");
|
||||||
|
const eingabe = hostFelder(formular);
|
||||||
|
const ungueltig = vorpruefen(eingabe);
|
||||||
|
if (ungueltig) return ungueltig;
|
||||||
|
({ id } = await hostAnlegen(eingabe, akteurVon(benutzer)));
|
||||||
|
} catch (fehler) {
|
||||||
|
return fehlerZustand(fehler);
|
||||||
|
}
|
||||||
|
revalidatePath("/hosts");
|
||||||
|
revalidatePath("/hostgruppen", "layout");
|
||||||
|
redirect(`/hosts/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hostBearbeitenAktion(
|
||||||
|
_vorher: FormularZustand,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<FormularZustand> {
|
||||||
|
const id = textfeld(formular, "id") ?? "";
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("hostsVerwalten");
|
||||||
|
const eingabe = hostFelder(formular);
|
||||||
|
const ungueltig = vorpruefen(eingabe);
|
||||||
|
if (ungueltig) return ungueltig;
|
||||||
|
await hostBearbeiten(id, eingabe, akteurVon(benutzer));
|
||||||
|
} catch (fehler) {
|
||||||
|
return fehlerZustand(fehler);
|
||||||
|
}
|
||||||
|
revalidatePath("/hosts", "layout");
|
||||||
|
revalidatePath("/hostgruppen", "layout");
|
||||||
|
redirect(`/hosts/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hostArchivierenAktion(
|
||||||
|
_vorher: FormularZustand,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<FormularZustand> {
|
||||||
|
const id = textfeld(formular, "id") ?? "";
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("hostsVerwalten");
|
||||||
|
await hostArchivieren(id, akteurVon(benutzer));
|
||||||
|
} catch (fehler) {
|
||||||
|
return fehlerZustand(fehler);
|
||||||
|
}
|
||||||
|
revalidatePath("/hosts", "layout");
|
||||||
|
revalidatePath("/hostgruppen", "layout");
|
||||||
|
redirect("/hosts");
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { hostKeyZusammenfassen, hostStatus } from "@/lib/hosts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lesemodelle fuer Hosts und Hostgruppen (#008).
|
||||||
|
*
|
||||||
|
* Alles hier darf jede angemeldete Rolle sehen (Recht `lesen`); die Seiten pruefen das per
|
||||||
|
* `sitzungErforderlich()`. Archivierte Hosts erscheinen in keiner Liste, nur ueber ihre
|
||||||
|
* Detailadresse – fuer Verweise aus dem Protokoll.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const OFFEN = { behobenAm: null } satisfies Prisma.AbweichungWhereInput;
|
||||||
|
|
||||||
|
export type HostFilter = { readonly q?: string; readonly gruppeId?: string };
|
||||||
|
|
||||||
|
export async function hostListe(filter: HostFilter = {}) {
|
||||||
|
const q = filter.q?.trim().slice(0, 100);
|
||||||
|
const where: Prisma.HostWhereInput = {
|
||||||
|
archiviertAm: null,
|
||||||
|
...(filter.gruppeId ? { gruppen: { some: { hostgruppeId: filter.gruppeId } } } : {}),
|
||||||
|
...(q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: q, mode: "insensitive" } },
|
||||||
|
{ adresse: { contains: q, mode: "insensitive" } },
|
||||||
|
{ tags: { has: q.toLowerCase() } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
const hosts = await prisma.host.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
adresse: true,
|
||||||
|
port: true,
|
||||||
|
tags: true,
|
||||||
|
erreichbar: true,
|
||||||
|
driftZustand: true,
|
||||||
|
letzterAbgleichAm: true,
|
||||||
|
kunde: { select: { name: true } },
|
||||||
|
gruppen: { select: { hostgruppe: { select: { id: true, name: true } } } },
|
||||||
|
hostSchluessel: {
|
||||||
|
where: { status: { in: ["VERTRAUT", "AUSSTEHEND"] } },
|
||||||
|
select: { status: true },
|
||||||
|
},
|
||||||
|
_count: { select: { abweichungen: { where: OFFEN } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return hosts.map((h) => ({
|
||||||
|
id: h.id,
|
||||||
|
name: h.name,
|
||||||
|
adresse: h.adresse,
|
||||||
|
port: h.port,
|
||||||
|
tags: h.tags,
|
||||||
|
kunde: h.kunde?.name ?? null,
|
||||||
|
gruppen: h.gruppen.map((g) => g.hostgruppe).sort((a, b) => a.name.localeCompare(b.name, "de")),
|
||||||
|
letzterAbgleichAm: h.letzterAbgleichAm,
|
||||||
|
offeneAbweichungen: h._count.abweichungen,
|
||||||
|
status: hostStatus({
|
||||||
|
erreichbar: h.erreichbar,
|
||||||
|
driftZustand: h.driftZustand,
|
||||||
|
offeneAbweichungen: h._count.abweichungen,
|
||||||
|
}),
|
||||||
|
hostKey: hostKeyZusammenfassen(h.hostSchluessel.map((s) => s.status)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HostListenEintrag = Awaited<ReturnType<typeof hostListe>>[number];
|
||||||
|
|
||||||
|
export async function hostDetail(id: string) {
|
||||||
|
const host = await prisma.host.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
adresse: true,
|
||||||
|
port: true,
|
||||||
|
beschreibung: true,
|
||||||
|
tags: true,
|
||||||
|
anmeldeart: true,
|
||||||
|
bootstrapAm: true,
|
||||||
|
erreichbar: true,
|
||||||
|
zuletztGeprueftAm: true,
|
||||||
|
letzterAbgleichAm: true,
|
||||||
|
driftZustand: true,
|
||||||
|
sshdBefund: true,
|
||||||
|
archiviertAm: true,
|
||||||
|
angelegtAm: true,
|
||||||
|
kunde: { select: { id: true, name: true } },
|
||||||
|
gruppen: { select: { hostgruppe: { select: { id: true, name: true } } } },
|
||||||
|
hostSchluessel: {
|
||||||
|
orderBy: [{ status: "asc" }, { algorithmus: "asc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
algorithmus: true,
|
||||||
|
fingerprint: true,
|
||||||
|
status: true,
|
||||||
|
erstmalsGesehen: true,
|
||||||
|
zuletztGesehen: true,
|
||||||
|
entschiedenAm: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
zielkonten: {
|
||||||
|
where: { entferntAm: null },
|
||||||
|
orderBy: { benutzername: "asc" },
|
||||||
|
select: { id: true, benutzername: true, sudoModus: true, vonSshkvAngelegt: true },
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
abweichungen: { where: OFFEN },
|
||||||
|
publicVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
|
||||||
|
privateVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!host) return null;
|
||||||
|
const { _count, gruppen, ...rest } = host;
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
gruppen: gruppen.map((g) => g.hostgruppe).sort((a, b) => a.name.localeCompare(b.name, "de")),
|
||||||
|
offeneAbweichungen: _count.abweichungen,
|
||||||
|
publicVerwendungen: _count.publicVerwendungen,
|
||||||
|
privateVerwendungen: _count.privateVerwendungen,
|
||||||
|
status: hostStatus({
|
||||||
|
erreichbar: host.erreichbar,
|
||||||
|
driftZustand: host.driftZustand,
|
||||||
|
offeneAbweichungen: _count.abweichungen,
|
||||||
|
}),
|
||||||
|
hostKey: hostKeyZusammenfassen(host.hostSchluessel.map((s) => s.status)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HostDetail = NonNullable<Awaited<ReturnType<typeof hostDetail>>>;
|
||||||
|
|
||||||
|
/** Fuer das Formular „Bearbeiten“: genau die Felder, die das Formular kennt. */
|
||||||
|
export async function hostFormularWerte(id: string) {
|
||||||
|
const host = await prisma.host.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
adresse: true,
|
||||||
|
port: true,
|
||||||
|
tags: true,
|
||||||
|
beschreibung: true,
|
||||||
|
anmeldeart: true,
|
||||||
|
archiviertAm: true,
|
||||||
|
kunde: { select: { name: true } },
|
||||||
|
gruppen: { select: { hostgruppeId: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!host) return null;
|
||||||
|
return {
|
||||||
|
id: host.id,
|
||||||
|
name: host.name,
|
||||||
|
adresse: host.adresse,
|
||||||
|
port: host.port,
|
||||||
|
tags: host.tags,
|
||||||
|
beschreibung: host.beschreibung ?? "",
|
||||||
|
anmeldeart: host.anmeldeart,
|
||||||
|
kundeName: host.kunde?.name ?? "",
|
||||||
|
gruppenIds: host.gruppen.map((g) => g.hostgruppeId),
|
||||||
|
archiviert: host.archiviertAm !== null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HostFormularWerte = NonNullable<Awaited<ReturnType<typeof hostFormularWerte>>>;
|
||||||
|
|
||||||
|
/** Auswahllisten fuer Formulare. */
|
||||||
|
export async function gruppenAuswahl() {
|
||||||
|
return prisma.hostgruppe.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hostAuswahl() {
|
||||||
|
return prisma.host.findMany({
|
||||||
|
where: { archiviertAm: null },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: { id: true, name: true, adresse: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kundenNamen() {
|
||||||
|
const kunden = await prisma.kunde.findMany({ orderBy: { name: "asc" }, select: { name: true } });
|
||||||
|
return kunden.map((k) => k.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Hostgruppen
|
||||||
|
|
||||||
|
export async function gruppenListe() {
|
||||||
|
const gruppen = await prisma.hostgruppe.findMany({
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
beschreibung: true,
|
||||||
|
_count: { select: { mitglieder: { where: { host: { archiviertAm: null } } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return gruppen.map(({ _count, ...g }) => ({ ...g, mitglieder: _count.mitglieder }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gruppeDetail(id: string) {
|
||||||
|
const gruppe = await prisma.hostgruppe.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
beschreibung: true,
|
||||||
|
angelegtAm: true,
|
||||||
|
mitglieder: {
|
||||||
|
where: { host: { archiviertAm: null } },
|
||||||
|
orderBy: { host: { name: "asc" } },
|
||||||
|
select: {
|
||||||
|
host: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
adresse: true,
|
||||||
|
port: true,
|
||||||
|
erreichbar: true,
|
||||||
|
driftZustand: true,
|
||||||
|
_count: { select: { abweichungen: { where: OFFEN } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!gruppe) return null;
|
||||||
|
return {
|
||||||
|
id: gruppe.id,
|
||||||
|
name: gruppe.name,
|
||||||
|
beschreibung: gruppe.beschreibung,
|
||||||
|
angelegtAm: gruppe.angelegtAm,
|
||||||
|
mitglieder: gruppe.mitglieder.map(({ host: { _count, ...h } }) => ({
|
||||||
|
id: h.id,
|
||||||
|
name: h.name,
|
||||||
|
adresse: h.adresse,
|
||||||
|
port: h.port,
|
||||||
|
status: hostStatus({
|
||||||
|
erreichbar: h.erreichbar,
|
||||||
|
driftZustand: h.driftZustand,
|
||||||
|
offeneAbweichungen: _count.abweichungen,
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import {
|
||||||
|
feldfehlerAus,
|
||||||
|
hostgruppeEingabeSchema,
|
||||||
|
type HostgruppeDaten,
|
||||||
|
type HostgruppeEingabe,
|
||||||
|
} from "@/lib/hosts";
|
||||||
|
import { HostFehler, istEindeutigkeitsFehler, type Akteur } from "@/server/services/hosts";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hostgruppen anlegen und bearbeiten, Mitglieder setzen (#008).
|
||||||
|
*
|
||||||
|
* Loeschen gibt es bewusst noch nicht: an einer Gruppe haengen spaeter Public-Verwendungen
|
||||||
|
* (Cascade) – ein Loeschen wuerde Zuweisungen still mitnehmen. Das kommt mit #016, wenn
|
||||||
|
* klar ist, was mit den Verwendungen passieren soll.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function pruefen(eingabe: HostgruppeEingabe): HostgruppeDaten {
|
||||||
|
const ergebnis = hostgruppeEingabeSchema.safeParse(eingabe);
|
||||||
|
if (!ergebnis.success) {
|
||||||
|
throw new HostFehler("Bitte die markierten Felder prüfen.", feldfehlerAus(ergebnis.error));
|
||||||
|
}
|
||||||
|
return ergebnis.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nameVergeben(name: string): HostFehler {
|
||||||
|
return new HostFehler(`Eine Hostgruppe namens „${name}“ existiert bereits.`, {
|
||||||
|
name: "Dieser Name ist schon vergeben.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur aktive Hosts duerfen Mitglied werden; liefert die Namen fuer das Protokoll. */
|
||||||
|
async function hostNamen(tx: Prisma.TransactionClient, ids: readonly string[]): Promise<Map<string, string>> {
|
||||||
|
if (ids.length === 0) return new Map();
|
||||||
|
const hosts = await tx.host.findMany({
|
||||||
|
where: { id: { in: [...ids] }, archiviertAm: null },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
if (hosts.length !== ids.length) {
|
||||||
|
throw new HostFehler("Mindestens ein gewählter Host ist archiviert oder existiert nicht.", {
|
||||||
|
hostIds: "Bitte die Auswahl prüfen.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Map(hosts.map((h) => [h.id, h.name]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hostgruppeAnlegen(eingabe: HostgruppeEingabe, akteur: Akteur) {
|
||||||
|
const daten = pruefen(eingabe);
|
||||||
|
if (await prisma.hostgruppe.findUnique({ where: { name: daten.name }, select: { id: true } })) {
|
||||||
|
throw nameVergeben(daten.name);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await prisma.$transaction(async (tx) => {
|
||||||
|
const namen = await hostNamen(tx, daten.hostIds);
|
||||||
|
const gruppe = await tx.hostgruppe.create({
|
||||||
|
data: {
|
||||||
|
name: daten.name,
|
||||||
|
beschreibung: daten.beschreibung,
|
||||||
|
mitglieder: { create: daten.hostIds.map((hostId) => ({ hostId })) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
akteur,
|
||||||
|
aktion: "hostgruppe.angelegt",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
details: {
|
||||||
|
name: gruppe.name,
|
||||||
|
beschreibung: gruppe.beschreibung,
|
||||||
|
mitglieder: [...namen.values()].sort(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return gruppe;
|
||||||
|
});
|
||||||
|
} catch (fehler) {
|
||||||
|
if (istEindeutigkeitsFehler(fehler)) throw nameVergeben(daten.name);
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name, Beschreibung und Mitglieder in einem Zug. Mitgliedschaften archivierter Hosts gibt
|
||||||
|
* es nicht (siehe hostArchivieren), deshalb darf die Auswahl die Liste komplett ersetzen.
|
||||||
|
*/
|
||||||
|
export async function hostgruppeBearbeiten(id: string, eingabe: HostgruppeEingabe, akteur: Akteur) {
|
||||||
|
const daten = pruefen(eingabe);
|
||||||
|
const alt = await prisma.hostgruppe.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { mitglieder: { include: { host: { select: { id: true, name: true } } } } },
|
||||||
|
});
|
||||||
|
if (!alt) throw new HostFehler("Diese Hostgruppe gibt es nicht.");
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await prisma.$transaction(async (tx) => {
|
||||||
|
const neu = await hostNamen(tx, daten.hostIds);
|
||||||
|
const vorher = new Map(alt.mitglieder.map((m) => [m.host.id, m.host.name]));
|
||||||
|
const hinzu = [...neu].filter(([hid]) => !vorher.has(hid));
|
||||||
|
const entfernt = [...vorher].filter(([hid]) => !neu.has(hid));
|
||||||
|
|
||||||
|
const geaendert: Record<string, unknown> = {};
|
||||||
|
if (alt.name !== daten.name) geaendert["name"] = { vorher: alt.name, nachher: daten.name };
|
||||||
|
if (alt.beschreibung !== daten.beschreibung) {
|
||||||
|
geaendert["beschreibung"] = { vorher: alt.beschreibung, nachher: daten.beschreibung };
|
||||||
|
}
|
||||||
|
if (hinzu.length > 0 || entfernt.length > 0) {
|
||||||
|
geaendert["mitglieder"] = {
|
||||||
|
hinzu: hinzu.map(([, n]) => n).sort(),
|
||||||
|
entfernt: entfernt.map(([, n]) => n).sort(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const gruppe = await tx.hostgruppe.update({
|
||||||
|
where: { id },
|
||||||
|
data: { name: daten.name, beschreibung: daten.beschreibung },
|
||||||
|
});
|
||||||
|
if (entfernt.length > 0) {
|
||||||
|
await tx.hostgruppeMitglied.deleteMany({
|
||||||
|
where: { hostgruppeId: id, hostId: { in: entfernt.map(([hid]) => hid) } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (hinzu.length > 0) {
|
||||||
|
await tx.hostgruppeMitglied.createMany({
|
||||||
|
data: hinzu.map(([hostId]) => ({ hostId, hostgruppeId: id })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (Object.keys(geaendert).length > 0) {
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
akteur,
|
||||||
|
aktion: "hostgruppe.geaendert",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
details: { gruppe: gruppe.name, ...geaendert },
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return gruppe;
|
||||||
|
});
|
||||||
|
} catch (fehler) {
|
||||||
|
if (istEindeutigkeitsFehler(fehler)) throw nameVergeben(daten.name);
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { feldfehlerAus, hostEingabeSchema, type HostDaten, type HostEingabe } from "@/lib/hosts";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hosts anlegen, bearbeiten, archivieren (#008).
|
||||||
|
*
|
||||||
|
* Archivieren statt Loeschen: am Host haengen Protokoll-Bezuege, Beobachtungen und spaeter
|
||||||
|
* Verwendungen. Ein geloeschter Host liesse Fragen wie „wer hatte bis Mai Zugang zu
|
||||||
|
* srv-pve01?“ unbeantwortet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Akteur = { readonly id: string; readonly name: string };
|
||||||
|
|
||||||
|
/** Fachlicher Fehler mit Meldung fuer die Oberflaeche und optional je Feld. */
|
||||||
|
export class HostFehler extends Error {
|
||||||
|
constructor(
|
||||||
|
nachricht: string,
|
||||||
|
readonly feldfehler: Record<string, string> = {},
|
||||||
|
) {
|
||||||
|
super(nachricht);
|
||||||
|
this.name = "HostFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function istEindeutigkeitsFehler(fehler: unknown): boolean {
|
||||||
|
return fehler instanceof Prisma.PrismaClientKnownRequestError && fehler.code === "P2002";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruefen(eingabe: HostEingabe): HostDaten {
|
||||||
|
const ergebnis = hostEingabeSchema.safeParse(eingabe);
|
||||||
|
if (!ergebnis.success) {
|
||||||
|
throw new HostFehler("Bitte die markierten Felder prüfen.", feldfehlerAus(ergebnis.error));
|
||||||
|
}
|
||||||
|
return ergebnis.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nameVergeben(name: string): HostFehler {
|
||||||
|
return new HostFehler(`Ein Host namens „${name}“ existiert bereits (auch archivierte zählen).`, {
|
||||||
|
name: "Dieser Name ist schon vergeben.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kunde per Name finden oder anlegen; `null` heisst „kein Kunde“. */
|
||||||
|
async function kundeZuordnen(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
kundeName: string | null,
|
||||||
|
akteur: Akteur,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!kundeName) return null;
|
||||||
|
const vorhanden = await tx.kunde.findUnique({ where: { name: kundeName }, select: { id: true } });
|
||||||
|
if (vorhanden) return vorhanden.id;
|
||||||
|
const kunde = await tx.kunde.create({ data: { name: kundeName }, select: { id: true } });
|
||||||
|
await protokollieren(
|
||||||
|
{ akteur, aktion: "kunde.angelegt", ergebnis: "ERFOLG", details: { name: kundeName } },
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return kunde.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prueft, dass alle Gruppen existieren, und liefert ihre Namen fuer das Protokoll. */
|
||||||
|
async function gruppenNamen(tx: Prisma.TransactionClient, ids: readonly string[]): Promise<string[]> {
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
const gruppen = await tx.hostgruppe.findMany({ where: { id: { in: [...ids] } }, select: { name: true } });
|
||||||
|
if (gruppen.length !== ids.length) {
|
||||||
|
throw new HostFehler("Mindestens eine gewählte Hostgruppe gibt es nicht mehr.", {
|
||||||
|
gruppenIds: "Bitte die Auswahl prüfen.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return gruppen.map((g) => g.name).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hostAnlegen(eingabe: HostEingabe, akteur: Akteur) {
|
||||||
|
const daten = pruefen(eingabe);
|
||||||
|
if (await prisma.host.findUnique({ where: { name: daten.name }, select: { id: true } })) {
|
||||||
|
throw nameVergeben(daten.name);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await prisma.$transaction(async (tx) => {
|
||||||
|
const gruppen = await gruppenNamen(tx, daten.gruppenIds);
|
||||||
|
const kundeId = await kundeZuordnen(tx, daten.kundeName, akteur);
|
||||||
|
const host = await tx.host.create({
|
||||||
|
data: {
|
||||||
|
name: daten.name,
|
||||||
|
adresse: daten.adresse,
|
||||||
|
port: daten.port,
|
||||||
|
tags: daten.tags,
|
||||||
|
beschreibung: daten.beschreibung,
|
||||||
|
anmeldeart: daten.anmeldeart,
|
||||||
|
kundeId,
|
||||||
|
gruppen: { create: daten.gruppenIds.map((hostgruppeId) => ({ hostgruppeId })) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
akteur,
|
||||||
|
aktion: "host.angelegt",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
hostName: host.name,
|
||||||
|
details: {
|
||||||
|
adresse: host.adresse,
|
||||||
|
port: host.port,
|
||||||
|
anmeldeart: host.anmeldeart,
|
||||||
|
tags: host.tags,
|
||||||
|
kunde: daten.kundeName,
|
||||||
|
gruppen,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return host;
|
||||||
|
});
|
||||||
|
} catch (fehler) {
|
||||||
|
// Zwischen Vorpruefung und Insert kann jemand denselben Namen angelegt haben.
|
||||||
|
if (istEindeutigkeitsFehler(fehler)) throw nameVergeben(daten.name);
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Aenderung = { vorher: unknown; nachher: unknown };
|
||||||
|
|
||||||
|
function gleich(a: unknown, b: unknown): boolean {
|
||||||
|
return JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hostBearbeiten(id: string, eingabe: HostEingabe, akteur: Akteur) {
|
||||||
|
const daten = pruefen(eingabe);
|
||||||
|
const alt = await prisma.host.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { kunde: true, gruppen: { include: { hostgruppe: { select: { name: true } } } } },
|
||||||
|
});
|
||||||
|
if (!alt) throw new HostFehler("Diesen Host gibt es nicht.");
|
||||||
|
if (alt.archiviertAm) throw new HostFehler("Archivierte Hosts lassen sich nicht bearbeiten.");
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await prisma.$transaction(async (tx) => {
|
||||||
|
const neueGruppen = await gruppenNamen(tx, daten.gruppenIds);
|
||||||
|
const kundeId = await kundeZuordnen(tx, daten.kundeName, akteur);
|
||||||
|
|
||||||
|
// Nur geaenderte Felder ins Protokoll – „wer hat den Port auf 2222 gestellt?“ soll
|
||||||
|
// ohne Vergleich zweier Vollstaende beantwortbar sein.
|
||||||
|
const vergleich: Record<string, Aenderung> = {
|
||||||
|
name: { vorher: alt.name, nachher: daten.name },
|
||||||
|
adresse: { vorher: alt.adresse, nachher: daten.adresse },
|
||||||
|
port: { vorher: alt.port, nachher: daten.port },
|
||||||
|
tags: { vorher: alt.tags, nachher: daten.tags },
|
||||||
|
beschreibung: { vorher: alt.beschreibung, nachher: daten.beschreibung },
|
||||||
|
anmeldeart: { vorher: alt.anmeldeart, nachher: daten.anmeldeart },
|
||||||
|
kunde: { vorher: alt.kunde?.name ?? null, nachher: daten.kundeName },
|
||||||
|
gruppen: { vorher: alt.gruppen.map((g) => g.hostgruppe.name).sort(), nachher: neueGruppen },
|
||||||
|
};
|
||||||
|
const geaendert = Object.fromEntries(
|
||||||
|
Object.entries(vergleich).filter(([, a]) => !gleich(a.vorher, a.nachher)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const host = await tx.host.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name: daten.name,
|
||||||
|
adresse: daten.adresse,
|
||||||
|
port: daten.port,
|
||||||
|
tags: daten.tags,
|
||||||
|
beschreibung: daten.beschreibung,
|
||||||
|
anmeldeart: daten.anmeldeart,
|
||||||
|
kundeId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if ("gruppen" in geaendert) {
|
||||||
|
await tx.hostgruppeMitglied.deleteMany({ where: { hostId: id } });
|
||||||
|
await tx.hostgruppeMitglied.createMany({
|
||||||
|
data: daten.gruppenIds.map((hostgruppeId) => ({ hostId: id, hostgruppeId })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (Object.keys(geaendert).length > 0) {
|
||||||
|
await protokollieren(
|
||||||
|
{ akteur, aktion: "host.geaendert", ergebnis: "ERFOLG", hostName: host.name, details: geaendert },
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return host;
|
||||||
|
});
|
||||||
|
} catch (fehler) {
|
||||||
|
if (istEindeutigkeitsFehler(fehler)) throw nameVergeben(daten.name);
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archiviert einen Host. Gruppenmitgliedschaften fallen dabei weg, damit spaetere
|
||||||
|
* Zuweisungen an eine Gruppe nie einen archivierten Host treffen; welche es waren, steht
|
||||||
|
* im Protokoll.
|
||||||
|
*/
|
||||||
|
export async function hostArchivieren(id: string, akteur: Akteur) {
|
||||||
|
const host = await prisma.host.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { gruppen: { include: { hostgruppe: { select: { name: true } } } } },
|
||||||
|
});
|
||||||
|
if (!host) throw new HostFehler("Diesen Host gibt es nicht.");
|
||||||
|
if (host.archiviertAm) throw new HostFehler("Dieser Host ist bereits archiviert.");
|
||||||
|
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
await tx.hostgruppeMitglied.deleteMany({ where: { hostId: id } });
|
||||||
|
const archiviert = await tx.host.update({ where: { id }, data: { archiviertAm: new Date() } });
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
akteur,
|
||||||
|
aktion: "host.archiviert",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
hostName: host.name,
|
||||||
|
details: { gruppen: host.gruppen.map((g) => g.hostgruppe.name).sort() },
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return archiviert;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { hostKeyZusammenfassen, hostStatus } from "@/lib/hosts";
|
||||||
|
import { gruppeDetail, gruppenListe, hostDetail, hostListe } from "@/server/queries/hosts";
|
||||||
|
import { hostgruppeAnlegen, hostgruppeBearbeiten } from "@/server/services/hostgruppen";
|
||||||
|
import { HostFehler, hostAnlegen, hostArchivieren, hostBearbeiten } from "@/server/services/hosts";
|
||||||
|
import { datenbankLeeren } from "./hilfen/db";
|
||||||
|
|
||||||
|
/** Hosts und Hostgruppen (#008): Services gegen die Testdatenbank, Queries, Rechte. */
|
||||||
|
|
||||||
|
let akteur: { id: string; name: string };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
const benutzer = await prisma.benutzer.create({ data: { name: "Olga Operator", rolle: "OPERATOR" } });
|
||||||
|
akteur = { id: benutzer.id, name: benutzer.name };
|
||||||
|
});
|
||||||
|
|
||||||
|
const GRUND = { name: "srv-pve01", adresse: "10.0.0.11" };
|
||||||
|
|
||||||
|
async function fehlerVon(versuch: Promise<unknown>): Promise<HostFehler> {
|
||||||
|
const fehler = await versuch.then(
|
||||||
|
() => undefined,
|
||||||
|
(f: unknown) => f,
|
||||||
|
);
|
||||||
|
expect(fehler).toBeInstanceOf(HostFehler);
|
||||||
|
return fehler as HostFehler;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Host anlegen", () => {
|
||||||
|
it("legt mit Standardwerten an und protokolliert", async () => {
|
||||||
|
const host = await hostAnlegen({ ...GRUND, tags: "PVE, produktion pve" }, akteur);
|
||||||
|
expect(host.port).toBe(22);
|
||||||
|
expect(host.anmeldeart).toBe("DIENSTBENUTZER");
|
||||||
|
// kleingeschrieben und ohne Doppelte
|
||||||
|
expect(host.tags).toEqual(["pve", "produktion"]);
|
||||||
|
expect(host.beschreibung).toBeNull();
|
||||||
|
|
||||||
|
const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "host.angelegt" } });
|
||||||
|
expect(eintrag.hostName).toBe("srv-pve01");
|
||||||
|
expect(eintrag.akteurId).toBe(akteur.id);
|
||||||
|
expect(eintrag.akteurName).toBe("Olga Operator");
|
||||||
|
expect(eintrag.ergebnis).toBe("ERFOLG");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("legt einen Kunden per Name an und verwendet ihn wieder", async () => {
|
||||||
|
const a = await hostAnlegen({ ...GRUND, kundeName: "Kunde X" }, akteur);
|
||||||
|
const b = await hostAnlegen({ name: "srv-app01", adresse: "10.0.0.20", kundeName: "Kunde X" }, akteur);
|
||||||
|
expect(a.kundeId).not.toBeNull();
|
||||||
|
expect(b.kundeId).toBe(a.kundeId);
|
||||||
|
expect(await prisma.kunde.count()).toBe(1);
|
||||||
|
expect(await prisma.protokoll.count({ where: { aktion: "kunde.angelegt" } })).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["name", { name: "srv pve01" }],
|
||||||
|
["name", { name: "srv;rm -rf /" }],
|
||||||
|
["name", { name: "x".repeat(65) }],
|
||||||
|
["name", { name: "" }],
|
||||||
|
["adresse", { adresse: "10.0.0.1; reboot" }],
|
||||||
|
["adresse", { adresse: "root@10.0.0.1" }],
|
||||||
|
["adresse", { adresse: "$(id)" }],
|
||||||
|
["adresse", { adresse: "host name" }],
|
||||||
|
["adresse", { adresse: "a".repeat(254) }],
|
||||||
|
["port", { port: 0 }],
|
||||||
|
["port", { port: 65536 }],
|
||||||
|
["port", { port: "zwei" }],
|
||||||
|
["tags", { tags: "gut, bö$e" }],
|
||||||
|
["tags", { tags: Array.from({ length: 21 }, (_, i) => `t${i}`) }],
|
||||||
|
["beschreibung", { beschreibung: "x".repeat(501) }],
|
||||||
|
["anmeldeart", { anmeldeart: "ADMIN" }],
|
||||||
|
])("weist ein ungueltiges Feld %s ab (%j)", async (feld, abweichung) => {
|
||||||
|
const fehler = await fehlerVon(hostAnlegen({ ...GRUND, ...abweichung } as never, akteur));
|
||||||
|
expect(Object.keys(fehler.feldfehler)).toContain(feld);
|
||||||
|
expect(await prisma.host.count()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nimmt IPv6, FQDN und hohe Ports an", async () => {
|
||||||
|
await hostAnlegen({ name: "v6", adresse: "fd00::11", port: 2222 }, akteur);
|
||||||
|
await hostAnlegen({ name: "fqdn", adresse: "pve01.intern.example.org", port: "65535" }, akteur);
|
||||||
|
expect(await prisma.host.count()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("meldet einen vergebenen Namen verstaendlich", async () => {
|
||||||
|
await hostAnlegen(GRUND, akteur);
|
||||||
|
const fehler = await fehlerVon(hostAnlegen({ ...GRUND, adresse: "10.0.0.99" }, akteur));
|
||||||
|
expect(fehler.message).toMatch(/srv-pve01.*existiert bereits/);
|
||||||
|
expect(fehler.feldfehler["name"]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verweigert unbekannte Gruppen", async () => {
|
||||||
|
const fehler = await fehlerVon(hostAnlegen({ ...GRUND, gruppenIds: ["gibt-es-nicht"] }, akteur));
|
||||||
|
expect(fehler.feldfehler["gruppenIds"]).toBeDefined();
|
||||||
|
expect(await prisma.host.count()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Host bearbeiten und archivieren", () => {
|
||||||
|
it("protokolliert nur geaenderte Felder", async () => {
|
||||||
|
const host = await hostAnlegen({ ...GRUND, tags: "pve" }, akteur);
|
||||||
|
await hostBearbeiten(host.id, { ...GRUND, tags: "pve", port: 2222, anmeldeart: "ROOT" }, akteur);
|
||||||
|
const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "host.geaendert" } });
|
||||||
|
expect(eintrag.details).toEqual({
|
||||||
|
port: { vorher: 22, nachher: 2222 },
|
||||||
|
anmeldeart: { vorher: "DIENSTBENUTZER", nachher: "ROOT" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schreibt ohne Aenderung keinen Eintrag", async () => {
|
||||||
|
const host = await hostAnlegen(GRUND, akteur);
|
||||||
|
await hostBearbeiten(host.id, GRUND, akteur);
|
||||||
|
expect(await prisma.protokoll.count({ where: { aktion: "host.geaendert" } })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("meldet beim Umbenennen einen vergebenen Namen", async () => {
|
||||||
|
await hostAnlegen(GRUND, akteur);
|
||||||
|
const zweiter = await hostAnlegen({ name: "srv-pve02", adresse: "10.0.0.12" }, akteur);
|
||||||
|
const fehler = await fehlerVon(hostBearbeiten(zweiter.id, { ...GRUND, adresse: "10.0.0.12" }, akteur));
|
||||||
|
expect(fehler.feldfehler["name"]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("archiviert statt zu loeschen, nimmt den Host aus Gruppen und Liste", async () => {
|
||||||
|
const gruppe = await hostgruppeAnlegen({ name: "Proxmox" }, akteur);
|
||||||
|
const host = await hostAnlegen({ ...GRUND, gruppenIds: [gruppe.id] }, akteur);
|
||||||
|
await hostArchivieren(host.id, akteur);
|
||||||
|
|
||||||
|
const gespeichert = await prisma.host.findUniqueOrThrow({ where: { id: host.id } });
|
||||||
|
expect(gespeichert.archiviertAm).not.toBeNull();
|
||||||
|
expect(await prisma.hostgruppeMitglied.count()).toBe(0);
|
||||||
|
expect(await hostListe()).toHaveLength(0);
|
||||||
|
const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "host.archiviert" } });
|
||||||
|
expect(eintrag.details).toEqual({ gruppen: ["Proxmox"] });
|
||||||
|
|
||||||
|
await fehlerVon(hostArchivieren(host.id, akteur));
|
||||||
|
await fehlerVon(hostBearbeiten(host.id, GRUND, akteur));
|
||||||
|
// Der Name bleibt belegt – sonst gaebe es zwei „srv-pve01“ im Protokoll.
|
||||||
|
await fehlerVon(hostAnlegen(GRUND, akteur));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Hostgruppen", () => {
|
||||||
|
it("legt mit Mitgliedern an und setzt sie neu", async () => {
|
||||||
|
const a = await hostAnlegen(GRUND, akteur);
|
||||||
|
const b = await hostAnlegen({ name: "srv-pve02", adresse: "10.0.0.12" }, akteur);
|
||||||
|
const gruppe = await hostgruppeAnlegen(
|
||||||
|
{ name: "Proxmox", beschreibung: "Cluster", hostIds: [a.id] },
|
||||||
|
akteur,
|
||||||
|
);
|
||||||
|
expect((await gruppeDetail(gruppe.id))?.mitglieder.map((m) => m.name)).toEqual(["srv-pve01"]);
|
||||||
|
|
||||||
|
await hostgruppeBearbeiten(gruppe.id, { name: "Proxmox VE", hostIds: [b.id] }, akteur);
|
||||||
|
const detail = await gruppeDetail(gruppe.id);
|
||||||
|
expect(detail?.name).toBe("Proxmox VE");
|
||||||
|
expect(detail?.beschreibung).toBeNull();
|
||||||
|
expect(detail?.mitglieder.map((m) => m.name)).toEqual(["srv-pve02"]);
|
||||||
|
|
||||||
|
const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "hostgruppe.geaendert" } });
|
||||||
|
expect(eintrag.details).toMatchObject({
|
||||||
|
name: { vorher: "Proxmox", nachher: "Proxmox VE" },
|
||||||
|
mitglieder: { hinzu: ["srv-pve02"], entfernt: ["srv-pve01"] },
|
||||||
|
});
|
||||||
|
expect(await prisma.protokoll.count({ where: { aktion: "hostgruppe.angelegt" } })).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("meldet doppelte Namen und ungueltige Mitglieder", async () => {
|
||||||
|
await hostgruppeAnlegen({ name: "DMZ" }, akteur);
|
||||||
|
const doppelt = await fehlerVon(hostgruppeAnlegen({ name: "DMZ" }, akteur));
|
||||||
|
expect(doppelt.message).toMatch(/existiert bereits/);
|
||||||
|
|
||||||
|
const host = await hostAnlegen(GRUND, akteur);
|
||||||
|
await hostArchivieren(host.id, akteur);
|
||||||
|
const archiviert = await fehlerVon(hostgruppeAnlegen({ name: "Neu", hostIds: [host.id] }, akteur));
|
||||||
|
expect(archiviert.feldfehler["hostIds"]).toBeDefined();
|
||||||
|
|
||||||
|
const leer = await fehlerVon(hostgruppeAnlegen({ name: " " }, akteur));
|
||||||
|
expect(leer.feldfehler["name"]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zaehlt Mitglieder in der Gruppenliste", async () => {
|
||||||
|
const a = await hostAnlegen(GRUND, akteur);
|
||||||
|
await hostgruppeAnlegen({ name: "Proxmox", hostIds: [a.id] }, akteur);
|
||||||
|
await hostgruppeAnlegen({ name: "DMZ" }, akteur);
|
||||||
|
expect((await gruppenListe()).map((g) => [g.name, g.mitglieder])).toEqual([
|
||||||
|
["DMZ", 0],
|
||||||
|
["Proxmox", 1],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Lesemodelle", () => {
|
||||||
|
it("filtert nach Name, Adresse, Tag und Gruppe", async () => {
|
||||||
|
const gruppe = await hostgruppeAnlegen({ name: "DMZ" }, akteur);
|
||||||
|
await hostAnlegen({ ...GRUND, tags: "pve" }, akteur);
|
||||||
|
await hostAnlegen({ name: "fw-edge01", adresse: "192.168.1.1", gruppenIds: [gruppe.id] }, akteur);
|
||||||
|
|
||||||
|
expect((await hostListe({ q: "PVE01" })).map((h) => h.name)).toEqual(["srv-pve01"]);
|
||||||
|
expect((await hostListe({ q: "192.168" })).map((h) => h.name)).toEqual(["fw-edge01"]);
|
||||||
|
expect((await hostListe({ q: "pve" })).map((h) => h.name)).toEqual(["srv-pve01"]);
|
||||||
|
expect((await hostListe({ gruppeId: gruppe.id })).map((h) => h.name)).toEqual(["fw-edge01"]);
|
||||||
|
expect(await hostListe()).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fasst Host-Keys und Abweichungen zusammen", async () => {
|
||||||
|
const host = await hostAnlegen(GRUND, akteur);
|
||||||
|
await prisma.hostSchluessel.create({
|
||||||
|
data: {
|
||||||
|
hostId: host.id,
|
||||||
|
algorithmus: "ssh-ed25519",
|
||||||
|
publicKey: "AAAA",
|
||||||
|
fingerprint: "SHA256:a",
|
||||||
|
status: "VERTRAUT",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let [eintrag] = await hostListe();
|
||||||
|
expect(eintrag?.hostKey.zustand).toBe("bestaetigt");
|
||||||
|
expect(eintrag?.status).toBe("unbekannt");
|
||||||
|
|
||||||
|
await prisma.hostSchluessel.create({
|
||||||
|
data: { hostId: host.id, algorithmus: "ssh-rsa", publicKey: "BBBB", fingerprint: "SHA256:b" },
|
||||||
|
});
|
||||||
|
await prisma.abweichung.create({ data: { hostId: host.id, art: "UNBEKANNTER_SCHLUESSEL" } });
|
||||||
|
[eintrag] = await hostListe();
|
||||||
|
expect(eintrag?.hostKey.zustand).toBe("ausstehend");
|
||||||
|
expect(eintrag?.offeneAbweichungen).toBe(1);
|
||||||
|
expect(eintrag?.status).toBe("drift");
|
||||||
|
|
||||||
|
const detail = await hostDetail(host.id);
|
||||||
|
expect(detail?.hostSchluessel).toHaveLength(2);
|
||||||
|
expect(detail?.hostKey).toMatchObject({ vertraut: 1, ausstehend: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Abgeleitete Zustaende", () => {
|
||||||
|
it("unerreichbar schlaegt Drift", () => {
|
||||||
|
expect(hostStatus({ erreichbar: false, driftZustand: "DRIFT", offeneAbweichungen: 3 })).toBe(
|
||||||
|
"unerreichbar",
|
||||||
|
);
|
||||||
|
expect(hostStatus({ erreichbar: true, driftZustand: "SYNCHRON", offeneAbweichungen: 0 })).toBe(
|
||||||
|
"synchron",
|
||||||
|
);
|
||||||
|
expect(hostStatus({ erreichbar: null, driftZustand: "UNBEKANNT", offeneAbweichungen: 0 })).toBe(
|
||||||
|
"unbekannt",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ein ausstehender Host-Key geht vor einem vertrauten", () => {
|
||||||
|
expect(hostKeyZusammenfassen([]).zustand).toBe("keiner");
|
||||||
|
expect(hostKeyZusammenfassen(["VERTRAUT", "ERSETZT"]).zustand).toBe("bestaetigt");
|
||||||
|
expect(hostKeyZusammenfassen(["VERTRAUT", "AUSSTEHEND"]).zustand).toBe("ausstehend");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Rechte der Aktionen", () => {
|
||||||
|
// Strukturell wie tests/zugang.test.ts: jede exportierte Aktion prueft das Recht selbst.
|
||||||
|
it.each(["src/server/actions/hosts.ts", "src/server/actions/hostgruppen.ts"])(
|
||||||
|
"%s verlangt hostsVerwalten in jeder Aktion",
|
||||||
|
(datei) => {
|
||||||
|
const inhalt = readFileSync(datei, "utf8");
|
||||||
|
expect(inhalt.startsWith('"use server";')).toBe(true);
|
||||||
|
const aktionen = inhalt.split(/^export async function /m).slice(1);
|
||||||
|
expect(aktionen.length).toBeGreaterThanOrEqual(2);
|
||||||
|
for (const rumpf of aktionen) {
|
||||||
|
expect(rumpf, rumpf.slice(0, 40)).toContain('rechtErforderlich("hostsVerwalten")');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user