cloudcleaner-agent 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.
- cloudcleaner/__init__.py +0 -0
- cloudcleaner/cli.py +239 -0
- cloudcleaner/config.py +103 -0
- cloudcleaner/evaluation/__init__.py +0 -0
- cloudcleaner/evaluation/evaluator.py +0 -0
- cloudcleaner/evidence/__init__.py +0 -0
- cloudcleaner/evidence/collector.py +80 -0
- cloudcleaner/evidence/formatter.py +0 -0
- cloudcleaner/fixtures/__init__.py +0 -0
- cloudcleaner/fixtures/demo.py +97 -0
- cloudcleaner/graph/__init__.py +0 -0
- cloudcleaner/graph/graph.py +101 -0
- cloudcleaner/graph/nodes/__init__.py +0 -0
- cloudcleaner/graph/nodes/approval.py +78 -0
- cloudcleaner/graph/nodes/assess.py +136 -0
- cloudcleaner/graph/nodes/detect.py +79 -0
- cloudcleaner/graph/nodes/execute.py +142 -0
- cloudcleaner/graph/nodes/investigate.py +30 -0
- cloudcleaner/graph/nodes/plan.py +52 -0
- cloudcleaner/graph/nodes/policy_check.py +25 -0
- cloudcleaner/graph/nodes/record.py +27 -0
- cloudcleaner/graph/nodes/rollback.py +90 -0
- cloudcleaner/graph/nodes/verify.py +100 -0
- cloudcleaner/graph/render.py +33 -0
- cloudcleaner/graph/routing.py +92 -0
- cloudcleaner/graph/state.py +53 -0
- cloudcleaner/policy/__init__.py +11 -0
- cloudcleaner/policy/approval.py +38 -0
- cloudcleaner/policy/context.py +67 -0
- cloudcleaner/policy/dependencies.py +151 -0
- cloudcleaner/policy/metrics.py +22 -0
- cloudcleaner/policy/risk.py +197 -0
- cloudcleaner/policy/safety.py +151 -0
- cloudcleaner/schemas.py +266 -0
- cloudcleaner/server.py +431 -0
- cloudcleaner/storage/__init__.py +0 -0
- cloudcleaner/storage/db.py +161 -0
- cloudcleaner/storage/repository.py +339 -0
- cloudcleaner/tools/__init__.py +0 -0
- cloudcleaner/tools/aws/__init__.py +0 -0
- cloudcleaner/tools/aws/actions.py +276 -0
- cloudcleaner/tools/aws/addresses.py +30 -0
- cloudcleaner/tools/aws/client.py +22 -0
- cloudcleaner/tools/aws/cost.py +98 -0
- cloudcleaner/tools/aws/history.py +0 -0
- cloudcleaner/tools/aws/inventory.py +63 -0
- cloudcleaner/tools/aws/metrics.py +75 -0
- cloudcleaner/tools/aws/pricing.py +80 -0
- cloudcleaner/tools/aws/volumes.py +69 -0
- cloudcleaner/tools/email/__init__.py +0 -0
- cloudcleaner/tools/email/messages.py +104 -0
- cloudcleaner/tools/github/__init__.py +0 -0
- cloudcleaner/tools/github/branches.py +17 -0
- cloudcleaner/tools/github/cicd.py +56 -0
- cloudcleaner/tools/github/client.py +11 -0
- cloudcleaner/tools/github/commits.py +24 -0
- cloudcleaner/tools/github/pull_requests.py +43 -0
- cloudcleaner/tools/provider.py +81 -0
- cloudcleaner/tools/slack/__init__.py +0 -0
- cloudcleaner/tools/slack/approvals.py +83 -0
- cloudcleaner/tools/slack/messages.py +171 -0
- cloudcleaner_agent-0.1.0.dist-info/METADATA +197 -0
- cloudcleaner_agent-0.1.0.dist-info/RECORD +66 -0
- cloudcleaner_agent-0.1.0.dist-info/WHEEL +5 -0
- cloudcleaner_agent-0.1.0.dist-info/entry_points.txt +2 -0
- cloudcleaner_agent-0.1.0.dist-info/top_level.txt +1 -0
cloudcleaner/__init__.py
ADDED
|
File without changes
|
cloudcleaner/cli.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Command line entry point.
|
|
2
|
+
|
|
3
|
+
cloudcleaner scan what the account holds, and what it costs
|
|
4
|
+
cloudcleaner sweep investigate everything, approve nothing
|
|
5
|
+
cloudcleaner investigate <id> one resource, with an approval prompt
|
|
6
|
+
cloudcleaner history past runs and realised savings
|
|
7
|
+
cloudcleaner doctor check credentials and configuration
|
|
8
|
+
cloudcleaner serve the HTTP API (needs the [server] extra)
|
|
9
|
+
|
|
10
|
+
Every command is read-only unless you approve something, and even then
|
|
11
|
+
CLOUDCLEANER_DRY_RUN defaults to true.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
import uuid
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _fmt(amount) -> str:
|
|
20
|
+
return f"${amount or 0:,.2f}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def cmd_scan(args) -> int:
|
|
24
|
+
from cloudcleaner.config import DRY_RUN, PROVIDER
|
|
25
|
+
from cloudcleaner.graph.nodes.detect import detect_node
|
|
26
|
+
|
|
27
|
+
scan = detect_node({})
|
|
28
|
+
rows = (scan.get("inventory") or []) + (scan.get("orphans") or [])
|
|
29
|
+
if not rows:
|
|
30
|
+
print("Nothing found. Either the account is empty or the credentials cannot see it.")
|
|
31
|
+
return 0
|
|
32
|
+
|
|
33
|
+
total = sum(r.estimated_monthly_cost or 0 for r in rows)
|
|
34
|
+
wasted = sum(r.estimated_monthly_cost or 0 for r in rows if r.billing_while_stopped)
|
|
35
|
+
|
|
36
|
+
print(f"provider={PROVIDER} dry_run={DRY_RUN}\n")
|
|
37
|
+
print(f"{'RESOURCE':<26} {'TYPE':<6} {'STATE':<14} {'$/MO':>8}")
|
|
38
|
+
for r in sorted(rows, key=lambda r: -(r.estimated_monthly_cost or 0)):
|
|
39
|
+
flag = " *" if r.billing_while_stopped else ""
|
|
40
|
+
print(f"{r.resource_id:<26} {r.resource_type:<6} {(r.state or '-'):<14} "
|
|
41
|
+
f"{_fmt(r.estimated_monthly_cost):>8}{flag}")
|
|
42
|
+
|
|
43
|
+
print(f"\n{len(rows)} resources · {_fmt(total)}/mo total · {_fmt(wasted)}/mo not running "
|
|
44
|
+
f"but still billing (*)")
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cmd_sweep(args) -> int:
|
|
49
|
+
"""Assess every candidate. Approves nothing, so it is safe to run anywhere."""
|
|
50
|
+
from langgraph.types import Command
|
|
51
|
+
|
|
52
|
+
from cloudcleaner.config import DRY_RUN, PROVIDER
|
|
53
|
+
from cloudcleaner.graph.graph import graph
|
|
54
|
+
from cloudcleaner.graph.nodes.detect import detect_node
|
|
55
|
+
|
|
56
|
+
scan = detect_node({})
|
|
57
|
+
candidates = (scan.get("inventory") or []) + (scan.get("orphans") or [])
|
|
58
|
+
if not candidates:
|
|
59
|
+
print("Nothing to investigate.")
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
print(f"provider={PROVIDER} dry_run={DRY_RUN}\n")
|
|
63
|
+
recoverable = 0.0
|
|
64
|
+
|
|
65
|
+
for resource in candidates:
|
|
66
|
+
config = {"configurable": {"thread_id": str(uuid.uuid4())}}
|
|
67
|
+
result = graph.invoke({**scan, "resource": resource}, config)
|
|
68
|
+
if result.get("__interrupt__"):
|
|
69
|
+
result = graph.invoke(Command(resume=""), config)
|
|
70
|
+
|
|
71
|
+
rec, plan = result.get("recommendation"), result.get("plan")
|
|
72
|
+
saving = sum(s.monthly_saving for s in plan.steps) if plan and plan.steps else 0.0
|
|
73
|
+
recoverable += saving
|
|
74
|
+
|
|
75
|
+
print(f"{resource.resource_id} {resource.resource_type}/{resource.state} "
|
|
76
|
+
f"{_fmt(resource.estimated_monthly_cost)}/mo")
|
|
77
|
+
if rec:
|
|
78
|
+
print(f" -> {rec.action} ({rec.confidence:.0%}, {rec.severity}) {rec.reason}")
|
|
79
|
+
if plan and plan.blocked:
|
|
80
|
+
print(f" -> blocked: {'; '.join(plan.blocked)}")
|
|
81
|
+
for s in (plan.steps if plan else []):
|
|
82
|
+
mark = "!" if not s.reversible else " "
|
|
83
|
+
print(f" {mark} {s.order}. {s.action:<21} {s.resource_id:<22} "
|
|
84
|
+
f"{_fmt(s.monthly_saving):>8} {s.reason}")
|
|
85
|
+
print()
|
|
86
|
+
|
|
87
|
+
print(f"Recoverable: {_fmt(recoverable)}/month ({_fmt(recoverable * 12)}/year)")
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def cmd_investigate(args) -> int:
|
|
92
|
+
from langgraph.types import Command
|
|
93
|
+
|
|
94
|
+
from cloudcleaner.config import DRY_RUN, PROVIDER
|
|
95
|
+
from cloudcleaner.graph.graph import graph
|
|
96
|
+
from cloudcleaner.graph.nodes.detect import detect_node
|
|
97
|
+
from cloudcleaner.graph.render import render_plan
|
|
98
|
+
|
|
99
|
+
scan = detect_node({})
|
|
100
|
+
pool = (scan.get("inventory") or []) + (scan.get("orphans") or [])
|
|
101
|
+
resource = next((r for r in pool if r.resource_id == args.resource_id), None)
|
|
102
|
+
if resource is None:
|
|
103
|
+
print(f"No resource {args.resource_id} in this account.", file=sys.stderr)
|
|
104
|
+
print("Run `cloudcleaner scan` to see what is visible.", file=sys.stderr)
|
|
105
|
+
return 1
|
|
106
|
+
|
|
107
|
+
print(f"provider={PROVIDER} dry_run={DRY_RUN}\n")
|
|
108
|
+
config = {"configurable": {"thread_id": str(uuid.uuid4())}}
|
|
109
|
+
result = graph.invoke({**scan, "resource": resource, "force_plan": args.force_plan}, config)
|
|
110
|
+
|
|
111
|
+
while "__interrupt__" in result:
|
|
112
|
+
payload = result["__interrupt__"][0].value
|
|
113
|
+
if not sys.stdin.isatty():
|
|
114
|
+
print(render_plan(payload).rstrip() + " [not a terminal, skipped]")
|
|
115
|
+
result = graph.invoke(Command(resume=""), config)
|
|
116
|
+
continue
|
|
117
|
+
result = graph.invoke(Command(resume=input(render_plan(payload))), config)
|
|
118
|
+
|
|
119
|
+
rec = result.get("recommendation")
|
|
120
|
+
if rec:
|
|
121
|
+
print(f"\nverdict {rec.action} ({rec.confidence:.0%}, {rec.severity})")
|
|
122
|
+
print(f" {rec.reason}")
|
|
123
|
+
for a in result.get("action_results") or []:
|
|
124
|
+
print(f"action {a['action']} {a['resource_id']} -> {a['detail']}")
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def cmd_history(args) -> int:
|
|
129
|
+
from cloudcleaner.storage.repository import list_runs, totals
|
|
130
|
+
|
|
131
|
+
runs = list_runs(args.limit)
|
|
132
|
+
if not runs:
|
|
133
|
+
print("No runs recorded yet.")
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
t = totals()
|
|
137
|
+
print(f"{t['runs']} runs · {t['approved']} approved · {t['kept']} kept")
|
|
138
|
+
print(f"realised {_fmt(t['realised_monthly'])}/mo "
|
|
139
|
+
f"simulated {_fmt(t['simulated_monthly'])}/mo (dry run, not saved)\n")
|
|
140
|
+
|
|
141
|
+
for r in runs:
|
|
142
|
+
outcome = (f"blocked: {r['blocked'][0]}" if r["blocked"]
|
|
143
|
+
else f"{r['executed']}/{r['planned_steps']} steps" if r["executed"]
|
|
144
|
+
else "no action")
|
|
145
|
+
print(f"{r['at'][:16]} {r['resource_id']:<26} {str(r['verdict']):<10} {outcome}")
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def cmd_doctor(args) -> int:
|
|
150
|
+
"""Check the things that actually stop this working."""
|
|
151
|
+
from cloudcleaner import config
|
|
152
|
+
|
|
153
|
+
ok = True
|
|
154
|
+
print(f"config file {config.ENV_FILE or 'none found'}")
|
|
155
|
+
print(f"data dir {config.DATA_DIR}")
|
|
156
|
+
print(f"provider {config.PROVIDER}")
|
|
157
|
+
print(f"dry run {config.DRY_RUN}")
|
|
158
|
+
print(f"model {config.GROQ_MODEL if config.AI_ENABLED else 'disabled, rules only'}")
|
|
159
|
+
print()
|
|
160
|
+
|
|
161
|
+
def check(label, passed, hint=""):
|
|
162
|
+
nonlocal ok
|
|
163
|
+
ok = ok and passed
|
|
164
|
+
print(f"[{'ok ' if passed else 'FAIL'}] {label}" + (f" — {hint}" if not passed else ""))
|
|
165
|
+
|
|
166
|
+
check("Groq key", bool(config.GROQ_API_KEY) or not config.AI_ENABLED,
|
|
167
|
+
"set GROQ_API_KEY, or CLOUDCLEANER_AI_ENABLED=false for rules only")
|
|
168
|
+
|
|
169
|
+
if config.PROVIDER == "fixture":
|
|
170
|
+
print("[ok ] AWS not needed — running against the built-in demo account")
|
|
171
|
+
else:
|
|
172
|
+
try:
|
|
173
|
+
from cloudcleaner.tools.aws.client import get_sts_client
|
|
174
|
+
who = get_sts_client().get_caller_identity()
|
|
175
|
+
check(f"AWS account {who['Account']}", True)
|
|
176
|
+
except Exception as e:
|
|
177
|
+
check("AWS credentials", False, str(e)[:80])
|
|
178
|
+
|
|
179
|
+
check("GitHub token", bool(config.GITHUB_TOKEN),
|
|
180
|
+
"optional — without it, code activity is skipped, not fatal")
|
|
181
|
+
|
|
182
|
+
return 0 if ok else 1
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def cmd_serve(args) -> int:
|
|
186
|
+
try:
|
|
187
|
+
import uvicorn # noqa: F401
|
|
188
|
+
except ImportError:
|
|
189
|
+
print("The HTTP API needs the server extra:\n\n"
|
|
190
|
+
" pip install 'cloudcleaner-agent[server]'\n", file=sys.stderr)
|
|
191
|
+
return 1
|
|
192
|
+
|
|
193
|
+
import uvicorn
|
|
194
|
+
uvicorn.run("cloudcleaner.server:app", host=args.host, port=args.port, reload=args.reload)
|
|
195
|
+
return 0
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
199
|
+
p = argparse.ArgumentParser(
|
|
200
|
+
prog="cloudcleaner",
|
|
201
|
+
description="Find AWS resources nobody is using, prove it, and plan a safe teardown.",
|
|
202
|
+
)
|
|
203
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
204
|
+
|
|
205
|
+
sub.add_parser("scan", help="list resources and what they cost").set_defaults(fn=cmd_scan)
|
|
206
|
+
sub.add_parser("sweep", help="investigate every candidate").set_defaults(fn=cmd_sweep)
|
|
207
|
+
|
|
208
|
+
inv = sub.add_parser("investigate", help="investigate one resource")
|
|
209
|
+
inv.add_argument("resource_id")
|
|
210
|
+
inv.add_argument("--force-plan", action="store_true",
|
|
211
|
+
help="plan a teardown even when the verdict is keep")
|
|
212
|
+
inv.set_defaults(fn=cmd_investigate)
|
|
213
|
+
|
|
214
|
+
hist = sub.add_parser("history", help="past runs and realised savings")
|
|
215
|
+
hist.add_argument("--limit", type=int, default=20)
|
|
216
|
+
hist.set_defaults(fn=cmd_history)
|
|
217
|
+
|
|
218
|
+
sub.add_parser("doctor", help="check credentials and configuration").set_defaults(fn=cmd_doctor)
|
|
219
|
+
|
|
220
|
+
serve = sub.add_parser("serve", help="run the HTTP API")
|
|
221
|
+
serve.add_argument("--host", default="0.0.0.0")
|
|
222
|
+
serve.add_argument("--port", type=int, default=8123)
|
|
223
|
+
serve.add_argument("--reload", action="store_true")
|
|
224
|
+
serve.set_defaults(fn=cmd_serve)
|
|
225
|
+
|
|
226
|
+
return p
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def main(argv=None) -> int:
|
|
230
|
+
args = build_parser().parse_args(argv)
|
|
231
|
+
try:
|
|
232
|
+
return args.fn(args)
|
|
233
|
+
except KeyboardInterrupt:
|
|
234
|
+
print("\ninterrupted", file=sys.stderr)
|
|
235
|
+
return 130
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
if __name__ == "__main__":
|
|
239
|
+
raise SystemExit(main())
|
cloudcleaner/config.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Backend configuration.
|
|
2
|
+
|
|
3
|
+
Paths are discovered rather than assumed, so the package works from a checkout,
|
|
4
|
+
from any working directory, or installed into site-packages.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from dotenv import load_dotenv
|
|
11
|
+
|
|
12
|
+
SEARCH_DEPTH = 4
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def find_env_file() -> Path | None:
|
|
16
|
+
"""Locate .env without assuming a git checkout.
|
|
17
|
+
|
|
18
|
+
Order: an explicit CLOUDCLEANER_ENV_FILE, then upwards from the working
|
|
19
|
+
directory, then upwards from this package, then ~/.cloudcleaner/.env. The
|
|
20
|
+
package walk keeps `uv run` working from inside agent/; the cwd walk is what
|
|
21
|
+
makes an installed copy usable from anywhere.
|
|
22
|
+
"""
|
|
23
|
+
explicit = os.getenv("CLOUDCLEANER_ENV_FILE")
|
|
24
|
+
if explicit:
|
|
25
|
+
candidate = Path(explicit).expanduser()
|
|
26
|
+
return candidate if candidate.is_file() else None
|
|
27
|
+
|
|
28
|
+
for start in (Path.cwd(), Path(__file__).resolve().parent):
|
|
29
|
+
for directory in [start, *start.parents][:SEARCH_DEPTH]:
|
|
30
|
+
candidate = directory / ".env"
|
|
31
|
+
if candidate.is_file():
|
|
32
|
+
return candidate
|
|
33
|
+
|
|
34
|
+
home = Path.home() / ".cloudcleaner" / ".env"
|
|
35
|
+
return home if home.is_file() else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
ENV_FILE = find_env_file()
|
|
39
|
+
if ENV_FILE:
|
|
40
|
+
load_dotenv(ENV_FILE)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def find_data_dir() -> Path:
|
|
44
|
+
"""Where the database, reasoning log and restore recipes are written.
|
|
45
|
+
|
|
46
|
+
Beside the .env when there is one, so a checkout keeps using output/.
|
|
47
|
+
Otherwise ~/.cloudcleaner, so an installed copy never writes to
|
|
48
|
+
site-packages.
|
|
49
|
+
"""
|
|
50
|
+
explicit = os.getenv("CLOUDCLEANER_DATA_DIR")
|
|
51
|
+
if explicit:
|
|
52
|
+
return Path(explicit).expanduser()
|
|
53
|
+
if ENV_FILE:
|
|
54
|
+
return ENV_FILE.parent / "output"
|
|
55
|
+
return Path.home() / ".cloudcleaner"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
DATA_DIR = find_data_dir()
|
|
59
|
+
|
|
60
|
+
# Back-compat aliases. Prefer DATA_DIR for anything that writes.
|
|
61
|
+
PROJECT_ROOT = ENV_FILE.parent if ENV_FILE else Path.cwd()
|
|
62
|
+
APP_DIR = PROJECT_ROOT
|
|
63
|
+
|
|
64
|
+
# Top-level constants for simple `from cloudcleaner.config import X` imports.
|
|
65
|
+
AWS_REGION = os.getenv("AWS_REGION", "us-east-1")
|
|
66
|
+
GROQ_API_KEY = os.getenv("GROQ_API_KEY")
|
|
67
|
+
GROQ_MODEL = os.getenv("GROQ_MODEL", "openai/gpt-oss-20b")
|
|
68
|
+
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN")
|
|
69
|
+
|
|
70
|
+
# Point boto3 at LocalStack instead of real AWS when set.
|
|
71
|
+
AWS_ENDPOINT_URL = os.getenv("AWS_ENDPOINT_URL") or None
|
|
72
|
+
|
|
73
|
+
# "aws" reads the live account; "fixture" runs offline against fixtures/demo.py.
|
|
74
|
+
# Resolved at call time in tools/provider.py, so tests can flip it per-test.
|
|
75
|
+
PROVIDER = os.getenv("CLOUDCLEANER_PROVIDER", "aws").lower()
|
|
76
|
+
|
|
77
|
+
METRIC_WINDOW_DAYS = int(os.getenv("METRIC_WINDOW_DAYS", "7"))
|
|
78
|
+
|
|
79
|
+
DRY_RUN = os.getenv("CLOUDCLEANER_DRY_RUN", "true").lower() not in ("false", "0", "no")
|
|
80
|
+
|
|
81
|
+
# false = rules only; no resource metadata is sent to the LLM.
|
|
82
|
+
AI_ENABLED = os.getenv("CLOUDCLEANER_AI_ENABLED", "true").lower() not in ("false", "0", "no")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Settings:
|
|
86
|
+
AWS_REGION: str = AWS_REGION
|
|
87
|
+
GROQ_API_KEY: str | None = GROQ_API_KEY
|
|
88
|
+
GROQ_MODEL: str = GROQ_MODEL
|
|
89
|
+
GITHUB_TOKEN: str | None = GITHUB_TOKEN
|
|
90
|
+
|
|
91
|
+
# Safety / Actions / Evaluation settings
|
|
92
|
+
DRY_RUN: bool = DRY_RUN
|
|
93
|
+
VERIFY_MAX_ATTEMPTS: int = int(os.getenv("CLOUDCLEANER_VERIFY_MAX_ATTEMPTS", "5"))
|
|
94
|
+
VERIFY_POLL_INTERVAL_SECONDS: float = float(
|
|
95
|
+
os.getenv("CLOUDCLEANER_VERIFY_POLL_INTERVAL", "2")
|
|
96
|
+
)
|
|
97
|
+
ROLLBACK_MAX_RETRIES: int = int(os.getenv("CLOUDCLEANER_ROLLBACK_MAX_RETRIES", "2"))
|
|
98
|
+
COST_APPROVAL_THRESHOLD_USD: float = float(
|
|
99
|
+
os.getenv("CLOUDCLEANER_COST_THRESHOLD_USD", "50")
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
settings = Settings()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Reasoning trail.
|
|
2
|
+
|
|
3
|
+
Every event is buffered for the current run, appended to a JSONL file for
|
|
4
|
+
tailing, and flushed to SQLite when the run is recorded. The buffer is scoped
|
|
5
|
+
to a run rather than the process, so one investigation never reports another's
|
|
6
|
+
reasoning.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from cloudcleaner.config import DATA_DIR
|
|
15
|
+
|
|
16
|
+
EVENT_TYPES = {"check", "finding", "skip", "decision", "action", "handoff", "error"}
|
|
17
|
+
CORE_FIELDS = ("ts", "node", "event", "resource_id", "message")
|
|
18
|
+
DEFAULT_LOG = DATA_DIR / "reasoning.jsonl"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ReasoningLog:
|
|
22
|
+
def __init__(self, path: Path | None = None):
|
|
23
|
+
self.path = path or DEFAULT_LOG
|
|
24
|
+
self.events: list[dict] = []
|
|
25
|
+
|
|
26
|
+
def start(self, truncate_file: bool = False):
|
|
27
|
+
"""Begin a new run. The file is appended to unless asked to truncate."""
|
|
28
|
+
self.events = []
|
|
29
|
+
if not truncate_file:
|
|
30
|
+
return
|
|
31
|
+
try:
|
|
32
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
self.path.write_text("")
|
|
34
|
+
except OSError as e:
|
|
35
|
+
print(f"[reasoning] cannot open log: {e}", file=sys.stderr)
|
|
36
|
+
|
|
37
|
+
def emit(self, node: str, event: str, resource_id: str, message: str, **extra):
|
|
38
|
+
if event not in EVENT_TYPES:
|
|
39
|
+
event = "check"
|
|
40
|
+
record = {
|
|
41
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
42
|
+
"node": node,
|
|
43
|
+
"event": event,
|
|
44
|
+
"resource_id": resource_id,
|
|
45
|
+
"message": message,
|
|
46
|
+
**extra,
|
|
47
|
+
}
|
|
48
|
+
self.events.append(record)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
with self.path.open("a") as f:
|
|
53
|
+
f.write(json.dumps(record) + "\n")
|
|
54
|
+
except OSError as e:
|
|
55
|
+
print(f"[reasoning] write failed: {e}", file=sys.stderr)
|
|
56
|
+
|
|
57
|
+
return record
|
|
58
|
+
|
|
59
|
+
def flush(self, run_id: str, conn) -> int:
|
|
60
|
+
"""Persist this run's events against the run that produced them."""
|
|
61
|
+
if not self.events:
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
conn.executemany(
|
|
65
|
+
"""INSERT INTO events (run_id, at, node, event, resource_id, message, extra)
|
|
66
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
|
67
|
+
[
|
|
68
|
+
(
|
|
69
|
+
run_id, e["ts"], e["node"], e["event"], e["resource_id"], e["message"],
|
|
70
|
+
json.dumps({k: v for k, v in e.items() if k not in CORE_FIELDS}),
|
|
71
|
+
)
|
|
72
|
+
for e in self.events
|
|
73
|
+
],
|
|
74
|
+
)
|
|
75
|
+
count = len(self.events)
|
|
76
|
+
self.events = []
|
|
77
|
+
return count
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
log = ReasoningLog()
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Offline demo stack. Shapes match real describe_* responses so the graph cannot tell."""
|
|
2
|
+
|
|
3
|
+
from cloudcleaner.schemas import CloudResource, GitHubEvidence
|
|
4
|
+
|
|
5
|
+
INSTANCES = [
|
|
6
|
+
CloudResource(
|
|
7
|
+
resource_id="i-0abc123def456789", resource_type="ec2", region="us-east-1",
|
|
8
|
+
name="payments-poc", state="stopped", instance_type="t3.micro",
|
|
9
|
+
launch_time="2026-06-14T09:12:00+00:00",
|
|
10
|
+
project="payments", environment="dev", owner="hayden",
|
|
11
|
+
tags={"Name": "payments-poc", "Project": "payments",
|
|
12
|
+
"Environment": "dev", "Owner": "hayden", "Repo": "wkxcass/cloudcleaner-demo-payments"},
|
|
13
|
+
),
|
|
14
|
+
CloudResource(
|
|
15
|
+
resource_id="i-0999prod888", resource_type="ec2", region="us-east-1",
|
|
16
|
+
name="checkout-api", state="running", instance_type="t3.medium",
|
|
17
|
+
launch_time="2026-02-01T08:00:00+00:00",
|
|
18
|
+
project="checkout", environment="prod", owner="team-payments",
|
|
19
|
+
tags={"Name": "checkout-api", "Environment": "prod", "Project": "checkout"},
|
|
20
|
+
),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
VOLUMES = [
|
|
24
|
+
CloudResource(
|
|
25
|
+
resource_id="vol-0a9b8c7d6", resource_type="ebs", region="us-east-1",
|
|
26
|
+
state="in-use", size_gb=16, attached_to="i-0abc123def456789",
|
|
27
|
+
delete_on_termination=False, estimated_monthly_cost=1.28,
|
|
28
|
+
billing_while_stopped=True, tags={"Name": "payments-poc-root"},
|
|
29
|
+
),
|
|
30
|
+
CloudResource(
|
|
31
|
+
resource_id="vol-0orphan11", resource_type="ebs", region="us-east-1",
|
|
32
|
+
state="available", size_gb=100, estimated_monthly_cost=8.0,
|
|
33
|
+
billing_while_stopped=True, tags={"Name": "old-migration-scratch"},
|
|
34
|
+
),
|
|
35
|
+
CloudResource(
|
|
36
|
+
resource_id="vol-0prod222", resource_type="ebs", region="us-east-1",
|
|
37
|
+
state="in-use", size_gb=50, attached_to="i-0999prod888",
|
|
38
|
+
delete_on_termination=True, estimated_monthly_cost=4.0,
|
|
39
|
+
billing_while_stopped=True, tags={"Name": "checkout-api-root"},
|
|
40
|
+
),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
ADDRESSES = [
|
|
44
|
+
CloudResource(
|
|
45
|
+
resource_id="eipalloc-0f2e3d4", resource_type="eip", region="us-east-1",
|
|
46
|
+
state="associated", public_ip="54.211.8.12", attached_to="i-0abc123def456789",
|
|
47
|
+
estimated_monthly_cost=3.65, billing_while_stopped=True,
|
|
48
|
+
tags={"Name": "payments-poc-ip", "AssociationId": "eipassoc-0c1b2a3"},
|
|
49
|
+
),
|
|
50
|
+
CloudResource(
|
|
51
|
+
resource_id="eipalloc-0unused9", resource_type="eip", region="us-east-1",
|
|
52
|
+
state="unassociated", public_ip="3.91.44.7",
|
|
53
|
+
estimated_monthly_cost=3.65, billing_while_stopped=True, tags={},
|
|
54
|
+
),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
USAGE = {
|
|
58
|
+
"i-0abc123def456789": dict(avg_cpu_percent=0.4, max_cpu_percent=1.1, idle_days=41,
|
|
59
|
+
network_in_bytes=2048.0, network_out_bytes=1024.0),
|
|
60
|
+
"i-0999prod888": dict(avg_cpu_percent=34.7, max_cpu_percent=81.2, idle_days=0,
|
|
61
|
+
network_in_bytes=9.2e9, network_out_bytes=7.7e9),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Matches the §5.2 demo narrative: "last commit 6 weeks ago, branch deleted, PR merged."
|
|
65
|
+
GITHUB = {
|
|
66
|
+
"wkxcass/cloudcleaner-demo-payments": dict(
|
|
67
|
+
latest_commit_at="2026-07-20T10:00:00+00:00",
|
|
68
|
+
pr_number=42, pr_status="merged", branch="feature/payments-poc",
|
|
69
|
+
branch_exists=False, last_workflow_run_at="2026-07-20T10:30:00+00:00",
|
|
70
|
+
scheduled_workflow_exists=False,
|
|
71
|
+
),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def list_ec2_instances():
|
|
76
|
+
return [i.model_copy(deep=True) for i in INSTANCES]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def list_volumes(only_unattached: bool = False):
|
|
80
|
+
vols = [v.model_copy(deep=True) for v in VOLUMES]
|
|
81
|
+
return [v for v in vols if v.attached_to is None] if only_unattached else vols
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def list_elastic_ips():
|
|
85
|
+
return [a.model_copy(deep=True) for a in ADDRESSES]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_ec2_usage_evidence(instance_id: str, days: int = 7):
|
|
89
|
+
from cloudcleaner.schemas import AWSEvidence
|
|
90
|
+
return AWSEvidence(metric_window_days=days, **USAGE.get(instance_id, {}))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_github_evidence(repo: str | None = None):
|
|
94
|
+
from cloudcleaner.schemas import GitHubEvidence
|
|
95
|
+
if not repo:
|
|
96
|
+
return GitHubEvidence()
|
|
97
|
+
return GitHubEvidence(repo=repo, **GITHUB.get(repo, {}))
|
|
File without changes
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from langgraph.checkpoint.memory import MemorySaver
|
|
2
|
+
from langgraph.checkpoint.sqlite import SqliteSaver
|
|
3
|
+
from langgraph.graph import END, START, StateGraph
|
|
4
|
+
|
|
5
|
+
from cloudcleaner.graph.nodes.approval import approval_node
|
|
6
|
+
from cloudcleaner.graph.nodes.assess import assess_node
|
|
7
|
+
from cloudcleaner.graph.nodes.detect import detect_node
|
|
8
|
+
from cloudcleaner.graph.nodes.execute import execute_node
|
|
9
|
+
from cloudcleaner.graph.nodes.investigate import investigate_node
|
|
10
|
+
from cloudcleaner.graph.nodes.plan import plan_node
|
|
11
|
+
from cloudcleaner.graph.nodes.policy_check import policy_check_node
|
|
12
|
+
from cloudcleaner.graph.nodes.record import record_node
|
|
13
|
+
from cloudcleaner.graph.nodes.rollback import rollback_node
|
|
14
|
+
from cloudcleaner.graph.nodes.verify import verify_node
|
|
15
|
+
from cloudcleaner.graph.routing import (
|
|
16
|
+
route_after_assess,
|
|
17
|
+
route_after_detect,
|
|
18
|
+
route_after_plan,
|
|
19
|
+
route_after_approval,
|
|
20
|
+
route_after_policy_check,
|
|
21
|
+
route_after_verify,
|
|
22
|
+
)
|
|
23
|
+
from cloudcleaner.graph.state import CloudCleanerState
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _default_checkpointer():
|
|
27
|
+
"""Persist paused runs so an approval survives an agent restart.
|
|
28
|
+
|
|
29
|
+
Falls back to memory if the file cannot be opened - a demo on a read-only
|
|
30
|
+
filesystem should still work, it just forgets interrupted runs.
|
|
31
|
+
"""
|
|
32
|
+
import sqlite3
|
|
33
|
+
|
|
34
|
+
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
|
|
35
|
+
|
|
36
|
+
from cloudcleaner.storage.db import DB_PATH
|
|
37
|
+
|
|
38
|
+
# Declare our own models rather than deserialising whatever the checkpoint
|
|
39
|
+
# file happens to contain.
|
|
40
|
+
from cloudcleaner import schemas
|
|
41
|
+
|
|
42
|
+
allowed = [
|
|
43
|
+
getattr(schemas, n) for n in dir(schemas)
|
|
44
|
+
if isinstance(getattr(schemas, n), type) and getattr(schemas, n).__module__
|
|
45
|
+
== "cloudcleaner.schemas"
|
|
46
|
+
]
|
|
47
|
+
serde = JsonPlusSerializer(allowed_msgpack_modules=allowed)
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
|
52
|
+
return SqliteSaver(conn, serde=serde)
|
|
53
|
+
except sqlite3.Error:
|
|
54
|
+
return MemorySaver(serde=serde)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def build_graph(checkpointer=None):
|
|
58
|
+
builder = StateGraph(CloudCleanerState)
|
|
59
|
+
|
|
60
|
+
builder.add_node("detect", detect_node)
|
|
61
|
+
builder.add_node("investigate", investigate_node)
|
|
62
|
+
builder.add_node("assess", assess_node)
|
|
63
|
+
builder.add_node("policy_check", policy_check_node)
|
|
64
|
+
builder.add_node("plan", plan_node)
|
|
65
|
+
builder.add_node("approval", approval_node)
|
|
66
|
+
builder.add_node("execute", execute_node)
|
|
67
|
+
builder.add_node("verify", verify_node)
|
|
68
|
+
builder.add_node("rollback", rollback_node)
|
|
69
|
+
builder.add_node("record", record_node)
|
|
70
|
+
|
|
71
|
+
builder.add_edge(START, "detect")
|
|
72
|
+
builder.add_conditional_edges(
|
|
73
|
+
"detect", route_after_detect, {"investigate": "investigate", "end": END}
|
|
74
|
+
)
|
|
75
|
+
builder.add_edge("investigate", "assess")
|
|
76
|
+
builder.add_conditional_edges(
|
|
77
|
+
"assess", route_after_assess,
|
|
78
|
+
{"plan": "plan", "policy_check": "policy_check", "record": "record"}
|
|
79
|
+
)
|
|
80
|
+
builder.add_conditional_edges(
|
|
81
|
+
"plan", route_after_plan, {"policy_check": "policy_check", "record": "record"}
|
|
82
|
+
)
|
|
83
|
+
builder.add_conditional_edges(
|
|
84
|
+
"policy_check", route_after_policy_check,
|
|
85
|
+
{"approval": "approval", "execute": "execute", "record": "record"},
|
|
86
|
+
)
|
|
87
|
+
builder.add_conditional_edges(
|
|
88
|
+
"approval", route_after_approval,
|
|
89
|
+
{"approval": "approval", "execute": "execute", "record": "record"},
|
|
90
|
+
)
|
|
91
|
+
builder.add_edge("execute", "verify")
|
|
92
|
+
builder.add_conditional_edges(
|
|
93
|
+
"verify", route_after_verify, {"complete": "record", "rollback": "rollback"}
|
|
94
|
+
)
|
|
95
|
+
builder.add_edge("rollback", "record")
|
|
96
|
+
builder.add_edge("record", END)
|
|
97
|
+
|
|
98
|
+
return builder.compile(checkpointer=checkpointer or _default_checkpointer())
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
graph = build_graph()
|
|
File without changes
|