diff --git a/.changelog/13972.txt b/.changelog/13972.txt
new file mode 100644
index 000000000..330faea98
--- /dev/null
+++ b/.changelog/13972.txt
@@ -0,0 +1,3 @@
+```release-note:improvement
+template: add script change_mode that allows scripts to be executed on template change
+```
\ No newline at end of file
diff --git a/api/tasks.go b/api/tasks.go
index b24c6b058..c8a1c0b3f 100644
--- a/api/tasks.go
+++ b/api/tasks.go
@@ -809,11 +809,34 @@ func (wc *WaitConfig) Copy() *WaitConfig {
return nwc
}
+type ChangeScript struct {
+ Command *string `mapstructure:"command" hcl:"command"`
+ Args []string `mapstructure:"args" hcl:"args,optional"`
+ Timeout *time.Duration `mapstructure:"timeout" hcl:"timeout,optional"`
+ FailOnError *bool `mapstructure:"fail_on_error" hcl:"fail_on_error"`
+}
+
+func (ch *ChangeScript) Canonicalize() {
+ if ch.Command == nil {
+ ch.Command = pointerOf("")
+ }
+ if ch.Args == nil {
+ ch.Args = []string{}
+ }
+ if ch.Timeout == nil {
+ ch.Timeout = pointerOf(5 * time.Second)
+ }
+ if ch.FailOnError == nil {
+ ch.FailOnError = pointerOf(false)
+ }
+}
+
type Template struct {
SourcePath *string `mapstructure:"source" hcl:"source,optional"`
DestPath *string `mapstructure:"destination" hcl:"destination,optional"`
EmbeddedTmpl *string `mapstructure:"data" hcl:"data,optional"`
ChangeMode *string `mapstructure:"change_mode" hcl:"change_mode,optional"`
+ ChangeScript *ChangeScript `mapstructure:"change_script" hcl:"change_script,block"`
ChangeSignal *string `mapstructure:"change_signal" hcl:"change_signal,optional"`
Splay *time.Duration `mapstructure:"splay" hcl:"splay,optional"`
Perms *string `mapstructure:"perms" hcl:"perms,optional"`
@@ -849,6 +872,9 @@ func (tmpl *Template) Canonicalize() {
sig := *tmpl.ChangeSignal
tmpl.ChangeSignal = pointerOf(strings.ToUpper(sig))
}
+ if tmpl.ChangeScript != nil {
+ tmpl.ChangeScript.Canonicalize()
+ }
if tmpl.Splay == nil {
tmpl.Splay = pointerOf(5 * time.Second)
}
diff --git a/client/allocrunner/taskrunner/template/template.go b/client/allocrunner/taskrunner/template/template.go
index d3536018c..10d45ccb7 100644
--- a/client/allocrunner/taskrunner/template/template.go
+++ b/client/allocrunner/taskrunner/template/template.go
@@ -54,6 +54,10 @@ type TaskTemplateManager struct {
// runner is the consul-template runner
runner *manager.Runner
+ // handle is used to execute scripts
+ handle interfaces.ScriptExecutor
+ handleLock sync.Mutex
+
// signals is a lookup map from the string representation of a signal to its
// actual signal
signals map[string]os.Signal
@@ -192,6 +196,14 @@ func (tm *TaskTemplateManager) Stop() {
}
}
+// SetDriverHandle sets the executor
+func (tm *TaskTemplateManager) SetDriverHandle(executor interfaces.ScriptExecutor) {
+ tm.handleLock.Lock()
+ defer tm.handleLock.Unlock()
+ tm.handle = executor
+
+}
+
// run is the long lived loop that handles errors and templates being rendered
func (tm *TaskTemplateManager) run() {
// Runner is nil if there are no templates
@@ -392,6 +404,7 @@ func (tm *TaskTemplateManager) onTemplateRendered(handledRenders map[string]time
var handling []string
signals := make(map[string]struct{})
+ scripts := []*structs.ChangeScript{}
restart := false
var splay time.Duration
@@ -436,6 +449,8 @@ func (tm *TaskTemplateManager) onTemplateRendered(handledRenders map[string]time
signals[tmpl.ChangeSignal] = struct{}{}
case structs.TemplateChangeModeRestart:
restart = true
+ case structs.TemplateChangeModeScript:
+ scripts = append(scripts, tmpl.ChangeScript)
case structs.TemplateChangeModeNoop:
continue
}
@@ -494,6 +509,72 @@ func (tm *TaskTemplateManager) onTemplateRendered(handledRenders map[string]time
}
}
+ // process script execution concurrently
+ var wg sync.WaitGroup
+ for _, script := range scripts {
+ wg.Add(1)
+ go tm.processScript(script, &wg)
+ }
+ wg.Wait()
+}
+
+// handleScriptError is a helper function that produces a TaskKilling event and
+// emits a message
+func (tm *TaskTemplateManager) handleScriptError(script *structs.ChangeScript, msg string) {
+ ev := structs.NewTaskEvent(structs.TaskHookFailed).SetDisplayMessage(msg)
+ tm.config.Events.EmitEvent(ev)
+
+ if script.FailOnError {
+ tm.config.Lifecycle.Kill(context.Background(),
+ structs.NewTaskEvent(structs.TaskKilling).
+ SetFailsTask().
+ SetDisplayMessage("Template script failed, task is being killed"))
+ }
+}
+
+// processScript is used for executing change_mode script and handling errors
+func (tm *TaskTemplateManager) processScript(script *structs.ChangeScript, wg *sync.WaitGroup) {
+ defer wg.Done()
+
+ if tm.handle == nil {
+ failureMsg := fmt.Sprintf(
+ "Template failed to run script %v with arguments %v because task driver doesn't support the exec operation",
+ script.Command,
+ script.Args,
+ )
+ tm.handleScriptError(script, failureMsg)
+ return
+ }
+ _, exitCode, err := tm.handle.Exec(script.Timeout, script.Command, script.Args)
+ if err != nil {
+ failureMsg := fmt.Sprintf(
+ "Template failed to run script %v with arguments %v on change: %v Exit code: %v",
+ script.Command,
+ script.Args,
+ err,
+ exitCode,
+ )
+ tm.handleScriptError(script, failureMsg)
+ return
+ }
+ if exitCode != 0 {
+ failureMsg := fmt.Sprintf(
+ "Template ran script %v with arguments %v on change but it exited with code code: %v",
+ script.Command,
+ script.Args,
+ exitCode,
+ )
+ tm.handleScriptError(script, failureMsg)
+ return
+ }
+ tm.config.Events.EmitEvent(structs.NewTaskEvent(structs.TaskHookMessage).
+ SetDisplayMessage(
+ fmt.Sprintf(
+ "Template successfully ran script %v with arguments: %v. Exit code: %v",
+ script.Command,
+ script.Args,
+ exitCode,
+ )))
}
// allTemplatesNoop returns whether all the managed templates have change mode noop.
diff --git a/client/allocrunner/taskrunner/template/template_test.go b/client/allocrunner/taskrunner/template/template_test.go
index b168e11dc..e4d7ca69e 100644
--- a/client/allocrunner/taskrunner/template/template_test.go
+++ b/client/allocrunner/taskrunner/template/template_test.go
@@ -5,7 +5,6 @@ import (
"context"
"fmt"
"io"
- "io/ioutil"
"os"
"os/user"
"path/filepath"
@@ -123,6 +122,16 @@ func (m *MockTaskHooks) EmitEvent(event *structs.TaskEvent) {
func (m *MockTaskHooks) SetState(state string, event *structs.TaskEvent) {}
+// mockExecutor implements script executor interface
+type mockExecutor struct {
+ DesiredExit int
+ DesiredErr error
+}
+
+func (m *mockExecutor) Exec(timeout time.Duration, cmd string, args []string) ([]byte, int, error) {
+ return []byte{}, m.DesiredExit, m.DesiredErr
+}
+
// testHarness is used to test the TaskTemplateManager by spinning up
// Consul/Vault as needed
type testHarness struct {
@@ -213,7 +222,6 @@ func (h *testHarness) startWithErr() error {
EnvBuilder: h.envBuilder,
MaxTemplateEventRate: h.emitRate,
})
-
return err
}
@@ -381,7 +389,7 @@ func TestTaskTemplateManager_InvalidConfig(t *testing.T) {
func TestTaskTemplateManager_HostPath(t *testing.T) {
ci.Parallel(t)
// Make a template that will render immediately and write it to a tmp file
- f, err := ioutil.TempFile("", "")
+ f, err := os.CreateTemp("", "")
if err != nil {
t.Fatalf("Bad: %v", err)
}
@@ -417,7 +425,7 @@ func TestTaskTemplateManager_HostPath(t *testing.T) {
// Check the file is there
path := filepath.Join(harness.taskDir, file)
- raw, err := ioutil.ReadFile(path)
+ raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read rendered template from %q: %v", path, err)
}
@@ -494,7 +502,7 @@ func TestTaskTemplateManager_Unblock_Static(t *testing.T) {
// Check the file is there
path := filepath.Join(harness.taskDir, file)
- raw, err := ioutil.ReadFile(path)
+ raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read rendered template from %q: %v", path, err)
}
@@ -573,7 +581,7 @@ func TestTaskTemplateManager_Unblock_Static_NomadEnv(t *testing.T) {
// Check the file is there
path := filepath.Join(harness.taskDir, file)
- raw, err := ioutil.ReadFile(path)
+ raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read rendered template from %q: %v", path, err)
}
@@ -598,7 +606,7 @@ func TestTaskTemplateManager_Unblock_Static_AlreadyRendered(t *testing.T) {
// Write the contents
path := filepath.Join(harness.taskDir, file)
- if err := ioutil.WriteFile(path, []byte(content), 0777); err != nil {
+ if err := os.WriteFile(path, []byte(content), 0777); err != nil {
t.Fatalf("Failed to write data: %v", err)
}
@@ -614,7 +622,7 @@ func TestTaskTemplateManager_Unblock_Static_AlreadyRendered(t *testing.T) {
// Check the file is there
path = filepath.Join(harness.taskDir, file)
- raw, err := ioutil.ReadFile(path)
+ raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read rendered template from %q: %v", path, err)
}
@@ -660,7 +668,7 @@ func TestTaskTemplateManager_Unblock_Consul(t *testing.T) {
// Check the file is there
path := filepath.Join(harness.taskDir, file)
- raw, err := ioutil.ReadFile(path)
+ raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read rendered template from %q: %v", path, err)
}
@@ -710,7 +718,7 @@ func TestTaskTemplateManager_Unblock_Vault(t *testing.T) {
// Check the file is there
path := filepath.Join(harness.taskDir, file)
- raw, err := ioutil.ReadFile(path)
+ raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read rendered template from %q: %v", path, err)
}
@@ -755,7 +763,7 @@ func TestTaskTemplateManager_Unblock_Multi_Template(t *testing.T) {
// Check that the static file has been rendered
path := filepath.Join(harness.taskDir, staticFile)
- raw, err := ioutil.ReadFile(path)
+ raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read rendered template from %q: %v", path, err)
}
@@ -776,7 +784,7 @@ func TestTaskTemplateManager_Unblock_Multi_Template(t *testing.T) {
// Check the consul file is there
path = filepath.Join(harness.taskDir, consulFile)
- raw, err = ioutil.ReadFile(path)
+ raw, err = os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read rendered template from %q: %v", path, err)
}
@@ -828,7 +836,7 @@ func TestTaskTemplateManager_FirstRender_Restored(t *testing.T) {
// Check the file is there
path := filepath.Join(harness.taskDir, file)
- raw, err := ioutil.ReadFile(path)
+ raw, err := os.ReadFile(path)
require.NoError(err, "Failed to read rendered template from %q", path)
require.Equal(content, string(raw), "Unexpected template data; got %s, want %q", raw, content)
@@ -922,7 +930,7 @@ func TestTaskTemplateManager_Rerender_Noop(t *testing.T) {
// Check the file is there
path := filepath.Join(harness.taskDir, file)
- raw, err := ioutil.ReadFile(path)
+ raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read rendered template from %q: %v", path, err)
}
@@ -944,7 +952,7 @@ func TestTaskTemplateManager_Rerender_Noop(t *testing.T) {
// Check the file has been updated
path = filepath.Join(harness.taskDir, file)
- raw, err = ioutil.ReadFile(path)
+ raw, err = os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read rendered template from %q: %v", path, err)
}
@@ -1034,7 +1042,7 @@ OUTER:
// Check the files have been updated
path := filepath.Join(harness.taskDir, file1)
- raw, err := ioutil.ReadFile(path)
+ raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read rendered template from %q: %v", path, err)
}
@@ -1044,7 +1052,7 @@ OUTER:
}
path = filepath.Join(harness.taskDir, file2)
- raw, err = ioutil.ReadFile(path)
+ raw, err = os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read rendered template from %q: %v", path, err)
}
@@ -1108,7 +1116,7 @@ OUTER:
// Check the files have been updated
path := filepath.Join(harness.taskDir, file1)
- raw, err := ioutil.ReadFile(path)
+ raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read rendered template from %q: %v", path, err)
}
@@ -1143,7 +1151,7 @@ func TestTaskTemplateManager_Interpolate_Destination(t *testing.T) {
// Check the file is there
actual := fmt.Sprintf("%s.tmpl", harness.node.ID)
path := filepath.Join(harness.taskDir, actual)
- raw, err := ioutil.ReadFile(path)
+ raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read rendered template from %q: %v", path, err)
}
@@ -1201,6 +1209,168 @@ func TestTaskTemplateManager_Signal_Error(t *testing.T) {
require.Contains(harness.mockHooks.KillEvent.DisplayMessage, "failed to send signals")
}
+func TestTaskTemplateManager_ScriptExecution(t *testing.T) {
+ ci.Parallel(t)
+
+ // Make a template that renders based on a key in Consul and triggers script
+ key1 := "bam"
+ key2 := "bar"
+ content1_1 := "cat"
+ content1_2 := "dog"
+ t1 := &structs.Template{
+ EmbeddedTmpl: `
+FOO={{key "bam"}}
+`,
+ DestPath: "test.env",
+ ChangeMode: structs.TemplateChangeModeScript,
+ ChangeScript: &structs.ChangeScript{
+ Command: "/bin/foo",
+ Args: []string{},
+ Timeout: 5 * time.Second,
+ FailOnError: false,
+ },
+ Envvars: true,
+ }
+ t2 := &structs.Template{
+ EmbeddedTmpl: `
+BAR={{key "bar"}}
+`,
+ DestPath: "test2.env",
+ ChangeMode: structs.TemplateChangeModeScript,
+ ChangeScript: &structs.ChangeScript{
+ Command: "/bin/foo",
+ Args: []string{},
+ Timeout: 5 * time.Second,
+ FailOnError: false,
+ },
+ Envvars: true,
+ }
+
+ me := mockExecutor{DesiredExit: 0, DesiredErr: nil}
+ harness := newTestHarness(t, []*structs.Template{t1, t2}, true, false)
+ harness.start(t)
+ harness.manager.SetDriverHandle(&me)
+ defer harness.stop()
+
+ // Ensure no unblock
+ select {
+ case <-harness.mockHooks.UnblockCh:
+ require.Fail(t, "Task unblock should not have been called")
+ case <-time.After(time.Duration(1*testutil.TestMultiplier()) * time.Second):
+ }
+
+ // Write the key to Consul
+ harness.consul.SetKV(t, key1, []byte(content1_1))
+ harness.consul.SetKV(t, key2, []byte(content1_1))
+
+ // Wait for the unblock
+ select {
+ case <-harness.mockHooks.UnblockCh:
+ case <-time.After(time.Duration(5*testutil.TestMultiplier()) * time.Second):
+ require.Fail(t, "Task unblock should have been called")
+ }
+
+ // Update the keys in Consul
+ harness.consul.SetKV(t, key1, []byte(content1_2))
+
+ // Wait for restart
+ timeout := time.After(time.Duration(5*testutil.TestMultiplier()) * time.Second)
+OUTER:
+ for {
+ select {
+ case <-harness.mockHooks.RestartCh:
+ require.Fail(t, "restart not expected")
+ case ev := <-harness.mockHooks.EmitEventCh:
+ if strings.Contains(ev.DisplayMessage, t1.ChangeScript.Command) {
+ break OUTER
+ }
+ case <-harness.mockHooks.SignalCh:
+ require.Fail(t, "signal not expected")
+ case <-timeout:
+ require.Fail(t, "should have received an event")
+ }
+ }
+}
+
+// TestTaskTemplateManager_ScriptExecutionFailTask tests whether we fail the
+// task upon script execution failure if that's how it's configured.
+func TestTaskTemplateManager_ScriptExecutionFailTask(t *testing.T) {
+ ci.Parallel(t)
+ require := require.New(t)
+
+ // Make a template that renders based on a key in Consul and triggers script
+ key1 := "bam"
+ key2 := "bar"
+ content1_1 := "cat"
+ content1_2 := "dog"
+ t1 := &structs.Template{
+ EmbeddedTmpl: `
+FOO={{key "bam"}}
+`,
+ DestPath: "test.env",
+ ChangeMode: structs.TemplateChangeModeScript,
+ ChangeScript: &structs.ChangeScript{
+ Command: "/bin/foo",
+ Args: []string{},
+ Timeout: 5 * time.Second,
+ FailOnError: true,
+ },
+ Envvars: true,
+ }
+ t2 := &structs.Template{
+ EmbeddedTmpl: `
+BAR={{key "bar"}}
+`,
+ DestPath: "test2.env",
+ ChangeMode: structs.TemplateChangeModeScript,
+ ChangeScript: &structs.ChangeScript{
+ Command: "/bin/foo",
+ Args: []string{},
+ Timeout: 5 * time.Second,
+ FailOnError: false,
+ },
+ Envvars: true,
+ }
+
+ me := mockExecutor{DesiredExit: 1, DesiredErr: fmt.Errorf("Script failed")}
+ harness := newTestHarness(t, []*structs.Template{t1, t2}, true, false)
+ harness.start(t)
+ harness.manager.SetDriverHandle(&me)
+ defer harness.stop()
+
+ // Ensure no unblock
+ select {
+ case <-harness.mockHooks.UnblockCh:
+ require.Fail("Task unblock should not have been called")
+ case <-time.After(time.Duration(1*testutil.TestMultiplier()) * time.Second):
+ }
+
+ // Write the key to Consul
+ harness.consul.SetKV(t, key1, []byte(content1_1))
+ harness.consul.SetKV(t, key2, []byte(content1_1))
+
+ // Wait for the unblock
+ select {
+ case <-harness.mockHooks.UnblockCh:
+ case <-time.After(time.Duration(5*testutil.TestMultiplier()) * time.Second):
+ require.Fail("Task unblock should have been called")
+ }
+
+ // Update the keys in Consul
+ harness.consul.SetKV(t, key1, []byte(content1_2))
+
+ // Wait for kill channel
+ select {
+ case <-harness.mockHooks.KillCh:
+ break
+ case <-time.After(time.Duration(1*testutil.TestMultiplier()) * time.Second):
+ require.Fail("Should have received a signals: %+v", harness.mockHooks)
+ }
+
+ require.NotNil(harness.mockHooks.KillEvent)
+ require.Contains(harness.mockHooks.KillEvent.DisplayMessage, "task is being killed")
+}
+
// TestTaskTemplateManager_FiltersProcessEnvVars asserts that we only render
// environment variables found in task env-vars and not read the nomad host
// process environment variables. nomad host process environment variables
@@ -1241,7 +1411,7 @@ TEST_ENV_NOT_FOUND: {{env "` + testenv + `_NOTFOUND" }}`
// Check the file is there
path := filepath.Join(harness.taskDir, file)
- raw, err := ioutil.ReadFile(path)
+ raw, err := os.ReadFile(path)
require.NoError(t, err)
require.Equal(t, expected, string(raw))
@@ -1297,7 +1467,7 @@ func TestTaskTemplateManager_Env_Missing(t *testing.T) {
d := t.TempDir()
// Fake writing the file so we don't have to run the whole template manager
- err := ioutil.WriteFile(filepath.Join(d, "exists.env"), []byte("FOO=bar\n"), 0644)
+ err := os.WriteFile(filepath.Join(d, "exists.env"), []byte("FOO=bar\n"), 0644)
if err != nil {
t.Fatalf("error writing template file: %v", err)
}
@@ -1330,7 +1500,7 @@ func TestTaskTemplateManager_Env_InterpolatedDest(t *testing.T) {
d := t.TempDir()
// Fake writing the file so we don't have to run the whole template manager
- err := ioutil.WriteFile(filepath.Join(d, "exists.env"), []byte("FOO=bar\n"), 0644)
+ err := os.WriteFile(filepath.Join(d, "exists.env"), []byte("FOO=bar\n"), 0644)
if err != nil {
t.Fatalf("error writing template file: %v", err)
}
@@ -1365,11 +1535,11 @@ func TestTaskTemplateManager_Env_Multi(t *testing.T) {
d := t.TempDir()
// Fake writing the files so we don't have to run the whole template manager
- err := ioutil.WriteFile(filepath.Join(d, "zzz.env"), []byte("FOO=bar\nSHARED=nope\n"), 0644)
+ err := os.WriteFile(filepath.Join(d, "zzz.env"), []byte("FOO=bar\nSHARED=nope\n"), 0644)
if err != nil {
t.Fatalf("error writing template file 1: %v", err)
}
- err = ioutil.WriteFile(filepath.Join(d, "aaa.env"), []byte("BAR=foo\nSHARED=yup\n"), 0644)
+ err = os.WriteFile(filepath.Join(d, "aaa.env"), []byte("BAR=foo\nSHARED=yup\n"), 0644)
if err != nil {
t.Fatalf("error writing template file 2: %v", err)
}
@@ -2209,7 +2379,7 @@ func TestTaskTemplateManager_writeToFile_Disabled(t *testing.T) {
// Check the file is not there
path := filepath.Join(harness.taskDir, file)
- _, err := ioutil.ReadFile(path)
+ _, err := os.ReadFile(path)
require.Error(t, err)
}
@@ -2262,13 +2432,13 @@ func TestTaskTemplateManager_writeToFile(t *testing.T) {
// Check the templated file is there
path := filepath.Join(harness.taskDir, file)
- r, err := ioutil.ReadFile(path)
+ r, err := os.ReadFile(path)
require.NoError(t, err)
require.True(t, bytes.HasSuffix(r, []byte("...done\n")), string(r))
// Check that writeToFile was allowed
path = filepath.Join(harness.taskDir, "writetofile.out")
- r, err = ioutil.ReadFile(path)
+ r, err = os.ReadFile(path)
require.NoError(t, err)
require.Equal(t, "hello", string(r))
}
diff --git a/client/allocrunner/taskrunner/template_hook.go b/client/allocrunner/taskrunner/template_hook.go
index cdc6ee16f..4e14fcc43 100644
--- a/client/allocrunner/taskrunner/template_hook.go
+++ b/client/allocrunner/taskrunner/template_hook.go
@@ -115,6 +115,19 @@ func (h *templateHook) Prestart(ctx context.Context, req *interfaces.TaskPrestar
return nil
}
+func (h *templateHook) Poststart(ctx context.Context, req *interfaces.TaskPoststartRequest, resp *interfaces.TaskPoststartResponse) error {
+ if req.DriverExec != nil {
+ h.templateManager.SetDriverHandle(req.DriverExec)
+ } else {
+ for _, template := range h.config.templates {
+ if template.ChangeMode == structs.TemplateChangeModeScript {
+ return fmt.Errorf("template has change mode set to 'script' but the driver it uses does not provide exec capability")
+ }
+ }
+ }
+ return nil
+}
+
func (h *templateHook) newManager() (unblock chan struct{}, err error) {
unblock = make(chan struct{})
m, err := template.NewTaskTemplateManager(&template.TaskTemplateManagerConfig{
diff --git a/command/agent/job_endpoint.go b/command/agent/job_endpoint.go
index 20fbca217..2fbd8f5d5 100644
--- a/command/agent/job_endpoint.go
+++ b/command/agent/job_endpoint.go
@@ -1216,6 +1216,7 @@ func ApiTaskToStructsTask(job *structs.Job, group *structs.TaskGroup,
EmbeddedTmpl: *template.EmbeddedTmpl,
ChangeMode: *template.ChangeMode,
ChangeSignal: *template.ChangeSignal,
+ ChangeScript: apiChangeScriptToStructsChangeScript(template.ChangeScript),
Splay: *template.Splay,
Perms: *template.Perms,
Uid: template.Uid,
@@ -1224,7 +1225,7 @@ func ApiTaskToStructsTask(job *structs.Job, group *structs.TaskGroup,
RightDelim: *template.RightDelim,
Envvars: *template.Envvars,
VaultGrace: *template.VaultGrace,
- Wait: ApiWaitConfigToStructsWaitConfig(template.Wait),
+ Wait: apiWaitConfigToStructsWaitConfig(template.Wait),
})
}
}
@@ -1243,16 +1244,29 @@ func ApiTaskToStructsTask(job *structs.Job, group *structs.TaskGroup,
}
}
-// ApiWaitConfigToStructsWaitConfig is a copy and type conversion between the API
+// apiWaitConfigToStructsWaitConfig is a copy and type conversion between the API
// representation of a WaitConfig from a struct representation of a WaitConfig.
-func ApiWaitConfigToStructsWaitConfig(waitConfig *api.WaitConfig) *structs.WaitConfig {
+func apiWaitConfigToStructsWaitConfig(waitConfig *api.WaitConfig) *structs.WaitConfig {
if waitConfig == nil {
return nil
}
return &structs.WaitConfig{
- Min: &*waitConfig.Min,
- Max: &*waitConfig.Max,
+ Min: waitConfig.Min,
+ Max: waitConfig.Max,
+ }
+}
+
+func apiChangeScriptToStructsChangeScript(changeScript *api.ChangeScript) *structs.ChangeScript {
+ if changeScript == nil {
+ return nil
+ }
+
+ return &structs.ChangeScript{
+ Command: *changeScript.Command,
+ Args: changeScript.Args,
+ Timeout: *changeScript.Timeout,
+ FailOnError: *changeScript.FailOnError,
}
}
diff --git a/command/agent/job_endpoint_test.go b/command/agent/job_endpoint_test.go
index 0302d7cc7..a0d70423e 100644
--- a/command/agent/job_endpoint_test.go
+++ b/command/agent/job_endpoint_test.go
@@ -2730,13 +2730,19 @@ func TestJobs_ApiJobToStructsJob(t *testing.T) {
EmbeddedTmpl: pointer.Of("embedded"),
ChangeMode: pointer.Of("change"),
ChangeSignal: pointer.Of("signal"),
- Splay: pointer.Of(1 * time.Minute),
- Perms: pointer.Of("666"),
- Uid: pointer.Of(1000),
- Gid: pointer.Of(1000),
- LeftDelim: pointer.Of("abc"),
- RightDelim: pointer.Of("def"),
- Envvars: pointer.Of(true),
+ ChangeScript: &api.ChangeScript{
+ Command: pointer.Of("/bin/foo"),
+ Args: []string{"-h"},
+ Timeout: pointer.Of(5 * time.Second),
+ FailOnError: pointer.Of(false),
+ },
+ Splay: pointer.Of(1 * time.Minute),
+ Perms: pointer.Of("666"),
+ Uid: pointer.Of(1000),
+ Gid: pointer.Of(1000),
+ LeftDelim: pointer.Of("abc"),
+ RightDelim: pointer.Of("def"),
+ Envvars: pointer.Of(true),
Wait: &api.WaitConfig{
Min: pointer.Of(5 * time.Second),
Max: pointer.Of(10 * time.Second),
@@ -3137,13 +3143,19 @@ func TestJobs_ApiJobToStructsJob(t *testing.T) {
EmbeddedTmpl: "embedded",
ChangeMode: "change",
ChangeSignal: "SIGNAL",
- Splay: 1 * time.Minute,
- Perms: "666",
- Uid: pointer.Of(1000),
- Gid: pointer.Of(1000),
- LeftDelim: "abc",
- RightDelim: "def",
- Envvars: true,
+ ChangeScript: &structs.ChangeScript{
+ Command: "/bin/foo",
+ Args: []string{"-h"},
+ Timeout: 5 * time.Second,
+ FailOnError: false,
+ },
+ Splay: 1 * time.Minute,
+ Perms: "666",
+ Uid: pointer.Of(1000),
+ Gid: pointer.Of(1000),
+ LeftDelim: "abc",
+ RightDelim: "def",
+ Envvars: true,
Wait: &structs.WaitConfig{
Min: pointer.Of(5 * time.Second),
Max: pointer.Of(10 * time.Second),
diff --git a/jobspec/parse_task.go b/jobspec/parse_task.go
index 016de86ff..a43ffded6 100644
--- a/jobspec/parse_task.go
+++ b/jobspec/parse_task.go
@@ -433,10 +433,19 @@ func parseArtifactOption(result map[string]string, list *ast.ObjectList) error {
func parseTemplates(result *[]*api.Template, list *ast.ObjectList) error {
for _, o := range list.Elem().Items {
+ // we'll need a list of all ast objects for later
+ var listVal *ast.ObjectList
+ if ot, ok := o.Val.(*ast.ObjectType); ok {
+ listVal = ot.List
+ } else {
+ return fmt.Errorf("should be an object")
+ }
+
// Check for invalid keys
valid := []string{
"change_mode",
"change_signal",
+ "change_script",
"data",
"destination",
"left_delimiter",
@@ -457,6 +466,7 @@ func parseTemplates(result *[]*api.Template, list *ast.ObjectList) error {
if err := hcl.DecodeObject(&m, o.Val); err != nil {
return err
}
+ delete(m, "change_script") // change_script is its own object
templ := &api.Template{
ChangeMode: stringToPtr("restart"),
@@ -476,6 +486,40 @@ func parseTemplates(result *[]*api.Template, list *ast.ObjectList) error {
return err
}
+ // If we have change_script, parse it
+ if o := listVal.Filter("change_script"); len(o.Items) > 0 {
+ if len(o.Items) != 1 {
+ return fmt.Errorf(
+ "change_script -> expected single stanza, got %d", len(o.Items),
+ )
+ }
+ var m map[string]interface{}
+ changeScriptBlock := o.Items[0]
+
+ // check for invalid fields
+ valid := []string{"command", "args", "timeout", "fail_on_error"}
+ if err := checkHCLKeys(changeScriptBlock.Val, valid); err != nil {
+ return multierror.Prefix(err, "change_script ->")
+ }
+
+ if err := hcl.DecodeObject(&m, changeScriptBlock.Val); err != nil {
+ return err
+ }
+
+ templ.ChangeScript = &api.ChangeScript{}
+ dec, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
+ DecodeHook: mapstructure.StringToTimeDurationHookFunc(),
+ WeaklyTypedInput: true,
+ Result: templ.ChangeScript,
+ })
+ if err != nil {
+ return err
+ }
+ if err := dec.Decode(m); err != nil {
+ return err
+ }
+ }
+
*result = append(*result, templ)
}
diff --git a/jobspec/parse_test.go b/jobspec/parse_test.go
index 75a8325ff..82819e9a0 100644
--- a/jobspec/parse_test.go
+++ b/jobspec/parse_test.go
@@ -22,6 +22,10 @@ const (
// templateChangeModeRestart marks that the task should be restarted if the
templateChangeModeRestart = "restart"
+
+ // templateChangeModeScript marks that ac script should be executed on
+ // template re-render
+ templateChangeModeScript = "script"
)
// Helper functions below are only used by this test suite
@@ -380,7 +384,13 @@ func TestParse(t *testing.T) {
{
SourcePath: stringToPtr("bar"),
DestPath: stringToPtr("bar"),
- ChangeMode: stringToPtr(templateChangeModeRestart),
+ ChangeMode: stringToPtr(templateChangeModeScript),
+ ChangeScript: &api.ChangeScript{
+ Args: []string{"-debug", "-verbose"},
+ Command: stringToPtr("/bin/foo"),
+ Timeout: timeToPtr(5 * time.Second),
+ FailOnError: boolToPtr(false),
+ },
Splay: timeToPtr(5 * time.Second),
Perms: stringToPtr("777"),
Uid: intToPtr(1001),
diff --git a/jobspec/test-fixtures/basic.hcl b/jobspec/test-fixtures/basic.hcl
index 273ae6ebd..a749bf91d 100644
--- a/jobspec/test-fixtures/basic.hcl
+++ b/jobspec/test-fixtures/basic.hcl
@@ -315,8 +315,15 @@ job "binstore-storagelocker" {
}
template {
- source = "bar"
- destination = "bar"
+ source = "bar"
+ destination = "bar"
+ change_mode = "script"
+ change_script {
+ command = "/bin/foo"
+ args = ["-debug", "-verbose"]
+ timeout = "5s"
+ fail_on_error = false
+ }
perms = "777"
uid = 1001
gid = 20
diff --git a/jobspec2/hcl_conversions.go b/jobspec2/hcl_conversions.go
index 423b30924..2afd71ed2 100644
--- a/jobspec2/hcl_conversions.go
+++ b/jobspec2/hcl_conversions.go
@@ -10,6 +10,7 @@ import (
"github.com/hashicorp/hcl/v2/gohcl"
"github.com/hashicorp/hcl/v2/hcldec"
"github.com/hashicorp/nomad/api"
+ "github.com/hashicorp/nomad/helper/pointer"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/gocty"
)
@@ -116,7 +117,7 @@ func decodeAffinity(body hcl.Body, ctx *hcl.EvalContext, val interface{}) hcl.Di
weight := v.GetAttr("weight")
if !weight.IsNull() {
w, _ := weight.AsBigFloat().Int64()
- a.Weight = int8ToPtr(int8(w))
+ a.Weight = pointer.Of(int8(w))
}
// If "version" is provided, set the operand
diff --git a/jobspec2/parse_job.go b/jobspec2/parse_job.go
index 9b533874f..a4ee50343 100644
--- a/jobspec2/parse_job.go
+++ b/jobspec2/parse_job.go
@@ -4,6 +4,7 @@ import (
"time"
"github.com/hashicorp/nomad/api"
+ "github.com/hashicorp/nomad/helper/pointer"
)
func normalizeJob(jc *jobConfig) {
@@ -59,10 +60,10 @@ func normalizeVault(v *api.Vault) {
}
if v.Env == nil {
- v.Env = boolToPtr(true)
+ v.Env = pointer.Of(true)
}
if v.ChangeMode == nil {
- v.ChangeMode = stringToPtr("restart")
+ v.ChangeMode = pointer.Of("restart")
}
}
@@ -102,29 +103,32 @@ func normalizeTemplates(templates []*api.Template) {
for _, t := range templates {
if t.ChangeMode == nil {
- t.ChangeMode = stringToPtr("restart")
+ t.ChangeMode = pointer.Of("restart")
}
if t.Perms == nil {
- t.Perms = stringToPtr("0644")
+ t.Perms = pointer.Of("0644")
}
if t.Splay == nil {
- t.Splay = durationToPtr(5 * time.Second)
+ t.Splay = pointer.Of(5 * time.Second)
}
+ normalizeChangeScript(t.ChangeScript)
}
}
-func int8ToPtr(v int8) *int8 {
- return &v
-}
+func normalizeChangeScript(ch *api.ChangeScript) {
+ if ch == nil {
+ return
+ }
-func boolToPtr(v bool) *bool {
- return &v
-}
+ if ch.Args == nil {
+ ch.Args = []string{}
+ }
-func stringToPtr(v string) *string {
- return &v
-}
+ if ch.Timeout == nil {
+ ch.Timeout = pointer.Of(5 * time.Second)
+ }
-func durationToPtr(v time.Duration) *time.Duration {
- return &v
+ if ch.FailOnError == nil {
+ ch.FailOnError = pointer.Of(false)
+ }
}
diff --git a/jobspec2/parse_test.go b/jobspec2/parse_test.go
index 75c10f67e..806412cad 100644
--- a/jobspec2/parse_test.go
+++ b/jobspec2/parse_test.go
@@ -9,6 +9,7 @@ import (
"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/ci"
+ "github.com/hashicorp/nomad/helper/pointer"
"github.com/hashicorp/nomad/jobspec"
"github.com/stretchr/testify/require"
)
@@ -644,13 +645,13 @@ job "job-webserver" {
{
"prod",
&api.Job{
- ID: stringToPtr("job-webserver"),
- Name: stringToPtr("job-webserver"),
+ ID: pointer.Of("job-webserver"),
+ Name: pointer.Of("job-webserver"),
Datacenters: []string{"prod-dc1", "prod-dc2"},
TaskGroups: []*api.TaskGroup{
{
- Name: stringToPtr("group-webserver"),
- Count: intToPtr(20),
+ Name: pointer.Of("group-webserver"),
+ Count: pointer.Of(20),
Tasks: []*api.Task{
{
@@ -670,13 +671,13 @@ job "job-webserver" {
{
"staging",
&api.Job{
- ID: stringToPtr("job-webserver"),
- Name: stringToPtr("job-webserver"),
+ ID: pointer.Of("job-webserver"),
+ Name: pointer.Of("job-webserver"),
Datacenters: []string{"dc1"},
TaskGroups: []*api.TaskGroup{
{
- Name: stringToPtr("group-webserver"),
- Count: intToPtr(3),
+ Name: pointer.Of("group-webserver"),
+ Count: pointer.Of(3),
Tasks: []*api.Task{
{
@@ -696,13 +697,13 @@ job "job-webserver" {
{
"unknown",
&api.Job{
- ID: stringToPtr("job-webserver"),
- Name: stringToPtr("job-webserver"),
+ ID: pointer.Of("job-webserver"),
+ Name: pointer.Of("job-webserver"),
Datacenters: []string{},
TaskGroups: []*api.TaskGroup{
{
- Name: stringToPtr("group-webserver"),
- Count: intToPtr(0),
+ Name: pointer.Of("group-webserver"),
+ Count: pointer.Of(0),
Tasks: []*api.Task{
{
@@ -1005,11 +1006,11 @@ func TestParseServiceCheck(t *testing.T) {
require.NoError(t, err)
expectedJob := &api.Job{
- ID: stringToPtr("group_service_check_script"),
- Name: stringToPtr("group_service_check_script"),
+ ID: pointer.Of("group_service_check_script"),
+ Name: pointer.Of("group_service_check_script"),
TaskGroups: []*api.TaskGroup{
{
- Name: stringToPtr("group"),
+ Name: pointer.Of("group"),
Services: []*api.Service{
{
Name: "foo-service",
diff --git a/nomad/structs/diff.go b/nomad/structs/diff.go
index bf827bf63..77f511f71 100644
--- a/nomad/structs/diff.go
+++ b/nomad/structs/diff.go
@@ -1649,6 +1649,39 @@ func waitConfigDiff(old, new *WaitConfig, contextual bool) *ObjectDiff {
return diff
}
+// changeScriptDiff returns the diff of two ChangeScript objects. If contextual
+// diff is enabled, all fields will be returned, even if no diff occurred.
+func changeScriptDiff(old, new *ChangeScript, contextual bool) *ObjectDiff {
+ diff := &ObjectDiff{Type: DiffTypeNone, Name: "ChangeScript"}
+ var oldPrimitiveFlat, newPrimitiveFlat map[string]string
+
+ if reflect.DeepEqual(old, new) {
+ return nil
+ } else if old == nil {
+ old = &ChangeScript{}
+ diff.Type = DiffTypeAdded
+ newPrimitiveFlat = flatmap.Flatten(new, nil, true)
+ } else if new == nil {
+ new = &ChangeScript{}
+ diff.Type = DiffTypeDeleted
+ oldPrimitiveFlat = flatmap.Flatten(old, nil, true)
+ } else {
+ diff.Type = DiffTypeEdited
+ oldPrimitiveFlat = flatmap.Flatten(old, nil, true)
+ newPrimitiveFlat = flatmap.Flatten(new, nil, true)
+ }
+
+ // Diff the primitive fields.
+ diff.Fields = fieldDiffs(oldPrimitiveFlat, newPrimitiveFlat, contextual)
+
+ // Args diffs
+ if setDiff := stringSetDiff(old.Args, new.Args, "Args", contextual); setDiff != nil {
+ diff.Objects = append(diff.Objects, setDiff)
+ }
+
+ return diff
+}
+
// templateDiff returns the diff of two Consul Template objects. If contextual diff is
// enabled, all fields will be returned, even if no diff occurred.
func templateDiff(old, new *Template, contextual bool) *ObjectDiff {
@@ -1697,6 +1730,13 @@ func templateDiff(old, new *Template, contextual bool) *ObjectDiff {
diff.Objects = append(diff.Objects, waitDiffs)
}
+ // ChangeScript diffs
+ if changeScriptDiffs := changeScriptDiff(
+ old.ChangeScript, new.ChangeScript, contextual,
+ ); changeScriptDiffs != nil {
+ diff.Objects = append(diff.Objects, changeScriptDiffs)
+ }
+
return diff
}
diff --git a/nomad/structs/diff_test.go b/nomad/structs/diff_test.go
index f141f06a5..6eca9ab99 100644
--- a/nomad/structs/diff_test.go
+++ b/nomad/structs/diff_test.go
@@ -7042,10 +7042,16 @@ func TestTaskDiff(t *testing.T) {
EmbeddedTmpl: "baz",
ChangeMode: "bam",
ChangeSignal: "SIGHUP",
- Splay: 1,
- Perms: "0644",
- Uid: pointer.Of(1001),
- Gid: pointer.Of(21),
+ ChangeScript: &ChangeScript{
+ Command: "/bin/foo",
+ Args: []string{"-debug"},
+ Timeout: 5,
+ FailOnError: false,
+ },
+ Splay: 1,
+ Perms: "0644",
+ Uid: pointer.Of(1001),
+ Gid: pointer.Of(21),
Wait: &WaitConfig{
Min: pointer.Of(5 * time.Second),
Max: pointer.Of(5 * time.Second),
@@ -7057,11 +7063,17 @@ func TestTaskDiff(t *testing.T) {
EmbeddedTmpl: "baz2",
ChangeMode: "bam2",
ChangeSignal: "SIGHUP2",
- Splay: 2,
- Perms: "0666",
- Uid: pointer.Of(1000),
- Gid: pointer.Of(20),
- Envvars: true,
+ ChangeScript: &ChangeScript{
+ Command: "/bin/foo2",
+ Args: []string{"-debugs"},
+ Timeout: 6,
+ FailOnError: false,
+ },
+ Splay: 2,
+ Perms: "0666",
+ Uid: pointer.Of(1000),
+ Gid: pointer.Of(20),
+ Envvars: true,
},
},
},
@@ -7073,10 +7085,16 @@ func TestTaskDiff(t *testing.T) {
EmbeddedTmpl: "baz new",
ChangeMode: "bam",
ChangeSignal: "SIGHUP",
- Splay: 1,
- Perms: "0644",
- Uid: pointer.Of(1001),
- Gid: pointer.Of(21),
+ ChangeScript: &ChangeScript{
+ Command: "/bin/foo",
+ Args: []string{"-debug"},
+ Timeout: 5,
+ FailOnError: false,
+ },
+ Splay: 1,
+ Perms: "0644",
+ Uid: pointer.Of(1001),
+ Gid: pointer.Of(21),
Wait: &WaitConfig{
Min: pointer.Of(5 * time.Second),
Max: pointer.Of(10 * time.Second),
@@ -7088,10 +7106,16 @@ func TestTaskDiff(t *testing.T) {
EmbeddedTmpl: "baz3",
ChangeMode: "bam3",
ChangeSignal: "SIGHUP3",
- Splay: 3,
- Perms: "0776",
- Uid: pointer.Of(1002),
- Gid: pointer.Of(22),
+ ChangeScript: &ChangeScript{
+ Command: "/bin/foo3",
+ Args: []string{"-debugss"},
+ Timeout: 7,
+ FailOnError: false,
+ },
+ Splay: 3,
+ Perms: "0776",
+ Uid: pointer.Of(1002),
+ Gid: pointer.Of(22),
Wait: &WaitConfig{
Min: pointer.Of(5 * time.Second),
Max: pointer.Of(10 * time.Second),
@@ -7218,6 +7242,44 @@ func TestTaskDiff(t *testing.T) {
},
},
},
+ {
+ Type: DiffTypeAdded,
+ Name: "ChangeScript",
+ Fields: []*FieldDiff{
+ {
+ Type: DiffTypeAdded,
+ Name: "Command",
+ Old: "",
+ New: "/bin/foo3",
+ },
+ {
+ Type: DiffTypeAdded,
+ Name: "FailOnError",
+ Old: "",
+ New: "false",
+ },
+ {
+ Type: DiffTypeAdded,
+ Name: "Timeout",
+ Old: "",
+ New: "7",
+ },
+ },
+ Objects: []*ObjectDiff{
+ {
+ Type: DiffTypeAdded,
+ Name: "Args",
+ Fields: []*FieldDiff{
+ {
+ Type: DiffTypeAdded,
+ Name: "Args",
+ Old: "",
+ New: "-debugss",
+ },
+ },
+ },
+ },
+ },
},
},
{
@@ -7291,6 +7353,46 @@ func TestTaskDiff(t *testing.T) {
New: "",
},
},
+ Objects: []*ObjectDiff{
+ {
+ Type: DiffTypeDeleted,
+ Name: "ChangeScript",
+ Fields: []*FieldDiff{
+ {
+ Type: DiffTypeDeleted,
+ Name: "Command",
+ Old: "/bin/foo2",
+ New: "",
+ },
+ {
+ Type: DiffTypeDeleted,
+ Name: "FailOnError",
+ Old: "false",
+ New: "",
+ },
+ {
+ Type: DiffTypeDeleted,
+ Name: "Timeout",
+ Old: "6",
+ New: "",
+ },
+ },
+ Objects: []*ObjectDiff{
+ {
+ Type: DiffTypeDeleted,
+ Name: "Args",
+ Fields: []*FieldDiff{
+ {
+ Type: DiffTypeDeleted,
+ Name: "Args",
+ Old: "-debugs",
+ New: "",
+ },
+ },
+ },
+ },
+ },
+ },
},
},
},
diff --git a/nomad/structs/structs.go b/nomad/structs/structs.go
index e23b6cef9..839feece5 100644
--- a/nomad/structs/structs.go
+++ b/nomad/structs/structs.go
@@ -7631,14 +7631,32 @@ const (
// TemplateChangeModeRestart marks that the task should be restarted if the
// template is re-rendered
TemplateChangeModeRestart = "restart"
+
+ // TemplateChangeModeScript marks that the task should trigger a script if
+ // the template is re-rendered
+ TemplateChangeModeScript = "script"
)
var (
// TemplateChangeModeInvalidError is the error for when an invalid change
// mode is given
- TemplateChangeModeInvalidError = errors.New("Invalid change mode. Must be one of the following: noop, signal, restart")
+ TemplateChangeModeInvalidError = errors.New("Invalid change mode. Must be one of the following: noop, signal, script, restart")
)
+// ChangeScript holds the configuration for the script that is executed if
+// change mode is set to script
+type ChangeScript struct {
+ // Command is the full path to the script
+ Command string
+ // Args is a slice of arguments passed to the script
+ Args []string
+ // Timeout is the amount of seconds we wait for the script to finish
+ Timeout time.Duration
+ // FailOnError indicates whether a task should fail in case script execution
+ // fails or log script failure and don't interrupt the task
+ FailOnError bool
+}
+
// Template represents a template configuration to be rendered for a given task
type Template struct {
// SourcePath is the path to the template to be rendered
@@ -7658,6 +7676,10 @@ type Template struct {
// requires it.
ChangeSignal string
+ // ChangeScript is the configuration of the script. It's required if
+ // ChangeMode is set to script.
+ ChangeScript *ChangeScript
+
// Splay is used to avoid coordinated restarts of processes by applying a
// random wait between 0 and the given splay value before signalling the
// application of a change
@@ -7756,6 +7778,10 @@ func (t *Template) Validate() error {
if t.Envvars {
_ = multierror.Append(&mErr, fmt.Errorf("cannot use signals with env var templates"))
}
+ case TemplateChangeModeScript:
+ if t.ChangeScript.Command == "" {
+ _ = multierror.Append(&mErr, fmt.Errorf("must specify script path value when change mode is script"))
+ }
default:
_ = multierror.Append(&mErr, TemplateChangeModeInvalidError)
}
@@ -8061,6 +8087,10 @@ const (
// TaskHookFailed indicates that one of the hooks for a task failed.
TaskHookFailed = "Task hook failed"
+ // TaskHookMessage indicates that one of the hooks for a task emitted a
+ // message.
+ TaskHookMessage = "Task hook message"
+
// TaskRestoreFailed indicates Nomad was unable to reattach to a
// restored task.
TaskRestoreFailed = "Failed Restoring Task"
diff --git a/website/content/api-docs/json-jobs.mdx b/website/content/api-docs/json-jobs.mdx
index ffc7c89a1..9bc6896a9 100644
--- a/website/content/api-docs/json-jobs.mdx
+++ b/website/content/api-docs/json-jobs.mdx
@@ -1055,11 +1055,32 @@ README][ct].
- `"noop"` - take no action (continue running the task)
- `"restart"` - restart the task
- `"signal"` - send a configurable signal to the task
+ - `"script"` - run a script
- `ChangeSignal` - Specifies the signal to send to the task as a string like
"SIGUSR1" or "SIGINT". This option is required if the `ChangeMode` is
`signal`.
+- `ChangeScript` - Configures the script triggered on template change. This
+ option is required if the `ChangeMode` is `script`.
+
+ The `ChangeScript` object supports the following attributes:
+
+ - `Command` - Specifies the full path to a script or executable that is to be
+ executed on template change. Path is relative to the driver, e.g., if running
+ with a container driver the path must be existing in the container. This
+ option is required is the `change_mode` is `script`.
+
+ - `Args` - List of arguments that are passed to the script that is to be
+ executed on template change.
+
+ - `Timeout` - Timeout for script execution specified using a label suffix like
+ "30s" or "1h". Default value is `"5s"`.
+
+ - `FailOnError` - If `true`, Nomad will kill the task if the script execution
+ fails. If `false`, script failure will be logged but the task will continue
+ uninterrupted. Default value is `false`.
+
- `DestPath` - Specifies the location where the resulting template should be
rendered, relative to the task directory.
@@ -1080,14 +1101,14 @@ README][ct].
- `Uid` - Specifies the rendered template owner's user ID.
~> **Caveat:** Works only on Unix-based systems. Be careful when using
- containerized drivers, suck as `docker` or `podman`, as groups and users
+ containerized drivers, such as `docker` or `podman`, as groups and users
inside the container may have different IDs than on the host system. This
feature will also **not** work with Docker Desktop.
- `Gid` - Specifies the rendered template owner's group ID.
~> **Caveat:** Works only on Unix-based systems. Be careful when using
- containerized drivers, suck as `docker` or `podman`, as groups and users
+ containerized drivers, such as `docker` or `podman`, as groups and users
inside the container may have different IDs than on the host system. This
feature will also **not** work with Docker Desktop.
diff --git a/website/content/docs/job-specification/change_script.mdx b/website/content/docs/job-specification/change_script.mdx
new file mode 100644
index 000000000..1b10e36b7
--- /dev/null
+++ b/website/content/docs/job-specification/change_script.mdx
@@ -0,0 +1,85 @@
+---
+layout: docs
+page_title: change_script Stanza - Job Specification
+description: The "change_script" stanza configures a script to be run on template re-render.
+---
+
+# `change_script` Stanza
+
+
+
+The `change_script` stanza allows operators to configure scripts that
+will be executed on template change. This stanza is only used when template
+`change_mode` is set to `script`.
+
+```hcl
+job "docs" {
+ group "example" {
+ task "server" {
+ template {
+ source = "local/redis.conf.tpl"
+ destination = "local/redis.conf"
+ change_mode = "script"
+ change_script {
+ command = "/bin/foo"
+ args = ["-verbose", "-debug"]
+ timeout = "5s"
+ fail_on_error = false
+ }
+ }
+ }
+ }
+}
+```
+
+## `change_script` Parameters
+
+- `command` `(string: "")` - Specifies the full path to a script or executable
+ that is to be executed on template change. The command must return exit code 0
+ to be considered successful. Path is relative to the driver, e.g., if running
+ with a container driver the path must be existing in the container. This option
+ is required if `change_mode` is `script`.
+
+- `args` `(array: [])` - List of arguments that are passed to the script
+ that is to be executed on template change.
+
+- `timeout` `(string: "5s")` - Timeout for script execution specified using a
+ label suffix like `"30s"` or `"1h"`.
+
+- `fail_on_error` `(bool: false)` - If `true`, Nomad will kill the task if the
+ script execution fails. If `false`, script failure will be logged but the task
+ will continue uninterrupted.
+
+### Template as a script example
+
+Below is an example of how a script can be embedded in a `data` block of another
+`template` stanza:
+
+```hcl
+job "docs" {
+ group "example" {
+ task "server" {
+ template {
+ data = "{{key \"my_key\"}}"
+ destination = "local/test"
+ change_mode = "script"
+
+ change_script {
+ path = "/local/script.sh"
+ }
+ }
+
+ template {
+ data = <([ChangeScript][]: nil) - Configures the script
+ triggered on template change. This option is required if the `change_mode` is
+ `script`.
+
- `data` `(string: "")` - Specifies the raw template to execute. One of `source`
or `data` must be specified, but not both. This is useful for smaller
templates, but we recommend using `source` for larger templates.
@@ -561,6 +566,7 @@ options](/docs/configuration/client#options):
files on the client host via the `file` function. By default templates can
access files only within the [task working directory].
+[changescript]: /docs/job-specification/change_script 'Nomad change_script Job Specification'
[ct]: https://github.com/hashicorp/consul-template 'Consul Template by HashiCorp'
[ct_api]: https://github.com/hashicorp/consul-template/blob/master/docs/templating-language.md 'Consul Template API by HashiCorp'
[ct_api_connect]: https://github.com/hashicorp/consul-template/blob/master/docs/templating-language.md#connect 'Consul Template API by HashiCorp - connect'
diff --git a/website/data/docs-nav-data.json b/website/data/docs-nav-data.json
index c543d7e58..a721fbe78 100644
--- a/website/data/docs-nav-data.json
+++ b/website/data/docs-nav-data.json
@@ -1358,6 +1358,10 @@
"title": "affinity",
"path": "job-specification/affinity"
},
+ {
+ "title": "change_script",
+ "path": "job-specification/change_script"
+ },
{
"title": "check",
"path": "job-specification/check"