feat(ausrollen): Public Keys mit Trockenlauf und Backup ausrollen (#016)
Zuweisungen von Public Keys an Hosts oder Hostgruppen, ein Trockenlauf mit Diff je Ziel und das Ausrollen genau dieses Plans. Hat sich die Datei oder das Soll seit dem Trockenlauf geaendert, bricht das Ziel ab: es wird nie etwas anderes geschrieben, als jemand gesehen hat. Auf dem Host geschieht alles als Zielbenutzer, mit Sicherung, Temp- Datei, mv und 0700/0600; der Hash wird dort erneut geprueft. Der Deployment-Key gehoert immer zum Soll des Anmeldebenutzers. Integrationstests gegen echtes sshd, die Oberflaechenbausteine folgen mit der Schluesselseite aus #012. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { Play, ScanSearch } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { DiffBox } from "@/components/ui/DiffBox";
|
||||
import { laeuftNoch, useVorgang } from "@/components/vorgang/useVorgang";
|
||||
import { ausrollenAktion, trockenlaufAktion } from "@/server/actions/verwendungen";
|
||||
import type { DiffZeile } from "@/lib/ssh/zeilen-diff";
|
||||
|
||||
/**
|
||||
* Dry-Run-Vorschau und Ausfuehren (#016). Was angezeigt wird, ist genau das, was der Worker
|
||||
* schreibt: der Plan traegt die Hashes, und das Ausrollen bricht ab, wenn sich seitdem auf
|
||||
* dem Host oder an den Zuweisungen etwas geaendert hat.
|
||||
*/
|
||||
type PlanZiel = {
|
||||
hostName: string;
|
||||
zielbenutzer: string;
|
||||
pfad?: string;
|
||||
geaendert: boolean;
|
||||
diff: DiffZeile[];
|
||||
hinweise: string[];
|
||||
fehler?: string;
|
||||
};
|
||||
|
||||
export function AusrollenPanel({ schluesselId, offen }: { schluesselId: string; offen: number }) {
|
||||
const router = useRouter();
|
||||
const [trockenlaufId, setTrockenlaufId] = useState<string>();
|
||||
const [ausrollenId, setAusrollenId] = useState<string>();
|
||||
const [fehler, setFehler] = useState<string>();
|
||||
const [startet, starten] = useTransition();
|
||||
const trockenlauf = useVorgang(trockenlaufId);
|
||||
const ausrollen = useVorgang(ausrollenId);
|
||||
|
||||
useEffect(() => {
|
||||
if (ausrollen.stand && !laeuftNoch(ausrollen.stand)) router.refresh();
|
||||
}, [ausrollen.stand, router]);
|
||||
|
||||
const plan = (trockenlauf.stand?.plan as { ziele?: PlanZiel[] } | null)?.ziele;
|
||||
const aenderungen = plan?.filter((z) => z.geaendert && !z.fehler).length ?? 0;
|
||||
const laeuft = startet || laeuftNoch(trockenlauf.stand) || laeuftNoch(ausrollen.stand);
|
||||
const fertig = ausrollen.stand?.status === "ERLEDIGT";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
icon={<ScanSearch className="size-4" />}
|
||||
disabled={laeuft}
|
||||
onClick={() =>
|
||||
starten(async () => {
|
||||
setFehler(undefined);
|
||||
setAusrollenId(undefined);
|
||||
const antwort = await trockenlaufAktion(schluesselId);
|
||||
if (antwort.fehler) setFehler(antwort.fehler);
|
||||
else setTrockenlaufId(antwort.vorgangId);
|
||||
})
|
||||
}
|
||||
>
|
||||
{laeuftNoch(trockenlauf.stand) ? "Trockenlauf läuft …" : "Dry-Run starten"}
|
||||
</Button>
|
||||
{offen > 0 && !plan && (
|
||||
<span className="text-accent text-[13.5px]">{offen} Zuweisung(en) noch nicht ausgerollt.</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(fehler || trockenlauf.fehler || trockenlauf.stand?.status === "FEHLGESCHLAGEN") && (
|
||||
<p role="status" className="text-danger text-[14px]">
|
||||
{fehler ?? trockenlauf.fehler ?? trockenlauf.stand?.fehler}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{plan?.map((ziel) => (
|
||||
<div key={`${ziel.hostName}-${ziel.zielbenutzer}`} className="flex flex-col gap-2">
|
||||
{ziel.fehler ? (
|
||||
<p className="border-danger-border bg-danger-soft text-danger rounded-box border px-4 py-3 font-mono text-[13px]">
|
||||
{ziel.zielbenutzer}@{ziel.hostName}: {ziel.fehler}
|
||||
</p>
|
||||
) : (
|
||||
<DiffBox
|
||||
datei={`${ziel.zielbenutzer}@${ziel.hostName}:${ziel.pfad ?? "~/.ssh/authorized_keys"}`}
|
||||
zeilen={ziel.geaendert ? ziel.diff : [{ art: "kontext", text: "Keine Änderung nötig." }]}
|
||||
/>
|
||||
)}
|
||||
{ziel.hinweise.map((h) => (
|
||||
<p key={h} className="text-accent text-[13px]">
|
||||
▲ {h}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{plan && aenderungen > 0 && !fertig && (
|
||||
<div className="border-border flex flex-col gap-3 border-t pt-4">
|
||||
<label className="text-muted flex min-h-11 items-center gap-3 text-[14px]">
|
||||
<input type="checkbox" checked disabled className="accent-accent size-5" />
|
||||
Backup jeder Datei anlegen (
|
||||
<code className="font-mono text-[12.5px]">authorized_keys.sshkv.bak.…</code>)
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
variante="primaer"
|
||||
icon={<Play className="size-4" />}
|
||||
disabled={laeuft}
|
||||
onClick={() =>
|
||||
starten(async () => {
|
||||
const antwort = await ausrollenAktion(trockenlaufId!);
|
||||
if (antwort.fehler) setFehler(antwort.fehler);
|
||||
else setAusrollenId(antwort.vorgangId);
|
||||
})
|
||||
}
|
||||
>
|
||||
{laeuftNoch(ausrollen.stand) ? "Wird ausgerollt …" : `Änderung anwenden (${aenderungen})`}
|
||||
</Button>
|
||||
<Button disabled={laeuft} onClick={() => setTrockenlaufId(undefined)}>
|
||||
Verwerfen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{plan && aenderungen === 0 && (
|
||||
<p className="text-ok text-[14px]">Alles ist bereits auf dem Soll-Stand.</p>
|
||||
)}
|
||||
{ausrollen.stand?.status === "FEHLGESCHLAGEN" && (
|
||||
<p role="status" className="text-danger text-[14px]">
|
||||
{ausrollen.stand.fehler}
|
||||
</p>
|
||||
)}
|
||||
{fertig && <p className="text-ok text-[14px]">Ausgerollt.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user