propt 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- propt/__init__.py +1 -0
- propt/audit.py +57 -0
- propt/cli.py +60 -0
- propt/engine.py +107 -0
- propt/explain.py +29 -0
- propt/k8s_graph.py +114 -0
- propt/metrics.py +49 -0
- propt/parser.py +116 -0
- propt/popeye_runner.py +68 -0
- propt-0.1.0.dist-info/METADATA +10 -0
- propt-0.1.0.dist-info/RECORD +14 -0
- propt-0.1.0.dist-info/WHEEL +5 -0
- propt-0.1.0.dist-info/entry_points.txt +2 -0
- propt-0.1.0.dist-info/top_level.txt +1 -0
propt/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
propt/audit.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
S3 audit logging.
|
|
3
|
+
|
|
4
|
+
Every impact analysis propt performs gets written to S3 as a timestamped
|
|
5
|
+
JSON record -- an auditable history of what was proposed, what evidence
|
|
6
|
+
was found, and whether the engineer chose to proceed. This matches the
|
|
7
|
+
"security/trust principles" in the original project spec: expose
|
|
8
|
+
evidence, keep an auditable trail, never silently change policy.
|
|
9
|
+
|
|
10
|
+
Uses boto3 (the AWS SDK) with the caller's existing AWS CLI credentials
|
|
11
|
+
-- no keys are stored or handled by propt itself.
|
|
12
|
+
"""
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import socket
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
|
|
18
|
+
BUCKET = os.environ.get("PROPT_AUDIT_BUCKET", "propt-audit-logs-sid-2026")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def log_decision(change, impact, executed: bool):
|
|
22
|
+
"""
|
|
23
|
+
Fire-and-forget: failures here must NEVER block or crash the actual
|
|
24
|
+
kubectl operation. Audit logging is a bonus, not a dependency.
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
import boto3
|
|
28
|
+
except ImportError:
|
|
29
|
+
return # boto3 not installed -- skip silently, propt still works
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
record = {
|
|
33
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
34
|
+
"hostname": socket.gethostname(),
|
|
35
|
+
"command": " ".join(change.raw_args),
|
|
36
|
+
"target": change.target(),
|
|
37
|
+
"namespace": change.namespace,
|
|
38
|
+
"impact_level": impact.level,
|
|
39
|
+
"evidence": impact.evidence,
|
|
40
|
+
"context": impact.context,
|
|
41
|
+
"consequence": impact.consequence,
|
|
42
|
+
"unavailable_signals": impact.unavailable_signals,
|
|
43
|
+
"executed": executed,
|
|
44
|
+
}
|
|
45
|
+
key = f"decisions/{record['timestamp']}.json"
|
|
46
|
+
s3 = boto3.client("s3")
|
|
47
|
+
s3.put_object(
|
|
48
|
+
Bucket=BUCKET,
|
|
49
|
+
Key=key,
|
|
50
|
+
Body=json.dumps(record, indent=2).encode("utf-8"),
|
|
51
|
+
ContentType="application/json",
|
|
52
|
+
)
|
|
53
|
+
except Exception:
|
|
54
|
+
# Never let audit logging break the actual workflow -- this is
|
|
55
|
+
# the same "honest degradation" principle used for Popeye and
|
|
56
|
+
# metrics-server: missing signal, not a crash.
|
|
57
|
+
pass
|
propt/cli.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
propt CLI entrypoint.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
propt kubectl delete pod payment-api-7d9f
|
|
6
|
+
propt kubectl get pods <- passes straight through, no analysis
|
|
7
|
+
|
|
8
|
+
The `propt` prefix is transparent: whatever command follows it runs
|
|
9
|
+
exactly as normal (after an optional confirmation for mutating ops).
|
|
10
|
+
"""
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from . import parser as change_parser
|
|
15
|
+
from . import engine, explain, audit
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main():
|
|
19
|
+
argv = sys.argv[1:]
|
|
20
|
+
if not argv:
|
|
21
|
+
print("Usage: propt <command> [args...] e.g. propt kubectl delete pod foo")
|
|
22
|
+
sys.exit(1)
|
|
23
|
+
|
|
24
|
+
tool = argv[0]
|
|
25
|
+
rest = argv[1:]
|
|
26
|
+
|
|
27
|
+
if tool != "kubectl":
|
|
28
|
+
# Pass through untouched for non-kubectl commands (v1 scope is k8s-only)
|
|
29
|
+
_run(argv)
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
change = change_parser.parse_kubectl(rest)
|
|
33
|
+
|
|
34
|
+
if not change.is_mutating:
|
|
35
|
+
# Read-only command: don't slow the engineer down, just run it.
|
|
36
|
+
_run(argv)
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
impact = engine.analyze(change)
|
|
40
|
+
print(explain.format_impact(impact))
|
|
41
|
+
print()
|
|
42
|
+
|
|
43
|
+
answer = input("Execute? [y/N]: ").strip().lower()
|
|
44
|
+
executed = answer == "y"
|
|
45
|
+
audit.log_decision(change, impact, executed)
|
|
46
|
+
|
|
47
|
+
if executed:
|
|
48
|
+
_run(argv)
|
|
49
|
+
else:
|
|
50
|
+
print("Aborted. No changes made.")
|
|
51
|
+
sys.exit(0)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _run(argv):
|
|
55
|
+
result = subprocess.run(argv)
|
|
56
|
+
sys.exit(result.returncode)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
main()
|
propt/engine.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Impact Inference Engine.
|
|
3
|
+
|
|
4
|
+
Combines:
|
|
5
|
+
- the parsed Change
|
|
6
|
+
- the live dependency graph
|
|
7
|
+
- live context (replica counts, pod phase)
|
|
8
|
+
- real Popeye findings, filtered to the affected chain
|
|
9
|
+
- real metrics, if available
|
|
10
|
+
|
|
11
|
+
...into a structured Impact result. No hardcoded per-command risk
|
|
12
|
+
table -- risk comes from what's actually true in the cluster right now.
|
|
13
|
+
"""
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import List
|
|
16
|
+
|
|
17
|
+
from . import k8s_graph, popeye_runner, metrics
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Impact:
|
|
22
|
+
level: str # "HIGH", "MEDIUM", "LOW", "NONE", "UNKNOWN"
|
|
23
|
+
target: str
|
|
24
|
+
affected: List[str] = field(default_factory=list)
|
|
25
|
+
evidence: List[str] = field(default_factory=list)
|
|
26
|
+
context: List[str] = field(default_factory=list)
|
|
27
|
+
consequence: str = ""
|
|
28
|
+
unavailable_signals: List[str] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def analyze(change) -> Impact:
|
|
32
|
+
target_node = f"{change.resource_type}/{change.resource_name}" if change.resource_type and change.resource_name else None
|
|
33
|
+
|
|
34
|
+
if not change.is_mutating or not target_node:
|
|
35
|
+
return Impact(level="NONE", target=target_node or "unknown",
|
|
36
|
+
consequence="Read-only or unrecognized operation; no impact analysis needed.")
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
graph = k8s_graph.build_graph(change.namespace)
|
|
40
|
+
except Exception as e:
|
|
41
|
+
return Impact(level="UNKNOWN", target=target_node,
|
|
42
|
+
consequence=f"Could not reach cluster: {e}",
|
|
43
|
+
unavailable_signals=["cluster"])
|
|
44
|
+
|
|
45
|
+
if not graph.has_node(target_node):
|
|
46
|
+
return Impact(level="UNKNOWN", target=target_node,
|
|
47
|
+
consequence="Resource not found in current cluster state.")
|
|
48
|
+
|
|
49
|
+
affected_nodes = k8s_graph.affected_chain(graph, target_node)
|
|
50
|
+
evidence = [] # signals that actually indicate risk -> drive the level
|
|
51
|
+
context = [] # informational signals -> shown, but never drive the level
|
|
52
|
+
unavailable = []
|
|
53
|
+
|
|
54
|
+
# 1. Live replica/context evidence -- a real risk signal
|
|
55
|
+
for node in [target_node] + affected_nodes:
|
|
56
|
+
ctx = k8s_graph.get_context(graph, node)
|
|
57
|
+
if ctx.get("kind") == "deployment":
|
|
58
|
+
desired = ctx.get("desired_replicas")
|
|
59
|
+
available = ctx.get("available_replicas")
|
|
60
|
+
if desired is not None and available is not None and available < desired:
|
|
61
|
+
evidence.append(
|
|
62
|
+
f"{node}: available replicas ({available}) below desired ({desired})"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# 2. Popeye findings, filtered to affected chain -- Warning/Error are risk
|
|
66
|
+
# signals; Info/OK-level notes are informational context only.
|
|
67
|
+
popeye_findings = popeye_runner.run_popeye(change.namespace)
|
|
68
|
+
if popeye_findings.get("_unavailable"):
|
|
69
|
+
unavailable.append(f"popeye ({popeye_findings.get('_reason')})")
|
|
70
|
+
else:
|
|
71
|
+
for node in [target_node] + affected_nodes:
|
|
72
|
+
for finding in popeye_findings.get(node, []):
|
|
73
|
+
line = f"Popeye [{finding['level']}] {node}: {finding['message']}"
|
|
74
|
+
if str(finding.get("level", "")).lower() in ("warning", "error", "critical"):
|
|
75
|
+
evidence.append(line)
|
|
76
|
+
else:
|
|
77
|
+
context.append(line)
|
|
78
|
+
|
|
79
|
+
# 3. Metrics: informational context only -- we don't have a resource
|
|
80
|
+
# limit to compare against, so a raw usage number is context, not
|
|
81
|
+
# a risk signal by itself.
|
|
82
|
+
if change.resource_type == "pod":
|
|
83
|
+
if metrics.metrics_available():
|
|
84
|
+
m = metrics.get_pod_metrics(change.namespace, change.resource_name)
|
|
85
|
+
if m.get("available"):
|
|
86
|
+
context.append(f"{target_node}: {m['cpu_millicores']}m CPU usage observed")
|
|
87
|
+
else:
|
|
88
|
+
unavailable.append("metrics-server")
|
|
89
|
+
|
|
90
|
+
level = "NONE"
|
|
91
|
+
consequence = "No significant impact detected from currently available signals."
|
|
92
|
+
if evidence:
|
|
93
|
+
level = "HIGH" if any("below desired" in e or "error" in e.lower() or "critical" in e.lower() for e in evidence) else "MEDIUM"
|
|
94
|
+
consequence = (
|
|
95
|
+
f"Potential reduction in capacity or degraded state propagating to: "
|
|
96
|
+
f"{', '.join(affected_nodes) if affected_nodes else target_node}."
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return Impact(
|
|
100
|
+
level=level,
|
|
101
|
+
target=target_node,
|
|
102
|
+
affected=affected_nodes,
|
|
103
|
+
evidence=evidence,
|
|
104
|
+
context=context,
|
|
105
|
+
consequence=consequence,
|
|
106
|
+
unavailable_signals=unavailable,
|
|
107
|
+
)
|
propt/explain.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from .engine import Impact
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def format_impact(impact: Impact) -> str:
|
|
5
|
+
lines = []
|
|
6
|
+
header = f"{impact.level} POTENTIAL IMPACT" if impact.level != "NONE" else "NO SIGNIFICANT IMPACT DETECTED"
|
|
7
|
+
lines.append(header)
|
|
8
|
+
lines.append("")
|
|
9
|
+
lines.append(f"Target: {impact.target}")
|
|
10
|
+
if impact.affected:
|
|
11
|
+
lines.append(f"Affected: {', '.join(impact.affected)}")
|
|
12
|
+
if impact.evidence:
|
|
13
|
+
lines.append("")
|
|
14
|
+
lines.append("Evidence:")
|
|
15
|
+
for e in impact.evidence:
|
|
16
|
+
lines.append(f" • {e}")
|
|
17
|
+
if impact.context:
|
|
18
|
+
lines.append("")
|
|
19
|
+
lines.append("Context:")
|
|
20
|
+
for c in impact.context:
|
|
21
|
+
lines.append(f" • {c}")
|
|
22
|
+
if impact.unavailable_signals:
|
|
23
|
+
lines.append("")
|
|
24
|
+
lines.append("Unknown / missing context:")
|
|
25
|
+
for u in impact.unavailable_signals:
|
|
26
|
+
lines.append(f" • {u} not available")
|
|
27
|
+
lines.append("")
|
|
28
|
+
lines.append(f"Potential consequence: {impact.consequence}")
|
|
29
|
+
return "\n".join(lines)
|
propt/k8s_graph.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Live Kubernetes discovery + dependency graph.
|
|
3
|
+
|
|
4
|
+
Builds a networkx.DiGraph of the current cluster state (in a given
|
|
5
|
+
namespace) using real ownerReferences and label-selector matching --
|
|
6
|
+
no hardcoded relationships. Edges point from a resource to the thing
|
|
7
|
+
that depends on it (child -> parent-of-traffic direction), i.e.:
|
|
8
|
+
|
|
9
|
+
Pod --owned_by--> ReplicaSet --owned_by--> Deployment
|
|
10
|
+
Pod --selected_by--> Service --routed_by--> Ingress
|
|
11
|
+
|
|
12
|
+
We traverse "upward" (toward things that would be affected) when
|
|
13
|
+
analyzing impact of a change to a lower-level resource.
|
|
14
|
+
"""
|
|
15
|
+
import networkx as nx
|
|
16
|
+
from kubernetes import client, config
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_cluster_client():
|
|
20
|
+
"""Load kubeconfig (works with minikube's default context)."""
|
|
21
|
+
config.load_kube_config()
|
|
22
|
+
return client.CoreV1Api(), client.AppsV1Api(), client.NetworkingV1Api()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_graph(namespace: str = "default") -> nx.DiGraph:
|
|
26
|
+
core, apps, net = load_cluster_client()
|
|
27
|
+
g = nx.DiGraph()
|
|
28
|
+
|
|
29
|
+
pods = core.list_namespaced_pod(namespace).items
|
|
30
|
+
rsets = apps.list_namespaced_replica_set(namespace).items
|
|
31
|
+
deployments = apps.list_namespaced_deployment(namespace).items
|
|
32
|
+
services = core.list_namespaced_service(namespace).items
|
|
33
|
+
try:
|
|
34
|
+
ingresses = net.list_namespaced_ingress(namespace).items
|
|
35
|
+
except Exception:
|
|
36
|
+
ingresses = []
|
|
37
|
+
|
|
38
|
+
# Nodes
|
|
39
|
+
for p in pods:
|
|
40
|
+
g.add_node(f"pod/{p.metadata.name}", kind="pod", raw=p)
|
|
41
|
+
for r in rsets:
|
|
42
|
+
g.add_node(f"replicaset/{r.metadata.name}", kind="replicaset", raw=r)
|
|
43
|
+
for d in deployments:
|
|
44
|
+
g.add_node(f"deployment/{d.metadata.name}", kind="deployment", raw=d)
|
|
45
|
+
for s in services:
|
|
46
|
+
g.add_node(f"service/{s.metadata.name}", kind="service", raw=s)
|
|
47
|
+
for i in ingresses:
|
|
48
|
+
g.add_node(f"ingress/{i.metadata.name}", kind="ingress", raw=i)
|
|
49
|
+
|
|
50
|
+
# Pod -> ReplicaSet (ownerReferences)
|
|
51
|
+
for p in pods:
|
|
52
|
+
for owner in (p.metadata.owner_references or []):
|
|
53
|
+
if owner.kind == "ReplicaSet":
|
|
54
|
+
node = f"replicaset/{owner.name}"
|
|
55
|
+
if g.has_node(node):
|
|
56
|
+
g.add_edge(f"pod/{p.metadata.name}", node, relation="owned_by")
|
|
57
|
+
|
|
58
|
+
# ReplicaSet -> Deployment (ownerReferences)
|
|
59
|
+
for r in rsets:
|
|
60
|
+
for owner in (r.metadata.owner_references or []):
|
|
61
|
+
if owner.kind == "Deployment":
|
|
62
|
+
node = f"deployment/{owner.name}"
|
|
63
|
+
if g.has_node(node):
|
|
64
|
+
g.add_edge(f"replicaset/{r.metadata.name}", node, relation="owned_by")
|
|
65
|
+
|
|
66
|
+
# Pod -> Service (label selector matching)
|
|
67
|
+
for s in services:
|
|
68
|
+
selector = s.spec.selector or {}
|
|
69
|
+
if not selector:
|
|
70
|
+
continue
|
|
71
|
+
for p in pods:
|
|
72
|
+
labels = p.metadata.labels or {}
|
|
73
|
+
if all(labels.get(k) == v for k, v in selector.items()):
|
|
74
|
+
g.add_edge(f"pod/{p.metadata.name}", f"service/{s.metadata.name}", relation="selected_by")
|
|
75
|
+
|
|
76
|
+
# Service -> Ingress (backend rules)
|
|
77
|
+
for i in ingresses:
|
|
78
|
+
rules = i.spec.rules or []
|
|
79
|
+
for rule in rules:
|
|
80
|
+
http = getattr(rule, "http", None)
|
|
81
|
+
if not http:
|
|
82
|
+
continue
|
|
83
|
+
for path in (http.paths or []):
|
|
84
|
+
backend = path.backend
|
|
85
|
+
svc = getattr(getattr(backend, "service", None), "name", None)
|
|
86
|
+
if svc and g.has_node(f"service/{svc}"):
|
|
87
|
+
g.add_edge(f"service/{svc}", f"ingress/{i.metadata.name}", relation="routed_by")
|
|
88
|
+
|
|
89
|
+
return g
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def affected_chain(g: nx.DiGraph, target_node: str) -> list:
|
|
93
|
+
"""Everything reachable upward (toward services/ingress) from target."""
|
|
94
|
+
if not g.has_node(target_node):
|
|
95
|
+
return []
|
|
96
|
+
return list(nx.descendants(g, target_node))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_context(g: nx.DiGraph, node: str) -> dict:
|
|
100
|
+
"""Pull live status fields for a node (replicas, ready state, etc.)."""
|
|
101
|
+
if not g.has_node(node):
|
|
102
|
+
return {}
|
|
103
|
+
kind = g.nodes[node]["kind"]
|
|
104
|
+
raw = g.nodes[node]["raw"]
|
|
105
|
+
ctx = {"kind": kind}
|
|
106
|
+
if raw is None:
|
|
107
|
+
return ctx
|
|
108
|
+
if kind == "deployment":
|
|
109
|
+
ctx["desired_replicas"] = raw.spec.replicas
|
|
110
|
+
ctx["available_replicas"] = raw.status.available_replicas or 0
|
|
111
|
+
ctx["ready_replicas"] = raw.status.ready_replicas or 0
|
|
112
|
+
if kind == "pod":
|
|
113
|
+
ctx["phase"] = raw.status.phase
|
|
114
|
+
return ctx
|
propt/metrics.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Optional metrics-server integration.
|
|
3
|
+
|
|
4
|
+
Pulls real CPU usage for pods via the Kubernetes metrics API, if
|
|
5
|
+
metrics-server is installed in the cluster. If it isn't, we say so
|
|
6
|
+
explicitly rather than inventing a "traffic" number -- consistent with
|
|
7
|
+
the project's own principle of distinguishing observed facts from
|
|
8
|
+
missing context.
|
|
9
|
+
"""
|
|
10
|
+
from kubernetes import client, config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def metrics_available() -> bool:
|
|
14
|
+
try:
|
|
15
|
+
config.load_kube_config()
|
|
16
|
+
api = client.CustomObjectsApi()
|
|
17
|
+
api.list_cluster_custom_object("metrics.k8s.io", "v1beta1", "pods")
|
|
18
|
+
return True
|
|
19
|
+
except Exception:
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_pod_metrics(namespace: str, pod_name: str) -> dict:
|
|
24
|
+
try:
|
|
25
|
+
config.load_kube_config()
|
|
26
|
+
api = client.CustomObjectsApi()
|
|
27
|
+
data = api.get_namespaced_custom_object(
|
|
28
|
+
"metrics.k8s.io", "v1beta1", namespace, "pods", pod_name
|
|
29
|
+
)
|
|
30
|
+
containers = data.get("containers", [])
|
|
31
|
+
cpu_total = 0
|
|
32
|
+
for c in containers:
|
|
33
|
+
cpu_str = c.get("usage", {}).get("cpu", "0")
|
|
34
|
+
cpu_total += _parse_cpu(cpu_str)
|
|
35
|
+
return {"cpu_millicores": cpu_total, "available": True}
|
|
36
|
+
except Exception as e:
|
|
37
|
+
return {"available": False, "reason": str(e)}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parse_cpu(cpu_str: str) -> int:
|
|
41
|
+
"""Convert k8s cpu strings ('100m', '1') to millicores."""
|
|
42
|
+
if cpu_str.endswith("n"):
|
|
43
|
+
return int(cpu_str[:-1]) // 1_000_000
|
|
44
|
+
if cpu_str.endswith("m"):
|
|
45
|
+
return int(cpu_str[:-1])
|
|
46
|
+
try:
|
|
47
|
+
return int(float(cpu_str) * 1000)
|
|
48
|
+
except ValueError:
|
|
49
|
+
return 0
|
propt/parser.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Common Change Model + kubectl command parser.
|
|
3
|
+
|
|
4
|
+
Turns a raw kubectl command (as a list of argv tokens, minus the
|
|
5
|
+
leading `propt`) into a normalized Change object the rest of the
|
|
6
|
+
engine can reason about.
|
|
7
|
+
"""
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
# Operations we intercept for risk analysis. Anything else (get,
|
|
12
|
+
# describe, logs, top, explain, ...) is treated as a read and passed
|
|
13
|
+
# straight through without analysis.
|
|
14
|
+
MUTATING_OPS = {"delete", "scale", "apply", "patch", "edit", "replace", "rollout"}
|
|
15
|
+
|
|
16
|
+
# kubectl resource shorthands we normalize to a canonical singular form.
|
|
17
|
+
RESOURCE_ALIASES = {
|
|
18
|
+
"po": "pod", "pods": "pod", "pod": "pod",
|
|
19
|
+
"deploy": "deployment", "deployments": "deployment", "deployment": "deployment",
|
|
20
|
+
"svc": "service", "services": "service", "service": "service",
|
|
21
|
+
"ing": "ingress", "ingresses": "ingress", "ingress": "ingress",
|
|
22
|
+
"rs": "replicaset", "replicasets": "replicaset", "replicaset": "replicaset",
|
|
23
|
+
"sts": "statefulset", "statefulsets": "statefulset", "statefulset": "statefulset",
|
|
24
|
+
"ds": "daemonset", "daemonsets": "daemonset", "daemonset": "daemonset",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Change:
|
|
30
|
+
actor: str
|
|
31
|
+
provider: str
|
|
32
|
+
operation: str
|
|
33
|
+
resource_type: Optional[str]
|
|
34
|
+
resource_name: Optional[str]
|
|
35
|
+
namespace: str
|
|
36
|
+
parameters: dict = field(default_factory=dict)
|
|
37
|
+
raw_args: list = field(default_factory=list)
|
|
38
|
+
is_mutating: bool = False
|
|
39
|
+
|
|
40
|
+
def target(self) -> str:
|
|
41
|
+
if self.resource_type and self.resource_name:
|
|
42
|
+
return f"{self.resource_type}/{self.resource_name}"
|
|
43
|
+
return self.resource_type or "unknown"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _extract_namespace(tokens: list) -> str:
|
|
47
|
+
for flag in ("-n", "--namespace"):
|
|
48
|
+
if flag in tokens:
|
|
49
|
+
idx = tokens.index(flag)
|
|
50
|
+
if idx + 1 < len(tokens):
|
|
51
|
+
return tokens[idx + 1]
|
|
52
|
+
return "default"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _extract_replicas(tokens: list) -> Optional[int]:
|
|
56
|
+
for tok in tokens:
|
|
57
|
+
if tok.startswith("--replicas"):
|
|
58
|
+
if "=" in tok:
|
|
59
|
+
try:
|
|
60
|
+
return int(tok.split("=", 1)[1])
|
|
61
|
+
except ValueError:
|
|
62
|
+
return None
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parse_kubectl(args: list) -> Change:
|
|
67
|
+
"""
|
|
68
|
+
args: the kubectl invocation WITHOUT the leading 'kubectl' token,
|
|
69
|
+
e.g. ["delete", "pod", "payment-api-7d9f"]
|
|
70
|
+
"""
|
|
71
|
+
if not args:
|
|
72
|
+
return Change(
|
|
73
|
+
actor="user", provider="kubernetes", operation="unknown",
|
|
74
|
+
resource_type=None, resource_name=None, namespace="default",
|
|
75
|
+
raw_args=args, is_mutating=False,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
op = args[0].lower()
|
|
79
|
+
rest = [a for a in args[1:] if not a.startswith("-")]
|
|
80
|
+
# crude flag-value stripping: drop flag tokens and the value that follows
|
|
81
|
+
# a flag we recognize (namespace, replicas) so they don't get mistaken
|
|
82
|
+
# for resource type/name.
|
|
83
|
+
namespace = _extract_namespace(args)
|
|
84
|
+
|
|
85
|
+
resource_type = None
|
|
86
|
+
resource_name = None
|
|
87
|
+
|
|
88
|
+
if rest:
|
|
89
|
+
first = rest[0]
|
|
90
|
+
if "/" in first:
|
|
91
|
+
# e.g. `pod/payment-api-7d9f`
|
|
92
|
+
resource_type, resource_name = first.split("/", 1)
|
|
93
|
+
else:
|
|
94
|
+
resource_type = first
|
|
95
|
+
if len(rest) > 1:
|
|
96
|
+
resource_name = rest[1]
|
|
97
|
+
|
|
98
|
+
if resource_type:
|
|
99
|
+
resource_type = RESOURCE_ALIASES.get(resource_type.lower(), resource_type.lower())
|
|
100
|
+
|
|
101
|
+
parameters = {}
|
|
102
|
+
replicas = _extract_replicas(args)
|
|
103
|
+
if replicas is not None:
|
|
104
|
+
parameters["replicas"] = replicas
|
|
105
|
+
|
|
106
|
+
return Change(
|
|
107
|
+
actor="user",
|
|
108
|
+
provider="kubernetes",
|
|
109
|
+
operation=op,
|
|
110
|
+
resource_type=resource_type,
|
|
111
|
+
resource_name=resource_name,
|
|
112
|
+
namespace=namespace,
|
|
113
|
+
parameters=parameters,
|
|
114
|
+
raw_args=args,
|
|
115
|
+
is_mutating=op in MUTATING_OPS,
|
|
116
|
+
)
|
propt/popeye_runner.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Popeye integration.
|
|
3
|
+
|
|
4
|
+
Runs the open-source Popeye cluster sanitizer (https://github.com/derailed/popeye)
|
|
5
|
+
against the live cluster and parses its JSON output into findings keyed
|
|
6
|
+
by resource, so the impact engine can filter to just the resources in
|
|
7
|
+
a change's dependency chain.
|
|
8
|
+
|
|
9
|
+
Popeye must be installed and on PATH as `popeye`.
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def popeye_available() -> bool:
|
|
17
|
+
return shutil.which("popeye") is not None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_popeye(namespace: str = "default") -> dict:
|
|
21
|
+
"""
|
|
22
|
+
Returns {"pod/name": [finding, ...], "deployment/name": [...], ...}
|
|
23
|
+
Falls back to an empty dict (with a note) if popeye isn't installed
|
|
24
|
+
or the scan fails -- we never fabricate findings.
|
|
25
|
+
"""
|
|
26
|
+
if not popeye_available():
|
|
27
|
+
return {"_unavailable": True, "_reason": "popeye not found on PATH"}
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
result = subprocess.run(
|
|
31
|
+
["popeye", "-n", namespace, "-o", "json", "--force-exit-zero"],
|
|
32
|
+
capture_output=True, text=True, timeout=60,
|
|
33
|
+
)
|
|
34
|
+
data = json.loads(result.stdout)
|
|
35
|
+
except Exception as e:
|
|
36
|
+
return {"_unavailable": True, "_reason": str(e)}
|
|
37
|
+
|
|
38
|
+
findings = {}
|
|
39
|
+
try:
|
|
40
|
+
sanitizers = data.get("popeye", {}).get("sanitizers", [])
|
|
41
|
+
for section in sanitizers:
|
|
42
|
+
gvr = section.get("sanitizer", "")
|
|
43
|
+
for issue_group in section.get("issues", {}).items():
|
|
44
|
+
resource_name, issues = issue_group
|
|
45
|
+
# Popeye keys issues by "namespace/name"
|
|
46
|
+
short_name = resource_name.split("/")[-1]
|
|
47
|
+
kind = _gvr_to_kind(gvr)
|
|
48
|
+
key = f"{kind}/{short_name}"
|
|
49
|
+
findings.setdefault(key, [])
|
|
50
|
+
for issue in issues:
|
|
51
|
+
findings[key].append({
|
|
52
|
+
"level": issue.get("level"),
|
|
53
|
+
"message": issue.get("message"),
|
|
54
|
+
})
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
return findings
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _gvr_to_kind(sanitizer_name: str) -> str:
|
|
62
|
+
mapping = {
|
|
63
|
+
"pod": "pod", "po": "pod",
|
|
64
|
+
"deployment": "deployment", "dp": "deployment",
|
|
65
|
+
"service": "service", "svc": "service",
|
|
66
|
+
"replicaset": "replicaset", "rs": "replicaset",
|
|
67
|
+
}
|
|
68
|
+
return mapping.get(sanitizer_name.lower(), sanitizer_name.lower())
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: propt
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pre-action operational intelligence layer for infrastructure changes.
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Requires-Dist: kubernetes>=29.0.0
|
|
7
|
+
Requires-Dist: networkx>=3.0
|
|
8
|
+
Requires-Dist: click>=8.0
|
|
9
|
+
Requires-Dist: requests>=2.28
|
|
10
|
+
Requires-Dist: pyyaml>=6.0
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
propt/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
propt/audit.py,sha256=nNcnipjuewUFLRJrUQhJXXtvhG6Bwv6pthPD0ApJvjY,2091
|
|
3
|
+
propt/cli.py,sha256=n8aNzOz2dzEhxYLegHjqYWWP7h1EDcn665U00i_U57w,1389
|
|
4
|
+
propt/engine.py,sha256=wvigf3EOBAQ7ZpO_nQurIvZdSnYXB4r3FFFMhvu9JBY,4411
|
|
5
|
+
propt/explain.py,sha256=1n4aokc0CZyqSlcm-vxnyJGogAIu1VhFT5xnCAIWQAQ,1005
|
|
6
|
+
propt/k8s_graph.py,sha256=7DbmAlBU-7T6QMXgP2XSiH_k2aT7e9IT-KqOeiUD2po,4284
|
|
7
|
+
propt/metrics.py,sha256=2zN1aTUPoD8letmC7PHfppQnx-46JOBWaodpW5PyYtA,1578
|
|
8
|
+
propt/parser.py,sha256=4GPFPwiaxaudMoX5Jcf6W7EWaAEnbOtCBMN7OoHjflg,3791
|
|
9
|
+
propt/popeye_runner.py,sha256=p6S3wJTFEdbzhKMeuXUYh7vRxd8NAgxIpCwpeyQE1yc,2289
|
|
10
|
+
propt-0.1.0.dist-info/METADATA,sha256=dnKszc7i8sX6GqZ3QoHIwMhP53zvCpLOhBvXwKo1B0s,307
|
|
11
|
+
propt-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
12
|
+
propt-0.1.0.dist-info/entry_points.txt,sha256=vThvx1V0s1ejRgU4JMNcKikTf_rebHslrLwN_RqHiv0,41
|
|
13
|
+
propt-0.1.0.dist-info/top_level.txt,sha256=Gr1nnzwiH4AJl7RyB6pWCwMFTsMget0VEwrx2MSpksk,6
|
|
14
|
+
propt-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
propt
|