VAULT-41572: hookup billing overview endpoint (#12328) (#12451)

* hookup the new path to the system backend

* add API client method for the new endpoint

* add test for the api method structure

* adjust the path implementation to capture all so-far added metrics

* add tests

* add go docs to the tests

* add more tests

* feedback: add go doc to test

* feedback: use require in tests

* fix the api: use parse secret method to properly parse response, add mapstructure definitions to api structs

* feedback: fix api method test by using mock api response

* Update vault/logical_system_use_case_billing.go



* Update vault/logical_system_use_case_billing.go



* feedback: refactor build month data method into methods that collect data separately

* feedback: make update_counts parameter a new user set field for the endpoint

* feedback: remove basic comments

* update logic around determining updated at field

* fix tests: add actual data and fix some assertions

* separate out ent only features from the neutral test file

* add a new test file to test ent only features

* call one update method to update all metrics

* add external tests for the endpoint

* add a changelog

* feedback: rename update_counts parameter to refresh_data

* feedback: fix determination of updated_at field

* feedback: convert created methods into core methods from system backend methods

* Update changelog/12328.txt



* feedback: create a new atomic tracker of last updated time for the metrics update and use that in the endpoint

* add unit tests to test updated_at

* always build metrics, even when the values are 0

* add test coverage to verify metrics still exist in the response with zero values even when there are no billing resources

* feedback: remove manual check of root namespace - rely on system backend to enforce root namespace restriction

* remove namespace test from oss test file

* properly accomodate new totp metric

* add pki cert and totp to endpoint response, add test coverage

* rename changelog file

* linters

* change changelog type to improvement, make the file CE and ENT

* test ent changelog

* fix some tests after the addition of totp and pki

* add test coverage for the new metrics in external and api tests

* make changelog CE file

---------

Co-authored-by: Amir Aslamov <amir.aslamov@hashicorp.com>
Co-authored-by: Violet Hynes <violet.hynes@hashicorp.com>
Co-authored-by: divyaac <divya.chandrasekaran@hashicorp.com>
This commit is contained in:
Vault Automation 2026-02-20 08:17:23 -05:00 committed by GitHub
parent 422acb5e1f
commit c0e8de6ed9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1522 additions and 127 deletions

70
api/sys_billing.go Normal file
View file

@ -0,0 +1,70 @@
// Copyright IBM Corp. 2016, 2025
// SPDX-License-Identifier: MPL-2.0
package api
import (
"context"
"errors"
"net/http"
"github.com/mitchellh/mapstructure"
)
// BillingOverview returns billing metrics for the current and previous month.
// If updateCounts is true, the current month's counts will be updated before returning.
// This is an expensive operation that holds locks and should be used sparingly.
func (c *Sys) BillingOverview(updateCounts bool) (*BillingOverviewResponse, error) {
return c.BillingOverviewWithContext(context.Background(), updateCounts)
}
// BillingOverviewWithContext returns billing metrics for the current and previous month.
func (c *Sys) BillingOverviewWithContext(ctx context.Context, updateCounts bool) (*BillingOverviewResponse, error) {
ctx, cancelFunc := c.c.withConfiguredTimeout(ctx)
defer cancelFunc()
r := c.c.NewRequest(http.MethodGet, "/v1/sys/billing/overview")
if updateCounts {
r.Params.Set("refresh_data", "true")
}
resp, err := c.c.rawRequestWithContext(ctx, r)
if err != nil {
return nil, err
}
defer resp.Body.Close()
secret, err := ParseSecret(resp.Body)
if err != nil {
return nil, err
}
if secret == nil || secret.Data == nil {
return nil, errors.New("data from server response is empty")
}
var result BillingOverviewResponse
err = mapstructure.Decode(secret.Data, &result)
if err != nil {
return nil, err
}
return &result, nil
}
// BillingOverviewResponse represents the response from the billing overview endpoint.
type BillingOverviewResponse struct {
Months []BillingMonth `json:"months" mapstructure:"months"`
}
// BillingMonth represents billing data for a single month.
type BillingMonth struct {
Month string `json:"month" mapstructure:"month"`
UpdatedAt string `json:"updated_at" mapstructure:"updated_at"`
UsageMetrics []UsageMetric `json:"usage_metrics" mapstructure:"usage_metrics"`
}
// UsageMetric represents a single usage metric with its data.
type UsageMetric struct {
MetricName string `json:"metric_name" mapstructure:"metric_name"`
MetricData map[string]interface{} `json:"metric_data" mapstructure:"metric_data"`
}

148
api/sys_billing_test.go Normal file
View file

@ -0,0 +1,148 @@
// Copyright IBM Corp. 2016, 2025
// SPDX-License-Identifier: MPL-2.0
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
// TestSys_BillingOverview tests the BillingOverview API client method and structure of the response
func TestSys_BillingOverview(t *testing.T) {
mockVaultServer := httptest.NewServer(http.HandlerFunc(mockVaultBillingHandler))
defer mockVaultServer.Close()
// Create API client pointing to mock server
cfg := DefaultConfig()
cfg.Address = mockVaultServer.URL
client, err := NewClient(cfg)
require.NoError(t, err)
resp, err := client.Sys().BillingOverview(false)
require.NoError(t, err)
require.NotNil(t, resp)
// Verify we have 2 months (current and previous)
require.Len(t, resp.Months, 2)
// Verify current month structure
currentMonth := resp.Months[0]
require.Equal(t, "2026-01", currentMonth.Month)
require.Equal(t, "2026-01-14T10:49:00Z", currentMonth.UpdatedAt)
require.Len(t, currentMonth.UsageMetrics, 4)
// Verify static_secrets metric
staticSecretsMetric := currentMonth.UsageMetrics[0]
require.Equal(t, "static_secrets", staticSecretsMetric.MetricName)
require.NotNil(t, staticSecretsMetric.MetricData)
require.Contains(t, staticSecretsMetric.MetricData, "total")
require.Contains(t, staticSecretsMetric.MetricData, "metric_details")
// Verify kmip metric
kmipMetric := currentMonth.UsageMetrics[1]
require.Equal(t, "kmip", kmipMetric.MetricName)
require.NotNil(t, kmipMetric.MetricData)
require.Contains(t, kmipMetric.MetricData, "used_in_month")
require.Equal(t, true, kmipMetric.MetricData["used_in_month"])
// Verify pki_units metric
pkiMetric := currentMonth.UsageMetrics[2]
require.Equal(t, "pki_units", pkiMetric.MetricName)
require.NotNil(t, pkiMetric.MetricData)
require.Contains(t, pkiMetric.MetricData, "total")
// Verify managed_keys metric
managedKeysMetric := currentMonth.UsageMetrics[3]
require.Equal(t, "managed_keys", managedKeysMetric.MetricName)
require.NotNil(t, managedKeysMetric.MetricData)
require.Contains(t, managedKeysMetric.MetricData, "total")
require.Contains(t, managedKeysMetric.MetricData, "metric_details")
// Verify previous month structure
previousMonth := resp.Months[1]
require.Equal(t, "2025-12", previousMonth.Month)
require.Equal(t, "2025-12-31T23:59:59Z", previousMonth.UpdatedAt)
require.Len(t, previousMonth.UsageMetrics, 1)
// Verify external_plugins metric in previous month
externalPluginsMetric := previousMonth.UsageMetrics[0]
require.Equal(t, "external_plugins", externalPluginsMetric.MetricName)
require.NotNil(t, externalPluginsMetric.MetricData)
require.Contains(t, externalPluginsMetric.MetricData, "total")
}
func mockVaultBillingHandler(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(billingOverviewResponse))
}
const billingOverviewResponse = `{
"request_id": "d8d3e6e1-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"lease_id": "",
"renewable": false,
"lease_duration": 0,
"data": {
"months": [
{
"month": "2026-01",
"updated_at": "2026-01-14T10:49:00Z",
"usage_metrics": [
{
"metric_name": "static_secrets",
"metric_data": {
"total": 10,
"metric_details": [
{
"type": "kv",
"count": 10
}
]
}
},
{
"metric_name": "kmip",
"metric_data": {
"used_in_month": true
}
},
{
"metric_name": "pki_units",
"metric_data": {
"total": 100.5
}
},
{
"metric_name": "managed_keys",
"metric_data": {
"total": 5,
"metric_details": [
{
"type": "totp",
"count": 5
}
]
}
}
]
},
{
"month": "2025-12",
"updated_at": "2025-12-31T23:59:59Z",
"usage_metrics": [
{
"metric_name": "external_plugins",
"metric_data": {
"total": 5
}
}
]
}
]
},
"wrap_info": null,
"warnings": null,
"auth": null
}`

3
changelog/12328.txt Normal file
View file

@ -0,0 +1,3 @@
```release-note:improvement
consumption-billing: Adds a new `sys/billing/overview` endpoint that returns current and previous month consumption billing metrics. Accessible via API client method `client.Sys().BillingOverview()`.
```

View file

@ -48,6 +48,11 @@ type ConsumptionBilling struct {
// KmipSeenEnabledThisMonth tracks whether KMIP has been enabled during the current billing month.
// This is used to avoid scanning all mounts every 10 minutes for KMIP billing detection.
KmipSeenEnabledThisMonth atomic.Bool
// LastMetricsUpdate tracks when billing metrics were last updated, either by the background worker
// or by the billing endpoint API call. This timestamp is used by the billing overview endpoint to
// indicate data freshness.
LastMetricsUpdate atomic.Value
}
type BillingConfig struct {

View file

@ -180,6 +180,7 @@ func (c *Core) updateBillingMetrics(ctx context.Context, currentMonth time.Time)
c.logger.Info("updated cluster data protection call counts", "prefix", billing.LocalPrefix, "currentMonth", currentMonth)
}
c.consumptionBilling.LastMetricsUpdate.Store(time.Now().UTC())
}
return nil
}

View file

@ -0,0 +1,209 @@
// Copyright IBM Corp. 2016, 2025
// SPDX-License-Identifier: BUSL-1.1
package api
import (
"context"
"encoding/json"
"testing"
"github.com/hashicorp/vault/api"
logicalAws "github.com/hashicorp/vault/builtin/logical/aws"
logicalDatabase "github.com/hashicorp/vault/builtin/logical/database"
logicalTransit "github.com/hashicorp/vault/builtin/logical/transit"
"github.com/hashicorp/vault/helper/pluginconsts"
"github.com/hashicorp/vault/helper/testhelpers/minimal"
"github.com/hashicorp/vault/sdk/logical"
"github.com/hashicorp/vault/vault"
"github.com/stretchr/testify/require"
)
// Test_BillingOverview tests that the BillingOverview API method works correctly
func Test_BillingOverview(t *testing.T) {
t.Parallel()
coreConfig := &vault.CoreConfig{
LogicalBackends: map[string]logical.Factory{
pluginconsts.SecretEngineAWS: logicalAws.Factory,
pluginconsts.SecretEngineDatabase: logicalDatabase.Factory,
pluginconsts.SecretEngineTransit: logicalTransit.Factory,
},
}
cluster := minimal.NewTestSoloCluster(t, coreConfig)
client := cluster.Cores[0].Client
// Mount AWS for dynamic roles
err := client.Sys().Mount("aws", &api.MountInput{
Type: "aws",
})
require.NoError(t, err)
// Create an AWS role
_, err = client.Logical().Write("aws/roles/test-role", map[string]interface{}{
"credential_type": "iam_user",
"policy_document": `{"Version": "2012-10-17","Statement": [{"Effect": "Allow","Action": "ec2:*","Resource": "*"}]}`,
})
require.NoError(t, err)
// Mount Database for dynamic roles
err = client.Sys().Mount("database", &api.MountInput{
Type: "database",
})
require.NoError(t, err)
// Create a database role
_, err = client.Logical().Write("database/roles/test-db-role", map[string]interface{}{
"db_name": "test-db",
"creation_statements": []string{"CREATE USER '{{name}}'@'%' IDENTIFIED BY '{{password}}';"},
"default_ttl": "1h",
"max_ttl": "24h",
})
require.NoError(t, err)
// Mount KV for static secrets
err = client.Sys().Mount("kv-v2", &api.MountInput{
Type: "kv-v2",
})
require.NoError(t, err)
// Create KV secrets
secretData := map[string]interface{}{
"foo": "bar",
}
_, err = client.KVv2("kv-v2").Put(context.Background(), "secret1", secretData)
require.NoError(t, err)
resp, err := client.Sys().BillingOverview(true)
require.NoError(t, err)
require.NotNil(t, resp)
// Validate response structure
require.NotNil(t, resp.Months)
require.Len(t, resp.Months, 2, "should have current and previous month")
// Check current month data
currentMonth := resp.Months[0]
require.NotEmpty(t, currentMonth.Month)
require.NotEmpty(t, currentMonth.UpdatedAt)
require.NotNil(t, currentMonth.UsageMetrics)
// Verify we have some metrics
require.NotEmpty(t, currentMonth.UsageMetrics, "should have usage metrics after creating test data")
// Validate that metrics have the expected structure
for _, metric := range currentMonth.UsageMetrics {
require.NotEmpty(t, metric.MetricName)
require.NotNil(t, metric.MetricData)
require.NotEmpty(t, metric.MetricData)
if total, ok := metric.MetricData["total"]; ok {
_, ok := total.(json.Number)
require.True(t, ok, "total should be json.Number for metric %s", metric.MetricName)
}
if details, ok := metric.MetricData["metric_details"]; ok {
_, ok := details.([]interface{})
require.True(t, ok, "metric_details should be []interface{} for metric %s", metric.MetricName)
}
}
}
// Test_BillingOverview_WithoutUpdateCounts tests that BillingOverview works with updateCounts=false
func Test_BillingOverview_WithoutUpdateCounts(t *testing.T) {
t.Parallel()
coreConfig := &vault.CoreConfig{
LogicalBackends: map[string]logical.Factory{
pluginconsts.SecretEngineAWS: logicalAws.Factory,
},
}
cluster := minimal.NewTestSoloCluster(t, coreConfig)
client := cluster.Cores[0].Client
// Call BillingOverview without updating counts
resp, err := client.Sys().BillingOverview(false)
require.NoError(t, err)
require.NotNil(t, resp)
// Validate basic response structure
require.NotNil(t, resp.Months)
require.Len(t, resp.Months, 2, "should have current and previous month")
// Check that months are properly formatted
for _, month := range resp.Months {
require.NotEmpty(t, month.Month)
require.NotEmpty(t, month.UpdatedAt)
require.NotNil(t, month.UsageMetrics)
}
}
// Test_BillingOverview_EmptyCluster tests BillingOverview on a cluster with no mounts.
// Verifies that all metrics are present with zero values
func Test_BillingOverview_EmptyCluster(t *testing.T) {
t.Parallel()
cluster := minimal.NewTestSoloCluster(t, nil)
client := cluster.Cores[0].Client
resp, err := client.Sys().BillingOverview(true)
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, resp.Months)
require.Len(t, resp.Months, 2)
currentMonth := resp.Months[0]
require.NotEmpty(t, currentMonth.Month)
require.NotEmpty(t, currentMonth.UpdatedAt)
require.NotNil(t, currentMonth.UsageMetrics)
// Verify all expected metrics are present even with no usage
require.NotEmpty(t, currentMonth.UsageMetrics, "should have all metrics even with zero values")
expectedMetrics := map[string]bool{
"static_secrets": false,
"dynamic_roles": false,
"auto_rotated_roles": false,
"kmip": false,
"external_plugins": false,
"data_protection_calls": false,
"pki_units": false,
"managed_keys": false,
}
for _, metric := range currentMonth.UsageMetrics {
require.NotEmpty(t, metric.MetricName)
require.Contains(t, expectedMetrics, metric.MetricName, "unexpected metric: %s", metric.MetricName)
expectedMetrics[metric.MetricName] = true
require.NotNil(t, metric.MetricData)
}
// Verify all expected metrics were found
for metricName, found := range expectedMetrics {
require.True(t, found, "metric %s should be present", metricName)
}
}
// Test_BillingOverview_MonthFormat tests that month strings are in correct format
func Test_BillingOverview_MonthFormat(t *testing.T) {
t.Parallel()
cluster := minimal.NewTestSoloCluster(t, nil)
client := cluster.Cores[0].Client
resp, err := client.Sys().BillingOverview(false)
require.NoError(t, err)
require.NotNil(t, resp)
// Verify month format (YYYY-MM)
for _, month := range resp.Months {
require.Regexp(t, `^\d{4}-\d{2}$`, month.Month, "month should be in YYYY-MM format")
require.Regexp(t, `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}`, month.UpdatedAt, "updated_at should be in ISO 8601 format")
}
// Verify months are in descending order (current, then previous)
require.Greater(t, resp.Months[0].Month, resp.Months[1].Month, "first month should be more recent than second")
}

View file

@ -239,6 +239,7 @@ func NewSystemBackend(core *Core, logger log.Logger, config *logical.BackendConf
b.Backend.Paths = append(b.Backend.Paths, b.introspectionPaths()...)
b.Backend.Paths = append(b.Backend.Paths, b.wellKnownPaths()...)
b.Backend.Paths = append(b.Backend.Paths, b.activationFlagsPaths()...)
b.Backend.Paths = append(b.Backend.Paths, b.useCaseConsumptionBillingPaths()...)
if core.rawEnabled {
b.Backend.Paths = append(b.Backend.Paths, b.rawPaths()...)

View file

@ -21,21 +21,24 @@ func (b *SystemBackend) useCaseConsumptionBillingPaths() []*framework.Path {
return []*framework.Path{
{
Pattern: "billing/overview$",
Fields: map[string]*framework.FieldSchema{
"refresh_data": {
Type: framework.TypeBool,
Description: "If set, updates the billing counts for the current month before returning. This is an expensive operation with potential performance impact and should be used sparingly.",
Query: true,
},
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.ReadOperation: &framework.PathOperation{
Callback: b.handleUseCaseConsumption,
Summary: "Report the count of secrets and roles for the purposes of use case billing.",
Summary: "Reports consumption billing metrics for the current and previous months.",
Responses: map[int][]framework.Response{
http.StatusOK: {{
Description: http.StatusText(http.StatusOK),
Fields: map[string]*framework.FieldSchema{
"high_watermark_role_counts": {
Type: framework.TypeMap,
Description: "High watermark (for this month) role counts for this cluster.",
},
"data_protection_call_counts": {
Type: framework.TypeMap,
Description: "Count of data protection calls on this cluster.",
"months": {
Type: framework.TypeSlice,
Description: "List of monthly billing data, including the current and previous months.",
},
},
}},
@ -56,127 +59,26 @@ func (b *SystemBackend) useCaseConsumptionBillingPaths() []*framework.Path {
}
func (b *SystemBackend) handleUseCaseConsumption(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
// Get HWM role counts
replicatedMaxRoleCounts := &RoleCounts{}
replicatedManagedKeyCounts := &ManagedKeyCounts{}
replicatedKvHWMCounts := 0
replicatedTotpHWMCounts := 0
var err error
refreshData := data.Get("refresh_data").(bool)
currentMonth := time.Now()
previousMonth := timeutil.StartOfPreviousMonth(currentMonth)
// If we are the primary, then we want to get the replicated max role counts. Else we shouldn't retrieve them.
if b.Core.isPrimary() {
// We use update instead of Get so that the counts are up to date.
replicatedMaxRoleCounts, replicatedManagedKeyCounts, err = b.Core.UpdateMaxRoleAndManagedKeyCounts(ctx, billing.ReplicatedPrefix, currentMonth)
if err != nil {
return nil, fmt.Errorf("error retrieving replicated max role and managed key counts: %w", err)
}
replicatedKvHWMCounts, err = b.Core.UpdateMaxKvCounts(ctx, billing.ReplicatedPrefix, currentMonth)
if err != nil {
return nil, fmt.Errorf("error retrieving replicated max kv counts: %w", err)
}
replicatedTotpHWMCounts = replicatedManagedKeyCounts.TotpKeys
// Refresh data only if explicitly requested and for current month
currentMonthData, err := b.buildMonthBillingData(ctx, currentMonth, refreshData)
if err != nil {
return nil, fmt.Errorf("error building current month billing data: %w", err)
}
// We always want to get the local max role counts
// We use update instead of Get so that the counts are up to date.
localMaxRoleCounts, localMaxManagedKeyCounts, err := b.Core.UpdateMaxRoleAndManagedKeyCounts(ctx, billing.LocalPrefix, currentMonth)
previousMonthData, err := b.buildMonthBillingData(ctx, previousMonth, false)
if err != nil {
return nil, fmt.Errorf("error retrieving local max role and managed key counts: %w", err)
}
localKvHWMCounts, err := b.Core.UpdateMaxKvCounts(ctx, billing.LocalPrefix, currentMonth)
if err != nil {
return nil, fmt.Errorf("error retrieving local max kv counts: %w", err)
}
localTotpHWMCounts := localMaxManagedKeyCounts.TotpKeys
// Data protection call counts are stored to local path only
// Each cluster tracks its own total requests to avoid double counting
localTransitCallCounts, err := b.Core.UpdateTransitCallCounts(ctx, currentMonth)
if err != nil {
return nil, fmt.Errorf("error retrieving local transit call counts: %w", err)
}
localTransformCallCounts, err := b.Core.UpdateTransformCallCounts(ctx, currentMonth)
if err != nil {
return nil, fmt.Errorf("error retrieving local transform call counts: %w", err)
}
// If we are the primary, then combine the replicated and local max role counts. Else just output the local
// max role counts. replicatedMaxRoleCounts will be empty if we are not a primary, so this is taken care of for us.
combinedMaxRoleCounts := combineRoleCounts(replicatedMaxRoleCounts, localMaxRoleCounts)
combinedMaxKvCounts := replicatedKvHWMCounts + localKvHWMCounts
combinedMaxTotpCounts := replicatedTotpHWMCounts + localTotpHWMCounts
// Data protection counts are not combined - each cluster reports its own total
combinedMaxDataProtectionCallCounts := map[string]interface{}{
"transit": localTransitCallCounts,
"transform": localTransformCallCounts,
}
var replicatedPreviousMonthRoleCounts *RoleCounts
replicatedPreviousMonthKvHWMCounts := 0
replicatedPreviousMonthTotpHWMCounts := 0
if b.Core.isPrimary() {
replicatedPreviousMonthRoleCounts, err = b.Core.GetStoredHWMRoleCounts(ctx, billing.ReplicatedPrefix, previousMonth)
if err != nil {
return nil, fmt.Errorf("error retrieving replicated max role counts for previous month: %w", err)
}
replicatedPreviousMonthKvHWMCounts, err = b.Core.GetStoredHWMKvCounts(ctx, billing.ReplicatedPrefix, previousMonth)
if err != nil {
return nil, fmt.Errorf("error retrieving replicated max kv counts for previous month: %w", err)
}
replicatedPreviousMonthTotpHWMCounts, err = b.Core.GetStoredHWMTotpCounts(ctx, billing.ReplicatedPrefix, previousMonth)
if err != nil {
return nil, fmt.Errorf("error retrieving replicated max totp key for previous month: %w", err)
}
}
localPreviousMonthRoleCounts, err := b.Core.GetStoredHWMRoleCounts(ctx, billing.LocalPrefix, previousMonth)
if err != nil {
return nil, fmt.Errorf("error retrieving local max role counts for previous month: %w", err)
}
localPreviousMonthKvHWMCounts, err := b.Core.GetStoredHWMKvCounts(ctx, billing.LocalPrefix, previousMonth)
if err != nil {
return nil, fmt.Errorf("error retrieving local max kv counts for previous month: %w", err)
}
localPreviousMonthTotpHWMCounts, err := b.Core.GetStoredHWMTotpCounts(ctx, billing.LocalPrefix, previousMonth)
if err != nil {
return nil, fmt.Errorf("error retrieving local max totp key for previous month: %w", err)
}
// Data protection counts for previous month
localPreviousMonthTransitCallCounts, err := b.Core.GetStoredTransitCallCounts(ctx, previousMonth)
if err != nil {
return nil, fmt.Errorf("error retrieving local transit call counts for previous month: %w", err)
}
localPreviousMonthTransformCallCounts, err := b.Core.GetStoredTransformCallCounts(ctx, previousMonth)
if err != nil {
return nil, fmt.Errorf("error retrieving local transform call counts for previous month: %w", err)
}
combinedPreviousMonthRoleCounts := combineRoleCounts(replicatedPreviousMonthRoleCounts, localPreviousMonthRoleCounts)
combinedPreviousMonthKvHWMCounts := replicatedPreviousMonthKvHWMCounts + localPreviousMonthKvHWMCounts
combinedPreviousMonthTotpHWMCounts := replicatedPreviousMonthTotpHWMCounts + localPreviousMonthTotpHWMCounts
// Data protection counts are not combined - each cluster reports its own total
combinedPreviousMonthDataProtectionCallCounts := map[string]interface{}{
"transit": localPreviousMonthTransitCallCounts,
"transform": localPreviousMonthTransformCallCounts,
return nil, fmt.Errorf("error building previous month billing data: %w", err)
}
resp := map[string]interface{}{
"current_month": map[string]interface{}{
"timestamp": timeutil.StartOfMonth(currentMonth),
"maximum_role_counts": combinedMaxRoleCounts,
"maximum_kv_counts": combinedMaxKvCounts,
"maximum_totp_counts": combinedMaxTotpCounts,
"data_protection_call_counts": combinedMaxDataProtectionCallCounts,
},
"previous_month": map[string]interface{}{
"timestamp": previousMonth,
"maximum_role_counts": combinedPreviousMonthRoleCounts,
"maximum_kv_counts": combinedPreviousMonthKvHWMCounts,
"maximum_totp_counts": combinedPreviousMonthTotpHWMCounts,
"data_protection_call_counts": combinedPreviousMonthDataProtectionCallCounts,
"months": []interface{}{
currentMonthData,
previousMonthData,
},
}
@ -185,8 +87,252 @@ func (b *SystemBackend) handleUseCaseConsumption(ctx context.Context, req *logic
}, nil
}
// generatePkiBillingMetric generates the billing metric for PKI duration-adjusted certificate counts.
func (b *SystemBackend) generatePkiBillingMetric(ctx context.Context, month time.Time) (map[string]interface{}, error) {
// buildMonthBillingData constructs billing data for a specific month
func (b *SystemBackend) buildMonthBillingData(ctx context.Context, month time.Time, refreshData bool) (map[string]interface{}, error) {
// Retrieve all billing metrics
combinedRoleCounts, combinedMaxTotpCounts, err := b.Core.getRoleAndManagedKeyCounts(ctx, month, refreshData)
if err != nil {
return nil, err
}
combinedKvCounts, err := b.Core.getKvCounts(ctx, month, refreshData)
if err != nil {
return nil, err
}
transitCounts, transformCounts, err := b.Core.getDataProtectionCounts(ctx, month, refreshData)
if err != nil {
return nil, err
}
kmipEnabled, err := b.Core.getKmipStatus(ctx, month, refreshData)
if err != nil {
return nil, err
}
thirdPartyPluginCounts, err := b.Core.getThirdPartyPluginCounts(ctx, month, refreshData)
if err != nil {
return nil, err
}
// Build the usage metrics
usageMetrics := []map[string]interface{}{}
kvDetails := []map[string]interface{}{}
if combinedKvCounts > 0 {
kvDetails = append(kvDetails, map[string]interface{}{"type": "kv", "count": combinedKvCounts})
}
usageMetrics = append(usageMetrics, map[string]interface{}{
"metric_name": "static_secrets",
"metric_data": map[string]interface{}{
"total": combinedKvCounts,
"metric_details": kvDetails,
},
})
usageMetrics = append(usageMetrics, buildDynamicRolesMetric(combinedRoleCounts))
usageMetrics = append(usageMetrics, buildAutoRotatedRolesMetric(combinedRoleCounts))
usageMetrics = append(usageMetrics, map[string]interface{}{
"metric_name": "kmip",
"metric_data": map[string]interface{}{
"used_in_month": kmipEnabled,
},
})
usageMetrics = append(usageMetrics, map[string]interface{}{
"metric_name": "external_plugins",
"metric_data": map[string]interface{}{
"total": thirdPartyPluginCounts,
},
})
dataProtectionDetails := []map[string]interface{}{}
if transitCounts > 0 {
dataProtectionDetails = append(dataProtectionDetails, map[string]interface{}{"type": "transit", "count": transitCounts})
}
if transformCounts > 0 {
dataProtectionDetails = append(dataProtectionDetails, map[string]interface{}{"type": "transform", "count": transformCounts})
}
usageMetrics = append(usageMetrics, map[string]interface{}{
"metric_name": "data_protection_calls",
"metric_data": map[string]interface{}{
"total": transitCounts + transformCounts,
"metric_details": dataProtectionDetails,
},
})
pkiMetric, err := b.buildPkiBillingMetric(ctx, month)
if err != nil {
return nil, err
}
usageMetrics = append(usageMetrics, pkiMetric)
managedKeysDetails := []map[string]interface{}{}
if combinedMaxTotpCounts > 0 {
managedKeysDetails = append(managedKeysDetails, map[string]interface{}{"type": "totp", "count": combinedMaxTotpCounts})
}
usageMetrics = append(usageMetrics, map[string]interface{}{
"metric_name": "managed_keys",
"metric_data": map[string]interface{}{
"total": combinedMaxTotpCounts,
"metric_details": managedKeysDetails,
},
})
// Determine updated_at timestamp based on whether data was refreshed
var dataUpdatedAt time.Time
if refreshData {
// Data was just refreshed, use current time and update the stored timestamp
dataUpdatedAt = time.Now().UTC()
b.Core.consumptionBilling.LastMetricsUpdate.Store(dataUpdatedAt)
} else {
// Data was not refreshed, use the last time metrics were updated by the background worker
lastUpdate := b.Core.consumptionBilling.LastMetricsUpdate.Load()
if lastUpdate != nil {
if t, ok := lastUpdate.(time.Time); ok && !t.IsZero() {
dataUpdatedAt = t
} else {
// Fallback to end of month if timestamp not available
dataUpdatedAt = timeutil.StartOfMonth(month.AddDate(0, 1, 0)).Add(-time.Second).UTC()
}
} else {
// Fallback to end of month if timestamp not available
dataUpdatedAt = timeutil.StartOfMonth(month.AddDate(0, 1, 0)).Add(-time.Second).UTC()
}
}
monthStr := month.Format("2006-01")
return map[string]interface{}{
"month": monthStr,
"updated_at": dataUpdatedAt.Format(time.RFC3339),
"usage_metrics": usageMetrics,
}, nil
}
// buildDynamicRolesMetric creates the dynamic_roles metric from role counts.
func buildDynamicRolesMetric(counts *RoleCounts) map[string]interface{} {
total := 0
if counts != nil {
total = counts.AWSDynamicRoles +
counts.AzureDynamicRoles +
counts.DatabaseDynamicRoles +
counts.GCPRolesets +
counts.LDAPDynamicRoles +
counts.OpenLDAPDynamicRoles +
counts.AlicloudDynamicRoles +
counts.RabbitMQDynamicRoles +
counts.ConsulDynamicRoles +
counts.NomadDynamicRoles +
counts.KubernetesDynamicRoles +
counts.MongoDBAtlasDynamicRoles +
counts.TerraformCloudDynamicRoles
}
details := []map[string]interface{}{}
if counts != nil {
if counts.AWSDynamicRoles > 0 {
details = append(details, map[string]interface{}{"type": "aws_dynamic", "count": counts.AWSDynamicRoles})
}
if counts.AzureDynamicRoles > 0 {
details = append(details, map[string]interface{}{"type": "azure_dynamic", "count": counts.AzureDynamicRoles})
}
if counts.DatabaseDynamicRoles > 0 {
details = append(details, map[string]interface{}{"type": "database_dynamic", "count": counts.DatabaseDynamicRoles})
}
if counts.GCPRolesets > 0 {
details = append(details, map[string]interface{}{"type": "gcp_dynamic", "count": counts.GCPRolesets})
}
if counts.LDAPDynamicRoles > 0 {
details = append(details, map[string]interface{}{"type": "ldap_dynamic", "count": counts.LDAPDynamicRoles})
}
if counts.OpenLDAPDynamicRoles > 0 {
details = append(details, map[string]interface{}{"type": "openldap_dynamic", "count": counts.OpenLDAPDynamicRoles})
}
if counts.AlicloudDynamicRoles > 0 {
details = append(details, map[string]interface{}{"type": "alicloud_dynamic", "count": counts.AlicloudDynamicRoles})
}
if counts.RabbitMQDynamicRoles > 0 {
details = append(details, map[string]interface{}{"type": "rabbitmq_dynamic", "count": counts.RabbitMQDynamicRoles})
}
if counts.ConsulDynamicRoles > 0 {
details = append(details, map[string]interface{}{"type": "consul_dynamic", "count": counts.ConsulDynamicRoles})
}
if counts.NomadDynamicRoles > 0 {
details = append(details, map[string]interface{}{"type": "nomad_dynamic", "count": counts.NomadDynamicRoles})
}
if counts.KubernetesDynamicRoles > 0 {
details = append(details, map[string]interface{}{"type": "kubernetes_dynamic", "count": counts.KubernetesDynamicRoles})
}
if counts.MongoDBAtlasDynamicRoles > 0 {
details = append(details, map[string]interface{}{"type": "mongodbatlas_dynamic", "count": counts.MongoDBAtlasDynamicRoles})
}
if counts.TerraformCloudDynamicRoles > 0 {
details = append(details, map[string]interface{}{"type": "terraform_dynamic", "count": counts.TerraformCloudDynamicRoles})
}
}
return map[string]interface{}{
"metric_name": "dynamic_roles",
"metric_data": map[string]interface{}{
"total": total,
"metric_details": details,
},
}
}
// buildAutoRotatedRolesMetric creates the auto_rotated_roles metric from role counts.
func buildAutoRotatedRolesMetric(counts *RoleCounts) map[string]interface{} {
total := 0
if counts != nil {
total = counts.AWSStaticRoles +
counts.AzureStaticRoles +
counts.DatabaseStaticRoles +
counts.GCPStaticAccounts +
counts.GCPImpersonatedAccounts +
counts.LDAPStaticRoles +
counts.OpenLDAPStaticRoles
}
details := []map[string]interface{}{}
if counts != nil {
if counts.AWSStaticRoles > 0 {
details = append(details, map[string]interface{}{"type": "aws_static", "count": counts.AWSStaticRoles})
}
if counts.AzureStaticRoles > 0 {
details = append(details, map[string]interface{}{"type": "azure_static", "count": counts.AzureStaticRoles})
}
if counts.DatabaseStaticRoles > 0 {
details = append(details, map[string]interface{}{"type": "database_static", "count": counts.DatabaseStaticRoles})
}
if counts.GCPStaticAccounts > 0 {
details = append(details, map[string]interface{}{"type": "gcp_static", "count": counts.GCPStaticAccounts})
}
if counts.GCPImpersonatedAccounts > 0 {
details = append(details, map[string]interface{}{"type": "gcp_impersonated", "count": counts.GCPImpersonatedAccounts})
}
if counts.LDAPStaticRoles > 0 {
details = append(details, map[string]interface{}{"type": "ldap_static", "count": counts.LDAPStaticRoles})
}
if counts.OpenLDAPStaticRoles > 0 {
details = append(details, map[string]interface{}{"type": "openldap_static", "count": counts.OpenLDAPStaticRoles})
}
}
return map[string]interface{}{
"metric_name": "auto_rotated_roles",
"metric_data": map[string]interface{}{
"total": total,
"metric_details": details,
},
}
}
// buildPkiBillingMetric creates the billing metric for PKI duration-adjusted certificate counts.
func (b *SystemBackend) buildPkiBillingMetric(ctx context.Context, month time.Time) (map[string]interface{}, error) {
count, err := b.Core.GetStoredPkiDurationAdjustedCount(ctx, month)
if err != nil {
return nil, fmt.Errorf("error retrieving PKI duration-adjusted count for month: %w", err)
@ -199,3 +345,157 @@ func (b *SystemBackend) generatePkiBillingMetric(ctx context.Context, month time
},
}, nil
}
// getRoleCounts retrieves and combines role and managed key counts from replicated and local storage
func (c *Core) getRoleAndManagedKeyCounts(ctx context.Context, month time.Time, updateCounts bool) (*RoleCounts, int, error) {
var replicatedRoleCounts *RoleCounts
var replicatedManagedKeyCounts *ManagedKeyCounts
replicatedTotpHWMValue := 0
var err error
if c.isPrimary() {
if updateCounts {
replicatedRoleCounts, replicatedManagedKeyCounts, err = c.UpdateMaxRoleAndManagedKeyCounts(ctx, billing.ReplicatedPrefix, month)
if err != nil {
return nil, 0, fmt.Errorf("error updating replicated max role and managed key counts: %w", err)
}
replicatedTotpHWMValue = replicatedManagedKeyCounts.TotpKeys
} else {
replicatedRoleCounts, err = c.GetStoredHWMRoleCounts(ctx, billing.ReplicatedPrefix, month)
if err != nil {
return nil, 0, fmt.Errorf("error retrieving replicated max role counts: %w", err)
}
replicatedTotpHWMValue, err = c.GetStoredHWMTotpCounts(ctx, billing.ReplicatedPrefix, month)
if err != nil {
return nil, 0, fmt.Errorf("error retrieving replicated max managed key count: %w", err)
}
}
}
var localRoleCounts *RoleCounts
var localManagedKeyCounts *ManagedKeyCounts
localTotpHWMValue := 0
if updateCounts {
localRoleCounts, localManagedKeyCounts, err = c.UpdateMaxRoleAndManagedKeyCounts(ctx, billing.LocalPrefix, month)
if err != nil {
return nil, 0, fmt.Errorf("error updating local max role and managed key counts: %w", err)
}
localTotpHWMValue = localManagedKeyCounts.TotpKeys
} else {
localRoleCounts, err = c.GetStoredHWMRoleCounts(ctx, billing.LocalPrefix, month)
if err != nil {
return nil, 0, fmt.Errorf("error retrieving local max role counts: %w", err)
}
localTotpHWMValue, err = c.GetStoredHWMTotpCounts(ctx, billing.LocalPrefix, month)
if err != nil {
return nil, 0, fmt.Errorf("error retrieving local max totp key count: %w", err)
}
}
return combineRoleCounts(replicatedRoleCounts, localRoleCounts), localTotpHWMValue + replicatedTotpHWMValue, nil
}
// getKvCounts retrieves and combines KV secret counts from replicated and local storage
func (c *Core) getKvCounts(ctx context.Context, month time.Time, updateCounts bool) (int, error) {
var replicatedKvCounts int
var err error
if c.isPrimary() {
if updateCounts {
replicatedKvCounts, err = c.UpdateMaxKvCounts(ctx, billing.ReplicatedPrefix, month)
if err != nil {
return 0, fmt.Errorf("error updating replicated max kv counts: %w", err)
}
} else {
replicatedKvCounts, err = c.GetStoredHWMKvCounts(ctx, billing.ReplicatedPrefix, month)
if err != nil {
return 0, fmt.Errorf("error retrieving replicated max kv counts: %w", err)
}
}
}
var localKvCounts int
if updateCounts {
localKvCounts, err = c.UpdateMaxKvCounts(ctx, billing.LocalPrefix, month)
if err != nil {
return 0, fmt.Errorf("error updating local max kv counts: %w", err)
}
} else {
localKvCounts, err = c.GetStoredHWMKvCounts(ctx, billing.LocalPrefix, month)
if err != nil {
return 0, fmt.Errorf("error retrieving local max kv counts: %w", err)
}
}
return replicatedKvCounts + localKvCounts, nil
}
// getDataProtectionCounts retrieves Transit and Transform call counts
// Data protection call counts are stored at local path only
// Each cluster tracks its own total requests to avoid double counting
func (c *Core) getDataProtectionCounts(ctx context.Context, month time.Time, updateCounts bool) (uint64, uint64, error) {
var transitCounts, transformCounts uint64
var err error
if updateCounts {
transitCounts, err = c.UpdateTransitCallCounts(ctx, month)
if err != nil {
return 0, 0, fmt.Errorf("error updating local transit call counts: %w", err)
}
transformCounts, err = c.UpdateTransformCallCounts(ctx, month)
if err != nil {
return 0, 0, fmt.Errorf("error updating local transform call counts: %w", err)
}
} else {
transitCounts, err = c.GetStoredTransitCallCounts(ctx, month)
if err != nil {
return 0, 0, fmt.Errorf("error retrieving local transit call counts: %w", err)
}
transformCounts, err = c.GetStoredTransformCallCounts(ctx, month)
if err != nil {
return 0, 0, fmt.Errorf("error retrieving local transform call counts: %w", err)
}
}
return transitCounts, transformCounts, nil
}
// getKmipStatus retrieves KMIP enabled status (always stored at local path)
func (c *Core) getKmipStatus(ctx context.Context, month time.Time, updateCounts bool) (bool, error) {
var kmipEnabled bool
var err error
if updateCounts {
kmipEnabled, err = c.UpdateKmipEnabled(ctx, month)
if err != nil {
return false, fmt.Errorf("error updating KMIP enabled status: %w", err)
}
} else {
kmipEnabled, err = c.GetStoredKmipEnabled(ctx, month)
if err != nil {
return false, fmt.Errorf("error retrieving KMIP enabled status: %w", err)
}
}
return kmipEnabled, nil
}
// getThirdPartyPluginCounts retrieves third-party plugin counts (always stored at local path)
func (c *Core) getThirdPartyPluginCounts(ctx context.Context, month time.Time, updateCounts bool) (int, error) {
var thirdPartyPluginCounts int
var err error
if updateCounts {
thirdPartyPluginCounts, err = c.UpdateMaxThirdPartyPluginCounts(ctx, month)
if err != nil {
return 0, fmt.Errorf("error updating third-party plugin counts: %w", err)
}
} else {
thirdPartyPluginCounts, err = c.GetStoredThirdPartyPluginCounts(ctx, month)
if err != nil {
return 0, fmt.Errorf("error retrieving third-party plugin counts: %w", err)
}
}
return thirdPartyPluginCounts, nil
}

View file

@ -20,7 +20,7 @@ func TestGeneratePkiBillingMetric(t *testing.T) {
currentMonth := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
t.Run("returns zero count when no data exists", func(t *testing.T) {
overview, err := backend.generatePkiBillingMetric(ctx, currentMonth)
overview, err := backend.buildPkiBillingMetric(ctx, currentMonth)
require.NoError(t, err)
require.NotNil(t, overview)
@ -43,7 +43,7 @@ func TestGeneratePkiBillingMetric(t *testing.T) {
require.NoError(t, err)
// Generate overview
overview, err := backend.generatePkiBillingMetric(ctx, month)
overview, err := backend.buildPkiBillingMetric(ctx, month)
require.NoError(t, err)
require.NotNil(t, overview)
@ -72,13 +72,13 @@ func TestGeneratePkiBillingMetric(t *testing.T) {
require.NoError(t, err)
// Generate overview for month1
overview1, err := backend.generatePkiBillingMetric(ctx, month1)
overview1, err := backend.buildPkiBillingMetric(ctx, month1)
require.NoError(t, err)
metricData1 := overview1["metric_data"].(map[string]interface{})
require.Equal(t, count1, metricData1["total"])
// Generate overview for month2
overview2, err := backend.generatePkiBillingMetric(ctx, month2)
overview2, err := backend.buildPkiBillingMetric(ctx, month2)
require.NoError(t, err)
metricData2 := overview2["metric_data"].(map[string]interface{})
require.Equal(t, count2, metricData2["total"])
@ -87,7 +87,7 @@ func TestGeneratePkiBillingMetric(t *testing.T) {
t.Run("uses constant for metric name", func(t *testing.T) {
month := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
overview, err := backend.generatePkiBillingMetric(ctx, month)
overview, err := backend.buildPkiBillingMetric(ctx, month)
require.NoError(t, err)
// Verify it uses the constant pkiDurationAjustedCountMetricName

View file

@ -0,0 +1,658 @@
// Copyright IBM Corp. 2016, 2025
// SPDX-License-Identifier: MPL-2.0
package vault
import (
"context"
"testing"
"time"
logicalKv "github.com/hashicorp/vault-plugin-secrets-kv"
logicalAws "github.com/hashicorp/vault/builtin/logical/aws"
logicalDatabase "github.com/hashicorp/vault/builtin/logical/database"
logicalTransit "github.com/hashicorp/vault/builtin/logical/transit"
"github.com/hashicorp/vault/helper/namespace"
"github.com/hashicorp/vault/helper/pluginconsts"
"github.com/hashicorp/vault/helper/timeutil"
"github.com/hashicorp/vault/sdk/logical"
"github.com/hashicorp/vault/vault/billing"
"github.com/stretchr/testify/require"
)
// TestSystemBackend_BillingOverview verifies that the billing overview endpoint
// returns the correct response structure with current and previous month data.
// It validates the response format, month strings (YYYY-MM), RFC3339 timestamps,
// and ensures both months are present in the response.
func TestSystemBackend_BillingOverview(t *testing.T) {
_, b, _ := testCoreSystemBackend(t)
ctx := namespace.RootContext(nil)
// Make a request to the billing overview endpoint
req := logical.TestRequest(t, logical.ReadOperation, "billing/overview")
resp, err := b.HandleRequest(ctx, req)
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, resp.Data)
// Verify the response structure
months, ok := resp.Data["months"].([]interface{})
require.True(t, ok, "months should be a slice")
require.Len(t, months, 2, "should have current and previous month")
// Verify current month structure
currentMonth, ok := months[0].(map[string]interface{})
require.True(t, ok, "current month should be a map")
require.Contains(t, currentMonth, "month")
require.Contains(t, currentMonth, "updated_at")
require.Contains(t, currentMonth, "usage_metrics")
// Verify month format (YYYY-MM)
monthStr, ok := currentMonth["month"].(string)
require.True(t, ok)
require.Regexp(t, `^\d{4}-\d{2}$`, monthStr)
// Verify updated_at format (RFC3339)
updatedAt, ok := currentMonth["updated_at"].(string)
require.True(t, ok)
_, err = time.Parse(time.RFC3339, updatedAt)
require.NoError(t, err, "updated_at should be valid RFC3339 timestamp")
// Verify usage_metrics is a slice
_, ok = currentMonth["usage_metrics"].([]map[string]interface{})
require.True(t, ok, "usage_metrics should be a slice of maps")
// Verify previous month structure
previousMonth, ok := months[1].(map[string]interface{})
require.True(t, ok, "previous month should be a map")
require.Contains(t, previousMonth, "month")
require.Contains(t, previousMonth, "updated_at")
require.Contains(t, previousMonth, "usage_metrics")
// Verify that current month is actually current
now := time.Now()
expectedCurrentMonth := now.Format("2006-01")
require.Equal(t, expectedCurrentMonth, monthStr)
// Verify that previous month is actually previous
prevMonthStr, ok := previousMonth["month"].(string)
require.True(t, ok)
expectedPreviousMonth := timeutil.StartOfPreviousMonth(now).Format("2006-01")
require.Equal(t, expectedPreviousMonth, prevMonthStr)
}
// TestSystemBackend_BillingOverview_WithMetrics tests the billing overview endpoint
// with actual KV secrets created to generate billing metrics. It verifies that KV v2
// secrets are properly counted in billing, the static_secrets metric appears in the
// response, the metric_data structure contains total and metric_details and the
// metric_details include the correct type and count.
func TestSystemBackend_BillingOverview_WithMetrics(t *testing.T) {
c, b, root := testCoreSystemBackend(t)
ctx := namespace.RootContext(nil)
// Create some KV secrets to generate metrics
req := logical.TestRequest(t, logical.UpdateOperation, "mounts/testkv")
req.Data = map[string]interface{}{
"type": "kv-v2",
}
resp, err := b.HandleRequest(ctx, req)
require.NoError(t, err)
require.Nil(t, resp)
kvReq := logical.TestRequest(t, logical.CreateOperation, "testkv/data/test")
kvReq.Data["data"] = map[string]interface{}{
"foo": "bar",
}
kvReq.ClientToken = root
kvResp, err := c.HandleRequest(ctx, kvReq)
require.NoError(t, err)
require.NotNil(t, kvResp)
currentMonth := time.Now()
_, err = c.UpdateMaxKvCounts(ctx, billing.LocalPrefix, currentMonth)
require.NoError(t, err)
req = logical.TestRequest(t, logical.ReadOperation, "billing/overview")
req.Data["refresh_data"] = true
resp, err = b.HandleRequest(ctx, req)
require.NoError(t, err)
require.NotNil(t, resp)
// Verify the response contains metrics
months, ok := resp.Data["months"].([]interface{})
require.True(t, ok)
require.Len(t, months, 2)
currentMonthData, ok := months[0].(map[string]interface{})
require.True(t, ok)
usageMetrics, ok := currentMonthData["usage_metrics"].([]map[string]interface{})
require.True(t, ok)
// Check if static_secrets metric exists
foundStaticSecrets := false
for _, metric := range usageMetrics {
if metricName, ok := metric["metric_name"].(string); ok && metricName == "static_secrets" {
foundStaticSecrets = true
// Verify metric_data structure
metricData, ok := metric["metric_data"].(map[string]interface{})
require.True(t, ok)
require.Contains(t, metricData, "total")
require.Contains(t, metricData, "metric_details")
// Verify total is greater than 0
total, ok := metricData["total"].(int)
require.True(t, ok)
require.Greater(t, total, 0)
// Verify metric_details structure
metricDetails, ok := metricData["metric_details"].([]map[string]interface{})
require.True(t, ok)
require.NotEmpty(t, metricDetails)
// Verify first detail has type and count
firstDetail := metricDetails[0]
require.Contains(t, firstDetail, "type")
require.Contains(t, firstDetail, "count")
require.Equal(t, "kv", firstDetail["type"])
break
}
}
require.True(t, foundStaticSecrets, "static_secrets metric should be present")
}
// TestSystemBackend_BillingOverview_MetricFormats validates that different metric types
// in the billing overview response have the correct data structure.
func TestSystemBackend_BillingOverview_MetricFormats(t *testing.T) {
c, _, root, _ := TestCoreUnsealedWithMetricsAndConfig(t, &CoreConfig{
LogicalBackends: map[string]logical.Factory{
pluginconsts.SecretEngineKV: logicalKv.Factory,
pluginconsts.SecretEngineAWS: logicalAws.Factory,
pluginconsts.SecretEngineDatabase: logicalDatabase.Factory,
pluginconsts.SecretEngineTransit: logicalTransit.Factory,
},
})
b := c.systemBackend
ctx := namespace.RootContext(context.Background())
// Create KV secrets for static_secrets metric
req := logical.TestRequest(t, logical.UpdateOperation, "mounts/kv-v1")
req.Data = map[string]interface{}{"type": "kv-v1"}
resp, err := b.HandleRequest(ctx, req)
require.NoError(t, err)
require.Nil(t, resp)
req = logical.TestRequest(t, logical.UpdateOperation, "mounts/kv-v2")
req.Data = map[string]interface{}{"type": "kv-v2"}
resp, err = b.HandleRequest(ctx, req)
require.NoError(t, err)
require.Nil(t, resp)
addKvSecretToStorage(t, ctx, c, "kv-v1", root, "secret1", "kv-v1")
addKvSecretToStorage(t, ctx, c, "kv-v2", root, "secret2", "kv-v2")
// Create roles for dynamic_roles and auto_rotated_roles metrics
req = logical.TestRequest(t, logical.UpdateOperation, "mounts/aws")
req.Data = map[string]interface{}{"type": "aws"}
resp, err = b.HandleRequest(ctx, req)
require.NoError(t, err)
require.Nil(t, resp)
addRoleToStorage(t, c, "aws", "role/", 2)
addRoleToStorage(t, c, "aws", "static-roles/", 1)
req = logical.TestRequest(t, logical.UpdateOperation, "mounts/database")
req.Data = map[string]interface{}{"type": "database"}
resp, err = b.HandleRequest(ctx, req)
require.NoError(t, err)
require.Nil(t, resp)
addRoleToStorage(t, c, "database", "role/", 1)
addRoleToStorage(t, c, "database", "static-role/", 1)
// Mount transit backend
req = logical.TestRequest(t, logical.CreateOperation, "sys/mounts/transit")
req.Data["type"] = "transit"
req.ClientToken = root
_, err = c.HandleRequest(ctx, req)
require.NoError(t, err)
// Create an encryption key
req = logical.TestRequest(t, logical.CreateOperation, "transit/keys/foo")
req.Data["type"] = "aes256-gcm96"
req.ClientToken = root
_, err = c.HandleRequest(ctx, req)
require.NoError(t, err)
// Perform encryption on the key
req = logical.TestRequest(t, logical.UpdateOperation, "transit/encrypt/foo")
req.Data["plaintext"] = "dGhlIHF1aWNrIGJyb3duIGZveA=="
req.ClientToken = root
_, err = c.HandleRequest(ctx, req)
require.NoError(t, err)
// Update all metrics
currentMonth := time.Now()
_, err = c.UpdateMaxKvCounts(ctx, billing.LocalPrefix, currentMonth)
require.NoError(t, err)
_, _, err = c.UpdateMaxRoleAndManagedKeyCounts(ctx, billing.LocalPrefix, currentMonth)
require.NoError(t, err)
_, err = c.UpdateTransitCallCounts(ctx, currentMonth)
require.NoError(t, err)
// Make a request to the billing overview endpoint
req = logical.TestRequest(t, logical.ReadOperation, "billing/overview")
req.Data["refresh_data"] = true
resp, err = b.HandleRequest(ctx, req)
require.NoError(t, err)
require.NotNil(t, resp)
months, ok := resp.Data["months"].([]interface{})
require.True(t, ok)
require.Len(t, months, 2)
currentMonthData, ok := months[0].(map[string]interface{})
require.True(t, ok)
usageMetrics, ok := currentMonthData["usage_metrics"].([]map[string]interface{})
require.True(t, ok)
require.NotEmpty(t, usageMetrics, "usage_metrics should not be empty after creating test data")
metricsFound := make(map[string]bool)
// Verify each metric has the correct structure
for _, metric := range usageMetrics {
metricName, ok := metric["metric_name"].(string)
require.True(t, ok, "metric_name should be a string")
require.NotEmpty(t, metricName)
metricsFound[metricName] = true
metricData, ok := metric["metric_data"].(map[string]interface{})
require.True(t, ok, "metric_data should be a map")
require.NotEmpty(t, metricData)
// Different metrics have different structures
switch metricName {
case "static_secrets":
// Should have total and metric_details
require.Contains(t, metricData, "total")
require.Contains(t, metricData, "metric_details")
total, ok := metricData["total"].(int)
require.True(t, ok)
require.Equal(t, total, 2)
metricDetails, ok := metricData["metric_details"].([]map[string]interface{})
require.True(t, ok)
require.NotEmpty(t, metricDetails)
for _, detail := range metricDetails {
require.Contains(t, detail, "type")
require.Contains(t, detail, "count")
count, ok := detail["count"].(int)
require.True(t, ok)
require.Equal(t, count, 2)
}
case "dynamic_roles":
// Should have total and metric_details
require.Contains(t, metricData, "total")
require.Contains(t, metricData, "metric_details")
total, ok := metricData["total"].(int)
require.True(t, ok)
require.Equal(t, total, 3)
metricDetails, ok := metricData["metric_details"].([]map[string]interface{})
require.True(t, ok)
require.NotEmpty(t, metricDetails)
for _, detail := range metricDetails {
require.Contains(t, detail, "type")
require.Contains(t, detail, "count")
}
case "auto_rotated_roles":
// Should have total and metric_details
require.Contains(t, metricData, "total")
require.Contains(t, metricData, "metric_details")
total, ok := metricData["total"].(int)
require.True(t, ok)
require.Equal(t, total, 2)
metricDetails, ok := metricData["metric_details"].([]map[string]interface{})
require.True(t, ok)
require.NotEmpty(t, metricDetails)
for _, detail := range metricDetails {
require.Contains(t, detail, "type")
require.Contains(t, detail, "count")
}
case "data_protection_calls":
require.Contains(t, metricData, "total")
total, ok := metricData["total"].(uint64)
require.True(t, ok)
require.Equal(t, total, uint64(1))
require.Contains(t, metricData, "metric_details")
metricDetails, ok := metricData["metric_details"].([]map[string]interface{})
require.True(t, ok, "metric_details should be []map[string]interface{}")
require.NotEmpty(t, metricDetails)
foundTransit := false
for _, detail := range metricDetails {
if detail["type"] == "transit" {
foundTransit = true
count, ok := detail["count"].(uint64)
require.True(t, ok)
require.Equal(t, count, uint64(1))
}
}
require.True(t, foundTransit, "should have transit type in metric_details")
case "pki_units":
require.Contains(t, metricData, "total")
total, ok := metricData["total"].(float64)
require.True(t, ok, "pki_units total should be float64")
require.GreaterOrEqual(t, total, float64(0))
case "managed_keys":
require.Contains(t, metricData, "total")
total, ok := metricData["total"].(int)
require.True(t, ok, "pki_units total should be float64")
require.GreaterOrEqual(t, total, 0)
require.Contains(t, metricData, "metric_details")
}
}
// Verify we found the expected metrics
require.True(t, metricsFound["static_secrets"], "should have static_secrets metric")
require.True(t, metricsFound["dynamic_roles"], "should have dynamic_roles metric")
require.True(t, metricsFound["auto_rotated_roles"], "should have auto_rotated_roles metric")
require.True(t, metricsFound["data_protection_calls"], "should have data_protection_calls metric")
require.True(t, metricsFound["pki_units"], "should have pki_units metric")
require.True(t, metricsFound["managed_keys"], "should have managed_keys metric")
}
// TestSystemBackend_BillingOverview_PreviousMonth verifies that the billing overview
// endpoint correctly retrieves and formats data for the previous month. It stores
// billing data for the previous month, validates the previous month string format,
// enures the updated_at timestamp is set to the end of the previous month, and confirms
// the previous month data is included in the response.
func TestSystemBackend_BillingOverview_PreviousMonth(t *testing.T) {
c, b, _ := testCoreSystemBackend(t)
ctx := namespace.RootContext(nil)
// Store some data for previous month
previousMonth := timeutil.StartOfPreviousMonth(time.Now())
// Manually store some counts for previous month
c.consumptionBilling.BillingStorageLock.Lock()
err := c.storeMaxKvCountsLocked(ctx, 5, "local/", previousMonth)
c.consumptionBilling.BillingStorageLock.Unlock()
require.NoError(t, err)
// Make a request to the billing overview endpoint
req := logical.TestRequest(t, logical.ReadOperation, "billing/overview")
resp, err := b.HandleRequest(ctx, req)
require.NoError(t, err)
require.NotNil(t, resp)
months, ok := resp.Data["months"].([]interface{})
require.True(t, ok)
require.Len(t, months, 2)
// Check previous month data
previousMonthData, ok := months[1].(map[string]interface{})
require.True(t, ok)
monthStr, ok := previousMonthData["month"].(string)
require.True(t, ok)
expectedMonth := previousMonth.Format("2006-01")
require.Equal(t, expectedMonth, monthStr)
// Verify updated_at is end of previous month
updatedAt, ok := previousMonthData["updated_at"].(string)
require.True(t, ok)
parsedTime, err := time.Parse(time.RFC3339, updatedAt)
require.NoError(t, err)
// The updated_at for previous month should be at the end of that month
expectedEndOfMonth := timeutil.StartOfMonth(previousMonth.AddDate(0, 1, 0)).Add(-time.Second)
require.WithinDuration(t, expectedEndOfMonth, parsedTime, time.Minute)
}
// TestSystemBackend_BillingOverview_EmptyMetrics verifies that the billing overview
// endpoint returns all metrics with zero values when no billing data exists.
func TestSystemBackend_BillingOverview_EmptyMetrics(t *testing.T) {
_, b, _ := testCoreSystemBackend(t)
ctx := namespace.RootContext(nil)
// Make a request without creating any billable resources
req := logical.TestRequest(t, logical.ReadOperation, "billing/overview")
resp, err := b.HandleRequest(ctx, req)
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, resp.Data)
// Verify the response structure exists
months, ok := resp.Data["months"].([]interface{})
require.True(t, ok)
require.Len(t, months, 2)
// Check current month has all metrics with zero values
currentMonth, ok := months[0].(map[string]interface{})
require.True(t, ok)
require.Contains(t, currentMonth, "usage_metrics")
usageMetrics, ok := currentMonth["usage_metrics"].([]map[string]interface{})
require.True(t, ok)
require.NotNil(t, usageMetrics)
require.NotEmpty(t, usageMetrics, "usage_metrics should contain all metrics even with zero values")
// Verify all expected metrics are present with zero/false values
expectedMetrics := map[string]bool{
"static_secrets": false,
"dynamic_roles": false,
"auto_rotated_roles": false,
"kmip": false,
"external_plugins": false,
"data_protection_calls": false,
"pki_units": false,
"managed_keys": false,
}
for _, metric := range usageMetrics {
metricName, ok := metric["metric_name"].(string)
require.True(t, ok, "metric_name should be a string")
require.Contains(t, expectedMetrics, metricName, "unexpected metric: %s", metricName)
expectedMetrics[metricName] = true
metricData, ok := metric["metric_data"].(map[string]interface{})
require.True(t, ok, "metric_data should be a map")
// Verify each metric has appropriate zero value
switch metricName {
case "static_secrets", "dynamic_roles", "auto_rotated_roles":
total, ok := metricData["total"].(int)
require.True(t, ok, "%s total should be int", metricName)
require.Equal(t, 0, total, "%s total should be 0", metricName)
details, ok := metricData["metric_details"].([]map[string]interface{})
require.True(t, ok, "%s metric_details should be array", metricName)
require.Empty(t, details, "%s metric_details should be empty when total is 0", metricName)
case "kmip":
used, ok := metricData["used_in_month"].(bool)
require.True(t, ok, "kmip used_in_month should be bool")
require.False(t, used, "kmip should be false when not used")
case "external_plugins":
total, ok := metricData["total"].(int)
require.True(t, ok, "external_plugins total should be int")
require.Equal(t, 0, total, "external_plugins total should be 0")
case "data_protection_calls":
total, ok := metricData["total"].(uint64)
require.True(t, ok, "data_protection_calls total should be uint64")
require.Equal(t, uint64(0), total, "data_protection_calls total should be 0")
details, ok := metricData["metric_details"].([]map[string]interface{})
require.True(t, ok, "data_protection_calls metric_details should be array")
require.Empty(t, details, "data_protection_calls metric_details should be empty when total is 0")
case "pki_units":
total, ok := metricData["total"].(float64)
require.True(t, ok, "pki_units total should be float64")
require.Equal(t, float64(0), total, "data_protection_calls total should be 0")
case "managed_keys":
total, ok := metricData["total"].(int)
require.True(t, ok, "managed_keys total should be float64")
require.Equal(t, int(0), total, "data_protection_calls total should be 0")
details, ok := metricData["metric_details"].([]map[string]interface{})
require.True(t, ok, "%s metric_details should be array", metricName)
require.Empty(t, details, "%s metric_details should be empty when total is 0", metricName)
}
}
// Verify all expected metrics were found
for metricName, found := range expectedMetrics {
require.True(t, found, "metric %s should be present", metricName)
}
}
// TestSystemBackend_BillingOverview_MultipleMetricTypes tests the billing overview
// endpoint with multiple different metric types to ensure they all appear correctly
// in the response with their respective data structures.
func TestSystemBackend_BillingOverview_MultipleMetricTypes(t *testing.T) {
c, b, root := testCoreSystemBackend(t)
ctx := namespace.RootContext(nil)
currentMonth := time.Now()
// Create KV secrets
req := logical.TestRequest(t, logical.UpdateOperation, "mounts/testkv")
req.Data = map[string]interface{}{
"type": "kv-v2",
}
resp, err := b.HandleRequest(ctx, req)
require.NoError(t, err)
require.Nil(t, resp)
kvReq := logical.TestRequest(t, logical.CreateOperation, "testkv/data/test")
kvReq.Data["data"] = map[string]interface{}{"foo": "bar"}
kvReq.ClientToken = root
_, err = c.HandleRequest(ctx, kvReq)
require.NoError(t, err)
_, err = c.UpdateMaxKvCounts(ctx, billing.LocalPrefix, currentMonth)
require.NoError(t, err)
// Make request to billing overview
req = logical.TestRequest(t, logical.ReadOperation, "billing/overview")
req.Data["refresh_data"] = true
resp, err = b.HandleRequest(ctx, req)
require.NoError(t, err)
require.NotNil(t, resp)
months, ok := resp.Data["months"].([]interface{})
require.True(t, ok)
require.Len(t, months, 2)
currentMonthData, ok := months[0].(map[string]interface{})
require.True(t, ok)
usageMetrics, ok := currentMonthData["usage_metrics"].([]map[string]interface{})
require.True(t, ok)
require.NotEmpty(t, usageMetrics)
// Verify each metric has proper structure
for _, metric := range usageMetrics {
metricName, ok := metric["metric_name"].(string)
require.True(t, ok, "metric_name should be a string")
require.NotEmpty(t, metricName, "metric_name should not be empty")
metricData, ok := metric["metric_data"].(map[string]interface{})
require.True(t, ok, "metric_data should be a map")
require.NotEmpty(t, metricData, "metric_data should not be empty")
}
// Verify we have at least the static_secrets metric from our KV secret
foundStaticSecrets := false
for _, metric := range usageMetrics {
if metricName, ok := metric["metric_name"].(string); ok && metricName == "static_secrets" {
foundStaticSecrets = true
break
}
}
require.True(t, foundStaticSecrets, "should have static_secrets metric from KV secret")
}
// TestSystemBackend_BillingOverview_UpdatedAtTimestamp verifies that the updated_at
// timestamp behaves correctly based on whether data was refreshed.
func TestSystemBackend_BillingOverview_UpdatedAtTimestamp(t *testing.T) {
c, b, _ := testCoreSystemBackend(t)
ctx := namespace.RootContext(nil)
// First, call with refresh_data set to set the LastMetricsUpdate timestamp
req := logical.TestRequest(t, logical.ReadOperation, "billing/overview")
req.Data["refresh_data"] = true
resp, err := b.HandleRequest(ctx, req)
require.NoError(t, err)
require.NotNil(t, resp)
months, ok := resp.Data["months"].([]interface{})
require.True(t, ok)
require.Len(t, months, 2)
currentMonth, ok := months[0].(map[string]interface{})
require.True(t, ok)
// Get the updated_at timestamp from the first call
firstUpdatedAt, ok := currentMonth["updated_at"].(string)
require.True(t, ok)
firstTime, err := time.Parse(time.RFC3339, firstUpdatedAt)
require.NoError(t, err)
// Verify LastMetricsUpdate was set
lastUpdate := c.consumptionBilling.LastMetricsUpdate.Load()
require.NotNil(t, lastUpdate, "LastMetricsUpdate should be set after refresh")
storedTime, ok := lastUpdate.(time.Time)
require.True(t, ok)
require.WithinDuration(t, firstTime, storedTime, time.Second, "stored timestamp should match response timestamp")
// Wait a moment to ensure time difference
time.Sleep(100 * time.Millisecond)
// Now call without refresh_data
req = logical.TestRequest(t, logical.ReadOperation, "billing/overview")
req.Data["refresh_data"] = false
resp, err = b.HandleRequest(ctx, req)
require.NoError(t, err)
require.NotNil(t, resp)
months, ok = resp.Data["months"].([]interface{})
require.True(t, ok)
require.Len(t, months, 2)
currentMonth, ok = months[0].(map[string]interface{})
require.True(t, ok)
// Get the updated_at timestamp from the second call
secondUpdatedAt, ok := currentMonth["updated_at"].(string)
require.True(t, ok)
secondTime, err := time.Parse(time.RFC3339, secondUpdatedAt)
require.NoError(t, err)
// The timestamp should be the same as the first call because we didn't refresh the data
require.WithinDuration(t, firstTime, secondTime, time.Second,
"updated_at without refresh should use stored LastMetricsUpdate timestamp")
// Verify the timestamps are equal
require.Equal(t, firstUpdatedAt, secondUpdatedAt,
"updated_at without refresh should be identical to the stored timestamp")
}