Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions docs/api/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ For the sake of clarity, in this document we have grouped API endpoints by servi
| [Delete Alertmanager configuration](#delete-alertmanager-configuration) | Alertmanager || `DELETE /api/v1/alerts` |
| [Tenant delete request](#tenant-delete-request) | Purger || `POST /purger/delete_tenant` |
| [Tenant delete status](#tenant-delete-status) | Purger || `GET /purger/delete_tenant_status` |
| [Get user overrides](#get-user-overrides) | Overrides || `GET /api/v1/user-overrides` |
| [Set user overrides](#set-user-overrides) | Overrides || `POST /api/v1/user-overrides` |
| [Delete user overrides](#delete-user-overrides) | Overrides || `DELETE /api/v1/user-overrides` |
| [Store-gateway ring status](#store-gateway-ring-status) | Store-gateway || `GET /store-gateway/ring` |
| [Compactor ring status](#compactor-ring-status) | Compactor || `GET /compactor/ring` |
| [Get rule files](#get-rule-files) | Configs API (deprecated) || `GET /api/prom/configs/rules` |
Expand Down Expand Up @@ -888,6 +891,64 @@ Returns status of tenant deletion. Output format to be defined. Experimental.

_Requires [authentication](#authentication)._

## Overrides

The Overrides service provides an API for managing user overrides.

### Get user overrides

```
GET /api/v1/user-overrides
```

Get the current overrides for the authenticated tenant. Returns the overrides in JSON format.

_Requires [authentication](#authentication)._

### Set user overrides

```
POST /api/v1/user-overrides
```

Set or update overrides for the authenticated tenant. The request body should contain a JSON object with the override values.

_Requires [authentication](#authentication)._

### Delete user overrides

```
DELETE /api/v1/user-overrides
```

Delete all overrides for the authenticated tenant. This will revert the tenant to using default values.

_Requires [authentication](#authentication)._

#### Example request body for PUT

```json
{
"ingestion_rate": 50000,
"max_global_series_per_user": 1000000,
"ruler_max_rules_per_rule_group": 100
}
```

#### Supported limits

The following limits can be modified via the API:
- `max_global_series_per_user`
- `max_global_series_per_metric`
- `ingestion_rate`
- `ingestion_burst_size`
- `ruler_max_rules_per_rule_group`
- `ruler_max_rule_groups_per_tenant`

#### Hard limits

Overrides are validated against hard limits defined in the runtime configuration file. If a requested override exceeds the hard limit for the tenant, the request will be rejected with a 400 status code.

## Store-gateway

### Store-gateway ring status
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration/v1-guarantees.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ Cortex is an actively developed project and we want to encourage the introductio

Currently experimental features are:

- Overrides API
- Runtime configuration API for managing tenant limits
- Ruler
- Evaluate rules to query frontend instead of ingesters (enabled via `-ruler.frontend-address`).
- When `-ruler.frontend-address` is specified, the response format can be specified (via `-ruler.query-response-format`).
Expand Down
2 changes: 1 addition & 1 deletion docs/proposals/user-overrides-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Response format:
}
```

#### 2. PUT /api/v1/user-overrides
#### 2. POST /api/v1/user-overrides
Updates overrides for a specific tenant. The request body should contain only the overrides that need to be updated.

Request body:
Expand Down
276 changes: 276 additions & 0 deletions integration/overrides_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
//go:build integration
// +build integration

package integration

import (
"bytes"
"context"
"encoding/json"
"net/http"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/thanos-io/objstore/providers/s3"
"gopkg.in/yaml.v3"

"github.com/cortexproject/cortex/integration/e2e"
e2edb "github.com/cortexproject/cortex/integration/e2e/db"
"github.com/cortexproject/cortex/integration/e2ecortex"
)

func TestOverridesAPIWithRunningCortex(t *testing.T) {
s, err := e2e.NewScenario(networkName)
require.NoError(t, err)
defer s.Close()

minio := e2edb.NewMinio(9000, "cortex")
require.NoError(t, s.StartAndWaitReady(minio))

runtimeConfig := map[string]interface{}{
"overrides": map[string]interface{}{
"user1": map[string]interface{}{
"ingestion_rate": 5000,
},
},
"api_allowed_limits": []string{
"ingestion_rate",
"max_global_series_per_user",
"max_global_series_per_metric",
"ingestion_burst_size",
"ruler_max_rules_per_rule_group",
"ruler_max_rule_groups_per_tenant",
},
}
runtimeConfigData, err := yaml.Marshal(runtimeConfig)
require.NoError(t, err)

s3Client, err := s3.NewBucketWithConfig(nil, s3.Config{
Endpoint: minio.HTTPEndpoint(),
Insecure: true,
Bucket: "cortex",
AccessKey: e2edb.MinioAccessKey,
SecretKey: e2edb.MinioSecretKey,
}, "overrides-test", nil)
require.NoError(t, err)

require.NoError(t, s3Client.Upload(context.Background(), "runtime.yaml", bytes.NewReader(runtimeConfigData)))

flags := map[string]string{
"-target": "overrides",

"-runtime-config.file": "runtime.yaml",
"-runtime-config.backend": "s3",
"-runtime-config.s3.access-key-id": e2edb.MinioAccessKey,
"-runtime-config.s3.secret-access-key": e2edb.MinioSecretKey,
"-runtime-config.s3.bucket-name": "cortex",
"-runtime-config.s3.endpoint": minio.NetworkHTTPEndpoint(),
"-runtime-config.s3.insecure": "true",
}

cortexSvc := e2ecortex.NewSingleBinary("cortex-overrides", flags, "")
require.NoError(t, s.StartAndWaitReady(cortexSvc))

t.Run("GET overrides for existing user", func(t *testing.T) {
req, err := http.NewRequest("GET", "http://"+cortexSvc.HTTPEndpoint()+"/api/v1/user-overrides", nil)
require.NoError(t, err)
req.Header.Set("X-Scope-OrgID", "user1")

resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()

assert.Equal(t, http.StatusOK, resp.StatusCode)

var overrides map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&overrides)
require.NoError(t, err)

assert.Equal(t, float64(5000), overrides["ingestion_rate"])
})

t.Run("GET overrides for non-existing user", func(t *testing.T) {
req, err := http.NewRequest("GET", "http://"+cortexSvc.HTTPEndpoint()+"/api/v1/user-overrides", nil)
require.NoError(t, err)
req.Header.Set("X-Scope-OrgID", "user2")

resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()

assert.Equal(t, http.StatusOK, resp.StatusCode)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A 4xx of some kind is probably more appropriate in this case.


var overrides map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&overrides)
require.NoError(t, err)

assert.Empty(t, overrides)
})

t.Run("POST overrides for new user", func(t *testing.T) {
newOverrides := map[string]interface{}{
"ingestion_rate": 6000,
"ingestion_burst_size": 7000,
}
requestBody, err := json.Marshal(newOverrides)
require.NoError(t, err)

req, err := http.NewRequest("POST", "http://"+cortexSvc.HTTPEndpoint()+"/api/v1/user-overrides", bytes.NewReader(requestBody))
require.NoError(t, err)
req.Header.Set("X-Scope-OrgID", "user3")
req.Header.Set("Content-Type", "application/json")

resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()

assert.Equal(t, http.StatusOK, resp.StatusCode)

req, err = http.NewRequest("GET", "http://"+cortexSvc.HTTPEndpoint()+"/api/v1/user-overrides", nil)
require.NoError(t, err)
req.Header.Set("X-Scope-OrgID", "user3")

resp, err = http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()

assert.Equal(t, http.StatusOK, resp.StatusCode)

var savedOverrides map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&savedOverrides)
require.NoError(t, err)

assert.Equal(t, float64(6000), savedOverrides["ingestion_rate"])
assert.Equal(t, float64(7000), savedOverrides["ingestion_burst_size"])
})

t.Run("POST overrides with invalid limit", func(t *testing.T) {
invalidOverrides := map[string]interface{}{
"invalid_limit": 5000,
}
requestBody, err := json.Marshal(invalidOverrides)
require.NoError(t, err)

req, err := http.NewRequest("POST", "http://"+cortexSvc.HTTPEndpoint()+"/api/v1/user-overrides", bytes.NewReader(requestBody))
require.NoError(t, err)
req.Header.Set("X-Scope-OrgID", "user4")
req.Header.Set("Content-Type", "application/json")

resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()

assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})

t.Run("POST overrides with invalid JSON", func(t *testing.T) {
req, err := http.NewRequest("POST", "http://"+cortexSvc.HTTPEndpoint()+"/api/v1/user-overrides", bytes.NewReader([]byte("invalid json")))
require.NoError(t, err)
req.Header.Set("X-Scope-OrgID", "user5")
req.Header.Set("Content-Type", "application/json")

resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()

assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})

t.Run("DELETE overrides", func(t *testing.T) {
req, err := http.NewRequest("DELETE", "http://"+cortexSvc.HTTPEndpoint()+"/api/v1/user-overrides", nil)
require.NoError(t, err)
req.Header.Set("X-Scope-OrgID", "user1")

resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()

assert.Equal(t, http.StatusOK, resp.StatusCode)

req, err = http.NewRequest("GET", "http://"+cortexSvc.HTTPEndpoint()+"/api/v1/user-overrides", nil)
require.NoError(t, err)
req.Header.Set("X-Scope-OrgID", "user1")

resp, err = http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()

assert.Equal(t, http.StatusOK, resp.StatusCode)

var overrides map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&overrides)
require.NoError(t, err)

assert.Empty(t, overrides)
})

require.NoError(t, s.Stop(cortexSvc))
}

func TestOverridesAPITenantExtraction(t *testing.T) {
s, err := e2e.NewScenario(networkName)
require.NoError(t, err)
defer s.Close()

minio := e2edb.NewMinio(9010, "cortex")
require.NoError(t, s.StartAndWaitReady(minio))

// Upload an empty runtime config file to S3
runtimeConfig := map[string]interface{}{
"overrides": map[string]interface{}{},
}
runtimeConfigData, err := yaml.Marshal(runtimeConfig)
require.NoError(t, err)

s3Client, err := s3.NewBucketWithConfig(nil, s3.Config{
Endpoint: minio.HTTPEndpoint(),
Insecure: true,
Bucket: "cortex",
AccessKey: e2edb.MinioAccessKey,
SecretKey: e2edb.MinioSecretKey,
}, "overrides-test-tenant", nil)
require.NoError(t, err)

require.NoError(t, s3Client.Upload(context.Background(), "runtime.yaml", bytes.NewReader(runtimeConfigData)))

flags := map[string]string{
"-target": "overrides",

"-runtime-config.file": "runtime.yaml",
"-runtime-config.backend": "s3",
"-runtime-config.s3.access-key-id": e2edb.MinioAccessKey,
"-runtime-config.s3.secret-access-key": e2edb.MinioSecretKey,
"-runtime-config.s3.bucket-name": "cortex",
"-runtime-config.s3.endpoint": minio.NetworkHTTPEndpoint(),
"-runtime-config.s3.insecure": "true",
}

cortexSvc := e2ecortex.NewSingleBinary("cortex-overrides-tenant", flags, "")
require.NoError(t, s.StartAndWaitReady(cortexSvc))

t.Run("no tenant header", func(t *testing.T) {
req, err := http.NewRequest("GET", "http://"+cortexSvc.HTTPEndpoint()+"/api/v1/user-overrides", nil)
require.NoError(t, err)

resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()

assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})

t.Run("empty tenant header", func(t *testing.T) {
req, err := http.NewRequest("GET", "http://"+cortexSvc.HTTPEndpoint()+"/api/v1/user-overrides", nil)
require.NoError(t, err)
req.Header.Set("X-Scope-OrgID", "")

resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()

assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})

require.NoError(t, s.Stop(cortexSvc))
}
12 changes: 12 additions & 0 deletions pkg/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
frontendv2 "github.com/cortexproject/cortex/pkg/frontend/v2"
"github.com/cortexproject/cortex/pkg/frontend/v2/frontendv2pb"
"github.com/cortexproject/cortex/pkg/ingester/client"
"github.com/cortexproject/cortex/pkg/overrides"
"github.com/cortexproject/cortex/pkg/purger"
"github.com/cortexproject/cortex/pkg/querier"
"github.com/cortexproject/cortex/pkg/ring"
Expand Down Expand Up @@ -385,6 +386,17 @@ func (a *API) RegisterRulerAPI(r *ruler.API) {
a.RegisterRoute(path.Join(a.cfg.LegacyHTTPPrefix, "/rules/{namespace}"), http.HandlerFunc(r.DeleteNamespace), true, "DELETE")
}

// RegisterOverrides registers routes associated with the Overrides API
func (a *API) RegisterOverrides(o *overrides.API) {
// Register individual overrides API routes with the main API
a.RegisterRoute("/api/v1/user-overrides", http.HandlerFunc(o.GetOverrides), true, "GET")
a.RegisterRoute("/api/v1/user-overrides", http.HandlerFunc(o.SetOverrides), true, "POST")
a.RegisterRoute("/api/v1/user-overrides", http.HandlerFunc(o.DeleteOverrides), true, "DELETE")

// Add link to the index page
a.indexPage.AddLink(SectionAdminEndpoints, "/api/v1/user-overrides", "User Overrides API")
}

// RegisterRing registers the ring UI page associated with the distributor for writes.
func (a *API) RegisterRing(r *ring.Ring) {
a.indexPage.AddLink(SectionAdminEndpoints, "/ingester/ring", "Ingester Ring Status")
Expand Down
Loading
Loading