cloudcleaner-agent 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.
Files changed (82) hide show
  1. cloudcleaner_agent-0.1.0/PKG-INFO +197 -0
  2. cloudcleaner_agent-0.1.0/README.md +158 -0
  3. cloudcleaner_agent-0.1.0/cloudcleaner/__init__.py +0 -0
  4. cloudcleaner_agent-0.1.0/cloudcleaner/cli.py +239 -0
  5. cloudcleaner_agent-0.1.0/cloudcleaner/config.py +103 -0
  6. cloudcleaner_agent-0.1.0/cloudcleaner/evaluation/__init__.py +0 -0
  7. cloudcleaner_agent-0.1.0/cloudcleaner/evaluation/evaluator.py +0 -0
  8. cloudcleaner_agent-0.1.0/cloudcleaner/evidence/__init__.py +0 -0
  9. cloudcleaner_agent-0.1.0/cloudcleaner/evidence/collector.py +80 -0
  10. cloudcleaner_agent-0.1.0/cloudcleaner/evidence/formatter.py +0 -0
  11. cloudcleaner_agent-0.1.0/cloudcleaner/fixtures/__init__.py +0 -0
  12. cloudcleaner_agent-0.1.0/cloudcleaner/fixtures/demo.py +97 -0
  13. cloudcleaner_agent-0.1.0/cloudcleaner/graph/__init__.py +0 -0
  14. cloudcleaner_agent-0.1.0/cloudcleaner/graph/graph.py +101 -0
  15. cloudcleaner_agent-0.1.0/cloudcleaner/graph/nodes/__init__.py +0 -0
  16. cloudcleaner_agent-0.1.0/cloudcleaner/graph/nodes/approval.py +78 -0
  17. cloudcleaner_agent-0.1.0/cloudcleaner/graph/nodes/assess.py +136 -0
  18. cloudcleaner_agent-0.1.0/cloudcleaner/graph/nodes/detect.py +79 -0
  19. cloudcleaner_agent-0.1.0/cloudcleaner/graph/nodes/execute.py +142 -0
  20. cloudcleaner_agent-0.1.0/cloudcleaner/graph/nodes/investigate.py +30 -0
  21. cloudcleaner_agent-0.1.0/cloudcleaner/graph/nodes/plan.py +52 -0
  22. cloudcleaner_agent-0.1.0/cloudcleaner/graph/nodes/policy_check.py +25 -0
  23. cloudcleaner_agent-0.1.0/cloudcleaner/graph/nodes/record.py +27 -0
  24. cloudcleaner_agent-0.1.0/cloudcleaner/graph/nodes/rollback.py +90 -0
  25. cloudcleaner_agent-0.1.0/cloudcleaner/graph/nodes/verify.py +100 -0
  26. cloudcleaner_agent-0.1.0/cloudcleaner/graph/render.py +33 -0
  27. cloudcleaner_agent-0.1.0/cloudcleaner/graph/routing.py +92 -0
  28. cloudcleaner_agent-0.1.0/cloudcleaner/graph/state.py +53 -0
  29. cloudcleaner_agent-0.1.0/cloudcleaner/policy/__init__.py +11 -0
  30. cloudcleaner_agent-0.1.0/cloudcleaner/policy/approval.py +38 -0
  31. cloudcleaner_agent-0.1.0/cloudcleaner/policy/context.py +67 -0
  32. cloudcleaner_agent-0.1.0/cloudcleaner/policy/dependencies.py +151 -0
  33. cloudcleaner_agent-0.1.0/cloudcleaner/policy/metrics.py +22 -0
  34. cloudcleaner_agent-0.1.0/cloudcleaner/policy/risk.py +197 -0
  35. cloudcleaner_agent-0.1.0/cloudcleaner/policy/safety.py +151 -0
  36. cloudcleaner_agent-0.1.0/cloudcleaner/schemas.py +266 -0
  37. cloudcleaner_agent-0.1.0/cloudcleaner/server.py +431 -0
  38. cloudcleaner_agent-0.1.0/cloudcleaner/storage/__init__.py +0 -0
  39. cloudcleaner_agent-0.1.0/cloudcleaner/storage/db.py +161 -0
  40. cloudcleaner_agent-0.1.0/cloudcleaner/storage/repository.py +339 -0
  41. cloudcleaner_agent-0.1.0/cloudcleaner/tools/__init__.py +0 -0
  42. cloudcleaner_agent-0.1.0/cloudcleaner/tools/aws/__init__.py +0 -0
  43. cloudcleaner_agent-0.1.0/cloudcleaner/tools/aws/actions.py +276 -0
  44. cloudcleaner_agent-0.1.0/cloudcleaner/tools/aws/addresses.py +30 -0
  45. cloudcleaner_agent-0.1.0/cloudcleaner/tools/aws/client.py +22 -0
  46. cloudcleaner_agent-0.1.0/cloudcleaner/tools/aws/cost.py +98 -0
  47. cloudcleaner_agent-0.1.0/cloudcleaner/tools/aws/history.py +0 -0
  48. cloudcleaner_agent-0.1.0/cloudcleaner/tools/aws/inventory.py +63 -0
  49. cloudcleaner_agent-0.1.0/cloudcleaner/tools/aws/metrics.py +75 -0
  50. cloudcleaner_agent-0.1.0/cloudcleaner/tools/aws/pricing.py +80 -0
  51. cloudcleaner_agent-0.1.0/cloudcleaner/tools/aws/volumes.py +69 -0
  52. cloudcleaner_agent-0.1.0/cloudcleaner/tools/email/__init__.py +0 -0
  53. cloudcleaner_agent-0.1.0/cloudcleaner/tools/email/messages.py +104 -0
  54. cloudcleaner_agent-0.1.0/cloudcleaner/tools/github/__init__.py +0 -0
  55. cloudcleaner_agent-0.1.0/cloudcleaner/tools/github/branches.py +17 -0
  56. cloudcleaner_agent-0.1.0/cloudcleaner/tools/github/cicd.py +56 -0
  57. cloudcleaner_agent-0.1.0/cloudcleaner/tools/github/client.py +11 -0
  58. cloudcleaner_agent-0.1.0/cloudcleaner/tools/github/commits.py +24 -0
  59. cloudcleaner_agent-0.1.0/cloudcleaner/tools/github/pull_requests.py +43 -0
  60. cloudcleaner_agent-0.1.0/cloudcleaner/tools/provider.py +81 -0
  61. cloudcleaner_agent-0.1.0/cloudcleaner/tools/slack/__init__.py +0 -0
  62. cloudcleaner_agent-0.1.0/cloudcleaner/tools/slack/approvals.py +83 -0
  63. cloudcleaner_agent-0.1.0/cloudcleaner/tools/slack/messages.py +171 -0
  64. cloudcleaner_agent-0.1.0/cloudcleaner_agent.egg-info/PKG-INFO +197 -0
  65. cloudcleaner_agent-0.1.0/cloudcleaner_agent.egg-info/SOURCES.txt +80 -0
  66. cloudcleaner_agent-0.1.0/cloudcleaner_agent.egg-info/dependency_links.txt +1 -0
  67. cloudcleaner_agent-0.1.0/cloudcleaner_agent.egg-info/entry_points.txt +2 -0
  68. cloudcleaner_agent-0.1.0/cloudcleaner_agent.egg-info/requires.txt +29 -0
  69. cloudcleaner_agent-0.1.0/cloudcleaner_agent.egg-info/top_level.txt +1 -0
  70. cloudcleaner_agent-0.1.0/pyproject.toml +68 -0
  71. cloudcleaner_agent-0.1.0/setup.cfg +4 -0
  72. cloudcleaner_agent-0.1.0/tests/test_approval_flow.py +93 -0
  73. cloudcleaner_agent-0.1.0/tests/test_cost_and_actions.py +32 -0
  74. cloudcleaner_agent-0.1.0/tests/test_evidence.py +214 -0
  75. cloudcleaner_agent-0.1.0/tests/test_graph.py +154 -0
  76. cloudcleaner_agent-0.1.0/tests/test_policy.py +188 -0
  77. cloudcleaner_agent-0.1.0/tests/test_policy_check.py +74 -0
  78. cloudcleaner_agent-0.1.0/tests/test_rollback.py +75 -0
  79. cloudcleaner_agent-0.1.0/tests/test_routing.py +84 -0
  80. cloudcleaner_agent-0.1.0/tests/test_teardown_policy.py +133 -0
  81. cloudcleaner_agent-0.1.0/tests/test_tools.py +132 -0
  82. cloudcleaner_agent-0.1.0/tests/test_verify.py +53 -0
@@ -0,0 +1,197 @@
1
+ Metadata-Version: 2.4
2
+ Name: cloudcleaner-agent
3
+ Version: 0.1.0
4
+ Summary: Finds idle AWS resources, proves they are unused, and plans a dependency-ordered teardown behind a human approval gate.
5
+ License-Expression: MIT
6
+ Project-URL: Repository, https://github.com/charltonxqh/CloudCleaner
7
+ Keywords: aws,finops,cost-optimization,agent,langgraph,cloud
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: System Administrators
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Topic :: System :: Systems Administration
13
+ Requires-Python: >=3.12
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: langchain==1.2.15
16
+ Requires-Dist: langgraph==1.1.6
17
+ Requires-Dist: langgraph-checkpoint-sqlite>=3.1.1
18
+ Requires-Dist: langchain-groq>=1.1.3
19
+ Requires-Dist: python-dotenv<2.0.0,>=1.0.0
20
+ Requires-Dist: pydantic<3.0.0,>=2.0.0
21
+ Requires-Dist: boto3>=1.43.86
22
+ Requires-Dist: pygithub>=2.10.0
23
+ Requires-Dist: pyyaml>=6.0.3
24
+ Provides-Extra: server
25
+ Requires-Dist: fastapi<1.0.0,>=0.115.5; extra == "server"
26
+ Requires-Dist: uvicorn<1.0.0,>=0.29.0; extra == "server"
27
+ Requires-Dist: copilotkit==0.1.96; extra == "server"
28
+ Requires-Dist: ag-ui-langgraph[fastapi]==0.0.43; extra == "server"
29
+ Requires-Dist: ag-ui-protocol==0.1.19; extra == "server"
30
+ Provides-Extra: openai
31
+ Requires-Dist: langchain-openai==1.1.9; extra == "openai"
32
+ Requires-Dist: openai==1.109.1; extra == "openai"
33
+ Provides-Extra: anthropic
34
+ Requires-Dist: langchain-anthropic==1.4.1; extra == "anthropic"
35
+ Provides-Extra: tracing
36
+ Requires-Dist: langsmith==0.7.33; extra == "tracing"
37
+ Provides-Extra: all
38
+ Requires-Dist: cloudcleaner-agent[anthropic,openai,server,tracing]; extra == "all"
39
+
40
+ # CloudCleaner
41
+
42
+ **Finds AWS resources nobody is using, proves it, works out the order they have
43
+ to be dismantled in, and asks a human before touching anything.**
44
+
45
+ ```bash
46
+ pip install cloudcleaner-agent
47
+ ```
48
+
49
+ The command it installs is `cloudcleaner`.
50
+
51
+ ---
52
+
53
+ ## The problem
54
+
55
+ **Stopping an EC2 instance does not stop the bill.** Stop releases the compute
56
+ and nothing else — the root volume, any extra volumes, and the public IPv4
57
+ address all keep charging. A stopped `t3.micro` with a 16 GB volume and an
58
+ Elastic IP still costs about **$4.93 a month**, indefinitely.
59
+
60
+ **Deleting is a dependency puzzle.** You cannot release an Elastic IP while it
61
+ is associated. You cannot delete a volume while it is attached. Volumes created
62
+ with `DeleteOnTermination=false` outlive the instance and bill silently forever.
63
+ AWS reports exactly one blocker per attempt, so the order is discovered by trial
64
+ and error.
65
+
66
+ CloudCleaner does both parts: it finds the waste, and it works out the sequence.
67
+
68
+ ---
69
+
70
+ ## Try it without an AWS account
71
+
72
+ A demo account ships with the package, so nothing is read or changed:
73
+
74
+ ```bash
75
+ CLOUDCLEANER_PROVIDER=fixture cloudcleaner scan
76
+ CLOUDCLEANER_PROVIDER=fixture cloudcleaner sweep
77
+ ```
78
+
79
+ ```
80
+ RESOURCE TYPE STATE $/MO
81
+ i-0999prod888 ec2 running $34.37
82
+ vol-0orphan11 ebs available $8.00 *
83
+ i-0abc123def456789 ec2 stopped $4.93 *
84
+ eipalloc-0unused9 eip unassociated $3.65 *
85
+
86
+ 4 resources · $50.95/mo total · $16.58/mo not running but still billing (*)
87
+ ```
88
+
89
+ ## Against a real account
90
+
91
+ ```bash
92
+ cloudcleaner doctor # check credentials and configuration first
93
+ cloudcleaner scan
94
+ cloudcleaner investigate i-0abc123def456789
95
+ ```
96
+
97
+ `CLOUDCLEANER_DRY_RUN` defaults to `true`. Nothing in AWS is modified until you
98
+ set it to `false`.
99
+
100
+ ---
101
+
102
+ ## What an investigation looks like
103
+
104
+ ```
105
+ i-0abc123def456789 payments-poc (ec2/stopped) $4.93/mo
106
+ -> retire (95%, high) Stopped 41 days with average CPU 0.4%, and still
107
+ billing $4.93/mo for its volume and address.
108
+
109
+ 1. disassociate_address eipassoc-0c1b2a3 $0.00 54.211.8.12 is attached
110
+ ! 2. release_address eipalloc-0f2e3d4 $3.65 public IPv4 bills hourly
111
+ 3. snapshot_volume vol-0a9b8c7d6 $0.00 restore point first
112
+ ! 4. terminate_instance i-0abc123def456789 $4.93 idle instance
113
+ ! 5. delete_volume vol-0a9b8c7d6 $1.28 DeleteOnTermination=false
114
+
115
+ 3 of 5 steps cannot be undone $9.86/mo
116
+
117
+ Type 'APPROVE i-0abc123def456789' to run this plan:
118
+ ```
119
+
120
+ The order is computed, not scripted: dependencies are discovered at runtime and
121
+ topologically sorted, so a cycle raises rather than producing a sequence that
122
+ fails halfway.
123
+
124
+ ---
125
+
126
+ ## Commands
127
+
128
+ | | |
129
+ |---|---|
130
+ | `cloudcleaner scan` | what the account holds and what it costs |
131
+ | `cloudcleaner sweep` | investigate everything, approve nothing |
132
+ | `cloudcleaner investigate <id>` | one resource, with the approval prompt |
133
+ | `cloudcleaner history` | past runs, realised versus simulated savings |
134
+ | `cloudcleaner doctor` | check credentials and configuration |
135
+ | `cloudcleaner serve` | the HTTP API (needs the `[server]` extra) |
136
+
137
+ ---
138
+
139
+ ## Safety
140
+
141
+ Three independent gates stand between a verdict and a deletion:
142
+
143
+ 1. **The model advises.** It sees only evidence and returns a typed verdict. It
144
+ can be wrong, and a human can overrule it.
145
+ 2. **The policy decides.** Plain Python — protected environments, protected
146
+ tags, minimum age, risk score. No prompt reaches it, so it cannot be argued
147
+ with or injected.
148
+ 3. **A human commits.** You type the resource id exactly. Never `y/n`.
149
+
150
+ Every volume is snapshotted before deletion, a restore recipe is written before
151
+ anything runs, and dry-run savings are recorded as **simulated** — never as
152
+ money saved.
153
+
154
+ ---
155
+
156
+ ## Configuration
157
+
158
+ Read from `.env` in the working directory, upwards from it, or
159
+ `~/.cloudcleaner/.env`.
160
+
161
+ | Variable | Default | Effect |
162
+ |---|---|---|
163
+ | `GROQ_API_KEY` | — | required unless AI is disabled |
164
+ | `CLOUDCLEANER_PROVIDER` | `aws` | `fixture` runs offline against the demo account |
165
+ | `CLOUDCLEANER_DRY_RUN` | `true` | `false` actually modifies AWS |
166
+ | `CLOUDCLEANER_AI_ENABLED` | `true` | `false` uses the rules engine only; nothing leaves the machine |
167
+ | `CLOUDCLEANER_DATA_DIR` | `./output` or `~/.cloudcleaner` | where the database and logs are written |
168
+ | `AWS_ENDPOINT_URL` | — | point boto3 at LocalStack |
169
+ | `GITHUB_TOKEN` | — | optional; without it, code activity is skipped rather than failing |
170
+ | `METRIC_WINDOW_DAYS` | `7` | CloudWatch lookback |
171
+
172
+ ## Extras
173
+
174
+ ```bash
175
+ pip install cloudcleaner-agent # the CLI
176
+ pip install 'cloudcleaner-agent[server]' # plus the HTTP API and web frontend
177
+ pip install 'cloudcleaner-agent[all]' # plus OpenAI, Anthropic, LangSmith
178
+ ```
179
+
180
+ ---
181
+
182
+ ## Coverage
183
+
184
+ **Today:** EC2 instances, EBS volumes, Elastic IPs, snapshots. CloudWatch usage,
185
+ cost attribution, and GitHub signals — last commit, branch existence, PR status,
186
+ CI runs — so the agent can tell whether the project that owns a resource is
187
+ still alive.
188
+
189
+ **Not yet:** RDS, NAT gateways, load balancers, ElastiCache, multi-account.
190
+
191
+ ## Requirements
192
+
193
+ Python 3.12+. AWS credentials with read access plus whichever write permissions
194
+ you intend to use. A Groq API key, free at [console.groq.com](https://console.groq.com),
195
+ unless you run with `CLOUDCLEANER_AI_ENABLED=false`.
196
+
197
+ MIT licensed.
@@ -0,0 +1,158 @@
1
+ # CloudCleaner
2
+
3
+ **Finds AWS resources nobody is using, proves it, works out the order they have
4
+ to be dismantled in, and asks a human before touching anything.**
5
+
6
+ ```bash
7
+ pip install cloudcleaner-agent
8
+ ```
9
+
10
+ The command it installs is `cloudcleaner`.
11
+
12
+ ---
13
+
14
+ ## The problem
15
+
16
+ **Stopping an EC2 instance does not stop the bill.** Stop releases the compute
17
+ and nothing else — the root volume, any extra volumes, and the public IPv4
18
+ address all keep charging. A stopped `t3.micro` with a 16 GB volume and an
19
+ Elastic IP still costs about **$4.93 a month**, indefinitely.
20
+
21
+ **Deleting is a dependency puzzle.** You cannot release an Elastic IP while it
22
+ is associated. You cannot delete a volume while it is attached. Volumes created
23
+ with `DeleteOnTermination=false` outlive the instance and bill silently forever.
24
+ AWS reports exactly one blocker per attempt, so the order is discovered by trial
25
+ and error.
26
+
27
+ CloudCleaner does both parts: it finds the waste, and it works out the sequence.
28
+
29
+ ---
30
+
31
+ ## Try it without an AWS account
32
+
33
+ A demo account ships with the package, so nothing is read or changed:
34
+
35
+ ```bash
36
+ CLOUDCLEANER_PROVIDER=fixture cloudcleaner scan
37
+ CLOUDCLEANER_PROVIDER=fixture cloudcleaner sweep
38
+ ```
39
+
40
+ ```
41
+ RESOURCE TYPE STATE $/MO
42
+ i-0999prod888 ec2 running $34.37
43
+ vol-0orphan11 ebs available $8.00 *
44
+ i-0abc123def456789 ec2 stopped $4.93 *
45
+ eipalloc-0unused9 eip unassociated $3.65 *
46
+
47
+ 4 resources · $50.95/mo total · $16.58/mo not running but still billing (*)
48
+ ```
49
+
50
+ ## Against a real account
51
+
52
+ ```bash
53
+ cloudcleaner doctor # check credentials and configuration first
54
+ cloudcleaner scan
55
+ cloudcleaner investigate i-0abc123def456789
56
+ ```
57
+
58
+ `CLOUDCLEANER_DRY_RUN` defaults to `true`. Nothing in AWS is modified until you
59
+ set it to `false`.
60
+
61
+ ---
62
+
63
+ ## What an investigation looks like
64
+
65
+ ```
66
+ i-0abc123def456789 payments-poc (ec2/stopped) $4.93/mo
67
+ -> retire (95%, high) Stopped 41 days with average CPU 0.4%, and still
68
+ billing $4.93/mo for its volume and address.
69
+
70
+ 1. disassociate_address eipassoc-0c1b2a3 $0.00 54.211.8.12 is attached
71
+ ! 2. release_address eipalloc-0f2e3d4 $3.65 public IPv4 bills hourly
72
+ 3. snapshot_volume vol-0a9b8c7d6 $0.00 restore point first
73
+ ! 4. terminate_instance i-0abc123def456789 $4.93 idle instance
74
+ ! 5. delete_volume vol-0a9b8c7d6 $1.28 DeleteOnTermination=false
75
+
76
+ 3 of 5 steps cannot be undone $9.86/mo
77
+
78
+ Type 'APPROVE i-0abc123def456789' to run this plan:
79
+ ```
80
+
81
+ The order is computed, not scripted: dependencies are discovered at runtime and
82
+ topologically sorted, so a cycle raises rather than producing a sequence that
83
+ fails halfway.
84
+
85
+ ---
86
+
87
+ ## Commands
88
+
89
+ | | |
90
+ |---|---|
91
+ | `cloudcleaner scan` | what the account holds and what it costs |
92
+ | `cloudcleaner sweep` | investigate everything, approve nothing |
93
+ | `cloudcleaner investigate <id>` | one resource, with the approval prompt |
94
+ | `cloudcleaner history` | past runs, realised versus simulated savings |
95
+ | `cloudcleaner doctor` | check credentials and configuration |
96
+ | `cloudcleaner serve` | the HTTP API (needs the `[server]` extra) |
97
+
98
+ ---
99
+
100
+ ## Safety
101
+
102
+ Three independent gates stand between a verdict and a deletion:
103
+
104
+ 1. **The model advises.** It sees only evidence and returns a typed verdict. It
105
+ can be wrong, and a human can overrule it.
106
+ 2. **The policy decides.** Plain Python — protected environments, protected
107
+ tags, minimum age, risk score. No prompt reaches it, so it cannot be argued
108
+ with or injected.
109
+ 3. **A human commits.** You type the resource id exactly. Never `y/n`.
110
+
111
+ Every volume is snapshotted before deletion, a restore recipe is written before
112
+ anything runs, and dry-run savings are recorded as **simulated** — never as
113
+ money saved.
114
+
115
+ ---
116
+
117
+ ## Configuration
118
+
119
+ Read from `.env` in the working directory, upwards from it, or
120
+ `~/.cloudcleaner/.env`.
121
+
122
+ | Variable | Default | Effect |
123
+ |---|---|---|
124
+ | `GROQ_API_KEY` | — | required unless AI is disabled |
125
+ | `CLOUDCLEANER_PROVIDER` | `aws` | `fixture` runs offline against the demo account |
126
+ | `CLOUDCLEANER_DRY_RUN` | `true` | `false` actually modifies AWS |
127
+ | `CLOUDCLEANER_AI_ENABLED` | `true` | `false` uses the rules engine only; nothing leaves the machine |
128
+ | `CLOUDCLEANER_DATA_DIR` | `./output` or `~/.cloudcleaner` | where the database and logs are written |
129
+ | `AWS_ENDPOINT_URL` | — | point boto3 at LocalStack |
130
+ | `GITHUB_TOKEN` | — | optional; without it, code activity is skipped rather than failing |
131
+ | `METRIC_WINDOW_DAYS` | `7` | CloudWatch lookback |
132
+
133
+ ## Extras
134
+
135
+ ```bash
136
+ pip install cloudcleaner-agent # the CLI
137
+ pip install 'cloudcleaner-agent[server]' # plus the HTTP API and web frontend
138
+ pip install 'cloudcleaner-agent[all]' # plus OpenAI, Anthropic, LangSmith
139
+ ```
140
+
141
+ ---
142
+
143
+ ## Coverage
144
+
145
+ **Today:** EC2 instances, EBS volumes, Elastic IPs, snapshots. CloudWatch usage,
146
+ cost attribution, and GitHub signals — last commit, branch existence, PR status,
147
+ CI runs — so the agent can tell whether the project that owns a resource is
148
+ still alive.
149
+
150
+ **Not yet:** RDS, NAT gateways, load balancers, ElastiCache, multi-account.
151
+
152
+ ## Requirements
153
+
154
+ Python 3.12+. AWS credentials with read access plus whichever write permissions
155
+ you intend to use. A Groq API key, free at [console.groq.com](https://console.groq.com),
156
+ unless you run with `CLOUDCLEANER_AI_ENABLED=false`.
157
+
158
+ MIT licensed.
File without changes
@@ -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())
@@ -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()