diff --git a/api/contexts/contexts.go b/api/contexts/contexts.go index 5176f5b82..20f099a38 100644 --- a/api/contexts/contexts.go +++ b/api/contexts/contexts.go @@ -23,6 +23,7 @@ const ( Plugins Context = "plugins" Variables Context = "vars" Volumes Context = "volumes" + HostVolumes Context = "host_volumes" // These Context types are used to associate a search result from a lower // level Nomad object with one of the higher level Context types above. diff --git a/nomad/search_endpoint.go b/nomad/search_endpoint.go index 4a66e9392..b6743c423 100644 --- a/nomad/search_endpoint.go +++ b/nomad/search_endpoint.go @@ -41,6 +41,7 @@ var ( structs.ScalingPolicies, structs.Variables, structs.Namespaces, + structs.HostVolumes, } ) @@ -84,6 +85,8 @@ func (s *Search) getPrefixMatches(iter memdb.ResultIterator, prefix string) ([]s id = t.ID case *structs.CSIVolume: id = t.ID + case *structs.HostVolume: + id = t.ID case *structs.ScalingPolicy: id = t.ID case *structs.Namespace: @@ -405,6 +408,8 @@ func getResourceIter(context structs.Context, aclObj *acl.ACL, namespace, prefix return store.ScalingPoliciesByIDPrefix(ws, namespace, prefix) case structs.Volumes: return store.CSIVolumesByIDPrefix(ws, namespace, prefix) + case structs.HostVolumes: + return store.HostVolumesByIDPrefix(ws, namespace, prefix, state.SortDefault) case structs.Namespaces: iter, err := store.NamespacesByNamePrefix(ws, prefix) if err != nil { @@ -684,6 +689,8 @@ func sufficientSearchPerms(aclObj *acl.ACL, namespace string, context structs.Co acl.NamespaceCapabilityCSIReadVolume, acl.NamespaceCapabilityListJobs, acl.NamespaceCapabilityReadJob)(aclObj, namespace) + case structs.HostVolumes: + return acl.NamespaceValidator(acl.NamespaceCapabilityHostVolumeRead)(aclObj, namespace) case structs.Variables: return aclObj.AllowVariableSearch(namespace) case structs.Plugins: @@ -774,7 +781,8 @@ func (s *Search) FuzzySearch(args *structs.FuzzySearchRequest, reply *structs.Fu for _, ctx := range prefixContexts { switch ctx { // only apply on the types that use UUID prefix searching - case structs.Evals, structs.Deployments, structs.ScalingPolicies, structs.Volumes, structs.Quotas, structs.Recommendations: + case structs.Evals, structs.Deployments, structs.ScalingPolicies, + structs.Volumes, structs.HostVolumes, structs.Quotas, structs.Recommendations: iter, err := getResourceIter(ctx, aclObj, namespace, roundUUIDDownIfOdd(args.Prefix, args.Context), ws, state) if err != nil { if !s.silenceError(err) { @@ -790,7 +798,9 @@ func (s *Search) FuzzySearch(args *structs.FuzzySearchRequest, reply *structs.Fu for _, ctx := range fuzzyContexts { switch ctx { // skip the types that use UUID prefix searching - case structs.Evals, structs.Deployments, structs.ScalingPolicies, structs.Volumes, structs.Quotas, structs.Recommendations: + case structs.Evals, structs.Deployments, structs.ScalingPolicies, + structs.Volumes, structs.HostVolumes, structs.Quotas, + structs.Recommendations: continue default: iter, err := getFuzzyResourceIterator(ctx, aclObj, namespace, ws, state) @@ -927,6 +937,11 @@ func filteredSearchContexts(aclObj *acl.ACL, namespace string, context structs.C if volRead { available = append(available, c) } + case structs.HostVolumes: + if acl.NamespaceValidator( + acl.NamespaceCapabilityHostVolumeRead)(aclObj, namespace) { + available = append(available, c) + } case structs.Plugins: if aclObj.AllowPluginList() { available = append(available, c) diff --git a/nomad/search_endpoint_test.go b/nomad/search_endpoint_test.go index e06688ac9..ae9e10e33 100644 --- a/nomad/search_endpoint_test.go +++ b/nomad/search_endpoint_test.go @@ -1039,6 +1039,53 @@ func TestSearch_PrefixSearch_CSIVolume(t *testing.T) { require.False(t, resp.Truncations[structs.Volumes]) } +func TestSearch_PrefixSearch_HostVolume(t *testing.T) { + ci.Parallel(t) + + srv, cleanup := TestServer(t, func(c *Config) { + c.NumSchedulers = 0 + }) + defer cleanup() + codec := rpcClient(t, srv) + testutil.WaitForLeader(t, srv.RPC) + + store := srv.fsm.State() + index, _ := store.LatestIndex() + + node := mock.Node() + index++ + must.NoError(t, store.UpsertNode(structs.MsgTypeTestSetup, index, node)) + + id := uuid.Generate() + index++ + err := store.UpsertHostVolumes(index, []*structs.HostVolume{{ + ID: id, + Name: "example", + Namespace: structs.DefaultNamespace, + PluginID: "glade", + NodeID: node.ID, + NodePool: node.NodePool, + }}) + must.NoError(t, err) + + req := &structs.SearchRequest{ + Prefix: id[:6], + Context: structs.HostVolumes, + QueryOptions: structs.QueryOptions{ + Region: "global", + Namespace: structs.DefaultNamespace, + }, + } + + var resp structs.SearchResponse + must.NoError(t, msgpackrpc.CallWithCodec(codec, "Search.PrefixSearch", req, &resp)) + + must.Len(t, 1, resp.Matches[structs.HostVolumes]) + must.Len(t, 0, resp.Matches[structs.Volumes]) + must.Eq(t, id, resp.Matches[structs.HostVolumes][0]) + must.False(t, resp.Truncations[structs.HostVolumes]) +} + func TestSearch_PrefixSearch_Namespace(t *testing.T) { ci.Parallel(t) @@ -1932,6 +1979,52 @@ func TestSearch_FuzzySearch_CSIVolume(t *testing.T) { require.False(t, resp.Truncations[structs.Volumes]) } +func TestSearch_FuzzySearch_HostVolume(t *testing.T) { + ci.Parallel(t) + + srv, cleanup := TestServer(t, func(c *Config) { + c.NumSchedulers = 0 + }) + defer cleanup() + codec := rpcClient(t, srv) + testutil.WaitForLeader(t, srv.RPC) + + store := srv.fsm.State() + index, _ := store.LatestIndex() + + node := mock.Node() + index++ + must.NoError(t, store.UpsertNode(structs.MsgTypeTestSetup, index, node)) + + id := uuid.Generate() + index++ + err := store.UpsertHostVolumes(index, []*structs.HostVolume{{ + ID: id, + Name: "example", + Namespace: structs.DefaultNamespace, + PluginID: "glade", + NodeID: node.ID, + NodePool: node.NodePool, + }}) + must.NoError(t, err) + + req := &structs.FuzzySearchRequest{ + Text: id[0:3], // volumes are prefix searched + Context: structs.HostVolumes, + QueryOptions: structs.QueryOptions{ + Region: "global", + Namespace: structs.DefaultNamespace, + }, + } + + var resp structs.FuzzySearchResponse + must.NoError(t, msgpackrpc.CallWithCodec(codec, "Search.FuzzySearch", req, &resp)) + + must.Len(t, 1, resp.Matches[structs.HostVolumes]) + must.Eq(t, id, resp.Matches[structs.HostVolumes][0].ID) + must.False(t, resp.Truncations[structs.HostVolumes]) +} + func TestSearch_FuzzySearch_Namespace(t *testing.T) { ci.Parallel(t) diff --git a/nomad/state/state_store_host_volumes.go b/nomad/state/state_store_host_volumes.go index dd5a68040..522d1d194 100644 --- a/nomad/state/state_store_host_volumes.go +++ b/nomad/state/state_store_host_volumes.go @@ -5,6 +5,7 @@ package state import ( "fmt" + "strings" memdb "github.com/hashicorp/go-memdb" "github.com/hashicorp/nomad/nomad/structs" @@ -156,6 +157,30 @@ func (s *StateStore) HostVolumes(ws memdb.WatchSet, sort SortOption) (memdb.Resu return s.hostVolumesIter(ws, indexID, sort) } +// HostVolumesByIDPrefix retrieves all host volumes by ID prefix. Because the ID +// index is namespaced, we need to handle the wildcard namespace here as well. +func (s *StateStore) HostVolumesByIDPrefix(ws memdb.WatchSet, ns, prefix string, sort SortOption) (memdb.ResultIterator, error) { + + if ns != structs.AllNamespacesSentinel { + return s.hostVolumesIter(ws, "id_prefix", sort, ns, prefix) + } + + // for wildcard namespace, wrap the iterator in a filter function that + // filters all volumes by prefix + iter, err := s.hostVolumesIter(ws, indexID, sort) + if err != nil { + return nil, err + } + wrappedIter := memdb.NewFilterIterator(iter, func(raw any) bool { + vol, ok := raw.(*structs.HostVolume) + if !ok { + return true + } + return !strings.HasPrefix(vol.ID, prefix) + }) + return wrappedIter, nil +} + // HostVolumesByName retrieves all host volumes of the same name func (s *StateStore) HostVolumesByName(ws memdb.WatchSet, ns, name string, sort SortOption) (memdb.ResultIterator, error) { return s.hostVolumesIter(ws, "name_prefix", sort, ns, name) diff --git a/nomad/state/state_store_host_volumes_test.go b/nomad/state/state_store_host_volumes_test.go index af4c77a72..358591160 100644 --- a/nomad/state/state_store_host_volumes_test.go +++ b/nomad/state/state_store_host_volumes_test.go @@ -163,6 +163,17 @@ func TestStateStore_HostVolumes_CRUD(t *testing.T) { must.NoError(t, err) got = consumeIter(iter) must.MapLen(t, 3, got, must.Sprint(`expected 3 volumes remain`)) + + prefix := vol.ID[:30] // sufficiently long prefix to avoid flakes + iter, err = store.HostVolumesByIDPrefix(nil, "*", prefix, SortDefault) + must.NoError(t, err) + got = consumeIter(iter) + must.MapLen(t, 1, got, must.Sprint(`expected only one volume to match prefix`)) + + iter, err = store.HostVolumesByIDPrefix(nil, vol.Namespace, prefix, SortDefault) + must.NoError(t, err) + got = consumeIter(iter) + must.MapLen(t, 1, got, must.Sprint(`expected only one volume to match prefix`)) } func TestStateStore_UpdateHostVolumesFromFingerprint(t *testing.T) { diff --git a/nomad/structs/search.go b/nomad/structs/search.go index b71798c21..53aebc01e 100644 --- a/nomad/structs/search.go +++ b/nomad/structs/search.go @@ -22,6 +22,7 @@ const ( Plugins Context = "plugins" Variables Context = "vars" Volumes Context = "volumes" + HostVolumes Context = "host_volumes" // Subtypes used in fuzzy matching. Groups Context = "groups"