zombiecost 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.
- zombiecost-0.1.0/LICENSE +21 -0
- zombiecost-0.1.0/PKG-INFO +111 -0
- zombiecost-0.1.0/README.md +96 -0
- zombiecost-0.1.0/pyproject.toml +31 -0
- zombiecost-0.1.0/setup.cfg +4 -0
- zombiecost-0.1.0/tests/test_checks.py +160 -0
- zombiecost-0.1.0/zombiecost/__init__.py +3 -0
- zombiecost-0.1.0/zombiecost/checks/__init__.py +22 -0
- zombiecost-0.1.0/zombiecost/checks/base.py +84 -0
- zombiecost-0.1.0/zombiecost/checks/ebs_unattached.py +37 -0
- zombiecost-0.1.0/zombiecost/checks/ec2_idle.py +60 -0
- zombiecost-0.1.0/zombiecost/checks/eip_unused.py +29 -0
- zombiecost-0.1.0/zombiecost/checks/lb_unused.py +86 -0
- zombiecost-0.1.0/zombiecost/checks/nat_idle.py +45 -0
- zombiecost-0.1.0/zombiecost/checks/rds_idle.py +59 -0
- zombiecost-0.1.0/zombiecost/checks/s3_stale.py +141 -0
- zombiecost-0.1.0/zombiecost/checks/snapshots_old.py +57 -0
- zombiecost-0.1.0/zombiecost/cli.py +76 -0
- zombiecost-0.1.0/zombiecost/models.py +41 -0
- zombiecost-0.1.0/zombiecost/pricing.py +133 -0
- zombiecost-0.1.0/zombiecost/scanner.py +56 -0
- zombiecost-0.1.0/zombiecost.egg-info/PKG-INFO +111 -0
- zombiecost-0.1.0/zombiecost.egg-info/SOURCES.txt +25 -0
- zombiecost-0.1.0/zombiecost.egg-info/dependency_links.txt +1 -0
- zombiecost-0.1.0/zombiecost.egg-info/entry_points.txt +2 -0
- zombiecost-0.1.0/zombiecost.egg-info/requires.txt +6 -0
- zombiecost-0.1.0/zombiecost.egg-info/top_level.txt +1 -0
zombiecost-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Arabo Markarian
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zombiecost
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find zombie AWS resources: idle and forgotten things that should be dead but still bill you every month.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: boto3>=1.34
|
|
10
|
+
Requires-Dist: rich>=13.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
13
|
+
Requires-Dist: moto[cloudwatch,ec2]>=5.0; extra == "dev"
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# zombiecost
|
|
17
|
+
|
|
18
|
+
Find the zombie resources in your AWS account: things that should be dead but still bill you every month.
|
|
19
|
+
|
|
20
|
+
Find idle and forgotten AWS resources and see what they cost you every month.
|
|
21
|
+
|
|
22
|
+
One read-only scan. One table. A number at the bottom you can act on today.
|
|
23
|
+
|
|
24
|
+
## What it finds
|
|
25
|
+
|
|
26
|
+
| Check | What it flags | How cost is estimated |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| `ebs-unattached` | EBS volumes attached to nothing | GB-month price by volume type |
|
|
29
|
+
| `eip-unused` | Elastic IPs not associated with anything | Hourly unused-EIP charge |
|
|
30
|
+
| `snapshots-old` | Snapshots older than 90 days that no AMI you own uses | GB-month snapshot price |
|
|
31
|
+
| `ec2-idle` | Running instances averaging under 3% CPU over the lookback window | On-demand hourly price |
|
|
32
|
+
| `nat-idle` | NAT gateways that moved under 1 GiB in the lookback window | Hourly NAT charge |
|
|
33
|
+
| `rds-idle` | RDS instances with zero connections over the lookback window | Instance class + storage |
|
|
34
|
+
| `lb-unused` | Load balancers with no registered targets, or ALBs that served 0 requests | Hourly LB charge |
|
|
35
|
+
| `s3-stale` | Buckets nothing has written to in 180 days, with their size and storage class | GB-month by storage class |
|
|
36
|
+
|
|
37
|
+
Prices are rough us-east-1 on-demand numbers. The goal is order of magnitude, not an invoice.
|
|
38
|
+
|
|
39
|
+
Checks that depend on usage history (`ec2-idle`, `rds-idle`, `nat-idle`, `lb-unused`)
|
|
40
|
+
ignore resources younger than 3 days, so a freshly launched instance is never
|
|
41
|
+
reported as idle. `s3-stale` can see writes but not reads; it says so in every
|
|
42
|
+
finding, because reads are invisible without paid request metrics.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install zombiecost
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Or from a clone:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
python3 -m venv .venv && source .venv/bin/activate
|
|
54
|
+
pip install -e ".[dev]"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Run
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
zombiecost # every enabled region, default profile
|
|
61
|
+
zombiecost --profile prod --regions eu-west-1,us-east-1
|
|
62
|
+
zombiecost --days 30 --json report.json # longer lookback, machine-readable output
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Permissions
|
|
66
|
+
|
|
67
|
+
The scan is read-only. Attach `ReadOnlyAccess`, or this minimal policy:
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"Version": "2012-10-17",
|
|
72
|
+
"Statement": [{
|
|
73
|
+
"Effect": "Allow",
|
|
74
|
+
"Action": [
|
|
75
|
+
"sts:GetCallerIdentity",
|
|
76
|
+
"ec2:DescribeRegions",
|
|
77
|
+
"ec2:DescribeVolumes",
|
|
78
|
+
"ec2:DescribeAddresses",
|
|
79
|
+
"ec2:DescribeSnapshots",
|
|
80
|
+
"ec2:DescribeImages",
|
|
81
|
+
"ec2:DescribeInstances",
|
|
82
|
+
"ec2:DescribeNatGateways",
|
|
83
|
+
"rds:DescribeDBInstances",
|
|
84
|
+
"elasticloadbalancing:DescribeLoadBalancers",
|
|
85
|
+
"elasticloadbalancing:DescribeTargetGroups",
|
|
86
|
+
"elasticloadbalancing:DescribeTargetHealth",
|
|
87
|
+
"s3:ListAllMyBuckets",
|
|
88
|
+
"s3:GetBucketLocation",
|
|
89
|
+
"s3:ListBucket",
|
|
90
|
+
"cloudwatch:GetMetricStatistics",
|
|
91
|
+
"cloudwatch:ListMetrics"
|
|
92
|
+
],
|
|
93
|
+
"Resource": "*"
|
|
94
|
+
}]
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Tests
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
pytest
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Tests run against [moto](https://github.com/getmoto/moto), no AWS account needed.
|
|
105
|
+
|
|
106
|
+
## Roadmap
|
|
107
|
+
|
|
108
|
+
- [ ] Unused KMS keys, Secrets Manager secrets and VPC endpoints (small, fixed monthly charges that add up)
|
|
109
|
+
- [ ] Stopped instances still paying for their volumes
|
|
110
|
+
- [ ] Old AMIs and the snapshots behind them
|
|
111
|
+
- [ ] Hosted version: cross-account role, weekly scans, Slack alerts
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# zombiecost
|
|
2
|
+
|
|
3
|
+
Find the zombie resources in your AWS account: things that should be dead but still bill you every month.
|
|
4
|
+
|
|
5
|
+
Find idle and forgotten AWS resources and see what they cost you every month.
|
|
6
|
+
|
|
7
|
+
One read-only scan. One table. A number at the bottom you can act on today.
|
|
8
|
+
|
|
9
|
+
## What it finds
|
|
10
|
+
|
|
11
|
+
| Check | What it flags | How cost is estimated |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| `ebs-unattached` | EBS volumes attached to nothing | GB-month price by volume type |
|
|
14
|
+
| `eip-unused` | Elastic IPs not associated with anything | Hourly unused-EIP charge |
|
|
15
|
+
| `snapshots-old` | Snapshots older than 90 days that no AMI you own uses | GB-month snapshot price |
|
|
16
|
+
| `ec2-idle` | Running instances averaging under 3% CPU over the lookback window | On-demand hourly price |
|
|
17
|
+
| `nat-idle` | NAT gateways that moved under 1 GiB in the lookback window | Hourly NAT charge |
|
|
18
|
+
| `rds-idle` | RDS instances with zero connections over the lookback window | Instance class + storage |
|
|
19
|
+
| `lb-unused` | Load balancers with no registered targets, or ALBs that served 0 requests | Hourly LB charge |
|
|
20
|
+
| `s3-stale` | Buckets nothing has written to in 180 days, with their size and storage class | GB-month by storage class |
|
|
21
|
+
|
|
22
|
+
Prices are rough us-east-1 on-demand numbers. The goal is order of magnitude, not an invoice.
|
|
23
|
+
|
|
24
|
+
Checks that depend on usage history (`ec2-idle`, `rds-idle`, `nat-idle`, `lb-unused`)
|
|
25
|
+
ignore resources younger than 3 days, so a freshly launched instance is never
|
|
26
|
+
reported as idle. `s3-stale` can see writes but not reads; it says so in every
|
|
27
|
+
finding, because reads are invisible without paid request metrics.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install zombiecost
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Or from a clone:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
python3 -m venv .venv && source .venv/bin/activate
|
|
39
|
+
pip install -e ".[dev]"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Run
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
zombiecost # every enabled region, default profile
|
|
46
|
+
zombiecost --profile prod --regions eu-west-1,us-east-1
|
|
47
|
+
zombiecost --days 30 --json report.json # longer lookback, machine-readable output
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Permissions
|
|
51
|
+
|
|
52
|
+
The scan is read-only. Attach `ReadOnlyAccess`, or this minimal policy:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"Version": "2012-10-17",
|
|
57
|
+
"Statement": [{
|
|
58
|
+
"Effect": "Allow",
|
|
59
|
+
"Action": [
|
|
60
|
+
"sts:GetCallerIdentity",
|
|
61
|
+
"ec2:DescribeRegions",
|
|
62
|
+
"ec2:DescribeVolumes",
|
|
63
|
+
"ec2:DescribeAddresses",
|
|
64
|
+
"ec2:DescribeSnapshots",
|
|
65
|
+
"ec2:DescribeImages",
|
|
66
|
+
"ec2:DescribeInstances",
|
|
67
|
+
"ec2:DescribeNatGateways",
|
|
68
|
+
"rds:DescribeDBInstances",
|
|
69
|
+
"elasticloadbalancing:DescribeLoadBalancers",
|
|
70
|
+
"elasticloadbalancing:DescribeTargetGroups",
|
|
71
|
+
"elasticloadbalancing:DescribeTargetHealth",
|
|
72
|
+
"s3:ListAllMyBuckets",
|
|
73
|
+
"s3:GetBucketLocation",
|
|
74
|
+
"s3:ListBucket",
|
|
75
|
+
"cloudwatch:GetMetricStatistics",
|
|
76
|
+
"cloudwatch:ListMetrics"
|
|
77
|
+
],
|
|
78
|
+
"Resource": "*"
|
|
79
|
+
}]
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Tests
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
pytest
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Tests run against [moto](https://github.com/getmoto/moto), no AWS account needed.
|
|
90
|
+
|
|
91
|
+
## Roadmap
|
|
92
|
+
|
|
93
|
+
- [ ] Unused KMS keys, Secrets Manager secrets and VPC endpoints (small, fixed monthly charges that add up)
|
|
94
|
+
- [ ] Stopped instances still paying for their volumes
|
|
95
|
+
- [ ] Old AMIs and the snapshots behind them
|
|
96
|
+
- [ ] Hosted version: cross-account role, weekly scans, Slack alerts
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "zombiecost"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Find zombie AWS resources: idle and forgotten things that should be dead but still bill you every month."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"boto3>=1.34",
|
|
15
|
+
"rich>=13.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = [
|
|
20
|
+
"pytest>=8.0",
|
|
21
|
+
"moto[ec2,cloudwatch]>=5.0",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
zombiecost = "zombiecost.cli:main"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.packages.find]
|
|
28
|
+
include = ["zombiecost*"]
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from datetime import datetime, timedelta, timezone
|
|
2
|
+
|
|
3
|
+
from zombiecost.checks import UnattachedEbsVolumes, UnusedElasticIps, OldSnapshots, IdleEc2Instances
|
|
4
|
+
from zombiecost.scanner import scan
|
|
5
|
+
|
|
6
|
+
REGION = "us-east-1"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_unattached_volume_is_flagged_and_attached_is_not(session):
|
|
10
|
+
ec2 = session.client("ec2", region_name=REGION)
|
|
11
|
+
ec2.create_volume(AvailabilityZone=f"{REGION}a", Size=100, VolumeType="gp3")
|
|
12
|
+
|
|
13
|
+
ami = ec2.describe_images()["Images"][0]["ImageId"]
|
|
14
|
+
inst = ec2.run_instances(ImageId=ami, InstanceType="t3.micro", MinCount=1, MaxCount=1)["Instances"][0]
|
|
15
|
+
attached = ec2.create_volume(AvailabilityZone=inst["Placement"]["AvailabilityZone"], Size=8)
|
|
16
|
+
ec2.attach_volume(VolumeId=attached["VolumeId"], InstanceId=inst["InstanceId"], Device="/dev/sdf")
|
|
17
|
+
|
|
18
|
+
findings = UnattachedEbsVolumes(session).run(REGION)
|
|
19
|
+
|
|
20
|
+
assert len(findings) == 1
|
|
21
|
+
assert findings[0].details["size_gb"] == 100
|
|
22
|
+
assert findings[0].monthly_cost_estimate == 8.0 # 100 GiB * $0.08 gp3
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_unassociated_eip_is_flagged(session):
|
|
26
|
+
ec2 = session.client("ec2", region_name=REGION)
|
|
27
|
+
ec2.allocate_address(Domain="vpc")
|
|
28
|
+
|
|
29
|
+
findings = UnusedElasticIps(session).run(REGION)
|
|
30
|
+
|
|
31
|
+
assert len(findings) == 1
|
|
32
|
+
assert findings[0].check == "eip-unused"
|
|
33
|
+
assert findings[0].monthly_cost_estimate > 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_recent_snapshot_is_not_flagged(session):
|
|
37
|
+
ec2 = session.client("ec2", region_name=REGION)
|
|
38
|
+
vol = ec2.create_volume(AvailabilityZone=f"{REGION}a", Size=20)
|
|
39
|
+
ec2.create_snapshot(VolumeId=vol["VolumeId"])
|
|
40
|
+
|
|
41
|
+
findings = OldSnapshots(session).run(REGION)
|
|
42
|
+
|
|
43
|
+
assert findings == []
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_brand_new_instance_is_not_flagged_even_with_low_cpu(session):
|
|
47
|
+
ec2 = session.client("ec2", region_name=REGION)
|
|
48
|
+
cw = session.client("cloudwatch", region_name=REGION)
|
|
49
|
+
ami = ec2.describe_images()["Images"][0]["ImageId"]
|
|
50
|
+
inst = ec2.run_instances(ImageId=ami, InstanceType="t3.micro", MinCount=1, MaxCount=1)["Instances"][0]
|
|
51
|
+
cw.put_metric_data(
|
|
52
|
+
Namespace="AWS/EC2",
|
|
53
|
+
MetricData=[{
|
|
54
|
+
"MetricName": "CPUUtilization",
|
|
55
|
+
"Dimensions": [{"Name": "InstanceId", "Value": inst["InstanceId"]}],
|
|
56
|
+
"Timestamp": datetime.now(timezone.utc) - timedelta(hours=1),
|
|
57
|
+
"Value": 0.0,
|
|
58
|
+
"Unit": "Percent",
|
|
59
|
+
}],
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
findings = IdleEc2Instances(session, lookback_days=14).run(REGION)
|
|
63
|
+
|
|
64
|
+
assert findings == [] # launched just now, too young to judge
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_idle_instance_is_flagged_when_cpu_is_low(session, monkeypatch):
|
|
68
|
+
# moto launches instances "now"; pretend this one is old enough to judge.
|
|
69
|
+
monkeypatch.setattr(IdleEc2Instances, "min_age_days", 0)
|
|
70
|
+
ec2 = session.client("ec2", region_name=REGION)
|
|
71
|
+
cw = session.client("cloudwatch", region_name=REGION)
|
|
72
|
+
ami = ec2.describe_images()["Images"][0]["ImageId"]
|
|
73
|
+
inst = ec2.run_instances(ImageId=ami, InstanceType="t3.medium", MinCount=1, MaxCount=1)["Instances"][0]
|
|
74
|
+
|
|
75
|
+
now = datetime.now(timezone.utc)
|
|
76
|
+
cw.put_metric_data(
|
|
77
|
+
Namespace="AWS/EC2",
|
|
78
|
+
MetricData=[
|
|
79
|
+
{
|
|
80
|
+
"MetricName": "CPUUtilization",
|
|
81
|
+
"Dimensions": [{"Name": "InstanceId", "Value": inst["InstanceId"]}],
|
|
82
|
+
"Timestamp": now - timedelta(days=d),
|
|
83
|
+
"Value": 1.0,
|
|
84
|
+
"Unit": "Percent",
|
|
85
|
+
}
|
|
86
|
+
for d in range(1, 8)
|
|
87
|
+
],
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
findings = IdleEc2Instances(session, lookback_days=14).run(REGION)
|
|
91
|
+
|
|
92
|
+
assert len(findings) == 1
|
|
93
|
+
assert findings[0].details["instance_type"] == "t3.medium"
|
|
94
|
+
assert findings[0].details["avg_cpu_percent"] == 1.0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_scan_sorts_findings_by_cost(session):
|
|
98
|
+
ec2 = session.client("ec2", region_name=REGION)
|
|
99
|
+
ec2.create_volume(AvailabilityZone=f"{REGION}a", Size=10, VolumeType="gp3") # $0.80
|
|
100
|
+
ec2.create_volume(AvailabilityZone=f"{REGION}a", Size=500, VolumeType="gp2") # $50.00
|
|
101
|
+
ec2.allocate_address(Domain="vpc") # $3.65
|
|
102
|
+
|
|
103
|
+
result = scan(session, regions=[REGION])
|
|
104
|
+
|
|
105
|
+
costs = [f.monthly_cost_estimate for f in result.findings]
|
|
106
|
+
assert costs == sorted(costs, reverse=True)
|
|
107
|
+
assert result.total_monthly_estimate == round(0.8 + 50.0 + 3.65, 2)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
from zombiecost.checks import IdleRdsInstances, UnusedLoadBalancers, StaleS3Buckets
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_rds_with_zero_connections_is_flagged(session, monkeypatch):
|
|
114
|
+
monkeypatch.setattr(IdleRdsInstances, "min_age_days", 0)
|
|
115
|
+
rds = session.client("rds", region_name=REGION)
|
|
116
|
+
cw = session.client("cloudwatch", region_name=REGION)
|
|
117
|
+
rds.create_db_instance(
|
|
118
|
+
DBInstanceIdentifier="lonely-db", DBInstanceClass="db.t3.micro", Engine="postgres",
|
|
119
|
+
AllocatedStorage=20, MasterUsername="admin", MasterUserPassword="password123",
|
|
120
|
+
)
|
|
121
|
+
cw.put_metric_data(
|
|
122
|
+
Namespace="AWS/RDS",
|
|
123
|
+
MetricData=[{
|
|
124
|
+
"MetricName": "DatabaseConnections",
|
|
125
|
+
"Dimensions": [{"Name": "DBInstanceIdentifier", "Value": "lonely-db"}],
|
|
126
|
+
"Timestamp": datetime.now(timezone.utc) - timedelta(days=1),
|
|
127
|
+
"Value": 0.0,
|
|
128
|
+
}],
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
findings = IdleRdsInstances(session).run(REGION)
|
|
132
|
+
|
|
133
|
+
assert [f.resource_id for f in findings] == ["lonely-db"]
|
|
134
|
+
assert findings[0].monthly_cost_estimate == round(0.017 * 730 + 0.115 * 20, 2)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_alb_with_no_targets_is_flagged(session):
|
|
138
|
+
ec2 = session.client("ec2", region_name=REGION)
|
|
139
|
+
elb = session.client("elbv2", region_name=REGION)
|
|
140
|
+
vpc = ec2.create_vpc(CidrBlock="10.0.0.0/16")["Vpc"]["VpcId"]
|
|
141
|
+
s1 = ec2.create_subnet(VpcId=vpc, CidrBlock="10.0.1.0/24", AvailabilityZone=f"{REGION}a")["Subnet"]["SubnetId"]
|
|
142
|
+
s2 = ec2.create_subnet(VpcId=vpc, CidrBlock="10.0.2.0/24", AvailabilityZone=f"{REGION}b")["Subnet"]["SubnetId"]
|
|
143
|
+
sg = ec2.create_security_group(GroupName="lb", Description="lb", VpcId=vpc)["GroupId"]
|
|
144
|
+
elb.create_load_balancer(Name="empty-alb", Subnets=[s1, s2], SecurityGroups=[sg], Type="application")
|
|
145
|
+
|
|
146
|
+
findings = UnusedLoadBalancers(session).run(REGION)
|
|
147
|
+
|
|
148
|
+
assert [f.resource_id for f in findings] == ["empty-alb"]
|
|
149
|
+
assert "no registered targets" in findings[0].description
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_recently_written_bucket_is_not_flagged_and_prefixes_are_ignored(session):
|
|
153
|
+
s3 = session.client("s3", region_name=REGION)
|
|
154
|
+
s3.create_bucket(Bucket="busy-bucket")
|
|
155
|
+
s3.put_object(Bucket="busy-bucket", Key="new.txt", Body=b"hi")
|
|
156
|
+
s3.create_bucket(Bucket="cf-templates-ignored")
|
|
157
|
+
|
|
158
|
+
findings = StaleS3Buckets(session).run(REGION)
|
|
159
|
+
|
|
160
|
+
assert findings == []
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from .base import Check
|
|
2
|
+
from .ebs_unattached import UnattachedEbsVolumes
|
|
3
|
+
from .eip_unused import UnusedElasticIps
|
|
4
|
+
from .snapshots_old import OldSnapshots
|
|
5
|
+
from .ec2_idle import IdleEc2Instances
|
|
6
|
+
from .nat_idle import IdleNatGateways
|
|
7
|
+
from .rds_idle import IdleRdsInstances
|
|
8
|
+
from .lb_unused import UnusedLoadBalancers
|
|
9
|
+
from .s3_stale import StaleS3Buckets
|
|
10
|
+
|
|
11
|
+
ALL_CHECKS: list[type[Check]] = [
|
|
12
|
+
UnattachedEbsVolumes,
|
|
13
|
+
UnusedElasticIps,
|
|
14
|
+
OldSnapshots,
|
|
15
|
+
IdleEc2Instances,
|
|
16
|
+
IdleNatGateways,
|
|
17
|
+
IdleRdsInstances,
|
|
18
|
+
UnusedLoadBalancers,
|
|
19
|
+
StaleS3Buckets,
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
__all__ = ["Check", "ALL_CHECKS"]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
|
|
6
|
+
import boto3
|
|
7
|
+
|
|
8
|
+
from ..models import Finding
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Check(ABC):
|
|
12
|
+
"""A single waste detector. Subclasses implement `run` for one region."""
|
|
13
|
+
|
|
14
|
+
name: str = "base"
|
|
15
|
+
description: str = ""
|
|
16
|
+
|
|
17
|
+
def __init__(self, session: boto3.Session, lookback_days: int = 14):
|
|
18
|
+
self.session = session
|
|
19
|
+
self.lookback_days = lookback_days
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def run(self, region: str) -> list[Finding]:
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
# Helpers shared by metric-based checks
|
|
26
|
+
|
|
27
|
+
def _window(self) -> tuple[datetime, datetime]:
|
|
28
|
+
end = datetime.now(timezone.utc)
|
|
29
|
+
start = end - timedelta(days=self.lookback_days)
|
|
30
|
+
return start, end
|
|
31
|
+
|
|
32
|
+
def _metric_average(
|
|
33
|
+
self, region: str, namespace: str, metric: str, dimensions: list[dict], stat: str = "Average"
|
|
34
|
+
) -> float | None:
|
|
35
|
+
"""Return the mean of a CloudWatch metric over the lookback window, or None if no data."""
|
|
36
|
+
cw = self.session.client("cloudwatch", region_name=region)
|
|
37
|
+
start, end = self._window()
|
|
38
|
+
resp = cw.get_metric_statistics(
|
|
39
|
+
Namespace=namespace,
|
|
40
|
+
MetricName=metric,
|
|
41
|
+
Dimensions=dimensions,
|
|
42
|
+
StartTime=start,
|
|
43
|
+
EndTime=end,
|
|
44
|
+
Period=86400,
|
|
45
|
+
Statistics=[stat],
|
|
46
|
+
)
|
|
47
|
+
points = resp.get("Datapoints", [])
|
|
48
|
+
if not points:
|
|
49
|
+
return None
|
|
50
|
+
return sum(p[stat] for p in points) / len(points)
|
|
51
|
+
|
|
52
|
+
def _metric_sum(self, region: str, namespace: str, metric: str, dimensions: list[dict]) -> float | None:
|
|
53
|
+
cw = self.session.client("cloudwatch", region_name=region)
|
|
54
|
+
start, end = self._window()
|
|
55
|
+
resp = cw.get_metric_statistics(
|
|
56
|
+
Namespace=namespace,
|
|
57
|
+
MetricName=metric,
|
|
58
|
+
Dimensions=dimensions,
|
|
59
|
+
StartTime=start,
|
|
60
|
+
EndTime=end,
|
|
61
|
+
Period=86400,
|
|
62
|
+
Statistics=["Sum"],
|
|
63
|
+
)
|
|
64
|
+
points = resp.get("Datapoints", [])
|
|
65
|
+
if not points:
|
|
66
|
+
return None
|
|
67
|
+
return sum(p["Sum"] for p in points)
|
|
68
|
+
|
|
69
|
+
def _metric_max(self, region: str, namespace: str, metric: str, dimensions: list[dict]) -> float | None:
|
|
70
|
+
cw = self.session.client("cloudwatch", region_name=region)
|
|
71
|
+
start, end = self._window()
|
|
72
|
+
resp = cw.get_metric_statistics(
|
|
73
|
+
Namespace=namespace,
|
|
74
|
+
MetricName=metric,
|
|
75
|
+
Dimensions=dimensions,
|
|
76
|
+
StartTime=start,
|
|
77
|
+
EndTime=end,
|
|
78
|
+
Period=86400,
|
|
79
|
+
Statistics=["Maximum"],
|
|
80
|
+
)
|
|
81
|
+
points = resp.get("Datapoints", [])
|
|
82
|
+
if not points:
|
|
83
|
+
return None
|
|
84
|
+
return max(p["Maximum"] for p in points)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..models import Finding
|
|
4
|
+
from .. import pricing
|
|
5
|
+
from .base import Check
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class UnattachedEbsVolumes(Check):
|
|
9
|
+
name = "ebs-unattached"
|
|
10
|
+
description = "EBS volumes in 'available' state, attached to nothing, still billed every month."
|
|
11
|
+
|
|
12
|
+
def run(self, region: str) -> list[Finding]:
|
|
13
|
+
ec2 = self.session.client("ec2", region_name=region)
|
|
14
|
+
findings: list[Finding] = []
|
|
15
|
+
paginator = ec2.get_paginator("describe_volumes")
|
|
16
|
+
for page in paginator.paginate(Filters=[{"Name": "status", "Values": ["available"]}]):
|
|
17
|
+
for vol in page.get("Volumes", []):
|
|
18
|
+
size = vol["Size"]
|
|
19
|
+
vtype = vol.get("VolumeType", "gp2")
|
|
20
|
+
cost = pricing.ebs_monthly(vtype, size)
|
|
21
|
+
findings.append(
|
|
22
|
+
Finding(
|
|
23
|
+
check=self.name,
|
|
24
|
+
resource_id=vol["VolumeId"],
|
|
25
|
+
region=region,
|
|
26
|
+
description=f"{size} GiB {vtype} volume not attached to any instance",
|
|
27
|
+
monthly_cost_estimate=cost,
|
|
28
|
+
recommendation="Snapshot it if you might need the data, then delete the volume.",
|
|
29
|
+
details={
|
|
30
|
+
"size_gb": size,
|
|
31
|
+
"volume_type": vtype,
|
|
32
|
+
"created": vol["CreateTime"].isoformat(),
|
|
33
|
+
"tags": {t["Key"]: t["Value"] for t in vol.get("Tags", [])},
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
return findings
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
from ..models import Finding
|
|
6
|
+
from .. import pricing
|
|
7
|
+
from .base import Check
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class IdleEc2Instances(Check):
|
|
11
|
+
name = "ec2-idle"
|
|
12
|
+
description = "Running instances whose average CPU over the lookback window is below the threshold."
|
|
13
|
+
|
|
14
|
+
cpu_threshold_percent = 3.0
|
|
15
|
+
# An instance younger than this has not had a chance to do anything yet.
|
|
16
|
+
min_age_days = 3
|
|
17
|
+
|
|
18
|
+
def run(self, region: str) -> list[Finding]:
|
|
19
|
+
now = datetime.now(timezone.utc)
|
|
20
|
+
ec2 = self.session.client("ec2", region_name=region)
|
|
21
|
+
findings: list[Finding] = []
|
|
22
|
+
paginator = ec2.get_paginator("describe_instances")
|
|
23
|
+
for page in paginator.paginate(Filters=[{"Name": "instance-state-name", "Values": ["running"]}]):
|
|
24
|
+
for reservation in page.get("Reservations", []):
|
|
25
|
+
for inst in reservation.get("Instances", []):
|
|
26
|
+
instance_id = inst["InstanceId"]
|
|
27
|
+
age_days = (now - inst["LaunchTime"]).days
|
|
28
|
+
if age_days < self.min_age_days:
|
|
29
|
+
continue
|
|
30
|
+
avg_cpu = self._metric_average(
|
|
31
|
+
region,
|
|
32
|
+
"AWS/EC2",
|
|
33
|
+
"CPUUtilization",
|
|
34
|
+
[{"Name": "InstanceId", "Value": instance_id}],
|
|
35
|
+
)
|
|
36
|
+
if avg_cpu is None or avg_cpu >= self.cpu_threshold_percent:
|
|
37
|
+
continue
|
|
38
|
+
itype = inst["InstanceType"]
|
|
39
|
+
name = next((t["Value"] for t in inst.get("Tags", []) if t["Key"] == "Name"), "")
|
|
40
|
+
findings.append(
|
|
41
|
+
Finding(
|
|
42
|
+
check=self.name,
|
|
43
|
+
resource_id=instance_id,
|
|
44
|
+
region=region,
|
|
45
|
+
description=(
|
|
46
|
+
f"{itype} instance{' ' + repr(name) if name else ''} averaged "
|
|
47
|
+
f"{avg_cpu:.1f}% CPU over the last {min(age_days, self.lookback_days)} days"
|
|
48
|
+
),
|
|
49
|
+
monthly_cost_estimate=pricing.ec2_monthly(itype),
|
|
50
|
+
recommendation="Stop it, downsize it, or move the workload to a scheduled job.",
|
|
51
|
+
details={
|
|
52
|
+
"instance_type": itype,
|
|
53
|
+
"name": name,
|
|
54
|
+
"avg_cpu_percent": round(avg_cpu, 2),
|
|
55
|
+
"age_days": age_days,
|
|
56
|
+
"launch_time": inst["LaunchTime"].isoformat(),
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
return findings
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..models import Finding
|
|
4
|
+
from .. import pricing
|
|
5
|
+
from .base import Check
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class UnusedElasticIps(Check):
|
|
9
|
+
name = "eip-unused"
|
|
10
|
+
description = "Elastic IPs that are allocated but not associated with anything."
|
|
11
|
+
|
|
12
|
+
def run(self, region: str) -> list[Finding]:
|
|
13
|
+
ec2 = self.session.client("ec2", region_name=region)
|
|
14
|
+
findings: list[Finding] = []
|
|
15
|
+
for addr in ec2.describe_addresses().get("Addresses", []):
|
|
16
|
+
if addr.get("AssociationId") or addr.get("InstanceId") or addr.get("NetworkInterfaceId"):
|
|
17
|
+
continue
|
|
18
|
+
findings.append(
|
|
19
|
+
Finding(
|
|
20
|
+
check=self.name,
|
|
21
|
+
resource_id=addr.get("AllocationId") or addr["PublicIp"],
|
|
22
|
+
region=region,
|
|
23
|
+
description=f"Elastic IP {addr['PublicIp']} is allocated but not associated",
|
|
24
|
+
monthly_cost_estimate=pricing.eip_monthly(),
|
|
25
|
+
recommendation="Release the address if nothing will use it.",
|
|
26
|
+
details={"public_ip": addr["PublicIp"]},
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
return findings
|