diff --git a/api/tasks.go b/api/tasks.go index a0624df90dc..1d945bc460c 100644 --- a/api/tasks.go +++ b/api/tasks.go @@ -916,10 +916,11 @@ func (wc *WaitConfig) Copy() *WaitConfig { } 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"` + 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"` + RunOnFirstRender *bool `mapstructure:"run_on_first_render" hcl:"run_on_first_render,optional"` } func (ch *ChangeScript) Canonicalize() { @@ -935,6 +936,9 @@ func (ch *ChangeScript) Canonicalize() { if ch.FailOnError == nil { ch.FailOnError = pointerOf(false) } + if ch.RunOnFirstRender == nil { + ch.RunOnFirstRender = pointerOf(false) + } } type Template struct { diff --git a/client/allocrunner/taskrunner/template/template.go b/client/allocrunner/taskrunner/template/template.go index a7af2d5b8c9..4056c48b8f2 100644 --- a/client/allocrunner/taskrunner/template/template.go +++ b/client/allocrunner/taskrunner/template/template.go @@ -68,6 +68,10 @@ type TaskTemplateManager struct { // shutdownCh is used to signal and started goroutine to shutdown shutdownCh chan struct{} + // firstRenderScripts holds change_scripts that should fire after the + // initial template render once the task reaches the running state. + firstRenderScripts []*structs.ChangeScript + // shutdown marks whether the manager has been shutdown shutdown bool shutdownLock sync.Mutex @@ -276,8 +280,13 @@ func (tm *TaskTemplateManager) Run() { // Unblock the task close(tm.config.UnblockCh) + // Collect change_script templates that should fire on first render. + // These are stored and executed later when the task reaches the running + // state, triggered via RunFirstRenderScripts from the Poststart hook. + tm.firstRenderScripts = tm.collectFirstRenderScripts() + // If all our templates are change mode no-op, then we can exit here - if tm.allTemplatesNoop() { + if tm.allTemplatesNoop() && len(tm.firstRenderScripts) == 0 { return } @@ -646,6 +655,38 @@ func (tm *TaskTemplateManager) allTemplatesNoop() bool { return true } +// collectFirstRenderScripts returns the set of ChangeScript objects from +// templates that have change_mode "script" with RunOnFirstRender enabled. +func (tm *TaskTemplateManager) collectFirstRenderScripts() []*structs.ChangeScript { + var scripts []*structs.ChangeScript + for _, tmpl := range tm.config.Templates { + if tmpl.ChangeMode == structs.TemplateChangeModeScript && + tmpl.ChangeScript != nil && + tmpl.ChangeScript.RunOnFirstRender { + scripts = append(scripts, tmpl.ChangeScript) + } + } + return scripts +} + +// RunFirstRenderScripts executes the change_scripts collected during the +// first template render. It is called by the template hook's Poststart +// method once the task is running and Exec is available. +func (tm *TaskTemplateManager) RunFirstRenderScripts() { + scripts := tm.firstRenderScripts + if len(scripts) == 0 { + return + } + tm.firstRenderScripts = nil + + var wg sync.WaitGroup + for _, script := range scripts { + wg.Add(1) + go tm.processScript(script, &wg) + } + wg.Wait() +} + // templateRunner returns a consul-template runner for the given templates and a // lookup by destination to the template. If no templates are in the config, a // nil template runner and lookup is returned. diff --git a/client/allocrunner/taskrunner/template/template_test.go b/client/allocrunner/taskrunner/template/template_test.go index cfd7c35885d..07f5064c43e 100644 --- a/client/allocrunner/taskrunner/template/template_test.go +++ b/client/allocrunner/taskrunner/template/template_test.go @@ -1347,6 +1347,59 @@ OUTER: } } +// TestTaskTemplateManager_FirstRenderScript verifies that a template with +// change_mode "script" and RunOnFirstRender collects the script so it can +// be executed once the task reaches the running state via RunFirstRenderScripts. +func TestTaskTemplateManager_FirstRenderScript(t *testing.T) { + ci.Parallel(t) + clienttestutil.RequireConsul(t) + + key := "first_render_key" + t1 := &structs.Template{ + EmbeddedTmpl: `FOO={{key "first_render_key"}}` + "\n", + DestPath: "first_render.env", + ChangeMode: structs.TemplateChangeModeScript, + ChangeScript: &structs.ChangeScript{ + Command: "/bin/foo", + Args: []string{}, + Timeout: 5 * time.Second, + FailOnError: false, + RunOnFirstRender: true, + }, + Envvars: true, + } + + harness := newTestHarness(t, []*structs.Template{t1}, true, false) + harness.mockHooks.SetupExecTest(0, nil) + harness.start(t) + defer harness.stop() + + // Write key so the template renders + harness.consul.SetKV(t, key, []byte("hello")) + + // Wait for unblock (first render complete) + select { + case <-harness.mockHooks.UnblockCh: + case <-time.After(time.Duration(5*testutil.TestMultiplier()) * time.Second): + t.Fatal("Task unblock should have been called") + } + + // Simulate the Poststart hook by calling RunFirstRenderScripts directly + harness.mockHooks.HasHandle = true + harness.manager.RunFirstRenderScripts() + + // Verify script execution event was emitted + timeout := time.After(time.Duration(5*testutil.TestMultiplier()) * time.Second) + select { + case ev := <-harness.mockHooks.EmitEventCh: + if !strings.Contains(ev.DisplayMessage, t1.ChangeScript.Command) { + t.Fatalf("expected script event, got: %s", ev.DisplayMessage) + } + case <-timeout: + t.Fatal("should have received a script execution 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) { diff --git a/client/allocrunner/taskrunner/template_hook.go b/client/allocrunner/taskrunner/template_hook.go index 93ae9151532..0d80fd4651e 100644 --- a/client/allocrunner/taskrunner/template_hook.go +++ b/client/allocrunner/taskrunner/template_hook.go @@ -226,6 +226,16 @@ func (h *templateHook) newManager(tmpls []*structs.Template) (manager *template. return m, unblock, nil } +func (h *templateHook) Poststart(_ context.Context, _ *interfaces.TaskPoststartRequest, _ *interfaces.TaskPoststartResponse) error { + h.managerLock.Lock() + defer h.managerLock.Unlock() + + if h.templateManager != nil { + h.templateManager.RunFirstRenderScripts() + } + return nil +} + func (h *templateHook) Stop(_ context.Context, req *interfaces.TaskStopRequest, resp *interfaces.TaskStopResponse) error { h.managerLock.Lock() defer h.managerLock.Unlock() diff --git a/command/agent/job_endpoint.go b/command/agent/job_endpoint.go index d4baeb9f360..ef4cdd4098f 100644 --- a/command/agent/job_endpoint.go +++ b/command/agent/job_endpoint.go @@ -1553,10 +1553,11 @@ func apiChangeScriptToStructsChangeScript(changeScript *api.ChangeScript) *struc } return &structs.ChangeScript{ - Command: *changeScript.Command, - Args: changeScript.Args, - Timeout: *changeScript.Timeout, - FailOnError: *changeScript.FailOnError, + Command: *changeScript.Command, + Args: changeScript.Args, + Timeout: *changeScript.Timeout, + FailOnError: *changeScript.FailOnError, + RunOnFirstRender: *changeScript.RunOnFirstRender, } } diff --git a/command/agent/job_endpoint_test.go b/command/agent/job_endpoint_test.go index 740126988c1..c2f155c77a2 100644 --- a/command/agent/job_endpoint_test.go +++ b/command/agent/job_endpoint_test.go @@ -3097,12 +3097,13 @@ func TestJobs_ApiJobToStructsJob(t *testing.T) { EmbeddedTmpl: pointer.Of("embedded"), ChangeMode: pointer.Of("change"), ChangeSignal: pointer.Of("signal"), - ChangeScript: &api.ChangeScript{ - Command: pointer.Of("/bin/foo"), - Args: []string{"-h"}, - Timeout: pointer.Of(5 * time.Second), - FailOnError: pointer.Of(false), - }, + ChangeScript: &api.ChangeScript{ + Command: pointer.Of("/bin/foo"), + Args: []string{"-h"}, + Timeout: pointer.Of(5 * time.Second), + FailOnError: pointer.Of(false), + RunOnFirstRender: pointer.Of(false), + }, Splay: pointer.Of(1 * time.Minute), Perms: pointer.Of("666"), Uid: pointer.Of(1000), diff --git a/jobspec2/parse_job.go b/jobspec2/parse_job.go index e5923e9642e..04a05441c2e 100644 --- a/jobspec2/parse_job.go +++ b/jobspec2/parse_job.go @@ -163,4 +163,7 @@ func normalizeChangeScript(ch *api.ChangeScript) { if ch.FailOnError == nil { ch.FailOnError = pointerOf(false) } + if ch.RunOnFirstRender == nil { + ch.RunOnFirstRender = pointerOf(false) + } } diff --git a/nomad/structs/diff_test.go b/nomad/structs/diff_test.go index b296b2de564..5bd8a43bf3c 100644 --- a/nomad/structs/diff_test.go +++ b/nomad/structs/diff_test.go @@ -8656,6 +8656,12 @@ func TestTaskDiff(t *testing.T) { Old: "", New: "false", }, + { + Type: DiffTypeAdded, + Name: "RunOnFirstRender", + Old: "", + New: "false", + }, { Type: DiffTypeAdded, Name: "Timeout", @@ -8780,6 +8786,12 @@ func TestTaskDiff(t *testing.T) { Old: "false", New: "", }, + { + Type: DiffTypeDeleted, + Name: "RunOnFirstRender", + Old: "false", + New: "", + }, { Type: DiffTypeDeleted, Name: "Timeout", diff --git a/nomad/structs/structs.go b/nomad/structs/structs.go index df142bab0dc..ddc7d218991 100644 --- a/nomad/structs/structs.go +++ b/nomad/structs/structs.go @@ -8919,6 +8919,10 @@ type ChangeScript struct { // FailOnError indicates whether a task should fail in case script execution // fails or log script failure and don't interrupt the task FailOnError bool + // RunOnFirstRender indicates that the script should also be triggered + // after the initial template render, not only on subsequent re-renders. + // The script will execute once the task reaches the running state. + RunOnFirstRender bool } func (cs *ChangeScript) Equal(o *ChangeScript) bool { @@ -8934,6 +8938,8 @@ func (cs *ChangeScript) Equal(o *ChangeScript) bool { return false case cs.FailOnError != o.FailOnError: return false + case cs.RunOnFirstRender != o.RunOnFirstRender: + return false } return true } @@ -8943,10 +8949,11 @@ func (cs *ChangeScript) Copy() *ChangeScript { return nil } return &ChangeScript{ - Command: cs.Command, - Args: slices.Clone(cs.Args), - Timeout: cs.Timeout, - FailOnError: cs.FailOnError, + Command: cs.Command, + Args: slices.Clone(cs.Args), + Timeout: cs.Timeout, + FailOnError: cs.FailOnError, + RunOnFirstRender: cs.RunOnFirstRender, } } diff --git a/nomad/structs/structs_test.go b/nomad/structs/structs_test.go index 6f77ff76b76..c5dacbb4120 100644 --- a/nomad/structs/structs_test.go +++ b/nomad/structs/structs_test.go @@ -7143,10 +7143,11 @@ func TestChangeScript_Equal(t *testing.T) { must.NotEqual[*ChangeScript](t, nil, new(ChangeScript)) must.StructEqual(t, &ChangeScript{ - Command: "/bin/sleep", - Args: []string{"infinity"}, - Timeout: 1 * time.Second, - FailOnError: true, + Command: "/bin/sleep", + Args: []string{"infinity"}, + Timeout: 1 * time.Second, + FailOnError: true, + RunOnFirstRender: true, }, []must.Tweak[*ChangeScript]{{ Field: "Command", Apply: func(c *ChangeScript) { c.Command = "/bin/false" }, @@ -7159,6 +7160,9 @@ func TestChangeScript_Equal(t *testing.T) { }, { Field: "FailOnError", Apply: func(c *ChangeScript) { c.FailOnError = false }, + }, { + Field: "RunOnFirstRender", + Apply: func(c *ChangeScript) { c.RunOnFirstRender = false }, }}) }