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.
- cloudcleaner_agent-0.1.0/PKG-INFO +197 -0
- cloudcleaner_agent-0.1.0/README.md +158 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/__init__.py +0 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/cli.py +239 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/config.py +103 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/evaluation/__init__.py +0 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/evaluation/evaluator.py +0 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/evidence/__init__.py +0 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/evidence/collector.py +80 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/evidence/formatter.py +0 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/fixtures/__init__.py +0 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/fixtures/demo.py +97 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/graph/__init__.py +0 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/graph/graph.py +101 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/graph/nodes/__init__.py +0 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/graph/nodes/approval.py +78 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/graph/nodes/assess.py +136 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/graph/nodes/detect.py +79 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/graph/nodes/execute.py +142 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/graph/nodes/investigate.py +30 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/graph/nodes/plan.py +52 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/graph/nodes/policy_check.py +25 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/graph/nodes/record.py +27 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/graph/nodes/rollback.py +90 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/graph/nodes/verify.py +100 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/graph/render.py +33 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/graph/routing.py +92 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/graph/state.py +53 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/policy/__init__.py +11 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/policy/approval.py +38 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/policy/context.py +67 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/policy/dependencies.py +151 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/policy/metrics.py +22 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/policy/risk.py +197 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/policy/safety.py +151 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/schemas.py +266 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/server.py +431 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/storage/__init__.py +0 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/storage/db.py +161 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/storage/repository.py +339 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/tools/__init__.py +0 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/tools/aws/__init__.py +0 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/tools/aws/actions.py +276 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/tools/aws/addresses.py +30 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/tools/aws/client.py +22 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/tools/aws/cost.py +98 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/tools/aws/history.py +0 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/tools/aws/inventory.py +63 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/tools/aws/metrics.py +75 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/tools/aws/pricing.py +80 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/tools/aws/volumes.py +69 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/tools/email/__init__.py +0 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/tools/email/messages.py +104 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/tools/github/__init__.py +0 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/tools/github/branches.py +17 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/tools/github/cicd.py +56 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/tools/github/client.py +11 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/tools/github/commits.py +24 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/tools/github/pull_requests.py +43 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/tools/provider.py +81 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/tools/slack/__init__.py +0 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/tools/slack/approvals.py +83 -0
- cloudcleaner_agent-0.1.0/cloudcleaner/tools/slack/messages.py +171 -0
- cloudcleaner_agent-0.1.0/cloudcleaner_agent.egg-info/PKG-INFO +197 -0
- cloudcleaner_agent-0.1.0/cloudcleaner_agent.egg-info/SOURCES.txt +80 -0
- cloudcleaner_agent-0.1.0/cloudcleaner_agent.egg-info/dependency_links.txt +1 -0
- cloudcleaner_agent-0.1.0/cloudcleaner_agent.egg-info/entry_points.txt +2 -0
- cloudcleaner_agent-0.1.0/cloudcleaner_agent.egg-info/requires.txt +29 -0
- cloudcleaner_agent-0.1.0/cloudcleaner_agent.egg-info/top_level.txt +1 -0
- cloudcleaner_agent-0.1.0/pyproject.toml +68 -0
- cloudcleaner_agent-0.1.0/setup.cfg +4 -0
- cloudcleaner_agent-0.1.0/tests/test_approval_flow.py +93 -0
- cloudcleaner_agent-0.1.0/tests/test_cost_and_actions.py +32 -0
- cloudcleaner_agent-0.1.0/tests/test_evidence.py +214 -0
- cloudcleaner_agent-0.1.0/tests/test_graph.py +154 -0
- cloudcleaner_agent-0.1.0/tests/test_policy.py +188 -0
- cloudcleaner_agent-0.1.0/tests/test_policy_check.py +74 -0
- cloudcleaner_agent-0.1.0/tests/test_rollback.py +75 -0
- cloudcleaner_agent-0.1.0/tests/test_routing.py +84 -0
- cloudcleaner_agent-0.1.0/tests/test_teardown_policy.py +133 -0
- cloudcleaner_agent-0.1.0/tests/test_tools.py +132 -0
- 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()
|