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
+84 -19
View File
@@ -36,6 +36,7 @@ from app.schemas.domain import (
AuditLogRead,
ClusterCreate,
ClusterRead,
ClusterUpdate,
FirewallApplyRequest,
FirewallPreview,
IpAddressRead,
@@ -264,26 +265,28 @@ 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:
provider_rule = {
"type": "in" if direction == "ingress" else "out",
"action": proxmox_action(str(rule.get("action", "allow"))),
"enable": 1,
"comment": (
f"NexaFabric policy={rule.get('policy_id')} version={rule.get('policy_version')} "
f"rule={rule_index} target={target_workload.name}"
),
}
if protocol != "any":
provider_rule["proto"] = protocol
if ports != "any":
provider_rule["dport"] = ports
if remote_value:
provider_rule["source" if direction == "ingress" else "dest"] = remote_value
if rule.get("logging"):
provider_rule["log"] = "info"
mapped_rule = {**mapped, "provider_target": provider_target, "provider_rule": provider_rule}
generated_rules.append(mapped_rule)
for provider_protocol in protocols:
provider_rule = {
"type": "in" if direction == "ingress" else "out",
"action": proxmox_action(str(rule.get("action", "allow"))),
"enable": 1,
"comment": (
f"NexaFabric policy={rule.get('policy_id')} version={rule.get('policy_version')} "
f"rule={rule_index} target={target_workload.name}"
),
}
if provider_protocol != "any":
provider_rule["proto"] = provider_protocol
if ports != "any":
provider_rule["dport"] = ports
if remote_value:
provider_rule["source" if direction == "ingress" else "dest"] = remote_value
if rule.get("logging"):
provider_rule["log"] = "info"
mapped_rule = {**mapped, "provider_target": provider_target, "provider_rule": provider_rule}
generated_rules.append(mapped_rule)
return FirewallPreview(
policy_id=preview.policy_id,
@@ -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)