Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
dc56e7b
docs: update nomad.service to point to the current site
benjamin-lykins Nov 4, 2025
b64afe4
Merge branch 'hashicorp:main' into main
benjamin-lykins Nov 4, 2025
244bcc0
Merge branch 'hashicorp:main' into main
benjamin-lykins Nov 11, 2025
36ea900
Merge branch 'hashicorp:main' into main
benjamin-lykins Nov 12, 2025
6549d98
api: add reload endpoint to agent for configuration reload
benjamin-lykins Nov 13, 2025
bb131b5
api: add simple returned message to reload endpoint
benjamin-lykins Nov 13, 2025
a53b746
test: add tests for agent reload endpoint
benjamin-lykins Nov 14, 2025
f560f36
api: update notes
benjamin-lykins Nov 14, 2025
295ecd2
api: update return message
benjamin-lykins Nov 14, 2025
5c17fa5
api: add logging for agent reload process
benjamin-lykins Nov 14, 2025
a243a8c
chore: cleaning up my comments and notes
benjamin-lykins Nov 14, 2025
1cbf394
api: cleanup
benjamin-lykins Nov 15, 2025
f345955
api: remove debug log from agent reload method, fix failing test
benjamin-lykins Nov 17, 2025
8297287
api: removing response validation in testing, check fo error and not …
benjamin-lykins Nov 17, 2025
906ca21
api: update Reload method to accept AgentReloadOpts for future config…
benjamin-lykins Dec 4, 2025
ddaa00a
api: update tests to use shoenig/test instead of testify, consolidate…
benjamin-lykins Dec 5, 2025
ddc4a21
api: testing clean up
benjamin-lykins Dec 5, 2025
41c6db1
config: store -config paths on startup
benjamin-lykins Dec 6, 2025
e13400b
api: fix reload api to use stored -config values when checking for ne…
benjamin-lykins Dec 6, 2025
dc19697
tests: removed old commented code
benjamin-lykins Dec 8, 2025
62de78a
api: move handleReload for parity between API and SIGHUP (at least i …
benjamin-lykins Jan 9, 2026
9d8d293
api: revert functionality, add parity between api and SIGHUP
benjamin-lykins Mar 10, 2026
7111dbd
api: simplify comments in Agent struct and remove unnecessary whitespace
benjamin-lykins Mar 10, 2026
f3e7335
removing unused test
benjamin-lykins Mar 10, 2026
1beb513
clean up
benjamin-lykins Mar 10, 2026
10364cb
Merge branch 'main' into feat/config-reload-api
benjamin-lykins Mar 10, 2026
db5251c
running gofmt
benjamin-lykins Mar 10, 2026
9adfab8
removing lock/unlock
benjamin-lykins Apr 30, 2026
3d6e23d
removing unnecessary variable
benjamin-lykins May 14, 2026
51a0349
fix: now picking up and logging newly found config files
benjamin-lykins May 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions api/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,12 @@ func (a *Agent) ListKeys() (*KeyringResponse, error) {
return &resp, nil
}

// Reload requests the agent to reload its configuration.
func (a *Agent) Reload() error {
Comment thread
benjamin-lykins marked this conversation as resolved.
Outdated
_, err := a.client.put("/v1/agent/reload", nil, nil, nil)
return err
}

// InstallKey installs a key in the keyrings of all the serf members
func (a *Agent) InstallKey(key string) (*KeyringResponse, error) {
args := KeyringRequest{
Expand Down
1 change: 1 addition & 0 deletions command/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -1530,6 +1530,7 @@ func (a *Agent) ShouldReload(newConfig *Config) (agent, http bool) {
// Reload handles configuration changes for the agent. Provides a method that
// is easier to unit test, as this action is invoked via SIGHUP.
func (a *Agent) Reload(newConfig *Config) error {
a.logger.Debug("starting reload of agent")
a.configLock.Lock()
defer a.configLock.Unlock()

Expand Down
46 changes: 46 additions & 0 deletions command/agent/agent_endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,52 @@ func (s *HTTPServer) listServers(resp http.ResponseWriter, req *http.Request) (i
return peers, nil
}

type reloadResponse struct {
Message string `json:"message"`
}

func (s *HTTPServer) AgentReloadRequest(resp http.ResponseWriter, req *http.Request) (interface{}, error) {
if req.Method != http.MethodPut && req.Method != http.MethodPost {
return nil, CodedError(405, ErrInvalidMethod)
}

aclObj, err := s.ResolveToken(req)
if err != nil {
return nil, err
}
if !aclObj.AllowAgentWrite() {
return nil, structs.ErrPermissionDenied
}

currConf := s.agent.GetConfig().Copy()

newConf := DefaultConfig()

for _, path := range currConf.Files {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the -config flag in the command line arguments for this agent points to a directory, this Files field will contain only the files that were in that directory at the time we started the agent. If you add a new file, it won't be present on reload. We may need to figure out a way to get the original -config flag attached to the config for this to work.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for catching this one, wasn't thinking of it or realized when I originally ran through it. Made some modifications to save the -config flag values and tested locally without issue. Tested if I passed over two -config as well and worked as expected. I was also able to reuse the logic in LoadConfig to check each path, which seems to work out well. Lastly, I tested adding files with bad configuration and got a decent response back, but I could also just send a canned message back

curl -k  -X PUT \
  https://localhost:4646/v1/agent/reload
Error loading /Users/benjamin.lykins/git/nomad/nomad/bin/config1/badfile.hcl: failed to decode HCL file /Users/benjamin.lykins/git/nomad/nomad/bin/config1/badfile.hcl: At 1:6: key 'asdf' expected start of object ('{') or assignment ('=')

if path == "" {
continue
}
if cfgFromFile, err := LoadConfig(path); err != nil {
s.logger.Error("failed to load config", "config", path, "error", err, "path", "/v1/agent/reload", "method", req.Method)
return nil, CodedError(400, error.Error(err))
} else if cfgFromFile != nil {
newConf = newConf.Merge(cfgFromFile)
}
}

newConf.Files = append([]string(nil), currConf.Files...)

if err := s.agent.Reload(newConf); err != nil {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you take a look at the signal handler for reloading, you'll see that this step only reloads the agent configuration and won't push any changes to the server, client, or HTTP server configuration. We should at least have parity with the signal handler here, which calls (*Command) handleReload. We may need to figure out a way to extract that function so that it's reachable here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still needs to be done, and I'll start looking ahead at how to get this going. I saw this when I was mucking around with TLS rotations with server/client, and it was only updating the more general agent config.

return nil, CodedError(400, err.Error())
}

response := reloadResponse{
Message: "agent configuration reloaded",
}

return response, nil
}

func (s *HTTPServer) updateServers(resp http.ResponseWriter, req *http.Request) (interface{}, error) {
client := s.agent.Client()
if client == nil {
Expand Down
88 changes: 88 additions & 0 deletions command/agent/agent_endpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2277,3 +2277,91 @@ func TestHTTP_AgentSchedulerWorkerConfigRequest_Client(t *testing.T) {
})
}
}
func TestHTTP_AgentReload(t *testing.T) {
ci.Parallel(t)

t.Run("invalid method", func(t *testing.T) {
httpTest(t, nil, func(s *TestAgent) {
req, err := http.NewRequest(http.MethodGet, "/v1/agent/reload", nil)
require.NoError(t, err)
Comment thread
benjamin-lykins marked this conversation as resolved.
Outdated
respW := httptest.NewRecorder()

_, err = s.Server.AgentReloadRequest(respW, req)
require.Error(t, err)
httpErr, ok := err.(HTTPCodedError)
require.True(t, ok)
require.Equal(t, 405, httpErr.Code())
})
})

t.Run("valid put request", func(t *testing.T) {
httpTest(t, nil, func(s *TestAgent) {
req, err := http.NewRequest(http.MethodPut, "/v1/agent/reload", nil)
require.NoError(t, err)
respW := httptest.NewRecorder()

obj, err := s.Server.AgentReloadRequest(respW, req)
require.NoError(t, err)
require.NotNil(t, obj)

response, ok := obj.(map[string]string)
require.True(t, ok)
require.Equal(t, "reload signaled", response["message"])
})
})
}

func TestHTTP_AgentReload_ACL(t *testing.T) {
Comment thread
benjamin-lykins marked this conversation as resolved.
ci.Parallel(t)
require := require.New(t)

httpACLTest(t, nil, func(s *TestAgent) {
state := s.Agent.server.State()

// Make the HTTP request
req, err := http.NewRequest(http.MethodPut, "/v1/agent/reload", nil)
require.Nil(err)

// Try request without a token and expect failure
{
respW := httptest.NewRecorder()
_, err := s.Server.AgentReloadRequest(respW, req)
require.NotNil(err)
require.Equal(err.Error(), structs.ErrPermissionDenied.Error())
}

// Try request with an invalid token and expect failure
{
respW := httptest.NewRecorder()
token := mock.CreatePolicyAndToken(t, state, 1005, "invalid", mock.NodePolicy(acl.PolicyWrite))
setToken(req, token)
_, err := s.Server.AgentReloadRequest(respW, req)
require.NotNil(err)
require.Equal(err.Error(), structs.ErrPermissionDenied.Error())
}

// Try request with a read token and expect failure
{
respW := httptest.NewRecorder()
token := mock.CreatePolicyAndToken(t, state, 1006, "read", mock.AgentPolicy(acl.PolicyRead))
setToken(req, token)
_, err := s.Server.AgentReloadRequest(respW, req)
require.NotNil(err)
require.Equal(err.Error(), structs.ErrPermissionDenied.Error())
}

// Try request with a valid write token
{
respW := httptest.NewRecorder()
token := mock.CreatePolicyAndToken(t, state, 1007, "valid", mock.AgentPolicy(acl.PolicyWrite))
setToken(req, token)
obj, err := s.Server.AgentReloadRequest(respW, req)
require.Nil(err)
require.NotNil(obj)

response, ok := obj.(map[string]string)
require.True(ok)
require.Equal("reload signaled", response["message"])
}
})
}
2 changes: 2 additions & 0 deletions command/agent/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ type RPCer interface {
Stats() map[string]map[string]string
GetConfig() *Config
GetMetricsSink() *metrics.InmemSink
Reload(*Config) error
}

// HTTPServer is used to wrap an Agent and expose it over an HTTP interface
Expand Down Expand Up @@ -464,6 +465,7 @@ func (s *HTTPServer) registerHandlers(enableDebug bool) {
s.mux.HandleFunc("/v1/agent/keyring/", s.wrap(s.KeyringOperationRequest))
s.mux.HandleFunc("/v1/agent/health", s.wrap(s.HealthRequest))
s.mux.HandleFunc("/v1/agent/host", s.wrap(s.AgentHostRequest))
s.mux.HandleFunc("/v1/agent/reload", s.wrap(s.AgentReloadRequest))

// Register our service registration handlers.
s.mux.HandleFunc("/v1/services", s.wrap(s.ServiceRegistrationListRequest))
Expand Down
Loading