Issue #12902 asks for a
container environment variable that is available to Container.withExec but is
not normal image configuration and does not invalidate the exec cache when its
value changes.
Current container env surfaces do not fit:
- Normal env is persistent -
withEnvVariablewrites toContainer.Config.Env, so the value is part of container image config,envVariable(s), expansion, and cache identity. - Secret env is too strong -
withSecretVariableavoids image config, but it is semantically secret, redacts output, and requires aSecretobject. - Exec needs runtime-only data - some values should be passed to the process environment only when an exec actually runs, while leaving the cached container recipe unchanged when the value changes.
Add Container.withVolatileVariable(name, value) and
Container.withoutVolatileVariable(name). A volatile variable is a non-secret
session resource: the cached container stores only a stable handle, while the
current concrete value is bound in the caller's session before cache lookup and
resolved only when an exec evaluates.
This gives the API the required shape:
- visible to the executed process
- not stored in
Config.Envor image config - not returned by
envVariableorenvVariables - not expanded by Dagger-side
expand: true - not redacted from logs or stdout/stderr
- not included in
withExeccache identity
type Container {
"""
Set an environment variable that is only applied to future execs.
The value is not stored in the container image config and does not participate
in exec cache identity. It is not secret and will not be redacted from output.
"""
withVolatileVariable(name: String!, value: String!): Container!
"""
Remove a volatile environment variable from future execs.
"""
withoutVolatileVariable(name: String!): Container!
}Example SDK usage:
out, err := dag.Container().
From("alpine").
WithVolatileVariable("CI_JOB_ID", jobID).
WithExec([]string{"sh", "-c", "printf '%s' \"$CI_JOB_ID\""}).
Stdout(ctx)Changing jobID must not change the withExec recipe. If the exec is a cache
hit, the cached result is returned. If the exec has to run for any other reason,
the process sees the latest bound volatile value.
Add an internal core type, roughly:
type VolatileVariable struct {
Handle dagql.SessionResourceHandle
}The concrete session-bound value should be a separate internal value, roughly:
type concreteVolatileVariable struct {
Value string
}Follow the Secret/Socket handle pattern:
- handle-form
VolatileVariableflows through dagql results - concrete value is bound through
dagql.Cache.BindSessionResource ResolveSessionResourcereturns the current concrete value during exec- handle-form result is stamped with
WithSessionResourceHandle - the handle-form result carries a content digest derived from the handle
- the handle-form type is persistable by handle only, never by concrete value
The handle should be derived from the canonical volatile binding slot, not from the value. The binding slot should include at least:
- a versioned kind string, such as
container.volatileVariable.v1 - the receiver container recipe identity
- the env var name
Do not include the volatile value.
Main files:
core/container.gocore/container_exec.gocore/schema/container.godagql/cache.gofor reference only; no cache API change should be needed
Add a field to core.Container:
VolatileVariables []ContainerVolatileVariablewith:
type ContainerVolatileVariable struct {
Name string
Variable dagql.ObjectResult[*VolatileVariable]
}Use AddEnv/shell.EqualEnvKeys semantics for replacement and removal:
withVolatileVariablereplaces an existing volatile variable with the same env keywithoutVolatileVariableremoves only volatile variables, not normal env or secret env
Thread the field through:
cloneContainerForSchemaChildmaterializeContainerStateFromParentcloneContainerForTerminal- persisted container payload encode/decode
Container.HasDependencyResults- lazy attach/decode paths
The important cache rule is that the container result must depend on the handle-form volatile variable result, so the volatile session-resource requirement propagates transitively to downstream cached results.
Install:
withVolatileVariablewithoutVolatileVariable
withVolatileVariable should use dagql.NodeFuncWithDynamicInputs.
The dynamic input hook runs before cache lookup. It must:
- read the caller-provided
nameandvalue - derive the stable volatile handle from the canonical binding slot
- bind the concrete value to the current session/client with
BindSessionResource - rewrite the
valuearg to a fixed sentinel before the recipe is finalized
The resolver then sees only the sentinel value. It creates or references the
handle-form VolatileVariable result and stores that result on the returned
container. The raw value never needs to enter the cached container, persisted
payload, or lazy payload.
The sentinel should be an internal constant, not a user-facing value. The field argument should not be marked secret: volatile variables are intentionally non-secret and must not trigger output redaction.
Add a helper near secretEnvValues:
func (container *Container) volatileEnvValues(ctx context.Context) ([]string, error)It resolves each volatile variable handle through ResolveSessionResource and
returns NAME=value entries.
In ContainerExecState.Evaluate:
- evaluate/materialize the parent container
- resolve volatile env values
- resolve secret env values
- build
metaSpecfrom persistent image config - clone runtime
meta - overlay volatile env onto
meta.Env - overlay secret env last
Use AddEnv for the overlays, not raw append, so normal env is overridden
cleanly:
meta.Env = slices.Clone(metaSpec.Env)
for _, env := range volatileEnv {
name, value, _ := strings.Cut(env, "=")
meta.Env = AddEnv(meta.Env, name, value)
}
for _, env := range secretEnv {
name, value, _ := strings.Cut(env, "=")
meta.Env = AddEnv(meta.Env, name, value)
}Secret env should keep final precedence because it is the stricter safety
boundary. Do not add volatile names to ExecutionMetadata.SecretEnvNames.
Leave envVariable and envVariables backed by Container.Config.Env.
Volatile variables should not be returned from those fields.
Leave image export unchanged: because volatile variables are not in
Container.Config.Env, they are not written to OCI image config.
Update Dagger-side expansion in:
core.expandContainerInputcore/schema.expandEnvVar
If an expansion references a volatile variable name, return an error. This should happen even if a normal env var with the same key also exists, because the exec-time value would be volatile and Dagger cannot expand it without making the value part of Dagger-side behavior.
The key invariant is:
The volatile value is bound before cache lookup, but the value is rewritten out of the recipe before identity is finalized.
That requires dynamic inputs, not DoNotCache.
DoNotCache would make object chaining and lazy execution harder to reason
about. A volatile variable is still an immutable container recipe node; only the
concrete session-bound value behind its handle changes.
The cache flow is:
- caller requests
withVolatileVariable(name: "CI_JOB_ID", value: "123") - dynamic hook derives handle
H(container-recipe, "CI_JOB_ID") - dynamic hook binds
H -> "123"in the current session/client - dynamic hook rewrites
valueto the sentinel - dagql cache looks up the canonical recipe
- a hit is valid only if the current session has handle
H - downstream
withExeccache identity sees only the container with handleH - if exec evaluates, runtime env resolves
Hto the latest bound value
Changing the volatile value alone should not invalidate the withExec result.
This necessarily means a cached exec result can hide a changed volatile value.
That is the point of the API: callers use volatile variables only when the value
should not participate in caching.
Concurrent calls that bind different values to the same canonical volatile slot within one session are last-writer-wins, matching the current session-resource binding model. Callers that need independent concurrent values must make the binding slot distinct through normal cache-participating inputs.
| Surface | Volatile behavior |
|---|---|
withExec process env |
visible when exec evaluates |
withExec cache identity |
value ignored |
envVariable / envVariables |
not visible |
Config.Env / image config |
not stored |
expand: true |
error if referenced |
| stdout/stderr/log redaction | no redaction |
| same name as normal env | volatile overrides during exec |
| same name as secret env | secret wins during exec |
withoutEnvVariable |
removes only normal env |
withoutSecretVariable |
removes only secret env |
withoutVolatileVariable |
removes only volatile env |
Add focused integration coverage under the existing container tests:
withExecsees a volatile variable when the exec runs.- Changing only the volatile value reuses the cached exec result.
- Changing another cache-participating input causes the exec to rerun and see the latest volatile value.
- A cached intermediate container with a volatile variable can be reused in a later call after rebinding the current value.
envVariableandenvVariablesdo not expose volatile variables.- Exported image config does not contain volatile variables.
expand: trueerrors when args or paths reference a volatile variable.- Volatile values are not redacted from stdout/stderr.
- Name precedence is normal env, then volatile env, then secret env.
withoutVolatileVariableremoves the volatile binding without affecting normal or secret variables of the same name.
Add dagql-level tests for the dynamic input/cache property:
- the dynamic hook rewrites the value before cache identity is finalized
- session-resource requirements propagate through the returned container
- a cache hit is rejected if the caller has not bound the required volatile handle in the current session
Regenerate GraphQL schema and SDK bindings after the API is implemented.
Proposal only. No implementation in this document.