diff --git a/packages/orchestrator/pkg/server/env_merge.go b/packages/orchestrator/pkg/server/env_merge.go new file mode 100644 index 0000000000..dd0ba32ded --- /dev/null +++ b/packages/orchestrator/pkg/server/env_merge.go @@ -0,0 +1,22 @@ +//go:build linux + +package server + +import "maps" + +// mergeTemplateEnvVars layers env vars for a sandbox start: the template's +// Docker image ENV vars are the base, and user-provided env vars (from the SDK +// create call) override them on conflict. Per-command env vars from +// process.start override both, applied later in envd. A nil/empty template map +// returns userVars unchanged (preserving nil), so the common no-image-ENV case +// adds nothing. +func mergeTemplateEnvVars(templateVars, userVars map[string]string) map[string]string { + if len(templateVars) == 0 { + return userVars + } + + merged := maps.Clone(templateVars) + maps.Copy(merged, userVars) + + return merged +} diff --git a/packages/orchestrator/pkg/server/env_merge_test.go b/packages/orchestrator/pkg/server/env_merge_test.go new file mode 100644 index 0000000000..01afd23db9 --- /dev/null +++ b/packages/orchestrator/pkg/server/env_merge_test.go @@ -0,0 +1,38 @@ +//go:build linux + +package server + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMergeTemplateEnvVars(t *testing.T) { + t.Parallel() + + for name, tc := range map[string]struct { + tmpl map[string]string + user map[string]string + want map[string]string + }{ + "no template, no user": {nil, nil, nil}, + "no template, user only": {nil, map[string]string{"A": "1"}, map[string]string{"A": "1"}}, + "template only": {map[string]string{"PYTHONPATH": "/lib"}, nil, map[string]string{"PYTHONPATH": "/lib"}}, + "disjoint merged": {map[string]string{"P": "/lib"}, map[string]string{"K": "v"}, map[string]string{"P": "/lib", "K": "v"}}, + "user overrides template": {map[string]string{"L": "info", "P": "/lib"}, map[string]string{"L": "debug"}, map[string]string{"L": "debug", "P": "/lib"}}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, mergeTemplateEnvVars(tc.tmpl, tc.user)) + }) + } +} + +// the template map must not be mutated (it is shared template metadata). +func TestMergeTemplateEnvVars_DoesNotMutateTemplate(t *testing.T) { + t.Parallel() + tmpl := map[string]string{"P": "/lib"} + _ = mergeTemplateEnvVars(tmpl, map[string]string{"P": "/override", "K": "v"}) + assert.Equal(t, map[string]string{"P": "/lib"}, tmpl, "template metadata map must be left unchanged") +} diff --git a/packages/orchestrator/pkg/server/sandboxes.go b/packages/orchestrator/pkg/server/sandboxes.go index 6abe238390..f36a088164 100644 --- a/packages/orchestrator/pkg/server/sandboxes.go +++ b/packages/orchestrator/pkg/server/sandboxes.go @@ -381,6 +381,14 @@ func (s *Server) Create(ctx context.Context, req *orchestrator.SandboxCreateRequ return nil, fmt.Errorf("failed to read template metadata: %w", err) } + // Propagate the template's Docker image ENV vars to the sandbox runtime. + // They form the base layer; user-provided env vars (already in config from + // the SDK create call) override them, and per-command env vars from + // process.start override both. Mutating config here reaches both the resume + // and the filesystem-only cold-boot branch below, since each sends + // config.Envd.Vars to envd via POST /init (newEnvdInitRequest). + config.Envd.Vars = mergeTemplateEnvVars(meta.Context.EnvVars, config.Envd.Vars) + fsOnly = meta.IsFilesystemOnly() filesystemBooted = filesystemBoot(meta, req) if filesystemBooted {