1// Copyright (c) The Thanos Authors.
2// Licensed under the Apache License 2.0.
3
4package v1
5
6import (
7	"context"
8	"encoding/json"
9	"fmt"
10	"io/ioutil"
11	"net/http"
12	"net/url"
13	"os"
14	"reflect"
15	"testing"
16	"time"
17
18	"github.com/go-kit/kit/log"
19	"github.com/prometheus/client_golang/prometheus"
20	"github.com/prometheus/common/model"
21	"github.com/prometheus/common/route"
22	"github.com/prometheus/prometheus/pkg/labels"
23	"github.com/prometheus/prometheus/promql"
24	"github.com/prometheus/prometheus/rules"
25	"github.com/prometheus/prometheus/storage"
26	"github.com/prometheus/prometheus/storage/tsdb"
27	qapi "github.com/thanos-io/thanos/pkg/query/api"
28	thanosrule "github.com/thanos-io/thanos/pkg/rule"
29	"github.com/thanos-io/thanos/pkg/store/storepb"
30)
31
32// NewStorage returns a new storage for testing purposes
33// that removes all associated files on closing.
34func newStorage(t *testing.T) storage.Storage {
35	dir, err := ioutil.TempDir("", "test_storage")
36	if err != nil {
37		t.Fatalf("Opening test dir failed: %s", err)
38	}
39
40	// Tests just load data for a series sequentially. Thus we
41	// need a long appendable window.
42	db, err := tsdb.Open(dir, nil, nil, &tsdb.Options{
43		MinBlockDuration: model.Duration(24 * time.Hour),
44		MaxBlockDuration: model.Duration(24 * time.Hour),
45	})
46	if err != nil {
47		t.Fatalf("Opening test storage failed: %s", err)
48	}
49	return testStorage{Storage: tsdb.Adapter(db, int64(0)), dir: dir}
50}
51
52type testStorage struct {
53	storage.Storage
54	dir string
55}
56
57func (s testStorage) Close() error {
58	if err := s.Storage.Close(); err != nil {
59		return err
60	}
61	return os.RemoveAll(s.dir)
62}
63
64type rulesRetrieverMock struct {
65	testing *testing.T
66}
67
68func (m rulesRetrieverMock) RuleGroups() []thanosrule.Group {
69	storage := newStorage(m.testing)
70
71	engineOpts := promql.EngineOpts{
72		Logger:        nil,
73		Reg:           nil,
74		MaxConcurrent: 10,
75		MaxSamples:    10,
76		Timeout:       100 * time.Second,
77	}
78
79	engine := promql.NewEngine(engineOpts)
80	opts := &rules.ManagerOptions{
81		QueryFunc:  rules.EngineQueryFunc(engine, storage),
82		Appendable: storage,
83		Context:    context.Background(),
84		Logger:     log.NewNopLogger(),
85	}
86
87	var r []rules.Rule
88	for _, ar := range alertingRules(m.testing) {
89		r = append(r, ar)
90	}
91
92	recordingExpr, err := promql.ParseExpr(`vector(1)`)
93	if err != nil {
94		m.testing.Fatalf("unable to parse alert expression: %s", err)
95	}
96	recordingRule := rules.NewRecordingRule("recording-rule-1", recordingExpr, labels.Labels{})
97	r = append(r, recordingRule)
98
99	return []thanosrule.Group{
100		thanosrule.Group{
101			Group:                   rules.NewGroup("grp", "/path/to/file", time.Second, r, false, opts),
102			PartialResponseStrategy: storepb.PartialResponseStrategy_WARN,
103		},
104	}
105}
106
107func (m rulesRetrieverMock) AlertingRules() []thanosrule.AlertingRule {
108	var ars []thanosrule.AlertingRule
109	for _, ar := range alertingRules(m.testing) {
110		ars = append(ars, thanosrule.AlertingRule{AlertingRule: ar})
111	}
112	return ars
113}
114
115func alertingRules(t *testing.T) []*rules.AlertingRule {
116	expr1, err := promql.ParseExpr(`absent(test_metric3) != 1`)
117	if err != nil {
118		t.Fatalf("unable to parse alert expression: %s", err)
119	}
120	expr2, err := promql.ParseExpr(`up == 1`)
121	if err != nil {
122		t.Fatalf("unable to parse alert expression: %s", err)
123	}
124
125	return []*rules.AlertingRule{
126		rules.NewAlertingRule(
127			"test_metric3",
128			expr1,
129			time.Second,
130			labels.Labels{},
131			labels.Labels{},
132			labels.Labels{},
133			true,
134			log.NewNopLogger(),
135		),
136		rules.NewAlertingRule(
137			"test_metric4",
138			expr2,
139			time.Second,
140			labels.Labels{},
141			labels.Labels{},
142			labels.Labels{},
143			true,
144			log.NewNopLogger(),
145		),
146	}
147}
148
149func TestEndpoints(t *testing.T) {
150	suite, err := promql.NewTest(t, `
151		load 1m
152			test_metric1{foo="bar"} 0+100x100
153			test_metric1{foo="boo"} 1+0x100
154			test_metric2{foo="boo"} 1+0x100
155	`)
156	if err != nil {
157		t.Fatal(err)
158	}
159	defer suite.Close()
160
161	if err := suite.Run(); err != nil {
162		t.Fatal(err)
163	}
164
165	var algr rulesRetrieverMock
166	algr.testing = t
167	algr.AlertingRules()
168	algr.RuleGroups()
169
170	t.Run("local", func(t *testing.T) {
171		var algr rulesRetrieverMock
172		algr.testing = t
173		algr.AlertingRules()
174		algr.RuleGroups()
175		api := NewAPI(
176			nil,
177			prometheus.DefaultRegisterer,
178			algr,
179		)
180		testEndpoints(t, api)
181	})
182}
183
184func testEndpoints(t *testing.T, api *API) {
185	type test struct {
186		endpointFn   qapi.ApiFunc
187		endpointName string
188		params       map[string]string
189		query        url.Values
190		response     interface{}
191	}
192	var tests = []test{
193		{
194			endpointFn:   api.rules,
195			endpointName: "rules",
196			response: &RuleDiscovery{
197				RuleGroups: []*RuleGroup{
198					{
199						Name:                    "grp",
200						File:                    "",
201						Interval:                1,
202						PartialResponseStrategy: "WARN",
203						Rules: []rule{
204							alertingRule{
205								Name:                    "test_metric3",
206								Query:                   "absent(test_metric3) != 1",
207								Duration:                1,
208								Labels:                  labels.Labels{},
209								Annotations:             labels.Labels{},
210								Alerts:                  []*Alert{},
211								Health:                  "unknown",
212								Type:                    "alerting",
213								PartialResponseStrategy: "WARN",
214							},
215							alertingRule{
216								Name:                    "test_metric4",
217								Query:                   "up == 1",
218								Duration:                1,
219								Labels:                  labels.Labels{},
220								Annotations:             labels.Labels{},
221								Alerts:                  []*Alert{},
222								Health:                  "unknown",
223								Type:                    "alerting",
224								PartialResponseStrategy: "WARN",
225							},
226							recordingRule{
227								Name:   "recording-rule-1",
228								Query:  "vector(1)",
229								Labels: labels.Labels{},
230								Health: "unknown",
231								Type:   "recording",
232							},
233						},
234					},
235				},
236			},
237		},
238	}
239
240	methods := func(f qapi.ApiFunc) []string {
241		return []string{http.MethodGet}
242	}
243
244	request := func(m string, q url.Values) (*http.Request, error) {
245		return http.NewRequest(m, fmt.Sprintf("http://example.com?%s", q.Encode()), nil)
246	}
247	for _, test := range tests {
248		for _, method := range methods(test.endpointFn) {
249			t.Run(fmt.Sprintf("endpoint=%s/method=%s/query=%q", test.endpointName, method, test.query.Encode()), func(t *testing.T) {
250				// Build a context with the correct request params.
251				ctx := context.Background()
252				for p, v := range test.params {
253					ctx = route.WithParam(ctx, p, v)
254				}
255
256				req, err := request(method, test.query)
257				if err != nil {
258					t.Fatal(err)
259				}
260				endpoint, errors, apiError := test.endpointFn(req.WithContext(ctx))
261
262				if errors != nil {
263					t.Fatalf("Unexpected errors: %s", errors)
264					return
265				}
266				assertAPIError(t, apiError)
267				assertAPIResponse(t, endpoint, test.response)
268			})
269		}
270	}
271}
272
273func assertAPIError(t *testing.T, got *qapi.ApiError) {
274	t.Helper()
275	if got != nil {
276		t.Fatalf("Unexpected error: %s", got)
277	}
278}
279
280func assertAPIResponse(t *testing.T, got interface{}, exp interface{}) {
281	t.Helper()
282	if !reflect.DeepEqual(exp, got) {
283		respJSON, err := json.Marshal(got)
284		if err != nil {
285			t.Fatalf("failed to marshal response as JSON: %v", err.Error())
286		}
287
288		expectedRespJSON, err := json.Marshal(exp)
289		if err != nil {
290			t.Fatalf("failed to marshal expected response as JSON: %v", err.Error())
291		}
292
293		t.Fatalf(
294			"Response does not match, expected:\n%+v\ngot:\n%+v",
295			string(expectedRespJSON),
296			string(respJSON),
297		)
298	}
299}
300