Skip to content

Commit 3d232fa

Browse files
aledbfclaude
andcommitted
refactor(config): phased VariableResolver with an inline ${...} scanner
Collapse the SubstituteHost/SubstituteContainer/SubstituteDevContainerID/ SubstituteTemplateOptions free functions into a single VariableResolver that applies substitutions one explicit phase at a time (host, container, identity, template) over a shared SubstitutionContext, and returns an error instead of silently swallowing malformed input. ${...} tags are scanned by a ~25-line substituteTags helper: text/template is the wrong tool here (its {{action}} model can't parse the opaque ${var:arg} syntax, and it can't leave unknown tags verbatim for a later phase), and a vendored engine was 200+ lines of dead API for one call site. Callers migrate to the resolver: the up flow runs the full pre-container pipeline (BeforeContainerInto) before consuming config fields and derives id-labels up front; container-phase call sites (exec, read-configuration, setup, up) share a resolveContainerVariables helper; mounts, initializeCommand and feature host-var substitution thread the typed context and surface resolution errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ac3e0ac commit 3d232fa

14 files changed

Lines changed: 557 additions & 208 deletions

File tree

internal/cli/env.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,17 @@ package cli
33
import (
44
"os"
55
"strings"
6+
7+
"github.com/devcontainers/cli/internal/config"
68
)
79

10+
func resolveContainerVariables(containerEnv map[string]string, value interface{}) (interface{}, error) {
11+
return config.NewVariableResolver().AfterContainer(config.SubstitutionContext{
12+
HostSubContext: config.HostSubContext{Platform: "linux"},
13+
ContainerEnv: containerEnv,
14+
}, value)
15+
}
16+
817
// osEnvMap returns the current process environment as a map.
918
func osEnvMap() map[string]string {
1019
env := make(map[string]string)

internal/cli/exec.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,10 @@ func runExec(ctx context.Context, opts *execOpts, cmdArgs []string) error {
262262

263263
// 8. Substitute ${containerEnv:X} in resolved remoteEnv
264264
for k, v := range resolvedRemoteEnv {
265-
substituted := config.SubstituteContainer("linux", containerEnv, v)
265+
substituted, err := resolveContainerVariables(containerEnv, v)
266+
if err != nil {
267+
return fmt.Errorf("resolve container variables: %w", err)
268+
}
266269
if s, ok := substituted.(string); ok {
267270
resolvedRemoteEnv[k] = s
268271
}

internal/cli/feature_hostsub_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ func TestSubstituteFeatureHostVars(t *testing.T) {
2222
}},
2323
}}
2424

25-
substituteFeatureHostVars(sets, hs)
25+
if err := substituteFeatureHostVars(sets, hs); err != nil {
26+
t.Fatal(err)
27+
}
2628

2729
f := sets[0].Features[0]
2830
if got := f.ContainerEnv["CACHE"]; got != "/home/me/.cache" {

internal/cli/feature_install.go

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -469,22 +469,33 @@ func realignFeatureDirs(tmpDir string, sets []*features.Set) error {
469469
// so a Feature mount like "${localEnv:HOME}/.cfg" reaches Docker resolved —
470470
// matching how the config's own containerEnv/mounts are substituted (upstream
471471
// #308, which only substituted devcontainer.json's own values).
472-
func substituteFeatureHostVars(sets []*features.Set, hs config.HostSubContext) {
472+
func substituteFeatureHostVars(sets []*features.Set, hs config.HostSubContext) error {
473+
resolver := config.NewVariableResolver()
474+
ctx := config.SubstitutionContext{HostSubContext: hs}
473475
for _, fs := range sets {
474476
for i := range fs.Features {
475477
f := &fs.Features[i]
476478
for k, v := range f.ContainerEnv {
477-
if s, ok := config.SubstituteHost(hs, v).(string); ok {
479+
resolved, err := resolver.Resolve(ctx, config.PhaseHost, v)
480+
if err != nil {
481+
return fmt.Errorf("resolve Feature %s containerEnv %s: %w", f.ID, k, err)
482+
}
483+
if s, ok := resolved.(string); ok {
478484
f.ContainerEnv[k] = s
479485
}
480486
}
481487
if len(f.Mounts) > 0 {
482-
if m, ok := config.SubstituteHost(hs, f.Mounts).([]interface{}); ok {
488+
resolved, err := resolver.Resolve(ctx, config.PhaseHost, f.Mounts)
489+
if err != nil {
490+
return fmt.Errorf("resolve Feature %s mounts: %w", f.ID, err)
491+
}
492+
if m, ok := resolved.([]interface{}); ok {
483493
f.Mounts = m
484494
}
485495
}
486496
}
487497
}
498+
return nil
488499
}
489500

490501
func extendImageWithFeatures(
@@ -526,7 +537,9 @@ func extendImageWithFeatures(
526537
// Resolve ${localEnv:…} / ${localWorkspaceFolder} / … in each Feature's
527538
// containerEnv and mounts, like the config's own values (upstream #308).
528539
if fbOpts != nil && fbOpts.HostSub != nil {
529-
substituteFeatureHostVars(featureSets, *fbOpts.HostSub)
540+
if err := substituteFeatureHostVars(featureSets, *fbOpts.HostSub); err != nil {
541+
return nil, err
542+
}
530543
}
531544

532545
// Write/validate the features lockfile when requested (opt-in via the

internal/cli/mounts.go

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,22 +102,44 @@ func composeVolumeSpecsFromMetadata(entries []interface{}, devcontainerID string
102102
}
103103

104104
func mountFromMetadata(entry interface{}, devcontainerID string) (dockermount.Mount, error) {
105+
resolve := func(value string) (string, error) {
106+
resolved, err := config.NewVariableResolver().Resolve(config.SubstitutionContext{
107+
DevContainerID: devcontainerID,
108+
}, config.PhaseIdentity, value)
109+
if err != nil {
110+
return "", err
111+
}
112+
result, ok := resolved.(string)
113+
if !ok {
114+
return "", fmt.Errorf("identity substitution returned %T for string", resolved)
115+
}
116+
return result, nil
117+
}
105118
switch raw := entry.(type) {
106119
case string:
107-
spec := config.SubstituteDevContainerIDString(devcontainerID, raw)
120+
spec, err := resolve(raw)
121+
if err != nil {
122+
return dockermount.Mount{}, err
123+
}
108124
return docker.ParseMountSpec(spec)
109125
case map[string]interface{}:
110126
target, _ := raw["target"].(string)
111127
// ${devcontainerId} must resolve in target too, not only source — the
112128
// string form substitutes the whole spec, so the object form has to match
113129
// (e.g. target "/cache/${devcontainerId}").
114-
target = config.SubstituteDevContainerIDString(devcontainerID, target)
130+
target, err := resolve(target)
131+
if err != nil {
132+
return dockermount.Mount{}, err
133+
}
115134
if target == "" {
116135
return dockermount.Mount{}, fmt.Errorf("mount requires a target/destination")
117136
}
118137

119138
source, _ := raw["source"].(string)
120-
source = config.SubstituteDevContainerIDString(devcontainerID, source)
139+
source, err = resolve(source)
140+
if err != nil {
141+
return dockermount.Mount{}, err
142+
}
121143
mountType, _ := raw["type"].(string)
122144
if mountType == "" {
123145
mountType = string(dockermount.TypeBind)

internal/cli/read_configuration.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,10 @@ func runReadConfiguration(ctx context.Context, out Output, opts *readConfigOpts)
228228
inspect, inspectErr := engine.InspectContainer(ctx, containerID)
229229
if inspectErr == nil && inspect.Config != nil {
230230
cEnv := envSliceToMap(inspect.Config.Env)
231-
substituted := config.SubstituteContainer("linux", cEnv, cfgMap)
231+
substituted, subErr := resolveContainerVariables(cEnv, cfgMap)
232+
if subErr != nil {
233+
return writeErrorResult(out, fmt.Sprintf("resolve configuration container variables: %v", subErr))
234+
}
232235
if subMap, ok := substituted.(map[string]interface{}); ok {
233236
cfgMap = subMap
234237
}
@@ -339,7 +342,10 @@ func runReadConfiguration(ctx context.Context, out Output, opts *readConfigOpts)
339342
mergedJSON, _ := json.Marshal(merged)
340343
var mergedGeneric interface{}
341344
json.Unmarshal(mergedJSON, &mergedGeneric)
342-
substituted := config.SubstituteContainer("linux", cEnv, mergedGeneric)
345+
substituted, subErr := resolveContainerVariables(cEnv, mergedGeneric)
346+
if subErr != nil {
347+
return subErr
348+
}
343349
subJSON, _ := json.Marshal(substituted)
344350
json.Unmarshal(subJSON, merged)
345351
}

internal/cli/setup.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,10 @@ func newSetUpCmd() *cobra.Command {
183183
cfgJSON, _ := json.Marshal(cfg)
184184
var cfgGeneric interface{}
185185
json.Unmarshal(cfgJSON, &cfgGeneric)
186-
substituted := config.SubstituteContainer("linux", containerEnv, cfgGeneric)
186+
substituted, subErr := resolveContainerVariables(containerEnv, cfgGeneric)
187+
if subErr != nil {
188+
return writeErrorResult(out, fmt.Sprintf("resolve configuration container variables: %v", subErr))
189+
}
187190
// Clean null values and internal fields
188191
if subMap, ok := substituted.(map[string]interface{}); ok {
189192
for k, v := range subMap {
@@ -225,7 +228,10 @@ func newSetUpCmd() *cobra.Command {
225228
mergedJSON, _ := json.Marshal(merged)
226229
var mergedGeneric interface{}
227230
json.Unmarshal(mergedJSON, &mergedGeneric)
228-
substituted := config.SubstituteContainer("linux", containerEnv, mergedGeneric)
231+
substituted, subErr := resolveContainerVariables(containerEnv, mergedGeneric)
232+
if subErr != nil {
233+
return writeErrorResult(out, fmt.Sprintf("resolve merged container variables: %v", subErr))
234+
}
229235
result["mergedConfiguration"] = substituted
230236
}
231237

internal/cli/up.go

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -321,25 +321,38 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error {
321321
}
322322
cfg := loadResult.Config
323323

324+
// Derive id labels before consuming configuration fields so the complete
325+
// pre-container pipeline can run first.
326+
if len(idLabels) == 0 {
327+
idLabels = []string{
328+
fmt.Sprintf("devcontainer.local_folder=%s", workspaceFolder),
329+
}
330+
if loadResult.Config.ConfigFilePath != "" {
331+
idLabels = append(idLabels, fmt.Sprintf("devcontainer.config_file=%s", loadResult.Config.ConfigFilePath))
332+
}
333+
}
334+
324335
// Merge --additional-features into config (config features have priority)
325336
var mergeErr error
326337
opts.lockfileExcludeIDs, mergeErr = mergeAdditionalFeatures(cfg, opts.additionalFeatures)
327338
if mergeErr != nil {
328339
return writeErrorResult(out, mergeErr.Error())
329340
}
330341

331-
if derr := enforceDisallowedFeatures(ctx, cfg, logger); derr != nil {
332-
return writeErrorJSON(out, coreerrors.ToErrorOutput(derr))
342+
idCtx := config.SubstitutionContext{
343+
HostSubContext: loadResult.HostSub,
344+
DevContainerID: config.ComputeDevContainerID(idLabelsMap(idLabels)),
345+
}
346+
resolver := config.NewVariableResolver()
347+
if err := resolver.BeforeContainerInto(idCtx, cfg); err != nil {
348+
return writeErrorResult(out, fmt.Sprintf("apply pre-container substitutions: %v", err))
349+
}
350+
if err := resolver.BeforeContainerInto(idCtx, loadResult.WorkspaceConfig); err != nil {
351+
return writeErrorResult(out, fmt.Sprintf("apply workspace pre-container substitutions: %v", err))
333352
}
334353

335-
// Derive id labels from workspace if not provided via --id-label.
336-
if len(idLabels) == 0 {
337-
idLabels = []string{
338-
fmt.Sprintf("devcontainer.local_folder=%s", workspaceFolder),
339-
}
340-
if loadResult.Config.ConfigFilePath != "" {
341-
idLabels = append(idLabels, fmt.Sprintf("devcontainer.config_file=%s", loadResult.Config.ConfigFilePath))
342-
}
354+
if derr := enforceDisallowedFeatures(ctx, cfg, logger); derr != nil {
355+
return writeErrorJSON(out, coreerrors.ToErrorOutput(derr))
343356
}
344357

345358
// Apply --remove-existing-container up-front, BEFORE initializeCommand, so a
@@ -356,7 +369,7 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error {
356369
// A failure aborts `up` with an error outcome, matching the TS CLI (which
357370
// throws a ContainerError). Swallowing it produced silently-broken setups.
358371
if !cfg.InitializeCommand.IsEmpty() {
359-
if err := lifecycle.RunInitializeCommand(ctx, logger, &cfg.InitializeCommand, workspaceFolder); err != nil {
372+
if err := lifecycle.RunInitializeCommand(ctx, logger, &cfg.InitializeCommand, loadResult.HostSub); err != nil {
360373
return writeErrorJSON(out, coreerrors.ToErrorOutput(&coreerrors.ContainerError{
361374
Description: "The initializeCommand in the devcontainer.json failed.",
362375
}))
@@ -492,7 +505,10 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error {
492505
cfgJSON, _ := json.Marshal(cfg)
493506
var cfgGeneric interface{}
494507
json.Unmarshal(cfgJSON, &cfgGeneric)
495-
substituted := config.SubstituteContainer("linux", containerEnv, cfgGeneric)
508+
substituted, subErr := resolveContainerVariables(containerEnv, cfgGeneric)
509+
if subErr != nil {
510+
return writeErrorResult(out, fmt.Sprintf("resolve configuration container variables: %v", subErr))
511+
}
496512
if subMap, ok := substituted.(map[string]interface{}); ok {
497513
for k, v := range subMap {
498514
if v == nil {
@@ -550,7 +566,10 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error {
550566
mergedJSON, _ := json.Marshal(merged)
551567
var mergedGeneric interface{}
552568
json.Unmarshal(mergedJSON, &mergedGeneric)
553-
mergedSub := config.SubstituteContainer("linux", containerEnv, mergedGeneric)
569+
mergedSub, subErr := resolveContainerVariables(containerEnv, mergedGeneric)
570+
if subErr != nil {
571+
return writeErrorResult(out, fmt.Sprintf("resolve merged container variables: %v", subErr))
572+
}
554573
// Carry over config-only properties spread into mergedConfiguration by
555574
// the TS CLI that the typed MergedConfig does not model.
556575
if mm, ok := mergedSub.(map[string]interface{}); ok {
@@ -569,6 +588,16 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error {
569588
return writeSuccessJSON(out, result)
570589
}
571590

591+
func idLabelsMap(labels []string) map[string]string {
592+
result := make(map[string]string, len(labels))
593+
for _, label := range labels {
594+
if i := strings.IndexByte(label, '='); i >= 0 {
595+
result[label[:i]] = label[i+1:]
596+
}
597+
}
598+
return result
599+
}
600+
572601
// finishUp produces the JSON result for an existing container found via --id-label
573602
// (no config/loadResult available). cfg and loadResult may be nil.
574603
func (r *upRunner) finishUp(ctx context.Context, containerID string, cfg *config.DevContainer, loadResult *config.LoadResult) error {

internal/config/loader.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,10 @@ func LoadDevContainerConfig(workspaceFolder, configPath, overrideConfigPath stri
169169
ConfigFilePath: config.ConfigFilePath,
170170
}
171171

172-
substituted := SubstituteHost(ctx, raw)
172+
substituted, err := NewVariableResolver().Resolve(SubstitutionContext{HostSubContext: ctx}, PhaseHost, raw)
173+
if err != nil {
174+
return nil, fmt.Errorf("apply host substitutions: %w", err)
175+
}
173176
if m, ok := substituted.(map[string]interface{}); ok {
174177
raw = m
175178
}

0 commit comments

Comments
 (0)