mirror of https://github.com/grafana/loki
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
301 lines
8.7 KiB
301 lines
8.7 KiB
package ui
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/go-kit/log"
|
|
"github.com/gorilla/mux"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/thanos-io/objstore"
|
|
|
|
"github.com/grafana/loki/v3/pkg/goldfish"
|
|
)
|
|
|
|
func TestFeaturesHandler_GoldfishWithNamespaces(t *testing.T) {
|
|
t.Run("returns goldfish object with namespaces when enabled", func(t *testing.T) {
|
|
service := &Service{
|
|
cfg: Config{
|
|
Goldfish: GoldfishConfig{
|
|
Enable: true,
|
|
CellANamespace: "loki-ops-002",
|
|
CellBNamespace: "loki-ops-003",
|
|
},
|
|
},
|
|
logger: log.NewNopLogger(),
|
|
}
|
|
|
|
handler := service.featuresHandler()
|
|
req := httptest.NewRequest("GET", "/api/v1/features", nil)
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rr.Code)
|
|
|
|
var response map[string]any
|
|
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
|
require.NoError(t, err)
|
|
|
|
// Check that goldfish is an object with the expected structure
|
|
goldfishData, ok := response["goldfish"].(map[string]interface{})
|
|
require.True(t, ok, "goldfish should be an object")
|
|
|
|
assert.Equal(t, true, goldfishData["enabled"])
|
|
assert.Equal(t, "loki-ops-002", goldfishData["cellANamespace"])
|
|
assert.Equal(t, "loki-ops-003", goldfishData["cellBNamespace"])
|
|
})
|
|
|
|
t.Run("returns goldfish object without namespaces when disabled", func(t *testing.T) {
|
|
service := &Service{
|
|
cfg: Config{
|
|
Goldfish: GoldfishConfig{
|
|
Enable: false,
|
|
CellANamespace: "loki-ops-002",
|
|
CellBNamespace: "loki-ops-003",
|
|
},
|
|
},
|
|
logger: log.NewNopLogger(),
|
|
}
|
|
|
|
handler := service.featuresHandler()
|
|
req := httptest.NewRequest("GET", "/api/v1/features", nil)
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rr.Code)
|
|
|
|
var response map[string]interface{}
|
|
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
|
require.NoError(t, err)
|
|
|
|
// Check that goldfish is an object with only enabled: false
|
|
goldfishData, ok := response["goldfish"].(map[string]interface{})
|
|
require.True(t, ok, "goldfish should be an object")
|
|
|
|
assert.Equal(t, false, goldfishData["enabled"])
|
|
assert.Nil(t, goldfishData["cellANamespace"], "namespaces should not be included when disabled")
|
|
assert.Nil(t, goldfishData["cellBNamespace"], "namespaces should not be included when disabled")
|
|
})
|
|
|
|
t.Run("returns goldfish object with enabled true but no namespaces when namespaces not configured", func(t *testing.T) {
|
|
service := &Service{
|
|
cfg: Config{
|
|
Goldfish: GoldfishConfig{
|
|
Enable: true,
|
|
// No namespaces configured
|
|
},
|
|
},
|
|
logger: log.NewNopLogger(),
|
|
}
|
|
|
|
handler := service.featuresHandler()
|
|
req := httptest.NewRequest("GET", "/api/v1/features", nil)
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rr.Code)
|
|
|
|
var response map[string]interface{}
|
|
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
|
require.NoError(t, err)
|
|
|
|
// Check that goldfish is an object with only enabled: true
|
|
goldfishData, ok := response["goldfish"].(map[string]interface{})
|
|
require.True(t, ok, "goldfish should be an object")
|
|
|
|
assert.Equal(t, true, goldfishData["enabled"])
|
|
assert.Nil(t, goldfishData["cellANamespace"], "cellANamespace should be nil when not configured")
|
|
assert.Nil(t, goldfishData["cellBNamespace"], "cellBNamespace should be nil when not configured")
|
|
})
|
|
}
|
|
|
|
func TestGoldfishResultHandler(t *testing.T) {
|
|
setup := func(enabled bool, storage goldfish.Storage, bucket objstore.InstrumentedBucket) *httptest.ResponseRecorder {
|
|
service := &Service{
|
|
cfg: Config{
|
|
Goldfish: GoldfishConfig{
|
|
Enable: enabled,
|
|
},
|
|
},
|
|
logger: log.NewNopLogger(),
|
|
goldfishStorage: storage,
|
|
goldfishBucket: bucket,
|
|
}
|
|
|
|
handler := service.goldfishResultHandler(cellA)
|
|
req := httptest.NewRequest("GET", "/api/v1/goldfish/results/test-id/cell-a", nil)
|
|
req = mux.SetURLVars(req, map[string]string{"correlationId": "test-id"})
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
return rr
|
|
}
|
|
|
|
t.Run("returns error when goldfish is disabled", func(t *testing.T) {
|
|
rr := setup(false, nil, nil)
|
|
assert.Equal(t, http.StatusNotFound, rr.Code)
|
|
assert.Contains(t, rr.Body.String(), "goldfish feature is disabled")
|
|
})
|
|
|
|
t.Run("returns error when bucket client is not configured", func(t *testing.T) {
|
|
rr := setup(true, nil, nil)
|
|
assert.Equal(t, http.StatusNotImplemented, rr.Code)
|
|
assert.Contains(t, rr.Body.String(), "result storage is not configured")
|
|
})
|
|
|
|
t.Run("returns 404 not found when correlation ID not found in database", func(t *testing.T) {
|
|
storage := &mockStorage{
|
|
getQueryFunc: func(_ context.Context, _ string) (*goldfish.QuerySample, error) {
|
|
return nil, errors.New("not found")
|
|
},
|
|
}
|
|
|
|
rr := setup(true, storage, &mockBucket{})
|
|
assert.Equal(t, http.StatusNotFound, rr.Code)
|
|
assert.Contains(t, rr.Body.String(), "not found")
|
|
})
|
|
|
|
t.Run("returns 404 not found when result URI is empty", func(t *testing.T) {
|
|
storage := &mockStorage{
|
|
getQueryFunc: func(_ context.Context, correlationID string) (*goldfish.QuerySample, error) {
|
|
return &goldfish.QuerySample{
|
|
CorrelationID: correlationID,
|
|
CellAResultURI: "", // No result URI
|
|
}, nil
|
|
},
|
|
}
|
|
|
|
rr := setup(true, storage, &mockBucket{})
|
|
assert.Equal(t, http.StatusNotFound, rr.Code)
|
|
assert.Contains(t, rr.Body.String(), "was not persisted to object storage")
|
|
})
|
|
|
|
t.Run("successfully fetches and decompresses gzipped result", func(t *testing.T) {
|
|
originalData := []byte(`{"result": "compressed test data"}`)
|
|
|
|
// Compress data with gzip
|
|
var compressedBuf bytes.Buffer
|
|
gzWriter := gzip.NewWriter(&compressedBuf)
|
|
_, err := gzWriter.Write(originalData)
|
|
require.NoError(t, err)
|
|
require.NoError(t, gzWriter.Close())
|
|
|
|
storage := &mockStorage{
|
|
getQueryFunc: func(_ context.Context, correlationID string) (*goldfish.QuerySample, error) {
|
|
return &goldfish.QuerySample{
|
|
CorrelationID: correlationID,
|
|
CellAResultURI: "s3://test-bucket/path/to/result.json.gz",
|
|
CellAResultCompression: "gzip",
|
|
}, nil
|
|
},
|
|
}
|
|
|
|
bucket := &mockBucket{
|
|
getFunc: func(_ context.Context, key string) (io.ReadCloser, error) {
|
|
assert.Equal(t, "path/to/result.json.gz", key)
|
|
return io.NopCloser(bytes.NewReader(compressedBuf.Bytes())), nil
|
|
},
|
|
}
|
|
|
|
rr := setup(true, storage, bucket)
|
|
assert.Equal(t, http.StatusOK, rr.Code)
|
|
assert.Equal(t, "application/json", rr.Header().Get("Content-Type"))
|
|
assert.JSONEq(t, string(originalData), rr.Body.String())
|
|
})
|
|
}
|
|
|
|
func TestParseObjectKeyFromURI(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
uri string
|
|
expectedKey string
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "valid GCS URI",
|
|
uri: "gcs://my-bucket/path/to/object.json",
|
|
expectedKey: "path/to/object.json",
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "valid S3 URI",
|
|
uri: "s3://my-bucket/path/to/object.json.gz",
|
|
expectedKey: "path/to/object.json.gz",
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "valid URI with nested path",
|
|
uri: "gcs://my-bucket/prefix/2025/01/01/test-id/cell-a.json",
|
|
expectedKey: "prefix/2025/01/01/test-id/cell-a.json",
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "valid URI with URL-encoded characters in path",
|
|
uri: "gcs://my-bucket/path/with%20spaces/object.json",
|
|
expectedKey: "path/with spaces/object.json", // url.Parse automatically decodes
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "unsupported scheme http",
|
|
uri: "http://bucket/path",
|
|
expectedKey: "",
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "unsupported scheme https",
|
|
uri: "https://bucket/path",
|
|
expectedKey: "",
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "missing path",
|
|
uri: "gcs://my-bucket",
|
|
expectedKey: "",
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "missing path with trailing slash",
|
|
uri: "gcs://my-bucket/",
|
|
expectedKey: "",
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "empty URI",
|
|
uri: "",
|
|
expectedKey: "",
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "malformed URI",
|
|
uri: "not-a-uri",
|
|
expectedKey: "",
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "realistic example",
|
|
uri: "gcs://dev-us-central-0-loki-dev-005-goldfish-results/goldfish/results/2024/10/11/fc761f29-edad-4152-bedf-331d8cf2dbd5/cell-a.json.gz",
|
|
expectedKey: "goldfish/results/2024/10/11/fc761f29-edad-4152-bedf-331d8cf2dbd5/cell-a.json.gz",
|
|
expectError: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
key, err := parseObjectKeyFromURI(tt.uri)
|
|
if tt.expectError {
|
|
assert.Error(t, err)
|
|
} else {
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tt.expectedKey, key)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|