feat: add policy deployment status tracking with cluster-level rule state monitoring and live firewall rule version comparison
Add policy_read_payload helper to build policy response with deployment status, implement nexafabric_rule_version to parse policy ID and version from rule comments, add policy_deployment_status to check active/stale/partial/unresolved states by comparing expected rules from preview against live firewall rules per cluster with version matching, extend PolicyRead schema with
This commit is contained in:
@@ -526,6 +526,159 @@ def flow_policy_decision(active_matches: list[dict[str, object]], policy_matches
|
|||||||
return "observed"
|
return "observed"
|
||||||
|
|
||||||
|
|
||||||
|
def policy_read_payload(policy: Policy, deployment_status: dict[str, object] | None = None) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"id": policy.id,
|
||||||
|
"project_id": policy.project_id,
|
||||||
|
"name": policy.name,
|
||||||
|
"version": policy.version,
|
||||||
|
"enabled": policy.enabled,
|
||||||
|
"enforcement_mode": policy.enforcement_mode,
|
||||||
|
"definition": policy.definition,
|
||||||
|
"last_compiled": policy.last_compiled,
|
||||||
|
"deployment_status": deployment_status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def nexafabric_rule_version(rule: dict[str, object], policy_id: str) -> int | None:
|
||||||
|
comment = str(rule.get("comment") or "")
|
||||||
|
marker = f"NexaFabric policy={policy_id} version="
|
||||||
|
if marker not in comment:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(comment.split(marker, 1)[1].split(" ", 1)[0])
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def policy_deployment_status(db: Session, policy: Policy) -> dict[str, object]:
|
||||||
|
if not policy.enabled:
|
||||||
|
return {"state": "disabled", "label": "Disabled", "expected_rules": 0, "active_rules": 0, "stale_rules": 0, "clusters": []}
|
||||||
|
if policy.enforcement_mode == "audit":
|
||||||
|
return {"state": "audit", "label": "Audit mode", "expected_rules": 0, "active_rules": 0, "stale_rules": 0, "clusters": []}
|
||||||
|
|
||||||
|
clusters = db.scalars(select(Cluster).order_by(Cluster.name)).all()
|
||||||
|
if not clusters:
|
||||||
|
return {"state": "unknown", "label": "No cluster", "expected_rules": 0, "active_rules": 0, "stale_rules": 0, "clusters": []}
|
||||||
|
|
||||||
|
expected_rules = 0
|
||||||
|
active_rules = 0
|
||||||
|
stale_rules = 0
|
||||||
|
unresolved = 0
|
||||||
|
cluster_results: list[dict[str, object]] = []
|
||||||
|
|
||||||
|
for cluster in clusters:
|
||||||
|
try:
|
||||||
|
preview = resolve_firewall_preview(db, cluster, await FirewallOrchestrator().preview(cluster, policy))
|
||||||
|
except Exception as exc:
|
||||||
|
cluster_results.append({"cluster_id": cluster.id, "cluster_name": cluster.name, "state": "error", "error": str(exc)})
|
||||||
|
continue
|
||||||
|
|
||||||
|
writable_rules = [
|
||||||
|
rule for rule in preview.generated_rules if rule.get("provider_target") and rule.get("provider_rule") and not rule.get("audit_only")
|
||||||
|
]
|
||||||
|
expected_rules += len(writable_rules)
|
||||||
|
unresolved += len(preview.conflicts)
|
||||||
|
|
||||||
|
provider = get_provider(cluster.provider)
|
||||||
|
list_rules = getattr(provider, "list_firewall_rules", None)
|
||||||
|
if not list_rules:
|
||||||
|
cluster_results.append(
|
||||||
|
{
|
||||||
|
"cluster_id": cluster.id,
|
||||||
|
"cluster_name": cluster.name,
|
||||||
|
"state": "unknown",
|
||||||
|
"expected_rules": len(writable_rules),
|
||||||
|
"active_rules": 0,
|
||||||
|
"stale_rules": 0,
|
||||||
|
"reason": "Provider cannot list active firewall rules.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
cluster_active = 0
|
||||||
|
cluster_stale = 0
|
||||||
|
seen_targets: dict[str, dict[str, object]] = {}
|
||||||
|
for rule in writable_rules:
|
||||||
|
target = rule.get("provider_target")
|
||||||
|
if isinstance(target, dict):
|
||||||
|
seen_targets[str(target)] = target
|
||||||
|
for target in seen_targets.values():
|
||||||
|
try:
|
||||||
|
rules = await list_rules(
|
||||||
|
ProviderConnection(
|
||||||
|
api_url=cluster.api_url,
|
||||||
|
token=cluster.token_ref or "",
|
||||||
|
verify_tls=cluster.verify_tls,
|
||||||
|
read_only=True,
|
||||||
|
),
|
||||||
|
target,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
cluster_results.append({"cluster_id": cluster.id, "cluster_name": cluster.name, "state": "error", "error": str(exc)})
|
||||||
|
continue
|
||||||
|
for active_rule in rules:
|
||||||
|
version = nexafabric_rule_version(active_rule, policy.id)
|
||||||
|
if version is None:
|
||||||
|
continue
|
||||||
|
if version == policy.version:
|
||||||
|
cluster_active += 1
|
||||||
|
else:
|
||||||
|
cluster_stale += 1
|
||||||
|
|
||||||
|
active_rules += cluster_active
|
||||||
|
stale_rules += cluster_stale
|
||||||
|
if preview.conflicts:
|
||||||
|
cluster_state = "unresolved"
|
||||||
|
elif cluster_active >= len(writable_rules) and writable_rules:
|
||||||
|
cluster_state = "active"
|
||||||
|
elif cluster_active:
|
||||||
|
cluster_state = "partial"
|
||||||
|
elif cluster_stale:
|
||||||
|
cluster_state = "stale"
|
||||||
|
else:
|
||||||
|
cluster_state = "not_applied"
|
||||||
|
cluster_results.append(
|
||||||
|
{
|
||||||
|
"cluster_id": cluster.id,
|
||||||
|
"cluster_name": cluster.name,
|
||||||
|
"state": cluster_state,
|
||||||
|
"expected_rules": len(writable_rules),
|
||||||
|
"active_rules": cluster_active,
|
||||||
|
"stale_rules": cluster_stale,
|
||||||
|
"conflicts": preview.conflicts,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if unresolved:
|
||||||
|
state = "unresolved"
|
||||||
|
label = "Needs attention"
|
||||||
|
elif expected_rules and active_rules >= expected_rules:
|
||||||
|
state = "active"
|
||||||
|
label = "Active"
|
||||||
|
elif active_rules:
|
||||||
|
state = "partial"
|
||||||
|
label = "Partially active"
|
||||||
|
elif stale_rules:
|
||||||
|
state = "stale"
|
||||||
|
label = "Outdated"
|
||||||
|
elif expected_rules:
|
||||||
|
state = "not_applied"
|
||||||
|
label = "Not applied"
|
||||||
|
else:
|
||||||
|
state = "unknown"
|
||||||
|
label = "No resolved rules"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"state": state,
|
||||||
|
"label": label,
|
||||||
|
"expected_rules": expected_rules,
|
||||||
|
"active_rules": active_rules,
|
||||||
|
"stale_rules": stale_rules,
|
||||||
|
"clusters": cluster_results,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def dashboard_top_talkers(db: Session) -> list[dict[str, int | str]]:
|
def dashboard_top_talkers(db: Session) -> list[dict[str, int | str]]:
|
||||||
totals: dict[str, int] = {}
|
totals: dict[str, int] = {}
|
||||||
workloads = db.scalars(select(Workload)).all()
|
workloads = db.scalars(select(Workload)).all()
|
||||||
@@ -1593,8 +1746,11 @@ def delete_security_rule(rule_id: str, user: CurrentUser, db: Session = Depends(
|
|||||||
|
|
||||||
|
|
||||||
@api_router.get("/policies", response_model=list[PolicyRead])
|
@api_router.get("/policies", response_model=list[PolicyRead])
|
||||||
def policies(_: CurrentUser, db: Session = Depends(get_db)) -> list[Policy]:
|
async def policies(_: CurrentUser, db: Session = Depends(get_db)) -> list[dict[str, object]]:
|
||||||
return db.scalars(select(Policy).order_by(Policy.name)).all()
|
return [
|
||||||
|
policy_read_payload(policy, await policy_deployment_status(db, policy))
|
||||||
|
for policy in db.scalars(select(Policy).order_by(Policy.name)).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@api_router.post("/policies", response_model=PolicyRead)
|
@api_router.post("/policies", response_model=PolicyRead)
|
||||||
|
|||||||
@@ -309,6 +309,7 @@ class PolicyRead(OrmModel):
|
|||||||
enforcement_mode: str
|
enforcement_mode: str
|
||||||
definition: dict[str, Any]
|
definition: dict[str, Any]
|
||||||
last_compiled: dict[str, Any] | None
|
last_compiled: dict[str, Any] | None
|
||||||
|
deployment_status: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class WorkloadInsight(BaseModel):
|
class WorkloadInsight(BaseModel):
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ export type Policy = {
|
|||||||
enforcement_mode: string;
|
enforcement_mode: string;
|
||||||
definition: Record<string, unknown>;
|
definition: Record<string, unknown>;
|
||||||
last_compiled: Record<string, unknown> | null;
|
last_compiled: Record<string, unknown> | null;
|
||||||
|
deployment_status: Record<string, unknown> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Workload = {
|
export type Workload = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Eye, GitBranch, Pencil, Play, Plus, Trash2 } from "lucide-react";
|
import { AlertTriangle, CheckCircle2, Clock3, Eye, GitBranch, Pencil, Play, Plus, Shield, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
import { api, Policy, Project, ServiceCatalogItem } from "../api/client";
|
import { api, Policy, Project, ServiceCatalogItem } from "../api/client";
|
||||||
import { DataTable } from "../components/DataTable";
|
import { DataTable } from "../components/DataTable";
|
||||||
@@ -22,6 +22,60 @@ function policyService(policy: Policy) {
|
|||||||
return `${String(value.protocol ?? "")}/${String(value.ports ?? "")}`;
|
return `${String(value.protocol ?? "")}/${String(value.ports ?? "")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function policyStatus(policy: Policy) {
|
||||||
|
const status = policy.deployment_status ?? {};
|
||||||
|
return {
|
||||||
|
state: String(status.state ?? "unknown"),
|
||||||
|
label: String(status.label ?? "Unknown"),
|
||||||
|
expectedRules: Number(status.expected_rules ?? 0),
|
||||||
|
activeRules: Number(status.active_rules ?? 0),
|
||||||
|
staleRules: Number(status.stale_rules ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClass(state: string) {
|
||||||
|
if (state === "active") {
|
||||||
|
return "border-accent/40 bg-accent/10 text-accent";
|
||||||
|
}
|
||||||
|
if (state === "audit") {
|
||||||
|
return "border-amber-400/40 bg-amber-400/10 text-amber-300";
|
||||||
|
}
|
||||||
|
if (["stale", "partial", "unresolved"].includes(state)) {
|
||||||
|
return "border-danger/40 bg-danger/10 text-danger";
|
||||||
|
}
|
||||||
|
return "border-border bg-canvas text-slate-500";
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusIcon({ state }: { state: string }) {
|
||||||
|
if (state === "active") {
|
||||||
|
return <CheckCircle2 size={15} />;
|
||||||
|
}
|
||||||
|
if (state === "audit") {
|
||||||
|
return <Shield size={15} />;
|
||||||
|
}
|
||||||
|
if (["stale", "partial", "unresolved"].includes(state)) {
|
||||||
|
return <AlertTriangle size={15} />;
|
||||||
|
}
|
||||||
|
return <Clock3 size={15} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PolicyDeploymentStatus({ policy }: { policy: Policy }) {
|
||||||
|
const status = policyStatus(policy);
|
||||||
|
const detail =
|
||||||
|
status.state === "audit"
|
||||||
|
? "No live firewall write"
|
||||||
|
: `${status.activeRules}/${status.expectedRules} active${status.staleRules ? ` · ${status.staleRules} stale` : ""}`;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className={`inline-flex w-fit items-center gap-1.5 rounded-md border px-2 py-1 text-xs ${statusClass(status.state)}`}>
|
||||||
|
<StatusIcon state={status.state} />
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500">{detail}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const defaultPolicyForm = {
|
const defaultPolicyForm = {
|
||||||
project_id: "",
|
project_id: "",
|
||||||
name: "Web to DB",
|
name: "Web to DB",
|
||||||
@@ -200,6 +254,7 @@ export function Policies() {
|
|||||||
{ key: "destination", label: "Destination", render: (row) => policyValue(row as unknown as Policy, "destination") },
|
{ key: "destination", label: "Destination", render: (row) => policyValue(row as unknown as Policy, "destination") },
|
||||||
{ key: "service", label: "Service", render: (row) => policyService(row as unknown as Policy) },
|
{ key: "service", label: "Service", render: (row) => policyService(row as unknown as Policy) },
|
||||||
{ key: "enforcement_mode", label: "Mode" },
|
{ key: "enforcement_mode", label: "Mode" },
|
||||||
|
{ key: "deployment_status", label: "Status", render: (row) => <PolicyDeploymentStatus policy={row as unknown as Policy} /> },
|
||||||
{ key: "version", label: "Version" },
|
{ key: "version", label: "Version" },
|
||||||
{
|
{
|
||||||
key: "actions",
|
key: "actions",
|
||||||
|
|||||||
Reference in New Issue
Block a user