feat: add cluster update/delete endpoints, expand tcp/udp protocol handling, and enhance cluster management UI
CI / backend (push) Failing after 3s
CI / frontend (push) Failing after 28s

Add PATCH /clusters/{cluster_id} endpoint with optional token update and audit logging, implement DELETE /clusters/{cluster_id} with cascading deletion of nodes, workloads, networks, subnets, and IP addresses, expand firewall rule generation to split tcp/udp protocol into separate tcp and udp rules for Proxmox compatibility, add ClusterUpdate schema with optional api_token field, include
This commit is contained in:
2026-07-09 14:04:17 +02:00
parent 1ada59d3c7
commit 88badf1f22
7 changed files with 270 additions and 79 deletions
+67 -2
View File
@@ -36,6 +36,7 @@ from app.schemas.domain import (
AuditLogRead,
ClusterCreate,
ClusterRead,
ClusterUpdate,
FirewallApplyRequest,
FirewallPreview,
IpAddressRead,
@@ -264,7 +265,9 @@ def resolve_firewall_preview(db: Session, cluster: Cluster, preview: FirewallPre
ports = str(rule.get("ports", "any"))
protocol = str(rule.get("protocol", "any"))
protocols = ["tcp", "udp"] if protocol == "tcp/udp" else [protocol]
for remote_value in remote_values:
for provider_protocol in protocols:
provider_rule = {
"type": "in" if direction == "ingress" else "out",
"action": proxmox_action(str(rule.get("action", "allow"))),
@@ -274,8 +277,8 @@ def resolve_firewall_preview(db: Session, cluster: Cluster, preview: FirewallPre
f"rule={rule_index} target={target_workload.name}"
),
}
if protocol != "any":
provider_rule["proto"] = protocol
if provider_protocol != "any":
provider_rule["proto"] = provider_protocol
if ports != "any":
provider_rule["dport"] = ports
if remote_value:
@@ -434,6 +437,68 @@ def create_cluster(payload: ClusterCreate, user: CurrentUser, db: Session = Depe
return cluster
@api_router.patch("/clusters/{cluster_id}", response_model=ClusterRead)
def update_cluster(cluster_id: str, payload: ClusterUpdate, user: CurrentUser, db: Session = Depends(get_db)) -> Cluster:
cluster = db.get(Cluster, cluster_id)
if not cluster:
raise HTTPException(status_code=404, detail="Cluster not found")
old_values = {
"name": cluster.name,
"api_url": cluster.api_url,
"provider": cluster.provider,
"mode": cluster.mode,
"verify_tls": cluster.verify_tls,
}
cluster.name = payload.name
cluster.api_url = payload.api_url
cluster.provider = payload.provider
cluster.mode = payload.mode
cluster.verify_tls = payload.verify_tls
if payload.api_token:
cluster.token_ref = payload.api_token
commit_or_400(db)
db.refresh(cluster)
write_audit(
db,
action="cluster.updated",
object_type="cluster",
object_id=cluster.id,
user_id=user.id,
old_values=old_values,
new_values={**payload.model_dump(exclude={"api_token"}), "api_token_changed": bool(payload.api_token)},
)
return cluster
@api_router.delete("/clusters/{cluster_id}")
def delete_cluster(cluster_id: str, user: CurrentUser, db: Session = Depends(get_db)) -> dict[str, str]:
cluster = db.get(Cluster, cluster_id)
if not cluster:
raise HTTPException(status_code=404, detail="Cluster not found")
workload_ids = [row[0] for row in db.execute(select(Workload.id).where(Workload.cluster_id == cluster.id)).all()]
if workload_ids:
for address in db.scalars(select(IpAddress).where(IpAddress.workload_id.in_(workload_ids))).all():
db.delete(address)
network_ids = [row[0] for row in db.execute(select(Network.id).where(Network.cluster_id == cluster.id)).all()]
if network_ids:
subnet_ids = [row[0] for row in db.execute(select(Subnet.id).where(Subnet.network_id.in_(network_ids))).all()]
if subnet_ids:
for address in db.scalars(select(IpAddress).where(IpAddress.subnet_id.in_(subnet_ids))).all():
db.delete(address)
for subnet in db.scalars(select(Subnet).where(Subnet.id.in_(subnet_ids))).all():
db.delete(subnet)
for network in db.scalars(select(Network).where(Network.id.in_(network_ids))).all():
db.delete(network)
for workload in db.scalars(select(Workload).where(Workload.cluster_id == cluster.id)).all():
db.delete(workload)
for node in db.scalars(select(Node).where(Node.cluster_id == cluster.id)).all():
db.delete(node)
db.delete(cluster)
commit_or_400(db)
write_audit(db, action="cluster.deleted", object_type="cluster", object_id=cluster_id, user_id=user.id)
return {"status": "deleted", "id": cluster_id}
@api_router.post("/clusters/{cluster_id}/test")
async def test_cluster(cluster_id: str, _: CurrentUser, db: Session = Depends(get_db)) -> dict:
cluster = db.get(Cluster, cluster_id)
+10
View File
@@ -71,6 +71,15 @@ class ClusterCreate(BaseModel):
verify_tls: bool = True
class ClusterUpdate(BaseModel):
name: str
api_url: str
api_token: str | None = Field(default=None, min_length=8)
provider: str = "proxmox"
mode: str = "read_only"
verify_tls: bool = True
class TenantCreate(BaseModel):
name: str
description: str | None = None
@@ -157,6 +166,7 @@ class ClusterRead(OrmModel):
api_url: str
provider: str
mode: str
verify_tls: bool
last_sync_at: datetime | None
last_sync_status: str | None
last_sync_error: str | None
+2
View File
@@ -22,6 +22,7 @@ export type Cluster = {
api_url: string;
provider: string;
mode: string;
verify_tls: boolean;
last_sync_status: string | null;
};
@@ -104,6 +105,7 @@ export type SecurityRule = {
export type Policy = {
id: string;
project_id: string | null;
name: string;
version: number;
enabled: boolean;
+1 -1
View File
@@ -18,4 +18,4 @@ export const inputClass = "h-10 w-full rounded-md border border-border bg-transp
export const selectClass = inputClass;
export const buttonClass = "inline-flex h-10 items-center justify-center gap-2 rounded-md bg-accent px-4 text-sm font-medium text-white disabled:opacity-50";
export const secondaryButtonClass = "inline-flex h-10 items-center justify-center gap-2 rounded-md border border-border px-4 text-sm hover:bg-slate-100 dark:hover:bg-slate-800";
export const iconButtonClass = "inline-flex h-9 w-9 items-center justify-center rounded-md border border-border text-slate-600 hover:bg-slate-100 hover:text-slate-950 disabled:opacity-50 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white";
+68 -22
View File
@@ -1,38 +1,71 @@
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Cable, Plus, RefreshCcw, Server } from "lucide-react";
import { Cable, Pencil, Plus, RefreshCcw, Server, Trash2 } from "lucide-react";
import { api, Cluster } from "../api/client";
import { DataTable } from "../components/DataTable";
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
import { buttonClass, Field, iconButtonClass, inputClass, selectClass } from "../components/FormControls";
import { Modal } from "../components/Modal";
import { PageHeader } from "../components/PageHeader";
export function Clusters() {
const queryClient = useQueryClient();
const clusters = useQuery({ queryKey: ["clusters"], queryFn: () => api<Cluster[]>("/clusters") });
const [form, setForm] = useState({
const emptyClusterForm = {
name: "Demo Provider",
api_url: "https://demo.local:8006",
api_token: "PVEAPIToken=demo",
provider: "demo",
mode: "read_only",
verify_tls: true,
});
};
export function Clusters() {
const queryClient = useQueryClient();
const clusters = useQuery({ queryKey: ["clusters"], queryFn: () => api<Cluster[]>("/clusters") });
const [form, setForm] = useState(emptyClusterForm);
const [result, setResult] = useState("");
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<Cluster | null>(null);
const create = useMutation({
mutationFn: () => api<Cluster>("/clusters", { method: "POST", body: JSON.stringify(form) }),
const save = useMutation({
mutationFn: () => {
const body = editing && !form.api_token ? { ...form, api_token: null } : form;
return api<Cluster>(editing ? `/clusters/${editing.id}` : "/clusters", {
method: editing ? "PATCH" : "POST",
body: JSON.stringify(body),
});
},
onSuccess: () => {
setOpen(false);
setEditing(null);
queryClient.invalidateQueries({ queryKey: ["clusters"] });
},
});
const remove = useMutation({
mutationFn: (cluster: Cluster) => api(`/clusters/${cluster.id}`, { method: "DELETE" }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["clusters"] }),
});
async function submit(event: FormEvent) {
event.preventDefault();
await create.mutateAsync();
await save.mutateAsync();
}
function addCluster() {
setEditing(null);
setForm(emptyClusterForm);
setOpen(true);
}
function editCluster(cluster: Cluster) {
setEditing(cluster);
setForm({
name: cluster.name,
api_url: cluster.api_url,
api_token: "",
provider: cluster.provider,
mode: cluster.mode,
verify_tls: cluster.verify_tls,
});
setOpen(true);
}
async function action(cluster: Cluster, kind: "test" | "sync") {
@@ -41,18 +74,24 @@ export function Clusters() {
await queryClient.invalidateQueries({ queryKey: ["clusters"] });
}
function deleteCluster(cluster: Cluster) {
if (window.confirm(`Delete cluster "${cluster.name}" and its imported inventory/IPAM data?`)) {
remove.mutate(cluster);
}
}
return (
<>
<PageHeader title="Clusters" subtitle="Register, test, and sync Proxmox or demo providers." />
<div className="space-y-4">
<button className={buttonClass} onClick={() => setOpen(true)}><Plus size={16} /> Add Cluster</button>
<Modal title="Add Cluster" open={open} onClose={() => setOpen(false)}>
<button className={buttonClass} onClick={addCluster}><Plus size={16} /> Add Cluster</button>
<Modal title={editing ? "Edit Cluster" : "Add Cluster"} open={open} onClose={() => setOpen(false)}>
<form onSubmit={submit}>
<div className="mb-4 flex items-center gap-2 font-medium"><Server size={18} /> Provider connection</div>
<div className="grid gap-3">
<Field label="Name"><input className={inputClass} value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></Field>
<Field label="API URL"><input className={inputClass} value={form.api_url} onChange={(event) => setForm({ ...form, api_url: event.target.value })} /></Field>
<Field label="API Token"><input className={inputClass} value={form.api_token} onChange={(event) => setForm({ ...form, api_token: event.target.value })} /></Field>
<Field label={editing ? "API Token (leave empty to keep current)" : "API Token"}><input className={inputClass} value={form.api_token} onChange={(event) => setForm({ ...form, api_token: event.target.value })} /></Field>
<Field label="Provider">
<select className={selectClass} value={form.provider} onChange={(event) => setForm({ ...form, provider: event.target.value })}>
<option value="demo">demo</option>
@@ -69,7 +108,7 @@ export function Clusters() {
<input type="checkbox" checked={form.verify_tls} onChange={(event) => setForm({ ...form, verify_tls: event.target.checked })} />
Verify TLS
</label>
<button className={buttonClass} disabled={create.isPending}>Save Cluster</button>
<button className={buttonClass} disabled={save.isPending}>{editing ? "Update Cluster" : "Save Cluster"}</button>
</div>
</form>
</Modal>
@@ -81,16 +120,23 @@ export function Clusters() {
{ key: "provider", label: "Provider" },
{ key: "mode", label: "Mode" },
{ key: "last_sync_status", label: "Sync" },
{
key: "actions",
label: "Actions",
render: (row) => {
const cluster = row as unknown as Cluster;
return (
<div className="flex justify-end gap-2">
<button className={iconButtonClass} title="Test connection" aria-label={`Test ${cluster.name}`} onClick={() => action(cluster, "test")}><Cable size={16} /></button>
<button className={iconButtonClass} title="Sync inventory" aria-label={`Sync ${cluster.name}`} onClick={() => action(cluster, "sync")}><RefreshCcw size={16} /></button>
<button className={iconButtonClass} title="Edit cluster" aria-label={`Edit ${cluster.name}`} onClick={() => editCluster(cluster)}><Pencil size={16} /></button>
<button className={iconButtonClass} title="Delete cluster" aria-label={`Delete ${cluster.name}`} disabled={remove.isPending} onClick={() => deleteCluster(cluster)}><Trash2 size={16} /></button>
</div>
);
},
},
]}
/>
<div className="flex flex-wrap gap-2">
{(clusters.data ?? []).map((cluster) => (
<div key={cluster.id} className="flex gap-2">
<button className={secondaryButtonClass} onClick={() => action(cluster, "test")}><Cable size={16} /> {cluster.name}</button>
<button className={secondaryButtonClass} onClick={() => action(cluster, "sync")}><RefreshCcw size={16} /> Sync</button>
</div>
))}
</div>
<pre className="min-h-24 overflow-auto rounded-md border border-border bg-panel p-3 text-xs">{result || "No cluster action result yet."}</pre>
</section>
</div>
+78 -25
View File
@@ -1,10 +1,10 @@
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { GitBranch, Play, Plus, Trash2 } from "lucide-react";
import { Eye, GitBranch, Pencil, Play, Plus, Trash2 } from "lucide-react";
import { api, Policy, Project, ServiceCatalogItem } from "../api/client";
import { DataTable } from "../components/DataTable";
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
import { buttonClass, Field, iconButtonClass, inputClass, selectClass } from "../components/FormControls";
import { Modal } from "../components/Modal";
import { PageHeader } from "../components/PageHeader";
@@ -21,14 +21,7 @@ function policyService(policy: Policy) {
return `${String(value.protocol ?? "")}/${String(value.ports ?? "")}`;
}
export function Policies() {
const queryClient = useQueryClient();
const policies = useQuery({ queryKey: ["policies"], queryFn: () => api<Policy[]>("/policies") });
const projects = useQuery({ queryKey: ["projects"], queryFn: () => api<Project[]>("/projects") });
const services = useQuery({ queryKey: ["service-catalog"], queryFn: () => api<ServiceCatalogItem[]>("/service-catalog") });
const [preview, setPreview] = useState("");
const [open, setOpen] = useState(false);
const [form, setForm] = useState({
const defaultPolicyForm = {
project_id: "",
name: "Web to DB",
source: "sg:Web Tier",
@@ -41,13 +34,23 @@ export function Policies() {
enforcement_mode: "enforced",
logging: true,
description: "Allow application database traffic",
});
};
const create = useMutation({
export function Policies() {
const queryClient = useQueryClient();
const policies = useQuery({ queryKey: ["policies"], queryFn: () => api<Policy[]>("/policies") });
const projects = useQuery({ queryKey: ["projects"], queryFn: () => api<Project[]>("/projects") });
const services = useQuery({ queryKey: ["service-catalog"], queryFn: () => api<ServiceCatalogItem[]>("/service-catalog") });
const [preview, setPreview] = useState("");
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<Policy | null>(null);
const [form, setForm] = useState(defaultPolicyForm);
const save = useMutation({
mutationFn: () => {
const service = services.data?.find((item) => item.id === form.service_id);
return api<Policy>("/policies", {
method: "POST",
return api<Policy>(editing ? `/policies/${editing.id}` : "/policies", {
method: editing ? "PATCH" : "POST",
body: JSON.stringify({
project_id: form.project_id || null,
name: form.name,
@@ -67,6 +70,7 @@ export function Policies() {
},
onSuccess: () => {
setOpen(false);
setEditing(null);
queryClient.invalidateQueries({ queryKey: ["policies"] });
},
});
@@ -77,7 +81,7 @@ export function Policies() {
async function submit(event: FormEvent) {
event.preventDefault();
await create.mutateAsync();
await save.mutateAsync();
}
async function compile(policy: Policy) {
@@ -91,14 +95,56 @@ export function Policies() {
setPreview(JSON.stringify(data, null, 2));
}
function addPolicy() {
setEditing(null);
setForm(defaultPolicyForm);
setOpen(true);
}
function editPolicy(policy: Policy) {
const service = policy.definition?.service as { protocol?: string; ports?: string } | undefined;
setEditing(policy);
setForm({
project_id: policy.project_id ?? "",
name: policy.name,
source: policyValue(policy, "source") || "any",
destination: policyValue(policy, "destination") || "any",
service_id: "",
protocol: service?.protocol ?? "tcp",
ports: service?.ports ?? "",
action: policyValue(policy, "action") || "allow",
direction: policyValue(policy, "direction") || "ingress",
enforcement_mode: policy.enforcement_mode || "enforced",
logging: Boolean(policy.definition?.logging),
description: policyValue(policy, "description"),
});
setOpen(true);
}
function chooseService(serviceId: string) {
const service = services.data?.find((item) => item.id === serviceId);
setForm({
...form,
service_id: serviceId,
protocol: service?.protocol ?? form.protocol,
ports: service?.ports ?? form.ports,
});
}
function deletePolicy(policy: Policy) {
if (window.confirm(`Delete policy "${policy.name}"?`)) {
remove.mutate(policy);
}
}
return (
<>
<PageHeader title="Policies" subtitle="Create versioned microsegmentation policies and compile firewall previews." />
<div className="space-y-4">
<button className={buttonClass} onClick={() => setOpen(true)}><Plus size={16} /> Add Policy</button>
<Modal title="Add Policy" open={open} onClose={() => setOpen(false)}>
<button className={buttonClass} onClick={addPolicy}><Plus size={16} /> Add Policy</button>
<Modal title={editing ? "Edit Policy" : "Add Policy"} open={open} onClose={() => setOpen(false)}>
<form onSubmit={submit}>
<div className="mb-4 flex items-center gap-2 font-medium"><GitBranch size={18} /> Add Policy</div>
<div className="mb-4 flex items-center gap-2 font-medium"><GitBranch size={18} /> {editing ? "Edit Policy" : "Add Policy"}</div>
<div className="grid gap-3">
<Field label="Project"><select className={selectClass} value={form.project_id} onChange={(event) => setForm({ ...form, project_id: event.target.value })}><option value="">Global</option>{(projects.data ?? []).map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}</select></Field>
<Field label="Name"><input className={inputClass} value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></Field>
@@ -106,9 +152,15 @@ export function Policies() {
<Field label="Source"><input className={inputClass} value={form.source} onChange={(event) => setForm({ ...form, source: event.target.value })} /></Field>
<Field label="Destination"><input className={inputClass} value={form.destination} onChange={(event) => setForm({ ...form, destination: event.target.value })} /></Field>
</div>
<Field label="Service"><select className={selectClass} value={form.service_id} onChange={(event) => setForm({ ...form, service_id: event.target.value })}><option value="">Custom</option>{(services.data ?? []).map((service) => <option key={service.id} value={service.id}>{service.name} {service.ports}</option>)}</select></Field>
<Field label="Service"><select className={selectClass} value={form.service_id} onChange={(event) => chooseService(event.target.value)}><option value="">Custom</option>{(services.data ?? []).map((service) => <option key={service.id} value={service.id}>{service.name} {service.ports}</option>)}</select></Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Protocol"><input className={inputClass} value={form.protocol} onChange={(event) => setForm({ ...form, protocol: event.target.value })} /></Field>
<Field label="Protocol">
<select className={selectClass} value={form.protocol} onChange={(event) => setForm({ ...form, protocol: event.target.value })}>
<option value="tcp">tcp</option>
<option value="udp">udp</option>
<option value="tcp/udp">tcp & udp</option>
</select>
</Field>
<Field label="Ports"><input className={inputClass} value={form.ports} onChange={(event) => setForm({ ...form, ports: event.target.value })} /></Field>
</div>
<div className="grid grid-cols-2 gap-3">
@@ -122,7 +174,7 @@ export function Policies() {
</select>
</Field>
<Field label="Description"><input className={inputClass} value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Field>
<button className={buttonClass}><Plus size={16} /> Save Policy</button>
<button className={buttonClass}><Plus size={16} /> {editing ? "Update Policy" : "Save Policy"}</button>
</div>
</form>
</Modal>
@@ -142,10 +194,11 @@ export function Policies() {
render: (row) => {
const policy = row as unknown as Policy;
return (
<div className="flex flex-wrap gap-2">
<button className={secondaryButtonClass} onClick={() => compile(policy)}><Play size={16} /> Compile</button>
<button className={secondaryButtonClass} onClick={() => firewallPreview(policy)}><Play size={16} /> Preview</button>
<button className={secondaryButtonClass} disabled={remove.isPending} onClick={() => remove.mutate(policy)}><Trash2 size={16} /> Delete</button>
<div className="flex justify-end gap-2">
<button className={iconButtonClass} title="Compile policy" aria-label={`Compile ${policy.name}`} onClick={() => compile(policy)}><Play size={16} /></button>
<button className={iconButtonClass} title="Preview policy" aria-label={`Preview ${policy.name}`} onClick={() => firewallPreview(policy)}><Eye size={16} /></button>
<button className={iconButtonClass} title="Edit policy" aria-label={`Edit ${policy.name}`} onClick={() => editPolicy(policy)}><Pencil size={16} /></button>
<button className={iconButtonClass} title="Delete policy" aria-label={`Delete ${policy.name}`} disabled={remove.isPending} onClick={() => deletePolicy(policy)}><Trash2 size={16} /></button>
</div>
);
},
+19 -4
View File
@@ -42,7 +42,6 @@ export function PolicyDesigner() {
}, [networks.data, securityGroups.data, workloads.data]);
function payload() {
const service = services.data?.find((item) => item.id === form.service_id);
return {
project_id: null,
name: form.name.trim(),
@@ -50,7 +49,7 @@ export function PolicyDesigner() {
definition: {
source: form.source,
destination: form.destination,
service: { protocol: service?.protocol ?? form.protocol, ports: service?.ports ?? form.ports },
service: { protocol: form.protocol, ports: form.ports },
action: form.action,
direction: form.direction,
enforcement_mode: form.enforcement_mode,
@@ -89,6 +88,16 @@ export function PolicyDesigner() {
});
}
function chooseService(serviceId: string) {
const service = services.data?.find((item) => item.id === serviceId);
setForm({
...form,
service_id: serviceId,
protocol: service?.protocol ?? form.protocol,
ports: service?.ports ?? form.ports,
});
}
return (
<>
<PageHeader title="Policy Designer" subtitle="Build tenant-aware microsegmentation policies and inspect their intended impact." />
@@ -116,7 +125,7 @@ export function PolicyDesigner() {
</select>
</Field>
<Field label="Service">
<select className={selectClass} value={form.service_id} onChange={(event) => setForm({ ...form, service_id: event.target.value })}>
<select className={selectClass} value={form.service_id} onChange={(event) => chooseService(event.target.value)}>
<option value="">Custom</option>
{(services.data ?? []).map((service) => <option key={service.id} value={service.id}>{service.name} {service.ports}</option>)}
</select>
@@ -128,7 +137,13 @@ export function PolicyDesigner() {
<option>reject</option>
</select>
</Field>
<Field label="Protocol"><input className={inputClass} value={form.protocol} onChange={(event) => setForm({ ...form, protocol: event.target.value })} /></Field>
<Field label="Protocol">
<select className={selectClass} value={form.protocol} onChange={(event) => setForm({ ...form, protocol: event.target.value })}>
<option value="tcp">tcp</option>
<option value="udp">udp</option>
<option value="tcp/udp">tcp & udp</option>
</select>
</Field>
<Field label="Ports"><input className={inputClass} value={form.ports} onChange={(event) => setForm({ ...form, ports: event.target.value })} /></Field>
<Field label="Direction">
<select className={selectClass} value={form.direction} onChange={(event) => setForm({ ...form, direction: event.target.value })}>