Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,6 @@ cypress/screenshots/
cypress/videos/
cypress/downloads/
cypress/shots/

# Other
*.mp4
118 changes: 118 additions & 0 deletions src/api/deployments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> | 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<string, Record<string, any>> | 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<RedeployDeploymentResponse> {
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<RedeployInstanceResponse> {
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;
}
35 changes: 30 additions & 5 deletions src/components/credentials/CredentialInstanceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand All @@ -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),
Expand Down Expand Up @@ -123,10 +131,27 @@ export function CredentialInstanceCard({
return (
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-base">{instance.vm_name || "VM"}</CardTitle>
<CardDescription>
Stack ID: {instance.openstack_stack_id || "-"}
</CardDescription>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<CardTitle className="text-base">{instance.vm_name || "VM"}</CardTitle>
<CardDescription>
Stack ID: {instance.openstack_stack_id || "-"}
</CardDescription>
</div>
{onRedeployInstance && (
<Button
variant="outline"
size="sm"
className="flex-shrink-0"
onClick={() =>
onRedeployInstance(instance.instance_id, instance.vm_name || "VM")
}
>
<RefreshCw className="w-4 h-4 mr-2" />
Neu deployen
</Button>
)}
</div>
</CardHeader>
<CardContent>
{/*
Expand Down
Loading
Loading