From 6fa8de132b3f75d42dff5f3d7a5439b44b824aa5 Mon Sep 17 00:00:00 2001 From: Simon Pasquier Date: Thu, 15 Nov 2018 14:22:16 +0100 Subject: [PATCH] web/v1/api: add tests for admin actions (#4767) Signed-off-by: Simon Pasquier --- web/api/v1/api.go | 17 ++- web/api/v1/api_test.go | 276 ++++++++++++++++++++++++++++++++++++----- web/web.go | 7 +- web/web_test.go | 1 + 4 files changed, 265 insertions(+), 36 deletions(-) diff --git a/web/api/v1/api.go b/web/api/v1/api.go index f88925329..c2f4c471a 100644 --- a/web/api/v1/api.go +++ b/web/api/v1/api.go @@ -34,7 +34,7 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/common/model" "github.com/prometheus/common/route" - "github.com/prometheus/tsdb" + tsdbLabels "github.com/prometheus/tsdb/labels" "github.com/prometheus/prometheus/config" "github.com/prometheus/prometheus/pkg/gate" @@ -49,7 +49,6 @@ import ( "github.com/prometheus/prometheus/storage/remote" "github.com/prometheus/prometheus/util/httputil" "github.com/prometheus/prometheus/util/stats" - tsdbLabels "github.com/prometheus/tsdb/labels" ) const ( @@ -131,6 +130,14 @@ func setCORS(w http.ResponseWriter) { type apiFunc func(r *http.Request) (interface{}, *apiError, func()) +// TSDBAdmin defines the tsdb interfaces used by the v1 API for admin operations. +type TSDBAdmin interface { + CleanTombstones() error + Delete(mint, maxt int64, ms ...tsdbLabels.Matcher) error + Dir() string + Snapshot(dir string, withHead bool) error +} + // API can register a set of endpoints in a router and handle // them using the provided storage and query engine. type API struct { @@ -145,7 +152,7 @@ type API struct { flagsMap map[string]string ready func(http.HandlerFunc) http.HandlerFunc - db func() *tsdb.DB + db func() TSDBAdmin enableAdmin bool logger log.Logger remoteReadSampleLimit int @@ -166,7 +173,7 @@ func NewAPI( configFunc func() config.Config, flagsMap map[string]string, readyFunc func(http.HandlerFunc) http.HandlerFunc, - db func() *tsdb.DB, + db func() TSDBAdmin, enableAdmin bool, logger log.Logger, rr rulesRetriever, @@ -950,7 +957,7 @@ func (api *API) snapshot(r *http.Request) (interface{}, *apiError, func()) { if r.FormValue("skip_head") != "" { skipHead, err = strconv.ParseBool(r.FormValue("skip_head")) if err != nil { - return nil, &apiError{errorUnavailable, fmt.Errorf("unable to parse boolean 'skip_head' argument: %v", err)}, nil + return nil, &apiError{errorBadData, fmt.Errorf("unable to parse boolean 'skip_head' argument: %v", err)}, nil } } diff --git a/web/api/v1/api_test.go b/web/api/v1/api_test.go index ea7ff203b..58d661192 100644 --- a/web/api/v1/api_test.go +++ b/web/api/v1/api_test.go @@ -24,13 +24,13 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" "reflect" "strings" "testing" "time" "github.com/go-kit/kit/log" - "github.com/gogo/protobuf/proto" "github.com/golang/snappy" config_util "github.com/prometheus/common/config" @@ -49,6 +49,7 @@ import ( "github.com/prometheus/prometheus/storage" "github.com/prometheus/prometheus/storage/remote" "github.com/prometheus/prometheus/util/testutil" + tsdbLabels "github.com/prometheus/tsdb/labels" ) type testTargetRetriever struct{} @@ -809,39 +810,49 @@ func testEndpoints(t *testing.T, api *API, testLabelAPI bool) { t.Fatal(err) } resp, apiErr, _ := test.endpoint(req.WithContext(ctx)) - if apiErr != nil { - if test.errType == errorNone { - t.Fatalf("Unexpected error: %s", apiErr) - } - if test.errType != apiErr.typ { - t.Fatalf("Expected error of type %q but got type %q", test.errType, apiErr.typ) - } - continue - } - if apiErr == nil && test.errType != errorNone { - t.Fatalf("Expected error of type %q but got none", test.errType) - } - if !reflect.DeepEqual(resp, test.response) { - respJSON, err := json.Marshal(resp) - if err != nil { - t.Fatalf("failed to marshal response as JSON: %v", err.Error()) - } - - expectedRespJSON, err := json.Marshal(test.response) - if err != nil { - t.Fatalf("failed to marshal expected response as JSON: %v", err.Error()) - } - - t.Fatalf( - "Response does not match, expected:\n%+v\ngot:\n%+v", - string(expectedRespJSON), - string(respJSON), - ) - } + assertAPIError(t, apiErr, test.errType) + assertAPIResponse(t, resp, test.response) } } } +func assertAPIError(t *testing.T, got *apiError, exp errorType) { + t.Helper() + + if got != nil { + if exp == errorNone { + t.Fatalf("Unexpected error: %s", got) + } + if exp != got.typ { + t.Fatalf("Expected error of type %q but got type %q (%q)", exp, got.typ, got) + } + return + } + if got == nil && exp != errorNone { + t.Fatalf("Expected error of type %q but got none", exp) + } +} + +func assertAPIResponse(t *testing.T, got interface{}, exp interface{}) { + if !reflect.DeepEqual(exp, got) { + respJSON, err := json.Marshal(got) + if err != nil { + t.Fatalf("failed to marshal response as JSON: %v", err.Error()) + } + + expectedRespJSON, err := json.Marshal(exp) + if err != nil { + t.Fatalf("failed to marshal expected response as JSON: %v", err.Error()) + } + + t.Fatalf( + "Response does not match, expected:\n%+v\ngot:\n%+v", + string(expectedRespJSON), + string(respJSON), + ) + } +} + func TestReadEndpoint(t *testing.T) { suite, err := promql.NewTest(t, ` load 1m @@ -944,6 +955,211 @@ func TestReadEndpoint(t *testing.T) { } } +type fakeDB struct { + err error + closer func() +} + +func (f *fakeDB) CleanTombstones() error { return f.err } +func (f *fakeDB) Delete(mint, maxt int64, ms ...tsdbLabels.Matcher) error { return f.err } +func (f *fakeDB) Dir() string { + dir, _ := ioutil.TempDir("", "fakeDB") + f.closer = func() { + os.RemoveAll(dir) + } + return dir +} +func (f *fakeDB) Snapshot(dir string, withHead bool) error { return f.err } + +func TestAdminEndpoints(t *testing.T) { + tsdb, tsdbWithError := &fakeDB{}, &fakeDB{err: fmt.Errorf("some error")} + snapshotAPI := func(api *API) apiFunc { return api.snapshot } + cleanAPI := func(api *API) apiFunc { return api.cleanTombstones } + deleteAPI := func(api *API) apiFunc { return api.deleteSeries } + + for i, tc := range []struct { + db *fakeDB + enableAdmin bool + endpoint func(api *API) apiFunc + method string + values url.Values + + errType errorType + }{ + // Tests for the snapshot endpoint. + { + db: tsdb, + enableAdmin: false, + endpoint: snapshotAPI, + + errType: errorUnavailable, + }, + { + db: tsdb, + enableAdmin: true, + endpoint: snapshotAPI, + + errType: errorNone, + }, + { + db: tsdb, + enableAdmin: true, + endpoint: snapshotAPI, + values: map[string][]string{"skip_head": []string{"true"}}, + + errType: errorNone, + }, + { + db: tsdb, + enableAdmin: true, + endpoint: snapshotAPI, + values: map[string][]string{"skip_head": []string{"xxx"}}, + + errType: errorBadData, + }, + { + db: tsdbWithError, + enableAdmin: true, + endpoint: snapshotAPI, + + errType: errorInternal, + }, + { + db: nil, + enableAdmin: true, + endpoint: snapshotAPI, + + errType: errorUnavailable, + }, + // Tests for the cleanTombstones endpoint. + { + db: tsdb, + enableAdmin: false, + endpoint: cleanAPI, + + errType: errorUnavailable, + }, + { + db: tsdb, + enableAdmin: true, + endpoint: cleanAPI, + + errType: errorNone, + }, + { + db: tsdbWithError, + enableAdmin: true, + endpoint: cleanAPI, + + errType: errorInternal, + }, + { + db: nil, + enableAdmin: true, + endpoint: cleanAPI, + + errType: errorUnavailable, + }, + // Tests for the deleteSeries endpoint. + { + db: tsdb, + enableAdmin: false, + endpoint: deleteAPI, + + errType: errorUnavailable, + }, + { + db: tsdb, + enableAdmin: true, + endpoint: deleteAPI, + + errType: errorBadData, + }, + { + db: tsdb, + enableAdmin: true, + endpoint: deleteAPI, + values: map[string][]string{"match[]": []string{"123"}}, + + errType: errorBadData, + }, + { + db: tsdb, + enableAdmin: true, + endpoint: deleteAPI, + values: map[string][]string{"match[]": []string{"up"}, "start": []string{"xxx"}}, + + errType: errorBadData, + }, + { + db: tsdb, + enableAdmin: true, + endpoint: deleteAPI, + values: map[string][]string{"match[]": []string{"up"}, "end": []string{"xxx"}}, + + errType: errorBadData, + }, + { + db: tsdb, + enableAdmin: true, + endpoint: deleteAPI, + values: map[string][]string{"match[]": []string{"up"}}, + + errType: errorNone, + }, + { + db: tsdb, + enableAdmin: true, + endpoint: deleteAPI, + values: map[string][]string{"match[]": []string{"up{job!=\"foo\"}", "{job=~\"bar.+\"}", "up{instance!~\"fred.+\"}"}}, + + errType: errorNone, + }, + { + db: tsdbWithError, + enableAdmin: true, + endpoint: deleteAPI, + values: map[string][]string{"match[]": []string{"up"}}, + + errType: errorInternal, + }, + { + db: nil, + enableAdmin: true, + endpoint: deleteAPI, + + errType: errorUnavailable, + }, + } { + tc := tc + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + api := &API{ + db: func() TSDBAdmin { + if tc.db != nil { + return tc.db + } + return nil + }, + ready: func(f http.HandlerFunc) http.HandlerFunc { return f }, + enableAdmin: tc.enableAdmin, + } + defer func() { + if tc.db != nil && tc.db.closer != nil { + tc.db.closer() + } + }() + + endpoint := tc.endpoint(api) + req, err := http.NewRequest(tc.method, fmt.Sprintf("?%s", tc.values.Encode()), nil) + if err != nil { + t.Fatalf("Error when creating test request: %s", err) + } + _, apiErr, _ := endpoint(req) + assertAPIError(t, apiErr, tc.errType) + }) + } +} + func TestRespondSuccess(t *testing.T) { s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { api := API{} diff --git a/web/web.go b/web/web.go index 7aa98a788..848bf82e6 100644 --- a/web/web.go +++ b/web/web.go @@ -225,7 +225,12 @@ func New(logger log.Logger, o *Options) *Handler { }, o.Flags, h.testReady, - h.options.TSDB, + func() api_v1.TSDBAdmin { + if db := h.options.TSDB(); db != nil { + return db + } + return nil + }, h.options.EnableAdminAPI, logger, h.ruleManager, diff --git a/web/web_test.go b/web/web_test.go index 99d41fcda..4c8ec63ec 100644 --- a/web/web_test.go +++ b/web/web_test.go @@ -287,6 +287,7 @@ func TestRoutePrefix(t *testing.T) { testutil.Ok(t, err) testutil.Equals(t, http.StatusOK, resp.StatusCode) } + func TestDebugHandler(t *testing.T) { for _, tc := range []struct { prefix, url string