From ba098ed0dea66694e999d6c3120afe8ee25c7549 Mon Sep 17 00:00:00 2001 From: Vedant Madane Date: Thu, 16 Apr 2026 07:06:23 +0000 Subject: [PATCH] template: add RunOnFirstRender option to change_script Add a RunOnFirstRender field to the ChangeScript configuration that allows template change_script to fire after the initial template render, not only on subsequent re-renders. When enabled, the template manager waits for the task to reach the running state after unblocking, then executes the configured scripts. This is useful for initialization tasks that need to run once the first template content is available. Replace the runFirstRenderScripts polling goroutine with a RunFirstRenderScripts public method that is called from the template hook Poststart handler. The task runner framework guarantees Poststart fires once the task reaches running state, so the polling loop is unnecessary. Also adds TestTaskTemplateManager_FirstRenderScript covering the new code path. Fixes #27429 --- api/tasks.go | 12 +++-- .../taskrunner/template/template.go | 43 ++++++++++++++- .../taskrunner/template/template_test.go | 53 +++++++++++++++++++ .../allocrunner/taskrunner/template_hook.go | 10 ++++ command/agent/job_endpoint.go | 9 ++-- command/agent/job_endpoint_test.go | 13 ++--- jobspec2/parse_job.go | 3 ++ nomad/structs/diff_test.go | 12 +++++ nomad/structs/structs.go | 15 ++++-- nomad/structs/structs_test.go | 12 +++-- 10 files changed, 159 insertions(+), 23 deletions(-) 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 }, }}) }