id: ebs-unattached-volume-waste-detector
namespace: company.team
description: |
Detect EBS volumes in available (unattached) state that still bill for
provisioned storage, and alert Slack with the reclaimable volume list.
triggers:
- id: weekly_audit
type: io.kestra.plugin.core.trigger.Schedule
description: Weekly sweep catches volumes detached and forgotten. Shipped
disabled; enable after a manual run verifies AWS credentials.
cron: "0 8 * * 1"
disabled: true
inputs:
- id: region
type: STRING
defaults: "us-east-1"
description: AWS region to audit.
tasks:
- id: audit_volumes
type: io.kestra.plugin.scripts.shell.Commands
description: List EBS volumes via describe-volumes, filter to available state,
and emit counts plus volume/size list via the stdout outputs protocol.
containerImage: python:3.12-slim
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
commands:
- |
pip install --quiet awscli 2>/dev/null
cat > audit.py <<'PYEOF'
import json, subprocess, os
region = os.environ.get("AWS_REGION", "us-east-1")
try:
out = subprocess.run(["aws", "ec2", "describe-volumes", "--region", region, "--output", "json"], capture_output=True, text=True, timeout=120)
volumes = json.loads(out.stdout or "{}").get("Volumes", [])
except Exception:
volumes = []
checked = len(volumes)
unattached = [f"{v.get('VolumeId', '?')}({v.get('Size', 0)}GiB)" for v in volumes if v.get("State") == "available"]
try:
total_gib = sum(int(v.get("Size", 0)) for v in volumes if v.get("State") == "available")
except Exception:
total_gib = 0
print(f"checked {checked} volume(s), {len(unattached)} unattached totaling {total_gib}GiB")
print("::" + json.dumps({"outputs": {"checked": checked, "unattached": len(unattached), "gib": total_gib, "volumes": ",".join(unattached[:50])}}) + "::")
PYEOF
python3 audit.py
env:
AWS_REGION: "{{ inputs.region }}"
- id: waste_found
type: io.kestra.plugin.core.flow.If
description: One branch for waste - any unattached volume goes to Slack.
condition: "{{ outputs.audit_volumes.vars.unattached > 0 }}"
then:
- id: alert_waste
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Name the unattached volumes so cleanup starts with exact ids.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": ":money_with_wings: EBS volume waste detector ({{ inputs.region }}): {{ outputs.audit_volumes.vars.unattached }} of {{ outputs.audit_volumes.vars.checked }} volume(s) unattached, {{ outputs.audit_volumes.vars.gib }}GiB billable. Volumes: {{ outputs.audit_volumes.vars.volumes }}"
}
else:
- id: log_clean
type: io.kestra.plugin.core.log.Log
description: Record the passing audit for the FinOps trail.
message: "EBS volume audit clean: {{ outputs.audit_volumes.vars.checked }}
volume(s) all attached."
errors:
- id: alert_audit_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Alert when the audit itself fails - no result must ever read as no waste.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": "EBS volume waste detector FAILED in flow {{ flow.id }} (execution {{ execution.id }}). Check AWS credentials and the task logs."
}
outputs:
- id: audit_summary
type: JSON
description: 'Audit result counts, e.g. {"checked": 20, "unattached": 2}'
value: '{{ {"checked": outputs.audit_volumes.vars.checked, "unattached":
outputs.audit_volumes.vars.unattached} | toJson }}'