T3MP3ST public launch build
This commit is contained in:
1 parent
972247b881
commit
184c7d4e59
1589 files changed
+57150
-506
No files matched your search
@@ -0,0 +1,56 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"github.com/hashicorp/nomad/acl"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
func (s *Server) Authenticate(ctx *RPCContext, args structs.RequestWithIdentity) error {
|
||||
return s.auth.Authenticate(ctx, args)
|
||||
}
|
||||
|
||||
func (s *Server) AuthenticateServerOnly(ctx *RPCContext, args structs.RequestWithIdentity) (*acl.ACL, error) {
|
||||
return s.auth.AuthenticateServerOnly(ctx, args)
|
||||
}
|
||||
|
||||
func (s *Server) AuthenticateNodeIdentityGenerator(ctx *RPCContext, args structs.RequestWithIdentity) error {
|
||||
return s.auth.AuthenticateNodeIdentityGenerator(ctx, args)
|
||||
}
|
||||
|
||||
func (s *Server) AuthenticateClientOnly(ctx *RPCContext, args structs.RequestWithIdentity) (*acl.ACL, error) {
|
||||
return s.auth.AuthenticateClientOnly(ctx, args)
|
||||
}
|
||||
|
||||
func (s *Server) ResolveAuthorizedClientNodePoolByNodeID(aclObj *acl.ACL, nodeID string) (string, error) {
|
||||
return s.auth.ResolveAuthorizedClientNodePoolByNodeID(aclObj, nodeID)
|
||||
}
|
||||
|
||||
func (s *Server) AuthorizeClientAllocation(
|
||||
aclObj *acl.ACL,
|
||||
alloc *structs.Allocation,
|
||||
allowNsOp func(*acl.ACL, string) bool,
|
||||
) error {
|
||||
return s.auth.AuthorizeClientAllocation(aclObj, alloc, allowNsOp)
|
||||
}
|
||||
|
||||
func (s *Server) ResolveAuthorizedClientNodePoolByServiceRegistrationID(
|
||||
aclObj *acl.ACL,
|
||||
namespace, id string,
|
||||
) (string, error) {
|
||||
return s.auth.ResolveAuthorizedClientNodePoolByServiceRegistrationID(aclObj, namespace, id)
|
||||
}
|
||||
|
||||
func (s *Server) ResolveACL(args structs.RequestWithIdentity) (*acl.ACL, error) {
|
||||
return s.auth.ResolveACL(args)
|
||||
}
|
||||
|
||||
func (s *Server) VerifyClaim(token string) (*structs.IdentityClaims, error) {
|
||||
return s.auth.VerifyClaim(token)
|
||||
}
|
||||
|
||||
func (s *Server) ResolvePoliciesForClaims(claims *structs.IdentityClaims) ([]*structs.ACLPolicy, error) {
|
||||
return s.auth.ResolvePoliciesForClaims(claims)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,329 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
msgpackrpc "github.com/hashicorp/net-rpc-msgpackrpc/v2"
|
||||
"github.com/hashicorp/nomad/ci"
|
||||
"github.com/hashicorp/nomad/helper/uuid"
|
||||
"github.com/hashicorp/nomad/nomad/mock"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
"github.com/hashicorp/nomad/nomad/structs/config"
|
||||
"github.com/hashicorp/nomad/testutil"
|
||||
"github.com/shoenig/test"
|
||||
"github.com/shoenig/test/must"
|
||||
)
|
||||
|
||||
func TestAuthenticate_mTLS(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
// Set up a cluster with mTLS and ACLs
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
tlsCfg := &config.TLSConfig{
|
||||
EnableHTTP: true,
|
||||
EnableRPC: true,
|
||||
VerifyServerHostname: true,
|
||||
CAFile: "../helper/tlsutil/testdata/nomad-agent-ca.pem",
|
||||
CertFile: "../helper/tlsutil/testdata/regionFoo-server-nomad.pem",
|
||||
KeyFile: "../helper/tlsutil/testdata/regionFoo-server-nomad-key.pem",
|
||||
}
|
||||
clientTLSCfg := tlsCfg.Copy()
|
||||
clientTLSCfg.CertFile = "../helper/tlsutil/testdata/regionFoo-client-nomad.pem"
|
||||
clientTLSCfg.KeyFile = "../helper/tlsutil/testdata/regionFoo-client-nomad-key.pem"
|
||||
|
||||
setCfg := func(name string, bootstrapExpect int) func(*Config) {
|
||||
return func(c *Config) {
|
||||
c.Region = "regionFoo"
|
||||
c.AuthoritativeRegion = "regionFoo"
|
||||
c.ACLEnabled = true
|
||||
c.BootstrapExpect = bootstrapExpect
|
||||
c.NumSchedulers = 0
|
||||
c.DevMode = false
|
||||
c.DataDir = path.Join(dir, name)
|
||||
c.TLSConfig = tlsCfg
|
||||
}
|
||||
}
|
||||
|
||||
leader, cleanupLeader := TestServer(t, setCfg("node1", 1))
|
||||
defer cleanupLeader()
|
||||
testutil.WaitForLeader(t, leader.RPC)
|
||||
|
||||
follower, cleanupFollower := TestServer(t, setCfg("node2", 0))
|
||||
defer cleanupFollower()
|
||||
|
||||
TestJoin(t, leader, follower)
|
||||
testutil.WaitForLeader(t, leader.RPC)
|
||||
|
||||
testutil.Wait(t, func() (bool, error) {
|
||||
cs, err := follower.encrypter.activeCipherSet()
|
||||
return cs != nil, err
|
||||
})
|
||||
|
||||
rootToken := uuid.Generate()
|
||||
var bootstrapResp *structs.ACLTokenUpsertResponse
|
||||
|
||||
codec := rpcClientWithTLS(t, follower, tlsCfg)
|
||||
must.NoError(t, msgpackrpc.CallWithCodec(codec,
|
||||
"ACL.Bootstrap", &structs.ACLTokenBootstrapRequest{
|
||||
BootstrapSecret: rootToken,
|
||||
WriteRequest: structs.WriteRequest{Region: "regionFoo"},
|
||||
}, &bootstrapResp))
|
||||
must.NotNil(t, bootstrapResp)
|
||||
must.Len(t, 1, bootstrapResp.Tokens)
|
||||
rootAccessor := bootstrapResp.Tokens[0].AccessorID
|
||||
|
||||
// create some ACL tokens directly into raft so we can bypass RPC validation
|
||||
// around expiration times
|
||||
|
||||
token1 := mock.ACLToken()
|
||||
token2 := mock.ACLToken()
|
||||
expireTime := time.Now().Add(time.Second * -10)
|
||||
token2.ExpirationTime = &expireTime
|
||||
|
||||
_, _, err := leader.raftApply(structs.ACLTokenUpsertRequestType,
|
||||
&structs.ACLTokenUpsertRequest{Tokens: []*structs.ACLToken{token1, token2}})
|
||||
must.NoError(t, err)
|
||||
|
||||
// create a node so we can test client RPCs
|
||||
|
||||
node := mock.Node()
|
||||
nodeRegisterReq := &structs.NodeRegisterRequest{
|
||||
Node: node,
|
||||
WriteRequest: structs.WriteRequest{Region: "regionFoo"},
|
||||
}
|
||||
var nodeRegisterResp structs.NodeUpdateResponse
|
||||
|
||||
must.NoError(t, msgpackrpc.CallWithCodec(codec,
|
||||
"Node.Register", nodeRegisterReq, &nodeRegisterResp))
|
||||
must.NotNil(t, bootstrapResp)
|
||||
|
||||
// create some allocations so we can test WorkloadIdentity claims. we'll
|
||||
// create directly into raft so we can bypass RPC validation and the whole
|
||||
// eval, plan, etc. workflow.
|
||||
job := mock.Job()
|
||||
|
||||
_, _, err = leader.raftApply(structs.JobRegisterRequestType,
|
||||
&structs.JobRegisterRequest{Job: job})
|
||||
must.NoError(t, err)
|
||||
|
||||
alloc1 := mock.Alloc()
|
||||
alloc1.NodeID = node.ID
|
||||
alloc1.ClientStatus = structs.AllocClientStatusFailed
|
||||
alloc1.Job = job
|
||||
alloc1.JobID = job.ID
|
||||
|
||||
alloc2 := mock.Alloc()
|
||||
alloc2.NodeID = node.ID
|
||||
alloc2.Job = job
|
||||
alloc2.JobID = job.ID
|
||||
alloc2.ClientStatus = structs.AllocClientStatusRunning
|
||||
|
||||
wiHandle := &structs.WIHandle{
|
||||
WorkloadIdentifier: "web",
|
||||
WorkloadType: structs.WorkloadTypeTask,
|
||||
}
|
||||
|
||||
task1 := alloc1.LookupTask("web")
|
||||
claims1 := structs.NewIdentityClaimsBuilder(job, alloc1,
|
||||
wiHandle,
|
||||
task1.Identity).
|
||||
WithTask(task1).
|
||||
Build(time.Now())
|
||||
|
||||
claims1Token, _, err := leader.encrypter.SignClaims(claims1)
|
||||
must.NoError(t, err, must.Sprint("could not sign claims"))
|
||||
|
||||
task2 := alloc2.LookupTask("web")
|
||||
claims2 := structs.NewIdentityClaimsBuilder(job, alloc2,
|
||||
wiHandle,
|
||||
task2.Identity).
|
||||
WithTask(task1).
|
||||
Build(time.Now())
|
||||
|
||||
claims2Token, _, err := leader.encrypter.SignClaims(claims2)
|
||||
must.NoError(t, err, must.Sprint("could not sign claims"))
|
||||
|
||||
planReq := &structs.ApplyPlanResultsRequest{
|
||||
AllocsUpdated: []*structs.Allocation{alloc1, alloc2},
|
||||
Job: job,
|
||||
}
|
||||
_, _, err = leader.raftApply(structs.ApplyPlanResultsRequestType, planReq)
|
||||
must.NoError(t, err)
|
||||
|
||||
testutil.WaitForResult(func() (bool, error) {
|
||||
store := follower.fsm.State()
|
||||
alloc, err := store.AllocByID(nil, alloc1.ID)
|
||||
return alloc != nil, err
|
||||
}, func(err error) {
|
||||
t.Fatalf("alloc was not replicated via raft: %v", err) // should never happen
|
||||
})
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
tlsCfg *config.TLSConfig
|
||||
stale bool
|
||||
testToken string
|
||||
expectAccessor string
|
||||
expectClientID string
|
||||
expectAllocID string
|
||||
expectTLSName string
|
||||
expectIP string
|
||||
expectErr string
|
||||
expectIDKey string
|
||||
sendFromPeer *Server
|
||||
}{
|
||||
{
|
||||
name: "root token",
|
||||
tlsCfg: clientTLSCfg, // TODO: this is a mixed use cert
|
||||
testToken: rootToken,
|
||||
expectAccessor: rootAccessor,
|
||||
expectIDKey: fmt.Sprintf("token:%s", rootAccessor),
|
||||
},
|
||||
{
|
||||
name: "from peer to leader without token", // ex. Eval.Dequeue
|
||||
tlsCfg: tlsCfg,
|
||||
expectTLSName: "server.regionFoo.nomad",
|
||||
expectAccessor: "anonymous",
|
||||
expectIP: follower.GetConfig().RPCAddr.IP.String(),
|
||||
sendFromPeer: follower,
|
||||
expectIDKey: "token:anonymous",
|
||||
},
|
||||
{
|
||||
// note: this test is somewhat bogus because under test all the
|
||||
// servers share the same IP address with the RPC client
|
||||
name: "anonymous forwarded from peer to leader",
|
||||
tlsCfg: tlsCfg,
|
||||
expectAccessor: "anonymous",
|
||||
expectTLSName: "server.regionFoo.nomad",
|
||||
expectIP: "127.0.0.1",
|
||||
expectIDKey: "token:anonymous",
|
||||
},
|
||||
{
|
||||
name: "invalid token",
|
||||
tlsCfg: clientTLSCfg,
|
||||
testToken: uuid.Generate(),
|
||||
expectTLSName: "server.regionFoo.nomad",
|
||||
expectIP: follower.GetConfig().RPCAddr.IP.String(),
|
||||
expectIDKey: "server.regionFoo.nomad:127.0.0.1",
|
||||
expectErr: "rpc error: Permission denied",
|
||||
},
|
||||
{
|
||||
name: "from peer to leader with leader ACL", // ex. core job GC
|
||||
tlsCfg: tlsCfg,
|
||||
testToken: leader.getLeaderAcl(),
|
||||
expectTLSName: "server.regionFoo.nomad",
|
||||
expectAccessor: "leader",
|
||||
expectIP: follower.GetConfig().RPCAddr.IP.String(),
|
||||
sendFromPeer: follower,
|
||||
expectIDKey: "token:leader",
|
||||
},
|
||||
{
|
||||
name: "from client", // ex. Node.GetAllocs
|
||||
tlsCfg: clientTLSCfg,
|
||||
testToken: node.SecretID,
|
||||
expectClientID: node.ID,
|
||||
expectIDKey: fmt.Sprintf("client:%s", node.ID),
|
||||
},
|
||||
{
|
||||
name: "from client missing secret", // ex. Node.Register
|
||||
tlsCfg: clientTLSCfg,
|
||||
expectAccessor: "anonymous",
|
||||
expectTLSName: "server.regionFoo.nomad",
|
||||
expectIP: follower.GetConfig().RPCAddr.IP.String(),
|
||||
},
|
||||
{
|
||||
name: "from failed workload", // ex. Variables.List
|
||||
tlsCfg: clientTLSCfg,
|
||||
testToken: claims1Token,
|
||||
expectErr: "rpc error: allocation is terminal",
|
||||
},
|
||||
{
|
||||
name: "from running workload", // ex. Variables.List
|
||||
tlsCfg: clientTLSCfg,
|
||||
testToken: claims2Token,
|
||||
expectAllocID: alloc2.ID,
|
||||
expectIDKey: fmt.Sprintf("alloc:%s", alloc2.ID),
|
||||
},
|
||||
{
|
||||
name: "valid user token",
|
||||
tlsCfg: clientTLSCfg,
|
||||
testToken: token1.SecretID,
|
||||
expectAccessor: token1.AccessorID,
|
||||
expectIDKey: fmt.Sprintf("token:%s", token1.AccessorID),
|
||||
},
|
||||
{
|
||||
name: "expired user token",
|
||||
tlsCfg: clientTLSCfg,
|
||||
testToken: token2.SecretID,
|
||||
expectErr: "rpc error: ACL token expired",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
req := &structs.GenericRequest{
|
||||
QueryOptions: structs.QueryOptions{
|
||||
Region: "regionFoo",
|
||||
AllowStale: tc.stale,
|
||||
AuthToken: tc.testToken,
|
||||
},
|
||||
}
|
||||
var resp structs.ACLWhoAmIResponse
|
||||
var err error
|
||||
|
||||
if tc.sendFromPeer != nil {
|
||||
aclEndpoint := NewACLEndpoint(tc.sendFromPeer, nil)
|
||||
err = aclEndpoint.WhoAmI(req, &resp)
|
||||
} else {
|
||||
err = msgpackrpc.CallWithCodec(codec, "ACL.WhoAmI", req, &resp)
|
||||
}
|
||||
|
||||
if tc.expectErr != "" {
|
||||
must.EqError(t, err, tc.expectErr)
|
||||
return
|
||||
}
|
||||
|
||||
must.NoError(t, err)
|
||||
must.NotNil(t, resp)
|
||||
must.NotNil(t, resp.Identity)
|
||||
|
||||
if tc.expectIDKey != "" {
|
||||
must.Eq(t, tc.expectIDKey, resp.Identity.String(),
|
||||
must.Sprintf("expected identity key for metrics to match"))
|
||||
}
|
||||
|
||||
if tc.expectAccessor != "" {
|
||||
must.NotNil(t, resp.Identity.ACLToken, must.Sprint("expected ACL token"))
|
||||
test.Eq(t, tc.expectAccessor, resp.Identity.ACLToken.AccessorID,
|
||||
test.Sprint("expected ACL token accessor ID"))
|
||||
}
|
||||
|
||||
test.Eq(t, tc.expectClientID, resp.Identity.ClientID,
|
||||
test.Sprint("expected client ID"))
|
||||
|
||||
if tc.expectAllocID != "" {
|
||||
must.NotNil(t, resp.Identity.Claims, must.Sprint("expected claims"))
|
||||
test.Eq(t, tc.expectAllocID, resp.Identity.Claims.AllocationID,
|
||||
test.Sprint("expected workload identity"))
|
||||
}
|
||||
|
||||
test.Eq(t, tc.expectTLSName, resp.Identity.TLSName, test.Sprint("expected TLS name"))
|
||||
|
||||
if tc.expectIP == "" {
|
||||
test.Nil(t, resp.Identity.RemoteIP, test.Sprint("expected no remote IP"))
|
||||
} else {
|
||||
test.Eq(t, tc.expectIP, resp.Identity.RemoteIP.String())
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-hclog"
|
||||
"github.com/hashicorp/go-memdb"
|
||||
metrics "github.com/hashicorp/go-metrics/compat"
|
||||
|
||||
"github.com/hashicorp/nomad/acl"
|
||||
"github.com/hashicorp/nomad/helper/pointer"
|
||||
"github.com/hashicorp/nomad/helper/uuid"
|
||||
"github.com/hashicorp/nomad/nomad/state"
|
||||
"github.com/hashicorp/nomad/nomad/state/paginator"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
// Alloc endpoint is used for manipulating allocations
|
||||
type Alloc struct {
|
||||
srv *Server
|
||||
ctx *RPCContext
|
||||
logger hclog.Logger
|
||||
}
|
||||
|
||||
func NewAllocEndpoint(srv *Server, ctx *RPCContext) *Alloc {
|
||||
return &Alloc{srv: srv, ctx: ctx, logger: srv.logger.Named("alloc")}
|
||||
}
|
||||
|
||||
// List is used to list the allocations in the system
|
||||
func (a *Alloc) List(args *structs.AllocListRequest, reply *structs.AllocListResponse) error {
|
||||
authErr := a.srv.Authenticate(a.ctx, args)
|
||||
if done, err := a.srv.forward("Alloc.List", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
a.srv.MeasureRPCRate("alloc", structs.RateMetricList, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "alloc", "list"}, time.Now())
|
||||
|
||||
namespace := args.RequestNamespace()
|
||||
|
||||
// Check namespace read-job permissions
|
||||
aclObj, err := a.srv.ResolveACL(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !aclObj.AllowNsOp(namespace, acl.NamespaceCapabilityReadJob) {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
allow := aclObj.AllowNsOpFunc(acl.NamespaceCapabilityReadJob)
|
||||
|
||||
// Setup the blocking query
|
||||
sort := state.SortOption(args.Reverse)
|
||||
opts := blockingOptions{
|
||||
queryOpts: &args.QueryOptions,
|
||||
queryMeta: &reply.QueryMeta,
|
||||
run: func(ws memdb.WatchSet, state *state.StateStore) error {
|
||||
// Scan all the allocations
|
||||
var err error
|
||||
var iter memdb.ResultIterator
|
||||
|
||||
// get list of accessible namespaces
|
||||
allowableNamespaces, err := allowedNSes(aclObj, state, allow)
|
||||
if err == structs.ErrPermissionDenied {
|
||||
// return empty allocation if token is not authorized for any
|
||||
// namespace, matching other endpoints
|
||||
reply.Allocations = make([]*structs.AllocListStub, 0)
|
||||
} else if err != nil {
|
||||
return err
|
||||
} else {
|
||||
var tokenizer paginator.Tokenizer[*structs.Allocation]
|
||||
|
||||
if prefix := args.QueryOptions.Prefix; prefix != "" {
|
||||
iter, err = state.AllocsByIDPrefix(ws, namespace, prefix, sort)
|
||||
tokenizer = paginator.IDTokenizer[*structs.Allocation](args.NextToken)
|
||||
} else if namespace != structs.AllNamespacesSentinel {
|
||||
iter, err = state.AllocsByNamespaceOrdered(ws, namespace, sort)
|
||||
tokenizer = paginator.CreateIndexAndIDTokenizer[*structs.Allocation](args.NextToken)
|
||||
} else {
|
||||
iter, err = state.Allocs(ws, sort)
|
||||
tokenizer = paginator.CreateIndexAndIDTokenizer[*structs.Allocation](args.NextToken)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pager, err := paginator.NewPaginator(iter, args.QueryOptions,
|
||||
paginator.NamespaceSelectorFunc[*structs.Allocation](allowableNamespaces),
|
||||
tokenizer,
|
||||
func(a *structs.Allocation) (*structs.AllocListStub, error) {
|
||||
return a.Stub(args.Fields), nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return structs.NewErrRPCCodedf(
|
||||
http.StatusBadRequest, "failed to create result paginator: %v", err)
|
||||
}
|
||||
|
||||
stubs, nextToken, err := pager.Page()
|
||||
if err != nil {
|
||||
return structs.NewErrRPCCodedf(
|
||||
http.StatusBadRequest, "failed to read result page: %v", err)
|
||||
}
|
||||
|
||||
reply.QueryMeta.NextToken = nextToken
|
||||
reply.Allocations = stubs
|
||||
}
|
||||
|
||||
// Use the last index that affected the allocs table
|
||||
index, err := state.Index("allocs")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply.Index = index
|
||||
|
||||
// Set the query response
|
||||
a.srv.setQueryMeta(&reply.QueryMeta)
|
||||
return nil
|
||||
}}
|
||||
return a.srv.blockingRPC(&opts)
|
||||
}
|
||||
|
||||
// GetAlloc is used to lookup a particular allocation
|
||||
func (a *Alloc) GetAlloc(args *structs.AllocSpecificRequest,
|
||||
reply *structs.SingleAllocResponse) error {
|
||||
authErr := a.srv.Authenticate(a.ctx, args)
|
||||
if done, err := a.srv.forward("Alloc.GetAlloc", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
a.srv.MeasureRPCRate("alloc", structs.RateMetricRead, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "alloc", "get_alloc"}, time.Now())
|
||||
|
||||
// Check namespace read-job permissions before performing blocking query.
|
||||
allowNsOp := acl.NamespaceValidator(acl.NamespaceCapabilityReadJob)
|
||||
aclObj, err := a.srv.ResolveACL(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Setup the blocking query
|
||||
opts := blockingOptions{
|
||||
queryOpts: &args.QueryOptions,
|
||||
queryMeta: &reply.QueryMeta,
|
||||
run: func(ws memdb.WatchSet, state *state.StateStore) error {
|
||||
// Lookup the allocation
|
||||
out, err := state.AllocByID(ws, args.AllocID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Setup the output
|
||||
if out != nil {
|
||||
if out.SignedIdentities != nil {
|
||||
out = out.Sanitize()
|
||||
}
|
||||
reply.Alloc = out
|
||||
|
||||
// Re-check namespace in case it differs from request.
|
||||
if err := a.srv.AuthorizeClientAllocation(aclObj, out, allowNsOp); err != nil {
|
||||
return structs.NewErrUnknownAllocation(args.AllocID)
|
||||
}
|
||||
|
||||
reply.Index = out.ModifyIndex
|
||||
} else {
|
||||
// Use the last index that affected the allocs table
|
||||
index, err := state.Index("allocs")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply.Index = index
|
||||
}
|
||||
|
||||
// Set the query response
|
||||
a.srv.setQueryMeta(&reply.QueryMeta)
|
||||
return nil
|
||||
}}
|
||||
return a.srv.blockingRPC(&opts)
|
||||
}
|
||||
|
||||
// GetAllocs is used to lookup a set of allocations
|
||||
func (a *Alloc) GetAllocs(args *structs.AllocsGetRequest,
|
||||
reply *structs.AllocsGetResponse) error {
|
||||
|
||||
aclObj, err := a.srv.AuthenticateClientOnly(a.ctx, args)
|
||||
if done, err := a.srv.forward("Alloc.GetAllocs", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
a.srv.MeasureRPCRate("alloc", structs.RateMetricWrite, args)
|
||||
if err != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "alloc", "get_allocs"}, time.Now())
|
||||
|
||||
allocs := make([]*structs.Allocation, len(args.AllocIDs))
|
||||
|
||||
// Setup the blocking query. We wait for at least one of the requested
|
||||
// allocations to be above the min query index. This guarantees that the
|
||||
// server has received that index.
|
||||
opts := blockingOptions{
|
||||
queryOpts: &args.QueryOptions,
|
||||
queryMeta: &reply.QueryMeta,
|
||||
run: func(ws memdb.WatchSet, state *state.StateStore) error {
|
||||
// Lookup the allocation
|
||||
thresholdMet := false
|
||||
maxIndex := uint64(0)
|
||||
for i, alloc := range args.AllocIDs {
|
||||
out, err := state.AllocByID(ws, alloc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if out == nil {
|
||||
// The alloc may have been GC'd or purged. Continue so we can
|
||||
// potentially satisfy the query with partial results from the
|
||||
// remaining allocs.
|
||||
continue
|
||||
}
|
||||
|
||||
// It shouldn't be possible to have an alloc with a nil job, but
|
||||
// let's not block the node from getting other allocs if that's
|
||||
// the case.
|
||||
if out.Job == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := a.srv.AuthorizeClientAllocation(aclObj, out, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Store the pointer
|
||||
allocs[i] = out
|
||||
|
||||
// Check if we have passed the minimum index
|
||||
if out.ModifyIndex > args.QueryOptions.MinQueryIndex {
|
||||
thresholdMet = true
|
||||
}
|
||||
|
||||
if maxIndex < out.ModifyIndex {
|
||||
maxIndex = out.ModifyIndex
|
||||
}
|
||||
}
|
||||
|
||||
// Setup the output
|
||||
if thresholdMet {
|
||||
reply.Allocs = allocs
|
||||
reply.Index = maxIndex
|
||||
} else {
|
||||
// Use the last index that affected the allocs table
|
||||
index, err := state.Index("allocs")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply.Index = index
|
||||
}
|
||||
|
||||
// Set the query response
|
||||
a.srv.setQueryMeta(&reply.QueryMeta)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return a.srv.blockingRPC(&opts)
|
||||
}
|
||||
|
||||
// Stop is used to stop an allocation and migrate it to another node.
|
||||
func (a *Alloc) Stop(args *structs.AllocStopRequest, reply *structs.AllocStopResponse) error {
|
||||
|
||||
authErr := a.srv.Authenticate(a.ctx, args)
|
||||
if done, err := a.srv.forward("Alloc.Stop", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
a.srv.MeasureRPCRate("alloc", structs.RateMetricWrite, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
defer metrics.MeasureSince([]string{"nomad", "alloc", "stop"}, time.Now())
|
||||
|
||||
alloc, err := getAlloc(a.srv.State(), args.AllocID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check for namespace alloc-lifecycle permissions.
|
||||
allowNsOp := acl.NamespaceValidator(acl.NamespaceCapabilityAllocLifecycle)
|
||||
aclObj, err := a.srv.ResolveACL(args)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !allowNsOp(aclObj, alloc.Namespace) {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
now := time.Now().UTC().UnixNano()
|
||||
eval := &structs.Evaluation{
|
||||
ID: uuid.Generate(),
|
||||
Namespace: alloc.Namespace,
|
||||
Priority: alloc.Job.Priority,
|
||||
Type: alloc.Job.Type,
|
||||
TriggeredBy: structs.EvalTriggerAllocStop,
|
||||
JobID: alloc.Job.ID,
|
||||
JobModifyIndex: alloc.Job.ModifyIndex,
|
||||
Status: structs.EvalStatusPending,
|
||||
CreateTime: now,
|
||||
ModifyTime: now,
|
||||
}
|
||||
|
||||
transitionReq := &structs.AllocUpdateDesiredTransitionRequest{
|
||||
Evals: []*structs.Evaluation{eval},
|
||||
Allocs: map[string]*structs.DesiredTransition{
|
||||
args.AllocID: {
|
||||
Migrate: pointer.Of(true),
|
||||
NoShutdownDelay: pointer.Of(args.NoShutdownDelay),
|
||||
Reschedule: pointer.Of(args.Reschedule),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Commit this update via Raft
|
||||
_, index, err := a.srv.raftApply(structs.AllocUpdateDesiredTransitionRequestType, transitionReq)
|
||||
if err != nil {
|
||||
a.logger.Error("AllocUpdateDesiredTransitionRequest failed", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Setup the response
|
||||
reply.Index = index
|
||||
reply.EvalID = eval.ID
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateDesiredTransition is used to update the desired transitions of an
|
||||
// allocation.
|
||||
func (a *Alloc) UpdateDesiredTransition(args *structs.AllocUpdateDesiredTransitionRequest, reply *structs.GenericResponse) error {
|
||||
authErr := a.srv.Authenticate(a.ctx, args)
|
||||
if done, err := a.srv.forward("Alloc.UpdateDesiredTransition", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
a.srv.MeasureRPCRate("alloc", structs.RateMetricWrite, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
defer metrics.MeasureSince([]string{"nomad", "alloc", "update_desired_transition"}, time.Now())
|
||||
|
||||
// Check that it is a management token.
|
||||
if aclObj, err := a.srv.ResolveACL(args); err != nil {
|
||||
return err
|
||||
} else if !aclObj.IsManagement() {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Ensure at least a single alloc
|
||||
if len(args.Allocs) == 0 {
|
||||
return fmt.Errorf("must update at least one allocation")
|
||||
}
|
||||
|
||||
// Commit this update via Raft
|
||||
_, index, err := a.srv.raftApply(structs.AllocUpdateDesiredTransitionRequestType, args)
|
||||
if err != nil {
|
||||
a.logger.Error("AllocUpdateDesiredTransitionRequest failed", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Setup the response
|
||||
reply.Index = index
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetServiceRegistrations returns a list of service registrations which belong
|
||||
// to the passed allocation ID.
|
||||
func (a *Alloc) GetServiceRegistrations(
|
||||
args *structs.AllocServiceRegistrationsRequest,
|
||||
reply *structs.AllocServiceRegistrationsResponse) error {
|
||||
|
||||
authErr := a.srv.Authenticate(a.ctx, args)
|
||||
if done, err := a.srv.forward(structs.AllocServiceRegistrationsRPCMethod, args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
a.srv.MeasureRPCRate("alloc", structs.RateMetricList, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
defer metrics.MeasureSince([]string{"nomad", "alloc", "get_service_registrations"}, time.Now())
|
||||
|
||||
// Ensure the caller has the read-job namespace capability.
|
||||
aclObj, err := a.srv.ResolveACL(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !aclObj.AllowNsOp(args.RequestNamespace(), acl.NamespaceCapabilityReadJob) {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Set up the blocking query.
|
||||
return a.srv.blockingRPC(&blockingOptions{
|
||||
queryOpts: &args.QueryOptions,
|
||||
queryMeta: &reply.QueryMeta,
|
||||
run: func(ws memdb.WatchSet, stateStore *state.StateStore) error {
|
||||
|
||||
// Read the allocation to ensure its namespace matches the request
|
||||
// args.
|
||||
alloc, err := stateStore.AllocByID(ws, args.AllocID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Guard against the alloc not-existing or that the namespace does
|
||||
// not match the request arguments.
|
||||
if alloc == nil || alloc.Namespace != args.RequestNamespace() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Perform the state query to get an iterator.
|
||||
iter, err := stateStore.GetServiceRegistrationsByAllocID(ws, args.AllocID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set up our output after we have checked the error.
|
||||
services := make([]*structs.ServiceRegistration, 0)
|
||||
|
||||
// Iterate the iterator, appending all service registrations
|
||||
// returned to the reply.
|
||||
for raw := iter.Next(); raw != nil; raw = iter.Next() {
|
||||
services = append(services, raw.(*structs.ServiceRegistration))
|
||||
}
|
||||
reply.Services = services
|
||||
|
||||
// Use the index table to populate the query meta as we have no way
|
||||
// of tracking the max index on deletes.
|
||||
return a.srv.setReplyQueryMeta(stateStore, state.TableServiceRegistrations, &reply.QueryMeta)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// SignIdentities allows nodes to retrieve workload identities for their
|
||||
// allocations.
|
||||
//
|
||||
// This is an internal-only RPC and not exposed via the HTTP API.
|
||||
func (a *Alloc) SignIdentities(args *structs.AllocIdentitiesRequest, reply *structs.AllocIdentitiesResponse) error {
|
||||
|
||||
aclObj, err := a.srv.AuthenticateClientOnly(a.ctx, args)
|
||||
if done, err := a.srv.forward("Alloc.SignIdentities", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
a.srv.MeasureRPCRate("alloc", structs.RateMetricRead, args)
|
||||
if err != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "alloc", "sign_identities"}, time.Now())
|
||||
|
||||
if len(args.Identities) == 0 {
|
||||
// Client bug. Fail loudly instead of letting clients waste time with
|
||||
// noops.
|
||||
return fmt.Errorf("no identities requested")
|
||||
}
|
||||
|
||||
// Tracks whether the min index was satisfied by the blocking query
|
||||
thresholdMet := false
|
||||
|
||||
// Most if not all identity requests will be for the same alloc, so create a
|
||||
// set of alloc IDs to avoid unnecessary looping in the blocking query.
|
||||
allocs := make(map[string]*structs.Allocation, len(args.Identities))
|
||||
for _, idReq := range args.Identities {
|
||||
allocs[idReq.AllocID] = nil // to be set while watching
|
||||
}
|
||||
|
||||
opts := blockingOptions{
|
||||
queryOpts: &args.QueryOptions,
|
||||
queryMeta: &reply.QueryMeta,
|
||||
run: func(ws memdb.WatchSet, state *state.StateStore) error {
|
||||
var maxIndex uint64
|
||||
|
||||
// Lookup the allocations
|
||||
for allocID := range allocs {
|
||||
out, err := state.AllocByID(ws, allocID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if out == nil {
|
||||
// Alloc may have been GC'd and therefore should not be able to get
|
||||
// identities signed.
|
||||
continue
|
||||
}
|
||||
|
||||
if err := a.srv.AuthorizeClientAllocation(aclObj, out, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Keep the alloc around so we can skip the statestore lookup later
|
||||
allocs[allocID] = out
|
||||
|
||||
// If we have found a requested alloc created after the min query index
|
||||
// that means we're observing a new enough version of state to satisfy
|
||||
// the query.
|
||||
if out.CreateIndex > args.QueryOptions.MinQueryIndex {
|
||||
thresholdMet = true
|
||||
}
|
||||
|
||||
if maxIndex < out.CreateIndex {
|
||||
maxIndex = out.CreateIndex
|
||||
}
|
||||
}
|
||||
|
||||
// If we could not find an alloc created after the min index, note when
|
||||
// the index allocs were last updated.
|
||||
if !thresholdMet {
|
||||
index, err := state.Index("allocs")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
maxIndex = index
|
||||
}
|
||||
|
||||
reply.Index = maxIndex
|
||||
return nil
|
||||
}}
|
||||
|
||||
if err := a.srv.blockingRPC(&opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Index threshold was not met in the blocking query. Set rejections since
|
||||
// allocs could not be found and should be considered invalid.
|
||||
if !thresholdMet {
|
||||
for _, idReq := range args.Identities {
|
||||
reply.Rejections = append(reply.Rejections, &structs.WorkloadIdentityRejection{
|
||||
WorkloadIdentityRequest: *idReq,
|
||||
Reason: structs.WIRejectionReasonMissingAlloc,
|
||||
})
|
||||
}
|
||||
|
||||
// Return early so the rest of the func acts as the else (thresholdMet)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Threshold met, so create the response
|
||||
now := time.Now().UTC()
|
||||
for _, idReq := range args.Identities {
|
||||
out := allocs[idReq.AllocID]
|
||||
|
||||
if out == nil {
|
||||
// Alloc may have been GC'd and therefore should not be able to get
|
||||
// new identities.
|
||||
reply.Rejections = append(reply.Rejections, &structs.WorkloadIdentityRejection{
|
||||
WorkloadIdentityRequest: *idReq,
|
||||
Reason: structs.WIRejectionReasonMissingAlloc,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
job := out.Job
|
||||
|
||||
switch idReq.WorkloadType {
|
||||
case structs.WorkloadTypeTask:
|
||||
task := out.LookupTask(idReq.WorkloadIdentifier)
|
||||
if task == nil {
|
||||
// Job has likely been updated to remove this task
|
||||
reply.Rejections = append(reply.Rejections, &structs.WorkloadIdentityRejection{
|
||||
WorkloadIdentityRequest: *idReq,
|
||||
Reason: structs.WIRejectionReasonMissingTask,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
widFound, err := a.signTasks(task, out, idReq, reply, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !widFound {
|
||||
reply.Rejections = append(reply.Rejections, &structs.WorkloadIdentityRejection{
|
||||
WorkloadIdentityRequest: *idReq,
|
||||
Reason: structs.WIRejectionReasonMissingIdentity,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
case structs.WorkloadTypeService:
|
||||
widFound, err := a.signServices(job, out, idReq, reply, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !widFound {
|
||||
reply.Rejections = append(reply.Rejections, &structs.WorkloadIdentityRejection{
|
||||
WorkloadIdentityRequest: *idReq,
|
||||
Reason: structs.WIRejectionReasonMissingIdentity,
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Alloc) signTasks(
|
||||
task *structs.Task,
|
||||
alloc *structs.Allocation,
|
||||
idReq *structs.WorkloadIdentityRequest,
|
||||
reply *structs.AllocIdentitiesResponse,
|
||||
now time.Time,
|
||||
) (widFound bool, err error) {
|
||||
for _, wid := range task.Identities {
|
||||
if wid.Name != idReq.IdentityName {
|
||||
continue
|
||||
}
|
||||
|
||||
widFound = true
|
||||
builder := structs.NewIdentityClaimsBuilder(alloc.Job, alloc, &idReq.WIHandle, wid).
|
||||
WithTask(task).
|
||||
WithConsul()
|
||||
|
||||
var node *structs.Node
|
||||
node, err = a.srv.State().NodeByID(nil, alloc.NodeID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
builder.WithNode(node)
|
||||
|
||||
vaultCfg := a.srv.GetConfig().GetVaultForIdentity(wid)
|
||||
if vaultCfg != nil && vaultCfg.DefaultIdentity != nil {
|
||||
builder.WithVault(vaultCfg.DefaultIdentity.ExtraClaims)
|
||||
}
|
||||
|
||||
claims := builder.Build(now)
|
||||
err = a.signClaims(claims, idReq, reply)
|
||||
break
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (a *Alloc) signServices(
|
||||
job *structs.Job,
|
||||
alloc *structs.Allocation,
|
||||
idReq *structs.WorkloadIdentityRequest,
|
||||
reply *structs.AllocIdentitiesResponse,
|
||||
now time.Time,
|
||||
) (widFound bool, err error) {
|
||||
wid := idReq.WIHandle
|
||||
|
||||
// services can be on the level of task groups or tasks
|
||||
for _, tg := range job.TaskGroups {
|
||||
for _, service := range tg.Services {
|
||||
if service.IdentityHandle(nil).Equal(wid) {
|
||||
claims := structs.NewIdentityClaimsBuilder(
|
||||
alloc.Job, alloc, &idReq.WIHandle, service.Identity).
|
||||
WithConsul().
|
||||
WithService(service).
|
||||
Build(now)
|
||||
return true, a.signClaims(claims, idReq, reply)
|
||||
}
|
||||
}
|
||||
for _, task := range tg.Tasks {
|
||||
for _, service := range task.Services {
|
||||
if service.IdentityHandle(nil).Equal(wid) {
|
||||
claims := structs.NewIdentityClaimsBuilder(
|
||||
alloc.Job, alloc, &idReq.WIHandle, service.Identity).
|
||||
WithTask(task).
|
||||
WithConsul().
|
||||
WithService(service).
|
||||
Build(now)
|
||||
return true, a.signClaims(claims, idReq, reply)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (a *Alloc) signClaims(
|
||||
claims *structs.IdentityClaims,
|
||||
idReq *structs.WorkloadIdentityRequest,
|
||||
reply *structs.AllocIdentitiesResponse,
|
||||
) error {
|
||||
token, _, err := a.srv.encrypter.SignClaims(claims)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply.SignedIdentities = append(reply.SignedIdentities, &structs.SignedWorkloadIdentity{
|
||||
WorkloadIdentityRequest: *idReq,
|
||||
JWT: token,
|
||||
Expiration: claims.Expiry.Time(),
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,957 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-hclog"
|
||||
metrics "github.com/hashicorp/go-metrics/compat"
|
||||
"github.com/hashicorp/nomad/acl"
|
||||
"github.com/hashicorp/nomad/helper"
|
||||
"github.com/hashicorp/nomad/nomad/state"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
// aclCacheSize is the number of ACL objects to keep cached. ACLs have a parsing
|
||||
// and construction cost, so we keep the hot objects cached to reduce the ACL
|
||||
// token resolution time.
|
||||
const aclCacheSize = 512
|
||||
|
||||
type StateGetter func() *state.StateStore
|
||||
type LeaderACLGetter func() string
|
||||
|
||||
type RPCContext interface {
|
||||
IsTLS() bool
|
||||
IsStatic() bool
|
||||
Certificate() *x509.Certificate
|
||||
GetRemoteIP() (net.IP, error)
|
||||
}
|
||||
|
||||
type Encrypter interface {
|
||||
VerifyClaim(string) (*structs.IdentityClaims, error)
|
||||
}
|
||||
|
||||
type Authenticator struct {
|
||||
aclsEnabled bool
|
||||
|
||||
// verifyTLS is used to determine whether the server should verify TLS and
|
||||
// is an atomic bool, so that the server TLS reload can update it at runtime
|
||||
// with a race condition.
|
||||
verifyTLS *atomic.Bool
|
||||
|
||||
logger hclog.Logger
|
||||
getState StateGetter
|
||||
getLeaderACL LeaderACLGetter
|
||||
region string
|
||||
|
||||
validServerCertNames []string
|
||||
validClientCertNames []string
|
||||
|
||||
// aclCache is used to maintain the parsed ACL objects
|
||||
aclCache *structs.ACLCache[*acl.ACL]
|
||||
|
||||
// encrypter is a pointer to the server's Encrypter that can be used to
|
||||
// verify claims
|
||||
encrypter Encrypter
|
||||
}
|
||||
|
||||
type AuthenticatorConfig struct {
|
||||
StateFn StateGetter
|
||||
Logger hclog.Logger
|
||||
GetLeaderACLFn LeaderACLGetter
|
||||
AclsEnabled bool
|
||||
VerifyTLS bool
|
||||
Region string
|
||||
Encrypter Encrypter
|
||||
}
|
||||
|
||||
func NewAuthenticator(cfg *AuthenticatorConfig) *Authenticator {
|
||||
a := Authenticator{
|
||||
aclsEnabled: cfg.AclsEnabled,
|
||||
verifyTLS: &atomic.Bool{},
|
||||
logger: cfg.Logger.With("auth"),
|
||||
getState: cfg.StateFn,
|
||||
getLeaderACL: cfg.GetLeaderACLFn,
|
||||
region: cfg.Region,
|
||||
aclCache: structs.NewACLCache[*acl.ACL](aclCacheSize),
|
||||
encrypter: cfg.Encrypter,
|
||||
validServerCertNames: []string{"server." + cfg.Region + ".nomad"},
|
||||
validClientCertNames: []string{
|
||||
"client." + cfg.Region + ".nomad",
|
||||
"server." + cfg.Region + ".nomad",
|
||||
},
|
||||
}
|
||||
|
||||
a.verifyTLS.Store(cfg.VerifyTLS)
|
||||
return &a
|
||||
}
|
||||
|
||||
// SetVerifyTLS is a helper method to set the verifyTLS field. This is used in
|
||||
// when the server TLS configuration is updated.
|
||||
func (s *Authenticator) SetVerifyTLS(verifyTLS bool) { s.verifyTLS.Store(verifyTLS) }
|
||||
|
||||
// Authenticate extracts an AuthenticatedIdentity from the request context or
|
||||
// provided token and sets the identity on the request. The caller can extract
|
||||
// an acl.ACL, WorkloadIdentity, or other identifying tokens to use for
|
||||
// authorization. Keeping these fields independent rather than merging them into
|
||||
// an ephemeral ACLToken makes the original of the credential clear to RPC
|
||||
// handlers, who may have different behavior for internal vs external origins.
|
||||
//
|
||||
// Note: when making a server-to-server RPC that authenticates with this method,
|
||||
// the RPC *must* include the leader's ACL token. Use AuthenticateServerOnly for
|
||||
// requests that don't have access to the leader's ACL token.
|
||||
//
|
||||
// Note: when called on the follower we'll be making stale queries, so it's
|
||||
// possible if the follower is behind that the leader will get a different value
|
||||
// if an ACL token or allocation's WI has just been created.
|
||||
//
|
||||
// This method returns errors that are used for testing diagnostics. RPC callers
|
||||
// should always return ErrPermissionDenied after checking forwarding when one
|
||||
// of these errors is received.
|
||||
func (s *Authenticator) Authenticate(ctx RPCContext, args structs.RequestWithIdentity) error {
|
||||
|
||||
// get the user ACLToken or anonymous token
|
||||
secretID := args.GetAuthToken()
|
||||
aclToken, err := s.resolveSecretToken(secretID)
|
||||
|
||||
switch {
|
||||
case err == nil && (aclToken == structs.AnonymousACLToken ||
|
||||
aclToken == structs.ACLsDisabledToken):
|
||||
// When ACLs are disabled or if we have an anonymous token, we want to
|
||||
// continue on to check mTLS certs, if available, so set the token but
|
||||
// don't return yet
|
||||
args.SetIdentity(&structs.AuthenticatedIdentity{ACLToken: aclToken})
|
||||
|
||||
case err == nil:
|
||||
// ACLs are enabled and we have a non-anonymous token, so set that as
|
||||
// our identity and return
|
||||
args.SetIdentity(&structs.AuthenticatedIdentity{ACLToken: aclToken})
|
||||
return nil
|
||||
|
||||
case errors.Is(err, structs.ErrTokenExpired):
|
||||
return err
|
||||
|
||||
case errors.Is(err, structs.ErrTokenInvalid):
|
||||
// if it's not a UUID it might be an identity claim
|
||||
claims, err := s.VerifyClaim(secretID)
|
||||
if err != nil {
|
||||
// we already know the token wasn't valid for an ACL in the state
|
||||
// store, so if we get an error at this point we have an invalid
|
||||
// token and there are no other options but to bail out
|
||||
return err
|
||||
}
|
||||
|
||||
args.SetIdentity(&structs.AuthenticatedIdentity{Claims: claims})
|
||||
return nil
|
||||
|
||||
case errors.Is(err, structs.ErrTokenNotFound):
|
||||
// Check if the secret ID is the leader's secret ID, in which case treat
|
||||
// it as a management token.
|
||||
leaderAcl := s.getLeaderACL()
|
||||
if leaderAcl != "" && secretID == leaderAcl {
|
||||
aclToken = structs.LeaderACLToken
|
||||
break
|
||||
} else {
|
||||
// Otherwise, see if the secret ID belongs to a node. We should
|
||||
// reach this point only on first connection.
|
||||
node, err := s.getState().NodeBySecretID(nil, secretID)
|
||||
if err != nil {
|
||||
// this is a go-memdb error; shouldn't happen
|
||||
return fmt.Errorf("could not resolve node secret: %w", err)
|
||||
}
|
||||
if node != nil {
|
||||
args.SetIdentity(&structs.AuthenticatedIdentity{ClientID: node.ID})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// we were passed a bogus token so we'll return an error, but we'll also
|
||||
// want to capture the IP for metrics
|
||||
remoteIP, err := ctx.GetRemoteIP()
|
||||
if err != nil {
|
||||
s.logger.Error("could not determine remote address", "error", err)
|
||||
}
|
||||
args.SetIdentity(&structs.AuthenticatedIdentity{RemoteIP: remoteIP})
|
||||
return structs.ErrPermissionDenied
|
||||
|
||||
default: // any other error
|
||||
return fmt.Errorf("could not resolve user: %w", err)
|
||||
|
||||
}
|
||||
|
||||
// If there's no context we're in a "static" handler which only happens for
|
||||
// cases where the leader is making RPCs internally (volumewatcher and
|
||||
// deploymentwatcher)
|
||||
if ctx.IsStatic() {
|
||||
args.SetIdentity(&structs.AuthenticatedIdentity{ACLToken: aclToken})
|
||||
return nil
|
||||
}
|
||||
|
||||
// At this point we either have an anonymous token or an invalid one.
|
||||
|
||||
// Unlike clients that provide their Node ID on first connection, server
|
||||
// RPCs don't include an ID for the server so we identify servers by cert
|
||||
// and IP address.
|
||||
identity := &structs.AuthenticatedIdentity{ACLToken: aclToken}
|
||||
if ctx.IsTLS() {
|
||||
identity.TLSName = ctx.Certificate().Subject.CommonName
|
||||
}
|
||||
|
||||
remoteIP, err := ctx.GetRemoteIP()
|
||||
if err != nil {
|
||||
s.logger.Error(
|
||||
"could not authenticate RPC request or determine remote address", "error", err)
|
||||
return err
|
||||
}
|
||||
identity.RemoteIP = remoteIP
|
||||
args.SetIdentity(identity)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveACL is an authentication wrapper that handles resolving ACL
|
||||
// tokens, Workload Identities, or client secrets into acl.ACL objects.
|
||||
// Exclusively server-to-server or client-to-server requests should be using
|
||||
// AuthenticateServerOnly or AuthenticateClientOnly unless they use the
|
||||
// AuthenticateNodeIdentityGenerator function.
|
||||
func (s *Authenticator) ResolveACL(args structs.RequestWithIdentity) (*acl.ACL, error) {
|
||||
identity := args.GetIdentity()
|
||||
if identity == nil {
|
||||
// should never happen
|
||||
return nil, structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
if !s.aclsEnabled {
|
||||
return acl.ACLsDisabledACL, nil
|
||||
}
|
||||
|
||||
if aclObj, err := s.ResolveClientIdentityACL(identity); aclObj != nil || err != nil {
|
||||
return aclObj, err
|
||||
}
|
||||
|
||||
claims := identity.GetClaims()
|
||||
if claims != nil {
|
||||
return s.resolveClaims(claims)
|
||||
}
|
||||
|
||||
// this will include any anonymous token, so this is the last chance to
|
||||
// avoid an error
|
||||
aclToken := identity.GetACLToken()
|
||||
if aclToken != nil {
|
||||
return s.resolveACLForToken(aclToken)
|
||||
}
|
||||
|
||||
return nil, structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// AuthenticateServerOnly returns an ACL object for use *only* with internal
|
||||
// server-to-server RPCs. This should never be used for RPCs that serve HTTP
|
||||
// endpoints or accept ACL tokens to avoid confused deputy attacks by making a
|
||||
// request to a follower that's forwarded.
|
||||
//
|
||||
// The returned ACL object is always an acl.ServerACL but in the future this
|
||||
// could be extended to allow servers to have jurisdiction over specific pools,
|
||||
// etc.
|
||||
func (s *Authenticator) AuthenticateServerOnly(ctx RPCContext, args structs.RequestWithIdentity) (*acl.ACL, error) {
|
||||
|
||||
remoteIP, err := ctx.GetRemoteIP() // capture for metrics
|
||||
if err != nil {
|
||||
s.logger.Error("could not determine remote address", "error", err)
|
||||
}
|
||||
|
||||
identity := &structs.AuthenticatedIdentity{RemoteIP: remoteIP}
|
||||
defer args.SetIdentity(identity) // always set the identity, even on errors
|
||||
|
||||
// Note: if servers had auth tokens like clients do, we would be able to
|
||||
// verify them here and only return the server ACL for actual servers even
|
||||
// if mTLS was disabled. Without mTLS, any request can spoof server RPCs.
|
||||
// This is known and documented in the Security Model:
|
||||
// https://developer.hashicorp.com/nomad/docs/secure/acl
|
||||
if err := verifyTLS(s.verifyTLS.Load(), ctx, s.validServerCertNames, identity); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return acl.ServerACL, nil
|
||||
}
|
||||
|
||||
// AuthenticateNodeIdentityGenerator is used for RPC endpoints (Node.Register
|
||||
// and Node.UpdateStatus) that have the potential to generate node identities.
|
||||
//
|
||||
// While the Authenticate method serves as a complete general purpose
|
||||
// authenticator, in some critical cases for identity generation checking, it
|
||||
// swallows the information needed.
|
||||
func (s *Authenticator) AuthenticateNodeIdentityGenerator(ctx RPCContext, args structs.RequestWithIdentity) error {
|
||||
|
||||
remoteIP, err := ctx.GetRemoteIP() // capture for metrics
|
||||
if err != nil {
|
||||
s.logger.Error("could not determine remote address", "error", err)
|
||||
}
|
||||
|
||||
identity := &structs.AuthenticatedIdentity{RemoteIP: remoteIP}
|
||||
defer args.SetIdentity(identity)
|
||||
|
||||
if err := verifyTLS(s.verifyTLS.Load(), ctx, s.validClientCertNames, identity); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
authToken := args.GetAuthToken()
|
||||
|
||||
// If the auth token is empty, we treat it as an anonymous request. In the
|
||||
// event of a node registration, this means the node is not yet registered.
|
||||
if authToken == "" {
|
||||
identity.ACLToken = structs.AnonymousACLToken
|
||||
return nil
|
||||
}
|
||||
|
||||
// If the auth token is a UUID, we check whether it's a node secret ID or
|
||||
// the leader's ACL token. If it's not a UUID, we assume it's a node
|
||||
// identity. Anything outside these cases is not supported and no identity
|
||||
// will be set.
|
||||
if helper.IsUUID(authToken) {
|
||||
if leaderACL := s.getLeaderACL(); leaderACL != "" && authToken == leaderACL {
|
||||
identity.ACLToken = structs.LeaderACLToken
|
||||
} else {
|
||||
node, err := s.getState().NodeBySecretID(nil, authToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not resolve node secret: %w", err)
|
||||
}
|
||||
if node == nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
identity.ClientID = node.ID
|
||||
}
|
||||
} else {
|
||||
// When verifying a node identity claim, we do not want to swallow the
|
||||
// initial error. This is because the caller may want to handle the
|
||||
// error type in the case that the JWT is expired.
|
||||
claims, err := s.VerifyClaim(authToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !claims.IsNode() && !claims.IsNodeIntroduction() {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
identity.Claims = claims
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthenticateClientOnly returns an ACL object for use *only* with internal
|
||||
// RPCs originating from clients (including those forwarded). This should never
|
||||
// be used for RPCs that serve HTTP endpoints to avoid confused deputy attacks
|
||||
// by making a request to a client that's forwarded. It should also not be used
|
||||
// with Node.Register or NodeUpdateStatus, which should use
|
||||
// AuthenticateNodeIdentityGenerator.
|
||||
func (s *Authenticator) AuthenticateClientOnly(ctx RPCContext, args structs.RequestWithIdentity) (*acl.ACL, error) {
|
||||
|
||||
remoteIP, err := ctx.GetRemoteIP() // capture for metrics
|
||||
if err != nil {
|
||||
s.logger.Error("could not determine remote address", "error", err)
|
||||
}
|
||||
|
||||
identity := &structs.AuthenticatedIdentity{RemoteIP: remoteIP}
|
||||
defer args.SetIdentity(identity) // always set the identity, even on errors
|
||||
|
||||
if err := verifyTLS(s.verifyTLS.Load(), ctx, s.validClientCertNames, identity); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authToken := args.GetAuthToken()
|
||||
if authToken == "" {
|
||||
return nil, structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// If the auth token is a UUID, we treat it as a node secret ID. Otherwise,
|
||||
// we assume it's a node identity claim. Anything outside these cases is not
|
||||
// permitted when using this method.
|
||||
if helper.IsUUID(authToken) {
|
||||
node, err := s.getState().NodeBySecretID(nil, authToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not resolve node secret: %w", err)
|
||||
}
|
||||
if node == nil {
|
||||
return nil, structs.ErrPermissionDenied
|
||||
}
|
||||
identity.ClientID = node.ID
|
||||
} else {
|
||||
claims, err := s.VerifyClaim(authToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !claims.IsNode() || claims.NodeIdentityClaims == nil {
|
||||
return nil, structs.ErrPermissionDenied
|
||||
}
|
||||
identity.ClientID = claims.NodeIdentityClaims.NodeID
|
||||
identity.Claims = claims
|
||||
}
|
||||
|
||||
return s.ResolveClientIdentityACL(identity)
|
||||
}
|
||||
|
||||
// verifyTLS is a helper function that performs TLS verification, if required,
|
||||
// given the passed RPCContext and valid names.
|
||||
//
|
||||
// It will always set the TLSName on the identity if we are performing
|
||||
// verification, so callers don't have to worry about setting it themselves.
|
||||
func verifyTLS(verify bool, ctx RPCContext, validNames []string, identity *structs.AuthenticatedIdentity) error {
|
||||
|
||||
if verify && !ctx.IsStatic() {
|
||||
|
||||
tlsCert := ctx.Certificate()
|
||||
if tlsCert == nil {
|
||||
return errors.New("missing certificate information")
|
||||
}
|
||||
|
||||
// Always set on the identity, even before validating the name, so we
|
||||
// can capture it for metrics.
|
||||
identity.TLSName = tlsCert.Subject.CommonName
|
||||
|
||||
// Perform the certificate validation, using the passed valid names.
|
||||
_, err := validateCertificateForNames(tlsCert, validNames)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateCertificateForNames returns true if the certificate is valid for any
|
||||
// of the given domain names.
|
||||
func validateCertificateForNames(cert *x509.Certificate, expectedNames []string) (bool, error) {
|
||||
if cert == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
validNames := []string{cert.Subject.CommonName}
|
||||
validNames = append(validNames, cert.DNSNames...)
|
||||
|
||||
for _, expectedName := range expectedNames {
|
||||
if slices.Contains(validNames, expectedName) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("invalid certificate: %s not in expected %s",
|
||||
strings.Join(validNames, ", "),
|
||||
strings.Join(expectedNames, ", "))
|
||||
|
||||
}
|
||||
|
||||
// IdentityToACLClaim returns an ACLClaim suitable for checking permissions
|
||||
func IdentityToACLClaim(ai *structs.AuthenticatedIdentity, store *state.StateStore) *acl.ACLClaim {
|
||||
if ai == nil || ai.Claims == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var group string
|
||||
alloc, err := store.AllocByID(nil, ai.Claims.AllocationID)
|
||||
if err != nil {
|
||||
// we should never hit this error, but if we did the caller would get a
|
||||
// nil claim and auth will fail
|
||||
return nil
|
||||
}
|
||||
if alloc != nil {
|
||||
group = alloc.TaskGroup
|
||||
}
|
||||
|
||||
return &acl.ACLClaim{
|
||||
Namespace: ai.Claims.Namespace,
|
||||
Job: ai.Claims.JobID,
|
||||
Group: group,
|
||||
Task: ai.Claims.TaskName,
|
||||
}
|
||||
}
|
||||
|
||||
// resolveACLForToken resolves an ACL from a token only. It should be used only
|
||||
// by Variables endpoints, which have additional implicit policies for their
|
||||
// claims so we can't wrap them up in ResolveACL.
|
||||
func (s *Authenticator) resolveACLForToken(aclToken *structs.ACLToken) (*acl.ACL, error) {
|
||||
snap, err := s.getState().Snapshot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resolveACLFromToken(snap, s.aclCache, aclToken)
|
||||
}
|
||||
|
||||
// ResolveToken is used to translate an ACL Token Secret ID into
|
||||
// an ACL object, nil if ACLs are disabled, or an error.
|
||||
//
|
||||
// TODO(tgross): this is used in lots of places we probably should be calling
|
||||
// Authenticate + ResolveACL on, because they support HTTP APIs that may be used
|
||||
// by the Task API
|
||||
func (s *Authenticator) ResolveToken(secretID string) (*acl.ACL, error) {
|
||||
// Fast-path if ACLs are disabled
|
||||
if !s.aclsEnabled {
|
||||
return acl.ACLsDisabledACL, nil
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "acl", "resolveToken"}, time.Now())
|
||||
|
||||
// Check if the secret ID is the leader secret ID, in which case treat it as
|
||||
// a management token.
|
||||
if leaderAcl := s.getLeaderACL(); leaderAcl != "" && secretID == leaderAcl {
|
||||
return acl.ManagementACL, nil
|
||||
}
|
||||
|
||||
// Snapshot the state
|
||||
snap, err := s.getState().Snapshot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Resolve the ACL
|
||||
return resolveTokenFromSnapshotCache(snap, s.aclCache, secretID)
|
||||
}
|
||||
|
||||
// VerifyClaim asserts that the token is valid. If it is for a workload
|
||||
// identity, it will ensure that the resulting allocation ID belongs to a
|
||||
// non-terminal allocation. If the token is for a node identity, it will ensure
|
||||
// the node ID matches the claim.
|
||||
//
|
||||
// This should usually not be called by RPC handlers.
|
||||
func (s *Authenticator) VerifyClaim(token string) (*structs.IdentityClaims, error) {
|
||||
|
||||
claims, err := s.encrypter.VerifyClaim(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims.IsWorkload() {
|
||||
if err := s.verifyWorkloadIdentityClaim(claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// If the claims are for a node identity, we can return them directly once
|
||||
// we have verified the claim. In the happy path, we could read the node out
|
||||
// of state and verify that it is found. However, it is possible the node
|
||||
// has been garbage collected, and if we failed on that check, the node
|
||||
// would not be able to register again without manual intervention.
|
||||
if claims.IsNode() {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// Node introduction claims are a case where we don't verify them against
|
||||
// the state store, since they are used to introduce a node that does not
|
||||
// yet exist.
|
||||
if claims.IsNodeIntroduction() {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("failed to determine claim type")
|
||||
}
|
||||
|
||||
// ResolveClientIdentityACL resolves a client ACL from an authenticated node
|
||||
// identity or client secret based identity. It returns (nil, nil) if the
|
||||
// identity does not represent a client.
|
||||
func (s *Authenticator) ResolveClientIdentityACL(identity *structs.AuthenticatedIdentity) (*acl.ACL, error) {
|
||||
if identity == nil || identity.ClientID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if claims := identity.GetClaims(); claims != nil {
|
||||
if !claims.IsNode() || claims.NodeIdentityClaims == nil || claims.NodeIdentityClaims.NodePool == "" {
|
||||
return nil, structs.ErrPermissionDenied
|
||||
}
|
||||
return acl.NewClientACL(claims.NodeIdentityClaims.NodePool), nil
|
||||
}
|
||||
|
||||
snap, err := s.getState().Snapshot()
|
||||
if err != nil {
|
||||
return nil, structs.ErrPermissionDenied
|
||||
}
|
||||
node, err := snap.NodeByID(nil, identity.ClientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if node == nil {
|
||||
return nil, structs.ErrPermissionDenied
|
||||
}
|
||||
return acl.NewClientACL(node.NodePool), nil
|
||||
}
|
||||
|
||||
// AuthenticatedNodeID returns the authenticated node ID for client and node
|
||||
// identity based requests.
|
||||
func AuthenticatedNodeID(identity *structs.AuthenticatedIdentity) string {
|
||||
if identity == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if identity.ClientID != "" {
|
||||
return identity.ClientID
|
||||
}
|
||||
|
||||
claims := identity.GetClaims()
|
||||
if claims != nil && claims.IsNode() && claims.NodeIdentityClaims != nil {
|
||||
return claims.NodeIdentityClaims.NodeID
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// AuthorizeSameNode returns ErrPermissionDenied unless the authenticated
|
||||
// identity represents the same node as targetNodeID.
|
||||
func AuthorizeSameNode(identity *structs.AuthenticatedIdentity, targetNodeID string) error {
|
||||
if AuthenticatedNodeID(identity) != targetNodeID {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthorizeSameNodeServiceRegistrations returns ErrPermissionDenied unless the
|
||||
// authenticated identity represents the same node as every service
|
||||
// registration's NodeID.
|
||||
func AuthorizeSameNodeServiceRegistrations(
|
||||
identity *structs.AuthenticatedIdentity,
|
||||
services []*structs.ServiceRegistration,
|
||||
) error {
|
||||
for _, service := range services {
|
||||
if service == nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
if err := AuthorizeSameNode(identity, service.NodeID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveAuthorizedClientNodePoolByNodeID resolves the node pool for nodeID
|
||||
func resolveAuthorizedClientNodePoolByNodeID(snap *state.StateSnapshot, aclObj *acl.ACL, nodeID string) (string, error) {
|
||||
if nodeID == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
pool, ok, err := snap.NodePoolByNodeID(nil, nodeID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
if !aclObj.AllowClientOp(pool) {
|
||||
return "", structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
// ResolveAuthorizedClientNodePoolByNodeID resolves the node pool for nodeID
|
||||
// and returns it if aclObj is authorized for that pool. It returns an empty
|
||||
// pool and nil error if the node does not exist yet, which allows callers to
|
||||
// continue handling blocking-query flows that may legitimately observe a
|
||||
// missing node.
|
||||
func (s *Authenticator) ResolveAuthorizedClientNodePoolByNodeID(aclObj *acl.ACL, nodeID string) (string, error) {
|
||||
snap, err := s.getState().Snapshot()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return resolveAuthorizedClientNodePoolByNodeID(snap, aclObj, nodeID)
|
||||
}
|
||||
|
||||
// AuthorizeClientAllocation returns ErrPermissionDenied unless aclObj is
|
||||
// authorized for alloc's node pool. If allowNsOp is provided, callers may
|
||||
// fall back to namespace-based authorization when client-scoped authorization
|
||||
// does not apply.
|
||||
func (s *Authenticator) AuthorizeClientAllocation(
|
||||
aclObj *acl.ACL,
|
||||
alloc *structs.Allocation,
|
||||
allowNsOp func(*acl.ACL, string) bool,
|
||||
) error {
|
||||
if alloc == nil || alloc.Job == nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
if aclObj.AllowClientOp(alloc.Job.NodePool) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if allowNsOp != nil && allowNsOp(aclObj, alloc.Namespace) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// ResolveAuthorizedClientNodePoolByServiceRegistrationID resolves the node pool
|
||||
// for the node backing a service registration and returns it if aclObj is
|
||||
// authorized for that pool.
|
||||
func (s *Authenticator) ResolveAuthorizedClientNodePoolByServiceRegistrationID(
|
||||
aclObj *acl.ACL,
|
||||
namespace, id string,
|
||||
) (string, error) {
|
||||
if aclObj == nil || !(aclObj.AllowAgentRead() || aclObj.AllowServerOp() || aclObj.AllowNodeRead()) {
|
||||
return "", structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
snap, err := s.getState().Snapshot()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
registration, err := snap.GetServiceRegistrationByID(nil, namespace, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if registration == nil {
|
||||
return "", structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
return resolveAuthorizedClientNodePoolByNodeID(snap, aclObj, registration.NodeID)
|
||||
}
|
||||
|
||||
func (s *Authenticator) verifyWorkloadIdentityClaim(claims *structs.IdentityClaims) error {
|
||||
snap, err := s.getState().Snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
alloc, err := snap.AllocByID(nil, claims.AllocationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if alloc == nil || alloc.Job == nil {
|
||||
return fmt.Errorf("allocation does not exist")
|
||||
}
|
||||
|
||||
// the claims for terminal allocs are always treated as expired
|
||||
if alloc.ClientTerminalStatus() {
|
||||
return fmt.Errorf("allocation is terminal")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Authenticator) resolveClaims(claims *structs.IdentityClaims) (*acl.ACL, error) {
|
||||
|
||||
// Nomad node identity claims currently map to a client ACL. If we open this
|
||||
// up in the future, we will want to modify this section to perform similar
|
||||
// work that is done for workload claims.
|
||||
if claims.IsNode() {
|
||||
if claims.NodeIdentityClaims == nil || claims.NodeIdentityClaims.NodePool == "" {
|
||||
return nil, fmt.Errorf("node identity claims missing node pool")
|
||||
}
|
||||
return acl.NewClientACL(claims.NodeIdentityClaims.NodePool), nil
|
||||
}
|
||||
|
||||
policies, err := s.ResolvePoliciesForClaims(claims)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Compile and cache the ACL object. For many claims this will result in an
|
||||
// ACL object with no policies, which can be efficiently cached.
|
||||
aclObj, err := structs.CompileACLObject(s.aclCache, policies)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return aclObj, nil
|
||||
}
|
||||
|
||||
// resolveTokenFromSnapshotCache is used to resolve an ACL object from a
|
||||
// snapshot of state, using a cache to avoid parsing and ACL construction when
|
||||
// possible. It is split from resolveToken to simplify testing.
|
||||
func resolveTokenFromSnapshotCache(snap *state.StateSnapshot, cache *structs.ACLCache[*acl.ACL], secretID string) (*acl.ACL, error) {
|
||||
// Lookup the ACL Token
|
||||
var token *structs.ACLToken
|
||||
var err error
|
||||
|
||||
// Handle anonymous requests
|
||||
if secretID == "" {
|
||||
token = structs.AnonymousACLToken
|
||||
} else {
|
||||
token, err = snap.ACLTokenBySecretID(nil, secretID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if token == nil {
|
||||
return nil, structs.ErrTokenNotFound
|
||||
}
|
||||
if token.IsExpired(time.Now().UTC()) {
|
||||
return nil, structs.ErrTokenExpired
|
||||
}
|
||||
}
|
||||
|
||||
return resolveACLFromToken(snap, cache, token)
|
||||
|
||||
}
|
||||
|
||||
func resolveACLFromToken(snap *state.StateSnapshot, cache *structs.ACLCache[*acl.ACL], token *structs.ACLToken) (*acl.ACL, error) {
|
||||
|
||||
// Check if this is a management token
|
||||
if token.Type == structs.ACLManagementToken {
|
||||
return acl.ManagementACL, nil
|
||||
}
|
||||
|
||||
// Store all policies detailed in the token request, this includes the
|
||||
// named policies and those referenced within the role link.
|
||||
policies := make([]*structs.ACLPolicy, 0, len(token.Policies)+len(token.Roles))
|
||||
|
||||
// Iterate all the token policies and add these to our policy tracking
|
||||
// array.
|
||||
for _, policyName := range token.Policies {
|
||||
policy, err := snap.ACLPolicyByName(nil, policyName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if policy == nil {
|
||||
// Ignore policies that don't exist, since they don't grant any
|
||||
// more privilege.
|
||||
continue
|
||||
}
|
||||
|
||||
// Add the policy to the tracking array.
|
||||
policies = append(policies, policy)
|
||||
}
|
||||
|
||||
// Iterate all the token role links, so we can unpack these and identify
|
||||
// the ACL policies.
|
||||
for _, roleLink := range token.Roles {
|
||||
|
||||
// Any error reading the role means we cannot move forward. We just
|
||||
// ignore any roles that have been detailed but are not within our
|
||||
// state.
|
||||
role, err := snap.GetACLRoleByID(nil, roleLink.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if role == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Unpack the policies held within the ACL role to form a single list
|
||||
// of ACL policies that this token has available.
|
||||
for _, policyLink := range role.Policies {
|
||||
policy, err := snap.ACLPolicyByName(nil, policyLink.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Ignore policies that don't exist, since they don't grant any
|
||||
// more privilege.
|
||||
if policy == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Add the policy to the tracking array.
|
||||
policies = append(policies, policy)
|
||||
}
|
||||
}
|
||||
|
||||
// Compile and cache the ACL object
|
||||
aclObj, err := structs.CompileACLObject(cache, policies)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return aclObj, nil
|
||||
}
|
||||
|
||||
// resolveSecretToken is used to translate an ACL Token Secret ID into a
|
||||
// Accessor ID, the anonymous accessor, or an error.
|
||||
func (s *Authenticator) resolveSecretToken(secretID string) (*structs.ACLToken, error) {
|
||||
|
||||
defer metrics.MeasureSince([]string{"nomad", "acl", "accessorForSecretToken"}, time.Now())
|
||||
|
||||
if !s.aclsEnabled {
|
||||
return structs.ACLsDisabledToken, nil
|
||||
}
|
||||
if secretID == "" {
|
||||
return structs.AnonymousACLToken, nil
|
||||
}
|
||||
if !helper.IsUUID(secretID) {
|
||||
return nil, structs.ErrTokenInvalid
|
||||
}
|
||||
|
||||
snap, err := s.getState().Snapshot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Lookup the ACL Token
|
||||
token, err := snap.ACLTokenBySecretID(nil, secretID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if token == nil {
|
||||
return nil, structs.ErrTokenNotFound
|
||||
}
|
||||
if token.IsExpired(time.Now().UTC()) {
|
||||
return nil, structs.ErrTokenExpired
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *Authenticator) ResolvePoliciesForClaims(claims *structs.IdentityClaims) ([]*structs.ACLPolicy, error) {
|
||||
|
||||
snap, err := s.getState().Snapshot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
alloc, err := snap.AllocByID(nil, claims.AllocationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if alloc == nil || alloc.Job == nil {
|
||||
return nil, fmt.Errorf("allocation does not exist")
|
||||
}
|
||||
|
||||
// Find any policies attached to the job
|
||||
jobId := alloc.Job.GetIDforWorkloadIdentity()
|
||||
iter, err := snap.ACLPolicyByJob(nil, alloc.Namespace, jobId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
policies := []*structs.ACLPolicy{}
|
||||
for {
|
||||
raw := iter.Next()
|
||||
if raw == nil {
|
||||
break
|
||||
}
|
||||
policy := raw.(*structs.ACLPolicy)
|
||||
if policy.JobACL == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case policy.JobACL.Group == "":
|
||||
policies = append(policies, policy)
|
||||
case policy.JobACL.Group != alloc.TaskGroup:
|
||||
continue // don't bother checking task
|
||||
case policy.JobACL.Task == "":
|
||||
policies = append(policies, policy)
|
||||
case policy.JobACL.Task == claims.TaskName:
|
||||
policies = append(policies, policy)
|
||||
}
|
||||
}
|
||||
|
||||
iter, err = snap.ACLPolicyByNamespace(nil, alloc.Namespace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for {
|
||||
raw := iter.Next()
|
||||
if raw == nil {
|
||||
break
|
||||
}
|
||||
|
||||
policy := raw.(*structs.ACLPolicy)
|
||||
if policy.JobACL == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
policies = append(policies, policy)
|
||||
}
|
||||
|
||||
return policies, nil
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,234 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
metrics "github.com/hashicorp/go-metrics/compat"
|
||||
"github.com/hashicorp/nomad/helper"
|
||||
"github.com/hashicorp/nomad/nomad/peers"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
"github.com/hashicorp/raft"
|
||||
autopilot "github.com/hashicorp/raft-autopilot"
|
||||
"github.com/hashicorp/serf/serf"
|
||||
)
|
||||
|
||||
const (
|
||||
// AutopilotRZTag is the Serf tag to use for the redundancy zone value
|
||||
// when passing the server metadata to Autopilot.
|
||||
AutopilotRZTag = "ap_zone"
|
||||
|
||||
// AutopilotRZTag is the Serf tag to use for the custom version value
|
||||
// when passing the server metadata to Autopilot.
|
||||
AutopilotVersionTag = "ap_version"
|
||||
)
|
||||
|
||||
// AutopilotDelegate is a Nomad delegate for autopilot operations. It implements
|
||||
// the autopilot.ApplicationIntegration interface, and the methods required for
|
||||
// that interface have been documented as such below.
|
||||
type AutopilotDelegate struct {
|
||||
server *Server
|
||||
}
|
||||
|
||||
// AutopilotConfig is used to retrieve the latest configuration from the Nomad
|
||||
// delegate. This method is required to implement the ApplicationIntegration
|
||||
// interface.
|
||||
func (d *AutopilotDelegate) AutopilotConfig() *autopilot.Config {
|
||||
c := d.server.getOrCreateAutopilotConfig()
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
conf := &autopilot.Config{
|
||||
CleanupDeadServers: c.CleanupDeadServers,
|
||||
LastContactThreshold: c.LastContactThreshold,
|
||||
MaxTrailingLogs: c.MaxTrailingLogs,
|
||||
MinQuorum: c.MinQuorum,
|
||||
ServerStabilizationTime: c.ServerStabilizationTime,
|
||||
Ext: autopilotConfigExt(c),
|
||||
}
|
||||
|
||||
return conf
|
||||
}
|
||||
|
||||
// FetchServerStats will be called by autopilot to request Nomad fetch the
|
||||
// server stats out of band. This method is required to implement the
|
||||
// ApplicationIntegration interface
|
||||
func (d *AutopilotDelegate) FetchServerStats(ctx context.Context, servers map[raft.ServerID]*autopilot.Server) map[raft.ServerID]*autopilot.ServerStats {
|
||||
return d.server.statsFetcher.Fetch(ctx, servers)
|
||||
}
|
||||
|
||||
// KnownServers will be called by autopilot to request the list of servers known
|
||||
// to Nomad. This method is required to implement the ApplicationIntegration
|
||||
// interface
|
||||
func (d *AutopilotDelegate) KnownServers() map[raft.ServerID]*autopilot.Server {
|
||||
return d.server.autopilotServers()
|
||||
}
|
||||
|
||||
// NotifyState will be called when the autopilot state is updated. The Nomad
|
||||
// leader heartbeats a metric for monitoring based on this information. This
|
||||
// method is required to implement the ApplicationIntegration interface
|
||||
func (d *AutopilotDelegate) NotifyState(state *autopilot.State) {
|
||||
if d.server.raft.State() == raft.Leader {
|
||||
metrics.SetGauge([]string{"nomad", "autopilot", "failure_tolerance"}, float32(state.FailureTolerance))
|
||||
if state.Healthy {
|
||||
metrics.SetGauge([]string{"nomad", "autopilot", "healthy"}, 1)
|
||||
} else {
|
||||
metrics.SetGauge([]string{"nomad", "autopilot", "healthy"}, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveFailedServer will be called by autopilot to notify Nomad to remove the
|
||||
// server in a failed state. This method is required to implement the
|
||||
// ApplicationIntegration interface. (Note this is expected to return
|
||||
// immediately so we'll spawn a goroutine for it.)
|
||||
func (d *AutopilotDelegate) RemoveFailedServer(failedSrv *autopilot.Server) {
|
||||
go func() {
|
||||
err := d.server.RemoveFailedNode(failedSrv.Name)
|
||||
if err != nil {
|
||||
d.server.logger.Error("could not remove failed server",
|
||||
"server", string(failedSrv.ID),
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// MinRaftProtocol returns the lowest supported Raft protocol among alive
|
||||
// servers
|
||||
func (s *Server) MinRaftProtocol() (int, error) {
|
||||
return minRaftProtocol(s.peersCache.RegionPeers(s.Region()))
|
||||
}
|
||||
|
||||
// GetClusterHealth is used to get the current health of the servers, as known
|
||||
// by the leader.
|
||||
func (s *Server) GetClusterHealth() *structs.OperatorHealthReply {
|
||||
|
||||
state := s.autopilot.GetState()
|
||||
if state == nil {
|
||||
// this behavior seems odd but its functionally equivalent to 1.8.5 where if
|
||||
// autopilot didn't have a health reply yet it would just return no error
|
||||
return nil
|
||||
}
|
||||
|
||||
health := &structs.OperatorHealthReply{
|
||||
Healthy: state.Healthy,
|
||||
FailureTolerance: state.FailureTolerance,
|
||||
Leader: string(state.Leader),
|
||||
Voters: stringIDs(state.Voters),
|
||||
Servers: make([]structs.ServerHealth, 0, len(state.Servers)),
|
||||
}
|
||||
|
||||
for _, srv := range state.Servers {
|
||||
srvHealth := autopilotToServerHealth(srv)
|
||||
|
||||
health.Servers = append(health.Servers, srvHealth)
|
||||
}
|
||||
err := s.autopilotStateExt(state, health)
|
||||
if err != nil {
|
||||
s.logger.Error("Error parsing autopilot state", "error", err)
|
||||
}
|
||||
|
||||
return health
|
||||
}
|
||||
|
||||
// -------------------
|
||||
// helper functions
|
||||
|
||||
func autopilotToServerHealth(srv *autopilot.ServerState) structs.ServerHealth {
|
||||
srvHealth := structs.ServerHealth{
|
||||
ID: string(srv.Server.ID),
|
||||
Name: srv.Server.Name,
|
||||
Address: string(srv.Server.Address),
|
||||
Version: srv.Server.Version,
|
||||
Leader: srv.State == autopilot.RaftLeader,
|
||||
Voter: srv.State == autopilot.RaftLeader || srv.State == autopilot.RaftVoter,
|
||||
LastContact: srv.Stats.LastContact,
|
||||
LastTerm: srv.Stats.LastTerm,
|
||||
LastIndex: srv.Stats.LastIndex,
|
||||
Healthy: srv.Health.Healthy,
|
||||
StableSince: srv.Health.StableSince,
|
||||
}
|
||||
|
||||
switch srv.Server.NodeStatus {
|
||||
case autopilot.NodeAlive:
|
||||
srvHealth.SerfStatus = serf.StatusAlive
|
||||
case autopilot.NodeLeft:
|
||||
srvHealth.SerfStatus = serf.StatusLeft
|
||||
case autopilot.NodeFailed:
|
||||
srvHealth.SerfStatus = serf.StatusFailed
|
||||
default:
|
||||
srvHealth.SerfStatus = serf.StatusNone
|
||||
}
|
||||
|
||||
return srvHealth
|
||||
}
|
||||
|
||||
func stringIDs(ids []raft.ServerID) []string {
|
||||
return helper.ConvertSlice(ids, func(id raft.ServerID) string { return string(id) })
|
||||
}
|
||||
|
||||
func minRaftProtocol(members []*peers.Parts) (int, error) {
|
||||
minVersion := -1
|
||||
for _, m := range members {
|
||||
if m.Status != serf.StatusAlive {
|
||||
continue
|
||||
}
|
||||
|
||||
if minVersion == -1 || m.RaftVersion < minVersion {
|
||||
minVersion = m.RaftVersion
|
||||
}
|
||||
}
|
||||
|
||||
if minVersion == -1 {
|
||||
return minVersion, fmt.Errorf("No servers found")
|
||||
}
|
||||
|
||||
return minVersion, nil
|
||||
}
|
||||
|
||||
func (s *Server) autopilotServers() map[raft.ServerID]*autopilot.Server {
|
||||
|
||||
// Get a list of the regional peers for our local region. We cannot use the
|
||||
// LocalPeers function, as we need peers in all states.
|
||||
localPeers := s.peersCache.RegionPeers(s.Region())
|
||||
|
||||
servers := make(map[raft.ServerID]*autopilot.Server, len(localPeers))
|
||||
|
||||
for _, srv := range localPeers {
|
||||
autopilotServer := s.autopilotServerFromMetadata(srv)
|
||||
servers[autopilotServer.ID] = autopilotServer
|
||||
}
|
||||
|
||||
return servers
|
||||
}
|
||||
|
||||
func (s *Server) autopilotServerFromMetadata(srv *peers.Parts) *autopilot.Server {
|
||||
server := &autopilot.Server{
|
||||
Name: srv.Name,
|
||||
ID: raft.ServerID(srv.ID),
|
||||
Address: raft.ServerAddress(srv.Addr.String()),
|
||||
Version: srv.Build.String(),
|
||||
RaftVersion: srv.RaftVersion,
|
||||
Ext: s.autopilotServerExt(srv),
|
||||
Meta: srv.Tags,
|
||||
}
|
||||
|
||||
switch srv.Status {
|
||||
case serf.StatusLeft:
|
||||
server.NodeStatus = autopilot.NodeLeft
|
||||
case serf.StatusAlive, serf.StatusLeaving:
|
||||
// we want to treat leaving as alive to prevent autopilot from
|
||||
// prematurely removing the node.
|
||||
server.NodeStatus = autopilot.NodeAlive
|
||||
case serf.StatusFailed:
|
||||
server.NodeStatus = autopilot.NodeFailed
|
||||
default:
|
||||
server.NodeStatus = autopilot.NodeUnknown
|
||||
}
|
||||
|
||||
return server
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
//go:build !ent
|
||||
// +build !ent
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
autopilot "github.com/hashicorp/raft-autopilot"
|
||||
|
||||
"github.com/hashicorp/nomad/nomad/peers"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
func (s *Server) autopilotPromoter() autopilot.Promoter {
|
||||
return autopilot.DefaultPromoter()
|
||||
}
|
||||
|
||||
// autopilotServerExt returns the autopilot-enterprise.Server extensions needed
|
||||
// for ENT feature support, but this is the empty OSS implementation.
|
||||
func (s *Server) autopilotServerExt(_ *peers.Parts) interface{} {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) autopilotStateExt(_ *autopilot.State, _ *structs.OperatorHealthReply) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// autopilotConfigExt returns the autopilot-enterprise.Config extensions needed
|
||||
// for ENT feature support, but this is the empty OSS implementation.
|
||||
func autopilotConfigExt(_ *structs.AutopilotConfig) interface{} {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/raft"
|
||||
autopilot "github.com/hashicorp/raft-autopilot"
|
||||
"github.com/hashicorp/serf/serf"
|
||||
"github.com/shoenig/test/must"
|
||||
|
||||
"github.com/hashicorp/nomad/ci"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
"github.com/hashicorp/nomad/testutil"
|
||||
)
|
||||
|
||||
var _ autopilot.ApplicationIntegration = (*AutopilotDelegate)(nil)
|
||||
|
||||
// wantPeers determines whether the server has the given
|
||||
// number of voting raft peers.
|
||||
func wantPeers(s *Server, peers int) error {
|
||||
future := s.raft.GetConfiguration()
|
||||
if err := future.Error(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var n int
|
||||
for _, server := range future.Configuration().Servers {
|
||||
if server.Suffrage == raft.Voter {
|
||||
n++
|
||||
}
|
||||
}
|
||||
|
||||
if got, want := n, peers; got != want {
|
||||
return fmt.Errorf("server %v: got %d peers want %d\n\tservers: %#+v", s.config.NodeName, got, want, future.Configuration().Servers)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAutopilot_CleanupDeadServer(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
conf := func(c *Config) {
|
||||
c.NumSchedulers = 0 // reduces test log noise
|
||||
c.BootstrapExpect = 3
|
||||
c.RaftConfig.ProtocolVersion = raft.ProtocolVersion(3)
|
||||
}
|
||||
|
||||
s1, cleanupS1 := TestServer(t, conf)
|
||||
defer cleanupS1()
|
||||
|
||||
s2, cleanupS2 := TestServer(t, conf)
|
||||
defer cleanupS2()
|
||||
|
||||
s3, cleanupS3 := TestServer(t, conf)
|
||||
defer cleanupS3()
|
||||
|
||||
servers := []*Server{s1, s2, s3}
|
||||
TestJoin(t, servers...)
|
||||
|
||||
t.Logf("waiting for initial stable cluster")
|
||||
waitForStableLeadership(t, servers)
|
||||
|
||||
s4, cleanupS4 := TestServer(t, conf)
|
||||
defer cleanupS4()
|
||||
|
||||
// Kill a non-leader server
|
||||
killedIdx := 0
|
||||
for i, s := range servers {
|
||||
if !s.IsLeader() {
|
||||
killedIdx = i
|
||||
t.Logf("killing a server (index %d)", killedIdx)
|
||||
s.Shutdown()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("waiting for server loss to be detected")
|
||||
testutil.WaitForResultUntil(10*time.Second, func() (bool, error) {
|
||||
for i, s := range servers {
|
||||
alive := 0
|
||||
if i == killedIdx {
|
||||
// Skip shutdown server
|
||||
continue
|
||||
}
|
||||
for _, m := range s.Members() {
|
||||
if m.Status == serf.StatusAlive {
|
||||
alive++
|
||||
}
|
||||
}
|
||||
|
||||
if alive != 2 {
|
||||
return false, fmt.Errorf("expected 2 alive servers but found %v", alive)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}, func(err error) { must.NoError(t, err) })
|
||||
|
||||
// Join the new server
|
||||
servers[killedIdx] = s4
|
||||
t.Logf("adding server s4")
|
||||
TestJoin(t, servers...)
|
||||
|
||||
t.Logf("waiting for dead server to be removed")
|
||||
waitForStableLeadership(t, servers)
|
||||
}
|
||||
|
||||
func TestAutopilot_CleanupDeadServerPeriodic(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
conf := func(c *Config) {
|
||||
c.NumSchedulers = 0 // reduces test log noise
|
||||
c.BootstrapExpect = 5
|
||||
}
|
||||
|
||||
s1, cleanupS1 := TestServer(t, conf)
|
||||
defer cleanupS1()
|
||||
|
||||
s2, cleanupS2 := TestServer(t, conf)
|
||||
defer cleanupS2()
|
||||
|
||||
s3, cleanupS3 := TestServer(t, conf)
|
||||
defer cleanupS3()
|
||||
|
||||
s4, cleanupS4 := TestServer(t, conf)
|
||||
defer cleanupS4()
|
||||
|
||||
s5, cleanupS5 := TestServer(t, conf)
|
||||
defer cleanupS5()
|
||||
|
||||
servers := []*Server{s1, s2, s3, s4, s5}
|
||||
TestJoin(t, servers...)
|
||||
|
||||
t.Logf("waiting for initial stable cluster")
|
||||
waitForStableLeadership(t, servers)
|
||||
|
||||
t.Logf("killing a non-leader server")
|
||||
if leader := waitForStableLeadership(t, servers); leader == s4 {
|
||||
s1, s4 = s4, s1
|
||||
}
|
||||
s4.Shutdown()
|
||||
|
||||
t.Logf("waiting for dead peer to be removed")
|
||||
servers = []*Server{s1, s2, s3, s5}
|
||||
waitForStableLeadership(t, servers)
|
||||
}
|
||||
|
||||
func TestAutopilot_RollingUpdate(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
conf := func(c *Config) {
|
||||
c.NumSchedulers = 0 // reduces test log noise
|
||||
c.BootstrapExpect = 3
|
||||
c.RaftConfig.ProtocolVersion = 3
|
||||
}
|
||||
|
||||
s1, cleanupS1 := TestServer(t, conf)
|
||||
defer cleanupS1()
|
||||
|
||||
s2, cleanupS2 := TestServer(t, conf)
|
||||
defer cleanupS2()
|
||||
|
||||
s3, cleanupS3 := TestServer(t, conf)
|
||||
defer cleanupS3()
|
||||
|
||||
servers := []*Server{s1, s2, s3}
|
||||
TestJoin(t, s1, s2, s3)
|
||||
|
||||
t.Logf("waiting for initial stable cluster")
|
||||
waitForStableLeadership(t, servers)
|
||||
|
||||
// Add one more server like we are doing a rolling update.
|
||||
t.Logf("adding server s4")
|
||||
s4, cleanupS4 := TestServer(t, conf)
|
||||
defer cleanupS4()
|
||||
TestJoin(t, s1, s4)
|
||||
|
||||
// Wait for s4 to stabilize and get promoted to a voter
|
||||
t.Logf("waiting for s4 to stabilize and be promoted")
|
||||
servers = append(servers, s4)
|
||||
waitForStableLeadership(t, servers)
|
||||
|
||||
// Now kill one of the "old" nodes like we are doing a rolling update.
|
||||
t.Logf("shutting down server s3")
|
||||
s3.Shutdown()
|
||||
|
||||
// Wait for s3 to be removed and the cluster to stablize.
|
||||
t.Logf("waiting for cluster to stabilize")
|
||||
servers = []*Server{s1, s2, s4}
|
||||
waitForStableLeadership(t, servers)
|
||||
}
|
||||
|
||||
func TestAutopilot_MultiRegion(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
conf := func(c *Config) {
|
||||
c.NumSchedulers = 0 // reduces test log noise
|
||||
c.BootstrapExpect = 3
|
||||
}
|
||||
s1, cleanupS1 := TestServer(t, conf)
|
||||
defer cleanupS1()
|
||||
|
||||
s2, cleanupS2 := TestServer(t, conf)
|
||||
defer cleanupS2()
|
||||
|
||||
s3, cleanupS3 := TestServer(t, conf)
|
||||
defer cleanupS3()
|
||||
|
||||
// federated regions should not be considered raft peers or show up in the
|
||||
// known servers list
|
||||
s4, cleanupS4 := TestServer(t, func(c *Config) {
|
||||
c.BootstrapExpect = 0
|
||||
c.Region = "other"
|
||||
})
|
||||
defer cleanupS4()
|
||||
|
||||
servers := []*Server{s1, s2, s3}
|
||||
TestJoin(t, s1, s2, s3, s4)
|
||||
|
||||
t.Logf("waiting for initial stable cluster")
|
||||
waitForStableLeadership(t, servers)
|
||||
|
||||
apDelegate := &AutopilotDelegate{s3}
|
||||
known := apDelegate.KnownServers()
|
||||
must.Eq(t, 3, len(known))
|
||||
|
||||
}
|
||||
|
||||
func TestAutopilot_CleanupStaleRaftServer(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
conf := func(c *Config) {
|
||||
c.NumSchedulers = 0 // reduces test log noise
|
||||
c.BootstrapExpect = 3
|
||||
}
|
||||
s1, cleanupS1 := TestServer(t, conf)
|
||||
defer cleanupS1()
|
||||
|
||||
s2, cleanupS2 := TestServer(t, conf)
|
||||
defer cleanupS2()
|
||||
|
||||
s3, cleanupS3 := TestServer(t, conf)
|
||||
defer cleanupS3()
|
||||
|
||||
s4, cleanupS4 := TestServer(t, func(c *Config) {
|
||||
c.BootstrapExpect = 0
|
||||
})
|
||||
defer cleanupS4()
|
||||
|
||||
servers := []*Server{s1, s2, s3}
|
||||
TestJoin(t, s1, s2, s3)
|
||||
|
||||
t.Logf("waiting for initial stable cluster")
|
||||
leader := waitForStableLeadership(t, servers)
|
||||
|
||||
t.Logf("adding server s4 to peers directly")
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", s4.config.RPCAddr.Port)
|
||||
future := leader.raft.AddVoter(raft.ServerID(s4.config.NodeID), raft.ServerAddress(addr), 0, 0)
|
||||
if err := future.Error(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Logf("waiting for 4th server to be removed")
|
||||
waitForStableLeadership(t, servers)
|
||||
}
|
||||
|
||||
func TestAutopilot_PromoteNonVoter(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
s1, cleanupS1 := TestServer(t, func(c *Config) {
|
||||
c.NumSchedulers = 0 // reduces test log noise
|
||||
c.RaftConfig.ProtocolVersion = 3
|
||||
})
|
||||
defer cleanupS1()
|
||||
codec := rpcClient(t, s1)
|
||||
defer codec.Close()
|
||||
testutil.WaitForLeader(t, s1.RPC)
|
||||
|
||||
s2, cleanupS2 := TestServer(t, func(c *Config) {
|
||||
c.NumSchedulers = 0 // reduces test log noise
|
||||
c.BootstrapExpect = 0
|
||||
c.RaftConfig.ProtocolVersion = 3
|
||||
})
|
||||
defer cleanupS2()
|
||||
TestJoin(t, s1, s2)
|
||||
|
||||
// Note: we can't reliably detect that the server is initially a non-voter,
|
||||
// because it can transition too quickly for the test setup to detect,
|
||||
// especially in low-resource environments like CI. We'll assume that
|
||||
// happens correctly here and only test that it transitions to become a
|
||||
// voter.
|
||||
testutil.WaitForResultUntil(10*time.Second, func() (bool, error) {
|
||||
future := s1.raft.GetConfiguration()
|
||||
if err := future.Error(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
servers := future.Configuration().Servers
|
||||
if len(servers) != 2 {
|
||||
return false, fmt.Errorf("expected 2 servers, got: %v", servers)
|
||||
}
|
||||
if servers[1].Suffrage != raft.Voter {
|
||||
return false, fmt.Errorf("expected server to be voter: %v", servers)
|
||||
}
|
||||
return true, nil
|
||||
}, func(err error) { must.NoError(t, err) })
|
||||
}
|
||||
|
||||
func TestAutopilot_ReturnAutopilotHealth(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
s1, cleanupS1 := TestServer(t, func(c *Config) {
|
||||
c.BootstrapExpect = 2
|
||||
c.RaftConfig.ProtocolVersion = 3
|
||||
c.AutopilotConfig.EnableCustomUpgrades = true
|
||||
c.UpgradeVersion = "0.0.1"
|
||||
c.NumSchedulers = 0 // reduce log noise
|
||||
})
|
||||
defer cleanupS1()
|
||||
|
||||
s2, cleanupS2 := TestServer(t, func(c *Config) {
|
||||
c.BootstrapExpect = 2
|
||||
c.RaftConfig.ProtocolVersion = 3
|
||||
c.AutopilotConfig.EnableCustomUpgrades = true
|
||||
c.UpgradeVersion = "0.0.1"
|
||||
c.NumSchedulers = 0 // reduce log noise
|
||||
})
|
||||
defer cleanupS2()
|
||||
|
||||
TestJoin(t, s1, s2)
|
||||
servers := []*Server{s1, s2}
|
||||
leader := waitForStableLeadership(t, servers)
|
||||
|
||||
get := &structs.GenericRequest{
|
||||
QueryOptions: structs.QueryOptions{
|
||||
Region: "global",
|
||||
},
|
||||
}
|
||||
reply := &structs.OperatorHealthReply{}
|
||||
err := s1.RPC("Operator.ServerHealth", get, reply)
|
||||
must.NoError(t, err)
|
||||
|
||||
must.Eq(t, reply.Healthy, true)
|
||||
_, leaderID := leader.raft.LeaderWithID()
|
||||
must.Eq(t, reply.Leader, string(leaderID))
|
||||
}
|
||||
@@ -0,0 +1,874 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-hclog"
|
||||
metrics "github.com/hashicorp/go-metrics/compat"
|
||||
"github.com/hashicorp/nomad/helper"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
const (
|
||||
// unblockBuffer is the buffer size for the unblock channel. The buffer
|
||||
// should be large to ensure that the FSM doesn't block when calling Unblock
|
||||
// as this would apply back-pressure on Raft.
|
||||
unblockBuffer = 8096
|
||||
|
||||
// pruneInterval is the interval at which we prune objects from the
|
||||
// BlockedEvals tracker
|
||||
pruneInterval = 5 * time.Minute
|
||||
|
||||
// pruneThreshold is the threshold after which objects will be pruned.
|
||||
pruneThreshold = 15 * time.Minute
|
||||
)
|
||||
|
||||
// BlockedEvals is used to track evaluations that shouldn't be queued until a
|
||||
// certain class of nodes becomes available. An evaluation is put into the
|
||||
// blocked state when it is run through the scheduler and produced failed
|
||||
// allocations. It is unblocked when the capacity of a node that could run the
|
||||
// failed allocation becomes available.
|
||||
type BlockedEvals struct {
|
||||
// logger is the logger to use by the blocked eval tracker.
|
||||
logger hclog.Logger
|
||||
|
||||
evalBroker *EvalBroker
|
||||
enabled bool
|
||||
|
||||
// the lock on the enabled flag and the control channels like the stopCh and
|
||||
// duplicateCh. This is read by most top-level methods including those
|
||||
// called from the FSM, but write-locked only in Flush called during
|
||||
// leadership changes
|
||||
flushLock sync.RWMutex
|
||||
|
||||
stats *BlockedStats
|
||||
|
||||
// l is the lock on the data we're tracking
|
||||
l sync.RWMutex
|
||||
|
||||
// captured is the set of evaluations that are captured by computed node
|
||||
// classes.
|
||||
captured map[string]wrappedEval
|
||||
|
||||
// escaped is the set of evaluations that have escaped computed node
|
||||
// classes.
|
||||
escaped map[string]wrappedEval
|
||||
|
||||
// system is the set of system evaluations that failed to start on nodes because of
|
||||
// resource constraints.
|
||||
system *systemEvals
|
||||
|
||||
// capacityChangeCh is used to buffer unblocking of evaluations.
|
||||
capacityChangeCh chan *capacityUpdate
|
||||
|
||||
// jobs is the map of blocked job and is used to ensure that only one
|
||||
// blocked eval exists for each job. The value is the blocked evaluation ID.
|
||||
jobs map[structs.NamespacedID]string
|
||||
|
||||
// unblockIndexes maps computed node classes or quota name to the index and
|
||||
// time at which they were unblocked. This is used to check if an
|
||||
// evaluation could have been unblocked between the time they were in the
|
||||
// scheduler and the time they are being blocked.
|
||||
unblockIndexes map[string]unblockEvent
|
||||
|
||||
// unblockIndexesLock protects unblockIndexes, which has its own lock
|
||||
// because we want to take a write lock in the Unblock* methods called from
|
||||
// the FSM
|
||||
unblockIndexesLock sync.RWMutex
|
||||
|
||||
// duplicates is the set of evaluations for jobs that had pre-existing
|
||||
// blocked evaluations. These should be marked as cancelled since only one
|
||||
// blocked eval is needed per job.
|
||||
duplicates []*structs.Evaluation
|
||||
|
||||
// duplicateCh is used to signal that a duplicate eval was added to the
|
||||
// duplicate set. It can be used to unblock waiting callers looking for
|
||||
// duplicates.
|
||||
duplicateCh chan struct{}
|
||||
|
||||
// stopCh is used to stop any created goroutines.
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
// unblockEvent keeps a record of the index and time of the unblock
|
||||
type unblockEvent struct {
|
||||
index uint64
|
||||
timestamp time.Time
|
||||
}
|
||||
|
||||
// capacityUpdate stores unblock data.
|
||||
type capacityUpdate struct {
|
||||
computedClass string
|
||||
quotaChange string
|
||||
nodeID string
|
||||
|
||||
blockedEval *structs.Evaluation
|
||||
blockToken string
|
||||
untrackJobID structs.NamespacedID
|
||||
|
||||
// future will be closed when the operation is done, which allows callers to
|
||||
// block until then if they need to (this is mostly used for tests). The
|
||||
// callers in the FSM should never wait on this future
|
||||
future chan struct{}
|
||||
}
|
||||
|
||||
// wrappedEval captures both the evaluation and the optional token
|
||||
type wrappedEval struct {
|
||||
eval *structs.Evaluation
|
||||
token string
|
||||
}
|
||||
|
||||
// NewBlockedEvals creates a new blocked eval tracker that will enqueue
|
||||
// unblocked evals into the passed broker.
|
||||
func NewBlockedEvals(evalBroker *EvalBroker, logger hclog.Logger) *BlockedEvals {
|
||||
return &BlockedEvals{
|
||||
logger: logger.Named("blocked_evals"),
|
||||
evalBroker: evalBroker,
|
||||
captured: make(map[string]wrappedEval),
|
||||
escaped: make(map[string]wrappedEval),
|
||||
system: newSystemEvals(),
|
||||
jobs: make(map[structs.NamespacedID]string),
|
||||
unblockIndexes: make(map[string]unblockEvent),
|
||||
capacityChangeCh: make(chan *capacityUpdate, unblockBuffer),
|
||||
duplicateCh: make(chan struct{}, 1),
|
||||
stopCh: make(chan struct{}),
|
||||
stats: NewBlockedStats(),
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled is used to check if the broker is enabled.
|
||||
func (b *BlockedEvals) Enabled() bool {
|
||||
b.flushLock.RLock()
|
||||
defer b.flushLock.RUnlock()
|
||||
return b.enabled
|
||||
}
|
||||
|
||||
// SetEnabled is used to control if the blocked eval tracker is enabled. The
|
||||
// tracker should only be enabled on the active leader.
|
||||
func (b *BlockedEvals) SetEnabled(enabled bool) {
|
||||
b.flushLock.Lock()
|
||||
b.l.Lock()
|
||||
if b.enabled == enabled {
|
||||
// No-op
|
||||
b.l.Unlock()
|
||||
b.flushLock.Unlock()
|
||||
return
|
||||
} else if enabled {
|
||||
go b.watchCapacity(b.stopCh, b.capacityChangeCh)
|
||||
go b.prune(b.stopCh)
|
||||
} else {
|
||||
close(b.stopCh)
|
||||
}
|
||||
b.enabled = enabled
|
||||
b.l.Unlock()
|
||||
b.flushLock.Unlock()
|
||||
if !enabled {
|
||||
b.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// Block tracks the passed evaluation and enqueues it into the eval broker when
|
||||
// a suitable node calls unblock.
|
||||
func (b *BlockedEvals) Block(eval *structs.Evaluation) chan struct{} {
|
||||
fut := make(chan struct{})
|
||||
|
||||
b.flushLock.RLock()
|
||||
defer b.flushLock.RUnlock()
|
||||
if !b.enabled {
|
||||
close(fut)
|
||||
return fut
|
||||
}
|
||||
|
||||
// Capture chan in flushlock as Flush overwrites it
|
||||
ch := b.capacityChangeCh
|
||||
done := b.stopCh
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case ch <- &capacityUpdate{blockedEval: eval, future: fut}:
|
||||
}
|
||||
|
||||
return fut
|
||||
}
|
||||
|
||||
// Reblock tracks the passed evaluation and enqueues it into the eval broker when
|
||||
// a suitable node calls unblock. Reblock should be used over Block when the
|
||||
// blocking is occurring by an outstanding evaluation. The token is the
|
||||
// evaluation's token.
|
||||
func (b *BlockedEvals) Reblock(eval *structs.Evaluation, token string) chan struct{} {
|
||||
fut := make(chan struct{})
|
||||
|
||||
b.flushLock.RLock()
|
||||
defer b.flushLock.RUnlock()
|
||||
if !b.enabled {
|
||||
close(fut)
|
||||
return fut
|
||||
}
|
||||
|
||||
// Capture chan in flushlock as Flush overwrites it
|
||||
ch := b.capacityChangeCh
|
||||
done := b.stopCh
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case ch <- &capacityUpdate{blockedEval: eval, blockToken: token, future: fut}:
|
||||
}
|
||||
|
||||
return fut
|
||||
}
|
||||
|
||||
// processBlock is the implementation of blocking an evaluation. It supports
|
||||
// taking an optional evaluation token to use when reblocking an evaluation that
|
||||
// may be outstanding.
|
||||
func (b *BlockedEvals) processBlock(eval *structs.Evaluation, token string) {
|
||||
b.flushLock.RLock()
|
||||
defer b.flushLock.RUnlock()
|
||||
if !b.enabled {
|
||||
return
|
||||
}
|
||||
|
||||
b.l.Lock()
|
||||
defer b.l.Unlock()
|
||||
|
||||
// Handle the new evaluation being for a job we are already tracking.
|
||||
if b.processBlockJobDuplicate(eval) {
|
||||
// If process block job duplicate returns true, the new evaluation has
|
||||
// been marked as a duplicate and we have nothing to do, so return
|
||||
// early.
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the eval missed an unblock while it was in the scheduler at an
|
||||
// older index. The scheduler could have been invoked with a snapshot of
|
||||
// state that was prior to additional capacity being added or allocations
|
||||
// becoming terminal.
|
||||
if b.missedUnblock(eval) {
|
||||
// Just re-enqueue the eval immediately. We pass the token so that the
|
||||
// eval_broker can properly handle the case in which the evaluation is
|
||||
// still outstanding.
|
||||
b.evalBroker.EnqueueAll(map[*structs.Evaluation]string{eval: token})
|
||||
return
|
||||
}
|
||||
|
||||
// Mark the job as tracked.
|
||||
b.jobs[structs.NewNamespacedID(eval.JobID, eval.Namespace)] = eval.ID
|
||||
b.stats.Block(eval)
|
||||
|
||||
// Wrap the evaluation, capturing its token.
|
||||
wrapped := wrappedEval{
|
||||
eval: eval,
|
||||
token: token,
|
||||
}
|
||||
|
||||
// If the eval has escaped, meaning computed node classes could not capture
|
||||
// the constraints of the job, we store the eval separately as we have to
|
||||
// unblock it whenever node capacity changes. This is because we don't know
|
||||
// what node class is feasible for the jobs constraints.
|
||||
if eval.EscapedComputedClass {
|
||||
b.escaped[eval.ID] = wrapped
|
||||
return
|
||||
}
|
||||
|
||||
// System evals are indexed by node and re-processed on utilization changes in
|
||||
// existing nodes
|
||||
if eval.Type == structs.JobTypeSystem {
|
||||
b.system.Add(eval, token)
|
||||
}
|
||||
|
||||
// Add the eval to the set of blocked evals whose jobs constraints are
|
||||
// captured by computed node class.
|
||||
b.captured[eval.ID] = wrapped
|
||||
}
|
||||
|
||||
// processBlockJobDuplicate handles the case where the new eval is for a job
|
||||
// that we are already tracking. If the eval is a duplicate, we add the older
|
||||
// evaluation by Raft index to the list of duplicates such that it can be
|
||||
// cancelled. We only ever want one blocked evaluation per job, otherwise we
|
||||
// would create unnecessary work for the scheduler as multiple evals for the
|
||||
// same job would be run, all producing the same outcome. It is critical to
|
||||
// prefer the newer evaluation, since it will contain the most up to date set of
|
||||
// class eligibility. The return value is set to true, if the passed evaluation
|
||||
// is cancelled. This should be called with the lock held.
|
||||
func (b *BlockedEvals) processBlockJobDuplicate(eval *structs.Evaluation) (newCancelled bool) {
|
||||
existingID, hasExisting := b.jobs[structs.NewNamespacedID(eval.JobID, eval.Namespace)]
|
||||
if !hasExisting {
|
||||
return
|
||||
}
|
||||
|
||||
var dup *structs.Evaluation
|
||||
existingW, ok := b.captured[existingID]
|
||||
if ok {
|
||||
if latestEvalIndex(existingW.eval) <= latestEvalIndex(eval) {
|
||||
delete(b.captured, existingID)
|
||||
dup = existingW.eval
|
||||
b.stats.Unblock(dup, false)
|
||||
} else {
|
||||
dup = eval
|
||||
newCancelled = true
|
||||
}
|
||||
} else {
|
||||
existingW, ok = b.escaped[existingID]
|
||||
if !ok {
|
||||
// This is a programming error
|
||||
b.logger.Error("existing blocked evaluation is neither tracked as captured or escaped", "existing_id", existingID)
|
||||
delete(b.jobs, structs.NewNamespacedID(eval.JobID, eval.Namespace))
|
||||
return
|
||||
}
|
||||
|
||||
if latestEvalIndex(existingW.eval) <= latestEvalIndex(eval) {
|
||||
delete(b.escaped, existingID)
|
||||
b.stats.decrementEscaped()
|
||||
dup = existingW.eval
|
||||
} else {
|
||||
dup = eval
|
||||
newCancelled = true
|
||||
}
|
||||
}
|
||||
|
||||
b.duplicates = append(b.duplicates, dup)
|
||||
|
||||
// Unblock any waiter.
|
||||
select {
|
||||
case b.duplicateCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// latestEvalIndex returns the max of the evaluations create and snapshot index
|
||||
func latestEvalIndex(eval *structs.Evaluation) uint64 {
|
||||
if eval == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return max(eval.CreateIndex, eval.SnapshotIndex)
|
||||
}
|
||||
|
||||
// missedUnblock returns whether an evaluation missed an unblock while it was in
|
||||
// the scheduler. Since the scheduler can operate at an index in the past, the
|
||||
// evaluation may have been processed missing data that would allow it to
|
||||
// complete. This method returns if that is the case and should be called with
|
||||
// the lock held.
|
||||
func (b *BlockedEvals) missedUnblock(eval *structs.Evaluation) bool {
|
||||
b.unblockIndexesLock.RLock()
|
||||
defer b.unblockIndexesLock.RUnlock()
|
||||
|
||||
var max uint64 = 0
|
||||
|
||||
for id, u := range b.unblockIndexes {
|
||||
// Calculate the max unblock index
|
||||
if max < u.index {
|
||||
max = u.index
|
||||
}
|
||||
|
||||
// The evaluation is blocked because it has hit a quota limit not class
|
||||
// eligibility
|
||||
if eval.QuotaLimitReached != "" {
|
||||
if eval.QuotaLimitReached != id {
|
||||
// Not a match
|
||||
continue
|
||||
} else if eval.SnapshotIndex < u.index {
|
||||
// The evaluation was processed before the quota specification was
|
||||
// updated, so unblock the evaluation.
|
||||
return true
|
||||
}
|
||||
|
||||
// The evaluation was processed having seen all changes to the quota
|
||||
return false
|
||||
}
|
||||
|
||||
elig, ok := eval.ClassEligibility[id]
|
||||
if !ok && eval.SnapshotIndex < u.index {
|
||||
// The evaluation was processed and did not encounter this class
|
||||
// because it was added after it was processed. Thus for correctness
|
||||
// we need to unblock it.
|
||||
return true
|
||||
}
|
||||
|
||||
// The evaluation could use the computed node class and the eval was
|
||||
// processed before the last unblock.
|
||||
if elig && eval.SnapshotIndex < u.index {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// If the evaluation has escaped, and the map contains an index older than
|
||||
// the evaluations, it should be unblocked.
|
||||
if eval.EscapedComputedClass && eval.SnapshotIndex < max {
|
||||
return true
|
||||
}
|
||||
|
||||
// The evaluation is ahead of all recent unblocks.
|
||||
return false
|
||||
}
|
||||
|
||||
// Untrack causes any blocked evaluation for the passed job to be no longer
|
||||
// tracked. Untrack is called when there is a successful evaluation for the job
|
||||
// and a blocked evaluation is no longer needed.
|
||||
func (b *BlockedEvals) Untrack(jobID, namespace string) chan struct{} {
|
||||
fut := make(chan struct{})
|
||||
b.flushLock.RLock()
|
||||
defer b.flushLock.RUnlock()
|
||||
if !b.enabled {
|
||||
close(fut)
|
||||
return fut
|
||||
}
|
||||
|
||||
// Capture chan in flushlock as Flush overwrites it
|
||||
ch := b.capacityChangeCh
|
||||
done := b.stopCh
|
||||
|
||||
nsID := structs.NewNamespacedID(jobID, namespace)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case ch <- &capacityUpdate{untrackJobID: nsID, future: fut}:
|
||||
}
|
||||
|
||||
return fut
|
||||
}
|
||||
|
||||
func (b *BlockedEvals) untrackImpl(nsID structs.NamespacedID) {
|
||||
b.flushLock.RLock()
|
||||
defer b.flushLock.RUnlock()
|
||||
if !b.enabled {
|
||||
return
|
||||
}
|
||||
|
||||
b.l.Lock()
|
||||
defer b.l.Unlock()
|
||||
|
||||
if evals, ok := b.system.JobEvals(nsID); ok {
|
||||
for _, e := range evals {
|
||||
b.system.Remove(e)
|
||||
b.stats.Unblock(e, false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Get the evaluation ID to cancel
|
||||
evalID, ok := b.jobs[nsID]
|
||||
if !ok {
|
||||
// No blocked evaluation so exit
|
||||
return
|
||||
}
|
||||
|
||||
// Attempt to delete the evaluation
|
||||
if w, ok := b.captured[evalID]; ok {
|
||||
delete(b.jobs, nsID)
|
||||
delete(b.captured, evalID)
|
||||
b.stats.Unblock(w.eval, false)
|
||||
}
|
||||
|
||||
if w, ok := b.escaped[evalID]; ok {
|
||||
delete(b.jobs, nsID)
|
||||
delete(b.escaped, evalID)
|
||||
b.stats.Unblock(w.eval, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Unblock causes any evaluation that could potentially make progress on a
|
||||
// capacity change on the passed computed node class to be enqueued into the
|
||||
// eval broker.
|
||||
func (b *BlockedEvals) Unblock(computedClass string, index uint64) chan struct{} {
|
||||
fut := make(chan struct{})
|
||||
|
||||
b.flushLock.RLock()
|
||||
defer b.flushLock.RUnlock()
|
||||
if !b.enabled {
|
||||
close(fut)
|
||||
return fut
|
||||
}
|
||||
// Capture chan in flushlock as Flush overwrites it
|
||||
ch := b.capacityChangeCh
|
||||
done := b.stopCh
|
||||
|
||||
// Store the index in which the unblock happened. We use this on subsequent
|
||||
// block calls in case the evaluation was in the scheduler when a trigger
|
||||
// occurred.
|
||||
b.unblockIndexesLock.Lock()
|
||||
b.unblockIndexes[computedClass] = unblockEvent{index, time.Now().UTC()}
|
||||
b.unblockIndexesLock.Unlock()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case ch <- &capacityUpdate{computedClass: computedClass, future: fut}:
|
||||
}
|
||||
|
||||
return fut
|
||||
}
|
||||
|
||||
// UnblockQuota causes any evaluation that could potentially make progress on a
|
||||
// capacity change on the passed quota to be enqueued into the eval broker.
|
||||
func (b *BlockedEvals) UnblockQuota(quota string, index uint64) chan struct{} {
|
||||
fut := make(chan struct{})
|
||||
|
||||
// Nothing to do
|
||||
if quota == "" {
|
||||
return fut
|
||||
}
|
||||
|
||||
b.flushLock.RLock()
|
||||
defer b.flushLock.RUnlock()
|
||||
if !b.enabled {
|
||||
close(fut)
|
||||
return fut
|
||||
}
|
||||
// Capture chan in flushlock as Flush overwrites it
|
||||
ch := b.capacityChangeCh
|
||||
done := b.stopCh
|
||||
|
||||
// Store the index in which the unblock happened. We use this on subsequent
|
||||
// block calls in case the evaluation was in the scheduler when a trigger
|
||||
// occurred.
|
||||
b.unblockIndexesLock.Lock()
|
||||
b.unblockIndexes[quota] = unblockEvent{index, time.Now().UTC()}
|
||||
b.unblockIndexesLock.Unlock()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case ch <- &capacityUpdate{quotaChange: quota, future: fut}:
|
||||
}
|
||||
|
||||
return fut
|
||||
}
|
||||
|
||||
// UnblockClassAndQuota causes any evaluation that could potentially make
|
||||
// progress on a capacity change on the passed computed node class or quota to
|
||||
// be enqueued into the eval broker.
|
||||
func (b *BlockedEvals) UnblockClassAndQuota(class, quota string, index uint64) chan struct{} {
|
||||
fut := make(chan struct{})
|
||||
|
||||
b.flushLock.RLock()
|
||||
defer b.flushLock.RUnlock()
|
||||
if !b.enabled {
|
||||
close(fut)
|
||||
return fut
|
||||
}
|
||||
// Capture chan in flushlock as Flush overwrites it
|
||||
ch := b.capacityChangeCh
|
||||
done := b.stopCh
|
||||
|
||||
// Store the index in which the unblock happened. We use this on subsequent
|
||||
// block calls in case the evaluation was in the scheduler when a trigger
|
||||
// occurred.
|
||||
b.unblockIndexesLock.Lock()
|
||||
now := time.Now().UTC()
|
||||
if quota != "" {
|
||||
b.unblockIndexes[quota] = unblockEvent{index, now}
|
||||
}
|
||||
b.unblockIndexes[class] = unblockEvent{index, now}
|
||||
b.unblockIndexesLock.Unlock()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case ch <- &capacityUpdate{computedClass: class, quotaChange: quota, future: fut}:
|
||||
}
|
||||
|
||||
return fut
|
||||
}
|
||||
|
||||
// UnblockNode finds any blocked evalution that's node specific (system jobs)
|
||||
// and enqueues it on the eval broker.
|
||||
func (b *BlockedEvals) UnblockNode(nodeID string) chan struct{} {
|
||||
fut := make(chan struct{})
|
||||
|
||||
b.flushLock.RLock()
|
||||
defer b.flushLock.RUnlock()
|
||||
if !b.enabled {
|
||||
close(fut)
|
||||
return fut
|
||||
}
|
||||
// Capture chan in flushlock as Flush overwrites it
|
||||
ch := b.capacityChangeCh
|
||||
done := b.stopCh
|
||||
|
||||
// Note that unlike other unblock methods, we can't usefully track an
|
||||
// unblockIndex because any blocked eval won't have the node ID attached
|
||||
// when we check the index.
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case ch <- &capacityUpdate{nodeID: nodeID, future: fut}:
|
||||
}
|
||||
|
||||
return fut
|
||||
}
|
||||
|
||||
// watchCapacity is a long lived function that watches for capacity changes in
|
||||
// nodes and unblocks the correct set of evals.
|
||||
func (b *BlockedEvals) watchCapacity(
|
||||
stopCh <-chan struct{},
|
||||
changeCh <-chan *capacityUpdate,
|
||||
) {
|
||||
for {
|
||||
select {
|
||||
case <-stopCh:
|
||||
return
|
||||
case update := <-changeCh:
|
||||
if update.blockedEval != nil {
|
||||
b.processBlock(update.blockedEval, update.blockToken)
|
||||
close(update.future)
|
||||
continue
|
||||
}
|
||||
if update.untrackJobID.ID != "" {
|
||||
b.untrackImpl(update.untrackJobID)
|
||||
close(update.future)
|
||||
continue
|
||||
}
|
||||
|
||||
b.unblock(update.computedClass, update.quotaChange, update.nodeID)
|
||||
close(update.future)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *BlockedEvals) unblock(computedClass, quota, nodeID string) {
|
||||
|
||||
// Protect against the case of a flush.
|
||||
b.flushLock.RLock()
|
||||
defer b.flushLock.RUnlock()
|
||||
if !b.enabled {
|
||||
return
|
||||
}
|
||||
|
||||
b.l.Lock()
|
||||
defer b.l.Unlock()
|
||||
|
||||
// Every eval that has escaped computed node class has to be unblocked
|
||||
// because any node could potentially be feasible.
|
||||
numEscaped := len(b.escaped)
|
||||
unblocked := make(map[*structs.Evaluation]string, max(uint64(numEscaped), 4))
|
||||
|
||||
if numEscaped != 0 && computedClass != "" {
|
||||
for id, wrapped := range b.escaped {
|
||||
unblocked[wrapped.eval] = wrapped.token
|
||||
delete(b.escaped, id)
|
||||
delete(b.jobs, structs.NewNamespacedID(wrapped.eval.JobID, wrapped.eval.Namespace))
|
||||
}
|
||||
}
|
||||
|
||||
if quota == "" && computedClass == "" {
|
||||
goto SKIP_TO_NODE
|
||||
}
|
||||
|
||||
// We unblock any eval that is explicitly eligible for the computed class
|
||||
// and also any eval that is not eligible or uneligible. This signifies that
|
||||
// when the evaluation was originally run through the scheduler, that it
|
||||
// never saw a node with the given computed class and thus needs to be
|
||||
// unblocked for correctness.
|
||||
for id, wrapped := range b.captured {
|
||||
if quota != "" &&
|
||||
wrapped.eval.QuotaLimitReached != "" &&
|
||||
wrapped.eval.QuotaLimitReached != quota {
|
||||
// We are unblocking based on quota and this eval doesn't match
|
||||
continue
|
||||
}
|
||||
if elig, ok := wrapped.eval.ClassEligibility[computedClass]; ok && !elig {
|
||||
// Can skip because the eval has explicitly marked the node class
|
||||
// as ineligible.
|
||||
continue
|
||||
}
|
||||
|
||||
// Unblock the evaluation because it is either for the matching quota,
|
||||
// is eligible based on the computed node class, or never seen the
|
||||
// computed node class.
|
||||
unblocked[wrapped.eval] = wrapped.token
|
||||
delete(b.jobs, structs.NewNamespacedID(wrapped.eval.JobID, wrapped.eval.Namespace))
|
||||
delete(b.captured, id)
|
||||
}
|
||||
|
||||
SKIP_TO_NODE:
|
||||
if nodeID != "" {
|
||||
evals, _ := b.system.NodeEvals(nodeID)
|
||||
for eval, token := range evals {
|
||||
b.system.Remove(eval)
|
||||
unblocked[eval] = token
|
||||
}
|
||||
}
|
||||
|
||||
if len(unblocked) != 0 {
|
||||
// Update the counters and reset TotalEscaped
|
||||
b.stats.UnblockAll(unblocked, -1)
|
||||
|
||||
// Enqueue all the unblocked evals into the broker.
|
||||
b.evalBroker.EnqueueAll(unblocked)
|
||||
}
|
||||
}
|
||||
|
||||
// UnblockFailed unblocks all blocked evaluation that were due to scheduler
|
||||
// failure.
|
||||
func (b *BlockedEvals) UnblockFailed() {
|
||||
b.flushLock.RLock()
|
||||
defer b.flushLock.RUnlock()
|
||||
if !b.enabled {
|
||||
return
|
||||
}
|
||||
|
||||
b.l.Lock()
|
||||
defer b.l.Unlock()
|
||||
|
||||
escaped := 0
|
||||
unblocked := make(map[*structs.Evaluation]string, 4)
|
||||
for id, wrapped := range b.captured {
|
||||
if wrapped.eval.TriggeredBy == structs.EvalTriggerMaxPlans {
|
||||
unblocked[wrapped.eval] = wrapped.token
|
||||
delete(b.captured, id)
|
||||
delete(b.jobs, structs.NewNamespacedID(wrapped.eval.JobID, wrapped.eval.Namespace))
|
||||
}
|
||||
}
|
||||
|
||||
for id, wrapped := range b.escaped {
|
||||
if wrapped.eval.TriggeredBy == structs.EvalTriggerMaxPlans {
|
||||
unblocked[wrapped.eval] = wrapped.token
|
||||
delete(b.escaped, id)
|
||||
delete(b.jobs, structs.NewNamespacedID(wrapped.eval.JobID, wrapped.eval.Namespace))
|
||||
escaped++
|
||||
}
|
||||
}
|
||||
|
||||
if len(unblocked) > 0 {
|
||||
b.stats.UnblockAll(unblocked, escaped)
|
||||
b.evalBroker.EnqueueAll(unblocked)
|
||||
}
|
||||
}
|
||||
|
||||
// GetDuplicates returns all the duplicate evaluations and blocks until the
|
||||
// passed timeout.
|
||||
func (b *BlockedEvals) GetDuplicates(timeout time.Duration) []*structs.Evaluation {
|
||||
var timeoutTimer *time.Timer
|
||||
var timeoutCh <-chan time.Time
|
||||
SCAN:
|
||||
b.l.Lock()
|
||||
if len(b.duplicates) != 0 {
|
||||
dups := b.duplicates
|
||||
b.duplicates = nil
|
||||
b.l.Unlock()
|
||||
return dups
|
||||
}
|
||||
|
||||
// Capture chans inside the lock to prevent a race with them getting
|
||||
// reset in Flush
|
||||
dupCh := b.duplicateCh
|
||||
stopCh := b.stopCh
|
||||
b.l.Unlock()
|
||||
|
||||
// Create the timer
|
||||
if timeoutTimer == nil && timeout != 0 {
|
||||
timeoutTimer = time.NewTimer(timeout)
|
||||
timeoutCh = timeoutTimer.C
|
||||
defer timeoutTimer.Stop()
|
||||
}
|
||||
|
||||
select {
|
||||
case <-stopCh:
|
||||
return nil
|
||||
case <-timeoutCh:
|
||||
return nil
|
||||
case <-dupCh:
|
||||
goto SCAN
|
||||
}
|
||||
}
|
||||
|
||||
// Flush is used to clear the state of blocked evaluations.
|
||||
func (b *BlockedEvals) Flush() {
|
||||
b.flushLock.Lock()
|
||||
defer b.flushLock.Unlock()
|
||||
|
||||
b.l.Lock()
|
||||
defer b.l.Unlock()
|
||||
|
||||
// Reset stats
|
||||
b.stats.Reset()
|
||||
|
||||
// Reset the tracker
|
||||
b.captured = make(map[string]wrappedEval)
|
||||
b.escaped = make(map[string]wrappedEval)
|
||||
b.jobs = make(map[structs.NamespacedID]string)
|
||||
b.unblockIndexes = make(map[string]unblockEvent)
|
||||
b.duplicates = nil
|
||||
b.capacityChangeCh = make(chan *capacityUpdate, unblockBuffer)
|
||||
b.stopCh = make(chan struct{})
|
||||
b.duplicateCh = make(chan struct{}, 1)
|
||||
b.system = newSystemEvals()
|
||||
}
|
||||
|
||||
// EmitStats is used to export metrics about the blocked eval tracker while enabled
|
||||
func (b *BlockedEvals) EmitStats(period time.Duration, stopCh <-chan struct{}) {
|
||||
timer, stop := helper.NewSafeTimer(period)
|
||||
defer stop()
|
||||
|
||||
for {
|
||||
timer.Reset(period)
|
||||
|
||||
select {
|
||||
|
||||
case <-timer.C:
|
||||
stats := b.stats.Copy()
|
||||
metrics.SetGauge([]string{"nomad", "blocked_evals", "total_quota_limit"}, float32(stats.TotalQuotaLimit))
|
||||
metrics.SetGauge([]string{"nomad", "blocked_evals", "total_blocked"}, float32(stats.TotalBlocked))
|
||||
metrics.SetGauge([]string{"nomad", "blocked_evals", "total_escaped"}, float32(stats.TotalEscaped))
|
||||
|
||||
for k, v := range stats.BlockedResources.ByJob {
|
||||
labels := []metrics.Label{
|
||||
{Name: "namespace", Value: k.Namespace},
|
||||
{Name: "job", Value: k.ID},
|
||||
}
|
||||
metrics.SetGaugeWithLabels([]string{"nomad", "blocked_evals", "job", "cpu"}, float32(v.CPU), labels)
|
||||
metrics.SetGaugeWithLabels([]string{"nomad", "blocked_evals", "job", "memory"}, float32(v.MemoryMB), labels)
|
||||
}
|
||||
|
||||
for k, v := range stats.BlockedResources.ByClassInDC {
|
||||
labels := []metrics.Label{
|
||||
{Name: "datacenter", Value: k.dc},
|
||||
{Name: "node_class", Value: k.class},
|
||||
{Name: "node_pool", Value: k.nodepool},
|
||||
}
|
||||
metrics.SetGaugeWithLabels([]string{"nomad", "blocked_evals", "cpu"}, float32(v.CPU), labels)
|
||||
metrics.SetGaugeWithLabels([]string{"nomad", "blocked_evals", "memory"}, float32(v.MemoryMB), labels)
|
||||
}
|
||||
case <-stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// prune is a long lived function that prunes unnecessary objects on a timer.
|
||||
func (b *BlockedEvals) prune(stopCh <-chan struct{}) {
|
||||
ticker := time.NewTicker(pruneInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stopCh:
|
||||
return
|
||||
case t := <-ticker.C:
|
||||
cutoff := t.UTC().Add(-1 * pruneThreshold)
|
||||
b.pruneUnblockIndexes(cutoff)
|
||||
b.pruneStats(cutoff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pruneUnblockIndexes is used to prune any tracked entry that is excessively
|
||||
// old. This protects against unbounded growth of the map.
|
||||
func (b *BlockedEvals) pruneUnblockIndexes(cutoff time.Time) {
|
||||
b.unblockIndexesLock.Lock()
|
||||
defer b.unblockIndexesLock.Unlock()
|
||||
|
||||
for key, u := range b.unblockIndexes {
|
||||
if u.timestamp.Before(cutoff) {
|
||||
delete(b.unblockIndexes, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pruneStats is used to prune any zero value stats that are excessively old.
|
||||
func (b *BlockedEvals) pruneStats(cutoff time.Time) {
|
||||
b.stats.prune(cutoff)
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
// BlockedStats returns all the stats about the blocked eval tracker.
|
||||
type BlockedStats struct {
|
||||
// TotalEscaped is the total number of blocked evaluations that have escaped
|
||||
// computed node classes.
|
||||
TotalEscaped int
|
||||
|
||||
// TotalBlocked is the total number of blocked evaluations.
|
||||
TotalBlocked int
|
||||
|
||||
// TotalQuotaLimit is the total number of blocked evaluations that are due
|
||||
// to the quota limit being reached.
|
||||
TotalQuotaLimit int
|
||||
|
||||
// BlockedResources stores the amount of resources requested by blocked
|
||||
// evaluations.
|
||||
BlockedResources *BlockedResourcesStats
|
||||
|
||||
lock sync.RWMutex
|
||||
}
|
||||
|
||||
// classInDC is a coordinate of a specific class in a specific datacenter
|
||||
type classInDC struct {
|
||||
dc string
|
||||
class string
|
||||
nodepool string
|
||||
}
|
||||
|
||||
// NewBlockedStats returns a new BlockedStats.
|
||||
func NewBlockedStats() *BlockedStats {
|
||||
return &BlockedStats{
|
||||
BlockedResources: NewBlockedResourcesStats(),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *BlockedStats) Reset() {
|
||||
b.lock.Lock()
|
||||
defer b.lock.Unlock()
|
||||
|
||||
b.TotalEscaped = 0
|
||||
b.TotalBlocked = 0
|
||||
b.TotalQuotaLimit = 0
|
||||
b.BlockedResources = NewBlockedResourcesStats()
|
||||
}
|
||||
|
||||
// Block updates the stats for the blocked eval tracker with the details of the
|
||||
// evaluation being blocked.
|
||||
func (b *BlockedStats) Block(eval *structs.Evaluation) {
|
||||
b.lock.Lock()
|
||||
defer b.lock.Unlock()
|
||||
|
||||
b.TotalBlocked++
|
||||
resourceStats := generateResourceStats(eval)
|
||||
b.BlockedResources = b.BlockedResources.Add(resourceStats)
|
||||
|
||||
// Track that the evaluation is being added due to reaching the quota limit
|
||||
if eval.QuotaLimitReached != "" {
|
||||
b.TotalQuotaLimit++
|
||||
}
|
||||
|
||||
// If the eval has escaped, meaning computed node classes could not capture
|
||||
// the constraints of the job, we store the eval separately as we have to
|
||||
// unblock it whenever node capacity changes. This is because we don't know
|
||||
// what node class is feasible for the jobs constraints.
|
||||
if eval.EscapedComputedClass {
|
||||
b.TotalEscaped++
|
||||
}
|
||||
}
|
||||
|
||||
// Copy returns a deep clone of the BlockedStats, holding a read lock
|
||||
func (b *BlockedStats) Copy() *BlockedStats {
|
||||
stats := NewBlockedStats()
|
||||
|
||||
b.lock.RLock()
|
||||
defer b.lock.RUnlock()
|
||||
|
||||
stats.TotalEscaped = b.TotalEscaped
|
||||
stats.TotalBlocked = b.TotalBlocked
|
||||
stats.TotalQuotaLimit = b.TotalQuotaLimit
|
||||
stats.BlockedResources = b.BlockedResources.Copy()
|
||||
return stats
|
||||
}
|
||||
|
||||
// Unblock updates the stats for the blocked eval tracker with the details of the
|
||||
// evaluation being unblocked.
|
||||
func (b *BlockedStats) Unblock(eval *structs.Evaluation, escaped bool) {
|
||||
b.lock.Lock()
|
||||
defer b.lock.Unlock()
|
||||
b.unblockImpl(eval, escaped)
|
||||
}
|
||||
|
||||
// UnblockAll updates the stats for a set of evaluations. It will also decrement
|
||||
// TotalEscaped or reset it to zero if negative
|
||||
func (b *BlockedStats) UnblockAll(evals map[*structs.Evaluation]string, escaped int) {
|
||||
b.lock.Lock()
|
||||
defer b.lock.Unlock()
|
||||
|
||||
for eval := range evals {
|
||||
b.unblockImpl(eval, false)
|
||||
}
|
||||
if escaped >= 0 {
|
||||
b.TotalEscaped -= escaped
|
||||
} else {
|
||||
b.TotalEscaped = 0
|
||||
}
|
||||
}
|
||||
|
||||
// unblockImpl expects that b.lock is held by the caller
|
||||
func (b *BlockedStats) unblockImpl(eval *structs.Evaluation, escaped bool) {
|
||||
b.TotalBlocked--
|
||||
|
||||
resourceStats := generateResourceStats(eval)
|
||||
b.BlockedResources = b.BlockedResources.Subtract(resourceStats)
|
||||
|
||||
if eval.QuotaLimitReached != "" {
|
||||
b.TotalQuotaLimit--
|
||||
}
|
||||
if escaped {
|
||||
b.TotalEscaped--
|
||||
}
|
||||
}
|
||||
|
||||
func (b *BlockedStats) decrementEscaped() {
|
||||
b.lock.Lock()
|
||||
defer b.lock.Unlock()
|
||||
b.TotalEscaped--
|
||||
}
|
||||
|
||||
// prune deletes any key zero metric values older than the cutoff.
|
||||
func (b *BlockedStats) prune(cutoff time.Time) {
|
||||
b.lock.Lock()
|
||||
defer b.lock.Unlock()
|
||||
|
||||
shouldPrune := func(s BlockedResourcesSummary) bool {
|
||||
return s.Timestamp.Before(cutoff) && s.IsZero()
|
||||
}
|
||||
|
||||
for k, v := range b.BlockedResources.ByJob {
|
||||
if shouldPrune(v) {
|
||||
delete(b.BlockedResources.ByJob, k)
|
||||
}
|
||||
}
|
||||
|
||||
for k, v := range b.BlockedResources.ByClassInDC {
|
||||
if shouldPrune(v) {
|
||||
delete(b.BlockedResources.ByClassInDC, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// generateResourceStats returns a summary of the resources requested by the
|
||||
// input evaluation.
|
||||
func generateResourceStats(eval *structs.Evaluation) *BlockedResourcesStats {
|
||||
dcs := make(map[string]struct{})
|
||||
classes := make(map[string]struct{})
|
||||
nodepools := make(map[string]struct{})
|
||||
|
||||
resources := BlockedResourcesSummary{
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
|
||||
for _, allocMetrics := range eval.FailedTGAllocs {
|
||||
for dc := range allocMetrics.NodesAvailable {
|
||||
dcs[dc] = struct{}{}
|
||||
}
|
||||
for class := range allocMetrics.ClassExhausted {
|
||||
classes[class] = struct{}{}
|
||||
}
|
||||
|
||||
nodepools[allocMetrics.NodePool] = struct{}{}
|
||||
|
||||
if len(allocMetrics.ClassExhausted) == 0 {
|
||||
// some evaluations have no class
|
||||
classes[""] = struct{}{}
|
||||
}
|
||||
for _, r := range allocMetrics.ResourcesExhausted {
|
||||
resources.CPU += r.CPU
|
||||
resources.MemoryMB += r.MemoryMB
|
||||
}
|
||||
}
|
||||
|
||||
byJob := make(map[structs.NamespacedID]BlockedResourcesSummary)
|
||||
nsID := structs.NewNamespacedID(eval.JobID, eval.Namespace)
|
||||
byJob[nsID] = resources
|
||||
|
||||
byClassInDC := make(map[classInDC]BlockedResourcesSummary)
|
||||
for nodepool := range nodepools {
|
||||
for dc := range dcs {
|
||||
for class := range classes {
|
||||
k := classInDC{dc: dc, class: class, nodepool: nodepool}
|
||||
byClassInDC[k] = resources
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &BlockedResourcesStats{
|
||||
ByJob: byJob,
|
||||
ByClassInDC: byClassInDC,
|
||||
}
|
||||
}
|
||||
|
||||
// BlockedResourcesStats stores resources requested by blocked evaluations,
|
||||
// tracked both by job and by node.
|
||||
type BlockedResourcesStats struct {
|
||||
ByJob map[structs.NamespacedID]BlockedResourcesSummary
|
||||
ByClassInDC map[classInDC]BlockedResourcesSummary
|
||||
}
|
||||
|
||||
// NewBlockedResourcesStats returns a new BlockedResourcesStats.
|
||||
func NewBlockedResourcesStats() *BlockedResourcesStats {
|
||||
return &BlockedResourcesStats{
|
||||
ByJob: make(map[structs.NamespacedID]BlockedResourcesSummary),
|
||||
ByClassInDC: make(map[classInDC]BlockedResourcesSummary),
|
||||
}
|
||||
}
|
||||
|
||||
// Copy returns a deep copy of the blocked resource stats.
|
||||
func (b *BlockedResourcesStats) Copy() *BlockedResourcesStats {
|
||||
result := NewBlockedResourcesStats()
|
||||
|
||||
for k, v := range b.ByJob {
|
||||
result.ByJob[k] = v // value copy
|
||||
}
|
||||
|
||||
for k, v := range b.ByClassInDC {
|
||||
result.ByClassInDC[k] = v // value copy
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Add returns a new BlockedResourcesStats with the values set to the current
|
||||
// resource values plus the input.
|
||||
func (b *BlockedResourcesStats) Add(a *BlockedResourcesStats) *BlockedResourcesStats {
|
||||
|
||||
for k, v := range a.ByJob {
|
||||
b.ByJob[k] = b.ByJob[k].Add(v)
|
||||
}
|
||||
for k, v := range a.ByClassInDC {
|
||||
b.ByClassInDC[k] = b.ByClassInDC[k].Add(v)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Subtract returns a new BlockedResourcesStats with the values set to the
|
||||
// current resource values minus the input.
|
||||
func (b *BlockedResourcesStats) Subtract(a *BlockedResourcesStats) *BlockedResourcesStats {
|
||||
|
||||
for k, v := range a.ByJob {
|
||||
b.ByJob[k] = b.ByJob[k].Subtract(v)
|
||||
}
|
||||
for k, v := range a.ByClassInDC {
|
||||
b.ByClassInDC[k] = b.ByClassInDC[k].Subtract(v)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// BlockedResourcesSummary stores resource values for blocked evals.
|
||||
type BlockedResourcesSummary struct {
|
||||
Timestamp time.Time
|
||||
CPU int
|
||||
MemoryMB int
|
||||
}
|
||||
|
||||
// Add returns a new BlockedResourcesSummary with each resource set to the
|
||||
// current value plus the input.
|
||||
func (b BlockedResourcesSummary) Add(a BlockedResourcesSummary) BlockedResourcesSummary {
|
||||
return BlockedResourcesSummary{
|
||||
Timestamp: a.Timestamp,
|
||||
CPU: b.CPU + a.CPU,
|
||||
MemoryMB: b.MemoryMB + a.MemoryMB,
|
||||
}
|
||||
}
|
||||
|
||||
// Subtract returns a new BlockedResourcesSummary with each resource set to the
|
||||
// current value minus the input.
|
||||
func (b BlockedResourcesSummary) Subtract(a BlockedResourcesSummary) BlockedResourcesSummary {
|
||||
return BlockedResourcesSummary{
|
||||
Timestamp: a.Timestamp,
|
||||
CPU: b.CPU - a.CPU,
|
||||
MemoryMB: b.MemoryMB - a.MemoryMB,
|
||||
}
|
||||
}
|
||||
|
||||
// IsZero returns true if all resource values are zero.
|
||||
func (b BlockedResourcesSummary) IsZero() bool {
|
||||
return b.CPU == 0 && b.MemoryMB == 0
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"reflect"
|
||||
"testing"
|
||||
"testing/quick"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/nomad/ci"
|
||||
"github.com/hashicorp/nomad/nomad/mock"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func now(year int) time.Time {
|
||||
return time.Date(2000+year, 1, 2, 3, 4, 5, 6, time.UTC)
|
||||
}
|
||||
|
||||
func TestBlockedResourceSummary_Add(t *testing.T) {
|
||||
now1 := now(1)
|
||||
now2 := now(2)
|
||||
a := BlockedResourcesSummary{
|
||||
Timestamp: now1,
|
||||
CPU: 600,
|
||||
MemoryMB: 256,
|
||||
}
|
||||
|
||||
b := BlockedResourcesSummary{
|
||||
Timestamp: now2,
|
||||
CPU: 250,
|
||||
MemoryMB: 128,
|
||||
}
|
||||
|
||||
result := a.Add(b)
|
||||
|
||||
// a not modified
|
||||
require.Equal(t, 600, a.CPU)
|
||||
require.Equal(t, 256, a.MemoryMB)
|
||||
require.Equal(t, now1, a.Timestamp)
|
||||
|
||||
// b not modified
|
||||
require.Equal(t, 250, b.CPU)
|
||||
require.Equal(t, 128, b.MemoryMB)
|
||||
require.Equal(t, now2, b.Timestamp)
|
||||
|
||||
// result is a + b, using timestamp from b
|
||||
require.Equal(t, 850, result.CPU)
|
||||
require.Equal(t, 384, result.MemoryMB)
|
||||
require.Equal(t, now2, result.Timestamp)
|
||||
}
|
||||
|
||||
func TestBlockedResourceSummary_Add_nil(t *testing.T) {
|
||||
now1 := now(1)
|
||||
b := BlockedResourcesSummary{
|
||||
Timestamp: now1,
|
||||
CPU: 250,
|
||||
MemoryMB: 128,
|
||||
}
|
||||
|
||||
// zero + b == b
|
||||
result := (BlockedResourcesSummary{}).Add(b)
|
||||
require.Equal(t, now1, result.Timestamp)
|
||||
require.Equal(t, 250, result.CPU)
|
||||
require.Equal(t, 128, result.MemoryMB)
|
||||
}
|
||||
|
||||
func TestBlockedResourceSummary_Subtract(t *testing.T) {
|
||||
now1 := now(1)
|
||||
now2 := now(2)
|
||||
a := BlockedResourcesSummary{
|
||||
Timestamp: now1,
|
||||
CPU: 600,
|
||||
MemoryMB: 256,
|
||||
}
|
||||
|
||||
b := BlockedResourcesSummary{
|
||||
Timestamp: now2,
|
||||
CPU: 250,
|
||||
MemoryMB: 120,
|
||||
}
|
||||
|
||||
result := a.Subtract(b)
|
||||
|
||||
// a not modified
|
||||
require.Equal(t, 600, a.CPU)
|
||||
require.Equal(t, 256, a.MemoryMB)
|
||||
require.Equal(t, now1, a.Timestamp)
|
||||
|
||||
// b not modified
|
||||
require.Equal(t, 250, b.CPU)
|
||||
require.Equal(t, 120, b.MemoryMB)
|
||||
require.Equal(t, now2, b.Timestamp)
|
||||
|
||||
// result is a + b, using timestamp from b
|
||||
require.Equal(t, 350, result.CPU)
|
||||
require.Equal(t, 136, result.MemoryMB)
|
||||
require.Equal(t, now2, result.Timestamp)
|
||||
}
|
||||
|
||||
func TestBlockedResourceSummary_IsZero(t *testing.T) {
|
||||
now1 := now(1)
|
||||
|
||||
// cpu and mem zero, timestamp is ignored
|
||||
require.True(t, (&BlockedResourcesSummary{
|
||||
Timestamp: now1,
|
||||
CPU: 0,
|
||||
MemoryMB: 0,
|
||||
}).IsZero())
|
||||
|
||||
// cpu non-zero
|
||||
require.False(t, (&BlockedResourcesSummary{
|
||||
Timestamp: now1,
|
||||
CPU: 1,
|
||||
MemoryMB: 0,
|
||||
}).IsZero())
|
||||
|
||||
// mem non-zero
|
||||
require.False(t, (&BlockedResourcesSummary{
|
||||
Timestamp: now1,
|
||||
CPU: 0,
|
||||
MemoryMB: 1,
|
||||
}).IsZero())
|
||||
}
|
||||
|
||||
func TestBlockedResourceStats_New(t *testing.T) {
|
||||
a := NewBlockedResourcesStats()
|
||||
require.NotNil(t, a.ByJob)
|
||||
require.Empty(t, a.ByJob)
|
||||
require.NotNil(t, a.ByClassInDC)
|
||||
require.Empty(t, a.ByClassInDC)
|
||||
}
|
||||
|
||||
var (
|
||||
id1 = structs.NamespacedID{
|
||||
ID: "1",
|
||||
Namespace: "one",
|
||||
}
|
||||
|
||||
id2 = structs.NamespacedID{
|
||||
ID: "2",
|
||||
Namespace: "two",
|
||||
}
|
||||
|
||||
node1 = classInDC{
|
||||
dc: "dc1",
|
||||
class: "alpha",
|
||||
nodepool: "default",
|
||||
}
|
||||
|
||||
node2 = classInDC{
|
||||
dc: "dc1",
|
||||
class: "beta",
|
||||
nodepool: "dev",
|
||||
}
|
||||
|
||||
node3 = classInDC{
|
||||
dc: "dc1",
|
||||
class: "", // not set
|
||||
}
|
||||
)
|
||||
|
||||
func TestBlockedResourceStats_Copy(t *testing.T) {
|
||||
now1 := now(1)
|
||||
now2 := now(2)
|
||||
|
||||
a := NewBlockedResourcesStats()
|
||||
a.ByJob = map[structs.NamespacedID]BlockedResourcesSummary{
|
||||
id1: {
|
||||
Timestamp: now1,
|
||||
CPU: 100,
|
||||
MemoryMB: 256,
|
||||
},
|
||||
}
|
||||
a.ByClassInDC = map[classInDC]BlockedResourcesSummary{
|
||||
node1: {
|
||||
Timestamp: now1,
|
||||
CPU: 300,
|
||||
MemoryMB: 333,
|
||||
},
|
||||
}
|
||||
|
||||
c := a.Copy()
|
||||
c.ByJob[id1] = BlockedResourcesSummary{
|
||||
Timestamp: now2,
|
||||
CPU: 888,
|
||||
MemoryMB: 888,
|
||||
}
|
||||
c.ByClassInDC[node1] = BlockedResourcesSummary{
|
||||
Timestamp: now2,
|
||||
CPU: 999,
|
||||
MemoryMB: 999,
|
||||
}
|
||||
|
||||
// underlying data should have been deep copied
|
||||
require.Equal(t, 100, a.ByJob[id1].CPU)
|
||||
require.Equal(t, 300, a.ByClassInDC[node1].CPU)
|
||||
}
|
||||
|
||||
func TestBlockedResourcesStats_Add(t *testing.T) {
|
||||
a := NewBlockedResourcesStats()
|
||||
a.ByJob = map[structs.NamespacedID]BlockedResourcesSummary{
|
||||
id1: {Timestamp: now(1), CPU: 111, MemoryMB: 222},
|
||||
}
|
||||
a.ByClassInDC = map[classInDC]BlockedResourcesSummary{
|
||||
node1: {Timestamp: now(2), CPU: 333, MemoryMB: 444},
|
||||
}
|
||||
|
||||
b := NewBlockedResourcesStats()
|
||||
b.ByJob = map[structs.NamespacedID]BlockedResourcesSummary{
|
||||
id1: {Timestamp: now(3), CPU: 200, MemoryMB: 300},
|
||||
id2: {Timestamp: now(4), CPU: 400, MemoryMB: 500},
|
||||
}
|
||||
b.ByClassInDC = map[classInDC]BlockedResourcesSummary{
|
||||
node1: {Timestamp: now(5), CPU: 600, MemoryMB: 700},
|
||||
node2: {Timestamp: now(6), CPU: 800, MemoryMB: 900},
|
||||
}
|
||||
|
||||
t.Run("a add b", func(t *testing.T) {
|
||||
result := a.Copy().Add(b)
|
||||
|
||||
require.Equal(t, map[structs.NamespacedID]BlockedResourcesSummary{
|
||||
id1: {Timestamp: now(3), CPU: 311, MemoryMB: 522},
|
||||
id2: {Timestamp: now(4), CPU: 400, MemoryMB: 500},
|
||||
}, result.ByJob)
|
||||
|
||||
require.Equal(t, map[classInDC]BlockedResourcesSummary{
|
||||
node1: {Timestamp: now(5), CPU: 933, MemoryMB: 1144},
|
||||
node2: {Timestamp: now(6), CPU: 800, MemoryMB: 900},
|
||||
}, result.ByClassInDC)
|
||||
})
|
||||
|
||||
// make sure we handle zeros in both directions
|
||||
// and timestamps originate from rhs
|
||||
t.Run("b add a", func(t *testing.T) {
|
||||
result := b.Copy().Add(a)
|
||||
require.Equal(t, map[structs.NamespacedID]BlockedResourcesSummary{
|
||||
id1: {Timestamp: now(1), CPU: 311, MemoryMB: 522},
|
||||
id2: {Timestamp: now(4), CPU: 400, MemoryMB: 500},
|
||||
}, result.ByJob)
|
||||
|
||||
require.Equal(t, map[classInDC]BlockedResourcesSummary{
|
||||
node1: {Timestamp: now(2), CPU: 933, MemoryMB: 1144},
|
||||
node2: {Timestamp: now(6), CPU: 800, MemoryMB: 900},
|
||||
}, result.ByClassInDC)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBlockedResourcesStats_Add_NoClass(t *testing.T) {
|
||||
a := NewBlockedResourcesStats()
|
||||
a.ByClassInDC = map[classInDC]BlockedResourcesSummary{
|
||||
node3: {Timestamp: now(1), CPU: 111, MemoryMB: 1111},
|
||||
}
|
||||
result := a.Add(a)
|
||||
require.Equal(t, map[classInDC]BlockedResourcesSummary{
|
||||
node3: {Timestamp: now(1), CPU: 222, MemoryMB: 2222},
|
||||
}, result.ByClassInDC)
|
||||
}
|
||||
|
||||
func TestBlockedResourcesStats_Subtract(t *testing.T) {
|
||||
a := NewBlockedResourcesStats()
|
||||
a.ByJob = map[structs.NamespacedID]BlockedResourcesSummary{
|
||||
id1: {Timestamp: now(1), CPU: 100, MemoryMB: 100},
|
||||
id2: {Timestamp: now(2), CPU: 200, MemoryMB: 200},
|
||||
}
|
||||
a.ByClassInDC = map[classInDC]BlockedResourcesSummary{
|
||||
node1: {Timestamp: now(3), CPU: 300, MemoryMB: 300},
|
||||
node2: {Timestamp: now(4), CPU: 400, MemoryMB: 400},
|
||||
}
|
||||
|
||||
b := NewBlockedResourcesStats()
|
||||
b.ByJob = map[structs.NamespacedID]BlockedResourcesSummary{
|
||||
id1: {Timestamp: now(5), CPU: 10, MemoryMB: 11},
|
||||
id2: {Timestamp: now(6), CPU: 12, MemoryMB: 13},
|
||||
}
|
||||
b.ByClassInDC = map[classInDC]BlockedResourcesSummary{
|
||||
node1: {Timestamp: now(7), CPU: 14, MemoryMB: 15},
|
||||
node2: {Timestamp: now(8), CPU: 16, MemoryMB: 17},
|
||||
}
|
||||
|
||||
result := a.Subtract(b)
|
||||
|
||||
// id1
|
||||
require.Equal(t, now(5), result.ByJob[id1].Timestamp)
|
||||
require.Equal(t, 90, result.ByJob[id1].CPU)
|
||||
require.Equal(t, 89, result.ByJob[id1].MemoryMB)
|
||||
|
||||
// id2
|
||||
require.Equal(t, now(6), result.ByJob[id2].Timestamp)
|
||||
require.Equal(t, 188, result.ByJob[id2].CPU)
|
||||
require.Equal(t, 187, result.ByJob[id2].MemoryMB)
|
||||
|
||||
// node1
|
||||
require.Equal(t, now(7), result.ByClassInDC[node1].Timestamp)
|
||||
require.Equal(t, 286, result.ByClassInDC[node1].CPU)
|
||||
require.Equal(t, 285, result.ByClassInDC[node1].MemoryMB)
|
||||
|
||||
// node2
|
||||
require.Equal(t, now(8), result.ByClassInDC[node2].Timestamp)
|
||||
require.Equal(t, 384, result.ByClassInDC[node2].CPU)
|
||||
require.Equal(t, 383, result.ByClassInDC[node2].MemoryMB)
|
||||
}
|
||||
|
||||
// testBlockedEvalsRandomBlockedEval wraps an eval that is randomly generated.
|
||||
type testBlockedEvalsRandomBlockedEval struct {
|
||||
eval *structs.Evaluation
|
||||
}
|
||||
|
||||
// Generate returns a random eval.
|
||||
func (t testBlockedEvalsRandomBlockedEval) Generate(rand *rand.Rand, _ int) reflect.Value {
|
||||
resourceTypes := []string{"cpu", "memory"}
|
||||
|
||||
// Start with a mock eval.
|
||||
e := mock.BlockedEval()
|
||||
|
||||
// Get how many task groups, datacenters and node classes to generate.
|
||||
// Add 1 to avoid 0.
|
||||
jobCount := rand.Intn(3) + 1
|
||||
tgCount := rand.Intn(10) + 1
|
||||
dcCount := rand.Intn(3) + 1
|
||||
nodeClassCount := rand.Intn(3) + 1
|
||||
nodePoolName := fmt.Sprintf("node-pool-%d", rand.Intn(3)+1)
|
||||
|
||||
failedTGAllocs := map[string]*structs.AllocMetric{}
|
||||
|
||||
e.JobID = fmt.Sprintf("job-%d", jobCount)
|
||||
for tg := 1; tg <= tgCount; tg++ {
|
||||
tgName := fmt.Sprintf("group-%d", tg)
|
||||
|
||||
// Get which resource type to use for this task group.
|
||||
// Nomad stops at the first dimension that is exhausted, so only 1 is
|
||||
// added per task group.
|
||||
i := rand.Int() % len(resourceTypes)
|
||||
resourceType := resourceTypes[i]
|
||||
|
||||
failedTGAllocs[tgName] = &structs.AllocMetric{
|
||||
DimensionExhausted: map[string]int{
|
||||
resourceType: 1,
|
||||
},
|
||||
NodesAvailable: map[string]int{},
|
||||
ClassExhausted: map[string]int{},
|
||||
NodePool: nodePoolName,
|
||||
}
|
||||
|
||||
for dc := 1; dc <= dcCount; dc++ {
|
||||
dcName := fmt.Sprintf("dc%d", dc)
|
||||
failedTGAllocs[tgName].NodesAvailable[dcName] = 1
|
||||
}
|
||||
|
||||
for nc := 1; nc <= nodeClassCount; nc++ {
|
||||
nodeClassName := fmt.Sprintf("node-class-%d", nc)
|
||||
failedTGAllocs[tgName].ClassExhausted[nodeClassName] = 1
|
||||
}
|
||||
|
||||
// Generate resources for each task.
|
||||
taskCount := rand.Intn(5) + 1
|
||||
resourcesExhausted := map[string]*structs.Resources{}
|
||||
|
||||
for t := 1; t <= taskCount; t++ {
|
||||
task := fmt.Sprintf("task-%d", t)
|
||||
resourcesExhausted[task] = &structs.Resources{}
|
||||
|
||||
resourceAmount := rand.Intn(1000)
|
||||
switch resourceType {
|
||||
case "cpu":
|
||||
resourcesExhausted[task].CPU = resourceAmount
|
||||
case "memory":
|
||||
resourcesExhausted[task].MemoryMB = resourceAmount
|
||||
}
|
||||
}
|
||||
failedTGAllocs[tgName].ResourcesExhausted = resourcesExhausted
|
||||
}
|
||||
e.FailedTGAllocs = failedTGAllocs
|
||||
t.eval = e
|
||||
return reflect.ValueOf(t)
|
||||
}
|
||||
|
||||
// clearTimestampFromBlockedResourceStats set timestamp metrics to zero to
|
||||
// avoid invalid comparisons.
|
||||
func clearTimestampFromBlockedResourceStats(b *BlockedResourcesStats) {
|
||||
for k, v := range b.ByJob {
|
||||
v.Timestamp = time.Time{}
|
||||
b.ByJob[k] = v
|
||||
}
|
||||
for k, v := range b.ByClassInDC {
|
||||
v.Timestamp = time.Time{}
|
||||
b.ByClassInDC[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// TestBlockedEvalsStats_BlockedResources generates random evals and processes
|
||||
// them using the expected code paths and a manual check of the expeceted result.
|
||||
func TestBlockedEvalsStats_BlockedResources(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, _ := testBlockedEvals(t)
|
||||
|
||||
// evalHistory stores all evals generated during the test.
|
||||
var evalHistory []*structs.Evaluation
|
||||
|
||||
// blockedEvals keeps track if evals are blocked or unblocked.
|
||||
blockedEvals := map[string]bool{}
|
||||
|
||||
// blockAndUntrack processes the generated evals in order using a
|
||||
// BlockedEvals instance.
|
||||
blockAndUntrack := func(testEval testBlockedEvalsRandomBlockedEval, block bool, unblockIdx uint16) *BlockedResourcesStats {
|
||||
if block || len(evalHistory) == 0 {
|
||||
<-blocked.Block(testEval.eval)
|
||||
} else {
|
||||
i := int(unblockIdx) % len(evalHistory)
|
||||
eval := evalHistory[i]
|
||||
<-blocked.Untrack(eval.JobID, eval.Namespace)
|
||||
}
|
||||
// Remove zero stats from unblocked evals.
|
||||
blocked.pruneStats(time.Now().UTC())
|
||||
|
||||
result := blocked.Stats().BlockedResources
|
||||
clearTimestampFromBlockedResourceStats(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// manualCount processes only the blocked evals and generate a
|
||||
// BlockedResourcesStats result directly from the eval history.
|
||||
manualCount := func(testEval testBlockedEvalsRandomBlockedEval, block bool, unblockIdx uint16) *BlockedResourcesStats {
|
||||
|
||||
if block || len(evalHistory) == 0 {
|
||||
|
||||
evalHistory = append(evalHistory, testEval.eval)
|
||||
|
||||
// Find and unblock evals for the same job.
|
||||
for _, e := range evalHistory {
|
||||
if e.Namespace == testEval.eval.Namespace && e.JobID == testEval.eval.JobID {
|
||||
blockedEvals[e.ID] = false
|
||||
}
|
||||
}
|
||||
|
||||
blockedEvals[testEval.eval.ID] = true
|
||||
} else {
|
||||
i := int(unblockIdx) % len(evalHistory)
|
||||
eval := evalHistory[i]
|
||||
counts := 0
|
||||
|
||||
// Find and unlock all evals for this job.
|
||||
for _, e := range evalHistory {
|
||||
if e.Namespace == eval.Namespace && e.JobID == eval.JobID {
|
||||
counts++
|
||||
blockedEvals[e.ID] = false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
result := NewBlockedResourcesStats()
|
||||
for _, e := range evalHistory {
|
||||
if !blockedEvals[e.ID] {
|
||||
continue
|
||||
}
|
||||
result = result.Add(generateResourceStats(e))
|
||||
}
|
||||
clearTimestampFromBlockedResourceStats(result)
|
||||
return result
|
||||
}
|
||||
|
||||
err := quick.CheckEqual(blockAndUntrack, manualCount, nil)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import "github.com/hashicorp/nomad/nomad/structs"
|
||||
|
||||
// systemEvals are handled specially, each job may have a blocked eval on each node
|
||||
type systemEvals struct {
|
||||
// byJob maps a jobID to a nodeID to that job's single blocked evalID on that node
|
||||
byJob map[structs.NamespacedID]map[string]string
|
||||
|
||||
// byNode maps a nodeID to a set of evalIDs
|
||||
byNode map[string]map[string]bool
|
||||
|
||||
// evals maps evalIDs to an eval and token
|
||||
evals map[string]*wrappedEval
|
||||
}
|
||||
|
||||
func newSystemEvals() *systemEvals {
|
||||
return &systemEvals{
|
||||
evals: map[string]*wrappedEval{},
|
||||
byJob: map[structs.NamespacedID]map[string]string{},
|
||||
byNode: map[string]map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *systemEvals) Add(eval *structs.Evaluation, token string) {
|
||||
// store the eval by node id
|
||||
if _, ok := s.byNode[eval.NodeID]; !ok {
|
||||
s.byNode[eval.NodeID] = make(map[string]bool)
|
||||
}
|
||||
|
||||
s.byNode[eval.NodeID][eval.ID] = true
|
||||
s.evals[eval.ID] = &wrappedEval{eval: eval, token: token}
|
||||
|
||||
// link the job to the node for cleanup
|
||||
jobID := structs.NewNamespacedID(eval.JobID, eval.Namespace)
|
||||
if _, ok := s.byJob[jobID]; !ok {
|
||||
s.byJob[jobID] = make(map[string]string)
|
||||
}
|
||||
|
||||
// if we're displacing the old blocked id for this job+node, delete it first
|
||||
if prevID, ok := s.byJob[jobID][eval.NodeID]; ok {
|
||||
prev, _ := s.Get(prevID)
|
||||
s.Remove(prev.eval)
|
||||
}
|
||||
|
||||
// set this eval as the new eval for this job on this node
|
||||
s.byJob[jobID][eval.NodeID] = eval.ID
|
||||
}
|
||||
|
||||
func (s *systemEvals) Get(evalID string) (*wrappedEval, bool) {
|
||||
w, ok := s.evals[evalID]
|
||||
return w, ok
|
||||
}
|
||||
|
||||
func (s *systemEvals) Remove(eval *structs.Evaluation) {
|
||||
// delete the job index if this eval is the currently listed blocked eval
|
||||
jobID := structs.NewNamespacedID(eval.JobID, eval.Namespace)
|
||||
e, ok := s.byJob[jobID][eval.NodeID]
|
||||
if ok && e == eval.ID {
|
||||
delete(s.byJob[jobID], eval.NodeID)
|
||||
}
|
||||
|
||||
// delete this eval from the node index, and then the map for this node if empty
|
||||
delete(s.byNode[eval.NodeID], eval.ID)
|
||||
if len(s.byNode[eval.NodeID]) == 0 {
|
||||
delete(s.byNode, eval.NodeID)
|
||||
}
|
||||
|
||||
// delete the eval itself
|
||||
delete(s.evals, eval.ID)
|
||||
}
|
||||
|
||||
func (s *systemEvals) NodeEvals(nodeID string) (map[*structs.Evaluation]string, bool) {
|
||||
out := map[*structs.Evaluation]string{}
|
||||
for eID := range s.byNode[nodeID] {
|
||||
if w, ok := s.Get(eID); ok {
|
||||
out[w.eval] = w.token
|
||||
}
|
||||
}
|
||||
|
||||
ok := len(out) > 0
|
||||
return out, ok
|
||||
}
|
||||
|
||||
func (s *systemEvals) JobEvals(jobID structs.NamespacedID) ([]*structs.Evaluation, bool) {
|
||||
out := []*structs.Evaluation{}
|
||||
_, ok := s.byJob[jobID]
|
||||
for _, eID := range s.byJob[jobID] {
|
||||
if e, ok := s.Get(eID); ok {
|
||||
out = append(out, e.eval)
|
||||
}
|
||||
}
|
||||
return out, ok
|
||||
}
|
||||
@@ -0,0 +1,811 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/nomad/ci"
|
||||
"github.com/hashicorp/nomad/helper/testlog"
|
||||
"github.com/hashicorp/nomad/nomad/mock"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
"github.com/shoenig/test/must"
|
||||
"github.com/shoenig/test/wait"
|
||||
)
|
||||
|
||||
// Stats is used to query the state of the blocked eval tracker. This is only
|
||||
// ever called in tests.
|
||||
func (b *BlockedEvals) Stats() *BlockedStats {
|
||||
return b.stats.Copy()
|
||||
}
|
||||
|
||||
func testBlockedEvals(t *testing.T) (*BlockedEvals, *EvalBroker) {
|
||||
broker := testBroker(t, 0)
|
||||
broker.SetEnabled(true)
|
||||
blocked := NewBlockedEvals(broker, testlog.HCLogger(t))
|
||||
blocked.SetEnabled(true)
|
||||
return blocked, broker
|
||||
}
|
||||
|
||||
func TestBlockedEvals_Block_Disabled(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, _ := testBlockedEvals(t)
|
||||
blocked.SetEnabled(false)
|
||||
|
||||
// Create an escaped eval and add it to the blocked tracker.
|
||||
e := mock.BlockedEval()
|
||||
e.EscapedComputedClass = true
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block did nothing.
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 0, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 0, stats.BlockedResources.ByJob)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_Block_SameJob(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, _ := testBlockedEvals(t)
|
||||
|
||||
// Create two blocked evals and add them to the blocked tracker.
|
||||
e := mock.BlockedEval()
|
||||
e2 := mock.BlockedEval()
|
||||
e2.JobID = e.JobID
|
||||
<-blocked.Block(e)
|
||||
<-blocked.Block(e2)
|
||||
|
||||
// Verify block didn't track duplicate.
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_Block_Quota(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
blocked, _ := testBlockedEvals(t)
|
||||
|
||||
// Create a blocked eval on quota.
|
||||
e := mock.BlockedEval()
|
||||
e.QuotaLimitReached = "foo"
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block did track eval.
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.Eq(t, 1, stats.TotalQuotaLimit)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_Block_PriorUnblocks(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, _ := testBlockedEvals(t)
|
||||
|
||||
// Do unblocks prior to blocking.
|
||||
blocked.Unblock("v1:123", 1000)
|
||||
blocked.Unblock("v1:123", 1001)
|
||||
|
||||
// Create blocked eval with two classes ineligible.
|
||||
e := mock.BlockedEval()
|
||||
e.ClassEligibility = map[string]bool{"v1:123": false, "v1:456": false}
|
||||
e.SnapshotIndex = 999
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block did track eval.
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_GetDuplicates(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, _ := testBlockedEvals(t)
|
||||
|
||||
// Create duplicate blocked evals and add them to the blocked tracker.
|
||||
e := mock.BlockedEval()
|
||||
e.CreateIndex = 100
|
||||
e2 := mock.BlockedEval()
|
||||
e2.JobID = e.JobID
|
||||
e2.CreateIndex = 101
|
||||
e3 := mock.BlockedEval()
|
||||
e3.JobID = e.JobID
|
||||
e3.CreateIndex = 102
|
||||
e4 := mock.BlockedEval()
|
||||
e4.JobID = e.JobID
|
||||
e4.CreateIndex = 100
|
||||
<-blocked.Block(e)
|
||||
<-blocked.Block(e2)
|
||||
|
||||
// Verify stats such that we are only tracking one.
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
|
||||
// Get the duplicates.
|
||||
out := blocked.GetDuplicates(0)
|
||||
must.Len(t, 1, out)
|
||||
must.Eq(t, e, out[0])
|
||||
|
||||
// Call block again after a small sleep.
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
<-blocked.Block(e3)
|
||||
}()
|
||||
|
||||
// Get the duplicates.
|
||||
out = blocked.GetDuplicates(1 * time.Second)
|
||||
must.Len(t, 1, out)
|
||||
must.Eq(t, e2, out[0])
|
||||
|
||||
// Verify stats such that we are only tracking one.
|
||||
stats = blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
|
||||
// Add an older evaluation and assert it gets cancelled.
|
||||
<-blocked.Block(e4)
|
||||
out = blocked.GetDuplicates(0)
|
||||
must.Len(t, 1, out)
|
||||
must.Eq(t, e4, out[0])
|
||||
|
||||
// Verify stats such that we are only tracking one.
|
||||
stats = blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_UnblockEscaped(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
blocked, broker := testBlockedEvals(t)
|
||||
|
||||
// Create an escaped eval and add it to the blocked tracker.
|
||||
e := mock.BlockedEval()
|
||||
e.Status = structs.EvalStatusBlocked
|
||||
e.EscapedComputedClass = true
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block caused the eval to be tracked
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 1, stats.TotalEscaped)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
|
||||
<-blocked.Unblock("v1:123", 1000)
|
||||
requireBlockedEvalsEnqueued(t, blocked, broker, 1)
|
||||
}
|
||||
|
||||
func requireBlockedEvalsEnqueued(t *testing.T, blocked *BlockedEvals, broker *EvalBroker, enqueued int) {
|
||||
t.Helper()
|
||||
|
||||
must.Wait(t, wait.InitialSuccess(
|
||||
wait.ErrorFunc(func() error {
|
||||
brokerStats := broker.Stats()
|
||||
if brokerStats.TotalReady != enqueued {
|
||||
return fmt.Errorf("missing enqueued evals: %#v", brokerStats)
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
wait.Timeout(500*time.Millisecond),
|
||||
wait.Gap(10*time.Millisecond),
|
||||
))
|
||||
|
||||
blocked.pruneStats(time.Now().UTC())
|
||||
|
||||
// verfiy stats have been cleared
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 0, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 0, stats.BlockedResources.ByJob)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_UnblockEligible(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, broker := testBlockedEvals(t)
|
||||
|
||||
// Create a blocked eval that is eligible on a specific node class and add
|
||||
// it to the blocked tracker.
|
||||
e := mock.BlockedEval()
|
||||
e.Status = structs.EvalStatusBlocked
|
||||
e.ClassEligibility = map[string]bool{"v1:123": true}
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block caused the eval to be tracked
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
|
||||
blocked.Unblock("v1:123", 1000)
|
||||
requireBlockedEvalsEnqueued(t, blocked, broker, 1)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_UnblockIneligible(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, broker := testBlockedEvals(t)
|
||||
|
||||
// Create a blocked eval that is ineligible on a specific node class and add
|
||||
// it to the blocked tracker.
|
||||
e := mock.BlockedEval()
|
||||
e.ClassEligibility = map[string]bool{"v1:123": false}
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block caused the eval to be tracked
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
|
||||
// Should do nothing
|
||||
<-blocked.Unblock("v1:123", 1000)
|
||||
|
||||
// Verify Unblock didn't cause an enqueue
|
||||
brokerStats := broker.Stats()
|
||||
must.Eq(t, 0, brokerStats.TotalReady, must.Sprintf("eval unblocked: %#v", brokerStats))
|
||||
|
||||
// Prune old and empty metrics.
|
||||
blocked.pruneStats(time.Now().UTC())
|
||||
|
||||
stats = blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_UnblockUnknown(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, broker := testBlockedEvals(t)
|
||||
|
||||
// Create a blocked eval that is ineligible on a specific node class and add
|
||||
// it to the blocked tracker.
|
||||
e := mock.BlockedEval()
|
||||
e.ClassEligibility = map[string]bool{"v1:123": true, "v1:456": false}
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block caused the eval to be tracked.
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
|
||||
// Should unblock because the eval hasn't seen this node class.
|
||||
<-blocked.Unblock("v1:789", 1000)
|
||||
requireBlockedEvalsEnqueued(t, blocked, broker, 1)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_UnblockEligible_Quota(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
blocked, broker := testBlockedEvals(t)
|
||||
|
||||
// Create a blocked eval that is eligible for a particular quota.
|
||||
e := mock.BlockedEval()
|
||||
e.QuotaLimitReached = "foo"
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block caused the eval to be tracked.
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 1, stats.TotalQuotaLimit)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
|
||||
<-blocked.UnblockQuota("foo", 1000)
|
||||
requireBlockedEvalsEnqueued(t, blocked, broker, 1)
|
||||
}
|
||||
|
||||
// The quota here is incidental. The eval is blocked due to something else,
|
||||
// e.g. cpu exhausted, but there happens to also be a quota on the namespace.
|
||||
func TestBlockedEvals_UnblockEligible_IncidentalQuota(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, broker := testBlockedEvals(t)
|
||||
|
||||
e := mock.BlockedEval()
|
||||
e.Status = structs.EvalStatusBlocked
|
||||
e.QuotaLimitReached = "" // explicitly not blocked due to quota limit
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block caused the eval to be tracked.
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
// but not due to quota.
|
||||
must.Eq(t, 0, stats.TotalQuotaLimit)
|
||||
|
||||
// When unblocking, the quota name from the alloc is passed in,
|
||||
// regardless of the cause of the initial blockage.
|
||||
// Since the initial block in this test was due to something else,
|
||||
// it should be unblocked without regard to quota.
|
||||
<-blocked.UnblockQuota("foo", 1000)
|
||||
requireBlockedEvalsEnqueued(t, blocked, broker, 1)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_UnblockIneligible_Quota(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, broker := testBlockedEvals(t)
|
||||
|
||||
// Create a blocked eval that is eligible on a specific quota.
|
||||
e := mock.BlockedEval()
|
||||
e.QuotaLimitReached = "foo"
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block caused the eval to be tracked.
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 1, stats.TotalQuotaLimit)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
|
||||
// Should do nothing and have no evals enqueued
|
||||
<-blocked.UnblockQuota("bar", 1000)
|
||||
brokerStats := broker.Stats()
|
||||
must.Eq(t, 0, brokerStats.TotalReady)
|
||||
|
||||
// Prune old and empty metrics.
|
||||
blocked.pruneStats(time.Now().UTC())
|
||||
|
||||
stats = blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.Eq(t, 1, stats.TotalQuotaLimit)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_Reblock(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, broker := testBlockedEvals(t)
|
||||
|
||||
// Create an evaluation, Enqueue/Dequeue it to get a token
|
||||
e := mock.BlockedEval()
|
||||
e.SnapshotIndex = 500
|
||||
e.ClassEligibility = map[string]bool{"v1:123": true, "v1:456": false}
|
||||
broker.Enqueue(e)
|
||||
|
||||
_, token, err := broker.Dequeue([]string{e.Type}, time.Second)
|
||||
must.NoError(t, err)
|
||||
|
||||
// Reblock the evaluation
|
||||
<-blocked.Reblock(e, token)
|
||||
|
||||
// Verify block caused the eval to be tracked
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
|
||||
// Should unblock because the eval
|
||||
<-blocked.Unblock("v1:123", 1000)
|
||||
|
||||
brokerStats := broker.Stats()
|
||||
must.Eq(t, 0, brokerStats.TotalReady)
|
||||
must.Eq(t, 1, brokerStats.TotalUnacked)
|
||||
|
||||
// Ack the evaluation which should cause the reblocked eval to transition
|
||||
// to ready
|
||||
err = broker.Ack(e.ID, token)
|
||||
must.NoError(t, err)
|
||||
|
||||
requireBlockedEvalsEnqueued(t, blocked, broker, 1)
|
||||
}
|
||||
|
||||
// Test the block case in which the eval should be immediately unblocked since
|
||||
// it is escaped and old
|
||||
func TestBlockedEvals_Block_ImmediateUnblock_Escaped(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, broker := testBlockedEvals(t)
|
||||
|
||||
// Do an unblock prior to blocking
|
||||
<-blocked.Unblock("v1:123", 1000)
|
||||
|
||||
// Create a blocked eval that is eligible on a specific node class and add
|
||||
// it to the blocked tracker.
|
||||
e := mock.BlockedEval()
|
||||
e.EscapedComputedClass = true
|
||||
e.SnapshotIndex = 900
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block caused the eval to be immediately unblocked
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 0, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 0, stats.BlockedResources.ByJob)
|
||||
|
||||
requireBlockedEvalsEnqueued(t, blocked, broker, 1)
|
||||
}
|
||||
|
||||
// Test the block case in which the eval should be immediately unblocked since
|
||||
// there is an unblock on an unseen class that occurred while it was in the
|
||||
// scheduler
|
||||
func TestBlockedEvals_Block_ImmediateUnblock_UnseenClass_After(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
blocked, broker := testBlockedEvals(t)
|
||||
|
||||
// Do an unblock prior to blocking
|
||||
<-blocked.Unblock("v1:123", 1000)
|
||||
|
||||
// Create a blocked eval that is eligible on a specific node class and add
|
||||
// it to the blocked tracker.
|
||||
e := mock.BlockedEval()
|
||||
e.EscapedComputedClass = false
|
||||
e.SnapshotIndex = 900
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block caused the eval to be immediately unblocked
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 0, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 0, stats.BlockedResources.ByJob)
|
||||
|
||||
requireBlockedEvalsEnqueued(t, blocked, broker, 1)
|
||||
}
|
||||
|
||||
// Test the block case in which the eval should not immediately unblock since
|
||||
// there is an unblock on an unseen class that occurred before it was in the
|
||||
// scheduler
|
||||
func TestBlockedEvals_Block_ImmediateUnblock_UnseenClass_Before(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, _ := testBlockedEvals(t)
|
||||
|
||||
// Do an unblock prior to blocking
|
||||
<-blocked.Unblock("v1:123", 500)
|
||||
|
||||
// Create a blocked eval that is eligible on a specific node class and add
|
||||
// it to the blocked tracker.
|
||||
e := mock.BlockedEval()
|
||||
e.EscapedComputedClass = false
|
||||
e.SnapshotIndex = 900
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block caused the eval to be immediately unblocked
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
}
|
||||
|
||||
// Test the block case in which the eval should be immediately unblocked since
|
||||
// it a class it is eligible for has been unblocked
|
||||
func TestBlockedEvals_Block_ImmediateUnblock_SeenClass(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, broker := testBlockedEvals(t)
|
||||
|
||||
// Do an unblock prior to blocking
|
||||
<-blocked.Unblock("v1:123", 1000)
|
||||
|
||||
// Create a blocked eval that is eligible on a specific node class and add
|
||||
// it to the blocked tracker.
|
||||
e := mock.BlockedEval()
|
||||
e.ClassEligibility = map[string]bool{"v1:123": true, "v1:456": false}
|
||||
e.SnapshotIndex = 900
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block caused the eval to be immediately unblocked
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 0, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 0, stats.BlockedResources.ByJob)
|
||||
|
||||
requireBlockedEvalsEnqueued(t, blocked, broker, 1)
|
||||
}
|
||||
|
||||
// Test the block case in which the eval should be immediately unblocked since
|
||||
// it a quota has changed that it is using
|
||||
func TestBlockedEvals_Block_ImmediateUnblock_Quota(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, broker := testBlockedEvals(t)
|
||||
|
||||
// Do an unblock prior to blocking
|
||||
<-blocked.UnblockQuota("my-quota", 1000)
|
||||
|
||||
// Create a blocked eval that is eligible on a specific node class and add
|
||||
// it to the blocked tracker.
|
||||
e := mock.BlockedEval()
|
||||
e.QuotaLimitReached = "my-quota"
|
||||
e.SnapshotIndex = 900
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block caused the eval to be immediately unblocked
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 0, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.Eq(t, 0, stats.TotalQuotaLimit)
|
||||
must.MapLen(t, 0, stats.BlockedResources.ByJob)
|
||||
|
||||
requireBlockedEvalsEnqueued(t, blocked, broker, 1)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_UnblockFailed(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
blocked, broker := testBlockedEvals(t)
|
||||
|
||||
// Create blocked evals that are due to failures
|
||||
e := mock.BlockedEval()
|
||||
e.TriggeredBy = structs.EvalTriggerMaxPlans
|
||||
e.EscapedComputedClass = true
|
||||
<-blocked.Block(e)
|
||||
|
||||
e2 := mock.BlockedEval()
|
||||
e2.Status = structs.EvalStatusBlocked
|
||||
e2.TriggeredBy = structs.EvalTriggerMaxPlans
|
||||
e2.ClassEligibility = map[string]bool{"v1:123": true, "v1:456": false}
|
||||
<-blocked.Block(e2)
|
||||
|
||||
e3 := mock.BlockedEval()
|
||||
e3.TriggeredBy = structs.EvalTriggerMaxPlans
|
||||
e3.QuotaLimitReached = "foo"
|
||||
<-blocked.Block(e3)
|
||||
|
||||
// Trigger an unblock fail
|
||||
blocked.UnblockFailed()
|
||||
|
||||
// Prune old and empty metrics.
|
||||
blocked.pruneStats(time.Now().UTC())
|
||||
|
||||
// Verify UnblockFailed caused the eval to be immediately unblocked
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 0, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.Eq(t, 0, stats.TotalQuotaLimit)
|
||||
must.MapLen(t, 0, stats.BlockedResources.ByJob)
|
||||
|
||||
requireBlockedEvalsEnqueued(t, blocked, broker, 3)
|
||||
|
||||
// Reblock an eval for the same job and check that it gets tracked.
|
||||
<-blocked.Block(e)
|
||||
stats = blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 1, stats.TotalEscaped)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_Untrack(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, _ := testBlockedEvals(t)
|
||||
|
||||
// Create blocked eval and add to the blocked tracker.
|
||||
e := mock.BlockedEval()
|
||||
e.ClassEligibility = map[string]bool{"v1:123": false, "v1:456": false}
|
||||
e.SnapshotIndex = 1000
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block did track
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
|
||||
// Untrack and verify
|
||||
<-blocked.Untrack(e.JobID, e.Namespace)
|
||||
blocked.pruneStats(time.Now().UTC())
|
||||
|
||||
stats = blocked.Stats()
|
||||
must.Eq(t, 0, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 0, stats.BlockedResources.ByJob)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_Untrack_Quota(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, _ := testBlockedEvals(t)
|
||||
|
||||
// Create a blocked eval and add it to the blocked tracker.
|
||||
e := mock.BlockedEval()
|
||||
e.QuotaLimitReached = "foo"
|
||||
e.SnapshotIndex = 1000
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block did track
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.Eq(t, 1, stats.TotalQuotaLimit)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
|
||||
// Untrack and verify
|
||||
<-blocked.Untrack(e.JobID, e.Namespace)
|
||||
stats = blocked.Stats()
|
||||
must.Eq(t, 0, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.Eq(t, 0, stats.TotalQuotaLimit)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
|
||||
now := time.Now().UTC()
|
||||
blocked.pruneStats(now)
|
||||
stats = blocked.Stats()
|
||||
must.Eq(t, 0, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.Eq(t, 0, stats.TotalQuotaLimit)
|
||||
must.MapLen(t, 0, stats.BlockedResources.ByJob)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_UnblockNode(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
blocked, broker := testBlockedEvals(t)
|
||||
|
||||
// Create a blocked evals and add it to the blocked tracker.
|
||||
e := mock.BlockedEval()
|
||||
e.Type = structs.JobTypeSystem
|
||||
e.NodeID = "foo"
|
||||
e.SnapshotIndex = 999
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block did track
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
|
||||
<-blocked.UnblockNode("foo")
|
||||
requireBlockedEvalsEnqueued(t, blocked, broker, 1)
|
||||
|
||||
blocked.pruneStats(time.Now().UTC())
|
||||
stats = blocked.Stats()
|
||||
must.MapEmpty(t, blocked.system.byNode)
|
||||
must.Eq(t, 0, stats.TotalBlocked)
|
||||
must.MapLen(t, 0, stats.BlockedResources.ByJob)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_SystemUntrack(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, _ := testBlockedEvals(t)
|
||||
|
||||
// Create a blocked evals and add it to the blocked tracker.
|
||||
e := mock.Eval()
|
||||
e.Type = structs.JobTypeSystem
|
||||
e.NodeID = "foo"
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block did track
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.Eq(t, 0, stats.TotalQuotaLimit)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
|
||||
// Untrack and verify
|
||||
<-blocked.Untrack(e.JobID, e.Namespace)
|
||||
blocked.pruneStats(time.Now().UTC())
|
||||
|
||||
stats = blocked.Stats()
|
||||
must.Eq(t, 0, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.Eq(t, 0, stats.TotalQuotaLimit)
|
||||
must.MapLen(t, 0, stats.BlockedResources.ByJob)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_SystemDisableFlush(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, _ := testBlockedEvals(t)
|
||||
|
||||
// Create a blocked evals and add it to the blocked tracker.
|
||||
e := mock.Eval()
|
||||
e.Type = structs.JobTypeSystem
|
||||
e.NodeID = "foo"
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block did track
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.Eq(t, 0, stats.TotalQuotaLimit)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
|
||||
// Disable empties
|
||||
blocked.SetEnabled(false)
|
||||
stats = blocked.Stats()
|
||||
must.Eq(t, 0, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.Eq(t, 0, stats.TotalQuotaLimit)
|
||||
must.MapLen(t, 0, stats.BlockedResources.ByJob)
|
||||
must.MapEmpty(t, blocked.system.evals)
|
||||
must.MapEmpty(t, blocked.system.byJob)
|
||||
must.MapEmpty(t, blocked.system.byNode)
|
||||
}
|
||||
|
||||
// TestBlockedEvals_UnblockBackpressure verifies that calling Unblock methods on
|
||||
// BlockedEvals doesn't result in backpressure that could impact the FSM.
|
||||
func TestBlockedEvals_UnblockBackpressure(t *testing.T) {
|
||||
pctx, cancel := context.WithCancel(context.TODO())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
broker, err := NewEvalBroker(pctx, time.Second, 1, 1, 1)
|
||||
must.NoError(t, err)
|
||||
broker.SetEnabled(true)
|
||||
blockedEvals := NewBlockedEvals(broker, testlog.HCLogger(t))
|
||||
blockedEvals.SetEnabled(true)
|
||||
|
||||
for range 10000 {
|
||||
eval := mock.Eval()
|
||||
eval.QueuedAllocations = map[string]int{"web": 1}
|
||||
eval.ClassEligibility = map[string]bool{"foo": false}
|
||||
eval.EscapedComputedClass = true
|
||||
eval.Status = structs.EvalStatusBlocked
|
||||
|
||||
<-blockedEvals.Block(eval)
|
||||
}
|
||||
stats := blockedEvals.Stats()
|
||||
must.Eq(t, 10000, stats.TotalBlocked)
|
||||
must.Eq(t, 10000, stats.TotalEscaped)
|
||||
|
||||
stopCtx, stopCancel := context.WithCancel(pctx)
|
||||
t.Cleanup(stopCancel)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errors := make(chan error, 144)
|
||||
|
||||
// this loop continuously calls unblock to trigger any contention we might
|
||||
// have; these calls should return quickly but be no-ops in watchCapacity
|
||||
// once we've unblocked all 10000 blocked evals
|
||||
//
|
||||
// Note that this test isn't particularly useful in CI where we have low
|
||||
// GOMAXPROCS. And with the fix it's intended to exercise, these Unblock
|
||||
// calls return too quickly for even highly concurrent continuous Reblock
|
||||
// loops to cause meaningful contention.
|
||||
for i := range 144 {
|
||||
wg.Go(func() {
|
||||
index := uint64(1000 * i)
|
||||
for {
|
||||
ctx, cancel := context.WithTimeout(stopCtx, 100*time.Millisecond)
|
||||
defer cancel()
|
||||
errCh := make(chan error)
|
||||
index++
|
||||
go func() {
|
||||
blockedEvals.Unblock("foo", index)
|
||||
close(errCh)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
errors <- fmt.Errorf("Unblock took more than 100ms to complete")
|
||||
}
|
||||
return
|
||||
case <-errCh:
|
||||
cancel()
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
// wait until all 10000 evals are unblocked or 5 seconds pass, whichever is
|
||||
// longer, and collect any errors from goroutines that would indicate
|
||||
// they've blocked for longer than expected
|
||||
timeout := time.After(time.Second * 5)
|
||||
poll := time.NewTicker(10 * time.Millisecond)
|
||||
errCount := 0
|
||||
var lastErr error
|
||||
|
||||
DONE:
|
||||
for {
|
||||
select {
|
||||
case <-timeout:
|
||||
break DONE
|
||||
case <-poll.C:
|
||||
stats = blockedEvals.Stats()
|
||||
if stats.TotalBlocked == 0 && stats.TotalEscaped == 0 {
|
||||
break DONE
|
||||
}
|
||||
case err := <-errors:
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
errCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
must.NoError(t, lastErr, must.Sprintf("got %d errors", errCount))
|
||||
stats = blockedEvals.Stats()
|
||||
must.Eq(t, 0, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
log "github.com/hashicorp/go-hclog"
|
||||
"github.com/hashicorp/go-msgpack/v2/codec"
|
||||
sframer "github.com/hashicorp/nomad/client/lib/streamframer"
|
||||
cstructs "github.com/hashicorp/nomad/client/structs"
|
||||
"github.com/hashicorp/nomad/command/agent/host"
|
||||
"github.com/hashicorp/nomad/command/agent/monitor"
|
||||
"github.com/hashicorp/nomad/command/agent/pprof"
|
||||
"github.com/hashicorp/nomad/helper/pointer"
|
||||
"github.com/hashicorp/nomad/nomad/peers"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
type Agent struct {
|
||||
srv *Server
|
||||
}
|
||||
|
||||
func NewAgentEndpoint(srv *Server) *Agent {
|
||||
return &Agent{srv: srv}
|
||||
}
|
||||
|
||||
func (a *Agent) register() {
|
||||
a.srv.streamingRpcs.Register("Agent.Monitor", a.monitor)
|
||||
a.srv.streamingRpcs.Register("Agent.MonitorExport", a.monitorExport)
|
||||
}
|
||||
|
||||
func (a *Agent) Profile(args *structs.AgentPprofRequest, reply *structs.AgentPprofResponse) error {
|
||||
authErr := a.srv.Authenticate(nil, args)
|
||||
a.srv.MeasureRPCRate("agent", structs.RateMetricRead, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Check ACL for agent write
|
||||
aclObj, err := a.srv.ResolveACL(args)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !aclObj.AllowAgentWrite() {
|
||||
// we're not checking AllowAgentDebug here because the target might not
|
||||
// be this server, and the server doesn't know if enable_debug has been
|
||||
// set on the target
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Forward to different region if necessary
|
||||
// this would typically be done in a.srv.forward() but since
|
||||
// we are targeting a specific server, not just the leader
|
||||
// we must manually handle region forwarding here.
|
||||
region := args.RequestRegion()
|
||||
if region == "" {
|
||||
return fmt.Errorf("missing target RPC")
|
||||
}
|
||||
|
||||
if region != a.srv.Region() {
|
||||
// Mark that we are forwarding
|
||||
args.SetForwarded()
|
||||
return a.srv.forwardRegion(region, "Agent.Profile", args, reply)
|
||||
}
|
||||
|
||||
// Targeting a node, forward request to node
|
||||
if args.NodeID != "" {
|
||||
return a.forwardProfileClient(args, reply)
|
||||
}
|
||||
|
||||
// Handle serverID not equal to ours
|
||||
if args.ServerID != "" {
|
||||
serverToFwd, err := a.forwardFor(args.ServerID, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if serverToFwd != nil {
|
||||
return a.srv.forwardServer(serverToFwd, "Agent.Profile", args, reply)
|
||||
}
|
||||
}
|
||||
|
||||
// This server is the target, so now we can check for AllowAgentDebug
|
||||
if !aclObj.AllowAgentDebug(a.srv.GetConfig().EnableDebug) {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Process the request on this server
|
||||
var resp []byte
|
||||
var headers map[string]string
|
||||
|
||||
// Determine which profile to run and generate profile.
|
||||
// Blocks for args.Seconds
|
||||
// Our RPC endpoints currently don't support context
|
||||
// or request cancellation so using server shutdownCtx as a
|
||||
// best effort.
|
||||
switch args.ReqType {
|
||||
case pprof.CPUReq:
|
||||
resp, headers, err = pprof.CPUProfile(a.srv.shutdownCtx, args.Seconds)
|
||||
case pprof.CmdReq:
|
||||
resp, headers, err = pprof.Cmdline()
|
||||
case pprof.LookupReq:
|
||||
resp, headers, err = pprof.Profile(args.Profile, args.Debug, args.GC)
|
||||
case pprof.TraceReq:
|
||||
resp, headers, err = pprof.Trace(a.srv.shutdownCtx, args.Seconds)
|
||||
default:
|
||||
err = structs.NewErrRPCCoded(404, "Unknown profile request type")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if pprof.IsErrProfileNotFound(err) {
|
||||
return structs.NewErrRPCCoded(404, err.Error())
|
||||
}
|
||||
return structs.NewErrRPCCoded(500, err.Error())
|
||||
}
|
||||
|
||||
// Copy profile response to reply
|
||||
reply.Payload = resp
|
||||
reply.HTTPHeaders = headers
|
||||
reply.AgentID = a.srv.serf.LocalMember().Name
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Agent) monitor(conn io.ReadWriteCloser) {
|
||||
defer conn.Close()
|
||||
|
||||
// Decode args
|
||||
var args cstructs.MonitorRequest
|
||||
decoder := codec.NewDecoder(conn, structs.MsgpackHandle)
|
||||
encoder := codec.NewEncoder(conn, structs.MsgpackHandle)
|
||||
|
||||
if err := decoder.Decode(&args); err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(500)), encoder)
|
||||
return
|
||||
}
|
||||
authErr := a.srv.Authenticate(nil, &args)
|
||||
a.srv.MeasureRPCRate("agent", structs.RateMetricRead, &args)
|
||||
if authErr != nil {
|
||||
handleStreamResultError(structs.ErrPermissionDenied, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Check agent read permissions
|
||||
if aclObj, err := a.srv.ResolveACL(&args); err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
} else if !aclObj.AllowAgentRead() {
|
||||
handleStreamResultError(structs.ErrPermissionDenied, pointer.Of(int64(403)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
logLevel := log.LevelFromString(args.LogLevel)
|
||||
if args.LogLevel == "" {
|
||||
logLevel = log.LevelFromString("INFO")
|
||||
}
|
||||
|
||||
if logLevel == log.NoLevel {
|
||||
handleStreamResultError(errors.New("Unknown log level"), pointer.Of(int64(400)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Targeting a node, forward request to node
|
||||
if args.NodeID != "" {
|
||||
a.forwardMonitorClient(conn, args, encoder, decoder, args.NodeID, "Agent.Monitor")
|
||||
// forwarded request has ended, return
|
||||
return
|
||||
}
|
||||
|
||||
region := args.RequestRegion()
|
||||
if region == "" {
|
||||
handleStreamResultError(fmt.Errorf("missing target region"), pointer.Of(int64(400)), encoder)
|
||||
return
|
||||
}
|
||||
if region != a.srv.Region() {
|
||||
// Mark that we are forwarding
|
||||
args.SetForwarded()
|
||||
}
|
||||
|
||||
// Try to forward request to remote region/server
|
||||
if args.ServerID != "" {
|
||||
serverToFwd, err := a.forwardFor(args.ServerID, region)
|
||||
if err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(400)), encoder)
|
||||
return
|
||||
}
|
||||
if serverToFwd != nil {
|
||||
// Empty ServerID to prevent forwarding loop
|
||||
args.ServerID = ""
|
||||
a.forwardMonitorServer(conn, serverToFwd, args, encoder, decoder, "Agent.Monitor")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// NodeID was empty, ServerID was equal to this server, monitor this server
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
m := monitor.New(512, a.srv.logger, &log.LoggerOptions{
|
||||
Level: logLevel,
|
||||
JSONFormat: args.LogJSON,
|
||||
IncludeLocation: args.LogIncludeLocation,
|
||||
})
|
||||
|
||||
frames := make(chan *sframer.StreamFrame, 32)
|
||||
errCh := make(chan error)
|
||||
var buf bytes.Buffer
|
||||
frameCodec := codec.NewEncoder(&buf, structs.JsonHandle)
|
||||
|
||||
framer := sframer.NewStreamFramer(frames, 1*time.Second, 200*time.Millisecond, 1024)
|
||||
framer.Run()
|
||||
defer framer.Destroy()
|
||||
|
||||
// goroutine to detect remote side closing
|
||||
go func() {
|
||||
if _, err := conn.Read(nil); err != nil {
|
||||
// One end of the pipe explicitly closed, exit
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
<-ctx.Done()
|
||||
}()
|
||||
|
||||
logCh := m.Start()
|
||||
defer m.Stop()
|
||||
initialOffset := int64(0)
|
||||
|
||||
// receive logs and build frames
|
||||
go func() {
|
||||
defer framer.Destroy()
|
||||
LOOP:
|
||||
for {
|
||||
select {
|
||||
case log := <-logCh:
|
||||
if err := framer.Send("", "log", log, initialOffset); err != nil {
|
||||
select {
|
||||
case errCh <- err:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
break LOOP
|
||||
}
|
||||
case <-ctx.Done():
|
||||
break LOOP
|
||||
}
|
||||
}
|
||||
}()
|
||||
streamEncoder := monitor.NewStreamEncoder(&buf, conn, encoder, frameCodec, args.PlainText)
|
||||
streamErr := streamEncoder.EncodeStream(frames, errCh, ctx, framer, false)
|
||||
if streamErr != nil {
|
||||
handleStreamResultError(streamErr, pointer.Of(int64(500)), encoder)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) monitorExport(conn io.ReadWriteCloser) {
|
||||
defer conn.Close()
|
||||
// Decode args
|
||||
var args cstructs.MonitorExportRequest
|
||||
decoder := codec.NewDecoder(conn, structs.MsgpackHandle)
|
||||
encoder := codec.NewEncoder(conn, structs.MsgpackHandle)
|
||||
|
||||
if err := decoder.Decode(&args); err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(500)), encoder)
|
||||
return
|
||||
}
|
||||
authErr := a.srv.Authenticate(nil, &args)
|
||||
a.srv.MeasureRPCRate("agent", structs.RateMetricRead, &args)
|
||||
if authErr != nil {
|
||||
handleStreamResultError(structs.ErrPermissionDenied, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Check agent read permissions
|
||||
if aclObj, err := a.srv.ResolveACL(&args); err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
} else if !aclObj.AllowAgentRead() {
|
||||
handleStreamResultError(structs.ErrPermissionDenied, pointer.Of(int64(403)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Targeting a node, forward request to node
|
||||
if args.NodeID != "" {
|
||||
a.forwardMonitorClient(conn, args, encoder, decoder, args.NodeID, "Agent.MonitorExport")
|
||||
// forwarded request has ended, return
|
||||
return
|
||||
}
|
||||
|
||||
region := args.RequestRegion()
|
||||
if region == "" {
|
||||
handleStreamResultError(fmt.Errorf("missing target region"), pointer.Of(int64(400)), encoder)
|
||||
return
|
||||
}
|
||||
if region != a.srv.Region() {
|
||||
// Mark that we are forwarding
|
||||
args.SetForwarded()
|
||||
}
|
||||
|
||||
// Try to forward request to remote region/server
|
||||
if args.ServerID != "" {
|
||||
serverToFwd, err := a.forwardFor(args.ServerID, region)
|
||||
if err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(400)), encoder)
|
||||
return
|
||||
}
|
||||
if serverToFwd != nil {
|
||||
//empty args.ServerID to prevent forwarding loop
|
||||
args.ServerID = ""
|
||||
a.forwardMonitorServer(conn, serverToFwd, args, encoder, decoder, "Agent.MonitorExport")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
nomadLogPath := a.srv.GetConfig().LogFile
|
||||
if args.OnDisk && nomadLogPath == "" {
|
||||
handleStreamResultError(errors.New("No nomad log file defined"), pointer.Of(int64(400)), encoder)
|
||||
}
|
||||
// NodeID was empty, ServerID was equal to this server, monitor this server
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
opts := monitor.MonitorExportOpts{
|
||||
Logger: a.srv.logger,
|
||||
LogsSince: args.LogsSince,
|
||||
ServiceName: args.ServiceName,
|
||||
NomadLogPath: nomadLogPath,
|
||||
OnDisk: args.OnDisk,
|
||||
Follow: args.Follow,
|
||||
Context: ctx,
|
||||
}
|
||||
|
||||
frames := make(chan *sframer.StreamFrame, 32)
|
||||
errCh := make(chan error)
|
||||
var buf bytes.Buffer
|
||||
frameSize := 1024
|
||||
frameCodec := codec.NewEncoder(&buf, structs.JsonHandle)
|
||||
|
||||
framer := sframer.NewStreamFramer(frames, 1*time.Second, 200*time.Millisecond, frameSize)
|
||||
framer.Run()
|
||||
defer framer.Destroy()
|
||||
|
||||
// goroutine to detect remote side closing
|
||||
go func() {
|
||||
if _, err := conn.Read(nil); err != nil {
|
||||
// One end of the pipe explicitly closed, exit
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
<-ctx.Done()
|
||||
}()
|
||||
m, err := monitor.NewExportMonitor(opts)
|
||||
if err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(500)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
var eofCancelCh chan error
|
||||
|
||||
streamCh := m.Start()
|
||||
initialOffset := int64(0)
|
||||
eofCancel := !opts.Follow
|
||||
|
||||
streamEncoder := monitor.NewStreamEncoder(&buf, conn, encoder, frameCodec, args.PlainText)
|
||||
// receive logs and build frames
|
||||
streamReader := monitor.NewStreamReader(streamCh, framer, int64(frameSize))
|
||||
go func() {
|
||||
defer framer.Destroy()
|
||||
if err := streamReader.StreamFixed(ctx, initialOffset, "", 0, eofCancelCh, eofCancel); err != nil {
|
||||
select {
|
||||
case errCh <- err:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
streamErr := streamEncoder.EncodeStream(frames, errCh, ctx, framer, true)
|
||||
if streamErr != nil {
|
||||
handleStreamResultError(streamErr, pointer.Of(int64(500)), encoder)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// forwardFor returns a serverParts for a request to be forwarded to.
|
||||
// A response of nil, nil indicates that the current server is equal to the
|
||||
// serverID and region so the request should not be forwarded.
|
||||
func (a *Agent) forwardFor(serverID, region string) (*peers.Parts, error) {
|
||||
var target *peers.Parts
|
||||
var err error
|
||||
|
||||
if serverID == "leader" {
|
||||
isLeader, remoteLeader := a.srv.getLeader()
|
||||
if !isLeader && remoteLeader != nil {
|
||||
target = remoteLeader
|
||||
} else if !isLeader && remoteLeader == nil {
|
||||
return nil, structs.ErrNoLeader
|
||||
} else if isLeader {
|
||||
// This server is current leader do not forward
|
||||
return nil, nil
|
||||
}
|
||||
} else {
|
||||
target, err = a.srv.getServer(region, serverID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Unable to find a server
|
||||
if target == nil {
|
||||
return nil, fmt.Errorf("unknown nomad server %s", serverID)
|
||||
}
|
||||
|
||||
// ServerID is this current server,
|
||||
// No need to forward request
|
||||
if target.Name == a.srv.LocalMember().Name {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (a *Agent) forwardMonitorClient(conn io.ReadWriteCloser, args any, encoder *codec.Encoder, decoder *codec.Decoder, nodeID string, endpoint string) {
|
||||
// Get the Connection to the client either by fowarding to another server
|
||||
// or creating direct stream
|
||||
state, srv, err := a.findClientConn(nodeID)
|
||||
if err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(500)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
var clientConn net.Conn
|
||||
|
||||
if state == nil {
|
||||
conn, err := a.srv.streamingRpc(srv, endpoint)
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
clientConn = conn
|
||||
} else {
|
||||
stream, err := NodeStreamingRpc(state.Session, endpoint)
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
clientConn = stream
|
||||
}
|
||||
defer clientConn.Close()
|
||||
|
||||
// Send the Request
|
||||
outEncoder := codec.NewEncoder(clientConn, structs.MsgpackHandle)
|
||||
if err := outEncoder.Encode(args); err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
structs.Bridge(conn, clientConn)
|
||||
}
|
||||
|
||||
func (a *Agent) forwardMonitorServer(conn io.ReadWriteCloser, server *peers.Parts, args any, encoder *codec.Encoder, decoder *codec.Decoder, endpoint string) {
|
||||
serverConn, err := a.srv.streamingRpc(server, "Agent.Monitor")
|
||||
if err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(500)), encoder)
|
||||
return
|
||||
}
|
||||
defer serverConn.Close()
|
||||
|
||||
// Send the Request
|
||||
outEncoder := codec.NewEncoder(serverConn, structs.MsgpackHandle)
|
||||
if err := outEncoder.Encode(args); err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(500)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
structs.Bridge(conn, serverConn)
|
||||
}
|
||||
|
||||
func (a *Agent) forwardProfileClient(args *structs.AgentPprofRequest, reply *structs.AgentPprofResponse) error {
|
||||
state, srv, err := a.findClientConn(args.NodeID)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if srv != nil {
|
||||
return a.srv.forwardServer(srv, "Agent.Profile", args, reply)
|
||||
}
|
||||
|
||||
// NodeRpc
|
||||
rpcErr := NodeRpc(state.Session, "Agent.Profile", args, reply)
|
||||
if rpcErr != nil {
|
||||
return rpcErr
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Host returns data about the agent's host system for the `debug` command.
|
||||
func (a *Agent) Host(args *structs.HostDataRequest, reply *structs.HostDataResponse) error {
|
||||
authErr := a.srv.Authenticate(nil, args)
|
||||
a.srv.MeasureRPCRate("agent", structs.RateMetricRead, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
aclObj, err := a.srv.ResolveACL(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !aclObj.AllowAgentDebug(a.srv.GetConfig().EnableDebug) {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Forward to different region if necessary
|
||||
// this would typically be done in a.srv.forward() but since
|
||||
// we are targeting a specific server, not just the leader
|
||||
// we must manually handle region forwarding here.
|
||||
region := args.RequestRegion()
|
||||
if region == "" {
|
||||
return fmt.Errorf("missing target RPC")
|
||||
}
|
||||
|
||||
if region != a.srv.Region() {
|
||||
// Mark that we are forwarding
|
||||
args.SetForwarded()
|
||||
return a.srv.forwardRegion(region, "Agent.Host", args, reply)
|
||||
}
|
||||
|
||||
// Targeting a client node, forward request to node
|
||||
if args.NodeID != "" {
|
||||
client, srv, err := a.findClientConn(args.NodeID)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if srv != nil {
|
||||
return a.srv.forwardServer(srv, "Agent.Host", args, reply)
|
||||
}
|
||||
|
||||
return NodeRpc(client.Session, "Agent.Host", args, reply)
|
||||
}
|
||||
|
||||
// Handle serverID not equal to ours
|
||||
if args.ServerID != "" {
|
||||
srv, err := a.forwardFor(args.ServerID, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if srv != nil {
|
||||
return a.srv.forwardServer(srv, "Agent.Host", args, reply)
|
||||
}
|
||||
}
|
||||
|
||||
data, err := host.MakeHostData()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reply.AgentID = a.srv.serf.LocalMember().Name
|
||||
reply.HostData = data
|
||||
return nil
|
||||
}
|
||||
|
||||
// findClientConn is a helper that returns a connection to the client node or, if the client
|
||||
// is connected to a different server, a serverParts describing the server to which the
|
||||
// client bound RPC should be forwarded.
|
||||
func (a *Agent) findClientConn(nodeID string) (*nodeConnState, *peers.Parts, error) {
|
||||
snap, err := a.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
return nil, nil, structs.NewErrRPCCoded(500, err.Error())
|
||||
}
|
||||
|
||||
node, err := snap.NodeByID(nil, nodeID)
|
||||
if err != nil {
|
||||
return nil, nil, structs.NewErrRPCCoded(500, err.Error())
|
||||
}
|
||||
|
||||
if node == nil {
|
||||
err := fmt.Errorf("Unknown node %q", nodeID)
|
||||
return nil, nil, structs.NewErrRPCCoded(404, err.Error())
|
||||
}
|
||||
|
||||
if err := nodeSupportsRpc(node); err != nil {
|
||||
return nil, nil, structs.NewErrRPCCoded(400, err.Error())
|
||||
}
|
||||
|
||||
// Get the Connection to the client either by fowarding to another server
|
||||
// or creating direct stream
|
||||
state, ok := a.srv.getNodeConn(nodeID)
|
||||
if ok {
|
||||
return state, nil, nil
|
||||
}
|
||||
|
||||
// Determine the server that has a connection to the node
|
||||
srv, err := a.srv.serverWithNodeConn(nodeID, a.srv.Region())
|
||||
if err != nil {
|
||||
code := 500
|
||||
if structs.IsErrNoNodeConn(err) {
|
||||
code = 404
|
||||
}
|
||||
return nil, nil, structs.NewErrRPCCoded(code, err.Error())
|
||||
}
|
||||
|
||||
return nil, srv, nil
|
||||
}
|
||||
+1106
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,639 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-hclog"
|
||||
metrics "github.com/hashicorp/go-metrics/compat"
|
||||
"github.com/hashicorp/go-msgpack/v2/codec"
|
||||
|
||||
"github.com/hashicorp/nomad/acl"
|
||||
cstructs "github.com/hashicorp/nomad/client/structs"
|
||||
"github.com/hashicorp/nomad/helper/pointer"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
// ClientAllocations is used to forward RPC requests to the targeted Nomad client's
|
||||
// Allocation endpoint.
|
||||
type ClientAllocations struct {
|
||||
srv *Server
|
||||
logger hclog.Logger
|
||||
}
|
||||
|
||||
func NewClientAllocationsEndpoint(srv *Server) *ClientAllocations {
|
||||
return &ClientAllocations{srv: srv, logger: srv.logger.Named("client_allocs")}
|
||||
}
|
||||
|
||||
func (a *ClientAllocations) register() {
|
||||
a.srv.streamingRpcs.Register("Allocations.Exec", a.exec)
|
||||
}
|
||||
|
||||
// GarbageCollectAll is used to garbage collect all allocations on a client.
|
||||
func (a *ClientAllocations) GarbageCollectAll(args *structs.NodeSpecificRequest, reply *structs.GenericResponse) error {
|
||||
// We only allow stale reads since the only potentially stale information is
|
||||
// the Node registration and the cost is fairly high for adding another hop
|
||||
// in the forwarding chain.
|
||||
args.QueryOptions.AllowStale = true
|
||||
|
||||
authErr := a.srv.Authenticate(nil, args)
|
||||
|
||||
// Potentially forward to a different region.
|
||||
if done, err := a.srv.forward("ClientAllocations.GarbageCollectAll", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
a.srv.MeasureRPCRate("client_allocations", structs.RateMetricWrite, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_allocations", "garbage_collect_all"}, time.Now())
|
||||
|
||||
// Check node read permissions
|
||||
if aclObj, err := a.srv.ResolveACL(args); err != nil {
|
||||
return err
|
||||
} else if !aclObj.AllowNodeWrite() {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Verify the arguments.
|
||||
if args.NodeID == "" {
|
||||
return errors.New("missing NodeID")
|
||||
}
|
||||
|
||||
// Make sure Node is valid and new enough to support RPC
|
||||
snap, err := a.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = getNodeForRpc(snap, args.NodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the connection to the client
|
||||
state, ok := a.srv.getNodeConn(args.NodeID)
|
||||
if !ok {
|
||||
return findNodeConnAndForward(a.srv, args.NodeID, "ClientAllocations.GarbageCollectAll", args, reply)
|
||||
}
|
||||
|
||||
// Make the RPC
|
||||
return NodeRpc(state.Session, "Allocations.GarbageCollectAll", args, reply)
|
||||
}
|
||||
|
||||
// Signal is used to send a signal to an allocation on a client.
|
||||
func (a *ClientAllocations) Signal(args *structs.AllocSignalRequest, reply *structs.GenericResponse) error {
|
||||
// We only allow stale reads since the only potentially stale information is
|
||||
// the Node registration and the cost is fairly high for adding another hope
|
||||
// in the forwarding chain.
|
||||
args.QueryOptions.AllowStale = true
|
||||
|
||||
authErr := a.srv.Authenticate(nil, args)
|
||||
|
||||
// Potentially forward to a different region.
|
||||
if done, err := a.srv.forward("ClientAllocations.Signal", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
a.srv.MeasureRPCRate("client_allocations", structs.RateMetricWrite, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_allocations", "signal"}, time.Now())
|
||||
|
||||
// Verify the arguments.
|
||||
if args.AllocID == "" {
|
||||
return errors.New("missing AllocID")
|
||||
}
|
||||
|
||||
// Find the allocation
|
||||
snap, err := a.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
alloc, err := getAlloc(snap, args.AllocID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check namespace alloc-lifecycle permission.
|
||||
if aclObj, err := a.srv.ResolveACL(args); err != nil {
|
||||
return err
|
||||
} else if !aclObj.AllowNsOp(alloc.Namespace, acl.NamespaceCapabilityAllocLifecycle) {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Make sure Node is valid and new enough to support RPC
|
||||
_, err = getNodeForRpc(snap, alloc.NodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the connection to the client
|
||||
state, ok := a.srv.getNodeConn(alloc.NodeID)
|
||||
if !ok {
|
||||
return findNodeConnAndForward(a.srv, alloc.NodeID, "ClientAllocations.Signal", args, reply)
|
||||
}
|
||||
|
||||
// Make the RPC
|
||||
return NodeRpc(state.Session, "Allocations.Signal", args, reply)
|
||||
}
|
||||
|
||||
func (a *ClientAllocations) SetPauseState(args *structs.AllocPauseRequest, reply *structs.GenericResponse) error {
|
||||
args.QueryOptions.AllowStale = true
|
||||
authErr := a.srv.Authenticate(nil, args)
|
||||
|
||||
// Potentially forward to a different region.
|
||||
if done, err := a.srv.forward("ClientAllocations.SetPauseState", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
a.srv.MeasureRPCRate("client_allocations", structs.RateMetricWrite, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_allocations", "pause_set"}, time.Now())
|
||||
|
||||
// Verify the arguments.
|
||||
if args.AllocID == "" {
|
||||
return errors.New("missing AllocID")
|
||||
}
|
||||
|
||||
// Find the allocation.
|
||||
snap, err := a.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
alloc, err := getAlloc(snap, args.AllocID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check namespace submit-job permission.
|
||||
if aclObj, err := a.srv.ResolveACL(args); err != nil {
|
||||
return err
|
||||
} else if !aclObj.AllowNsOpAnyOf(alloc.Namespace,
|
||||
acl.NamespaceCapabilitySubmitJob,
|
||||
acl.NamespaceCapabilityPauseAllocation,
|
||||
) {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Make sure the node is valid and new enough to support RPC
|
||||
_, err = getNodeForRpc(snap, alloc.NodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the connection to the client
|
||||
state, ok := a.srv.getNodeConn(alloc.NodeID)
|
||||
if !ok {
|
||||
return findNodeConnAndForward(a.srv, alloc.NodeID, "ClientAllocations.SetPauseState", args, reply)
|
||||
}
|
||||
|
||||
// Make the RPC
|
||||
return NodeRpc(state.Session, "Allocations.SetPauseState", args, reply)
|
||||
}
|
||||
|
||||
func (a *ClientAllocations) GetPauseState(args *structs.AllocGetPauseStateRequest, reply *structs.AllocGetPauseStateResponse) error {
|
||||
args.QueryOptions.AllowStale = true
|
||||
authErr := a.srv.Authenticate(nil, args)
|
||||
|
||||
// Potentially forward to a different region.
|
||||
if done, err := a.srv.forward("ClientAllocations.GetPauseState", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
a.srv.MeasureRPCRate("client_allocations", structs.RateMetricRead, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_allocations", "pause_get"}, time.Now())
|
||||
|
||||
// Verify the arguments.
|
||||
if args.AllocID == "" {
|
||||
return errors.New("missing AllocID")
|
||||
}
|
||||
|
||||
// Find the allocation.
|
||||
snap, err := a.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
alloc, err := getAlloc(snap, args.AllocID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check namespace read-job permission.
|
||||
if aclObj, err := a.srv.ResolveACL(args); err != nil {
|
||||
return err
|
||||
} else if !aclObj.AllowNsOp(alloc.Namespace, acl.NamespaceCapabilityReadJob) {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Make sure the node is valid and new enough to support RPC
|
||||
_, err = getNodeForRpc(snap, alloc.NodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the connection to the client
|
||||
state, ok := a.srv.getNodeConn(alloc.NodeID)
|
||||
if !ok {
|
||||
return findNodeConnAndForward(a.srv, alloc.NodeID, "ClientAllocations.GetPauseState", args, reply)
|
||||
}
|
||||
|
||||
// Make the RPC
|
||||
return NodeRpc(state.Session, "Allocations.GetPauseState", args, reply)
|
||||
}
|
||||
|
||||
// GarbageCollect is used to garbage collect an allocation on a client.
|
||||
func (a *ClientAllocations) GarbageCollect(args *structs.AllocSpecificRequest, reply *structs.GenericResponse) error {
|
||||
// We only allow stale reads since the only potentially stale information is
|
||||
// the Node registration and the cost is fairly high for adding another hop
|
||||
// in the forwarding chain.
|
||||
args.QueryOptions.AllowStale = true
|
||||
|
||||
authErr := a.srv.Authenticate(nil, args)
|
||||
|
||||
// Potentially forward to a different region.
|
||||
if done, err := a.srv.forward("ClientAllocations.GarbageCollect", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
a.srv.MeasureRPCRate("client_allocations", structs.RateMetricWrite, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_allocations", "garbage_collect"}, time.Now())
|
||||
|
||||
// Verify the arguments.
|
||||
if args.AllocID == "" {
|
||||
return errors.New("missing AllocID")
|
||||
}
|
||||
|
||||
// Find the allocation
|
||||
snap, err := a.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
alloc, err := getAlloc(snap, args.AllocID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check namespace submit-job permission.
|
||||
if aclObj, err := a.srv.ResolveACL(args); err != nil {
|
||||
return err
|
||||
} else if !aclObj.AllowNsOpAnyOf(alloc.Namespace,
|
||||
acl.NamespaceCapabilitySubmitJob,
|
||||
acl.NamespaceCapabilityGCAllocation,
|
||||
) {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Make sure Node is valid and new enough to support RPC
|
||||
_, err = getNodeForRpc(snap, alloc.NodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the connection to the client
|
||||
state, ok := a.srv.getNodeConn(alloc.NodeID)
|
||||
if !ok {
|
||||
return findNodeConnAndForward(a.srv, alloc.NodeID, "ClientAllocations.GarbageCollect", args, reply)
|
||||
}
|
||||
|
||||
// Make the RPC
|
||||
return NodeRpc(state.Session, "Allocations.GarbageCollect", args, reply)
|
||||
}
|
||||
|
||||
// Restart is used to trigger a restart of an allocation or a subtask on a client.
|
||||
func (a *ClientAllocations) Restart(args *structs.AllocRestartRequest, reply *structs.GenericResponse) error {
|
||||
// We only allow stale reads since the only potentially stale information is
|
||||
// the Node registration and the cost is fairly high for adding another hop
|
||||
// in the forwarding chain.
|
||||
args.QueryOptions.AllowStale = true
|
||||
|
||||
authErr := a.srv.Authenticate(nil, args)
|
||||
|
||||
// Potentially forward to a different region.
|
||||
if done, err := a.srv.forward("ClientAllocations.Restart", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
a.srv.MeasureRPCRate("client_allocations", structs.RateMetricWrite, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_allocations", "restart"}, time.Now())
|
||||
|
||||
// Find the allocation
|
||||
snap, err := a.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
alloc, err := getAlloc(snap, args.AllocID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check for namespace alloc-lifecycle permissions.
|
||||
if aclObj, err := a.srv.ResolveACL(args); err != nil {
|
||||
return err
|
||||
} else if !aclObj.AllowNsOp(alloc.Namespace, acl.NamespaceCapabilityAllocLifecycle) {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Make sure Node is valid and new enough to support RPC
|
||||
_, err = getNodeForRpc(snap, alloc.NodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the connection to the client
|
||||
state, ok := a.srv.getNodeConn(alloc.NodeID)
|
||||
if !ok {
|
||||
return findNodeConnAndForward(a.srv, alloc.NodeID, "ClientAllocations.Restart", args, reply)
|
||||
}
|
||||
|
||||
// Make the RPC
|
||||
return NodeRpc(state.Session, "Allocations.Restart", args, reply)
|
||||
}
|
||||
|
||||
// Stats is used to collect allocation statistics
|
||||
func (a *ClientAllocations) Stats(args *cstructs.AllocStatsRequest, reply *cstructs.AllocStatsResponse) error {
|
||||
// We only allow stale reads since the only potentially stale information is
|
||||
// the Node registration and the cost is fairly high for adding another hop
|
||||
// in the forwarding chain.
|
||||
args.QueryOptions.AllowStale = true
|
||||
|
||||
authErr := a.srv.Authenticate(nil, args)
|
||||
|
||||
// Potentially forward to a different region.
|
||||
if done, err := a.srv.forward("ClientAllocations.Stats", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
a.srv.MeasureRPCRate("client_allocations", structs.RateMetricRead, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_allocations", "stats"}, time.Now())
|
||||
|
||||
// Find the allocation
|
||||
snap, err := a.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
alloc, err := getAlloc(snap, args.AllocID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check for namespace read-job permissions.
|
||||
if aclObj, err := a.srv.ResolveACL(args); err != nil {
|
||||
return err
|
||||
} else if !aclObj.AllowNsOp(alloc.Namespace, acl.NamespaceCapabilityReadJob) {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Make sure Node is valid and new enough to support RPC
|
||||
_, err = getNodeForRpc(snap, alloc.NodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the connection to the client
|
||||
state, ok := a.srv.getNodeConn(alloc.NodeID)
|
||||
if !ok {
|
||||
return findNodeConnAndForward(a.srv, alloc.NodeID, "ClientAllocations.Stats", args, reply)
|
||||
}
|
||||
|
||||
// Make the RPC
|
||||
return NodeRpc(state.Session, "Allocations.Stats", args, reply)
|
||||
}
|
||||
|
||||
// Checks is the server implementation of the allocation checks RPC. The
|
||||
// ultimate response is provided by the node running the allocation. This RPC
|
||||
// is needed to handle queries which hit the server agent API directly, or via
|
||||
// another node which is not running the allocation.
|
||||
func (a *ClientAllocations) Checks(args *cstructs.AllocChecksRequest, reply *cstructs.AllocChecksResponse) error {
|
||||
|
||||
// We only allow stale reads since the only potentially stale information
|
||||
// is the Node registration and the cost is fairly high for adding another
|
||||
// hop in the forwarding chain.
|
||||
args.QueryOptions.AllowStale = true
|
||||
|
||||
authErr := a.srv.Authenticate(nil, args)
|
||||
|
||||
// Potentially forward to a different region.
|
||||
if done, err := a.srv.forward("ClientAllocations.Checks", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
a.srv.MeasureRPCRate("client_allocations", structs.RateMetricRead, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_allocations", "checks"}, time.Now())
|
||||
|
||||
// Grab the state snapshot, as we need this to perform lookups for a number
|
||||
// of objects, all things being well.
|
||||
snap, err := a.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the full allocation object, so we have information such as the
|
||||
// namespace and node ID.
|
||||
alloc, err := getAlloc(snap, args.AllocID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check for namespace read-job permissions.
|
||||
if aclObj, err := a.srv.ResolveACL(args); err != nil {
|
||||
return err
|
||||
} else if !aclObj.AllowNsOp(alloc.Namespace, acl.NamespaceCapabilityReadJob) {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Make sure Node is valid and new enough to support RPC.
|
||||
if _, err = getNodeForRpc(snap, alloc.NodeID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the connection to the client.
|
||||
state, ok := a.srv.getNodeConn(alloc.NodeID)
|
||||
if !ok {
|
||||
return findNodeConnAndForward(a.srv, alloc.NodeID, "ClientAllocations.Checks", args, reply)
|
||||
}
|
||||
|
||||
// Make the RPC
|
||||
return NodeRpc(state.Session, "Allocations.Checks", args, reply)
|
||||
}
|
||||
|
||||
// exec is used to execute command in a running task
|
||||
func (a *ClientAllocations) exec(conn io.ReadWriteCloser) {
|
||||
defer conn.Close()
|
||||
defer metrics.MeasureSince([]string{"nomad", "alloc", "exec"}, time.Now())
|
||||
|
||||
// Decode the arguments
|
||||
var args cstructs.AllocExecRequest
|
||||
decoder := codec.NewDecoder(conn, structs.MsgpackHandle)
|
||||
encoder := codec.NewEncoder(conn, structs.MsgpackHandle)
|
||||
|
||||
if err := decoder.Decode(&args); err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(500)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
authErr := a.srv.Authenticate(nil, &args)
|
||||
|
||||
// Check if we need to forward to a different region
|
||||
if r := args.RequestRegion(); r != a.srv.Region() {
|
||||
forwardRegionStreamingRpc(a.srv, conn, encoder, &args, "Allocations.Exec",
|
||||
args.AllocID, &args.QueryOptions)
|
||||
return
|
||||
}
|
||||
a.srv.MeasureRPCRate("client_allocations", structs.RateMetricWrite, &args)
|
||||
if authErr != nil {
|
||||
handleStreamResultError(structs.ErrPermissionDenied, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the arguments.
|
||||
if args.AllocID == "" {
|
||||
handleStreamResultError(errors.New("missing AllocID"), pointer.Of(int64(400)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Retrieve the allocation
|
||||
snap, err := a.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
alloc, err := getAlloc(snap, args.AllocID)
|
||||
if structs.IsErrUnknownAllocation(err) {
|
||||
handleStreamResultError(err, pointer.Of(int64(404)), encoder)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Check node read permissions
|
||||
if aclObj, err := a.srv.ResolveACL(&args); err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
} else if !aclObj.AllowNsOp(alloc.Namespace, acl.NamespaceCapabilityAllocExec) {
|
||||
// client ultimately checks if AllocNodeExec is required
|
||||
handleStreamResultError(structs.ErrPermissionDenied, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
if alloc.ClientTerminalStatus() {
|
||||
handleStreamResultError(fmt.Errorf("exec not possible, client status of allocation %s is %s", alloc.ID, alloc.ClientStatus),
|
||||
pointer.Of(int64(http.StatusBadRequest)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle job ID if requested.
|
||||
if args.JobID != "" {
|
||||
// Verify job exists.
|
||||
job, err := snap.JobByID(nil, args.Namespace, args.JobID)
|
||||
if err != nil {
|
||||
handleStreamResultError(err,
|
||||
pointer.Of(int64(http.StatusInternalServerError)), encoder)
|
||||
return
|
||||
}
|
||||
if job == nil {
|
||||
handleStreamResultError(
|
||||
fmt.Errorf("job %s not found in namespace %s", args.JobID, args.Namespace),
|
||||
pointer.Of(int64(http.StatusNotFound)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify requested allocation belongs to the job.
|
||||
if args.JobID != alloc.JobID {
|
||||
handleStreamResultError(
|
||||
fmt.Errorf("job %s does not have allocation %s", args.JobID, alloc.ID),
|
||||
pointer.Of(int64(http.StatusBadRequest)), encoder,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
nodeID := alloc.NodeID
|
||||
|
||||
// Make sure Node is valid and new enough to support RPC
|
||||
node, err := snap.NodeByID(nil, nodeID)
|
||||
if err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(500)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
if node == nil {
|
||||
err := fmt.Errorf("Unknown node %q", nodeID)
|
||||
handleStreamResultError(err, pointer.Of(int64(400)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
if err := nodeSupportsRpc(node); err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(400)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the connection to the client either by forwarding to another server
|
||||
// or creating a direct stream
|
||||
var clientConn net.Conn
|
||||
state, ok := a.srv.getNodeConn(nodeID)
|
||||
if !ok {
|
||||
// Determine the Server that has a connection to the node.
|
||||
srv, err := a.srv.serverWithNodeConn(nodeID, a.srv.Region())
|
||||
if err != nil {
|
||||
var code *int64
|
||||
if structs.IsErrNoNodeConn(err) {
|
||||
code = pointer.Of(int64(404))
|
||||
}
|
||||
handleStreamResultError(err, code, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Get a connection to the server
|
||||
conn, err := a.srv.streamingRpc(srv, "Allocations.Exec")
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
clientConn = conn
|
||||
} else {
|
||||
stream, err := NodeStreamingRpc(state.Session, "Allocations.Exec")
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
clientConn = stream
|
||||
}
|
||||
defer clientConn.Close()
|
||||
|
||||
// Send the request.
|
||||
outEncoder := codec.NewEncoder(clientConn, structs.MsgpackHandle)
|
||||
if err := outEncoder.Encode(args); err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
structs.Bridge(conn, clientConn)
|
||||
}
|
||||
+1503
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,360 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/hashicorp/go-hclog"
|
||||
memdb "github.com/hashicorp/go-memdb"
|
||||
metrics "github.com/hashicorp/go-metrics/compat"
|
||||
|
||||
cstructs "github.com/hashicorp/nomad/client/structs"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
// ClientCSI is used to forward RPC requests to the targed Nomad client's
|
||||
// CSIController endpoint.
|
||||
type ClientCSI struct {
|
||||
srv *Server
|
||||
ctx *RPCContext
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
func NewClientCSIEndpoint(srv *Server, ctx *RPCContext) *ClientCSI {
|
||||
return &ClientCSI{srv: srv, ctx: ctx, logger: srv.logger.Named("client_csi")}
|
||||
}
|
||||
|
||||
func (a *ClientCSI) ControllerAttachVolume(args *cstructs.ClientCSIControllerAttachVolumeRequest, reply *cstructs.ClientCSIControllerAttachVolumeResponse) error {
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_csi_controller", "attach_volume"}, time.Now())
|
||||
|
||||
err := a.sendCSIControllerRPC(args.PluginID,
|
||||
"CSI.ControllerAttachVolume",
|
||||
"ClientCSI.ControllerAttachVolume",
|
||||
structs.RateMetricWrite,
|
||||
args, reply)
|
||||
if err != nil {
|
||||
return fmt.Errorf("controller attach volume: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ClientCSI) ControllerValidateVolume(args *cstructs.ClientCSIControllerValidateVolumeRequest, reply *cstructs.ClientCSIControllerValidateVolumeResponse) error {
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_csi_controller", "validate_volume"}, time.Now())
|
||||
|
||||
err := a.sendCSIControllerRPC(args.PluginID,
|
||||
"CSI.ControllerValidateVolume",
|
||||
"ClientCSI.ControllerValidateVolume",
|
||||
structs.RateMetricWrite,
|
||||
args, reply)
|
||||
if err != nil {
|
||||
return fmt.Errorf("controller validate volume: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ClientCSI) ControllerDetachVolume(args *cstructs.ClientCSIControllerDetachVolumeRequest, reply *cstructs.ClientCSIControllerDetachVolumeResponse) error {
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_csi_controller", "detach_volume"}, time.Now())
|
||||
|
||||
err := a.sendCSIControllerRPC(args.PluginID,
|
||||
"CSI.ControllerDetachVolume",
|
||||
"ClientCSI.ControllerDetachVolume",
|
||||
structs.RateMetricWrite,
|
||||
args, reply)
|
||||
if err != nil {
|
||||
return fmt.Errorf("controller detach volume: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ClientCSI) ControllerCreateVolume(args *cstructs.ClientCSIControllerCreateVolumeRequest, reply *cstructs.ClientCSIControllerCreateVolumeResponse) error {
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_csi_controller", "create_volume"}, time.Now())
|
||||
|
||||
err := a.sendCSIControllerRPC(args.PluginID,
|
||||
"CSI.ControllerCreateVolume",
|
||||
"ClientCSI.ControllerCreateVolume",
|
||||
structs.RateMetricWrite,
|
||||
args, reply)
|
||||
if err != nil {
|
||||
return fmt.Errorf("controller create volume: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ClientCSI) ControllerExpandVolume(args *cstructs.ClientCSIControllerExpandVolumeRequest, reply *cstructs.ClientCSIControllerExpandVolumeResponse) error {
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_csi_controller", "expand_volume"}, time.Now())
|
||||
|
||||
err := a.sendCSIControllerRPC(args.PluginID,
|
||||
"CSI.ControllerExpandVolume",
|
||||
"ClientCSI.ControllerExpandVolume",
|
||||
structs.RateMetricWrite,
|
||||
args, reply)
|
||||
if err != nil {
|
||||
return fmt.Errorf("controller expand volume: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ClientCSI) ControllerDeleteVolume(args *cstructs.ClientCSIControllerDeleteVolumeRequest, reply *cstructs.ClientCSIControllerDeleteVolumeResponse) error {
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_csi_controller", "delete_volume"}, time.Now())
|
||||
|
||||
err := a.sendCSIControllerRPC(args.PluginID,
|
||||
"CSI.ControllerDeleteVolume",
|
||||
"ClientCSI.ControllerDeleteVolume",
|
||||
structs.RateMetricWrite,
|
||||
args, reply)
|
||||
if err != nil {
|
||||
return fmt.Errorf("controller delete volume: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ClientCSI) ControllerListVolumes(args *cstructs.ClientCSIControllerListVolumesRequest, reply *cstructs.ClientCSIControllerListVolumesResponse) error {
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_csi_controller", "list_volumes"}, time.Now())
|
||||
|
||||
err := a.sendCSIControllerRPC(args.PluginID,
|
||||
"CSI.ControllerListVolumes",
|
||||
"ClientCSI.ControllerListVolumes",
|
||||
structs.RateMetricList,
|
||||
args, reply)
|
||||
if err != nil {
|
||||
return fmt.Errorf("controller list volumes: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ClientCSI) ControllerCreateSnapshot(args *cstructs.ClientCSIControllerCreateSnapshotRequest, reply *cstructs.ClientCSIControllerCreateSnapshotResponse) error {
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_csi_controller", "create_snapshot"}, time.Now())
|
||||
|
||||
err := a.sendCSIControllerRPC(args.PluginID,
|
||||
"CSI.ControllerCreateSnapshot",
|
||||
"ClientCSI.ControllerCreateSnapshot",
|
||||
structs.RateMetricWrite,
|
||||
args, reply)
|
||||
if err != nil {
|
||||
return fmt.Errorf("controller create snapshot: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ClientCSI) ControllerDeleteSnapshot(args *cstructs.ClientCSIControllerDeleteSnapshotRequest, reply *cstructs.ClientCSIControllerDeleteSnapshotResponse) error {
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_csi_controller", "delete_snapshot"}, time.Now())
|
||||
|
||||
err := a.sendCSIControllerRPC(args.PluginID,
|
||||
"CSI.ControllerDeleteSnapshot",
|
||||
"ClientCSI.ControllerDeleteSnapshot",
|
||||
structs.RateMetricWrite,
|
||||
args, reply)
|
||||
if err != nil {
|
||||
return fmt.Errorf("controller delete snapshot: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ClientCSI) ControllerListSnapshots(args *cstructs.ClientCSIControllerListSnapshotsRequest, reply *cstructs.ClientCSIControllerListSnapshotsResponse) error {
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_csi_controller", "list_snapshots"}, time.Now())
|
||||
|
||||
err := a.sendCSIControllerRPC(args.PluginID,
|
||||
"CSI.ControllerListSnapshots",
|
||||
"ClientCSI.ControllerListSnapshots",
|
||||
structs.RateMetricList,
|
||||
args, reply)
|
||||
if err != nil {
|
||||
return fmt.Errorf("controller list snapshots: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ClientCSI) sendCSIControllerRPC(pluginID, method, fwdMethod, op string, args cstructs.CSIControllerRequest, reply interface{}) error {
|
||||
|
||||
// client requests aren't RequestWithIdentity, so we use a placeholder here
|
||||
// to populate the identity data for metrics
|
||||
identityReq := &structs.GenericRequest{}
|
||||
|
||||
aclObj, err := a.srv.AuthenticateServerOnly(a.ctx, identityReq)
|
||||
a.srv.MeasureRPCRate("client_csi", op, identityReq)
|
||||
|
||||
if err != nil || !aclObj.AllowServerOp() {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
clientIDs, err := a.clientIDsForController(pluginID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, clientID := range clientIDs {
|
||||
args.SetControllerNodeID(clientID)
|
||||
|
||||
state, ok := a.srv.getNodeConn(clientID)
|
||||
if !ok {
|
||||
return findNodeConnAndForward(a.srv,
|
||||
clientID, fwdMethod, args, reply)
|
||||
}
|
||||
|
||||
err = NodeRpc(state.Session, method, args, reply)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if a.isRetryable(err) {
|
||||
a.logger.Debug("failed to reach controller on client",
|
||||
"nodeID", clientID, "error", err)
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// we can retry the same RPC on a different controller in the cases where the
|
||||
// client has stopped and been GC'd, or where the controller has stopped but
|
||||
// we don't have the fingerprint update yet
|
||||
func (a *ClientCSI) isRetryable(err error) bool {
|
||||
// TODO: msgpack-rpc mangles the error so we lose the wrapping,
|
||||
// but if that can be fixed upstream we should use that here instead
|
||||
return strings.Contains(err.Error(), "CSI client error (retryable)") ||
|
||||
strings.Contains(err.Error(), "Unknown node")
|
||||
}
|
||||
|
||||
func (a *ClientCSI) NodeDetachVolume(args *cstructs.ClientCSINodeDetachVolumeRequest, reply *cstructs.ClientCSINodeDetachVolumeResponse) error {
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_csi_node", "detach_volume"}, time.Now())
|
||||
return a.sendCSINodeRPC(
|
||||
args.NodeID,
|
||||
"CSI.NodeDetachVolume",
|
||||
"ClientCSI.NodeDetachVolume",
|
||||
structs.RateMetricWrite,
|
||||
args,
|
||||
reply,
|
||||
)
|
||||
}
|
||||
|
||||
func (a *ClientCSI) NodeExpandVolume(args *cstructs.ClientCSINodeExpandVolumeRequest, reply *cstructs.ClientCSINodeExpandVolumeResponse) error {
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_csi_node", "expand_volume"}, time.Now())
|
||||
return a.sendCSINodeRPC(
|
||||
args.Claim.NodeID,
|
||||
"CSI.NodeExpandVolume",
|
||||
"ClientCSI.NodeExpandVolume",
|
||||
structs.RateMetricWrite,
|
||||
args,
|
||||
reply,
|
||||
)
|
||||
}
|
||||
|
||||
func (a *ClientCSI) sendCSINodeRPC(nodeID, method, fwdMethod, op string, args any, reply any) error {
|
||||
// client requests aren't RequestWithIdentity, so we use a placeholder here
|
||||
// to populate the identity data for metrics
|
||||
identityReq := &structs.GenericRequest{}
|
||||
aclObj, err := a.srv.AuthenticateServerOnly(a.ctx, identityReq)
|
||||
a.srv.MeasureRPCRate("client_csi", op, identityReq)
|
||||
|
||||
if err != nil || !aclObj.AllowServerOp() {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Make sure Node is valid and new enough to support RPC
|
||||
snap, err := a.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = getNodeForRpc(snap, nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the connection to the client
|
||||
state, ok := a.srv.getNodeConn(nodeID)
|
||||
if !ok {
|
||||
return findNodeConnAndForward(a.srv, nodeID, fwdMethod, args, reply)
|
||||
}
|
||||
|
||||
// Make the RPC
|
||||
if err := NodeRpc(state.Session, method, args, reply); err != nil {
|
||||
return fmt.Errorf("%s error: %w", method, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clientIDsForController returns a sorted list of client IDs where the
|
||||
// controller plugin is expected to be running.
|
||||
func (a *ClientCSI) clientIDsForController(pluginID string) ([]string, error) {
|
||||
|
||||
snap, err := a.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if pluginID == "" {
|
||||
return nil, fmt.Errorf("missing plugin ID")
|
||||
}
|
||||
|
||||
ws := memdb.NewWatchSet()
|
||||
|
||||
// note: plugin IDs are not scoped to region but volumes are. so any Nomad
|
||||
// client we get for a controller is already in the same region for the
|
||||
// volume.
|
||||
plugin, err := snap.CSIPluginByID(ws, pluginID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting plugin: %s, %v", pluginID, err)
|
||||
}
|
||||
if plugin == nil {
|
||||
return nil, fmt.Errorf("plugin missing: %s", pluginID)
|
||||
}
|
||||
|
||||
clientIDs := []string{}
|
||||
|
||||
if len(plugin.Controllers) == 0 {
|
||||
return nil, fmt.Errorf("failed to find instances of controller plugin %q", pluginID)
|
||||
}
|
||||
|
||||
var merr error
|
||||
for clientID, controller := range plugin.Controllers {
|
||||
if !controller.IsController() {
|
||||
// we don't have separate types for CSIInfo depending on whether
|
||||
// it's a controller or node. this error should never make it to
|
||||
// production
|
||||
merr = errors.Join(merr, fmt.Errorf(
|
||||
"plugin instance %q is not a controller but was registered as one - this is always a bug", controller.AllocID))
|
||||
continue
|
||||
}
|
||||
|
||||
if !controller.Healthy {
|
||||
merr = errors.Join(merr, fmt.Errorf(
|
||||
"plugin instance %q is not healthy", controller.AllocID))
|
||||
continue
|
||||
}
|
||||
|
||||
node, err := getNodeForRpc(snap, clientID)
|
||||
if err != nil || node == nil {
|
||||
merr = errors.Join(merr, fmt.Errorf(
|
||||
"cannot find node %q for plugin instance %q", clientID, controller.AllocID))
|
||||
continue
|
||||
}
|
||||
|
||||
if node.Status != structs.NodeStatusReady {
|
||||
merr = errors.Join(merr, fmt.Errorf(
|
||||
"node %q for plugin instance %q is not ready", clientID, controller.AllocID))
|
||||
continue
|
||||
}
|
||||
|
||||
clientIDs = append(clientIDs, clientID)
|
||||
}
|
||||
|
||||
if len(clientIDs) == 0 {
|
||||
return nil, fmt.Errorf("failed to find clients running controller plugin %q: %v",
|
||||
pluginID, merr)
|
||||
}
|
||||
|
||||
// Many plugins don't handle concurrent requests as described in the spec,
|
||||
// and have undocumented expectations of using k8s-specific sidecars to
|
||||
// leader elect. Sort the client IDs so that we prefer sending requests to
|
||||
// the same controller to hack around this.
|
||||
slices.Sort(clientIDs)
|
||||
|
||||
return clientIDs, nil
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/rpc"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
memdb "github.com/hashicorp/go-memdb"
|
||||
msgpackrpc "github.com/hashicorp/net-rpc-msgpackrpc/v2"
|
||||
"github.com/hashicorp/nomad/ci"
|
||||
"github.com/hashicorp/nomad/client"
|
||||
"github.com/hashicorp/nomad/client/config"
|
||||
cstructs "github.com/hashicorp/nomad/client/structs"
|
||||
"github.com/hashicorp/nomad/helper/uuid"
|
||||
"github.com/hashicorp/nomad/nomad/mock"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
"github.com/hashicorp/nomad/testutil"
|
||||
"github.com/shoenig/test/must"
|
||||
)
|
||||
|
||||
// MockClientCSI is a mock for the nomad.ClientCSI RPC server (see
|
||||
// nomad/client_csi_endpoint.go). This can be used with a TestRPCOnlyClient to
|
||||
// return specific plugin responses back to server RPCs for testing. Note that
|
||||
// responses that have no bodies have no "Next*Response" field and will always
|
||||
// return an empty response body.
|
||||
type MockClientCSI struct {
|
||||
NextValidateError error
|
||||
NextAttachError error
|
||||
NextAttachResponse *cstructs.ClientCSIControllerAttachVolumeResponse
|
||||
NextDetachError error
|
||||
NextCreateError error
|
||||
NextCreateResponse *cstructs.ClientCSIControllerCreateVolumeResponse
|
||||
NextDeleteError error
|
||||
NextListExternalError error
|
||||
NextListExternalResponse *cstructs.ClientCSIControllerListVolumesResponse
|
||||
NextCreateSnapshotError error
|
||||
NextCreateSnapshotResponse *cstructs.ClientCSIControllerCreateSnapshotResponse
|
||||
NextDeleteSnapshotError error
|
||||
NextListExternalSnapshotsError error
|
||||
NextListExternalSnapshotsResponse *cstructs.ClientCSIControllerListSnapshotsResponse
|
||||
NextControllerExpandVolumeError error
|
||||
NextControllerExpandVolumeResponse *cstructs.ClientCSIControllerExpandVolumeResponse
|
||||
NextNodeDetachError error
|
||||
NextNodeExpandError error
|
||||
LastNodeExpandRequest *cstructs.ClientCSINodeExpandVolumeRequest
|
||||
LastDeleteSnapshotRequest *cstructs.ClientCSIControllerDeleteSnapshotRequest
|
||||
}
|
||||
|
||||
func newMockClientCSI() *MockClientCSI {
|
||||
return &MockClientCSI{
|
||||
NextAttachResponse: &cstructs.ClientCSIControllerAttachVolumeResponse{},
|
||||
NextCreateResponse: &cstructs.ClientCSIControllerCreateVolumeResponse{},
|
||||
NextListExternalResponse: &cstructs.ClientCSIControllerListVolumesResponse{},
|
||||
NextCreateSnapshotResponse: &cstructs.ClientCSIControllerCreateSnapshotResponse{},
|
||||
NextListExternalSnapshotsResponse: &cstructs.ClientCSIControllerListSnapshotsResponse{},
|
||||
NextControllerExpandVolumeResponse: &cstructs.ClientCSIControllerExpandVolumeResponse{},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *MockClientCSI) ControllerValidateVolume(req *cstructs.ClientCSIControllerValidateVolumeRequest, resp *cstructs.ClientCSIControllerValidateVolumeResponse) error {
|
||||
return c.NextValidateError
|
||||
}
|
||||
|
||||
func (c *MockClientCSI) ControllerAttachVolume(req *cstructs.ClientCSIControllerAttachVolumeRequest, resp *cstructs.ClientCSIControllerAttachVolumeResponse) error {
|
||||
*resp = *c.NextAttachResponse
|
||||
return c.NextAttachError
|
||||
}
|
||||
|
||||
func (c *MockClientCSI) ControllerDetachVolume(req *cstructs.ClientCSIControllerDetachVolumeRequest, resp *cstructs.ClientCSIControllerDetachVolumeResponse) error {
|
||||
return c.NextDetachError
|
||||
}
|
||||
|
||||
func (c *MockClientCSI) ControllerCreateVolume(req *cstructs.ClientCSIControllerCreateVolumeRequest, resp *cstructs.ClientCSIControllerCreateVolumeResponse) error {
|
||||
*resp = *c.NextCreateResponse
|
||||
return c.NextCreateError
|
||||
}
|
||||
|
||||
func (c *MockClientCSI) ControllerDeleteVolume(req *cstructs.ClientCSIControllerDeleteVolumeRequest, resp *cstructs.ClientCSIControllerDeleteVolumeResponse) error {
|
||||
return c.NextDeleteError
|
||||
}
|
||||
|
||||
func (c *MockClientCSI) ControllerListVolumes(req *cstructs.ClientCSIControllerListVolumesRequest, resp *cstructs.ClientCSIControllerListVolumesResponse) error {
|
||||
*resp = *c.NextListExternalResponse
|
||||
return c.NextListExternalError
|
||||
}
|
||||
|
||||
func (c *MockClientCSI) ControllerCreateSnapshot(req *cstructs.ClientCSIControllerCreateSnapshotRequest, resp *cstructs.ClientCSIControllerCreateSnapshotResponse) error {
|
||||
*resp = *c.NextCreateSnapshotResponse
|
||||
return c.NextCreateSnapshotError
|
||||
}
|
||||
|
||||
func (c *MockClientCSI) ControllerDeleteSnapshot(req *cstructs.ClientCSIControllerDeleteSnapshotRequest, resp *cstructs.ClientCSIControllerDeleteSnapshotResponse) error {
|
||||
c.LastDeleteSnapshotRequest = req
|
||||
return c.NextDeleteSnapshotError
|
||||
}
|
||||
|
||||
func (c *MockClientCSI) ControllerListSnapshots(req *cstructs.ClientCSIControllerListSnapshotsRequest, resp *cstructs.ClientCSIControllerListSnapshotsResponse) error {
|
||||
*resp = *c.NextListExternalSnapshotsResponse
|
||||
return c.NextListExternalSnapshotsError
|
||||
}
|
||||
|
||||
func (c *MockClientCSI) ControllerExpandVolume(req *cstructs.ClientCSIControllerExpandVolumeRequest, resp *cstructs.ClientCSIControllerExpandVolumeResponse) error {
|
||||
*resp = *c.NextControllerExpandVolumeResponse
|
||||
return c.NextControllerExpandVolumeError
|
||||
}
|
||||
|
||||
func (c *MockClientCSI) NodeDetachVolume(req *cstructs.ClientCSINodeDetachVolumeRequest, resp *cstructs.ClientCSINodeDetachVolumeResponse) error {
|
||||
return c.NextNodeDetachError
|
||||
}
|
||||
|
||||
func (c *MockClientCSI) NodeExpandVolume(req *cstructs.ClientCSINodeExpandVolumeRequest, resp *cstructs.ClientCSINodeExpandVolumeResponse) error {
|
||||
c.LastNodeExpandRequest = req
|
||||
return c.NextNodeExpandError
|
||||
}
|
||||
|
||||
func TestClientCSIController_AttachVolume_Local(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupLocal(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerAttachVolumeRequest{
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerAttachVolume", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_AttachVolume_Forwarded(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupForward(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerAttachVolumeRequest{
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerAttachVolume", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_DetachVolume_Local(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupLocal(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerDetachVolumeRequest{
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerDetachVolume", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_DetachVolume_Forwarded(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupForward(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerDetachVolumeRequest{
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerDetachVolume", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_ValidateVolume_Local(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupLocal(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerValidateVolumeRequest{
|
||||
VolumeID: "test",
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerValidateVolume", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_ValidateVolume_Forwarded(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupForward(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerValidateVolumeRequest{
|
||||
VolumeID: "test",
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerValidateVolume", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_CreateVolume_Local(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupLocal(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerCreateVolumeRequest{
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerCreateVolume", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_CreateVolume_Forwarded(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupForward(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerCreateVolumeRequest{
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerCreateVolume", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_DeleteVolume_Local(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupLocal(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerDeleteVolumeRequest{
|
||||
ExternalVolumeID: "test",
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerDeleteVolume", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_DeleteVolume_Forwarded(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupForward(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerDeleteVolumeRequest{
|
||||
ExternalVolumeID: "test",
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerDeleteVolume", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_ListVolumes_Local(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupLocal(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerListVolumesRequest{
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerListVolumes", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_ListVolumes_Forwarded(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupForward(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerListVolumesRequest{
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerListVolumes", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_CreateSnapshot_Local(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupLocal(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerCreateSnapshotRequest{
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerCreateSnapshot", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_CreateSnapshot_Forwarded(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupForward(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerCreateSnapshotRequest{
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerCreateSnapshot", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_DeleteSnapshot_Local(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupLocal(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerDeleteSnapshotRequest{
|
||||
ID: "test",
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerDeleteSnapshot", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_DeleteSnapshot_Forwarded(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupForward(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerDeleteSnapshotRequest{
|
||||
ID: "test",
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerDeleteSnapshot", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_ListSnapshots_Local(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupLocal(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerListSnapshotsRequest{
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerListSnapshots", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_ListSnapshots_Forwarded(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupForward(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerListSnapshotsRequest{
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerListSnapshots", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSI_NodeForControllerPlugin(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
srv, shutdown := TestServer(t, func(c *Config) {})
|
||||
testutil.WaitForLeader(t, srv.RPC)
|
||||
defer shutdown()
|
||||
|
||||
plugins := map[string]*structs.CSIInfo{
|
||||
"minnie": {PluginID: "minnie",
|
||||
Healthy: true,
|
||||
ControllerInfo: &structs.CSIControllerInfo{},
|
||||
NodeInfo: &structs.CSINodeInfo{},
|
||||
RequiresControllerPlugin: true,
|
||||
},
|
||||
}
|
||||
state := srv.fsm.State()
|
||||
|
||||
node1 := mock.Node()
|
||||
node1.Attributes["nomad.version"] = "0.11.0" // client RPCs not supported on early versions
|
||||
node1.CSIControllerPlugins = plugins
|
||||
node2 := mock.Node()
|
||||
node2.CSIControllerPlugins = plugins
|
||||
node2.ID = uuid.Generate()
|
||||
node3 := mock.Node()
|
||||
node3.ID = uuid.Generate()
|
||||
|
||||
err := state.UpsertNode(structs.MsgTypeTestSetup, 1002, node1)
|
||||
must.NoError(t, err)
|
||||
err = state.UpsertNode(structs.MsgTypeTestSetup, 1003, node2)
|
||||
must.NoError(t, err)
|
||||
err = state.UpsertNode(structs.MsgTypeTestSetup, 1004, node3)
|
||||
must.NoError(t, err)
|
||||
|
||||
ws := memdb.NewWatchSet()
|
||||
|
||||
plugin, err := state.CSIPluginByID(ws, "minnie")
|
||||
must.NoError(t, err)
|
||||
|
||||
clientCSI := NewClientCSIEndpoint(srv, nil)
|
||||
nodeIDs, err := clientCSI.clientIDsForController(plugin.ID)
|
||||
must.NoError(t, err)
|
||||
must.Len(t, 1, nodeIDs)
|
||||
// only node1 has both the controller and a recent Nomad version
|
||||
must.Eq(t, nodeIDs[0], node1.ID)
|
||||
}
|
||||
|
||||
// sets up a pair of servers, each with one client, and registers a plugin to the clients.
|
||||
// returns a RPC client to the leader and a cleanup function.
|
||||
func setupForward(t *testing.T) rpc.ClientCodec {
|
||||
|
||||
s1, cleanupS1 := TestServer(t, func(c *Config) { c.BootstrapExpect = 2 })
|
||||
s2, cleanupS2 := TestServer(t, func(c *Config) { c.BootstrapExpect = 2 })
|
||||
t.Cleanup(cleanupS1)
|
||||
t.Cleanup(cleanupS2)
|
||||
TestJoin(t, s1, s2)
|
||||
|
||||
testutil.WaitForLeader(t, s1.RPC)
|
||||
testutil.WaitForLeader(t, s2.RPC)
|
||||
codec := rpcClient(t, s1)
|
||||
|
||||
c1, cleanupC1 := client.TestClient(t, func(c *config.Config) {
|
||||
c.Servers = []string{s1.config.RPCAddr.String()}
|
||||
})
|
||||
t.Cleanup(func() { cleanupC1() })
|
||||
|
||||
// Wait for client initialization
|
||||
select {
|
||||
case <-c1.Ready():
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("client timedout on initialize")
|
||||
}
|
||||
|
||||
c2, cleanupC2 := client.TestClient(t, func(c *config.Config) {
|
||||
c.Servers = []string{s2.config.RPCAddr.String()}
|
||||
})
|
||||
t.Cleanup(func() { cleanupC2() })
|
||||
select {
|
||||
case <-c2.Ready():
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("client timedout on initialize")
|
||||
}
|
||||
|
||||
s1.nodeConnsLock.Lock()
|
||||
delete(s1.nodeConns, c2.NodeID())
|
||||
s1.nodeConnsLock.Unlock()
|
||||
|
||||
s2.nodeConnsLock.Lock()
|
||||
delete(s2.nodeConns, c1.NodeID())
|
||||
s2.nodeConnsLock.Unlock()
|
||||
|
||||
waitForNodes(t, s2, 1, 2)
|
||||
|
||||
plugins := map[string]*structs.CSIInfo{
|
||||
"minnie": {PluginID: "minnie",
|
||||
Healthy: true,
|
||||
ControllerInfo: &structs.CSIControllerInfo{},
|
||||
NodeInfo: &structs.CSINodeInfo{},
|
||||
RequiresControllerPlugin: true,
|
||||
},
|
||||
}
|
||||
|
||||
// update w/ plugin
|
||||
node1 := c1.Node()
|
||||
node1.Attributes["nomad.version"] = "0.11.0" // client RPCs not supported on early versions
|
||||
node1.CSIControllerPlugins = plugins
|
||||
|
||||
s1.fsm.state.UpsertNode(structs.MsgTypeTestSetup, 1000, node1)
|
||||
|
||||
return codec
|
||||
}
|
||||
|
||||
// sets up a single server with a client, and registers a plugin to the client.
|
||||
func setupLocal(t *testing.T) rpc.ClientCodec {
|
||||
var err error
|
||||
s1, cleanupS1 := TestServer(t, func(c *Config) { c.BootstrapExpect = 1 })
|
||||
t.Cleanup(cleanupS1)
|
||||
|
||||
testutil.WaitForLeader(t, s1.RPC)
|
||||
testutil.WaitForKeyring(t, s1.RPC, s1.config.Region)
|
||||
codec := rpcClient(t, s1)
|
||||
|
||||
mockCSI := newMockClientCSI()
|
||||
mockCSI.NextValidateError = fmt.Errorf("no plugins registered for type")
|
||||
mockCSI.NextAttachError = fmt.Errorf("no plugins registered for type")
|
||||
mockCSI.NextDetachError = fmt.Errorf("no plugins registered for type")
|
||||
mockCSI.NextCreateError = fmt.Errorf("no plugins registered for type")
|
||||
mockCSI.NextDeleteError = fmt.Errorf("no plugins registered for type")
|
||||
mockCSI.NextListExternalError = fmt.Errorf("no plugins registered for type")
|
||||
mockCSI.NextCreateSnapshotError = fmt.Errorf("no plugins registered for type")
|
||||
mockCSI.NextDeleteSnapshotError = fmt.Errorf("no plugins registered for type")
|
||||
mockCSI.NextListExternalSnapshotsError = fmt.Errorf("no plugins registered for type")
|
||||
|
||||
c1, cleanupC1 := client.TestRPCOnlyClient(t, nil, s1.config.RPCAddr,
|
||||
map[string]interface{}{"CSI": mockCSI},
|
||||
)
|
||||
t.Cleanup(cleanupC1)
|
||||
|
||||
node1 := c1.UpdateConfig(func(c *config.Config) {
|
||||
c.Node.Attributes["nomad.version"] = "0.11.0" // client RPCs not supported on early versions
|
||||
}).Node
|
||||
|
||||
req := &structs.NodeRegisterRequest{
|
||||
Node: node1,
|
||||
WriteRequest: structs.WriteRequest{Region: "global"},
|
||||
}
|
||||
var resp structs.NodeUpdateResponse
|
||||
err = c1.RPC("Node.Register", req, &resp)
|
||||
must.NoError(t, err, must.Sprint("could not register client node"))
|
||||
|
||||
waitForNodes(t, s1, 1, 1)
|
||||
|
||||
plugins := map[string]*structs.CSIInfo{
|
||||
"minnie": {PluginID: "minnie",
|
||||
Healthy: true,
|
||||
ControllerInfo: &structs.CSIControllerInfo{},
|
||||
NodeInfo: &structs.CSINodeInfo{},
|
||||
RequiresControllerPlugin: true,
|
||||
},
|
||||
}
|
||||
|
||||
// update w/ plugin
|
||||
node1 = c1.UpdateConfig(func(c *config.Config) {
|
||||
c.Node.CSIControllerPlugins = plugins
|
||||
}).Node
|
||||
s1.fsm.state.UpsertNode(structs.MsgTypeTestSetup, 1000, node1)
|
||||
|
||||
return codec
|
||||
}
|
||||
|
||||
// waitForNodes waits until the server is connected to connectedNodes
|
||||
// clients and totalNodes clients are in the state store
|
||||
func waitForNodes(t *testing.T, s *Server, connectedNodes, totalNodes int) {
|
||||
codec := rpcClient(t, s)
|
||||
|
||||
testutil.WaitForResult(func() (bool, error) {
|
||||
connNodes := s.connectedNodes()
|
||||
if len(connNodes) != connectedNodes {
|
||||
return false, fmt.Errorf("expected %d connected nodes but found %d", connectedNodes, len(connNodes))
|
||||
}
|
||||
|
||||
get := &structs.NodeListRequest{
|
||||
QueryOptions: structs.QueryOptions{Region: "global"},
|
||||
}
|
||||
var resp structs.NodeListResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "Node.List", get, &resp)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to list nodes: %v", err)
|
||||
}
|
||||
if len(resp.Nodes) != totalNodes {
|
||||
return false, fmt.Errorf("expected %d total nodes but found %d", totalNodes, len(resp.Nodes))
|
||||
}
|
||||
return true, nil
|
||||
}, func(err error) {
|
||||
must.NoError(t, err)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/hashicorp/go-hclog"
|
||||
metrics "github.com/hashicorp/go-metrics/compat"
|
||||
cstructs "github.com/hashicorp/nomad/client/structs"
|
||||
"github.com/hashicorp/nomad/helper/pointer"
|
||||
|
||||
"github.com/hashicorp/go-msgpack/v2/codec"
|
||||
"github.com/hashicorp/nomad/acl"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
// FileSystem endpoint is used for accessing the logs and filesystem of
|
||||
// allocations from a Node.
|
||||
type FileSystem struct {
|
||||
srv *Server
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
func NewFileSystemEndpoint(srv *Server) *FileSystem {
|
||||
return &FileSystem{srv: srv, logger: srv.logger.Named("client_fs")}
|
||||
}
|
||||
|
||||
func (f *FileSystem) register() {
|
||||
f.srv.streamingRpcs.Register("FileSystem.Logs", f.logs)
|
||||
f.srv.streamingRpcs.Register("FileSystem.Stream", f.stream)
|
||||
}
|
||||
|
||||
// handleStreamResultError is a helper for sending an error with a potential
|
||||
// error code. The transmission of the error is ignored if the error has been
|
||||
// generated by the closing of the underlying transport.
|
||||
func handleStreamResultError(err error, code *int64, encoder *codec.Encoder) {
|
||||
// Nothing to do as the conn is closed
|
||||
if err == io.EOF || strings.Contains(err.Error(), "closed") {
|
||||
return
|
||||
}
|
||||
|
||||
// Attempt to send the error
|
||||
encoder.Encode(&cstructs.StreamErrWrapper{
|
||||
Error: cstructs.NewRpcError(err, code),
|
||||
})
|
||||
}
|
||||
|
||||
// forwardRegionStreamingRpc is used to make a streaming RPC to a different
|
||||
// region. It looks up the allocation in the remote region to determine what
|
||||
// remote server can route the request.
|
||||
func forwardRegionStreamingRpc(fsrv *Server, conn io.ReadWriteCloser,
|
||||
encoder *codec.Encoder, args interface{}, method, allocID string, qo *structs.QueryOptions) {
|
||||
// Request the allocation from the target region
|
||||
allocReq := &structs.AllocSpecificRequest{
|
||||
AllocID: allocID,
|
||||
QueryOptions: *qo,
|
||||
}
|
||||
var allocResp structs.SingleAllocResponse
|
||||
if err := fsrv.forwardRegion(qo.RequestRegion(), "Alloc.GetAlloc", allocReq, &allocResp); err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
if allocResp.Alloc == nil {
|
||||
handleStreamResultError(structs.NewErrUnknownAllocation(allocID), pointer.Of(int64(404)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Determine the Server that has a connection to the node.
|
||||
srv, err := fsrv.serverWithNodeConn(allocResp.Alloc.NodeID, qo.RequestRegion())
|
||||
if err != nil {
|
||||
var code *int64
|
||||
if structs.IsErrNoNodeConn(err) {
|
||||
code = pointer.Of(int64(404))
|
||||
}
|
||||
handleStreamResultError(err, code, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Get a connection to the server
|
||||
srvConn, err := fsrv.streamingRpc(srv, method)
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
defer srvConn.Close()
|
||||
|
||||
// Send the request.
|
||||
outEncoder := codec.NewEncoder(srvConn, structs.MsgpackHandle)
|
||||
if err := outEncoder.Encode(args); err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
structs.Bridge(conn, srvConn)
|
||||
}
|
||||
|
||||
// List is used to list the contents of an allocation's directory.
|
||||
func (f *FileSystem) List(args *cstructs.FsListRequest, reply *cstructs.FsListResponse) error {
|
||||
// We only allow stale reads since the only potentially stale information is
|
||||
// the Node registration and the cost is fairly high for adding another hope
|
||||
// in the forwarding chain.
|
||||
args.QueryOptions.AllowStale = true
|
||||
|
||||
authErr := f.srv.Authenticate(nil, args)
|
||||
|
||||
// Potentially forward to a different region.
|
||||
if done, err := f.srv.forward("FileSystem.List", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
f.srv.MeasureRPCRate("file_system", structs.RateMetricList, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "file_system", "list"}, time.Now())
|
||||
|
||||
// Verify the arguments.
|
||||
if args.AllocID == "" {
|
||||
return errors.New("missing allocation ID")
|
||||
}
|
||||
|
||||
// Lookup the allocation
|
||||
snap, err := f.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
alloc, err := getAlloc(snap, args.AllocID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check namespace filesystem read permissions
|
||||
allowNsOp := acl.NamespaceValidator(acl.NamespaceCapabilityReadFS)
|
||||
aclObj, err := f.srv.ResolveACL(args)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !allowNsOp(aclObj, alloc.Namespace) {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Make sure Node is valid and new enough to support RPC
|
||||
_, err = getNodeForRpc(snap, alloc.NodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the connection to the client
|
||||
state, ok := f.srv.getNodeConn(alloc.NodeID)
|
||||
if !ok {
|
||||
return findNodeConnAndForward(f.srv, alloc.NodeID, "FileSystem.List", args, reply)
|
||||
}
|
||||
|
||||
// Make the RPC
|
||||
return NodeRpc(state.Session, "FileSystem.List", args, reply)
|
||||
}
|
||||
|
||||
// Stat is used to stat a file in the allocation's directory.
|
||||
func (f *FileSystem) Stat(args *cstructs.FsStatRequest, reply *cstructs.FsStatResponse) error {
|
||||
// We only allow stale reads since the only potentially stale information is
|
||||
// the Node registration and the cost is fairly high for adding another hope
|
||||
// in the forwarding chain.
|
||||
args.QueryOptions.AllowStale = true
|
||||
|
||||
authErr := f.srv.Authenticate(nil, args)
|
||||
|
||||
// Potentially forward to a different region.
|
||||
if done, err := f.srv.forward("FileSystem.Stat", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
f.srv.MeasureRPCRate("file_system", structs.RateMetricRead, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "file_system", "stat"}, time.Now())
|
||||
|
||||
// Verify the arguments.
|
||||
if args.AllocID == "" {
|
||||
return errors.New("missing allocation ID")
|
||||
}
|
||||
|
||||
// Lookup the allocation
|
||||
snap, err := f.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
alloc, err := getAlloc(snap, args.AllocID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check filesystem read permissions
|
||||
if aclObj, err := f.srv.ResolveACL(args); err != nil {
|
||||
return err
|
||||
} else if !aclObj.AllowNsOp(alloc.Namespace, acl.NamespaceCapabilityReadFS) {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Make sure Node is valid and new enough to support RPC
|
||||
_, err = getNodeForRpc(snap, alloc.NodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the connection to the client
|
||||
state, ok := f.srv.getNodeConn(alloc.NodeID)
|
||||
if !ok {
|
||||
return findNodeConnAndForward(f.srv, alloc.NodeID, "FileSystem.Stat", args, reply)
|
||||
}
|
||||
|
||||
// Make the RPC
|
||||
return NodeRpc(state.Session, "FileSystem.Stat", args, reply)
|
||||
}
|
||||
|
||||
// stream is is used to stream the contents of file in an allocation's
|
||||
// directory.
|
||||
func (f *FileSystem) stream(conn io.ReadWriteCloser) {
|
||||
defer conn.Close()
|
||||
defer metrics.MeasureSince([]string{"nomad", "file_system", "stream"}, time.Now())
|
||||
|
||||
// Decode the arguments
|
||||
var args cstructs.FsStreamRequest
|
||||
decoder := codec.NewDecoder(conn, structs.MsgpackHandle)
|
||||
encoder := codec.NewEncoder(conn, structs.MsgpackHandle)
|
||||
|
||||
if err := decoder.Decode(&args); err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(500)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
authErr := f.srv.Authenticate(nil, &args)
|
||||
|
||||
// Check if we need to forward to a different region
|
||||
if r := args.RequestRegion(); r != f.srv.Region() {
|
||||
forwardRegionStreamingRpc(f.srv, conn, encoder, &args, "FileSystem.Stream",
|
||||
args.AllocID, &args.QueryOptions)
|
||||
return
|
||||
}
|
||||
f.srv.MeasureRPCRate("file_system", structs.RateMetricRead, &args)
|
||||
if authErr != nil {
|
||||
handleStreamResultError(structs.ErrPermissionDenied, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the arguments.
|
||||
if args.AllocID == "" {
|
||||
handleStreamResultError(errors.New("missing AllocID"), pointer.Of(int64(400)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Retrieve the allocation
|
||||
snap, err := f.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
alloc, err := getAlloc(snap, args.AllocID)
|
||||
if structs.IsErrUnknownAllocation(err) {
|
||||
handleStreamResultError(structs.NewErrUnknownAllocation(args.AllocID), pointer.Of(int64(404)), encoder)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Check namespace read-fs permissions.
|
||||
if aclObj, err := f.srv.ResolveACL(&args); err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
} else if !aclObj.AllowNsOp(alloc.Namespace, acl.NamespaceCapabilityReadFS) {
|
||||
handleStreamResultError(structs.ErrPermissionDenied, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
nodeID := alloc.NodeID
|
||||
|
||||
// Make sure Node is valid and new enough to support RPC
|
||||
node, err := snap.NodeByID(nil, nodeID)
|
||||
if err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(500)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
if node == nil {
|
||||
err := fmt.Errorf("Unknown node %q", nodeID)
|
||||
handleStreamResultError(err, pointer.Of(int64(400)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
if err := nodeSupportsRpc(node); err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(400)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the connection to the client either by forwarding to another server
|
||||
// or creating a direct stream
|
||||
var clientConn net.Conn
|
||||
state, ok := f.srv.getNodeConn(nodeID)
|
||||
if !ok {
|
||||
// Determine the Server that has a connection to the node.
|
||||
srv, err := f.srv.serverWithNodeConn(nodeID, f.srv.Region())
|
||||
if err != nil {
|
||||
var code *int64
|
||||
if structs.IsErrNoNodeConn(err) {
|
||||
code = pointer.Of(int64(404))
|
||||
}
|
||||
handleStreamResultError(err, code, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Get a connection to the server
|
||||
conn, err := f.srv.streamingRpc(srv, "FileSystem.Stream")
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
clientConn = conn
|
||||
} else {
|
||||
stream, err := NodeStreamingRpc(state.Session, "FileSystem.Stream")
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
clientConn = stream
|
||||
}
|
||||
defer clientConn.Close()
|
||||
|
||||
// Send the request.
|
||||
outEncoder := codec.NewEncoder(clientConn, structs.MsgpackHandle)
|
||||
if err := outEncoder.Encode(args); err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
structs.Bridge(conn, clientConn)
|
||||
}
|
||||
|
||||
// logs is used to access an task's logs for a given allocation
|
||||
func (f *FileSystem) logs(conn io.ReadWriteCloser) {
|
||||
defer conn.Close()
|
||||
defer metrics.MeasureSince([]string{"nomad", "file_system", "logs"}, time.Now())
|
||||
|
||||
// Decode the arguments
|
||||
var args cstructs.FsLogsRequest
|
||||
decoder := codec.NewDecoder(conn, structs.MsgpackHandle)
|
||||
encoder := codec.NewEncoder(conn, structs.MsgpackHandle)
|
||||
|
||||
if err := decoder.Decode(&args); err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(500)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
authErr := f.srv.Authenticate(nil, &args)
|
||||
|
||||
// Check if we need to forward to a different region
|
||||
if r := args.RequestRegion(); r != f.srv.Region() {
|
||||
forwardRegionStreamingRpc(f.srv, conn, encoder, &args, "FileSystem.Logs",
|
||||
args.AllocID, &args.QueryOptions)
|
||||
return
|
||||
}
|
||||
f.srv.MeasureRPCRate("file_system", structs.RateMetricRead, &args)
|
||||
if authErr != nil {
|
||||
handleStreamResultError(structs.ErrPermissionDenied, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the arguments.
|
||||
if args.AllocID == "" {
|
||||
handleStreamResultError(structs.ErrMissingAllocID, pointer.Of(int64(400)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Retrieve the allocation
|
||||
snap, err := f.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
alloc, err := getAlloc(snap, args.AllocID)
|
||||
if structs.IsErrUnknownAllocation(err) {
|
||||
handleStreamResultError(structs.NewErrUnknownAllocation(args.AllocID), pointer.Of(int64(404)), encoder)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Check namespace read-logs *or* read-fs permissions.
|
||||
allowNsOp := acl.NamespaceValidator(
|
||||
acl.NamespaceCapabilityReadFS, acl.NamespaceCapabilityReadLogs)
|
||||
aclObj, err := f.srv.ResolveACL(&args)
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
} else if !allowNsOp(aclObj, alloc.Namespace) {
|
||||
handleStreamResultError(structs.ErrPermissionDenied, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
nodeID := alloc.NodeID
|
||||
|
||||
// Make sure Node is valid and new enough to support RPC
|
||||
node, err := snap.NodeByID(nil, nodeID)
|
||||
if err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(500)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
if node == nil {
|
||||
err := fmt.Errorf("Unknown node %q", nodeID)
|
||||
handleStreamResultError(err, pointer.Of(int64(400)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
if err := nodeSupportsRpc(node); err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(400)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the connection to the client either by forwarding to another server
|
||||
// or creating a direct stream
|
||||
var clientConn net.Conn
|
||||
state, ok := f.srv.getNodeConn(nodeID)
|
||||
if !ok {
|
||||
// Determine the Server that has a connection to the node.
|
||||
srv, err := f.srv.serverWithNodeConn(nodeID, f.srv.Region())
|
||||
if err != nil {
|
||||
var code *int64
|
||||
if structs.IsErrNoNodeConn(err) {
|
||||
code = pointer.Of(int64(404))
|
||||
}
|
||||
handleStreamResultError(err, code, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Get a connection to the server
|
||||
conn, err := f.srv.streamingRpc(srv, "FileSystem.Logs")
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
clientConn = conn
|
||||
} else {
|
||||
stream, err := NodeStreamingRpc(state.Session, "FileSystem.Logs")
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
clientConn = stream
|
||||
}
|
||||
defer clientConn.Close()
|
||||
|
||||
// Send the request.
|
||||
outEncoder := codec.NewEncoder(clientConn, structs.MsgpackHandle)
|
||||
if err := outEncoder.Encode(args); err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
structs.Bridge(conn, clientConn)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+96
@@ -0,0 +1,96 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
log "github.com/hashicorp/go-hclog"
|
||||
metrics "github.com/hashicorp/go-metrics/compat"
|
||||
cstructs "github.com/hashicorp/nomad/client/structs"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
// ClientHostVolume is the client RPC endpoint for host volumes
|
||||
type ClientHostVolume struct {
|
||||
srv *Server
|
||||
ctx *RPCContext
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
func NewClientHostVolumeEndpoint(srv *Server, ctx *RPCContext) *ClientHostVolume {
|
||||
return &ClientHostVolume{srv: srv, ctx: ctx, logger: srv.logger.Named("client_host_volume")}
|
||||
}
|
||||
|
||||
func (c *ClientHostVolume) Create(args *cstructs.ClientHostVolumeCreateRequest, reply *cstructs.ClientHostVolumeCreateResponse) error {
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_host_node", "create"}, time.Now())
|
||||
return c.sendVolumeRPC(
|
||||
args.NodeID,
|
||||
"HostVolume.Create",
|
||||
"ClientHostVolume.Create",
|
||||
structs.RateMetricWrite,
|
||||
args,
|
||||
reply,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *ClientHostVolume) Register(args *cstructs.ClientHostVolumeRegisterRequest, reply *cstructs.ClientHostVolumeRegisterResponse) error {
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_host_node", "register"}, time.Now())
|
||||
return c.sendVolumeRPC(
|
||||
args.NodeID,
|
||||
"HostVolume.Register",
|
||||
"ClientHostVolume.Register",
|
||||
structs.RateMetricWrite,
|
||||
args,
|
||||
reply,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *ClientHostVolume) Delete(args *cstructs.ClientHostVolumeDeleteRequest, reply *cstructs.ClientHostVolumeDeleteResponse) error {
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_host_volume", "delete"}, time.Now())
|
||||
return c.sendVolumeRPC(
|
||||
args.NodeID,
|
||||
"HostVolume.Delete",
|
||||
"ClientHostVolume.Delete",
|
||||
structs.RateMetricWrite,
|
||||
args,
|
||||
reply,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *ClientHostVolume) sendVolumeRPC(nodeID, method, fwdMethod, op string, args any, reply any) error {
|
||||
// client requests aren't RequestWithIdentity, so we use a placeholder here
|
||||
// to populate the identity data for metrics
|
||||
identityReq := &structs.GenericRequest{}
|
||||
aclObj, err := c.srv.AuthenticateServerOnly(c.ctx, identityReq)
|
||||
c.srv.MeasureRPCRate("client_host_volume", op, identityReq)
|
||||
|
||||
if err != nil || !aclObj.AllowServerOp() {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Make sure Node is valid and new enough to support RPC
|
||||
snap, err := c.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = getNodeForRpc(snap, nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the connection to the client
|
||||
state, ok := c.srv.getNodeConn(nodeID)
|
||||
if !ok {
|
||||
return findNodeConnAndForward(c.srv, nodeID, fwdMethod, args, reply)
|
||||
}
|
||||
|
||||
// Make the RPC
|
||||
if err := NodeRpc(state.Session, method, args, reply); err != nil {
|
||||
return fmt.Errorf("%s error: %w", method, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
metrics "github.com/hashicorp/go-metrics/compat"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
type NodeIdentity struct {
|
||||
srv *Server
|
||||
}
|
||||
|
||||
func newNodeIdentityEndpoint(srv *Server) *NodeIdentity {
|
||||
return &NodeIdentity{
|
||||
srv: srv,
|
||||
}
|
||||
}
|
||||
|
||||
func (n *NodeIdentity) Get(args *structs.NodeIdentityGetReq, reply *structs.NodeIdentityGetResp) error {
|
||||
|
||||
// Prevent infinite loop between the leader and the follower with the target
|
||||
// node connection.
|
||||
args.QueryOptions.AllowStale = true
|
||||
|
||||
authErr := n.srv.Authenticate(nil, args)
|
||||
if done, err := n.srv.forward(structs.NodeIdentityGetRPCMethod, args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
n.srv.MeasureRPCRate("client_identity", structs.RateMetricRead, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_identity", "get"}, time.Now())
|
||||
|
||||
// Check node read permissions
|
||||
if aclObj, err := n.srv.ResolveACL(args); err != nil {
|
||||
return err
|
||||
} else if !aclObj.AllowNodeRead() {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
return n.srv.forwardClientRPC(structs.NodeIdentityGetRPCMethod, args.NodeID, args, reply)
|
||||
}
|
||||
|
||||
func (n *NodeIdentity) Renew(args *structs.NodeIdentityRenewReq, reply *structs.NodeIdentityRenewResp) error {
|
||||
|
||||
// Prevent infinite loop between the leader and the follower with the target
|
||||
// node connection.
|
||||
args.QueryOptions.AllowStale = true
|
||||
|
||||
authErr := n.srv.Authenticate(nil, args)
|
||||
if done, err := n.srv.forward(structs.NodeIdentityRenewRPCMethod, args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
n.srv.MeasureRPCRate("client_identity", structs.RateMetricWrite, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_identity", "renew"}, time.Now())
|
||||
|
||||
// Check node write permissions
|
||||
if aclObj, err := n.srv.ResolveACL(args); err != nil {
|
||||
return err
|
||||
} else if !aclObj.AllowNodeWrite() {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
return n.srv.forwardClientRPC(structs.NodeIdentityRenewRPCMethod, args.NodeID, args, reply)
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/nomad/ci"
|
||||
"github.com/hashicorp/nomad/client"
|
||||
"github.com/hashicorp/nomad/client/config"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
"github.com/hashicorp/nomad/testutil"
|
||||
"github.com/shoenig/test/must"
|
||||
)
|
||||
|
||||
func TestNodeIdentity_Get_Forward(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
servers := []*Server{}
|
||||
for range 3 {
|
||||
s, cleanup := TestServer(t, func(c *Config) {
|
||||
c.BootstrapExpect = 3
|
||||
c.NumSchedulers = 0
|
||||
})
|
||||
t.Cleanup(cleanup)
|
||||
servers = append(servers, s)
|
||||
}
|
||||
|
||||
TestJoin(t, servers...)
|
||||
leader := testutil.WaitForLeaders(t, servers[0].RPC, servers[1].RPC, servers[2].RPC)
|
||||
|
||||
followers := []string{}
|
||||
for _, s := range servers {
|
||||
if addr := s.config.RPCAddr.String(); addr != leader {
|
||||
followers = append(followers, addr)
|
||||
}
|
||||
}
|
||||
t.Logf("leader=%s followers=%q", leader, followers)
|
||||
|
||||
clients := make([]*client.Client, 4)
|
||||
|
||||
for i := range 4 {
|
||||
c, cleanup := client.TestClient(t, func(c *config.Config) {
|
||||
c.Servers = followers
|
||||
})
|
||||
t.Cleanup(func() { _ = cleanup() })
|
||||
clients[i] = c
|
||||
}
|
||||
for _, c := range clients {
|
||||
testutil.WaitForClient(t, servers[0].RPC, c.NodeID(), c.Region())
|
||||
}
|
||||
|
||||
agentRPCs := []func(string, any, any) error{}
|
||||
nodeIDs := make([]string, 0, len(clients))
|
||||
|
||||
// Build list of agents and node IDs
|
||||
for _, s := range servers {
|
||||
agentRPCs = append(agentRPCs, s.RPC)
|
||||
}
|
||||
|
||||
for _, c := range clients {
|
||||
agentRPCs = append(agentRPCs, c.RPC)
|
||||
nodeIDs = append(nodeIDs, c.NodeID())
|
||||
}
|
||||
|
||||
// Iterate through all the agent RPCs to ensure that the renew RPC will
|
||||
// succeed, no matter which agent we connect to.
|
||||
for _, agentRPC := range agentRPCs {
|
||||
for _, nodeID := range nodeIDs {
|
||||
args := &structs.NodeIdentityGetReq{
|
||||
NodeID: nodeID,
|
||||
QueryOptions: structs.QueryOptions{
|
||||
Region: clients[0].Region(),
|
||||
},
|
||||
}
|
||||
must.NoError(t,
|
||||
agentRPC(structs.NodeIdentityGetRPCMethod,
|
||||
args,
|
||||
&structs.NodeIdentityGetResp{},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeIdentity_Renew_Forward(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
servers := []*Server{}
|
||||
for i := 0; i < 3; i++ {
|
||||
s, cleanup := TestServer(t, func(c *Config) {
|
||||
c.BootstrapExpect = 3
|
||||
c.NumSchedulers = 0
|
||||
})
|
||||
t.Cleanup(cleanup)
|
||||
servers = append(servers, s)
|
||||
}
|
||||
|
||||
TestJoin(t, servers...)
|
||||
leader := testutil.WaitForLeaders(t, servers[0].RPC, servers[1].RPC, servers[2].RPC)
|
||||
|
||||
followers := []string{}
|
||||
for _, s := range servers {
|
||||
if addr := s.config.RPCAddr.String(); addr != leader {
|
||||
followers = append(followers, addr)
|
||||
}
|
||||
}
|
||||
t.Logf("leader=%s followers=%q", leader, followers)
|
||||
|
||||
clients := make([]*client.Client, 4)
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
c, cleanup := client.TestClient(t, func(c *config.Config) {
|
||||
c.Servers = followers
|
||||
})
|
||||
t.Cleanup(func() { _ = cleanup() })
|
||||
clients[i] = c
|
||||
}
|
||||
for _, c := range clients {
|
||||
testutil.WaitForClient(t, servers[0].RPC, c.NodeID(), c.Region())
|
||||
}
|
||||
|
||||
agentRPCs := []func(string, any, any) error{}
|
||||
nodeIDs := make([]string, 0, len(clients))
|
||||
|
||||
// Build list of agents and node IDs
|
||||
for _, s := range servers {
|
||||
agentRPCs = append(agentRPCs, s.RPC)
|
||||
}
|
||||
|
||||
for _, c := range clients {
|
||||
agentRPCs = append(agentRPCs, c.RPC)
|
||||
nodeIDs = append(nodeIDs, c.NodeID())
|
||||
}
|
||||
|
||||
// Iterate through all the agent RPCs to ensure that the renew RPC will
|
||||
// succeed, no matter which agent we connect to.
|
||||
for _, agentRPC := range agentRPCs {
|
||||
for _, nodeID := range nodeIDs {
|
||||
args := &structs.NodeIdentityRenewReq{
|
||||
NodeID: nodeID,
|
||||
QueryOptions: structs.QueryOptions{
|
||||
Region: clients[0].Region(),
|
||||
},
|
||||
}
|
||||
must.NoError(t,
|
||||
agentRPC(structs.NodeIdentityRenewRPCMethod,
|
||||
args,
|
||||
&structs.NodeIdentityRenewResp{},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
log "github.com/hashicorp/go-hclog"
|
||||
metrics "github.com/hashicorp/go-metrics/compat"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
nstructs "github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
type NodeMeta struct {
|
||||
srv *Server
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
func newNodeMetaEndpoint(srv *Server) *NodeMeta {
|
||||
n := &NodeMeta{
|
||||
srv: srv,
|
||||
logger: srv.logger.Named("node_meta"),
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (n *NodeMeta) Apply(args *structs.NodeMetaApplyRequest, reply *structs.NodeMetaResponse) error {
|
||||
const method = "NodeMeta.Apply"
|
||||
|
||||
// Prevent infinite loop between leader and
|
||||
// follower-with-the-target-node-connection.
|
||||
args.QueryOptions.AllowStale = true
|
||||
|
||||
authErr := n.srv.Authenticate(nil, args)
|
||||
if done, err := n.srv.forward(method, args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
n.srv.MeasureRPCRate("node_meta", nstructs.RateMetricRead, args)
|
||||
if authErr != nil {
|
||||
return nstructs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_meta", "apply"}, time.Now())
|
||||
|
||||
// Check node write permissions
|
||||
if aclObj, err := n.srv.ResolveACL(args); err != nil {
|
||||
return err
|
||||
} else if !aclObj.AllowNodeWrite() {
|
||||
return nstructs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
return n.srv.forwardClientRPC(method, args.NodeID, args, reply)
|
||||
}
|
||||
|
||||
func (n *NodeMeta) Read(args *structs.NodeSpecificRequest, reply *structs.NodeMetaResponse) error {
|
||||
const method = "NodeMeta.Read"
|
||||
|
||||
// Prevent infinite loop between leader and
|
||||
// follower-with-the-target-node-connection.
|
||||
args.QueryOptions.AllowStale = true
|
||||
|
||||
authErr := n.srv.Authenticate(nil, args)
|
||||
if done, err := n.srv.forward(method, args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
n.srv.MeasureRPCRate("node_meta", nstructs.RateMetricRead, args)
|
||||
if authErr != nil {
|
||||
return nstructs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_meta", "read"}, time.Now())
|
||||
|
||||
// Check node read permissions
|
||||
if aclObj, err := n.srv.ResolveACL(args); err != nil {
|
||||
return err
|
||||
} else if !aclObj.AllowNodeRead() {
|
||||
return nstructs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
return n.srv.forwardClientRPC(method, args.NodeID, args, reply)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/nomad/ci"
|
||||
"github.com/hashicorp/nomad/client"
|
||||
"github.com/hashicorp/nomad/client/config"
|
||||
"github.com/hashicorp/nomad/helper/pointer"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
"github.com/hashicorp/nomad/testutil"
|
||||
"github.com/shoenig/test/must"
|
||||
)
|
||||
|
||||
// TestNodeMeta_Forward asserts that Client RPCs do not result in infinite
|
||||
// loops. For example in a cluster with 1 Leader, 2 Followers, and a Node
|
||||
// connected to Follower 1:
|
||||
//
|
||||
// If a NodeMeta.Apply RPC with AllowStale=false is received by Follower 1, it
|
||||
// will honor AllowStale=false and forward the request to the Leader.
|
||||
//
|
||||
// The Leader will accept the RPC, notice that Follower 1 has a connection to
|
||||
// the Node, and the Leader will send the request back to Follower 1.
|
||||
//
|
||||
// Follower 1, ever respectful of AllowStale=false, will forward it back to the
|
||||
// Leader.
|
||||
//
|
||||
// The Leader, being unable to forward to the Node, will send it back to
|
||||
// Follower 1.
|
||||
//
|
||||
// This argument will continue until one of the Servers runs out of memory or
|
||||
// patience and stomps away in anger (crashes). Like any good argument the
|
||||
// ending is never pretty as the Servers will suffer CPU starvation and
|
||||
// potentially Raft flapping before anyone actually OOMs.
|
||||
//
|
||||
// See https://github.com/hashicorp/nomad/issues/16517 for details.
|
||||
//
|
||||
// If test fails it will do so spectacularly by consuming all available CPU and
|
||||
// potentially all available memory. Running it in a VM or container is
|
||||
// suggested.
|
||||
func TestNodeMeta_Forward(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
servers := []*Server{}
|
||||
for i := 0; i < 3; i++ {
|
||||
s, cleanup := TestServer(t, func(c *Config) {
|
||||
c.BootstrapExpect = 3
|
||||
c.NumSchedulers = 0
|
||||
})
|
||||
t.Cleanup(cleanup)
|
||||
servers = append(servers, s)
|
||||
}
|
||||
|
||||
TestJoin(t, servers...)
|
||||
leader := testutil.WaitForLeaders(t, servers[0].RPC, servers[1].RPC, servers[2].RPC)
|
||||
|
||||
followers := []string{}
|
||||
for _, s := range servers {
|
||||
if addr := s.config.RPCAddr.String(); addr != leader {
|
||||
followers = append(followers, addr)
|
||||
}
|
||||
}
|
||||
t.Logf("leader=%s followers=%q", leader, followers)
|
||||
|
||||
clients := []*client.Client{}
|
||||
for i := 0; i < 4; i++ {
|
||||
c, cleanup := client.TestClient(t, func(c *config.Config) {
|
||||
// Clients will rebalance across all servers, but try to get them to use
|
||||
// followers to ensure we don't hit the loop in #16517
|
||||
c.Servers = followers
|
||||
})
|
||||
defer cleanup()
|
||||
clients = append(clients, c)
|
||||
}
|
||||
for _, c := range clients {
|
||||
testutil.WaitForClient(t, servers[0].RPC, c.NodeID(), c.Region())
|
||||
}
|
||||
|
||||
agentRPCs := []func(string, any, any) error{}
|
||||
nodeIDs := make([]string, 0, len(clients))
|
||||
|
||||
// Build list of agents and node IDs
|
||||
for _, s := range servers {
|
||||
agentRPCs = append(agentRPCs, s.RPC)
|
||||
}
|
||||
|
||||
for _, c := range clients {
|
||||
agentRPCs = append(agentRPCs, c.RPC)
|
||||
nodeIDs = append(nodeIDs, c.NodeID())
|
||||
}
|
||||
|
||||
region := clients[0].Region()
|
||||
|
||||
// Apply metadata to every client through every agent to ensure forwarding
|
||||
// always works regardless of path taken.
|
||||
for _, rpc := range agentRPCs {
|
||||
for _, nodeID := range nodeIDs {
|
||||
args := &structs.NodeMetaApplyRequest{
|
||||
// Intentionally don't set QueryOptions.AllowStale to exercise #16517
|
||||
QueryOptions: structs.QueryOptions{
|
||||
Region: region,
|
||||
},
|
||||
NodeID: nodeID,
|
||||
Meta: map[string]*string{"testing": pointer.Of("123")},
|
||||
}
|
||||
reply := &structs.NodeMetaResponse{}
|
||||
must.NoError(t, rpc("NodeMeta.Apply", args, reply))
|
||||
must.MapNotEmpty(t, reply.Meta)
|
||||
}
|
||||
}
|
||||
|
||||
for _, rpc := range agentRPCs {
|
||||
for _, nodeID := range nodeIDs {
|
||||
args := &structs.NodeSpecificRequest{
|
||||
// Intentionally don't set QueryOptions.AllowStale to exercise #16517
|
||||
QueryOptions: structs.QueryOptions{
|
||||
Region: region,
|
||||
},
|
||||
NodeID: nodeID,
|
||||
}
|
||||
reply := &structs.NodeMetaResponse{}
|
||||
must.NoError(t, rpc("NodeMeta.Read", args, reply))
|
||||
must.MapNotEmpty(t, reply.Meta)
|
||||
must.Eq(t, reply.Meta["testing"], "123")
|
||||
must.MapNotEmpty(t, reply.Dynamic)
|
||||
must.Eq(t, *reply.Dynamic["testing"], "123")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-msgpack/v2/codec"
|
||||
multierror "github.com/hashicorp/go-multierror"
|
||||
msgpackrpc "github.com/hashicorp/net-rpc-msgpackrpc/v2"
|
||||
"github.com/hashicorp/nomad/helper/pool"
|
||||
"github.com/hashicorp/nomad/nomad/peers"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
"github.com/hashicorp/yamux"
|
||||
)
|
||||
|
||||
// nodeConnState is used to track connection information about a Nomad Client.
|
||||
type nodeConnState struct {
|
||||
// Session holds the multiplexed yamux Session for dialing back.
|
||||
Session *yamux.Session
|
||||
|
||||
// Established is when the connection was established.
|
||||
Established time.Time
|
||||
|
||||
// Ctx is the full RPC context
|
||||
Ctx *RPCContext
|
||||
}
|
||||
|
||||
// getNodeConn returns the connection to the given node and whether it exists.
|
||||
func (s *Server) getNodeConn(nodeID string) (*nodeConnState, bool) {
|
||||
s.nodeConnsLock.RLock()
|
||||
defer s.nodeConnsLock.RUnlock()
|
||||
conns, ok := s.nodeConns[nodeID]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Return the latest conn
|
||||
var state *nodeConnState
|
||||
for _, conn := range conns {
|
||||
if state == nil || state.Established.Before(conn.Established) {
|
||||
state = conn
|
||||
}
|
||||
}
|
||||
|
||||
// Shouldn't happen but rather be safe
|
||||
if state == nil {
|
||||
s.logger.Named("client_rpc").Warn("node exists in node connection map without any connection", "node_id", nodeID)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return state, ok
|
||||
}
|
||||
|
||||
// connectedNodes returns the set of nodes we have a connection with.
|
||||
func (s *Server) connectedNodes() map[string]time.Time {
|
||||
s.nodeConnsLock.RLock()
|
||||
defer s.nodeConnsLock.RUnlock()
|
||||
nodes := make(map[string]time.Time, len(s.nodeConns))
|
||||
for nodeID, conns := range s.nodeConns {
|
||||
for _, conn := range conns {
|
||||
if nodes[nodeID].Before(conn.Established) {
|
||||
nodes[nodeID] = conn.Established
|
||||
}
|
||||
}
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
// addNodeConn adds the mapping between a node and its session.
|
||||
func (s *Server) addNodeConn(ctx *RPCContext) {
|
||||
// Hotpath the no-op
|
||||
if ctx == nil || ctx.NodeID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
s.nodeConnsLock.Lock()
|
||||
defer s.nodeConnsLock.Unlock()
|
||||
|
||||
// Capture the tracked connections so far
|
||||
currentConns := s.nodeConns[ctx.NodeID]
|
||||
|
||||
// Check if we already have the connection. If we do, just update the
|
||||
// establish time.
|
||||
for _, c := range currentConns {
|
||||
if c.Ctx.Conn.LocalAddr().String() == ctx.Conn.LocalAddr().String() &&
|
||||
c.Ctx.Conn.RemoteAddr().String() == ctx.Conn.RemoteAddr().String() {
|
||||
c.Established = time.Now()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Add the new conn
|
||||
s.nodeConns[ctx.NodeID] = append(s.nodeConns[ctx.NodeID], &nodeConnState{
|
||||
Session: ctx.Session,
|
||||
Established: time.Now(),
|
||||
Ctx: ctx,
|
||||
})
|
||||
}
|
||||
|
||||
// removeNodeConn removes the mapping between a node and its session.
|
||||
func (s *Server) removeNodeConn(ctx *RPCContext) {
|
||||
// Hotpath the no-op
|
||||
if ctx == nil || ctx.NodeID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
s.nodeConnsLock.Lock()
|
||||
defer s.nodeConnsLock.Unlock()
|
||||
conns, ok := s.nodeConns[ctx.NodeID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// It is important that we check that the connection being removed is the
|
||||
// actual stored connection for the client. It is possible for the client to
|
||||
// dial various addresses that all route to the same server. The most common
|
||||
// case for this is the original address the client uses to connect to the
|
||||
// server differs from the advertised address sent by the heartbeat.
|
||||
for i, conn := range conns {
|
||||
if conn.Ctx.Conn.LocalAddr().String() == ctx.Conn.LocalAddr().String() &&
|
||||
conn.Ctx.Conn.RemoteAddr().String() == ctx.Conn.RemoteAddr().String() {
|
||||
|
||||
if len(conns) == 1 {
|
||||
// We are deleting the last conn, remove it from the map
|
||||
delete(s.nodeConns, ctx.NodeID)
|
||||
} else {
|
||||
// Slice out the connection we are deleting
|
||||
s.nodeConns[ctx.NodeID] = append(s.nodeConns[ctx.NodeID][:i], s.nodeConns[ctx.NodeID][i+1:]...)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// serverWithNodeConn is used to determine which remote server has the most
|
||||
// recent connection to the given node. The local server is not queried.
|
||||
// ErrNoNodeConn is returned if all local peers could be queried but did not
|
||||
// have a connection to the node. Otherwise if a connection could not be found
|
||||
// and there were RPC errors, an error is returned.
|
||||
func (s *Server) serverWithNodeConn(nodeID, region string) (*peers.Parts, error) {
|
||||
// We skip ourselves.
|
||||
selfAddr := s.LocalMember().Addr.String()
|
||||
|
||||
// Build the request
|
||||
req := &structs.NodeSpecificRequest{
|
||||
NodeID: nodeID,
|
||||
QueryOptions: structs.QueryOptions{
|
||||
Region: s.config.Region,
|
||||
},
|
||||
}
|
||||
|
||||
// Select the list of servers to check based on what region we are querying
|
||||
var targets []*peers.Parts
|
||||
if region == s.Region() {
|
||||
targets = s.peersCache.LocalPeers()
|
||||
} else {
|
||||
targets = s.peersCache.RegionPeers(region)
|
||||
if targets == nil {
|
||||
return nil, structs.ErrNoRegionPath
|
||||
}
|
||||
}
|
||||
|
||||
// connections is used to store the servers that have connections to the
|
||||
// requested node.
|
||||
var mostRecentServer *peers.Parts
|
||||
var mostRecent time.Time
|
||||
|
||||
var rpcErr multierror.Error
|
||||
for _, server := range targets {
|
||||
if server.Addr.String() == selfAddr {
|
||||
continue
|
||||
}
|
||||
|
||||
// Make the RPC
|
||||
var resp structs.NodeConnQueryResponse
|
||||
err := s.connPool.RPC(s.config.Region, server.Addr, "Status.HasNodeConn", &req, &resp)
|
||||
if err != nil {
|
||||
multierror.Append(&rpcErr, fmt.Errorf("failed querying server %q: %v", server.Addr.String(), err))
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.Connected && resp.Established.After(mostRecent) {
|
||||
mostRecentServer = server
|
||||
mostRecent = resp.Established
|
||||
}
|
||||
}
|
||||
|
||||
// Return an error if there is no route to the node.
|
||||
if mostRecentServer == nil {
|
||||
if err := rpcErr.ErrorOrNil(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, structs.ErrNoNodeConn
|
||||
}
|
||||
|
||||
return mostRecentServer, nil
|
||||
}
|
||||
|
||||
// forwardClientRPC forwards the RPC specified by method to the node specified
|
||||
// by nodeID. Must be done after region forwarding, metrics, and permissions
|
||||
// checks.
|
||||
//
|
||||
// This is a wrapper method for getNodeForRpc, getNodeConn, etc that Client
|
||||
// RPCs which only need Servers to forward requests can use.
|
||||
func (s *Server) forwardClientRPC(method, nodeID string, args, reply any) error {
|
||||
if nodeID == "" {
|
||||
return errors.New("missing NodeID")
|
||||
}
|
||||
|
||||
// Check if the node even exists and is compatible with NodeRpc
|
||||
snap, err := s.State().Snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Make sure Node is new enough to support RPC
|
||||
_, err = getNodeForRpc(snap, nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the connection to the client
|
||||
state, ok := s.getNodeConn(nodeID)
|
||||
if !ok {
|
||||
// Make the RPC via another server
|
||||
return findNodeConnAndForward(s, nodeID, method, args, reply)
|
||||
}
|
||||
|
||||
// Make the RPC
|
||||
return NodeRpc(state.Session, method, args, reply)
|
||||
}
|
||||
|
||||
// NodeRpc is used to make an RPC call to a node. The method takes the
|
||||
// Yamux session for the node and the method to be called.
|
||||
func NodeRpc(session *yamux.Session, method string, args, reply interface{}) error {
|
||||
// Open a new session
|
||||
stream, err := session.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("session open: %v", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
// Write the RpcNomad byte to set the mode
|
||||
if _, err := stream.Write([]byte{byte(pool.RpcNomad)}); err != nil {
|
||||
stream.Close()
|
||||
return fmt.Errorf("set mode: %v", err)
|
||||
}
|
||||
|
||||
// Make the RPC
|
||||
err = msgpackrpc.CallWithCodec(pool.NewClientCodec(stream), method, args, reply)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NodeStreamingRpc is used to make a streaming RPC call to a node. The method
|
||||
// takes the Yamux session for the node and the method to be called. It conducts
|
||||
// the initial handshake and returns a connection to be used or an error. It is
|
||||
// the callers responsibility to close the connection if there is no error.
|
||||
func NodeStreamingRpc(session *yamux.Session, method string) (net.Conn, error) {
|
||||
// Open a new session
|
||||
stream, err := session.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Write the RpcNomad byte to set the mode
|
||||
if _, err := stream.Write([]byte{byte(pool.RpcStreaming)}); err != nil {
|
||||
stream.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Send the header
|
||||
encoder := codec.NewEncoder(stream, structs.MsgpackHandle)
|
||||
decoder := codec.NewDecoder(stream, structs.MsgpackHandle)
|
||||
header := structs.StreamingRpcHeader{
|
||||
Method: method,
|
||||
}
|
||||
if err := encoder.Encode(header); err != nil {
|
||||
stream.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Wait for the acknowledgement
|
||||
var ack structs.StreamingRpcAck
|
||||
if err := decoder.Decode(&ack); err != nil {
|
||||
stream.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if ack.Error != "" {
|
||||
stream.Close()
|
||||
return nil, errors.New(ack.Error)
|
||||
}
|
||||
|
||||
return stream, nil
|
||||
}
|
||||
|
||||
// findNodeConnAndForward is a helper for finding the server with a connection
|
||||
// to the given node and forwarding the RPC to the correct server. This does not
|
||||
// work for streaming RPCs.
|
||||
func findNodeConnAndForward(srv *Server, nodeID, method string, args, reply interface{}) error {
|
||||
// Determine the Server that has a connection to the node.
|
||||
srvWithConn, err := srv.serverWithNodeConn(nodeID, srv.Region())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if srvWithConn == nil {
|
||||
return structs.ErrNoNodeConn
|
||||
}
|
||||
|
||||
return srv.forwardServer(srvWithConn, method, args, reply)
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/nomad/ci"
|
||||
"github.com/hashicorp/nomad/client"
|
||||
"github.com/hashicorp/nomad/client/config"
|
||||
"github.com/hashicorp/nomad/helper/uuid"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
"github.com/hashicorp/nomad/testutil"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type namedConnWrapper struct {
|
||||
net.Conn
|
||||
name string
|
||||
}
|
||||
|
||||
type namedAddr string
|
||||
|
||||
func (n namedAddr) String() string { return string(n) }
|
||||
func (n namedAddr) Network() string { return string(n) }
|
||||
|
||||
func (n namedConnWrapper) LocalAddr() net.Addr {
|
||||
return namedAddr(n.name)
|
||||
}
|
||||
|
||||
func TestServer_removeNodeConn_differentAddrs(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
require := require.New(t)
|
||||
|
||||
s1, cleanupS1 := TestServer(t, nil)
|
||||
defer cleanupS1()
|
||||
testutil.WaitForLeader(t, s1.RPC)
|
||||
|
||||
p1, p2 := net.Pipe()
|
||||
w1 := namedConnWrapper{
|
||||
Conn: p1,
|
||||
name: "a",
|
||||
}
|
||||
w2 := namedConnWrapper{
|
||||
Conn: p2,
|
||||
name: "b",
|
||||
}
|
||||
|
||||
// Add the connections
|
||||
nodeID := uuid.Generate()
|
||||
ctx1 := &RPCContext{
|
||||
Conn: w1,
|
||||
NodeID: nodeID,
|
||||
}
|
||||
ctx2 := &RPCContext{
|
||||
Conn: w2,
|
||||
NodeID: nodeID,
|
||||
}
|
||||
|
||||
s1.addNodeConn(ctx1)
|
||||
s1.addNodeConn(ctx2)
|
||||
require.Len(s1.connectedNodes(), 1)
|
||||
require.Len(s1.nodeConns[nodeID], 2)
|
||||
|
||||
// Check that the value is the second conn.
|
||||
state, ok := s1.getNodeConn(nodeID)
|
||||
require.True(ok)
|
||||
require.Equal(state.Ctx.Conn.LocalAddr().String(), w2.name)
|
||||
|
||||
// Delete the first
|
||||
s1.removeNodeConn(ctx1)
|
||||
require.Len(s1.connectedNodes(), 1)
|
||||
require.Len(s1.nodeConns[nodeID], 1)
|
||||
|
||||
// Check that the value is the second conn.
|
||||
state, ok = s1.getNodeConn(nodeID)
|
||||
require.True(ok)
|
||||
require.Equal(state.Ctx.Conn.LocalAddr().String(), w2.name)
|
||||
|
||||
// Delete the second
|
||||
s1.removeNodeConn(ctx2)
|
||||
require.Len(s1.connectedNodes(), 0)
|
||||
|
||||
_, ok = s1.getNodeConn(nodeID)
|
||||
require.False(ok)
|
||||
}
|
||||
|
||||
func TestServerWithNodeConn_NoPath(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
require := require.New(t)
|
||||
|
||||
s1, cleanupS1 := TestServer(t, func(c *Config) {
|
||||
c.BootstrapExpect = 2
|
||||
})
|
||||
defer cleanupS1()
|
||||
s2, cleanupS2 := TestServer(t, func(c *Config) {
|
||||
c.BootstrapExpect = 2
|
||||
})
|
||||
defer cleanupS2()
|
||||
TestJoin(t, s1, s2)
|
||||
testutil.WaitForLeader(t, s1.RPC)
|
||||
testutil.WaitForLeader(t, s2.RPC)
|
||||
|
||||
nodeID := uuid.Generate()
|
||||
srv, err := s1.serverWithNodeConn(nodeID, s1.Region())
|
||||
require.Nil(srv)
|
||||
require.EqualError(err, structs.ErrNoNodeConn.Error())
|
||||
}
|
||||
|
||||
func TestServerWithNodeConn_NoPath_Region(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
require := require.New(t)
|
||||
|
||||
s1, cleanupS1 := TestServer(t, nil)
|
||||
defer cleanupS1()
|
||||
testutil.WaitForLeader(t, s1.RPC)
|
||||
|
||||
nodeID := uuid.Generate()
|
||||
srv, err := s1.serverWithNodeConn(nodeID, "fake-region")
|
||||
require.Nil(srv)
|
||||
require.EqualError(err, structs.ErrNoRegionPath.Error())
|
||||
}
|
||||
|
||||
func TestServerWithNodeConn_Path(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
require := require.New(t)
|
||||
|
||||
s1, cleanupS1 := TestServer(t, func(c *Config) {
|
||||
c.BootstrapExpect = 2
|
||||
})
|
||||
defer cleanupS1()
|
||||
s2, cleanupS2 := TestServer(t, func(c *Config) {
|
||||
c.BootstrapExpect = 2
|
||||
})
|
||||
defer cleanupS2()
|
||||
TestJoin(t, s1, s2)
|
||||
testutil.WaitForLeader(t, s1.RPC)
|
||||
testutil.WaitForLeader(t, s2.RPC)
|
||||
|
||||
// Create a fake connection for the node on server 2
|
||||
nodeID := uuid.Generate()
|
||||
s2.addNodeConn(&RPCContext{
|
||||
NodeID: nodeID,
|
||||
})
|
||||
|
||||
srv, err := s1.serverWithNodeConn(nodeID, s1.Region())
|
||||
require.NotNil(srv)
|
||||
require.Equal(srv.Addr.String(), s2.config.RPCAddr.String())
|
||||
require.Nil(err)
|
||||
}
|
||||
|
||||
func TestServerWithNodeConn_Path_Region(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
require := require.New(t)
|
||||
|
||||
s1, cleanupS1 := TestServer(t, nil)
|
||||
defer cleanupS1()
|
||||
s2, cleanupS2 := TestServer(t, func(c *Config) {
|
||||
c.Region = "two"
|
||||
})
|
||||
defer cleanupS2()
|
||||
TestJoin(t, s1, s2)
|
||||
testutil.WaitForLeader(t, s1.RPC)
|
||||
testutil.WaitForLeader(t, s2.RPC)
|
||||
|
||||
// Create a fake connection for the node on server 2
|
||||
nodeID := uuid.Generate()
|
||||
s2.addNodeConn(&RPCContext{
|
||||
NodeID: nodeID,
|
||||
})
|
||||
|
||||
srv, err := s1.serverWithNodeConn(nodeID, s2.Region())
|
||||
require.NotNil(srv)
|
||||
require.Equal(srv.Addr.String(), s2.config.RPCAddr.String())
|
||||
require.Nil(err)
|
||||
}
|
||||
|
||||
func TestServerWithNodeConn_Path_Newest(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
require := require.New(t)
|
||||
|
||||
s1, cleanupS1 := TestServer(t, func(c *Config) {
|
||||
c.BootstrapExpect = 3
|
||||
})
|
||||
defer cleanupS1()
|
||||
s2, cleanupS2 := TestServer(t, func(c *Config) {
|
||||
c.BootstrapExpect = 3
|
||||
})
|
||||
defer cleanupS2()
|
||||
s3, cleanupS3 := TestServer(t, func(c *Config) {
|
||||
c.BootstrapExpect = 3
|
||||
})
|
||||
defer cleanupS3()
|
||||
TestJoin(t, s1, s2, s3)
|
||||
testutil.WaitForLeader(t, s1.RPC)
|
||||
testutil.WaitForLeader(t, s2.RPC)
|
||||
testutil.WaitForLeader(t, s3.RPC)
|
||||
|
||||
// Create a fake connection for the node on server 2 and 3
|
||||
nodeID := uuid.Generate()
|
||||
s2.addNodeConn(&RPCContext{
|
||||
NodeID: nodeID,
|
||||
})
|
||||
s3.addNodeConn(&RPCContext{
|
||||
NodeID: nodeID,
|
||||
})
|
||||
|
||||
srv, err := s1.serverWithNodeConn(nodeID, s1.Region())
|
||||
require.NotNil(srv)
|
||||
require.Equal(srv.Addr.String(), s3.config.RPCAddr.String())
|
||||
require.Nil(err)
|
||||
}
|
||||
|
||||
func TestServerWithNodeConn_PathAndErr(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
require := require.New(t)
|
||||
|
||||
s1, cleanupS1 := TestServer(t, func(c *Config) {
|
||||
c.BootstrapExpect = 3
|
||||
})
|
||||
defer cleanupS1()
|
||||
s2, cleanupS2 := TestServer(t, func(c *Config) {
|
||||
c.BootstrapExpect = 3
|
||||
})
|
||||
defer cleanupS2()
|
||||
s3, cleanupS3 := TestServer(t, func(c *Config) {
|
||||
c.BootstrapExpect = 3
|
||||
})
|
||||
defer cleanupS3()
|
||||
TestJoin(t, s1, s2, s3)
|
||||
testutil.WaitForLeader(t, s1.RPC)
|
||||
testutil.WaitForLeader(t, s2.RPC)
|
||||
testutil.WaitForLeader(t, s3.RPC)
|
||||
|
||||
// Create a fake connection for the node on server 2
|
||||
nodeID := uuid.Generate()
|
||||
s2.addNodeConn(&RPCContext{
|
||||
NodeID: nodeID,
|
||||
})
|
||||
|
||||
// Shutdown the RPC layer for server 3
|
||||
s3.rpcListener.Close()
|
||||
|
||||
srv, err := s1.serverWithNodeConn(nodeID, s1.Region())
|
||||
require.NotNil(srv)
|
||||
require.Equal(srv.Addr.String(), s2.config.RPCAddr.String())
|
||||
require.Nil(err)
|
||||
}
|
||||
|
||||
func TestServerWithNodeConn_NoPathAndErr(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
require := require.New(t)
|
||||
|
||||
s1, cleanupS1 := TestServer(t, func(c *Config) {
|
||||
c.BootstrapExpect = 3
|
||||
})
|
||||
defer cleanupS1()
|
||||
s2, cleanupS2 := TestServer(t, func(c *Config) {
|
||||
c.BootstrapExpect = 3
|
||||
})
|
||||
defer cleanupS2()
|
||||
s3, cleanupS3 := TestServer(t, func(c *Config) {
|
||||
c.BootstrapExpect = 3
|
||||
})
|
||||
defer cleanupS3()
|
||||
TestJoin(t, s1, s2, s3)
|
||||
testutil.WaitForLeader(t, s1.RPC)
|
||||
testutil.WaitForLeader(t, s2.RPC)
|
||||
testutil.WaitForLeader(t, s3.RPC)
|
||||
|
||||
// Shutdown the RPC layer for server 3
|
||||
s3.rpcListener.Close()
|
||||
|
||||
srv, err := s1.serverWithNodeConn(uuid.Generate(), s1.Region())
|
||||
require.Nil(srv)
|
||||
require.NotNil(err)
|
||||
|
||||
// the exact error seems to be dependent on timing and raft protocol version
|
||||
if !strings.Contains(err.Error(), "failed querying") && !strings.Contains(err.Error(), "No path to node") {
|
||||
require.Contains(err.Error(), "failed querying")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeStreamingRpc_badEndpoint(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
require := require.New(t)
|
||||
s1, cleanupS1 := TestServer(t, nil)
|
||||
defer cleanupS1()
|
||||
testutil.WaitForLeader(t, s1.RPC)
|
||||
|
||||
c, cleanupC := client.TestClient(t, func(c *config.Config) {
|
||||
c.Servers = []string{s1.config.RPCAddr.String()}
|
||||
})
|
||||
defer cleanupC()
|
||||
|
||||
// Wait for the client to connect
|
||||
testutil.WaitForResult(func() (bool, error) {
|
||||
nodes := s1.connectedNodes()
|
||||
return len(nodes) == 1, nil
|
||||
}, func(err error) {
|
||||
t.Fatalf("should have a clients")
|
||||
})
|
||||
|
||||
state, ok := s1.getNodeConn(c.NodeID())
|
||||
require.True(ok)
|
||||
|
||||
conn, err := NodeStreamingRpc(state.Session, "Bogus")
|
||||
require.Nil(conn)
|
||||
require.NotNil(err)
|
||||
require.Contains(err.Error(), "Bogus")
|
||||
require.True(structs.IsErrUnknownMethod(err))
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
log "github.com/hashicorp/go-hclog"
|
||||
metrics "github.com/hashicorp/go-metrics/compat"
|
||||
nstructs "github.com/hashicorp/nomad/nomad/structs"
|
||||
|
||||
"github.com/hashicorp/nomad/client/structs"
|
||||
)
|
||||
|
||||
// ClientStats is used to forward RPC requests to the targed Nomad client's
|
||||
// ClientStats endpoint.
|
||||
type ClientStats struct {
|
||||
srv *Server
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
func NewClientStatsEndpoint(srv *Server) *ClientStats {
|
||||
return &ClientStats{srv: srv, logger: srv.logger.Named("client_stats")}
|
||||
}
|
||||
|
||||
func (s *ClientStats) Stats(args *nstructs.NodeSpecificRequest, reply *structs.ClientStatsResponse) error {
|
||||
|
||||
// We only allow stale reads since the only potentially stale information is
|
||||
// the Node registration and the cost is fairly high for adding another hope
|
||||
// in the forwarding chain.
|
||||
args.QueryOptions.AllowStale = true
|
||||
authErr := s.srv.Authenticate(nil, args)
|
||||
|
||||
// Potentially forward to a different region.
|
||||
if done, err := s.srv.forward("ClientStats.Stats", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
s.srv.MeasureRPCRate("client_stats", nstructs.RateMetricRead, args)
|
||||
if authErr != nil {
|
||||
return nstructs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_stats", "stats"}, time.Now())
|
||||
|
||||
// Check node read permissions
|
||||
if aclObj, err := s.srv.ResolveACL(args); err != nil {
|
||||
return err
|
||||
} else if !aclObj.AllowNodeRead() {
|
||||
return nstructs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
return s.srv.forwardClientRPC("ClientStats.Stats", args.NodeID, args, reply)
|
||||
}
|
||||
Reference in new issue
Block a user