From 7239ee56768442a851635541353356bb1a3d8e88 Mon Sep 17 00:00:00 2001 From: Gree44 Date: Sat, 25 Jul 2026 15:23:49 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Redeploy=20f=C3=BCr=20Deployment=20und?= =?UTF-8?q?=20einzelne=20VMs=20mit=20Config-Override=20(#192)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend-Anbindung an die bestehenden Backend-Redeploy-Endpoints: - POST /deployments/{id}/redeploy (ganzes Deployment) - POST /deployments/{id}/instances/{iid}/redeploy (einzelne VM) Änderungen: - api/deployments.ts: redeployDeployment() + redeployInstance() samt RedeployRequest-Typ. Body-Keys exakt nach dem eingefrorenen Backend-Schema (deployment_parameter_overrides, instance_parameter_overrides, preserve_credentials); Fehler-Mapping mit .status/.body für 400/403/404. - components/deployments/RedeployDialog.tsx (neu): gemeinsamer Dialog für Deployment- und VM-Redeploy. Editierbarer Config-Override (nur geänderte Keys werden gesendet), preserve_credentials-Toggle (Default an) mit Warnhinweis. Klar kommuniziert, dass ein Redeploy destroy+recreate ist und Config-Änderungen nur für diesen Lauf gelten (nicht persistent gespeichert). - pages/DeploymentDetails.tsx: "Neu deployen"-Button in der Aktionen-Card (nur bei running, owner-gated) ersetzt den alten Platzhalter; Dialog-State und per-VM-Handler an die Credential-Karten durchgereicht. - components/credentials/CredentialInstanceCard.tsx: optionale onRedeployInstance-Prop + "Neu deployen"-Button pro VM (nur Lecturer/Owner). - pages/DeploymentDetailsPage.tsx: onRefresh via reloadKey, damit der async 202-Statuswechsel (running -> per-VM REDEPLOYING) sichtbar wird. Config-Overrides werden ausschließlich beim Redeploy mitgegeben (nicht persistent), wie mit dem Backend-Vertrag abgestimmt. --- .gitignore | 3 + src/api/deployments.ts | 118 +++++++ .../credentials/CredentialInstanceCard.tsx | 35 ++- src/components/deployments/RedeployDialog.tsx | 293 ++++++++++++++++++ src/pages/DeploymentDetails.tsx | 56 +++- src/pages/DeploymentDetailsPage.tsx | 6 +- 6 files changed, 499 insertions(+), 12 deletions(-) create mode 100644 src/components/deployments/RedeployDialog.tsx diff --git a/.gitignore b/.gitignore index 931899d..7bd4301 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,6 @@ cypress/screenshots/ cypress/videos/ cypress/downloads/ cypress/shots/ + +# Other +*.mp4 \ No newline at end of file diff --git a/src/api/deployments.ts b/src/api/deployments.ts index a8bd90b..4a921a6 100644 --- a/src/api/deployments.ts +++ b/src/api/deployments.ts @@ -442,3 +442,121 @@ export async function extendDeployment( ); return resp.data; } + +// ── Redeploy: destroy + recreate ───────────────────────────────────────────── +// +// POST /api/v1/deployments/{id}/redeploy — alle VMs +// POST /api/v1/deployments/{id}/instances/{iid}/redeploy — genau eine VM +// +// Anders als "restart" (nur Heat update_stack) baut ein Redeploy jede VM neu auf: +// Heat-Stack gelöscht → neu erstellt → Ansible neu → Credentials neu generiert +// (außer preserve_credentials=true). So werden geänderte Config-/Template- +// Parameter tatsächlich wirksam. Beide Endpoints antworten mit 202 Accepted; der +// eigentliche Redeploy läuft asynchron (Celery), die betroffene VM geht auf +// Status REDEPLOYING, das Parent-Deployment bleibt RUNNING. +// +// Body-Keys müssen exakt stimmen — das Backend-Schema ist `extra="forbid"`, ein +// Tippfehler führt zu 422 statt stillem Verwerfen. + +export type RedeployRequest = { + /** + * Parameter, die ON TOP der gespeicherten deployment_parameters gemergt + * werden — für JEDE neu deployte VM. Leeres Objekt = Config unverändert + * übernehmen. Beim per-Instanz-Endpoint ist dies der volle Override für + * genau diese eine VM. + */ + deployment_parameter_overrides?: Record | null; + /** + * Per-VM-Overrides gekeyt auf DeploymentInstance.id, gemergt ON TOP der + * deployment-weiten Overrides. Wird vom per-Instanz-Endpoint ignoriert — + * dort die VM-Config direkt in deployment_parameter_overrides schicken. + */ + instance_parameter_overrides?: Record> | null; + /** + * true = bestehende Credentials (Passwörter/SSH-Keys/Aktivierungslinks) an + * die neu erstellte Instanz re-binden, statt sie neu zu generieren. Verhindert, + * dass Studenten-Logins bei einem Config-Redeploy brechen. + */ + preserve_credentials?: boolean; +}; + +export type RedeployDeploymentResponse = { + deployment_id: string; + instance_count: number; + status: string; + preserve_credentials: boolean; +}; + +export type RedeployInstanceResponse = { + deployment_id: string; + instance_id: string; + status: string; + preserve_credentials: boolean; +}; + +/** + * Redeploy every VM in a deployment. Wirft `Error & { status; body }` auf + * non-2xx, damit der Caller 400 ("redeploy already in progress") / 403 / 404 + * unterscheiden und einen verständlichen Toast zeigen kann. + */ +export async function redeployDeployment( + deploymentId: string, + body: RedeployRequest, + openstackProjectId: string | null, +): Promise { + await keycloak.updateToken(30).catch(() => {}); + const res = await fetch( + `/api/v1/deployments/${deploymentId}/redeploy${projectQuery(openstackProjectId)}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(keycloak.token ? { Authorization: `Bearer ${keycloak.token}` } : {}), + }, + body: JSON.stringify(body), + }, + ); + if (!res.ok) { + const text = await res.text().catch(() => ""); + const err = new Error(text || res.statusText) as Error & { status: number; body: string }; + err.status = res.status; + err.body = text; + throw err; + } + const json = await res.json(); + return json.data as RedeployDeploymentResponse; +} + +/** + * Redeploy exactly one VM (DeploymentInstance) inside a deployment. Die VM-Config + * geht in `deployment_parameter_overrides` (der Endpoint ignoriert + * `instance_parameter_overrides`, da nur eine VM im Scope ist). + */ +export async function redeployInstance( + deploymentId: string, + instanceId: string, + body: RedeployRequest, + openstackProjectId: string | null, +): Promise { + await keycloak.updateToken(30).catch(() => {}); + const res = await fetch( + `/api/v1/deployments/${deploymentId}/instances/${instanceId}/redeploy${projectQuery(openstackProjectId)}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(keycloak.token ? { Authorization: `Bearer ${keycloak.token}` } : {}), + }, + body: JSON.stringify(body), + }, + ); + if (!res.ok) { + const text = await res.text().catch(() => ""); + const err = new Error(text || res.statusText) as Error & { status: number; body: string }; + err.status = res.status; + err.body = text; + throw err; + } + const json = await res.json(); + return json.data as RedeployInstanceResponse; +} diff --git a/src/components/credentials/CredentialInstanceCard.tsx b/src/components/credentials/CredentialInstanceCard.tsx index 04b3542..034957e 100644 --- a/src/components/credentials/CredentialInstanceCard.tsx +++ b/src/components/credentials/CredentialInstanceCard.tsx @@ -15,7 +15,7 @@ // allen rows). SSH-Key-Download geht über onDownloadSshKey-Prop, die // den dedizierten /access/{id}/ssh-key-Endpoint anstößt. import { useState } from "react"; -import { Copy, Download, Eye, EyeOff, Key, Loader2, ShieldCheck } from "lucide-react"; +import { Copy, Download, Eye, EyeOff, Key, Loader2, RefreshCw, ShieldCheck } from "lucide-react"; import { Card, CardContent, @@ -85,6 +85,13 @@ export interface CredentialInstanceCardProps { * wir case-insensitive gegen `access.username`. Im Student-Mode irrelevant. */ currentUsername?: string | null; + /** + * Optional (nur Lecturer-Mode sinnvoll). Wenn gesetzt, erscheint im + * Karten-Header ein „Neu deployen"-Button für genau diese VM. Ruft die + * Funktion mit (instanceId, vmName) auf — der Caller öffnet damit den + * RedeployDialog im Instanz-Modus (Issue #192). + */ + onRedeployInstance?: (instanceId: string, vmName: string) => void; } export function CredentialInstanceCard({ @@ -96,6 +103,7 @@ export function CredentialInstanceCard({ getMaskedPassword, onDownloadSshKey, currentUsername, + onRedeployInstance, }: CredentialInstanceCardProps) { // Lecturer-View: Dozent-Zeilen (group_id IS NULL) vs. Gruppen-Zeilen. // Student-View: alle Zeilen sind Gruppen-Zeilen (Backend filtert NULL aus), @@ -123,10 +131,27 @@ export function CredentialInstanceCard({ return ( - {instance.vm_name || "VM"} - - Stack ID: {instance.openstack_stack_id || "-"} - +
+
+ {instance.vm_name || "VM"} + + Stack ID: {instance.openstack_stack_id || "-"} + +
+ {onRedeployInstance && ( + + )} +
{/* diff --git a/src/components/deployments/RedeployDialog.tsx b/src/components/deployments/RedeployDialog.tsx new file mode 100644 index 0000000..f181088 --- /dev/null +++ b/src/components/deployments/RedeployDialog.tsx @@ -0,0 +1,293 @@ +// Redeploy-Dialog für Issue #192 — destroy+recreate eines gesamten Deployments +// oder einer einzelnen VM, mit optionalem Config-Override und einem Toggle für +// preserve_credentials. +// +// Wichtig fürs Framing: Ein Redeploy SPEICHERT keine Config, sondern BAUT NEU AUF. +// Die hier geänderten Parameter werden nur für genau diesen Redeploy-Lauf +// angewendet (das Backend schreibt sie nicht dauerhaft in deployment_parameters +// zurück). Der Dialog-Text macht das explizit. +// +// Feld-Rendering ist bewusst an DeploymentWizard.renderParameterField angelehnt +// (boolean → Switch, number → Input[number], sonst Text). Wir haben hier aber +// keine TemplateParameter-Definitionen zur Hand — nur die gespeicherten +// Key/Value-Paare aus deployment_parameters.parameters. Den Feldtyp leiten wir +// daher aus `typeof value` ab. Nur tatsächlich geänderte Keys werden als Override +// gesendet (Diff gegen die Startwerte); unveränderte Config → leeres Objekt = +// "unverändert übernehmen". +import { useEffect, useMemo, useState } from "react"; +import { AlertTriangle, Loader2, RefreshCw } from "lucide-react"; +import { toast } from "sonner@2.0.3"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../ui/dialog"; +import { Button } from "../ui/button"; +import { Label } from "../ui/label"; +import { Input } from "../ui/input"; +import { Switch } from "../ui/switch"; +import { Checkbox } from "../ui/checkbox"; +import { + redeployDeployment, + redeployInstance, + type RedeployRequest, +} from "../../api/deployments"; + +export type RedeployTarget = + | { kind: "deployment" } + | { kind: "instance"; instanceId: string; vmName: string }; + +export interface RedeployDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + deploymentId: string; + /** Zielobjekt: ganzes Deployment oder eine einzelne VM. */ + target: RedeployTarget; + /** + * Aktuelle Config-Parameter (aus deployment.deploymentParameters.parameters). + * Dienen als editierbare Startwerte. Leer/undefined → nur der + * preserve_credentials-Toggle wird angezeigt. + */ + currentParameters?: Record; + openstackProjectId: string | null; + /** Wird nach erfolgreichem 202 aufgerufen (z.B. Detailseite neu laden). */ + onRedeployed?: () => void; +} + +// Feldtyp aus dem gespeicherten Wert ableiten — wir haben hier keine +// TemplateParameter-Metadaten. +type FieldType = "boolean" | "number" | "text"; +function inferType(value: any): FieldType { + if (typeof value === "boolean") return "boolean"; + if (typeof value === "number") return "number"; + return "text"; +} + +function humanizeKey(key: string): string { + return key.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); +} + +export function RedeployDialog({ + open, + onOpenChange, + deploymentId, + target, + currentParameters, + openstackProjectId, + onRedeployed, +}: RedeployDialogProps) { + // Startwerte einfrieren, sobald der Dialog aufgeht — der Diff beim Absenden + // vergleicht dagegen. + const initialParams = useMemo( + () => currentParameters ?? {}, + [currentParameters], + ); + const paramKeys = useMemo(() => Object.keys(initialParams), [initialParams]); + + const [values, setValues] = useState>(initialParams); + const [preserveCredentials, setPreserveCredentials] = useState(true); + const [inFlight, setInFlight] = useState(false); + + // Bei jedem Öffnen die Formularwerte auf den aktuellen Stand zurücksetzen. + useEffect(() => { + if (open) { + setValues(initialParams); + setPreserveCredentials(true); + setInFlight(false); + } + }, [open, initialParams]); + + const isInstance = target.kind === "instance"; + const titleTarget = isInstance ? `VM „${target.vmName}"` : "Deployment"; + + const handleValueChange = (key: string, value: any) => { + setValues((prev) => ({ ...prev, [key]: value })); + }; + + // Nur geänderte Keys als Override — unveränderte Config fällt backend-seitig + // auf die gespeicherten Werte zurück. + const buildOverrides = (): Record => { + const overrides: Record = {}; + for (const key of paramKeys) { + if (values[key] !== initialParams[key]) { + overrides[key] = values[key]; + } + } + return overrides; + }; + + const handleSubmit = async () => { + if (inFlight) return; + setInFlight(true); + + const overrides = buildOverrides(); + // Für beide Endpoints wandert der Override in deployment_parameter_overrides: + // beim per-Instanz-Endpoint IST das der volle Override für diese eine VM + // (instance_parameter_overrides wird dort ignoriert). + const body: RedeployRequest = { + deployment_parameter_overrides: overrides, + preserve_credentials: preserveCredentials, + }; + + try { + if (isInstance) { + await redeployInstance(deploymentId, target.instanceId, body, openstackProjectId); + toast.success(`Redeploy für VM „${target.vmName}" gestartet.`); + } else { + await redeployDeployment(deploymentId, body, openstackProjectId); + toast.success("Redeploy für das Deployment gestartet."); + } + onOpenChange(false); + onRedeployed?.(); + } catch (err) { + const e = err as Error & { status?: number }; + // 400 = bereits ein Redeploy in Arbeit (Race-Schutz im Backend). + if (e.status === 400) { + toast.error( + "Es läuft bereits ein Redeploy für dieses Deployment. Bitte warten, bis er abgeschlossen ist.", + ); + } else if (e.status === 403) { + toast.error("Keine Berechtigung für diesen Redeploy."); + } else if (e.status === 404) { + toast.error("Deployment oder VM nicht gefunden. Bitte Seite neu laden."); + } else { + toast.error("Redeploy konnte nicht gestartet werden. Bitte erneut versuchen."); + } + console.error("redeploy failed", err); + } finally { + setInFlight(false); + } + }; + + const renderField = (key: string) => { + const type = inferType(initialParams[key]); + const value = values[key]; + const fieldId = `redeploy-param-${key}`; + + if (type === "boolean") { + return ( +
+ + handleValueChange(key, checked)} + /> +
+ ); + } + + if (type === "number") { + return ( +
+ + { + if (!e.target.value) { + handleValueChange(key, ""); + return; + } + const next = Number(e.target.value); + handleValueChange(key, Number.isNaN(next) ? "" : next); + }} + /> +
+ ); + } + + return ( +
+ + handleValueChange(key, e.target.value)} + /> +
+ ); + }; + + return ( + + + + + + {titleTarget} neu deployen + + + {isInstance + ? `Die VM „${target.vmName}" wird zerstört und neu aufgebaut. Andere VMs des Deployments bleiben unberührt.` + : "Alle VMs dieses Deployments werden nacheinander zerstört und neu aufgebaut."}{" "} + Änderungen an der Konfiguration werden nur für diesen Redeploy angewendet. + + + +
+ {paramKeys.length > 0 && ( +
+

Konfiguration

+ {paramKeys.map((key) => renderField(key))} +
+ )} + +
+ setPreserveCredentials(checked === true)} + className="mt-0.5" + /> +
+ +

+ Bestehende Passwörter, SSH-Keys und Aktivierungslinks bleiben gültig. + Deaktivieren, um beim Redeploy frische Zugangsdaten zu erzeugen. +

+
+
+ + {!preserveCredentials && ( +
+ +

+ Ohne „Zugangsdaten beibehalten" werden neue Passwörter und SSH-Keys + generiert — bestehende Studenten-Logins funktionieren danach nicht mehr. +

+
+ )} +
+ + + + + +
+
+ ); +} diff --git a/src/pages/DeploymentDetails.tsx b/src/pages/DeploymentDetails.tsx index 3369424..3b72f98 100644 --- a/src/pages/DeploymentDetails.tsx +++ b/src/pages/DeploymentDetails.tsx @@ -17,6 +17,7 @@ import { AlertOctagon, Calendar, Users, + RefreshCw, } from "lucide-react"; import { toast } from "sonner@2.0.3"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card"; @@ -55,6 +56,7 @@ import { getExpiryState } from "../utils/deployment"; import { useActiveOpenstackProject } from "../contexts/OpenstackProjectContext"; import { useCurrentUser } from "../auth/useCurrentUser"; import { CredentialInstanceCard } from "../components/credentials/CredentialInstanceCard"; +import { RedeployDialog, type RedeployTarget } from "../components/deployments/RedeployDialog"; import { Select, SelectContent, @@ -164,9 +166,15 @@ interface DeploymentDetailsProps { * step pre-filled with the same configuration. */ onRetry?: (deploymentId: string) => Promise | void; + /** + * Wird nach einem erfolgreich angestoßenen Redeploy (Deployment oder VM) + * aufgerufen, damit die Detailseite neu lädt und den Statuswechsel + * (running → per-VM REDEPLOYING) zeigt. Redeploy ist async (202). + */ + onRefresh?: () => void; } -export function DeploymentDetails({ deployment, onBack, onDelete, onRetry }: DeploymentDetailsProps) { +export function DeploymentDetails({ deployment, onBack, onDelete, onRetry, onRefresh }: DeploymentDetailsProps) { const { activeProjectId } = useActiveOpenstackProject(); const currentUser = useCurrentUser(); // ── Owner-only gate for the Aktionen card ──────────────────────────────── @@ -201,6 +209,10 @@ export function DeploymentDetails({ deployment, onBack, onDelete, onRetry }: Dep const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [detailsDialogOpen, setDetailsDialogOpen] = useState(false); + // Redeploy-Dialog (Issue #192). `redeployTarget` bestimmt, ob das ganze + // Deployment oder eine einzelne VM neu deployt wird; null = Dialog zu. + const [redeployTarget, setRedeployTarget] = useState(null); + // ── Status-driven delete/cancel action ──────────────────────────────────── // // Backend uses ONE endpoint for cancel-build and delete-deployment; it @@ -1027,11 +1039,23 @@ export function DeploymentDetails({ deployment, onBack, onDelete, onRetry }: Dep Aktionen - {/*deployment.status === 'running' && ( - - )*/} + {/* Redeploy des gesamten Deployments (Issue #192) — nur für + laufende Deployments. Öffnet den RedeployDialog im + Deployment-Modus (Config-Override + preserve_credentials). */} + {deployment.status === 'running' && ( + withOwnerTooltip( + , + !canManageDeployment, + ) + )} {/* Retry-from-failed (separate flow from the delete/cleanup action) */} {deployment.status === 'failed' && ( @@ -1200,6 +1224,12 @@ export function DeploymentDetails({ deployment, onBack, onDelete, onRetry }: Dep getMaskedPassword={getMaskedPassword} onDownloadSshKey={handleDownloadSshKey} currentUsername={currentUsername} + onRedeployInstance={ + canManageDeployment && deployment.status === 'running' + ? (instanceId, vmName) => + setRedeployTarget({ kind: "instance", instanceId, vmName }) + : undefined + } /> ))} @@ -1217,6 +1247,20 @@ export function DeploymentDetails({ deployment, onBack, onDelete, onRetry }: Dep keycloakCourseId={deployment.keycloakCourseId} /> )} + + {/* Redeploy-Dialog (Issue #192) — gemeinsam für Deployment- und + per-VM-Redeploy. `redeployTarget` steuert den Modus. */} + { + if (!open) setRedeployTarget(null); + }} + deploymentId={deployment.id} + target={redeployTarget ?? { kind: "deployment" }} + currentParameters={deployment.deploymentParameters?.parameters} + openstackProjectId={activeProjectId} + onRedeployed={() => onRefresh?.()} + /> ); } diff --git a/src/pages/DeploymentDetailsPage.tsx b/src/pages/DeploymentDetailsPage.tsx index 78688ec..1f36991 100644 --- a/src/pages/DeploymentDetailsPage.tsx +++ b/src/pages/DeploymentDetailsPage.tsx @@ -177,6 +177,9 @@ export function DeploymentDetailsPage() { const { activeProjectId } = useActiveOpenstackProject(); const [deploymentData, setDeploymentData] = useState(null); const [loadingDeployment, setLoadingDeployment] = useState(false); + // Bump to force a full re-fetch of the deployment (used after a redeploy is + // queued — Issue #192 — so the async 202 status change surfaces). + const [reloadKey, setReloadKey] = useState(0); const isDeletingRef = useRef(false); // Stable reference data fetched once @@ -629,7 +632,7 @@ export function DeploymentDetailsPage() { }).catch(() => setLoadingDeployment(false)); return () => { stopStreamRef.current?.(); stopStreamRef.current = null; }; - }, [deploymentId, activeProjectId, buildDeploymentData]); + }, [deploymentId, activeProjectId, buildDeploymentData, reloadKey]); if (!deploymentId) return ; if (loadingDeployment) return
Lade Deployment...
; @@ -641,6 +644,7 @@ export function DeploymentDetailsPage() { onBack={handleBackToDashboard} onDelete={handleDeleteDeployment} onRetry={handleRetryDeployment} + onRefresh={() => setReloadKey((k) => k + 1)} /> ); }