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 +10 -0
- propt-0.1.0/README.md +183 -0
- propt-0.1.0/propt/__init__.py +1 -0
- propt-0.1.0/propt/audit.py +57 -0
- propt-0.1.0/propt/cli.py +60 -0
- propt-0.1.0/propt/engine.py +107 -0
- propt-0.1.0/propt/explain.py +29 -0
- propt-0.1.0/propt/k8s_graph.py +114 -0
- propt-0.1.0/propt/metrics.py +49 -0
- propt-0.1.0/propt/parser.py +116 -0
- propt-0.1.0/propt/popeye_runner.py +68 -0
- propt-0.1.0/propt.egg-info/PKG-INFO +10 -0
- propt-0.1.0/propt.egg-info/SOURCES.txt +20 -0
- propt-0.1.0/propt.egg-info/dependency_links.txt +1 -0
- propt-0.1.0/propt.egg-info/entry_points.txt +2 -0
- propt-0.1.0/propt.egg-info/requires.txt +5 -0
- propt-0.1.0/propt.egg-info/top_level.txt +1 -0
- propt-0.1.0/pyproject.toml +22 -0
- propt-0.1.0/setup.cfg +4 -0
- propt-0.1.0/tests/test_audit.py +30 -0
- propt-0.1.0/tests/test_engine.py +71 -0
- propt-0.1.0/tests/test_parser.py +37 -0
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
|
propt-0.1.0/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()
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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,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
|