diff --git a/acl/acl.go b/acl/acl.go index b1f6699e5..5e3d79c23 100644 --- a/acl/acl.go +++ b/acl/acl.go @@ -410,6 +410,35 @@ func (a *ACL) AllowNodePool(pool string) bool { return !capabilities.Check(PolicyDeny) } +// AllowNodePoolSearch returns true if any operation is allowed in at least one +// node pool. +// +// This is a very loose check and is expected that callers perform more precise +// verification later. +func (a *ACL) AllowNodePoolSearch() bool { + // Hot path if ACL is not enabled or token is management. + if a == nil || a.management { + return true + } + + // Check for any non-deny capabilities. + iter := a.nodePools.Root().Iterator() + for _, capability, ok := iter.Next(); ok; _, capability, ok = iter.Next() { + if !capability.Check(NodePoolCapabilityDeny) { + return true + } + } + + iter = a.wildcardNodePools.Root().Iterator() + for _, capability, ok := iter.Next(); ok; _, capability, ok = iter.Next() { + if !capability.Check(NodePoolCapabilityDeny) { + return true + } + } + + return false +} + // AllowHostVolumeOperation checks if a given operation is allowed for a host volume func (a *ACL) AllowHostVolumeOperation(hv string, op string) bool { // Hot path management tokens diff --git a/nomad/search_endpoint.go b/nomad/search_endpoint.go index de64da10f..5e2503901 100644 --- a/nomad/search_endpoint.go +++ b/nomad/search_endpoint.go @@ -33,6 +33,7 @@ var ( structs.Allocs, structs.Jobs, structs.Nodes, + structs.NodePools, structs.Evals, structs.Deployments, structs.Plugins, @@ -75,6 +76,8 @@ func (s *Search) getPrefixMatches(iter memdb.ResultIterator, prefix string) ([]s id = t.ID case *structs.Node: id = t.ID + case *structs.NodePool: + id = t.Name case *structs.Deployment: id = t.ID case *structs.CSIPlugin: @@ -216,6 +219,10 @@ func (s *Search) fuzzyMatchSingle(raw interface{}, text string) (structs.Context name = t.Name scope = []string{t.ID} ctx = structs.Nodes + case *structs.NodePool: + name = t.Name + scope = []string{t.Name} + ctx = structs.NodePools case *structs.Namespace: name = t.Name ctx = structs.Namespaces @@ -381,6 +388,15 @@ func getResourceIter(context structs.Context, aclObj *acl.ACL, namespace, prefix return store.AllocsByIDPrefix(ws, namespace, prefix, state.SortDefault) case structs.Nodes: return store.NodesByIDPrefix(ws, prefix) + case structs.NodePools: + iter, err := store.NodePoolsByNamePrefix(ws, prefix, state.SortDefault) + if err != nil { + return nil, err + } + if aclObj == nil || aclObj.IsManagement() { + return iter, nil + } + return memdb.NewFilterIterator(iter, nodePoolCapFilter(aclObj)), nil case structs.Deployments: return store.DeploymentsByIDPrefix(ws, namespace, prefix, state.SortDefault) case structs.Plugins: @@ -449,6 +465,17 @@ func getFuzzyResourceIterator(context structs.Context, aclObj *acl.ACL, namespac } return store.Nodes(ws) + case structs.NodePools: + iter, err := store.NodePools(ws, state.SortDefault) + if err != nil { + return nil, err + } + + if aclObj == nil || aclObj.IsManagement() { + return iter, nil + } + return memdb.NewFilterIterator(iter, nodePoolCapFilter(aclObj)), nil + case structs.Plugins: if wildcard(namespace) { iter, err := store.CSIPlugins(ws) @@ -507,6 +534,15 @@ func nsCapFilter(aclObj *acl.ACL) memdb.FilterFunc { } } +// nodePoolCapFilter produces a memdb.FilterFunc for removing node pools not +// accessible by aclObj during a table scan. +func nodePoolCapFilter(aclObj *acl.ACL) memdb.FilterFunc { + return func(v interface{}) bool { + pool := v.(*structs.NodePool) + return !aclObj.AllowNodePoolOperation(pool.Name, acl.NodePoolCapabilityRead) + } +} + // If the length of a prefix is odd, return a subset to the last even character // This only applies to UUIDs, jobs are excluded func roundUUIDDownIfOdd(prefix string, context structs.Context) string { @@ -633,11 +669,12 @@ func sufficientSearchPerms(aclObj *acl.ACL, namespace string, context structs.Co } nodeRead := aclObj.AllowNodeRead() + allowNodePool := aclObj.AllowNodePoolSearch() allowNS := aclObj.AllowNamespace(namespace) jobRead := aclObj.AllowNsOp(namespace, acl.NamespaceCapabilityReadJob) allowEnt := sufficientSearchPermsEnt(aclObj) - if !nodeRead && !allowNS && !allowEnt && !jobRead { + if !nodeRead && !allowNodePool && !allowNS && !allowEnt && !jobRead { return false } @@ -647,6 +684,12 @@ func sufficientSearchPerms(aclObj *acl.ACL, namespace string, context structs.Co switch context { case structs.Nodes: return nodeRead + case structs.NodePools: + // The search term alone is not enough to determine if the token is + // allowed to access the given prefix since it may not match node pool + // label in the policy. Node pools will be filtered when iterating over + // the results. + return true case structs.Namespaces: return allowNS case structs.Allocs, structs.Deployments, structs.Evals, structs.Jobs: @@ -673,7 +716,7 @@ func sufficientSearchPerms(aclObj *acl.ACL, namespace string, context structs.Co // // These types are available for fuzzy searching: // -// Nodes, Namespaces, Jobs, Allocs, Plugins +// Nodes, Node Pools, Namespaces, Jobs, Allocs, Plugins // // Jobs are a special case that expand into multiple types, and whose return // values include Scope which is a descending list of IDs of parent objects, @@ -891,6 +934,10 @@ func filteredSearchContexts(aclObj *acl.ACL, namespace string, context structs.C if aclObj.AllowNodeRead() { available = append(available, c) } + case structs.NodePools: + if aclObj.AllowNodePoolSearch() { + available = append(available, c) + } case structs.Volumes: if volRead { available = append(available, c) diff --git a/nomad/search_endpoint_test.go b/nomad/search_endpoint_test.go index 59cecdde4..90ec32a81 100644 --- a/nomad/search_endpoint_test.go +++ b/nomad/search_endpoint_test.go @@ -17,6 +17,7 @@ import ( "github.com/hashicorp/nomad/nomad/state" "github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/testutil" + "github.com/shoenig/test/must" "github.com/stretchr/testify/require" ) @@ -483,6 +484,201 @@ func TestSearch_PrefixSearch_Node(t *testing.T) { require.Equal(t, uint64(100), resp.Index) } +func TestSearch_PrefixSearch_NodePool(t *testing.T) { + ci.Parallel(t) + + // Start test server. + s, cleanupS := TestServer(t, nil) + defer cleanupS() + + codec := rpcClient(t, s) + testutil.WaitForLeader(t, s.RPC) + + // Populate state with test node pools. + fsmState := s.fsm.State() + dev1 := &structs.NodePool{Name: "dev-1"} + dev2 := &structs.NodePool{Name: "dev-2"} + prod := &structs.NodePool{Name: "prod"} + + err := fsmState.UpsertNodePools(structs.MsgTypeTestSetup, 1000, []*structs.NodePool{dev1, dev2, prod}) + must.NoError(t, err) + + // Run test cases. + testCases := []struct { + name string + prefix string + context structs.Context + expected []string + }{ + { + name: "prefix match", + prefix: "dev", + context: structs.NodePools, + expected: []string{dev1.Name, dev2.Name}, + }, + { + name: "prefix match - all", + prefix: "dev", + context: structs.All, + expected: []string{dev1.Name, dev2.Name}, + }, + { + name: "empty prefix", + prefix: "", + context: structs.NodePools, + expected: []string{ + structs.NodePoolAll, structs.NodePoolDefault, + dev1.Name, dev2.Name, prod.Name, + }, + }, + { + name: "other context", + prefix: "dev", + context: structs.Jobs, + expected: []string{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + req := &structs.SearchRequest{ + Prefix: tc.prefix, + Context: tc.context, + QueryOptions: structs.QueryOptions{ + Region: "global", + }, + } + var resp structs.SearchResponse + err := msgpackrpc.CallWithCodec(codec, "Search.PrefixSearch", req, &resp) + must.NoError(t, err) + must.Len(t, len(tc.expected), resp.Matches[structs.NodePools]) + + for k, v := range resp.Matches { + switch k { + case structs.NodePools: + must.SliceContainsAll(t, v, tc.expected) + default: + must.Len(t, 0, v, must.Sprintf("found %d results in %v: %v", len(v), k, v)) + } + } + }) + } +} + +func TestSearch_PrefixSearch_NodePool_ACL(t *testing.T) { + ci.Parallel(t) + + // Start test server with ACL. + s, root, cleanupS := TestACLServer(t, nil) + defer cleanupS() + + codec := rpcClient(t, s) + testutil.WaitForLeader(t, s.RPC) + + // Populate state with test node pools and ACL policies. + fsmState := s.fsm.State() + + dev1 := &structs.NodePool{Name: "dev-1"} + dev2 := &structs.NodePool{Name: "dev-2"} + prod := &structs.NodePool{Name: "prod"} + err := fsmState.UpsertNodePools(structs.MsgTypeTestSetup, 1000, []*structs.NodePool{dev1, dev2, prod}) + must.NoError(t, err) + + devToken := mock.CreatePolicyAndToken(t, s.fsm.State(), 1001, "dev-node-pools", + mock.NodePoolPolicy("dev-*", "read", nil), + ) + noPolicyToken := mock.CreateToken(t, s.fsm.State(), 1003, nil) + allPoolsToken := mock.CreatePolicyAndToken(t, s.fsm.State(), 1005, "all-node-pools", + mock.NodePoolPolicy("*", "read", nil), + ) + denyDevToken := mock.CreatePolicyAndToken(t, s.fsm.State(), 1007, "deny-dev-node-pools", + mock.NodePoolPolicy("dev-*", "deny", nil), + ) + + // Run test cases. + testCases := []struct { + name string + token string + prefix string + expected []string + }{ + { + name: "management token has access to all", + token: root.SecretID, + prefix: "", + expected: []string{ + structs.NodePoolAll, structs.NodePoolDefault, + dev1.Name, dev2.Name, prod.Name, + }, + }, + { + name: "all pools access", + token: allPoolsToken.SecretID, + prefix: "", + expected: []string{ + structs.NodePoolAll, structs.NodePoolDefault, + dev1.Name, dev2.Name, prod.Name, + }, + }, + { + name: "only return what token has access", + token: devToken.SecretID, + prefix: "dev", + expected: []string{dev1.Name, dev2.Name}, + }, + { + name: "no results if token doesn't have access", + token: devToken.SecretID, + prefix: "prod", + expected: []string{}, + }, + { + name: "no results if token is denied", + token: denyDevToken.SecretID, + prefix: "dev", + expected: []string{}, + }, + { + name: "no policy", + token: noPolicyToken.SecretID, + prefix: "", + expected: []string{}, + }, + { + name: "no token", + token: "", + prefix: "", + expected: []string{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + req := &structs.SearchRequest{ + Prefix: tc.prefix, + Context: structs.NodePools, + QueryOptions: structs.QueryOptions{ + Region: "global", + AuthToken: tc.token, + }, + } + var resp structs.SearchResponse + err := msgpackrpc.CallWithCodec(codec, "Search.PrefixSearch", req, &resp) + must.NoError(t, err) + must.Len(t, len(tc.expected), resp.Matches[structs.NodePools]) + + for k, v := range resp.Matches { + switch k { + case structs.NodePools: + must.SliceContainsAll(t, v, tc.expected) + default: + must.Len(t, 0, v, must.Sprintf("found %d results in %v: %v", len(v), k, v)) + } + } + }) + } +} + func TestSearch_PrefixSearch_Deployment(t *testing.T) { ci.Parallel(t) @@ -1283,6 +1479,206 @@ func TestSearch_FuzzySearch_Node(t *testing.T) { require.Equal(t, uint64(100), resp.Index) } +func TestSearch_FuzzySearch_NodePool(t *testing.T) { + ci.Parallel(t) + + // Start test server. + s, cleanupS := TestServer(t, nil) + defer cleanupS() + + codec := rpcClient(t, s) + testutil.WaitForLeader(t, s.RPC) + + // Populate state with test node pools. + fsmState := s.fsm.State() + devEng := &structs.NodePool{Name: "dev-eng"} + devInfra := &structs.NodePool{Name: "dev-infra"} + prodEng := &structs.NodePool{Name: "prod-eng"} + + err := fsmState.UpsertNodePools(structs.MsgTypeTestSetup, 1000, []*structs.NodePool{devEng, devInfra, prodEng}) + must.NoError(t, err) + + // Run test cases. + testCases := []struct { + name string + text string + context structs.Context + expected []string + expectedErr string + }{ + { + name: "fuzzy match", + text: "eng", + context: structs.NodePools, + expected: []string{devEng.Name, prodEng.Name}, + }, + { + name: "fuzzy match - all", + text: "eng", + context: structs.All, + expected: []string{devEng.Name, prodEng.Name}, + }, + { + name: "empty prefix", + text: "", + context: structs.NodePools, + expectedErr: "search query must be at least 2 characters", + }, + { + name: "other context", + text: "eng", + context: structs.Jobs, + expected: []string{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + req := &structs.FuzzySearchRequest{ + Text: tc.text, + Context: tc.context, + QueryOptions: structs.QueryOptions{ + Region: "global", + }, + } + var resp structs.FuzzySearchResponse + err := msgpackrpc.CallWithCodec(codec, "Search.FuzzySearch", req, &resp) + if tc.expectedErr != "" { + must.ErrorContains(t, err, tc.expectedErr) + return + } + must.NoError(t, err) + must.Len(t, len(tc.expected), resp.Matches[structs.NodePools]) + + for k, v := range resp.Matches { + switch k { + case structs.NodePools: + got := make([]string, len(v)) + for i, m := range v { + got[i] = m.ID + } + must.SliceContainsAll(t, got, tc.expected) + default: + must.Len(t, 0, v, must.Sprintf("found %d results in %v: %v", len(v), k, v)) + } + } + }) + } +} + +func TestSearch_FuzzySearch_NodePool_ACL(t *testing.T) { + ci.Parallel(t) + + // Start test server with ACL. + s, root, cleanupS := TestACLServer(t, nil) + defer cleanupS() + + codec := rpcClient(t, s) + testutil.WaitForLeader(t, s.RPC) + + // Populate state with test node pools and ACL policies. + fsmState := s.fsm.State() + + devEng := &structs.NodePool{Name: "dev-eng"} + devInfra := &structs.NodePool{Name: "dev-infra"} + prodEng := &structs.NodePool{Name: "prod-eng"} + + err := fsmState.UpsertNodePools(structs.MsgTypeTestSetup, 1000, []*structs.NodePool{devEng, devInfra, prodEng}) + must.NoError(t, err) + + engToken := mock.CreatePolicyAndToken(t, s.fsm.State(), 1001, "eng-node-pools", + mock.NodePoolPolicy("*eng", "read", nil), + ) + noPolicyToken := mock.CreateToken(t, s.fsm.State(), 1003, nil) + allPoolsToken := mock.CreatePolicyAndToken(t, s.fsm.State(), 1005, "all-node-pools", + mock.NodePoolPolicy("*", "read", nil), + ) + denyEngToken := mock.CreatePolicyAndToken(t, s.fsm.State(), 1007, "deny-eng-node-pools", + mock.NodePoolPolicy("*eng", "deny", nil), + ) + + // Run test cases. + testCases := []struct { + name string + token string + text string + expected []string + }{ + { + name: "management token has access to all", + token: root.SecretID, + text: "dev", + expected: []string{devEng.Name, devInfra.Name}, + }, + { + name: "all pools access", + token: allPoolsToken.SecretID, + text: "dev", + expected: []string{devEng.Name, devInfra.Name}, + }, + { + name: "only return what token has access", + token: engToken.SecretID, + text: "eng", + expected: []string{devEng.Name, prodEng.Name}, + }, + { + name: "no results if token doesn't have access", + token: engToken.SecretID, + text: "infra", + expected: []string{}, + }, + { + name: "no results if token is denied", + token: denyEngToken.SecretID, + text: "eng", + expected: []string{}, + }, + { + name: "no policy", + token: noPolicyToken.SecretID, + text: "dev", + expected: []string{}, + }, + { + name: "no token", + token: "", + text: "dev", + expected: []string{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + req := &structs.FuzzySearchRequest{ + Text: tc.text, + Context: structs.NodePools, + QueryOptions: structs.QueryOptions{ + Region: "global", + AuthToken: tc.token, + }, + } + var resp structs.FuzzySearchResponse + err := msgpackrpc.CallWithCodec(codec, "Search.FuzzySearch", req, &resp) + must.NoError(t, err) + must.Len(t, len(tc.expected), resp.Matches[structs.NodePools]) + + for k, v := range resp.Matches { + switch k { + case structs.NodePools: + got := make([]string, len(v)) + for i, m := range v { + got[i] = m.ID + } + must.SliceContainsAll(t, got, tc.expected) + default: + must.Len(t, 0, v, must.Sprintf("found %d results in %v: %v", len(v), k, v)) + } + } + }) + } +} + func TestSearch_FuzzySearch_Deployment(t *testing.T) { ci.Parallel(t) diff --git a/nomad/structs/search.go b/nomad/structs/search.go index 8027fbc6c..19ac7c8c3 100644 --- a/nomad/structs/search.go +++ b/nomad/structs/search.go @@ -14,6 +14,7 @@ const ( Evals Context = "evals" Jobs Context = "jobs" Nodes Context = "nodes" + NodePools Context = "node_pools" Namespaces Context = "namespaces" Quotas Context = "quotas" Recommendations Context = "recommendations" diff --git a/website/content/api-docs/search.mdx b/website/content/api-docs/search.mdx index 4c3762ffb..290fc2abd 100644 --- a/website/content/api-docs/search.mdx +++ b/website/content/api-docs/search.mdx @@ -9,10 +9,10 @@ description: The /search endpoint is used to search for Nomad objects ## Prefix Searching The `/search` endpoint returns matches for a given prefix and context, where a -context can be jobs, allocations, evaluations, nodes, deployments, plugins, -namespaces, or volumes. When using Nomad Enterprise, the allowed contexts -include quotas. Additionally, a prefix can be searched for within every -context. +context can be jobs, allocations, evaluations, nodes, node pools, deployments, +plugins, namespaces, or volumes. When using Nomad Enterprise, the allowed +contexts include quotas. Additionally, a prefix can be searched for within +every context. | Method | Path | Produces | | ------ | ------------ | ------------------ | @@ -22,14 +22,14 @@ The table below shows this endpoint's support for [blocking queries](/nomad/api-docs#blocking-queries) and [required ACLs](/nomad/api-docs#acls). -| Blocking Queries | ACL Required | -| ---------------- | -------------------------------- | -| `NO` | `node:read, namespace:read-jobs` | +| Blocking Queries | ACL Required | +| ---------------- | ------------------------------------------------ | +| `NO` | `node:read, node_pool:read, namespace:read-jobs` | -When ACLs are enabled, requests must have a token valid for `node:read` or -`namespace:read-jobs` roles. If the token is only valid for `node:read`, then -job related results will not be returned. If the token is only valid for -`namespace:read-jobs`, then node results will not be returned. +When ACLs are enabled, requests must have a token valid for `node:read`, +`node_pool:read`, or `namespace:read-jobs` roles. If the token is only valid +for a portion of these capabilities, then results will include results +including only data readable with the given token. ### Parameters @@ -38,8 +38,8 @@ job related results will not be returned. If the token is only valid for matches might be "abcd", or "aabb". - `Context` `(string: )` - Defines the scope in which a search for a prefix operates. Contexts can be: "jobs", "evals", "allocs", "nodes", - "deployment", "plugins", "volumes" or "all", where "all" means every - context will be searched. + "node_pools", "deployment", "plugins", "volumes" or "all", where "all" means + every context will be searched. ### Sample Payload (for all contexts) @@ -123,11 +123,12 @@ $ curl \ ## Fuzzy Searching -The `/search/fuzzy` endpoint returns partial substring matches for a given search -term and context, where a context can be jobs, allocations, nodes, plugins, or namespaces. -Additionally, fuzzy searching can be done across all contexts. For better control -over the performance implications of fuzzy searching on Nomad servers, aspects of -fuzzy searching can be tuned through the [search] block in Nomad agent config. +The `/search/fuzzy` endpoint returns partial substring matches for a given +search term and context, where a context can be jobs, allocations, nodes, node +pools, plugins, or namespaces. Additionally, fuzzy searching can be done across +all contexts. For better control over the performance implications of fuzzy +searching on Nomad servers, aspects of fuzzy searching can be tuned through +the [search] block in Nomad agent config. Fuzzy search results are ordered starting with closest matching terms. Items of a name that exactly matches the search term are listed first. @@ -140,27 +141,28 @@ The table below shows this endpoint's support for [blocking queries](/nomad/api-docs#blocking-queries) and [required ACLs](/nomad/api-docs#acls). -| Blocking Queries | ACL Required | -| ---------------- | ----------------------------------------------------------- | -| `NO` | `node:read, namespace:read-jobs, namespace:csi-list-plugin` | +| Blocking Queries | ACL Required | +| ---------------- | --------------------------------------------------------------------------- | +| `NO` | `node:read, node_pool:read, namespace:read-jobs, namespace:csi-list-plugin` | -When ACLs are enabled, requests must have a token valid for `node:read`, `plugin:read` or -`namespace:read-jobs` roles. If the token is only valid for a portion of these -capabilities, then results will include results including only data readable with -the given token. +When ACLs are enabled, requests must have a token valid for `node:read`, +`node_pool:read`, `plugin:read`, or `namespace:read-jobs` roles. If the token +is only valid for a portion of these capabilities, then results will include +results including only data readable with the given token. ### Parameters - `Text` `(string: )` - Specifies the identifier against which matches will be found. For example, if the given text were "py", potential fuzzy matches might be "python", "spying", or "happy". + - `Context` `(string: )` - Defines the scope in which a search for a - prefix operates. Contexts can be: "jobs", "allocs", "nodes", "plugins", or - "all", where "all" means every context will be searched. When "all" is selected, - additional prefix matches will be included for the "deployments", "evals", and - "volumes" types. When searching in the "jobs" context, results that fuzzy match - "groups", "services", "tasks", "images", "commands", and "classes" are also - included in the results. + prefix operates. Contexts can be: "jobs", "allocs", "nodes", "node_pools", + "plugins", or "all", where "all" means every context will be searched. When + "all" is selected, additional prefix matches will be included for the + "deployments", "evals", and "volumes" types. When searching in the "jobs" + context, results that fuzzy match "groups", "services", "tasks", "images", + "commands", and "classes" are also included in the results. ### Scope @@ -339,6 +341,51 @@ $ curl \ - `Scope[0]` : Node ID +### Sample Payload (for node pools) + +```json +{ + "Text": "lab", + "Context": "node_pools" +} +``` + +### Sample Request + +```shell-session +$ curl \ + --request POST \ + --data @payload.json \ + https://localhost:4646/v1/search/fuzzy +``` + +### Sample Response + +```json +{ + "Index": 9, + "KnownLeader": true, + "LastContact": 0, + "Matches": { + "node_pools": [ + { + "ID": "dev-lab1", + "Scope": [ + "dev-lab1" + ] + } + ] + }, + "Truncations": { + "nodes": false + } +} +``` + +##### Scope (node pools) + +- `Scope[0]` : Node Pool Name + ### Sample Payload (for allocs) ```json