Skip to content

agent: shared currentEtag overwritten per template function call (last-write-wins) causes missed re-renders on multi-secret templates #310

Description

@alexandredupeu

Summary

When a single agent template file references more than one secret/path via more than one templating function call (e.g. a secret "<project>" "<env>" "<path>" range block followed by one or more getSecretByName "<project>" "<env>" "<path>" "<key>" calls, or several getSecretByName calls on different paths), the agent's change-detection etag is a single shared variable that gets overwritten by every function call evaluated during that render — not tracked per referenced secret/path, and not combined/merged across all functions referenced by the file.

As a consequence, on the next poll cycle the agent only compares the etag of the last function call executed during the previous render. If that particular secret/path hasn't changed — even though an earlier function call in the same file references a secret that has changed — the agent concludes nothing changed and skips re-rendering the destination file entirely. The poll loop keeps running at the configured polling-interval, but silently discards the fact that a re-render is needed.

Only a full agent restart (firstRun=true, which forces an unconditional render) picks up the change.

Affected versions

Confirmed on:

  • v0.43.84 (commit fc0ac97b73c0f97e1f24db9d7cd632595142ea9d)
  • v0.43.104 (commit 2d989880fa77222633d0207ed150255ee86ef3b5) — file is byte-identical to main
  • main at commit 20e251cead9014ba448f9b71ef7d12d1696d33c2 (2026-07-10)

The design (single shared etag pointer passed into every template function) is unchanged between v0.43.84 and main; only line numbers shift (~13 lines) due to an unrelated feature addition (dynamic secret "principals").

Root cause (confirmed by code reading)

packages/cmd/agent.go. Every template function that fetches secrets is handed the same *string pointer for etag tracking, and each one unconditionally overwrites it:

// secretTemplateFunction (listSecrets)
func secretTemplateFunction(accessToken string, currentEtag *string) func(...) {
    return func(projectID, envSlug, secretPath string, args ...string) (...) {
        res, err := util.GetPlainTextSecretsV4(...)
        *currentEtag = res.Etag   // overwrites the shared value
        return res.Secrets, nil
    }
}

// getSingleSecretTemplateFunction (getSecretByName)
func getSingleSecretTemplateFunction(accessToken string, currentEtag *string) func(...) {
    return func(projectID, envSlug, secretPath, secretName string) (...) {
        secret, etag, err := util.GetSinglePlainTextSecretByNameV3(...)
        *currentEtag = etag      // same pointer, overwrites the previous call's etag
        return secret, nil
    }
}

ProcessTemplate / ProcessLiteralTemplate / ProcessBase64Template all pass the same &currentEtag into newTemplateFunctions, which wires it into secretTemplateFunction, secretTemplateByProjectSlugFunction, getSingleSecretTemplateFunction, and dynamicSecretTemplateFunction alike. Whichever of these functions is evaluated last during the Go template render wins the race and determines the etag used for the next poll's change-detection gate:

var existingEtag string
var currentEtag string          // one shared string across the whole template render
var firstRun = true
...
processedTemplate, err = ProcessTemplate(templateId, ..., &currentEtag, ...)
...
if (existingEtag != currentEtag) || firstRun {   // only sees the LAST etag written
    ... // re-render
    existingEtag = currentEtag
}

If a template calls multiple secret-fetching functions (a listSecrets range plus one or more getSecretByName calls, or several getSecretByName calls on different paths), only a change to whichever one is evaluated last in the template is ever detected after the first render.

Confirmed locations (main, commit 20e251cead9014ba448f9b71ef7d12d1696d33c2):

Same pattern on v0.43.84 (commit fc0ac97b73c0f97e1f24db9d7cd632595142ea9d), lines ~903/932/968 for the writes and ~1899-1908 for the gate.

Repro steps

  1. Configure an agent template that references more than one secret path, e.g.:
    {{- with secret "myproject" "prod" "/" }}
    {{- range . }}
    {{ .Key }}={{ .Value }}
    {{- end }}
    {{- end }}
    {{- with getSecretByName "myproject" "prod" "/app" "DB_PASSWORD" }}
    {{ .Key }}={{ .Value }}
    {{- end }}
    
  2. Start the agent (firstRun=true) → the initial render is correct.
  3. Modify only a secret referenced by the first block (the secret "/" range), not the one fetched by the trailing getSecretByName.
  4. Wait longer than the configured polling-interval.
  5. Observe: the destination file is not re-rendered, even though the underlying secret changed (verifiable independently via infisical secrets get).
  6. Restart the agent (firstRun=true again) → the file is immediately correct.

Expected behavior

Any change to any secret referenced anywhere in a template should trigger a re-render on the next poll, regardless of the order in which the template's functions are evaluated.

Actual behavior

Only a change to the secret referenced by the last-evaluated template function in a given file's render triggers a re-render. Any other secret referenced earlier in the same template is effectively never polled for changes again after the first render — the destination file silently goes stale until the process is restarted.

Suggested fix

Replace the single shared *string etag with per-source tracking, e.g. a map[string]string keyed by (projectID, envSlug, secretPath[, secretName]), or accumulate a combined hash of all etags seen during a render (e.g. hash the sorted concatenation of every etag returned by every function call), and compare that combined value against the previous render's combined value at the gate. Either approach ensures a change to any referenced secret is detected, not just the last one evaluated.

Workaround currently in use

Until this is fixed, we force periodic unconditional re-renders by having an external timer issue a systemctl try-restart (or equivalent) on the agent process every few minutes, since only firstRun=true reliably picks up all changes. This is a blunt workaround and defeats the purpose of the poll-based change detection.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions