Skip to content

Commit

Permalink
Adds a prepared query debug endpoint.
Browse files Browse the repository at this point in the history
James Phillips committed Mar 3, 2016
1 parent 89b24e3 commit 4ec826d
Showing 6 changed files with 405 additions and 3 deletions.
34 changes: 33 additions & 1 deletion command/agent/prepared_query_endpoint.go
Original file line number Diff line number Diff line change
@@ -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)
}
75 changes: 75 additions & 0 deletions command/agent/prepared_query_endpoint_test.go
Original file line number Diff line number Diff line change
@@ -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{}
47 changes: 47 additions & 0 deletions consul/prepared_query_endpoint.go
Original file line number Diff line number Diff line change
@@ -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.
192 changes: 190 additions & 2 deletions consul/prepared_query_endpoint_test.go
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions consul/structs/prepared_query.go
Original file line number Diff line number Diff line change
@@ -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
}
51 changes: 51 additions & 0 deletions website/source/docs/agent/http/query.html.markdown
Original file line number Diff line number Diff line change
@@ -34,6 +34,8 @@ The following endpoints are supported:
a prepared query
* [`/v1/query/<query or name>/execute`](#execute): Executes a
prepared query by its ID or optional name
* [`/v1/query/<query or name>/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.

### <a name="debug"></a> /v1/query/\<query or name\>/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 `<hidden>` 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": "<hidden>",
"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".

0 comments on commit 4ec826d

Please sign in to comment.