|
|
|
|
@ -1,14 +1,23 @@ |
|
|
|
|
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) { |
|
|
|
|
@ -107,3 +116,186 @@ func TestFeaturesHandler_GoldfishWithNamespaces(t *testing.T) { |
|
|
|
|
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) |
|
|
|
|
} |
|
|
|
|
}) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|