Skip to content

Commit ba098ed

Browse files
committed
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
1 parent 9e6d492 commit ba098ed

10 files changed

Lines changed: 159 additions & 23 deletions

File tree

api/tasks.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -916,10 +916,11 @@ func (wc *WaitConfig) Copy() *WaitConfig {
916916
}
917917

918918
type ChangeScript struct {
919-
Command *string `mapstructure:"command" hcl:"command"`
920-
Args []string `mapstructure:"args" hcl:"args,optional"`
921-
Timeout *time.Duration `mapstructure:"timeout" hcl:"timeout,optional"`
922-
FailOnError *bool `mapstructure:"fail_on_error" hcl:"fail_on_error"`
919+
Command *string `mapstructure:"command" hcl:"command"`
920+
Args []string `mapstructure:"args" hcl:"args,optional"`
921+
Timeout *time.Duration `mapstructure:"timeout" hcl:"timeout,optional"`
922+
FailOnError *bool `mapstructure:"fail_on_error" hcl:"fail_on_error"`
923+
RunOnFirstRender *bool `mapstructure:"run_on_first_render" hcl:"run_on_first_render,optional"`
923924
}
924925

925926
func (ch *ChangeScript) Canonicalize() {
@@ -935,6 +936,9 @@ func (ch *ChangeScript) Canonicalize() {
935936
if ch.FailOnError == nil {
936937
ch.FailOnError = pointerOf(false)
937938
}
939+
if ch.RunOnFirstRender == nil {
940+
ch.RunOnFirstRender = pointerOf(false)
941+
}
938942
}
939943

940944
type Template struct {

client/allocrunner/taskrunner/template/template.go

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ type TaskTemplateManager struct {
6868
// shutdownCh is used to signal and started goroutine to shutdown
6969
shutdownCh chan struct{}
7070

71+
// firstRenderScripts holds change_scripts that should fire after the
72+
// initial template render once the task reaches the running state.
73+
firstRenderScripts []*structs.ChangeScript
74+
7175
// shutdown marks whether the manager has been shutdown
7276
shutdown bool
7377
shutdownLock sync.Mutex
@@ -276,8 +280,13 @@ func (tm *TaskTemplateManager) Run() {
276280
// Unblock the task
277281
close(tm.config.UnblockCh)
278282

283+
// Collect change_script templates that should fire on first render.
284+
// These are stored and executed later when the task reaches the running
285+
// state, triggered via RunFirstRenderScripts from the Poststart hook.
286+
tm.firstRenderScripts = tm.collectFirstRenderScripts()
287+
279288
// If all our templates are change mode no-op, then we can exit here
280-
if tm.allTemplatesNoop() {
289+
if tm.allTemplatesNoop() && len(tm.firstRenderScripts) == 0 {
281290
return
282291
}
283292

@@ -646,6 +655,38 @@ func (tm *TaskTemplateManager) allTemplatesNoop() bool {
646655
return true
647656
}
648657

658+
// collectFirstRenderScripts returns the set of ChangeScript objects from
659+
// templates that have change_mode "script" with RunOnFirstRender enabled.
660+
func (tm *TaskTemplateManager) collectFirstRenderScripts() []*structs.ChangeScript {
661+
var scripts []*structs.ChangeScript
662+
for _, tmpl := range tm.config.Templates {
663+
if tmpl.ChangeMode == structs.TemplateChangeModeScript &&
664+
tmpl.ChangeScript != nil &&
665+
tmpl.ChangeScript.RunOnFirstRender {
666+
scripts = append(scripts, tmpl.ChangeScript)
667+
}
668+
}
669+
return scripts
670+
}
671+
672+
// RunFirstRenderScripts executes the change_scripts collected during the
673+
// first template render. It is called by the template hook's Poststart
674+
// method once the task is running and Exec is available.
675+
func (tm *TaskTemplateManager) RunFirstRenderScripts() {
676+
scripts := tm.firstRenderScripts
677+
if len(scripts) == 0 {
678+
return
679+
}
680+
tm.firstRenderScripts = nil
681+
682+
var wg sync.WaitGroup
683+
for _, script := range scripts {
684+
wg.Add(1)
685+
go tm.processScript(script, &wg)
686+
}
687+
wg.Wait()
688+
}
689+
649690
// templateRunner returns a consul-template runner for the given templates and a
650691
// lookup by destination to the template. If no templates are in the config, a
651692
// nil template runner and lookup is returned.

client/allocrunner/taskrunner/template/template_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1347,6 +1347,59 @@ OUTER:
13471347
}
13481348
}
13491349

1350+
// TestTaskTemplateManager_FirstRenderScript verifies that a template with
1351+
// change_mode "script" and RunOnFirstRender collects the script so it can
1352+
// be executed once the task reaches the running state via RunFirstRenderScripts.
1353+
func TestTaskTemplateManager_FirstRenderScript(t *testing.T) {
1354+
ci.Parallel(t)
1355+
clienttestutil.RequireConsul(t)
1356+
1357+
key := "first_render_key"
1358+
t1 := &structs.Template{
1359+
EmbeddedTmpl: `FOO={{key "first_render_key"}}` + "\n",
1360+
DestPath: "first_render.env",
1361+
ChangeMode: structs.TemplateChangeModeScript,
1362+
ChangeScript: &structs.ChangeScript{
1363+
Command: "/bin/foo",
1364+
Args: []string{},
1365+
Timeout: 5 * time.Second,
1366+
FailOnError: false,
1367+
RunOnFirstRender: true,
1368+
},
1369+
Envvars: true,
1370+
}
1371+
1372+
harness := newTestHarness(t, []*structs.Template{t1}, true, false)
1373+
harness.mockHooks.SetupExecTest(0, nil)
1374+
harness.start(t)
1375+
defer harness.stop()
1376+
1377+
// Write key so the template renders
1378+
harness.consul.SetKV(t, key, []byte("hello"))
1379+
1380+
// Wait for unblock (first render complete)
1381+
select {
1382+
case <-harness.mockHooks.UnblockCh:
1383+
case <-time.After(time.Duration(5*testutil.TestMultiplier()) * time.Second):
1384+
t.Fatal("Task unblock should have been called")
1385+
}
1386+
1387+
// Simulate the Poststart hook by calling RunFirstRenderScripts directly
1388+
harness.mockHooks.HasHandle = true
1389+
harness.manager.RunFirstRenderScripts()
1390+
1391+
// Verify script execution event was emitted
1392+
timeout := time.After(time.Duration(5*testutil.TestMultiplier()) * time.Second)
1393+
select {
1394+
case ev := <-harness.mockHooks.EmitEventCh:
1395+
if !strings.Contains(ev.DisplayMessage, t1.ChangeScript.Command) {
1396+
t.Fatalf("expected script event, got: %s", ev.DisplayMessage)
1397+
}
1398+
case <-timeout:
1399+
t.Fatal("should have received a script execution event")
1400+
}
1401+
}
1402+
13501403
// TestTaskTemplateManager_ScriptExecutionFailTask tests whether we fail the
13511404
// task upon script execution failure if that's how it's configured.
13521405
func TestTaskTemplateManager_ScriptExecutionFailTask(t *testing.T) {

client/allocrunner/taskrunner/template_hook.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,16 @@ func (h *templateHook) newManager(tmpls []*structs.Template) (manager *template.
226226
return m, unblock, nil
227227
}
228228

229+
func (h *templateHook) Poststart(_ context.Context, _ *interfaces.TaskPoststartRequest, _ *interfaces.TaskPoststartResponse) error {
230+
h.managerLock.Lock()
231+
defer h.managerLock.Unlock()
232+
233+
if h.templateManager != nil {
234+
h.templateManager.RunFirstRenderScripts()
235+
}
236+
return nil
237+
}
238+
229239
func (h *templateHook) Stop(_ context.Context, req *interfaces.TaskStopRequest, resp *interfaces.TaskStopResponse) error {
230240
h.managerLock.Lock()
231241
defer h.managerLock.Unlock()

command/agent/job_endpoint.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1553,10 +1553,11 @@ func apiChangeScriptToStructsChangeScript(changeScript *api.ChangeScript) *struc
15531553
}
15541554

15551555
return &structs.ChangeScript{
1556-
Command: *changeScript.Command,
1557-
Args: changeScript.Args,
1558-
Timeout: *changeScript.Timeout,
1559-
FailOnError: *changeScript.FailOnError,
1556+
Command: *changeScript.Command,
1557+
Args: changeScript.Args,
1558+
Timeout: *changeScript.Timeout,
1559+
FailOnError: *changeScript.FailOnError,
1560+
RunOnFirstRender: *changeScript.RunOnFirstRender,
15601561
}
15611562
}
15621563

command/agent/job_endpoint_test.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3097,12 +3097,13 @@ func TestJobs_ApiJobToStructsJob(t *testing.T) {
30973097
EmbeddedTmpl: pointer.Of("embedded"),
30983098
ChangeMode: pointer.Of("change"),
30993099
ChangeSignal: pointer.Of("signal"),
3100-
ChangeScript: &api.ChangeScript{
3101-
Command: pointer.Of("/bin/foo"),
3102-
Args: []string{"-h"},
3103-
Timeout: pointer.Of(5 * time.Second),
3104-
FailOnError: pointer.Of(false),
3105-
},
3100+
ChangeScript: &api.ChangeScript{
3101+
Command: pointer.Of("/bin/foo"),
3102+
Args: []string{"-h"},
3103+
Timeout: pointer.Of(5 * time.Second),
3104+
FailOnError: pointer.Of(false),
3105+
RunOnFirstRender: pointer.Of(false),
3106+
},
31063107
Splay: pointer.Of(1 * time.Minute),
31073108
Perms: pointer.Of("666"),
31083109
Uid: pointer.Of(1000),

jobspec2/parse_job.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,4 +163,7 @@ func normalizeChangeScript(ch *api.ChangeScript) {
163163
if ch.FailOnError == nil {
164164
ch.FailOnError = pointerOf(false)
165165
}
166+
if ch.RunOnFirstRender == nil {
167+
ch.RunOnFirstRender = pointerOf(false)
168+
}
166169
}

nomad/structs/diff_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8656,6 +8656,12 @@ func TestTaskDiff(t *testing.T) {
86568656
Old: "",
86578657
New: "false",
86588658
},
8659+
{
8660+
Type: DiffTypeAdded,
8661+
Name: "RunOnFirstRender",
8662+
Old: "",
8663+
New: "false",
8664+
},
86598665
{
86608666
Type: DiffTypeAdded,
86618667
Name: "Timeout",
@@ -8780,6 +8786,12 @@ func TestTaskDiff(t *testing.T) {
87808786
Old: "false",
87818787
New: "",
87828788
},
8789+
{
8790+
Type: DiffTypeDeleted,
8791+
Name: "RunOnFirstRender",
8792+
Old: "false",
8793+
New: "",
8794+
},
87838795
{
87848796
Type: DiffTypeDeleted,
87858797
Name: "Timeout",

nomad/structs/structs.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8919,6 +8919,10 @@ type ChangeScript struct {
89198919
// FailOnError indicates whether a task should fail in case script execution
89208920
// fails or log script failure and don't interrupt the task
89218921
FailOnError bool
8922+
// RunOnFirstRender indicates that the script should also be triggered
8923+
// after the initial template render, not only on subsequent re-renders.
8924+
// The script will execute once the task reaches the running state.
8925+
RunOnFirstRender bool
89228926
}
89238927

89248928
func (cs *ChangeScript) Equal(o *ChangeScript) bool {
@@ -8934,6 +8938,8 @@ func (cs *ChangeScript) Equal(o *ChangeScript) bool {
89348938
return false
89358939
case cs.FailOnError != o.FailOnError:
89368940
return false
8941+
case cs.RunOnFirstRender != o.RunOnFirstRender:
8942+
return false
89378943
}
89388944
return true
89398945
}
@@ -8943,10 +8949,11 @@ func (cs *ChangeScript) Copy() *ChangeScript {
89438949
return nil
89448950
}
89458951
return &ChangeScript{
8946-
Command: cs.Command,
8947-
Args: slices.Clone(cs.Args),
8948-
Timeout: cs.Timeout,
8949-
FailOnError: cs.FailOnError,
8952+
Command: cs.Command,
8953+
Args: slices.Clone(cs.Args),
8954+
Timeout: cs.Timeout,
8955+
FailOnError: cs.FailOnError,
8956+
RunOnFirstRender: cs.RunOnFirstRender,
89508957
}
89518958
}
89528959

nomad/structs/structs_test.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7143,10 +7143,11 @@ func TestChangeScript_Equal(t *testing.T) {
71437143
must.NotEqual[*ChangeScript](t, nil, new(ChangeScript))
71447144

71457145
must.StructEqual(t, &ChangeScript{
7146-
Command: "/bin/sleep",
7147-
Args: []string{"infinity"},
7148-
Timeout: 1 * time.Second,
7149-
FailOnError: true,
7146+
Command: "/bin/sleep",
7147+
Args: []string{"infinity"},
7148+
Timeout: 1 * time.Second,
7149+
FailOnError: true,
7150+
RunOnFirstRender: true,
71507151
}, []must.Tweak[*ChangeScript]{{
71517152
Field: "Command",
71527153
Apply: func(c *ChangeScript) { c.Command = "/bin/false" },
@@ -7159,6 +7160,9 @@ func TestChangeScript_Equal(t *testing.T) {
71597160
}, {
71607161
Field: "FailOnError",
71617162
Apply: func(c *ChangeScript) { c.FailOnError = false },
7163+
}, {
7164+
Field: "RunOnFirstRender",
7165+
Apply: func(c *ChangeScript) { c.RunOnFirstRender = false },
71627166
}})
71637167
}
71647168

0 commit comments

Comments
 (0)