Add Partial Month Client Count API for Activity Log (#11022)
* sketch out partial month activity log client API * unit test partialMonthClientCount * cleanup api * add api doc, fix test, update api nomenclature to match existing * cleanup * add PR changelog file * integration test for API * report entities and tokens separately
This commit is contained in:
parent
2da7de2fec
commit
d74f82346b
|
@ -0,0 +1,3 @@
|
||||||
|
```release-note:improvement
|
||||||
|
core: add partial month client count api
|
||||||
|
```
|
|
@ -1771,3 +1771,29 @@ func (c *Core) activeEntityGaugeCollector(ctx context.Context) ([]metricsutil.Ga
|
||||||
}
|
}
|
||||||
return a.PartialMonthMetrics(ctx)
|
return a.PartialMonthMetrics(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// partialMonthClientCount returns the number of clients used so far this month.
|
||||||
|
// If activity log is not enabled, the response will be nil
|
||||||
|
func (a *ActivityLog) partialMonthClientCount(ctx context.Context) map[string]interface{} {
|
||||||
|
a.fragmentLock.RLock()
|
||||||
|
defer a.fragmentLock.RUnlock()
|
||||||
|
|
||||||
|
if !a.enabled {
|
||||||
|
// nothing to count
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
entityCount := len(a.activeEntities)
|
||||||
|
var tokenCount int
|
||||||
|
for _, countByNS := range a.currentSegment.tokenCount.CountByNamespaceID {
|
||||||
|
tokenCount += int(countByNS)
|
||||||
|
}
|
||||||
|
clientCount := entityCount + tokenCount
|
||||||
|
|
||||||
|
responseData := make(map[string]interface{})
|
||||||
|
responseData["distinct_entities"] = entityCount
|
||||||
|
responseData["non_entity_tokens"] = tokenCount
|
||||||
|
responseData["clients"] = clientCount
|
||||||
|
|
||||||
|
return responseData
|
||||||
|
}
|
||||||
|
|
|
@ -2435,3 +2435,56 @@ func TestActivityLog_Deletion(t *testing.T) {
|
||||||
checkPresent(21)
|
checkPresent(21)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestActivityLog_partialMonthClientCount(t *testing.T) {
|
||||||
|
timeutil.SkipAtEndOfMonth(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
a, entities, tokenCounts := setupActivityRecordsInStorage(t, timeutil.StartOfMonth(now), true, true)
|
||||||
|
|
||||||
|
a.SetEnable(true)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
err := a.refreshFromStoredLog(ctx, &wg, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error loading clients: %v", err)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// entities[0] is from a previous month
|
||||||
|
partialMonthEntityCount := len(entities[1:])
|
||||||
|
var partialMonthTokenCount int
|
||||||
|
for _, countByNS := range tokenCounts {
|
||||||
|
partialMonthTokenCount += int(countByNS)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedClientCount := partialMonthEntityCount + partialMonthTokenCount
|
||||||
|
|
||||||
|
results := a.partialMonthClientCount(ctx)
|
||||||
|
if results == nil {
|
||||||
|
t.Fatal("no results to test")
|
||||||
|
}
|
||||||
|
|
||||||
|
entityCount, ok := results["distinct_entities"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("malformed results. got %v", results)
|
||||||
|
}
|
||||||
|
if entityCount != partialMonthEntityCount {
|
||||||
|
t.Errorf("bad entity count. expected %d, got %d", partialMonthEntityCount, entityCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenCount, ok := results["non_entity_tokens"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("malformed results. got %v", results)
|
||||||
|
}
|
||||||
|
if tokenCount != partialMonthTokenCount {
|
||||||
|
t.Errorf("bad token count. expected %d, got %d", partialMonthTokenCount, tokenCount)
|
||||||
|
}
|
||||||
|
clientCount, ok := results["clients"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("malformed results. got %v", results)
|
||||||
|
}
|
||||||
|
if clientCount != expectedClientCount {
|
||||||
|
t.Errorf("bad client count. expected %d, got %d", expectedClientCount, clientCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -8,6 +8,34 @@ import (
|
||||||
"github.com/hashicorp/vault/vault/activity"
|
"github.com/hashicorp/vault/vault/activity"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// InjectActivityLogDataThisMonth populates the in-memory client store
|
||||||
|
// with some entities and tokens, overriding what was already there
|
||||||
|
// It is currently used for API integration tests
|
||||||
|
func (c *Core) InjectActivityLogDataThisMonth(t *testing.T) (map[string]struct{}, map[string]uint64) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
activeEntities := map[string]struct{}{
|
||||||
|
"entity0": struct{}{},
|
||||||
|
"entity1": struct{}{},
|
||||||
|
"entity2": struct{}{},
|
||||||
|
}
|
||||||
|
tokens := map[string]uint64{
|
||||||
|
"ns0": 5,
|
||||||
|
"ns1": 1,
|
||||||
|
"ns2": 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.activityLog.l.Lock()
|
||||||
|
defer c.activityLog.l.Unlock()
|
||||||
|
c.activityLog.fragmentLock.Lock()
|
||||||
|
defer c.activityLog.fragmentLock.Unlock()
|
||||||
|
|
||||||
|
c.activityLog.activeEntities = activeEntities
|
||||||
|
c.activityLog.currentSegment.tokenCount.CountByNamespaceID = tokens
|
||||||
|
|
||||||
|
return activeEntities, tokens
|
||||||
|
}
|
||||||
|
|
||||||
// Return the in-memory activeEntities from an activity log
|
// Return the in-memory activeEntities from an activity log
|
||||||
func (c *Core) GetActiveEntities() map[string]struct{} {
|
func (c *Core) GetActiveEntities() map[string]struct{} {
|
||||||
out := make(map[string]struct{})
|
out := make(map[string]struct{})
|
||||||
|
@ -171,3 +199,9 @@ func (a *ActivityLog) GetEnabled() bool {
|
||||||
defer a.fragmentLock.RUnlock()
|
defer a.fragmentLock.RUnlock()
|
||||||
return a.enabled
|
return a.enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetActivityLog returns a pointer to the (private) activity log on a core
|
||||||
|
// Note: you must do the usual locking scheme when modifying the ActivityLog
|
||||||
|
func (c *Core) GetActivityLog() *ActivityLog {
|
||||||
|
return c.activityLog
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,116 @@
|
||||||
|
package activity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/hashicorp/vault/api"
|
||||||
|
"github.com/hashicorp/vault/builtin/credential/userpass"
|
||||||
|
"github.com/hashicorp/vault/helper/timeutil"
|
||||||
|
vaulthttp "github.com/hashicorp/vault/http"
|
||||||
|
"github.com/hashicorp/vault/sdk/logical"
|
||||||
|
"github.com/hashicorp/vault/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validateClientCounts(t *testing.T, resp *api.Secret, expectedEntities, expectedTokens int) {
|
||||||
|
if resp == nil {
|
||||||
|
t.Fatal("nil response")
|
||||||
|
}
|
||||||
|
if resp.Data == nil {
|
||||||
|
t.Fatal("no data")
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedClients := expectedEntities + expectedTokens
|
||||||
|
|
||||||
|
entityCountJSON, ok := resp.Data["distinct_entities"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("no entity count: %v", resp.Data)
|
||||||
|
}
|
||||||
|
entityCount, err := entityCountJSON.(json.Number).Int64()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if entityCount != int64(expectedEntities) {
|
||||||
|
t.Errorf("bad entity count. expected %v, got %v", expectedEntities, entityCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenCountJSON, ok := resp.Data["non_entity_tokens"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("no token count: %v", resp.Data)
|
||||||
|
}
|
||||||
|
tokenCount, err := tokenCountJSON.(json.Number).Int64()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tokenCount != int64(expectedTokens) {
|
||||||
|
t.Errorf("bad token count. expected %v, got %v", expectedTokens, tokenCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
clientCountJSON, ok := resp.Data["clients"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("no client count: %v", resp.Data)
|
||||||
|
}
|
||||||
|
clientCount, err := clientCountJSON.(json.Number).Int64()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if clientCount != int64(expectedClients) {
|
||||||
|
t.Errorf("bad client count. expected %v, got %v", expectedClients, clientCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActivityLog_MonthlyActivityApi(t *testing.T) {
|
||||||
|
timeutil.SkipAtEndOfMonth(t)
|
||||||
|
|
||||||
|
coreConfig := &vault.CoreConfig{
|
||||||
|
CredentialBackends: map[string]logical.Factory{
|
||||||
|
"userpass": userpass.Factory,
|
||||||
|
},
|
||||||
|
ActivityLogConfig: vault.ActivityLogCoreConfig{
|
||||||
|
ForceEnable: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cluster := vault.NewTestCluster(t, coreConfig, &vault.TestClusterOptions{
|
||||||
|
HandlerFunc: vaulthttp.Handler,
|
||||||
|
})
|
||||||
|
cluster.Start()
|
||||||
|
defer cluster.Cleanup()
|
||||||
|
|
||||||
|
client := cluster.Cores[0].Client
|
||||||
|
core := cluster.Cores[0].Core
|
||||||
|
|
||||||
|
resp, err := client.Logical().Read("sys/internal/counters/activity/monthly")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
validateClientCounts(t, resp, 0, 0)
|
||||||
|
|
||||||
|
// inject some data and query the API
|
||||||
|
entities, tokens := core.InjectActivityLogDataThisMonth(t)
|
||||||
|
expectedEntities := len(entities)
|
||||||
|
var expectedTokens int
|
||||||
|
for _, tokenCount := range tokens {
|
||||||
|
expectedTokens += int(tokenCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err = client.Logical().Read("sys/internal/counters/activity/monthly")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
validateClientCounts(t, resp, expectedEntities, expectedTokens)
|
||||||
|
|
||||||
|
// we expect a 204 if activity log is disabled
|
||||||
|
core.GetActivityLog().SetEnable(false)
|
||||||
|
req := client.NewRequest("GET", "/v1/sys/internal/counters/activity/monthly")
|
||||||
|
rawResp, err := client.RawRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rawResp == nil {
|
||||||
|
t.Error("nil response")
|
||||||
|
}
|
||||||
|
if rawResp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Errorf("expected status code %v, got %v", http.StatusNoContent, rawResp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
|
@ -4670,6 +4670,10 @@ This path responds to the following HTTP methods.
|
||||||
"Query the historical count of clients.",
|
"Query the historical count of clients.",
|
||||||
"Query the historical count of clients.",
|
"Query the historical count of clients.",
|
||||||
},
|
},
|
||||||
|
"activity-monthly": {
|
||||||
|
"Count of active clients so far this month.",
|
||||||
|
"Count of active clients so far this month.",
|
||||||
|
},
|
||||||
"activity-config": {
|
"activity-config": {
|
||||||
"Control the collection and reporting of client counts.",
|
"Control the collection and reporting of client counts.",
|
||||||
"Control the collection and reporting of client counts.",
|
"Control the collection and reporting of client counts.",
|
||||||
|
|
|
@ -38,10 +38,26 @@ func (b *SystemBackend) activityQueryPath() *framework.Path {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// monthlyActivityCountPath is available in every namespace
|
||||||
|
func (b *SystemBackend) monthlyActivityCountPath() *framework.Path {
|
||||||
|
return &framework.Path{
|
||||||
|
Pattern: "internal/counters/activity/monthly",
|
||||||
|
HelpSynopsis: strings.TrimSpace(sysHelp["activity-monthly"][0]),
|
||||||
|
HelpDescription: strings.TrimSpace(sysHelp["activity-monthly"][1]),
|
||||||
|
Operations: map[logical.Operation]framework.OperationHandler{
|
||||||
|
logical.ReadOperation: &framework.PathOperation{
|
||||||
|
Callback: b.handleMonthlyActivityCount,
|
||||||
|
Summary: "Report the number of clients for this month, for this namespace and all child namespaces.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// rootActivityPaths are available only in the root namespace
|
// rootActivityPaths are available only in the root namespace
|
||||||
func (b *SystemBackend) rootActivityPaths() []*framework.Path {
|
func (b *SystemBackend) rootActivityPaths() []*framework.Path {
|
||||||
return []*framework.Path{
|
return []*framework.Path{
|
||||||
b.activityQueryPath(),
|
b.activityQueryPath(),
|
||||||
|
b.monthlyActivityCountPath(),
|
||||||
{
|
{
|
||||||
Pattern: "internal/counters/config$",
|
Pattern: "internal/counters/config$",
|
||||||
Fields: map[string]*framework.FieldSchema{
|
Fields: map[string]*framework.FieldSchema{
|
||||||
|
@ -120,6 +136,22 @@ func (b *SystemBackend) handleClientMetricQuery(ctx context.Context, req *logica
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *SystemBackend) handleMonthlyActivityCount(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
|
||||||
|
a := b.Core.activityLog
|
||||||
|
if a == nil {
|
||||||
|
return logical.ErrorResponse("no activity log present"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
results := a.partialMonthClientCount(ctx)
|
||||||
|
if results == nil {
|
||||||
|
return logical.RespondWithStatusCode(nil, req, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &logical.Response{
|
||||||
|
Data: results,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (b *SystemBackend) handleActivityConfigRead(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
|
func (b *SystemBackend) handleActivityConfigRead(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
|
||||||
a := b.Core.activityLog
|
a := b.Core.activityLog
|
||||||
if a == nil {
|
if a == nil {
|
||||||
|
|
|
@ -233,6 +233,51 @@ $ curl \
|
||||||
http://127.0.0.1:8200/v1/sys/internal/counters/activity?end_time=2020-06-30T00%3A00%3A00Z&start_time=2020-06-01T00%3A00%3A00Z
|
http://127.0.0.1:8200/v1/sys/internal/counters/activity?end_time=2020-06-30T00%3A00%3A00Z&start_time=2020-06-01T00%3A00%3A00Z
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Partial Month Client Count
|
||||||
|
|
||||||
|
This endpoint returns the number of clients for the current month, as the sum of active entities and non-entity tokens.
|
||||||
|
An "active entity" is a distinct entity that has created one or more tokens in the given time period.
|
||||||
|
A "non-entity token" is a token with no attached entity ID.
|
||||||
|
|
||||||
|
The time period is from the start of the current month, up until the time that this request was made.
|
||||||
|
|
||||||
|
Note: the client count may be inaccurate in the moments following a Vault reboot, or leadership change.
|
||||||
|
The estimate will stabilize when background loading of client data has completed.
|
||||||
|
|
||||||
|
This endpoint was added in Vault 1.6.4.
|
||||||
|
|
||||||
|
| Method | Path |
|
||||||
|
| :----- | :-------------------------------- |
|
||||||
|
| `GET` | `/sys/internal/counters/activity/monthly` |
|
||||||
|
|
||||||
|
### Sample Request
|
||||||
|
|
||||||
|
```shell-session
|
||||||
|
$ curl \
|
||||||
|
--header "X-Vault-Token: ..." \
|
||||||
|
--request GET \
|
||||||
|
http://127.0.0.1:8200/v1/sys/internal/counters/activity/monthly
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sample Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"request_id": "26be5ab9-dcac-9237-ec12-269a8ca64742",
|
||||||
|
"lease_id": "",
|
||||||
|
"renewable": false,
|
||||||
|
"lease_duration": 0,
|
||||||
|
"data": {
|
||||||
|
"distinct_entities": 100,
|
||||||
|
"non_entity_tokens": 120,
|
||||||
|
"clients": 220,
|
||||||
|
},
|
||||||
|
"wrap_info": null,
|
||||||
|
"warnings": null,
|
||||||
|
"auth": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Update the Client Count Configuration
|
## Update the Client Count Configuration
|
||||||
|
|
||||||
The `/sys/internal/counters/config` endpoint is used to configure logging of active clients.
|
The `/sys/internal/counters/config` endpoint is used to configure logging of active clients.
|
||||||
|
|
Loading…
Reference in New Issue