diff --git a/changelog/25751.txt b/changelog/25751.txt new file mode 100644 index 000000000000..cfde6d9de06e --- /dev/null +++ b/changelog/25751.txt @@ -0,0 +1,3 @@ +```release-note:improvement +cli: include secret syncs counts in the `vault operator usage` command output +``` \ No newline at end of file diff --git a/command/command_testonly/operator_usage_testonly_test.go b/command/command_testonly/operator_usage_testonly_test.go new file mode 100644 index 000000000000..47bec03720d0 --- /dev/null +++ b/command/command_testonly/operator_usage_testonly_test.go @@ -0,0 +1,97 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +//go:build testonly + +package command_testonly + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/hashicorp/cli" + "github.com/hashicorp/vault/command" + "github.com/hashicorp/vault/helper/timeutil" + vaulthttp "github.com/hashicorp/vault/http" + "github.com/hashicorp/vault/sdk/helper/clientcountutil" + "github.com/hashicorp/vault/sdk/helper/clientcountutil/generation" + "github.com/hashicorp/vault/vault" + "github.com/stretchr/testify/require" +) + +func testOperatorUsageCommand(tb testing.TB) (*cli.MockUi, *command.OperatorUsageCommand) { + tb.Helper() + + ui := cli.NewMockUi() + return ui, &command.OperatorUsageCommand{ + BaseCommand: &command.BaseCommand{ + UI: ui, + }, + } +} + +// TestOperatorUsageCommandRun writes mock activity log data and runs the +// operator usage command. The test verifies that the output contains the +// expected values per client type. +// This test cannot be run in parallel because it sets the VAULT_TOKEN env +// var +func TestOperatorUsageCommandRun(t *testing.T) { + cluster := vault.NewTestCluster(t, nil, &vault.TestClusterOptions{ + HandlerFunc: vaulthttp.Handler, + NumCores: 1, + }) + defer cluster.Cleanup() + core := cluster.Cores[0].Core + vault.TestWaitActive(t, core) + + client := cluster.Cores[0].Client + _, err := client.Logical().Write("sys/internal/counters/config", map[string]interface{}{"enabled": "enable"}) + require.NoError(t, err) + + now := time.Now().UTC() + + _, err = clientcountutil.NewActivityLogData(client). + NewPreviousMonthData(1). + NewClientsSeen(6, clientcountutil.WithClientType("entity")). + NewClientsSeen(4, clientcountutil.WithClientType("non-entity-token")). + NewClientsSeen(2, clientcountutil.WithClientType("secret-sync")). + NewCurrentMonthData(). + NewClientsSeen(3, clientcountutil.WithClientType("entity")). + NewClientsSeen(4, clientcountutil.WithClientType("non-entity-token")). + NewClientsSeen(5, clientcountutil.WithClientType("secret-sync")). + Write(context.Background(), generation.WriteOptions_WRITE_ENTITIES, generation.WriteOptions_WRITE_PRECOMPUTED_QUERIES) + require.NoError(t, err) + + ui, cmd := testOperatorUsageCommand(t) + + t.Setenv("VAULT_TOKEN", client.Token()) + start := timeutil.MonthsPreviousTo(1, now).Format(time.RFC3339) + end := timeutil.EndOfMonth(now).UTC().Format(time.RFC3339) + // Reset and check output + code := cmd.Run([]string{ + "-address", client.Address(), + "-tls-skip-verify", + "-start-time", start, + "-end-time", end, + }) + require.Equal(t, 0, code, ui.ErrorWriter.String()) + output := ui.OutputWriter.String() + outputLines := strings.Split(output, "\n") + require.Equal(t, fmt.Sprintf("Period start: %s", start), outputLines[0]) + require.Equal(t, fmt.Sprintf("Period end: %s", end), outputLines[1]) + + require.Contains(t, outputLines[3], "Secret sync") + nsCounts := strings.Fields(outputLines[5]) + require.Equal(t, "[root]", nsCounts[0]) + require.Equal(t, "9", nsCounts[1]) + require.Equal(t, "8", nsCounts[2]) + require.Equal(t, "7", nsCounts[3]) + require.Equal(t, "24", nsCounts[4]) + + totalCounts := strings.Fields(outputLines[7]) + require.Equal(t, "Total", totalCounts[0]) + require.Equal(t, nsCounts[1:], totalCounts[1:]) +} diff --git a/command/operator_usage.go b/command/operator_usage.go index 8fa0f8f41518..d6468d67ddbf 100644 --- a/command/operator_usage.go +++ b/command/operator_usage.go @@ -132,7 +132,7 @@ func (c *OperatorUsageCommand) Run(args []string) int { c.outputTimestamps(resp.Data) out := []string{ - "Namespace path | Distinct entities | Non-Entity tokens | Active clients", + "Namespace path | Distinct entities | Non-Entity tokens | Secret syncs | Active clients", } out = append(out, c.namespacesOutput(resp.Data)...) @@ -196,8 +196,8 @@ type UsageResponse struct { entityCount int64 // As per 1.9, the tokenCount field will contain the distinct non-entity // token clients instead of each individual token. - tokenCount int64 - + tokenCount int64 + secretSyncs int64 clientCount int64 } @@ -242,6 +242,9 @@ func (c *OperatorUsageCommand) parseNamespaceCount(rawVal interface{}) (UsageRes return ret, errors.New("missing non_entity_tokens") } + // don't error if the secret syncs key is missing + ret.secretSyncs, _ = jsonNumberOK(counts, "secret_syncs") + ret.clientCount, ok = jsonNumberOK(counts, "clients") if !ok { return ret, errors.New("missing clients") @@ -274,8 +277,8 @@ func (c *OperatorUsageCommand) namespacesOutput(data map[string]interface{}) []s sortOrder = "2" + val.namespacePath } - formattedLine := fmt.Sprintf("%s | %d | %d | %d", - val.namespacePath, val.entityCount, val.tokenCount, val.clientCount) + formattedLine := fmt.Sprintf("%s | %d | %d | %d | %d", + val.namespacePath, val.entityCount, val.tokenCount, val.secretSyncs, val.clientCount) nsOut = append(nsOut, UsageCommandNamespace{ formattedLine: formattedLine, sortOrder: sortOrder, @@ -296,7 +299,7 @@ func (c *OperatorUsageCommand) namespacesOutput(data map[string]interface{}) []s func (c *OperatorUsageCommand) totalOutput(data map[string]interface{}) []string { // blank line separating it from namespaces - out := []string{" | | | "} + out := []string{" | | | | "} total, ok := data["total"].(map[string]interface{}) if !ok { @@ -315,13 +318,16 @@ func (c *OperatorUsageCommand) totalOutput(data map[string]interface{}) []string c.UI.Error("missing non_entity_tokens in total") return out } + // don't error if secret syncs key is missing + secretSyncs, _ := jsonNumberOK(total, "secret_syncs") + clientCount, ok := jsonNumberOK(total, "clients") if !ok { c.UI.Error("missing clients in total") return out } - out = append(out, fmt.Sprintf("Total | %d | %d | %d", - entityCount, tokenCount, clientCount)) + out = append(out, fmt.Sprintf("Total | %d | %d | %d | %d", + entityCount, tokenCount, secretSyncs, clientCount)) return out }