client: Add state functionality for set and get client identities. (#26184)

The Nomad client will persist its own identity within its state
store for restart persistence. The added benefit of using it over
the filesystem is that it supports transactions. This is useful
when considering the identity will be renewed periodically.
This commit is contained in:
James Rasell
2025-07-07 16:28:27 +02:00
committed by GitHub
parent d5b2d5078b
commit 2f30205102
8 changed files with 113 additions and 2 deletions

View File

@@ -140,6 +140,12 @@ var (
nodeRegistrationKey = []byte("node_registration") nodeRegistrationKey = []byte("node_registration")
hostVolBucket = []byte("host_volumes_to_create") hostVolBucket = []byte("host_volumes_to_create")
// nodeIdentityBucket and nodeIdentityBucketStateKey are used to persist
// the client identity and its state. Each client will only have a single
// identity, so we use a single key value for the storage.
nodeIdentityBucket = []byte("node_identity")
nodeIdentityBucketStateKey = []byte("node_identity_state")
) )
// taskBucketName returns the bucket name for the given task name. // taskBucketName returns the bucket name for the given task name.
@@ -1089,6 +1095,42 @@ func (s *BoltStateDB) DeleteDynamicHostVolume(id string) error {
}) })
} }
// clientIdentity wraps the signed client identity so we can safely add more
// state in the future without needing a new entry type.
type clientIdentity struct {
SignedIdentity string
}
func (s *BoltStateDB) PutNodeIdentity(identity string) error {
return s.db.Update(func(tx *boltdd.Tx) error {
b, err := tx.CreateBucketIfNotExists(nodeIdentityBucket)
if err != nil {
return err
}
identityWrapper := clientIdentity{SignedIdentity: identity}
return b.Put(nodeIdentityBucketStateKey, &identityWrapper)
})
}
func (s *BoltStateDB) GetNodeIdentity() (string, error) {
var identityWrapper clientIdentity
err := s.db.View(func(tx *boltdd.Tx) error {
b := tx.Bucket(nodeIdentityBucket)
if b == nil {
return nil
}
return b.Get(nodeIdentityBucketStateKey, &identityWrapper)
})
if boltdd.IsErrNotFound(err) {
return "", nil
}
return identityWrapper.SignedIdentity, err
}
// init initializes metadata entries in a newly created state database. // init initializes metadata entries in a newly created state database.
func (s *BoltStateDB) init() error { func (s *BoltStateDB) init() error {
return s.db.Update(func(tx *boltdd.Tx) error { return s.db.Update(func(tx *boltdd.Tx) error {

View File

@@ -172,3 +172,7 @@ func (m *ErrDB) DeleteDynamicHostVolume(_ string) error {
func (m *ErrDB) Close() error { func (m *ErrDB) Close() error {
return fmt.Errorf("Error!") return fmt.Errorf("Error!")
} }
func (m *ErrDB) PutNodeIdentity(_ string) error { return ErrDBError }
func (m *ErrDB) GetNodeIdentity() (string, error) { return "", ErrDBError }

View File

@@ -6,6 +6,7 @@ package state
import ( import (
"maps" "maps"
"sync" "sync"
"sync/atomic"
"github.com/hashicorp/go-hclog" "github.com/hashicorp/go-hclog"
arstate "github.com/hashicorp/nomad/client/allocrunner/state" arstate "github.com/hashicorp/nomad/client/allocrunner/state"
@@ -62,6 +63,9 @@ type MemDB struct {
dynamicHostVolumes map[string]*cstructs.HostVolumeState dynamicHostVolumes map[string]*cstructs.HostVolumeState
// clientIdentity is the persisted identity of the client.
clientIdentity atomic.Value
logger hclog.Logger logger hclog.Logger
mu sync.RWMutex mu sync.RWMutex
@@ -79,6 +83,7 @@ func NewMemDB(logger hclog.Logger) *MemDB {
checks: make(checks.ClientResults), checks: make(checks.ClientResults),
identities: make(map[string][]*structs.SignedWorkloadIdentity), identities: make(map[string][]*structs.SignedWorkloadIdentity),
dynamicHostVolumes: make(map[string]*cstructs.HostVolumeState), dynamicHostVolumes: make(map[string]*cstructs.HostVolumeState),
clientIdentity: atomic.Value{},
logger: logger, logger: logger,
} }
} }
@@ -379,6 +384,19 @@ func (m *MemDB) DeleteDynamicHostVolume(s string) error {
return nil return nil
} }
func (m *MemDB) PutNodeIdentity(identity string) error {
m.clientIdentity.Store(identity)
return nil
}
func (m *MemDB) GetNodeIdentity() (string, error) {
if obj := m.clientIdentity.Load(); obj == nil {
return "", nil
} else {
return obj.(string), nil
}
}
func (m *MemDB) Close() error { func (m *MemDB) Close() error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()

View File

@@ -157,6 +157,10 @@ func (n NoopDB) DeleteDynamicHostVolume(_ string) error {
return nil return nil
} }
func (n NoopDB) PutNodeIdentity(_ string) error { return nil }
func (n NoopDB) GetNodeIdentity() (string, error) { return "", nil }
func (n NoopDB) Close() error { func (n NoopDB) Close() error {
return nil return nil
} }

View File

@@ -493,6 +493,24 @@ func TestStateDB_CheckResult(t *testing.T) {
} }
func TestStateDB_NodeIdentity(t *testing.T) {
ci.Parallel(t)
testDB(t, func(t *testing.T, db StateDB) {
identity, err := db.GetNodeIdentity()
must.NoError(t, err)
must.Eq(t, "", identity)
fakeIdentity := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30"
must.NoError(t, db.PutNodeIdentity(fakeIdentity))
identity, err = db.GetNodeIdentity()
must.NoError(t, err)
must.Eq(t, fakeIdentity, identity)
})
}
// TestStateDB_Upgrade asserts calling Upgrade on new databases always // TestStateDB_Upgrade asserts calling Upgrade on new databases always
// succeeds. // succeeds.
func TestStateDB_Upgrade(t *testing.T) { func TestStateDB_Upgrade(t *testing.T) {

View File

@@ -141,6 +141,14 @@ type StateDB interface {
GetDynamicHostVolumes() ([]*cstructs.HostVolumeState, error) GetDynamicHostVolumes() ([]*cstructs.HostVolumeState, error)
DeleteDynamicHostVolume(string) error DeleteDynamicHostVolume(string) error
// PutNodeIdentity stores the signed identity JWT for the client.
PutNodeIdentity(identity string) error
// GetNodeIdentity retrieves the signed identity JWT for the client. If the
// client has not generated an identity, this will return an empty string
// and no error.
GetNodeIdentity() (string, error)
// Close the database. Unsafe for further use after calling regardless // Close the database. Unsafe for further use after calling regardless
// of return value. // of return value.
Close() error Close() error

View File

@@ -132,8 +132,18 @@ func (c *OperatorClientStateCommand) Run(args []string) int {
Tasks: tasks, Tasks: tasks,
} }
} }
// Get the node identity state, which is useful when debugging to see the
// real and current identity the node is using.
nodeIdentity, err := db.GetNodeIdentity()
if err != nil {
c.Ui.Error(fmt.Sprintf("failed to get node identity state: %v", err))
return 1
}
output := debugOutput{ output := debugOutput{
Allocations: data, Allocations: data,
NodeIdentity: nodeIdentity,
} }
bytes, err := json.Marshal(output) bytes, err := json.Marshal(output)
if err != nil { if err != nil {
@@ -146,7 +156,8 @@ func (c *OperatorClientStateCommand) Run(args []string) int {
} }
type debugOutput struct { type debugOutput struct {
Allocations map[string]*clientStateAlloc Allocations map[string]*clientStateAlloc
NodeIdentity string
} }
type clientStateAlloc struct { type clientStateAlloc struct {

View File

@@ -38,10 +38,16 @@ func TestOperatorClientStateCommand(t *testing.T) {
alloc := structs.MockAlloc() alloc := structs.MockAlloc()
err = db.PutAllocation(alloc) err = db.PutAllocation(alloc)
must.NoError(t, err) must.NoError(t, err)
// Write a node identity to the DB, so we can test that the command reads
// this data.
must.NoError(t, db.PutNodeIdentity("mynodeidentity"))
must.NoError(t, db.Close()) must.NoError(t, db.Close())
// run against an incomplete client state directory // run against an incomplete client state directory
code = cmd.Run([]string{dir}) code = cmd.Run([]string{dir})
must.Eq(t, 0, code) must.Eq(t, 0, code)
must.StrContains(t, ui.OutputWriter.String(), alloc.ID) must.StrContains(t, ui.OutputWriter.String(), alloc.ID)
must.StrContains(t, ui.OutputWriter.String(), "NodeIdentity\":\"mynodeidentity")
} }