| // Copyright (c) HashiCorp, Inc. |
| // SPDX-License-Identifier: MPL-2.0 |
| |
| package command |
| |
| import ( |
| "encoding/json" |
| "errors" |
| "fmt" |
| "sort" |
| "strings" |
| "time" |
| |
| "github.com/hashicorp/vault/api" |
| "github.com/mitchellh/cli" |
| "github.com/posener/complete" |
| "github.com/ryanuber/columnize" |
| ) |
| |
| var ( |
| _ cli.Command = (*OperatorUsageCommand)(nil) |
| _ cli.CommandAutocomplete = (*OperatorUsageCommand)(nil) |
| ) |
| |
| type OperatorUsageCommand struct { |
| *BaseCommand |
| flagStartTime time.Time |
| flagEndTime time.Time |
| } |
| |
| func (c *OperatorUsageCommand) Synopsis() string { |
| return "Lists historical client counts" |
| } |
| |
| func (c *OperatorUsageCommand) Help() string { |
| helpText := ` |
| Usage: vault operator usage |
| |
| List the client counts for the default reporting period. |
| |
| $ vault operator usage |
| |
| List the client counts for a specific time period. |
| |
| $ vault operator usage -start-time=2020-10 -end-time=2020-11 |
| |
| ` + c.Flags().Help() |
| |
| return strings.TrimSpace(helpText) |
| } |
| |
| func (c *OperatorUsageCommand) Flags() *FlagSets { |
| set := c.flagSet(FlagSetHTTP | FlagSetOutputFormat) |
| |
| f := set.NewFlagSet("Command Options") |
| |
| f.TimeVar(&TimeVar{ |
| Name: "start-time", |
| Usage: "Start of report period. Defaults to 'default_reporting_period' before end time.", |
| Target: &c.flagStartTime, |
| Completion: complete.PredictNothing, |
| Default: time.Time{}, |
| Formats: TimeVar_TimeOrDay | TimeVar_Month, |
| }) |
| f.TimeVar(&TimeVar{ |
| Name: "end-time", |
| Usage: "End of report period. Defaults to end of last month.", |
| Target: &c.flagEndTime, |
| Completion: complete.PredictNothing, |
| Default: time.Time{}, |
| Formats: TimeVar_TimeOrDay | TimeVar_Month, |
| }) |
| |
| return set |
| } |
| |
| func (c *OperatorUsageCommand) AutocompleteArgs() complete.Predictor { |
| return complete.PredictAnything |
| } |
| |
| func (c *OperatorUsageCommand) AutocompleteFlags() complete.Flags { |
| return c.Flags().Completions() |
| } |
| |
| func (c *OperatorUsageCommand) Run(args []string) int { |
| f := c.Flags() |
| |
| if err := f.Parse(args); err != nil { |
| c.UI.Error(err.Error()) |
| return 1 |
| } |
| |
| data := make(map[string][]string) |
| if !c.flagStartTime.IsZero() { |
| data["start_time"] = []string{c.flagStartTime.Format(time.RFC3339)} |
| } |
| if !c.flagEndTime.IsZero() { |
| data["end_time"] = []string{c.flagEndTime.Format(time.RFC3339)} |
| } |
| |
| client, err := c.Client() |
| if err != nil { |
| c.UI.Error(err.Error()) |
| return 2 |
| } |
| |
| resp, err := client.Logical().ReadWithData("sys/internal/counters/activity", data) |
| if err != nil { |
| c.UI.Error(fmt.Sprintf("Error retrieving client counts: %v", err)) |
| return 2 |
| } |
| |
| if resp == nil || resp.Data == nil { |
| if c.noReportAvailable(client) { |
| c.UI.Warn("Vault does not have any usage data available. A report will be available\n" + |
| "after the first calendar month in which monitoring is enabled.") |
| } else { |
| c.UI.Warn("No data is available for the given time range.") |
| } |
| // No further output |
| return 0 |
| } |
| |
| switch Format(c.UI) { |
| case "table": |
| default: |
| // Handle JSON, YAML, etc. |
| return OutputData(c.UI, resp) |
| } |
| |
| // Show this before the headers |
| c.outputTimestamps(resp.Data) |
| |
| out := []string{ |
| "Namespace path | Distinct entities | Non-Entity tokens | Active clients", |
| } |
| |
| out = append(out, c.namespacesOutput(resp.Data)...) |
| out = append(out, c.totalOutput(resp.Data)...) |
| |
| colConfig := columnize.DefaultConfig() |
| colConfig.Empty = " " // Do not show n/a on intentional blank lines |
| colConfig.Glue = " " |
| c.UI.Output(tableOutput(out, colConfig)) |
| return 0 |
| } |
| |
| // noReportAvailable checks whether we can definitively say that no |
| // queries can be answered; if there's an error, just fall back to |
| // reporting that the response is empty. |
| func (c *OperatorUsageCommand) noReportAvailable(client *api.Client) bool { |
| if c.flagOutputCurlString || c.flagOutputPolicy { |
| // Don't mess up the original query string |
| return false |
| } |
| |
| resp, err := client.Logical().Read("sys/internal/counters/config") |
| if err != nil || resp == nil || resp.Data == nil { |
| c.UI.Warn("bad response from config") |
| return false |
| } |
| |
| qaRaw, ok := resp.Data["queries_available"] |
| if !ok { |
| c.UI.Warn("no queries_available key") |
| return false |
| } |
| |
| qa, ok := qaRaw.(bool) |
| if !ok { |
| c.UI.Warn("wrong type") |
| return false |
| } |
| |
| return !qa |
| } |
| |
| func (c *OperatorUsageCommand) outputTimestamps(data map[string]interface{}) { |
| c.UI.Output(fmt.Sprintf("Period start: %v\nPeriod end: %v\n", |
| data["start_time"].(string), |
| data["end_time"].(string))) |
| } |
| |
| type UsageCommandNamespace struct { |
| formattedLine string |
| sortOrder string |
| |
| // Sort order: |
| // -- root first |
| // -- namespaces in lexicographic order |
| // -- deleted namespace "xxxxx" last |
| } |
| |
| type UsageResponse struct { |
| namespacePath string |
| entityCount int64 |
| // As per 1.9, the tokenCount field will contain the distinct non-entity |
| // token clients instead of each individual token. |
| tokenCount int64 |
| |
| clientCount int64 |
| } |
| |
| func jsonNumberOK(m map[string]interface{}, key string) (int64, bool) { |
| val, ok := m[key].(json.Number) |
| if !ok { |
| return 0, false |
| } |
| intVal, err := val.Int64() |
| if err != nil { |
| return 0, false |
| } |
| return intVal, true |
| } |
| |
| // TODO: provide a function in the API module for doing this conversion? |
| func (c *OperatorUsageCommand) parseNamespaceCount(rawVal interface{}) (UsageResponse, error) { |
| var ret UsageResponse |
| |
| val, ok := rawVal.(map[string]interface{}) |
| if !ok { |
| return ret, errors.New("value is not a map") |
| } |
| |
| ret.namespacePath, ok = val["namespace_path"].(string) |
| if !ok { |
| return ret, errors.New("bad namespace path") |
| } |
| |
| counts, ok := val["counts"].(map[string]interface{}) |
| if !ok { |
| return ret, errors.New("missing counts") |
| } |
| |
| ret.entityCount, ok = jsonNumberOK(counts, "distinct_entities") |
| if !ok { |
| return ret, errors.New("missing distinct_entities") |
| } |
| |
| ret.tokenCount, ok = jsonNumberOK(counts, "non_entity_tokens") |
| if !ok { |
| return ret, errors.New("missing non_entity_tokens") |
| } |
| |
| ret.clientCount, ok = jsonNumberOK(counts, "clients") |
| if !ok { |
| return ret, errors.New("missing clients") |
| } |
| |
| return ret, nil |
| } |
| |
| func (c *OperatorUsageCommand) namespacesOutput(data map[string]interface{}) []string { |
| byNs, ok := data["by_namespace"].([]interface{}) |
| if !ok { |
| c.UI.Error("missing namespace breakdown in response") |
| return nil |
| } |
| |
| nsOut := make([]UsageCommandNamespace, 0, len(byNs)) |
| |
| for _, rawVal := range byNs { |
| val, err := c.parseNamespaceCount(rawVal) |
| if err != nil { |
| c.UI.Error(fmt.Sprintf("malformed namespace in response: %v", err)) |
| continue |
| } |
| |
| sortOrder := "1" + val.namespacePath |
| if val.namespacePath == "" { |
| val.namespacePath = "[root]" |
| sortOrder = "0" |
| } else if strings.HasPrefix(val.namespacePath, "deleted namespace") { |
| sortOrder = "2" + val.namespacePath |
| } |
| |
| formattedLine := fmt.Sprintf("%s | %d | %d | %d", |
| val.namespacePath, val.entityCount, val.tokenCount, val.clientCount) |
| nsOut = append(nsOut, UsageCommandNamespace{ |
| formattedLine: formattedLine, |
| sortOrder: sortOrder, |
| }) |
| } |
| |
| sort.Slice(nsOut, func(i, j int) bool { |
| return nsOut[i].sortOrder < nsOut[j].sortOrder |
| }) |
| |
| out := make([]string, len(nsOut)) |
| for i := range nsOut { |
| out[i] = nsOut[i].formattedLine |
| } |
| |
| return out |
| } |
| |
| func (c *OperatorUsageCommand) totalOutput(data map[string]interface{}) []string { |
| // blank line separating it from namespaces |
| out := []string{" | | | "} |
| |
| total, ok := data["total"].(map[string]interface{}) |
| if !ok { |
| c.UI.Error("missing total in response") |
| return out |
| } |
| |
| entityCount, ok := jsonNumberOK(total, "distinct_entities") |
| if !ok { |
| c.UI.Error("missing distinct_entities in total") |
| return out |
| } |
| |
| tokenCount, ok := jsonNumberOK(total, "non_entity_tokens") |
| if !ok { |
| c.UI.Error("missing non_entity_tokens in total") |
| return out |
| } |
| 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)) |
| return out |
| } |