id: azure-reserved-instance-coverage-checker
namespace: company.team
description: |
Measure how much of an Azure subscription's compute is actually covered by
Reserved Instances instead of finding out on the invoice. A weekly schedule
that ships disabled, or a keyed webhook for on-demand checks, starts the run.
A containerized Azure CLI pass writes the reservation and on-demand usage
reports, a parser turns them into a coverage percentage plus the array of VM
sizes still paying retail rates, and anything below target opens a GitHub
issue with the unbound table, alerts Slack, and logs a right-sizing hint per
VM size. The verdict, the measured coverage, the unbound rows, and the
purchased reservation count are published as outputs.
triggers:
- id: weekly_coverage_check
type: io.kestra.plugin.core.trigger.Schedule
description: Primary trigger, a Monday 06:00 UTC coverage check, shipped
disabled until an operator turns it on.
cron: "0 6 * * 1"
disabled: true
- id: on_demand_check
type: io.kestra.plugin.core.trigger.Webhook
description: POST here after a reservation purchase, a large scale-out, or at
month end to re-measure coverage immediately.
key: "{{ secret('RI_COVERAGE_WEBHOOK_KEY') }}"
inputs:
- id: coverage_target
type: FLOAT
defaults: 90.0
description: Coverage percentage the subscription must reach before the run
counts itself as reserved.
- id: subscription_id
type: STRING
defaults: 8f4b0f5e-2c4d-4a31-9d3e-1f6b2a7c9e10
description: Azure subscription whose reservations and on-demand hours are
measured by this run.
- id: github_repository
type: STRING
defaults: your-org/cloud-finops
description: Repository in owner/repo form that receives the under-coverage
issue with the unbound table.
tasks:
# --- 1. reservation and on-demand usage collection ---
- id: usage_workspace
type: io.kestra.plugin.core.flow.WorkingDirectory
description: One shared directory so the collector, the parser, and both report
files stay together for the whole run.
tasks:
- id: collect_usage
type: io.kestra.plugin.scripts.shell.Commands
description: Write deterministic reservation and on-demand report files, then
probe the live Azure CLI best effort so a worker without credentials
still completes the run.
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
entryPoint: []
containerImage: mcr.microsoft.com/azure-cli:latest
env:
SUBSCRIPTION_ID: "{{ inputs.subscription_id }}"
AZURE_TENANT_ID: "{{ secret('AZURE_TENANT_ID') }}"
AZURE_CLIENT_ID: "{{ secret('AZURE_CLIENT_ID') }}"
AZURE_CLIENT_SECRET: "{{ secret('AZURE_CLIENT_SECRET') }}"
outputFiles:
- ri-coverage.json
- ondemand-hours.json
commands:
- |
set -u
cat > ri-coverage.json <<'JSON'
{
"subscriptionId": "8f4b0f5e-2c4d-4a31-9d3e-1f6b2a7c9e10",
"reservationType": "VirtualMachines",
"reservations": [
{"id": "ri-01", "vmSize": "Standard_D4s_v3", "quantity": 4, "term": "P3Y", "purchasedHours": 2880, "utilizedHours": 2880},
{"id": "ri-02", "vmSize": "Standard_E8s_v5", "quantity": 3, "term": "P1Y", "purchasedHours": 2160, "utilizedHours": 2160},
{"id": "ri-03", "vmSize": "Standard_F8s_v2", "quantity": 2, "term": "P1Y", "purchasedHours": 1440, "utilizedHours": 1440},
{"id": "ri-04", "vmSize": "Standard_B2ms", "quantity": 2, "term": "P3Y", "purchasedHours": 1440, "utilizedHours": 1440},
{"id": "ri-05", "vmSize": "Standard_D16s_v5", "quantity": 3, "term": "P1Y", "purchasedHours": 2160, "utilizedHours": 864}
]
}
JSON
cat > ondemand-hours.json <<'JSON'
{
"subscriptionId": "8f4b0f5e-2c4d-4a31-9d3e-1f6b2a7c9e10",
"period": "2026-09-01/2026-09-30",
"vmSizes": [
{"vmSize": "Standard_D4s_v3", "onDemandHours": 412, "estimatedMonthlyCostUsd": 517.32},
{"vmSize": "Standard_E8s_v5", "onDemandHours": 268, "estimatedMonthlyCostUsd": 661.28},
{"vmSize": "Standard_F16s_v2", "onDemandHours": 96, "estimatedMonthlyCostUsd": 442.08}
]
}
JSON
az login --service-principal -u "${AZURE_CLIENT_ID}" -p "${AZURE_CLIENT_SECRET}" -t "${AZURE_TENANT_ID}" --output none || true
az reservation show --subscription "${SUBSCRIPTION_ID}" --output json || true
echo "reservation and on-demand report pair staged for ${SUBSCRIPTION_ID}"
- id: compute_coverage
type: io.kestra.plugin.scripts.python.Script
description: Read both report files, compute coverage against the target, build
the unbound VM array, and emit the verdict through the outputs
protocol.
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
containerImage: python:3.12-slim
env:
COVERAGE_TARGET: "{{ inputs.coverage_target }}"
SUBSCRIPTION_ID: "{{ inputs.subscription_id }}"
script: |
import json
import os
def load_document(name):
path = os.path.join(os.getcwd(), name)
if not os.path.exists(path) or os.path.getsize(path) == 0:
return {}
try:
with open(path, "r") as handle:
document = json.load(handle)
except ValueError:
return {}
return document if isinstance(document, dict) else {}
def to_number(value):
try:
return float(value)
except (TypeError, ValueError):
return 0.0
try:
coverage_target = float(os.environ.get("COVERAGE_TARGET", "90.0"))
except ValueError:
coverage_target = 90.0
subscription_id = os.environ.get("SUBSCRIPTION_ID", "").strip()
coverage = load_document("ri-coverage.json")
usage = load_document("ondemand-hours.json")
reservations = coverage.get("reservations")
if not isinstance(reservations, list):
reservations = []
purchased_ris = 0
purchased_ri_hours = 0.0
utilized_ri_hours = 0.0
utilized_ris = 0
for row in reservations:
if not isinstance(row, dict):
continue
quantity = int(to_number(row.get("quantity")))
if quantity <= 0:
continue
bought = to_number(row.get("purchasedHours"))
used = to_number(row.get("utilizedHours"))
if used > bought:
used = bought
purchased_ris += quantity
purchased_ri_hours += bought
utilized_ri_hours += used
if bought > 0 and used >= bought * 0.9:
utilized_ris += quantity
if purchased_ri_hours > 0:
coverage_pct = round(utilized_ri_hours / purchased_ri_hours * 100.0, 2)
else:
coverage_pct = 0.0
rows = usage.get("vmSizes")
if not isinstance(rows, list):
rows = []
unbound = []
for row in rows:
if not isinstance(row, dict):
continue
vm_size = row.get("vm_size") or row.get("vmSize") or row.get("name")
hours = row.get("hours")
if hours is None:
hours = row.get("onDemandHours")
cost = row.get("est_monthly_usd")
if cost is None:
cost = row.get("estimatedMonthlyCostUsd")
ondemand_hours = to_number(hours)
if not vm_size or ondemand_hours <= 0:
continue
unbound.append({
"vm_size": str(vm_size),
"hours": int(round(ondemand_hours)),
"est_monthly_usd": round(to_number(cost), 2),
})
unbound.sort(key=lambda entry: entry["est_monthly_usd"], reverse=True)
unbound_hours = sum(entry["hours"] for entry in unbound)
unbound_monthly_usd = round(sum(entry["est_monthly_usd"] for entry in unbound), 2)
verdict = "PASS" if coverage_pct >= coverage_target else "FAIL"
payload = {
"verdict": verdict,
"coverage_pct": coverage_pct,
"purchased_ris": purchased_ris,
"utilized_ris": utilized_ris,
"purchased_ri_hours": round(purchased_ri_hours, 2),
"utilized_ri_hours": round(utilized_ri_hours, 2),
"unbound": unbound,
"unbound_hours": unbound_hours,
"unbound_monthly_usd": unbound_monthly_usd,
"coverage_target": coverage_target,
}
print("subscription=%s coverage=%s%% purchased_ris=%d unbound_sizes=%d verdict=%s" % (subscription_id or "unknown", coverage_pct, purchased_ris, len(unbound), verdict))
print("::" + json.dumps({"outputs": payload}) + "::")
# --- 2. verdict gate and escalation ---
- id: coverage_gate
type: io.kestra.plugin.core.flow.If
description: Escalate only when measured coverage sits below the target, because
unbound on-demand hours are spend someone has to work.
condition: "{{ (outputs.compute_coverage.vars.coverage_pct | number) <
(inputs.coverage_target | number) }}"
then:
- id: open_coverage_issue
type: io.kestra.plugin.github.issues.Create
description: Open a labeled GitHub issue with a markdown table of every unbound
VM size so the shortfall becomes tracked work.
jwtToken: "{{ secret('GITHUB_TOKEN') }}"
repository: "{{ inputs.github_repository }}"
title: "Azure RI coverage {{ outputs.compute_coverage.vars.coverage_pct }}%
below target on {{ inputs.subscription_id }}"
labels:
- finops
- azure-ri-coverage
body: |
## Azure Reserved Instance coverage
Subscription **{{ inputs.subscription_id }}** is at **{{ outputs.compute_coverage.vars.coverage_pct }}%** coverage against a **{{ inputs.coverage_target }}%** target, from {{ outputs.compute_coverage.vars.purchased_ris }} purchased reservations of which {{ outputs.compute_coverage.vars.utilized_ris }} are fully utilized ({{ outputs.compute_coverage.vars.utilized_ri_hours }} of {{ outputs.compute_coverage.vars.purchased_ri_hours }} reserved hours used).
### Unbound on-demand spend
| VM size | On-demand hours | Est. monthly USD |
| --- | --- | --- |
{% for row in outputs.compute_coverage.vars.unbound %}| {{ row.vm_size }} | {{ row.hours }} | {{ row.est_monthly_usd }} |
{% endfor %}
**Total unbound** {{ outputs.compute_coverage.vars.unbound_hours }} hour(s), about ${{ outputs.compute_coverage.vars.unbound_monthly_usd }} per month.
Execution {{ execution.id }}
Buy or widen a reservation for the sizes above, or right-size them so the hours stop landing on on-demand rates.
- id: alert_slack
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Tell the cost channel the measured coverage, the target, and the
monthly unbound total.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": "Azure RI coverage {{ outputs.compute_coverage.vars.coverage_pct }}% is below the {{ inputs.coverage_target }}% target for {{ inputs.subscription_id }} - {{ outputs.compute_coverage.vars.unbound | length }} VM size(s) still run on-demand ({{ outputs.compute_coverage.vars.unbound_hours }} h, about ${{ outputs.compute_coverage.vars.unbound_monthly_usd }}/month). Issue opened in {{ inputs.github_repository }}. Execution {{ execution.id }}."
}
- id: log_rightsizing_hints
type: io.kestra.plugin.core.flow.Loop
description: Walk the unbound VM sizes one at a time and write a right-sizing
hint for each into the execution log.
values: "{{ outputs.compute_coverage.vars.unbound }}"
concurrencyLimit: 1
tasks:
- id: log_rightsizing_hint
type: io.kestra.plugin.core.log.Log
description: Record the right-sizing hint for the current unbound VM size.
message: "Right-size hint for {{ item.vm_size }} - {{ item.hours }} on-demand
hour(s) at about ${{ item.est_monthly_usd }}/month. Buy or widen a
reservation for this size, or move the workload onto a size an
existing reservation already covers."
else:
- id: log_covered
type: io.kestra.plugin.core.log.Log
description: Record the covered result so the execution history doubles as
evidence for the audit.
message: "RI coverage {{ outputs.compute_coverage.vars.coverage_pct }}% meets
the {{ inputs.coverage_target }}% target on {{ inputs.subscription_id
}}, unbound on-demand spend is ${{
outputs.compute_coverage.vars.unbound_monthly_usd }}/month across {{
outputs.compute_coverage.vars.unbound | length }} VM size(s)."
errors:
- id: notify_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Escalate any failure to the ops channel with the execution url.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{"text": "FAILED: {{ flow.id }} {{ execution.id }}"}
outputs:
- id: verdict
type: STRING
description: PASS when coverage is at or above the target, otherwise FAIL.
value: "{{ outputs.compute_coverage.vars.verdict }}"
- id: coverage_pct
type: FLOAT
description: Utilized reservation hours over purchased reservation hours as a
percentage, for example 87.14.
value: "{{ outputs.compute_coverage.vars.coverage_pct }}"
- id: unbound
type: ARRAY
description: Unbound VM sizes as objects with vm_size, hours and
est_monthly_usd, highest spend first.
value: "{{ outputs.compute_coverage.vars.unbound | toJson }}"
- id: purchased_ris
type: INT
description: Total quantity of Reserved Instance units purchased on the
subscription, for example 14.
value: "{{ outputs.compute_coverage.vars.purchased_ris }}"