Skip to content

Instantly share code, notes, and snippets.

@shykes
Created May 13, 2026 23:46
Show Gist options
  • Select an option

  • Save shykes/d50cb796eecf302cab4b72b69f6729a2 to your computer and use it in GitHub Desktop.

Select an option

Save shykes/d50cb796eecf302cab4b72b69f6729a2 to your computer and use it in GitHub Desktop.
Dagger Design: Part 1 - Container Volatile Variables

Part 1: Container Volatile Variables

Table of Contents

Problem

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:

  1. Normal env is persistent - withEnvVariable writes to Container.Config.Env, so the value is part of container image config, envVariable(s), expansion, and cache identity.
  2. Secret env is too strong - withSecretVariable avoids image config, but it is semantically secret, redacts output, and requires a Secret object.
  3. 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.

Solution

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.Env or image config
  • not returned by envVariable or envVariables
  • not expanded by Dagger-side expand: true
  • not redacted from logs or stdout/stderr
  • not included in withExec cache identity

Core Concept

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.

Implementation Plan

1. Model a volatile variable as a session resource

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 VolatileVariable flows through dagql results
  • concrete value is bound through dagql.Cache.BindSessionResource
  • ResolveSessionResource returns 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.go
  • core/container_exec.go
  • core/schema/container.go
  • dagql/cache.go for reference only; no cache API change should be needed

2. Store volatile variables on Container

Add a field to core.Container:

VolatileVariables []ContainerVolatileVariable

with:

type ContainerVolatileVariable struct {
	Name     string
	Variable dagql.ObjectResult[*VolatileVariable]
}

Use AddEnv/shell.EqualEnvKeys semantics for replacement and removal:

  • withVolatileVariable replaces an existing volatile variable with the same env key
  • withoutVolatileVariable removes only volatile variables, not normal env or secret env

Thread the field through:

  • cloneContainerForSchemaChild
  • materializeContainerStateFromParent
  • cloneContainerForTerminal
  • 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.

3. Add schema fields with dynamic input handling

Install:

  • withVolatileVariable
  • withoutVolatileVariable

withVolatileVariable should use dagql.NodeFuncWithDynamicInputs.

The dynamic input hook runs before cache lookup. It must:

  1. read the caller-provided name and value
  2. derive the stable volatile handle from the canonical binding slot
  3. bind the concrete value to the current session/client with BindSessionResource
  4. rewrite the value arg 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.

4. Apply volatile env only during exec evaluation

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:

  1. evaluate/materialize the parent container
  2. resolve volatile env values
  3. resolve secret env values
  4. build metaSpec from persistent image config
  5. clone runtime meta
  6. overlay volatile env onto meta.Env
  7. 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.

5. Keep volatile vars out of non-exec surfaces

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.expandContainerInput
  • core/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.

Cache Model

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:

  1. caller requests withVolatileVariable(name: "CI_JOB_ID", value: "123")
  2. dynamic hook derives handle H(container-recipe, "CI_JOB_ID")
  3. dynamic hook binds H -> "123" in the current session/client
  4. dynamic hook rewrites value to the sentinel
  5. dagql cache looks up the canonical recipe
  6. a hit is valid only if the current session has handle H
  7. downstream withExec cache identity sees only the container with handle H
  8. if exec evaluates, runtime env resolves H to 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.

Behavior

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

Tests

Add focused integration coverage under the existing container tests:

  1. withExec sees a volatile variable when the exec runs.
  2. Changing only the volatile value reuses the cached exec result.
  3. Changing another cache-participating input causes the exec to rerun and see the latest volatile value.
  4. A cached intermediate container with a volatile variable can be reused in a later call after rebinding the current value.
  5. envVariable and envVariables do not expose volatile variables.
  6. Exported image config does not contain volatile variables.
  7. expand: true errors when args or paths reference a volatile variable.
  8. Volatile values are not redacted from stdout/stderr.
  9. Name precedence is normal env, then volatile env, then secret env.
  10. withoutVolatileVariable removes the volatile binding without affecting normal or secret variables of the same name.

Add dagql-level tests for the dynamic input/cache property:

  1. the dynamic hook rewrites the value before cache identity is finalized
  2. session-resource requirements propagate through the returned container
  3. 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.

Status

Proposal only. No implementation in this document.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment