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:
swayne275 2021-03-01 16:15:59 -07:00 committed by GitHub
parent 2da7de2fec
commit d74f82346b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 313 additions and 0 deletions

3
changelog/11022.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
core: add partial month client count api
```

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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.",

View File

@ -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 {

View File

@ -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.