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)
}
// 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)
}
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"
)
// 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
func (c *Core) GetActiveEntities() map[string]struct{} {
out := make(map[string]struct{})
@ -171,3 +199,9 @@ func (a *ActivityLog) GetEnabled() bool {
defer a.fragmentLock.RUnlock()
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.",
},
"activity-monthly": {
"Count of active clients so far this month.",
"Count of active clients so far this month.",
},
"activity-config": {
"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
func (b *SystemBackend) rootActivityPaths() []*framework.Path {
return []*framework.Path{
b.activityQueryPath(),
b.monthlyActivityCountPath(),
{
Pattern: "internal/counters/config$",
Fields: map[string]*framework.FieldSchema{
@ -120,6 +136,22 @@ func (b *SystemBackend) handleClientMetricQuery(ctx context.Context, req *logica
}, 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) {
a := b.Core.activityLog
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
```
## 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
The `/sys/internal/counters/config` endpoint is used to configure logging of active clients.