node pool: add search support (#17385)

This commit is contained in:
Luiz Aoqui
2023-06-01 17:48:14 -04:00
committed by GitHub
parent 2d059bbf22
commit 9ee68fc02c
5 changed files with 553 additions and 33 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"

View File

@@ -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: <required>)` - 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 <code>[search]</code> 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 <code>[search]</code> 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: <required>)` - 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: <required>)` - 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