config: substitute variables in feature-contributed metadata by bilby91 · Pull Request #53 · crunchloop/devcontainer · GitHub
Skip to content

config: substitute variables in feature-contributed metadata#53

Merged
bilby91 merged 2 commits into
mainfrom
fix/feature-mount-substitution
May 13, 2026
Merged

config: substitute variables in feature-contributed metadata#53
bilby91 merged 2 commits into
mainfrom
fix/feature-mount-substitution

Conversation

@bilby91

@bilby91 bilby91 commented May 13, 2026

Copy link
Copy Markdown
Member

Summary

Fix #52: feature-contributed mounts (and env, lifecycle commands, …) now get host-context substitution before reaching Docker.

Previously ResolveBytes only substituted variables in the user's devcontainer.json. Fields folded in afterwards by MergeMetadata from feature / base-image metadata kept their literal \${devcontainerId}, \${localWorkspaceFolder}, \${localEnv:*} tokens and flowed straight into ContainerCreate. The docker-in-docker feature's dind-var-lib-docker-\${devcontainerId} volume mount tripped Docker's volume name validator.

MergeMetadata now takes a SubstitutionContext and resolves layer-contributed strings before folding them in. Substitution copies — input layers are not mutated, so cached feature metadata stays clean. Call sites (applyMetadataMerge in up.go, Attach) build the context from cfg + localEnv.

Test plan

  • Unit: TestMergeMetadata_SubstitutesLayerStrings covers mount sources, env values, and lifecycle commands with \${devcontainerId} / \${localWorkspaceFolder} / \${localEnv:*}
  • Unit: TestMergeMetadata_SubstitutionDoesNotMutateLayers pins the no-input-mutation invariant (feature metadata is often shared / cached)
  • Integration (build tag integration, requires Docker): TestFeatureMountSubstitution reproduces the docker-in-docker mount shape with a local feature, asserts Up succeeds and the inspected container mount path embeds the substituted volume name

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Metadata substitution now consistently resolves placeholders like ${devcontainerId}, ${localEnv:...}, and ${localWorkspaceFolder} in mounts, env vars, and lifecycle commands so feature-provided values are correctly applied at workspace startup.
  • Tests

    • Added/updated unit and integration tests to verify substitution behavior and prevent regressions.

Review Change Stack

Feature-supplied mounts, env, and lifecycle commands now get host-context
substitution. Previously only the user's devcontainer.json fields were
resolved by ResolveBytes; feature/base-image metadata folded in by
MergeMetadata kept their literal ${devcontainerId}, ${localWorkspaceFolder},
${localEnv:*} tokens and reached ContainerCreate unchanged, causing Docker
to reject mounts like dind-var-lib-docker-${devcontainerId} as having
invalid volume name characters.

MergeMetadata now accepts a SubstitutionContext and resolves layer-
contributed strings before folding them into cfg. Callers (Up's
applyMetadataMerge, Attach) build the context from cfg + localEnv.

Fixes #52.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
up.go (1)

263-277: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Normalize localEnv before metadata substitution in Up paths.

applyMetadataMerge currently uses opts.LocalEnv as-is. When nil, ${localEnv:*} from feature/base metadata will not resolve from host env, which conflicts with the UpOptions.LocalEnv contract (nil => use os.Environ()) and differs from AttachWith behavior.

Suggested fix
func applyMetadataMerge(cfg *config.ResolvedConfig, baseLayers []config.FeatureMetadata, localEnv map[string]string) {
+	if localEnv == nil {
+		localEnv = environAsMap(os.Environ())
+	}
 	chain := make([]config.FeatureMetadata, 0, len(baseLayers)+len(cfg.Features))
 	chain = append(chain, baseLayers...)
 	for _, f := range cfg.Features {
 		if f.AlreadyInstalled {
 			continue
 		}
 		chain = append(chain, f.Metadata)
 	}
 	subCtx := config.SubstitutionContext{
 		LocalWorkspaceFolder:     cfg.LocalWorkspaceFolder,
 		ContainerWorkspaceFolder: cfg.ContainerWorkspaceFolder,
 		DevcontainerID:           cfg.DevcontainerID,
 		LocalEnv:                 localEnv,
 	}
 	config.MergeMetadata(cfg, subCtx, chain)
 	cfg.Finalize()
}

Also applies to: 475-475, 863-879


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f31bd3dd-e0b5-473a-af10-4790707b6cba

📥 Commits

Reviewing files that changed from the base of the PR and between 6db715d and 9d4310e.

📒 Files selected for processing (5)
  • attach.go
  • config/merge_metadata.go
  • config/merge_metadata_test.go
  • test/integration/feature_mount_substitution_test.go
  • up.go

After `if x == nil { t.Fatal(...) }`, staticcheck doesn't model
t.Fatal/t.Fatalf as terminating, so subsequent dereferences trip
SA5011. Add an explicit return so the analyzer sees the post-nil
code as unreachable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bilby91 bilby91 merged commit 42aa6d6 into main May 13, 2026
5 checks passed
@bilby91 bilby91 deleted the fix/feature-mount-substitution branch May 14, 2026 13:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature-contributed mounts skip variable substitution — ${devcontainerId} reaches Docker literally

1 participant