config: merge devcontainer.metadata image label into resolved config by bilby91 · Pull Request #21 · crunchloop/devcontainer · GitHub
Skip to content

config: merge devcontainer.metadata image label into resolved config#21

Merged
bilby91 merged 2 commits into
mainfrom
metadata-merge
May 10, 2026
Merged

config: merge devcontainer.metadata image label into resolved config#21
bilby91 merged 2 commits into
mainfrom
metadata-merge

Conversation

@bilby91

@bilby91 bilby91 commented May 10, 2026

Copy link
Copy Markdown
Member

Summary

  • Implements the devcontainers metadata-merge spec: image-baked devcontainer.metadata (remoteUser, lifecycle hooks, mounts, env, host requirements, probes) now flows into the resolved config on both Up and Attach paths.
  • New config/merge_metadata.go reducer (last-write-wins scalars, union+dedup slices, target-dedup mounts, append-per-phase lifecycle; user devcontainer.json wins on conflict) plus ResolvedConfig.Finalize() for spec defaults.
  • Lossless metadata round-trip (feature/metadata.go, feature/parse.go), engine wiring in up.go/attach.go/exec.go, and full test coverage including an integration regression test for issue Image-metadata label is not merged into resolved config (remoteUser/containerUser dropped, lifecycle hooks ignored, etc.) #20.

Breaking changes (pre-0.1.0)

  • ResolvedConfig.{Init,Privileged,OverrideCommand,UpdateRemoteUserUID} are now *bool (nil = unset). Use config.BoolOr(*bool, default) at consumers.
  • LifecycleCommands per-phase fields are now []LifecycleCommand so base image → features → user hooks run sequentially.
  • FeatureMetadata expanded with the full mergeable surface (RemoteUser, ContainerUser, UserEnvProbe, WaitFor, ShutdownAction, UpdateRemoteUserUID, RemoteEnv, OverrideCommand, HostRequirements).

Test plan

  • go test ./... (unit) passes
  • go test -tags=integration ./test/integration/... passes (41s)
  • Integration regression for Image-metadata label is not merged into resolved config (remoteUser/containerUser dropped, lifecycle hooks ignored, etc.) #20: image with LABEL devcontainer.metadata declaring remoteUser=vscodeEngine.Exec(whoami) == "vscode"
  • Companion test: user devcontainer.json remoteUser overrides image metadata
  • 14 reducer table tests in config/merge_metadata_test.go (precedence, union, dedup, lifecycle ordering, idempotency)
  • feature/dockerfile_test.go round-trip preserves no-ID final entry plus full-field surface

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Container images can now embed configuration metadata that is automatically merged with workspace settings.
    • Image-provided settings (remote user, environment variables, mounts, capabilities) are inherited when not explicitly overridden.
    • Multiple lifecycle commands per phase are now supported with proper execution ordering.
  • Bug Fixes

    • Fixed execution fallback behavior when user is not specified.
  • Tests

    • Added integration tests for image metadata handling and override precedence.

Review Change Stack

Implements the devcontainers metadata-merge spec so image-baked metadata
(remoteUser, lifecycle hooks, mounts, env, host requirements, probes)
flows into the resolved config on both Up and Attach paths.

- ResolvedConfig bool fields → *bool to distinguish unset from false;
  add config.BoolOr helper and Finalize() to apply spec defaults after
  the merge pipeline.
- LifecycleCommands phases → []LifecycleCommand so base image, features,
  and user hooks all run sequentially.
- New config/merge_metadata.go reducer: last-write-wins scalars, union
  + dedup slices, target-dedup mounts, append-per-phase lifecycle,
  user devcontainer.json wins on conflict.
- feature/metadata.go: lossless round-trip of the full mergeable
  surface; ParseMetadataLabel preserves the no-ID resolved entry.
- feature/parse.go: read mounts and lifecycle hooks from
  devcontainer-feature.json (previously dropped).
- up.go / attach.go: read devcontainer.metadata label unconditionally
  and run MergeMetadata + Finalize on createFresh, createFreshCompose,
  and attachExisting.
- exec.go: fall back to effectiveUser(ws.Config) when opts.User is
  empty, matching lifecycle.go.
- Tests: 14 reducer table tests, dockerfile round-trip coverage, and
  an integration regression test for issue #20.

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

coderabbitai Bot commented May 10, 2026

Copy link
Copy Markdown

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

@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.

🧹 Nitpick comments (4)
feature/metadata.go (1)

159-187: 💤 Low value

Edge case: Parallel command with empty Shell and Exec encodes as empty array.

In encodeLifecycleCommand lines 175-181, if a parallel command entry has both cmd.Shell == "" and len(cmd.Exec) == 0, it encodes cmd.Exec (empty slice) as []. This produces {"name": []} in the output, which decodes back as an empty exec command. While this round-trips correctly (empty command is skipped during merge/execution), it's inconsistent with the single-command path which returns nil for empty commands.

This is unlikely to occur in practice (features/users shouldn't define empty parallel entries), so this is a minor observation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@feature/metadata.go` around lines 159 - 187, The encodeLifecycleCommand
function currently marshals parallel entries even when both cmd.Shell == "" and
len(cmd.Exec) == 0, producing {"name": []}; update the loop over c.Parallel in
encodeLifecycleCommand to skip entries that are empty (if cmd.Shell == "" &&
len(cmd.Exec) == 0 continue) and after building obj return nil when obj is empty
(if len(obj) == 0 return nil) so empty parallel maps are omitted like the
single-command path; reference vars: encodeLifecycleCommand, c.Parallel, obj,
cmd.
feature/parse.go (1)

112-118: 💤 Low value

Mount decode warnings are discarded.

config.DecodeMounts returns warnings (e.g., for unrecognized mount entries), but they're discarded here with _, err := config.DecodeMounts(...). Consider propagating these warnings to the caller or accumulating them for diagnostics. If this is intentional (e.g., feature metadata parsing is lenient), a brief comment would clarify.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@feature/parse.go` around lines 112 - 118, The code is discarding warnings
returned by config.DecodeMounts; capture the warnings value (e.g., warnings, err
:= config.DecodeMounts(r.Mounts)) and either append them to the feature
metadata's diagnostic/warnings field (e.g., add to out.Warnings or similar on
config.FeatureMetadata) or return them to the caller alongside the error/result;
if ignoring warnings is intentional, add a concise comment above the call
explaining why. Update the block that currently assigns out.Mounts so it also
preserves and surfaces the DecodeMounts warnings via the existing metadata
warning mechanism (or document the intentional discard).
config/merge_metadata.go (1)

64-67: Update misleading comment on lines 64–65: it incorrectly implies user values are preserved on collision.

The comment states "an explicit user entry stays after dedup," but unionDedup keeps first occurrence, and since layers are passed first (line 66), layer values are preferred on collision. Test TestMergeMetadata_CapAdd_UnionDedup confirms this is intentional for union semantics (the capability is present either way), but the comment text contradicts the actual behavior.

Clarify the comment to explain union behavior correctly: "Layers contribute first, then user, dedup keeps first occurrence (layer values preferred on collision)."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@config/merge_metadata.go` around lines 64 - 67, The comment above the
union/dedup logic is misleading: update the text to state that layers are
provided first and unionDedup keeps the first occurrence, so layer values are
preferred on collisions; specifically, revise the comment near the unionDedup
calls for cfg.CapAdd and cfg.SecurityOpt to mention layerStrings,
FeatureMetadata, and unionDedup semantics (layers contribute first, then user,
dedup keeps first occurrence — layer wins on collision).
test/integration/image_metadata_test.go (1)

100-103: 💤 Low value

Inconsistent skip message.

The first test uses t.Skip("integration tests skipped with -short") while this test uses t.Skip() with no message. Consider using a consistent skip message for clarity in test output.

♻️ Suggested fix
 func TestImageMetadata_UserOverrideWins(t *testing.T) {
 	if testing.Short() {
-		t.Skip()
+		t.Skip("integration tests skipped with -short")
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/integration/image_metadata_test.go` around lines 100 - 103, The test
TestImageMetadata_UserOverrideWins calls t.Skip() without a message; change it
to use the same skip message as the other integration test (e.g.,
t.Skip("integration tests skipped with -short")) so skip output is consistent —
update the t.Skip call in TestImageMetadata_UserOverrideWins accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@config/merge_metadata.go`:
- Around line 64-67: The comment above the union/dedup logic is misleading:
update the text to state that layers are provided first and unionDedup keeps the
first occurrence, so layer values are preferred on collisions; specifically,
revise the comment near the unionDedup calls for cfg.CapAdd and cfg.SecurityOpt
to mention layerStrings, FeatureMetadata, and unionDedup semantics (layers
contribute first, then user, dedup keeps first occurrence — layer wins on
collision).

In `@feature/metadata.go`:
- Around line 159-187: The encodeLifecycleCommand function currently marshals
parallel entries even when both cmd.Shell == "" and len(cmd.Exec) == 0,
producing {"name": []}; update the loop over c.Parallel in
encodeLifecycleCommand to skip entries that are empty (if cmd.Shell == "" &&
len(cmd.Exec) == 0 continue) and after building obj return nil when obj is empty
(if len(obj) == 0 return nil) so empty parallel maps are omitted like the
single-command path; reference vars: encodeLifecycleCommand, c.Parallel, obj,
cmd.

In `@feature/parse.go`:
- Around line 112-118: The code is discarding warnings returned by
config.DecodeMounts; capture the warnings value (e.g., warnings, err :=
config.DecodeMounts(r.Mounts)) and either append them to the feature metadata's
diagnostic/warnings field (e.g., add to out.Warnings or similar on
config.FeatureMetadata) or return them to the caller alongside the error/result;
if ignoring warnings is intentional, add a concise comment above the call
explaining why. Update the block that currently assigns out.Mounts so it also
preserves and surfaces the DecodeMounts warnings via the existing metadata
warning mechanism (or document the intentional discard).

In `@test/integration/image_metadata_test.go`:
- Around line 100-103: The test TestImageMetadata_UserOverrideWins calls
t.Skip() without a message; change it to use the same skip message as the other
integration test (e.g., t.Skip("integration tests skipped with -short")) so skip
output is consistent — update the t.Skip call in
TestImageMetadata_UserOverrideWins accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 67f05ac2-9cea-4108-9c78-f36d30819179

📥 Commits

Reviewing files that changed from the base of the PR and between b143452 and 4cc244e.

📒 Files selected for processing (14)
  • attach.go
  • config/merge_metadata.go
  • config/merge_metadata_test.go
  • config/normalize.go
  • config/resolve.go
  • config/resolve_test.go
  • config/resolved.go
  • exec.go
  • feature/dockerfile_test.go
  • feature/metadata.go
  • feature/parse.go
  • lifecycle.go
  • test/integration/image_metadata_test.go
  • up.go

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.

1 participant