propt 0.1.0__tar.gz

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-0.1.0/PKG-INFO ADDED
@@ -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
propt-0.1.0/README.md ADDED
@@ -0,0 +1,183 @@
1
+ # propt
2
+
3
+ Pre-action operational intelligence for Kubernetes. `propt` wraps `kubectl`
4
+ transparently — mutating commands (delete, scale, apply, patch, edit,
5
+ replace, rollout) get a real, live-cluster impact check and a
6
+ `Execute? [y/N]` prompt first. Read-only commands pass straight through
7
+ with zero overhead.
8
+
9
+ Built on real open-source signals, not a hardcoded risk table:
10
+ - **kubernetes** Python client — live resource discovery
11
+ - **networkx** — dependency graph from real `ownerReferences` and
12
+ label-selector matching (no invented relationships)
13
+ - **Popeye** (derailed/popeye) — real, actively-maintained cluster
14
+ sanitizer, used as the operational risk knowledge source
15
+ - **metrics-server** (optional) — real CPU usage, if installed
16
+
17
+ Every decision is also logged as an auditable record to **Amazon S3**
18
+ via `boto3` — see [AWS integration](#aws-integration-audit-logging) below.
19
+
20
+ ---
21
+
22
+ ## 1. Install (Windows 11 / PowerShell)
23
+
24
+ ### Python dependencies
25
+ ```powershell
26
+ cd propt
27
+ pip install -e .
28
+ pip install pytest
29
+ ```
30
+
31
+ ### Popeye (Windows binary, no Go toolchain needed)
32
+ 1. Go to https://github.com/derailed/popeye/releases
33
+ 2. Download the latest `popeye_Windows_x86_64.tar.gz` (or `.zip` if offered)
34
+ 3. Extract `popeye.exe` somewhere on your PATH, e.g. `C:\tools\popeye.exe`
35
+ 4. Verify: `popeye version`
36
+
37
+ If Popeye isn't found on PATH, `propt` still works — it just reports
38
+ "popeye not available" in the Unknown/missing context section instead
39
+ of fabricating findings.
40
+
41
+ ### Cluster
42
+ Make sure `kubectl` points at your minikube cluster:
43
+ ```powershell
44
+ minikube status
45
+ kubectl get nodes
46
+ ```
47
+
48
+ ### metrics-server (optional, for real load data)
49
+ ```powershell
50
+ minikube addons enable metrics-server
51
+ ```
52
+
53
+ ### AWS (for audit logging)
54
+ `propt` writes every decision it makes to an S3 bucket as a timestamped
55
+ JSON record, using your existing AWS CLI credentials (via `boto3` —
56
+ no keys are stored or handled by propt itself).
57
+
58
+ ```powershell
59
+ aws configure # if not already set up
60
+ aws s3 mb s3://<your-bucket-name> --region ap-south-1
61
+ ```
62
+ Set the bucket name propt should use (defaults to `propt-audit-logs-sid-2026`
63
+ if unset):
64
+ ```powershell
65
+ $env:PROPT_AUDIT_BUCKET = "<your-bucket-name>"
66
+ ```
67
+ If AWS isn't configured, `propt` still works — audit logging fails
68
+ silently rather than blocking the actual kubectl operation.
69
+
70
+ ---
71
+
72
+ ## 2. Verify it's wired up
73
+ ```powershell
74
+ pytest
75
+ ```
76
+ These tests mock the cluster so they run without one — they document
77
+ expected behavior (see `tests/test_engine.py`). The real proof is the
78
+ live demo below.
79
+
80
+ ---
81
+
82
+ ## 3. Set up the demo scenario
83
+
84
+ Deploy two contrasting workloads into your cluster:
85
+
86
+ ```powershell
87
+ kubectl create deployment payment-api --image=nginx --replicas=3
88
+ kubectl create deployment worker --image=nginx --replicas=10
89
+ kubectl expose deployment payment-api --port=80 --name=payment-api
90
+
91
+ # Simulate the understaffed condition
92
+ kubectl scale deployment payment-api --replicas=3
93
+ kubectl delete pod <one-of-the-payment-api-pods> # bring it to 2/3 without propt, to set the stage
94
+ ```
95
+
96
+ (Or just let `propt` itself do the second delete below — the first
97
+ manual delete above is only to establish the "already short one
98
+ replica" starting condition for the demo.)
99
+
100
+ ## 4. Run it
101
+
102
+ ```powershell
103
+ propt kubectl delete pod <another-payment-api-pod-name>
104
+ ```
105
+ Expect: **HIGH POTENTIAL IMPACT** — evidence should show available
106
+ replicas below desired, plus any live Popeye findings for that
107
+ deployment.
108
+
109
+ ```powershell
110
+ propt kubectl delete pod <a-worker-pod-name>
111
+ ```
112
+ Expect: **NONE / low impact** — same engine, different live state,
113
+ different conclusion.
114
+
115
+ ```powershell
116
+ propt kubectl get pods
117
+ ```
118
+ Expect: passes straight through immediately, no analysis, no prompt.
119
+
120
+ ---
121
+
122
+ ## Architecture
123
+
124
+ ```
125
+ kubectl command
126
+ |
127
+ v
128
+ Common Change Model (propt/parser.py)
129
+ |
130
+ v
131
+ Live Resource Graph (propt/k8s_graph.py) --- built from real ownerReferences + selectors
132
+ |
133
+ v
134
+ Impact Inference Engine (propt/engine.py) --- combines graph + Popeye findings + metrics
135
+ |
136
+ v
137
+ Explanation (propt/explain.py) --- printed to terminal, evidence-based, no opaque score
138
+ |
139
+ v
140
+ Execute? [y/N] --- human decides
141
+ |
142
+ +---> Audit record written to Amazon S3 (propt/audit.py, via boto3)
143
+ |
144
+ v
145
+ propt runs the ORIGINAL kubectl command as-is
146
+ ```
147
+
148
+
149
+ ## AWS integration: audit logging
150
+
151
+ Every impact decision — the command, the target, the evidence, the
152
+ consequence text, and whether the engineer chose to proceed — is
153
+ written to Amazon S3 as a timestamped JSON object under
154
+ `decisions/<timestamp>.json`. This gives a team a real, queryable
155
+ history of infrastructure decisions and their reasoning, matching the
156
+ "auditable, trust-first" principle from the original project spec
157
+ (never silently change policy; keep evidence and outcomes reviewable).
158
+
159
+ Example record:
160
+ ```json
161
+ {
162
+ "timestamp": "2026-09-20T06:48:43.005997+00:00",
163
+ "command": "delete pod payment-api-85648675fb-6bj46",
164
+ "target": "pod/payment-api-85648675fb-6bj46",
165
+ "impact_level": "HIGH",
166
+ "evidence": ["deployment/payment-api: available replicas (5) below desired (6)"],
167
+ "consequence": "Potential reduction in capacity or degraded state propagating to: deployment/payment-api, replicaset/payment-api-85648675fb, service/payment-api.",
168
+ "executed": false
169
+ }
170
+ ```
171
+
172
+ Audit logging is intentionally fire-and-forget: if AWS credentials
173
+ aren't configured or S3 is unreachable, `propt` degrades silently and
174
+ the actual kubectl workflow is never blocked.
175
+
176
+ ## What this is not (by design, for the hackathon scope)
177
+ - Not tied to a hardcoded command→risk table — risk comes from live
178
+ cluster state + Popeye's real findings.
179
+ - Not a live "traffic" simulator — if metrics-server isn't installed,
180
+ it says so rather than inventing a number.
181
+ - No LLM reasoning layer, no multi-provider adapters (Terraform etc.),
182
+ no auth/security hardening — explicitly out of scope for this build,
183
+ same as the original project spec.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -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
@@ -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()
@@ -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
+ )
@@ -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)
@@ -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
@@ -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
@@ -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
+ )
@@ -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,20 @@
1
+ README.md
2
+ pyproject.toml
3
+ propt/__init__.py
4
+ propt/audit.py
5
+ propt/cli.py
6
+ propt/engine.py
7
+ propt/explain.py
8
+ propt/k8s_graph.py
9
+ propt/metrics.py
10
+ propt/parser.py
11
+ propt/popeye_runner.py
12
+ propt.egg-info/PKG-INFO
13
+ propt.egg-info/SOURCES.txt
14
+ propt.egg-info/dependency_links.txt
15
+ propt.egg-info/entry_points.txt
16
+ propt.egg-info/requires.txt
17
+ propt.egg-info/top_level.txt
18
+ tests/test_audit.py
19
+ tests/test_engine.py
20
+ tests/test_parser.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ propt = propt.cli:main
@@ -0,0 +1,5 @@
1
+ kubernetes>=29.0.0
2
+ networkx>=3.0
3
+ click>=8.0
4
+ requests>=2.28
5
+ pyyaml>=6.0
@@ -0,0 +1 @@
1
+ propt
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "propt"
7
+ version = "0.1.0"
8
+ description = "Pre-action operational intelligence layer for infrastructure changes."
9
+ requires-python = ">=3.9"
10
+ dependencies = [
11
+ "kubernetes>=29.0.0",
12
+ "networkx>=3.0",
13
+ "click>=8.0",
14
+ "requests>=2.28",
15
+ "pyyaml>=6.0",
16
+ ]
17
+
18
+ [project.scripts]
19
+ propt = "propt.cli:main"
20
+
21
+ [tool.setuptools]
22
+ packages = ["propt"]
propt-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,30 @@
1
+ from propt import audit
2
+ from propt.parser import parse_kubectl
3
+ from propt.engine import Impact
4
+
5
+
6
+ def test_log_decision_never_raises_on_bad_credentials(monkeypatch):
7
+ """Audit logging must never crash the CLI, even if AWS is
8
+ unreachable, unconfigured, or boto3 throws."""
9
+ change = parse_kubectl(["delete", "pod", "payment-api-A"])
10
+ impact = Impact(level="HIGH", target="pod/payment-api-A", evidence=["test"])
11
+
12
+ # Should not raise even with no real AWS credentials configured
13
+ # in the test environment.
14
+ audit.log_decision(change, impact, executed=False)
15
+
16
+
17
+ def test_log_decision_handles_missing_boto3(monkeypatch):
18
+ import builtins
19
+ real_import = builtins.__import__
20
+
21
+ def fake_import(name, *args, **kwargs):
22
+ if name == "boto3":
23
+ raise ImportError("no boto3")
24
+ return real_import(name, *args, **kwargs)
25
+
26
+ monkeypatch.setattr(builtins, "__import__", fake_import)
27
+
28
+ change = parse_kubectl(["delete", "pod", "payment-api-A"])
29
+ impact = Impact(level="HIGH", target="pod/payment-api-A")
30
+ audit.log_decision(change, impact, executed=False) # must not raisepytest
@@ -0,0 +1,71 @@
1
+ """
2
+ These tests mock the cluster-facing layer (k8s_graph, popeye_runner,
3
+ metrics) so the inference logic itself can be verified without a
4
+ live cluster -- useful for CI, and for anyone reading this as
5
+ documentation of expected behavior.
6
+
7
+ Run the two demo scenarios against a REAL cluster with:
8
+ propt kubectl delete pod payment-api-A (expect HIGH)
9
+ propt kubectl delete pod worker-A (expect NONE/LOW)
10
+ """
11
+ import networkx as nx
12
+ import pytest
13
+
14
+ from propt import engine
15
+ from propt.parser import parse_kubectl
16
+
17
+
18
+ class FakeDeployment:
19
+ def __init__(self, desired, available):
20
+ self.spec = type("Spec", (), {"replicas": desired})()
21
+ self.status = type("Status", (), {"available_replicas": available, "ready_replicas": available})()
22
+
23
+
24
+ def make_fake_graph(desired, available):
25
+ g = nx.DiGraph()
26
+ g.add_node("pod/payment-api-A", kind="pod", raw=None)
27
+ g.add_node("replicaset/payment-api-rs", kind="replicaset", raw=None)
28
+ g.add_node("deployment/payment-api", kind="deployment", raw=FakeDeployment(desired, available))
29
+ g.add_node("service/payment-api", kind="service", raw=None)
30
+ g.add_edge("pod/payment-api-A", "replicaset/payment-api-rs", relation="owned_by")
31
+ g.add_edge("replicaset/payment-api-rs", "deployment/payment-api", relation="owned_by")
32
+ g.add_edge("pod/payment-api-A", "service/payment-api", relation="selected_by")
33
+ return g
34
+
35
+
36
+ def test_high_impact_when_understaffed(monkeypatch):
37
+ monkeypatch.setattr(engine.k8s_graph, "build_graph", lambda ns: make_fake_graph(3, 2))
38
+ monkeypatch.setattr(engine.popeye_runner, "run_popeye", lambda ns: {"_unavailable": True, "_reason": "test"})
39
+ monkeypatch.setattr(engine.metrics, "metrics_available", lambda: False)
40
+
41
+ change = parse_kubectl(["delete", "pod", "payment-api-A"])
42
+ impact = engine.analyze(change)
43
+
44
+ assert impact.level == "HIGH"
45
+ assert any("below desired" in e for e in impact.evidence)
46
+ assert "deployment/payment-api" in impact.affected
47
+
48
+
49
+ def test_no_impact_when_fully_staffed(monkeypatch):
50
+ monkeypatch.setattr(engine.k8s_graph, "build_graph", lambda ns: make_fake_graph(3, 3))
51
+ monkeypatch.setattr(engine.popeye_runner, "run_popeye", lambda ns: {"_unavailable": True, "_reason": "test"})
52
+ monkeypatch.setattr(engine.metrics, "metrics_available", lambda: False)
53
+
54
+ change = parse_kubectl(["delete", "pod", "payment-api-A"])
55
+ impact = engine.analyze(change)
56
+
57
+ assert impact.level == "NONE"
58
+
59
+
60
+ def test_read_only_command_skips_analysis():
61
+ change = parse_kubectl(["get", "pods"])
62
+ impact = engine.analyze(change)
63
+ assert impact.level == "NONE"
64
+ assert "Read-only" in impact.consequence
65
+
66
+
67
+ def test_unknown_resource_returns_unknown(monkeypatch):
68
+ monkeypatch.setattr(engine.k8s_graph, "build_graph", lambda ns: nx.DiGraph())
69
+ change = parse_kubectl(["delete", "pod", "does-not-exist"])
70
+ impact = engine.analyze(change)
71
+ assert impact.level == "UNKNOWN"
@@ -0,0 +1,37 @@
1
+ from propt.parser import parse_kubectl
2
+
3
+
4
+ def test_delete_pod():
5
+ c = parse_kubectl(["delete", "pod", "payment-api-7d9f"])
6
+ assert c.operation == "delete"
7
+ assert c.resource_type == "pod"
8
+ assert c.resource_name == "payment-api-7d9f"
9
+ assert c.is_mutating is True
10
+ assert c.target() == "pod/payment-api-7d9f"
11
+
12
+
13
+ def test_get_pods_is_not_mutating():
14
+ c = parse_kubectl(["get", "pods"])
15
+ assert c.is_mutating is False
16
+
17
+
18
+ def test_resource_alias_normalization():
19
+ c = parse_kubectl(["delete", "po", "worker-A"])
20
+ assert c.resource_type == "pod"
21
+
22
+
23
+ def test_slash_syntax():
24
+ c = parse_kubectl(["delete", "pod/payment-api-7d9f"])
25
+ assert c.resource_type == "pod"
26
+ assert c.resource_name == "payment-api-7d9f"
27
+
28
+
29
+ def test_namespace_flag():
30
+ c = parse_kubectl(["delete", "pod", "foo", "-n", "prod"])
31
+ assert c.namespace == "prod"
32
+
33
+
34
+ def test_scale_replicas_param():
35
+ c = parse_kubectl(["scale", "deployment", "worker", "--replicas=1"])
36
+ assert c.parameters.get("replicas") == 1
37
+ assert c.is_mutating is True