diff --git a/command/agent/prepared_query_endpoint.go b/command/agent/prepared_query_endpoint.go index af6ed7691..3e44cfa70 100644 --- a/command/agent/prepared_query_endpoint.go +++ b/command/agent/prepared_query_endpoint.go @@ -13,6 +13,7 @@ import ( const ( preparedQueryEndpoint = "PreparedQuery" preparedQueryExecuteSuffix = "/execute" + preparedQueryDebugSuffix = "/debug" ) // preparedQueryCreateResponse is used to wrap the query ID. @@ -124,6 +125,31 @@ func (s *HTTPServer) preparedQueryExecute(id string, resp http.ResponseWriter, r return reply, nil } +// preparedQueryDebug shows what a given name resolves to, which is useful for +// operators in a world with templates. +func (s *HTTPServer) preparedQueryDebug(id string, resp http.ResponseWriter, req *http.Request) (interface{}, error) { + args := structs.PreparedQueryExecuteRequest{ + QueryIDOrName: id, + } + if done := s.parse(resp, req, &args.Datacenter, &args.QueryOptions); done { + return nil, nil + } + + var reply structs.PreparedQueryDebugResponse + endpoint := s.agent.getEndpoint(preparedQueryEndpoint) + if err := s.agent.RPC(endpoint+".Debug", &args, &reply); err != nil { + // We have to check the string since the RPC sheds + // the specific error type. + if err.Error() == consul.ErrQueryNotFound.Error() { + resp.WriteHeader(404) + resp.Write([]byte(err.Error())) + return nil, nil + } + return nil, err + } + return reply, nil +} + // preparedQueryGet returns a single prepared query. func (s *HTTPServer) preparedQueryGet(id string, resp http.ResponseWriter, req *http.Request) (interface{}, error) { args := structs.PreparedQuerySpecificRequest{ @@ -197,16 +223,22 @@ func (s *HTTPServer) preparedQueryDelete(id string, resp http.ResponseWriter, re // particular query. func (s *HTTPServer) PreparedQuerySpecific(resp http.ResponseWriter, req *http.Request) (interface{}, error) { id := strings.TrimPrefix(req.URL.Path, "/v1/query/") - execute := false + + execute, debug := false, false if strings.HasSuffix(id, preparedQueryExecuteSuffix) { execute = true id = strings.TrimSuffix(id, preparedQueryExecuteSuffix) + } else if strings.HasSuffix(id, preparedQueryDebugSuffix) { + debug = true + id = strings.TrimSuffix(id, preparedQueryDebugSuffix) } switch req.Method { case "GET": if execute { return s.preparedQueryExecute(id, resp, req) + } else if debug { + return s.preparedQueryDebug(id, resp, req) } else { return s.preparedQueryGet(id, resp, req) } diff --git a/command/agent/prepared_query_endpoint_test.go b/command/agent/prepared_query_endpoint_test.go index ac095320c..487f2b6c4 100644 --- a/command/agent/prepared_query_endpoint_test.go +++ b/command/agent/prepared_query_endpoint_test.go @@ -25,6 +25,7 @@ type MockPreparedQuery struct { getFn func(*structs.PreparedQuerySpecificRequest, *structs.IndexedPreparedQueries) error listFn func(*structs.DCSpecificRequest, *structs.IndexedPreparedQueries) error executeFn func(*structs.PreparedQueryExecuteRequest, *structs.PreparedQueryExecuteResponse) error + debugFn func(*structs.PreparedQueryExecuteRequest, *structs.PreparedQueryDebugResponse) error } func (m *MockPreparedQuery) Apply(args *structs.PreparedQueryRequest, @@ -59,6 +60,14 @@ func (m *MockPreparedQuery) Execute(args *structs.PreparedQueryExecuteRequest, return fmt.Errorf("should not have called Execute") } +func (m *MockPreparedQuery) Debug(args *structs.PreparedQueryExecuteRequest, + reply *structs.PreparedQueryDebugResponse) error { + if m.debugFn != nil { + return m.debugFn(args, reply) + } + return fmt.Errorf("should not have called Debug") +} + func TestPreparedQuery_Create(t *testing.T) { httpTest(t, func(srv *HTTPServer) { m := MockPreparedQuery{} @@ -332,6 +341,72 @@ func TestPreparedQuery_Execute(t *testing.T) { }) } +func TestPreparedQuery_Debug(t *testing.T) { + httpTest(t, func(srv *HTTPServer) { + m := MockPreparedQuery{} + if err := srv.agent.InjectEndpoint("PreparedQuery", &m); err != nil { + t.Fatalf("err: %v", err) + } + + m.debugFn = func(args *structs.PreparedQueryExecuteRequest, reply *structs.PreparedQueryDebugResponse) error { + expected := &structs.PreparedQueryExecuteRequest{ + Datacenter: "dc1", + QueryIDOrName: "my-id", + QueryOptions: structs.QueryOptions{ + Token: "my-token", + RequireConsistent: true, + }, + } + if !reflect.DeepEqual(args, expected) { + t.Fatalf("bad: %v", args) + } + + // Just set something so we can tell this is returned. + reply.Query.Name = "hello" + return nil + } + + body := bytes.NewBuffer(nil) + req, err := http.NewRequest("GET", "/v1/query/my-id/debug?token=my-token&consistent=true", body) + if err != nil { + t.Fatalf("err: %v", err) + } + + resp := httptest.NewRecorder() + obj, err := srv.PreparedQuerySpecific(resp, req) + if err != nil { + t.Fatalf("err: %v", err) + } + if resp.Code != 200 { + t.Fatalf("bad code: %d", resp.Code) + } + r, ok := obj.(structs.PreparedQueryDebugResponse) + if !ok { + t.Fatalf("unexpected: %T", obj) + } + if r.Query.Name != "hello" { + t.Fatalf("bad: %v", r) + } + }) + + httpTest(t, func(srv *HTTPServer) { + body := bytes.NewBuffer(nil) + req, err := http.NewRequest("GET", "/v1/query/not-there/debug", body) + if err != nil { + t.Fatalf("err: %v", err) + } + + resp := httptest.NewRecorder() + _, err = srv.PreparedQuerySpecific(resp, req) + if err != nil { + t.Fatalf("err: %v", err) + } + if resp.Code != 404 { + t.Fatalf("bad code: %d", resp.Code) + } + }) +} + func TestPreparedQuery_Get(t *testing.T) { httpTest(t, func(srv *HTTPServer) { m := MockPreparedQuery{} diff --git a/consul/prepared_query_endpoint.go b/consul/prepared_query_endpoint.go index 6cd6d62ba..5fcbdbbf7 100644 --- a/consul/prepared_query_endpoint.go +++ b/consul/prepared_query_endpoint.go @@ -269,6 +269,53 @@ func (p *PreparedQuery) List(args *structs.DCSpecificRequest, reply *structs.Ind }) } +// Debug resolves a prepared query and returns the (possibly rendered template) +// to the caller. This is useful for letting operators figure out which query is +// picking up a given name. +func (p *PreparedQuery) Debug(args *structs.PreparedQueryExecuteRequest, + reply *structs.PreparedQueryDebugResponse) error { + if done, err := p.srv.forward("PreparedQuery.Debug", args, args, reply); done { + return err + } + defer metrics.MeasureSince([]string{"consul", "prepared-query", "debug"}, time.Now()) + + // We have to do this ourselves since we are not doing a blocking RPC. + p.srv.setQueryMeta(&reply.QueryMeta) + if args.RequireConsistent { + if err := p.srv.consistentRead(); err != nil { + return err + } + } + + // Try to locate the query. + state := p.srv.fsm.State() + _, query, err := state.PreparedQueryResolve(args.QueryIDOrName) + if err != nil { + return err + } + if query == nil { + return ErrQueryNotFound + } + + // Place the query into a list so we can run the standard ACL filter on + // it. + queries := &structs.IndexedPreparedQueries{ + Queries: structs.PreparedQueries{query}, + } + if err := p.srv.filterACL(args.Token, queries); err != nil { + return err + } + + // If the query was filtered out, return an error. + if len(queries.Queries) == 0 { + p.srv.logger.Printf("[WARN] consul.prepared_query: Debug on prepared query '%s' denied due to ACLs", query.ID) + return permissionDeniedErr + } + + reply.Query = *(queries.Queries[0]) + return nil +} + // Execute runs a prepared query and returns the results. This will perform the // failover logic if no local results are available. This is typically called as // part of a DNS lookup, or when executing prepared queries from the HTTP API. diff --git a/consul/prepared_query_endpoint_test.go b/consul/prepared_query_endpoint_test.go index d95680902..1ad05fb63 100644 --- a/consul/prepared_query_endpoint_test.go +++ b/consul/prepared_query_endpoint_test.go @@ -579,7 +579,7 @@ func TestPreparedQuery_parseQuery(t *testing.T) { } } -func TestPreparedQuery_ACLDeny_Template(t *testing.T) { +func TestPreparedQuery_ACLDeny_Catchall_Template(t *testing.T) { dir1, s1 := testServerWithConfig(t, func(c *Config) { c.ACLDatacenter = "dc1" c.ACLMasterToken = "root" @@ -681,7 +681,6 @@ func TestPreparedQuery_ACLDeny_Template(t *testing.T) { Datacenter: "dc1", QueryID: query.Query.ID, } - var resp structs.IndexedPreparedQueries err := msgpackrpc.CallWithCodec(codec, "PreparedQuery.Get", req, &resp) if err == nil || !strings.Contains(err.Error(), permissionDenied) { @@ -734,6 +733,63 @@ func TestPreparedQuery_ACLDeny_Template(t *testing.T) { t.Fatalf("bad: %v", actual) } } + + // Debugging should also be denied without a token. + { + req := &structs.PreparedQueryExecuteRequest{ + Datacenter: "dc1", + QueryIDOrName: "anything", + } + var resp structs.PreparedQueryDebugResponse + err := msgpackrpc.CallWithCodec(codec, "PreparedQuery.Debug", req, &resp) + if err == nil || !strings.Contains(err.Error(), permissionDenied) { + t.Fatalf("bad: %v", err) + } + } + + // The user can debug and see the redacted token. + query.Query.Token = redactedToken + query.Query.Service.Service = "anything" + { + req := &structs.PreparedQueryExecuteRequest{ + Datacenter: "dc1", + QueryIDOrName: "anything", + QueryOptions: structs.QueryOptions{Token: token}, + } + var resp structs.PreparedQueryDebugResponse + err := msgpackrpc.CallWithCodec(codec, "PreparedQuery.Debug", req, &resp) + if err != nil { + t.Fatalf("err: %v", err) + } + + actual := &resp.Query + actual.CreateIndex, actual.ModifyIndex = 0, 0 + if !reflect.DeepEqual(actual, query.Query) { + t.Fatalf("bad: %v", actual) + } + } + + // Make sure the management token can also debug and see the token. + query.Query.Token = "5e1e24e5-1329-f86f-18c6-3d3734edb2cd" + query.Query.Service.Service = "anything" + { + req := &structs.PreparedQueryExecuteRequest{ + Datacenter: "dc1", + QueryIDOrName: "anything", + QueryOptions: structs.QueryOptions{Token: "root"}, + } + var resp structs.PreparedQueryDebugResponse + err := msgpackrpc.CallWithCodec(codec, "PreparedQuery.Debug", req, &resp) + if err != nil { + t.Fatalf("err: %v", err) + } + + actual := &resp.Query + actual.CreateIndex, actual.ModifyIndex = 0, 0 + if !reflect.DeepEqual(actual, query.Query) { + t.Fatalf("bad: %v", actual) + } + } } func TestPreparedQuery_Get(t *testing.T) { @@ -1161,6 +1217,138 @@ func TestPreparedQuery_List(t *testing.T) { } } +func TestPreparedQuery_Debug(t *testing.T) { + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" + c.ACLMasterToken = "root" + c.ACLDefaultPolicy = "deny" + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + codec := rpcClient(t, s1) + defer codec.Close() + + testutil.WaitForLeader(t, s1.RPC, "dc1") + + // Create an ACL with write permissions for prod- queries. + var token string + { + var rules = ` + query "prod-" { + policy = "write" + } + ` + + req := structs.ACLRequest{ + Datacenter: "dc1", + Op: structs.ACLSet, + ACL: structs.ACL{ + Name: "User token", + Type: structs.ACLTypeClient, + Rules: rules, + }, + WriteRequest: structs.WriteRequest{Token: "root"}, + } + if err := msgpackrpc.CallWithCodec(codec, "ACL.Apply", &req, &token); err != nil { + t.Fatalf("err: %v", err) + } + } + + // Set up a template. + query := structs.PreparedQueryRequest{ + Datacenter: "dc1", + Op: structs.PreparedQueryCreate, + Query: &structs.PreparedQuery{ + Name: "prod-", + Token: "5e1e24e5-1329-f86f-18c6-3d3734edb2cd", + Template: structs.QueryTemplateOptions{ + Type: structs.QueryTemplateTypeNamePrefixMatch, + }, + Service: structs.ServiceQuery{ + Service: "${name.full}", + }, + }, + WriteRequest: structs.WriteRequest{Token: token}, + } + var reply string + if err := msgpackrpc.CallWithCodec(codec, "PreparedQuery.Apply", &query, &reply); err != nil { + t.Fatalf("err: %v", err) + } + + // Debug via the management token. + query.Query.ID = reply + query.Query.Service.Service = "prod-redis" + { + req := &structs.PreparedQueryExecuteRequest{ + Datacenter: "dc1", + QueryIDOrName: "prod-redis", + QueryOptions: structs.QueryOptions{Token: "root"}, + } + var resp structs.PreparedQueryDebugResponse + err := msgpackrpc.CallWithCodec(codec, "PreparedQuery.Debug", req, &resp) + if err != nil { + t.Fatalf("err: %v", err) + } + + actual := &resp.Query + actual.CreateIndex, actual.ModifyIndex = 0, 0 + if !reflect.DeepEqual(actual, query.Query) { + t.Fatalf("bad: %v", actual) + } + } + + // Debug via the user token, which will redact the captured token. + query.Query.Token = redactedToken + query.Query.Service.Service = "prod-redis" + { + req := &structs.PreparedQueryExecuteRequest{ + Datacenter: "dc1", + QueryIDOrName: "prod-redis", + QueryOptions: structs.QueryOptions{Token: token}, + } + var resp structs.PreparedQueryDebugResponse + err := msgpackrpc.CallWithCodec(codec, "PreparedQuery.Debug", req, &resp) + if err != nil { + t.Fatalf("err: %v", err) + } + + actual := &resp.Query + actual.CreateIndex, actual.ModifyIndex = 0, 0 + if !reflect.DeepEqual(actual, query.Query) { + t.Fatalf("bad: %v", actual) + } + } + + // Debugging should be denied without a token, since the user isn't + // allowed to see the query. + { + req := &structs.PreparedQueryExecuteRequest{ + Datacenter: "dc1", + QueryIDOrName: "prod-redis", + } + var resp structs.PreparedQueryDebugResponse + err := msgpackrpc.CallWithCodec(codec, "PreparedQuery.Debug", req, &resp) + if err == nil || !strings.Contains(err.Error(), permissionDenied) { + t.Fatalf("bad: %v", err) + } + } + + // Try to debug a bogus ID. + { + req := &structs.PreparedQueryExecuteRequest{ + Datacenter: "dc1", + QueryIDOrName: generateUUID(), + QueryOptions: structs.QueryOptions{Token: "root"}, + } + var resp structs.IndexedPreparedQueries + if err := msgpackrpc.CallWithCodec(codec, "PreparedQuery.Debug", req, &resp); err != nil { + if err.Error() != ErrQueryNotFound.Error() { + t.Fatalf("err: %v", err) + } + } + } +} + // This is a beast of a test, but the setup is so extensive it makes sense to // walk through the different cases once we have it up. This is broken into // sections so it's still pretty easy to read. diff --git a/consul/structs/prepared_query.go b/consul/structs/prepared_query.go index 0e95fc429..dad94c5f0 100644 --- a/consul/structs/prepared_query.go +++ b/consul/structs/prepared_query.go @@ -231,3 +231,12 @@ type PreparedQueryExecuteResponse struct { // QueryMeta has freshness information about the query. QueryMeta } + +// PreparedQueryDebugResponse has the results when debugging a query. +type PreparedQueryDebugResponse struct { + // Query has the fully-rendered query. + Query PreparedQuery + + // QueryMeta has freshness information about the query. + QueryMeta +} diff --git a/website/source/docs/agent/http/query.html.markdown b/website/source/docs/agent/http/query.html.markdown index 6977a3270..21336afee 100644 --- a/website/source/docs/agent/http/query.html.markdown +++ b/website/source/docs/agent/http/query.html.markdown @@ -34,6 +34,8 @@ The following endpoints are supported: a prepared query * [`/v1/query//execute`](#execute): Executes a prepared query by its ID or optional name +* [`/v1/query//debug`](#debug): Debugs a + prepared query by its ID or optional name Not all endpoints support blocking queries and all consistency modes, see details in the sections below. @@ -229,6 +231,9 @@ above with a `Regexp` field set to `^geo-db-(.*?)-([^\-]+?)$` would return "master" for `${match(2)}`. If the regular expression doesn't match, or an invalid index is given, then `${match(N)}` will return an empty string. +See the [query debug](#debug) endpoint which is useful for testing interpolations +and determining which query is handling a given name. + Using templates it's possible to apply prepared query behaviors to many services with a single template. Here's an example template that matches any query and applies a failover policy to it: @@ -433,3 +438,49 @@ and `Failovers` has the number of remote datacenters that were queried while executing the query. This provides some insight into where the data came from. This will be zero during non-failover operations where there were healthy nodes found in the local datacenter. + +### /v1/query/\/debug + +The query debug endpoint supports only the `GET` method and is used to see +a fully-rendered query for a given name. This is especially useful for finding +which [prepared query template](#templates) matches a given name, and what the +final query looks like after interpolation. + +By default, the datacenter of the agent is queried; however, the `dc` can be +provided using the "?dc=" query parameter. This endpoint does not support +blocking queries, but it does support all consistency modes. + +If ACLs are enabled, then the client will only see prepared queries for which their +token has `query` read privileges. A management token will be able to see all +prepared queries. Tokens will be redacted and displayed as `` unless a +management token is used. + +If the query does not exist then a 404 status code will be returned. Otherwise, +a JSON body will be returned like this: + +```javascript +{ + "Query": { + "ID": "8f246b77-f3e1-ff88-5b48-8ec93abf3e05", + "Name": "my-query", + "Session": "adf4238a-882b-9ddc-4a9d-5b6758e4159e", + "Token": "", + "Name": "geo-db", + "Template" { + "Type": "name_prefix_match", + "Regexp": "^geo-db-(.*?)-([^\-]+?)$" + }, + "Service": { + "Service": "mysql-customer", + "Failover": { + "NearestN": 3, + "Datacenters": ["dc1", "dc2"] + }, + "OnlyPassing": true, + "Tags": ["master"] + } +} +``` + +Note that even though this query is a template, it is shown with its `Service` +fields interpolated based on the example query name "geo-db-customer-master".