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.
Files changed (66) hide show
  1. cloudcleaner/__init__.py +0 -0
  2. cloudcleaner/cli.py +239 -0
  3. cloudcleaner/config.py +103 -0
  4. cloudcleaner/evaluation/__init__.py +0 -0
  5. cloudcleaner/evaluation/evaluator.py +0 -0
  6. cloudcleaner/evidence/__init__.py +0 -0
  7. cloudcleaner/evidence/collector.py +80 -0
  8. cloudcleaner/evidence/formatter.py +0 -0
  9. cloudcleaner/fixtures/__init__.py +0 -0
  10. cloudcleaner/fixtures/demo.py +97 -0
  11. cloudcleaner/graph/__init__.py +0 -0
  12. cloudcleaner/graph/graph.py +101 -0
  13. cloudcleaner/graph/nodes/__init__.py +0 -0
  14. cloudcleaner/graph/nodes/approval.py +78 -0
  15. cloudcleaner/graph/nodes/assess.py +136 -0
  16. cloudcleaner/graph/nodes/detect.py +79 -0
  17. cloudcleaner/graph/nodes/execute.py +142 -0
  18. cloudcleaner/graph/nodes/investigate.py +30 -0
  19. cloudcleaner/graph/nodes/plan.py +52 -0
  20. cloudcleaner/graph/nodes/policy_check.py +25 -0
  21. cloudcleaner/graph/nodes/record.py +27 -0
  22. cloudcleaner/graph/nodes/rollback.py +90 -0
  23. cloudcleaner/graph/nodes/verify.py +100 -0
  24. cloudcleaner/graph/render.py +33 -0
  25. cloudcleaner/graph/routing.py +92 -0
  26. cloudcleaner/graph/state.py +53 -0
  27. cloudcleaner/policy/__init__.py +11 -0
  28. cloudcleaner/policy/approval.py +38 -0
  29. cloudcleaner/policy/context.py +67 -0
  30. cloudcleaner/policy/dependencies.py +151 -0
  31. cloudcleaner/policy/metrics.py +22 -0
  32. cloudcleaner/policy/risk.py +197 -0
  33. cloudcleaner/policy/safety.py +151 -0
  34. cloudcleaner/schemas.py +266 -0
  35. cloudcleaner/server.py +431 -0
  36. cloudcleaner/storage/__init__.py +0 -0
  37. cloudcleaner/storage/db.py +161 -0
  38. cloudcleaner/storage/repository.py +339 -0
  39. cloudcleaner/tools/__init__.py +0 -0
  40. cloudcleaner/tools/aws/__init__.py +0 -0
  41. cloudcleaner/tools/aws/actions.py +276 -0
  42. cloudcleaner/tools/aws/addresses.py +30 -0
  43. cloudcleaner/tools/aws/client.py +22 -0
  44. cloudcleaner/tools/aws/cost.py +98 -0
  45. cloudcleaner/tools/aws/history.py +0 -0
  46. cloudcleaner/tools/aws/inventory.py +63 -0
  47. cloudcleaner/tools/aws/metrics.py +75 -0
  48. cloudcleaner/tools/aws/pricing.py +80 -0
  49. cloudcleaner/tools/aws/volumes.py +69 -0
  50. cloudcleaner/tools/email/__init__.py +0 -0
  51. cloudcleaner/tools/email/messages.py +104 -0
  52. cloudcleaner/tools/github/__init__.py +0 -0
  53. cloudcleaner/tools/github/branches.py +17 -0
  54. cloudcleaner/tools/github/cicd.py +56 -0
  55. cloudcleaner/tools/github/client.py +11 -0
  56. cloudcleaner/tools/github/commits.py +24 -0
  57. cloudcleaner/tools/github/pull_requests.py +43 -0
  58. cloudcleaner/tools/provider.py +81 -0
  59. cloudcleaner/tools/slack/__init__.py +0 -0
  60. cloudcleaner/tools/slack/approvals.py +83 -0
  61. cloudcleaner/tools/slack/messages.py +171 -0
  62. cloudcleaner_agent-0.1.0.dist-info/METADATA +197 -0
  63. cloudcleaner_agent-0.1.0.dist-info/RECORD +66 -0
  64. cloudcleaner_agent-0.1.0.dist-info/WHEEL +5 -0
  65. cloudcleaner_agent-0.1.0.dist-info/entry_points.txt +2 -0
  66. cloudcleaner_agent-0.1.0.dist-info/top_level.txt +1 -0
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