diff --git a/README.md b/README.md index c5e8ec2..8ee4fa0 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ This exporter is using Azure ResourceGraph queries and not wasting Azure API cal ## Azure permissions -This exporter needs `Reader` permissions on subscription level. +This exporter needs `Reader` permissions on subscription level. For Metrics about Reservation State and Expiry Time `Reservation Reader` is required. ## Metrics diff --git a/example.yaml b/example.yaml index 8f3a081..ed9e761 100644 --- a/example.yaml +++ b/example.yaml @@ -146,6 +146,8 @@ collectors: # for BillingProfile scope (modern). # # see https://learn.microsoft.com/en-us/rest/api/consumption/reservations-summaries/list?view=rest-consumption-2023-05-01&tabs=HTTP + # + # If no scopes are set, the exporter will only collect Expiry Time and Provisioning State metrics scopes: [] granularity: daily # or monthly diff --git a/go.mod b/go.mod index f22d7fc..faca230 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( require ( github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/reservations/armreservations v1.1.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 // indirect diff --git a/go.sum b/go.sum index fa36a93..80cd246 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanage github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0/go.mod h1:mLfWfj8v3jfWKsL9G4eoBoXVcsqcIUTapmdKy7uGOp0= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork v1.1.0 h1:QM6sE5k2ZT/vI5BEe0r7mqjsUSnhVBFbOsVkEuaEfiA= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork v1.1.0/go.mod h1:243D9iHbcQXoFUtgHJwL7gl2zx1aDuDMjvBZVGr2uW0= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/reservations/armreservations v1.1.0 h1:0OO/3K+SKt45gXiOU4gHRILOLeNOUZdqeNO47Mq6iN8= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/reservations/armreservations v1.1.0/go.mod h1:2FDnHkGwh1BFK1ZQt+iDJU47Cg9Y28F0W3FSI389NOA= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 h1:zLzoX5+W2l95UJoVwiyNS4dX8vHyQ6x2xRLoBBL9wMk= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0/go.mod h1:wVEOJfGTj0oPAUGA1JuRAvz/lxXQsWW16axmHPP47Bk= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcehealth/armresourcehealth v1.3.0 h1:hz+ZQ21PKZ6TBEiVMq8zqWUzA5DGj087lYC8OCm6wuY= diff --git a/metrics_azurerm_reservation.go b/metrics_azurerm_reservation.go index bc9e005..9de6097 100644 --- a/metrics_azurerm_reservation.go +++ b/metrics_azurerm_reservation.go @@ -2,14 +2,32 @@ package main import ( "log/slog" + "strings" "time" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/consumption/armconsumption" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/reservations/armreservations" "github.com/prometheus/client_golang/prometheus" "github.com/webdevops/go-common/prometheus/collector" "github.com/webdevops/go-common/utils/to" ) +var provisioningStates = []string{ + "Creating", + "PendingResourceHold", + "ConfirmedResourceHold", + "PendingBilling", + "ConfirmedBilling", + "Created", + "Succeeded", + "Cancelled", + "Expired", + "BillingFailed", + "Failed", + "Split", + "Merged", +} + // Define MetricsCollectorAzureRmReservation struct type MetricsCollectorAzureRmReservation struct { collector.Processor @@ -22,6 +40,8 @@ type MetricsCollectorAzureRmReservation struct { reservationUsedHours *prometheus.GaugeVec reservationReservedHours *prometheus.GaugeVec reservationTotalReservedQuantity *prometheus.GaugeVec + reservationProvisioningState *prometheus.GaugeVec + reservationExpiryTimestamp *prometheus.GaugeVec } } @@ -100,14 +120,52 @@ func (m *MetricsCollectorAzureRmReservation) Setup(collector *collector.Collecto commonLabels, ) m.Collector.RegisterMetricList("reservationTotalReservedQuantity", m.prometheus.reservationTotalReservedQuantity, true) + + // Metrics from management.azure.com/providers/Microsoft.Capacity/reservations (List All, no scopes required) + reservationListAllLabels := []string{ + "reservationOrderID", + "reservationID", + "skuName", + "kind", + "displayName", + "purchaseDate", + "reservedResourceType", + "skuDescription", + } + reservationListAllLabelsWithState := append(append([]string{}, reservationListAllLabels...), "provisioningState") + m.prometheus.reservationProvisioningState = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "azurerm_reservation_provisioning_state", + Help: "Azure ResourceManager Reservation provisioning state as a label (value 1 for current state, 0 otherwise)", + }, + reservationListAllLabelsWithState, + ) + m.Collector.RegisterMetricList("reservationProvisioningState", m.prometheus.reservationProvisioningState, true) + + m.prometheus.reservationExpiryTimestamp = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "azurerm_reservation_expiry_timestamp", + Help: "Azure ResourceManager Reservation expiry date as Unix timestamp", + }, + reservationListAllLabels, + ) + m.Collector.RegisterMetricList("reservationExpiryTimestamp", m.prometheus.reservationExpiryTimestamp, true) } func (m *MetricsCollectorAzureRmReservation) Reset() {} func (m *MetricsCollectorAzureRmReservation) Collect(callback chan<- func()) { - for _, scope := range Config.Collectors.Reservation.Scopes { + scopes := Config.Collectors.Reservation.Scopes + if len(scopes) == 0 { + // No scopes: only produce metrics from List All (management.azure.com/providers/Microsoft.Capacity/reservations) + m.collectReservationListAll(m.Logger(), callback) + return + } + // Scopes set: produce scope-based usage metrics and List All metrics + for _, scope := range scopes { m.collectReservationUsage(m.Logger(), scope, callback) } + m.collectReservationListAll(m.Logger(), callback) } func (m *MetricsCollectorAzureRmReservation) collectReservationUsage(logger *slog.Logger, scope string, callback chan<- func()) { @@ -167,3 +225,110 @@ func (m *MetricsCollectorAzureRmReservation) collectReservationUsage(logger *slo } } } + +// parseReservationID extracts reservationOrderID and reservationID from the reservation resource ID. +func parseReservationID(id string) (reservationOrderID, reservationID string) { + if id == "" { + return "", "" + } + parts := strings.Split(strings.TrimPrefix(id, "/"), "/") + for i := 0; i < len(parts)-1; i++ { + switch strings.ToLower(parts[i]) { + case "reservationorders": + if i+1 < len(parts) { + reservationOrderID = parts[i+1] + } + case "reservations": + if i+1 < len(parts) { + reservationID = parts[i+1] + } + } + } + return reservationOrderID, reservationID +} + +func (m *MetricsCollectorAzureRmReservation) collectReservationListAll(logger *slog.Logger, callback chan<- func()) { + provisioningStateMetric := m.Collector.GetMetricList("reservationProvisioningState") + expiryTimestampMetric := m.Collector.GetMetricList("reservationExpiryTimestamp") + + client, err := armreservations.NewReservationClient(AzureClient.GetCred(), AzureClient.NewArmClientOptions()) + if err != nil { + logger.Error("failed to create reservations client", slog.Any("error", err)) + return + } + + // Filter out archived reservations; API expects URL-encoded filter: (properties/archived eq false) + pager := client.NewListAllPager(&armreservations.ReservationClientListAllOptions{ + Filter: to.Ptr("(properties/archived eq false)"), + }) + + for pager.More() { + page, err := pager.NextPage(m.Context()) + if err != nil { + logger.Error("failed to get next reservations page", slog.Any("error", err)) + return + } + + for _, item := range page.Value { + if item == nil || item.Properties == nil { + continue + } + reservationOrderID, reservationID := parseReservationID(to.String(item.ID)) + if reservationOrderID == "" || reservationID == "" { + continue + } + + skuName := "" + if item.SKU != nil && item.SKU.Name != nil { + skuName = *item.SKU.Name + } + kind := to.String(item.Kind) + + displayName := to.String(item.Properties.DisplayName) + purchaseDate := "" + if item.Properties.PurchaseDate != nil { + purchaseDate = item.Properties.PurchaseDate.Format("2006-01-02") + } + reservedResourceType := "" + if item.Properties.ReservedResourceType != nil { + reservedResourceType = string(*item.Properties.ReservedResourceType) + } + skuDescription := to.String(item.Properties.SKUDescription) + + baseLabels := prometheus.Labels{ + "reservationOrderID": reservationOrderID, + "reservationID": reservationID, + "skuName": skuName, + "kind": kind, + "displayName": displayName, + "purchaseDate": purchaseDate, + "reservedResourceType": reservedResourceType, + "skuDescription": skuDescription, + } + + currentState := "" + if item.Properties.ProvisioningState != nil { + currentState = string(*item.Properties.ProvisioningState) + } + + for _, state := range provisioningStates { + labels := prometheus.Labels{} + for k, v := range baseLabels { + labels[k] = v + } + labels["provisioningState"] = state + + value := 0.0 + if state == currentState { + value = 1.0 + } + + provisioningStateMetric.Add(labels, value) + } + + if item.Properties.ExpiryDate != nil { + expiryTimestampMetric.Add(baseLabels, float64(item.Properties.ExpiryDate.Unix())) + } + } + } +}