Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/runusercommands.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ func (cmd *RunUserCommandsCmd) Run(ctx context.Context) error {

user := devcconfig.GetRemoteUser(result)
log.Infof("lifecycle commands completed for container %s", params.containerID)
_ = devcconfig.WriteResultJSON(os.Stderr, params.containerID, user, params.workdir)
_ = devcconfig.WriteResultJSON(os.Stderr, params.containerID, user, params.workdir, nil)
return nil
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"image": "ghcr.io/devsy-org/test-images/base:alpine",
"features": {
"./features/current-feature": {},
"./features/consumer-feature": {}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"id": "consumer-feature",
"version": "1.0.0",
"name": "Consumer Feature",
"description": "A feature that depends on another feature using its legacy ID",
"dependsOn": {
"old-feature-name": {}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#!/bin/bash
set -e

echo "Installing consumer-feature"

cat >/usr/local/bin/test-legacy-resolution <<'EOF'
#!/bin/bash
if command -v legacy-resolved >/dev/null 2>&1; then
echo "SUCCESS: legacy ID resolution worked"
legacy-resolved
else
echo "FAILURE: legacy-resolved command not found"
exit 1
fi
EOF

chmod +x /usr/local/bin/test-legacy-resolution
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"id": "current-feature",
"version": "1.0.0",
"name": "Current Feature",
"description": "A feature with legacy IDs for testing resolution",
"legacyIds": ["old-feature-name", "ancient-feature-name"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#!/bin/bash
set -e

echo "Installing current-feature"

cat >/usr/local/bin/legacy-resolved <<'EOF'
#!/bin/bash
echo "legacy-id-resolved-successfully"
EOF

chmod +x /usr/local/bin/legacy-resolved
27 changes: 27 additions & 0 deletions e2e/tests/up-features/up_features.go
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,33 @@ var _ = ginkgo.Describe("testing up command", ginkgo.Label("up-features", "suite
framework.ExpectEqual(strings.TrimSpace(out), "ubuntu")
}, ginkgo.SpecTimeout(framework.TimeoutShort()))

ginkgo.It(
"should resolve legacy feature IDs in dependsOn",
ginkgo.Label("features", "depends-on", "legacy-id"),
func(ctx context.Context) {
f, err := setupDockerProvider(initialDir+"/bin", "docker")
framework.ExpectNoError(err)

tempDir, err := framework.CopyToTempDir(
"tests/up-features/testdata/docker-features-legacy-id-resolution",
)
framework.ExpectNoError(err)
ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, tempDir)

wsName := filepath.Base(tempDir)
ginkgo.DeferCleanup(f.DevsyWorkspaceDelete, wsName)

err = f.DevsyUp(ctx, tempDir)
framework.ExpectNoError(err)

out, err := f.DevsySSH(ctx, wsName, "test-legacy-resolution")
framework.ExpectNoError(err)
gomega.Expect(out).To(gomega.ContainSubstring("SUCCESS: legacy ID resolution worked"))
gomega.Expect(out).To(gomega.ContainSubstring("legacy-id-resolved-successfully"))
},
ginkgo.SpecTimeout(framework.TimeoutShort()),
)

ginkgo.It(
"should reject overrideFeatureInstallOrder that violates dependsOn",
ginkgo.Label("features", "override"),
Expand Down
27 changes: 27 additions & 0 deletions pkg/devcontainer/feature/extend.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ type featureDependencyResolver struct {
resolved map[string]*config.FeatureSet
visiting map[string]bool
processor *featureProcessor
legacyMap map[string]string
}

func (r *featureDependencyResolver) resolveFeatureDependency(
Expand All @@ -386,13 +387,21 @@ func (r *featureDependencyResolver) resolveFeatureDependency(
normalizedDepID := normalizeFeatureID(depID)
depFeatureSet, exists := r.features[normalizedDepID]
if !exists {
if currentID, legacyMatch := r.legacyMap[normalizedDepID]; legacyMatch {
log.Debugf("resolved legacy ID %s to current feature %s", depID, currentID)
depFeatureSet = r.features[currentID]
normalizedDepID = currentID
}
}
if depFeatureSet == nil {
log.Debugf("installing dependency feature %s", depID)
var err error
depFeatureSet, err = r.processor.processFeature(depID, depOptions)
if err != nil {
return fmt.Errorf("failed to resolve dependency %s: %w", depID, err)
}
r.features[normalizedDepID] = depFeatureSet
r.rebuildLegacyMap()
}

err := r.resolveFeatureDependency(normalizedDepID, depFeatureSet)
Expand All @@ -405,6 +414,10 @@ func (r *featureDependencyResolver) resolveFeatureDependency(
return nil
}

func (r *featureDependencyResolver) rebuildLegacyMap() {
r.legacyMap = buildLegacyIDMap(r.features)
}

func resolveDependencies(
processor *featureProcessor,
features map[string]*config.FeatureSet,
Expand All @@ -414,6 +427,7 @@ func resolveDependencies(
resolved: make(map[string]*config.FeatureSet),
visiting: make(map[string]bool),
processor: processor,
legacyMap: buildLegacyIDMap(features),
}

for featureID, featureSet := range features {
Expand All @@ -426,6 +440,19 @@ func resolveDependencies(
return resolver.resolved, nil
}

func buildLegacyIDMap(features map[string]*config.FeatureSet) map[string]string {
legacyMap := make(map[string]string)
for configID, featureSet := range features {
if featureSet.Config == nil {
continue
}
for _, legacyID := range featureSet.Config.LegacyIds {
legacyMap[normalizeFeatureID(legacyID)] = configID
}
}
return legacyMap
}

func normalizeFeatureID(featureID string) string {
ref, err := name.ParseReference(featureID)
if err != nil {
Expand Down
103 changes: 103 additions & 0 deletions pkg/devcontainer/feature/extend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -487,3 +487,106 @@ func (suite *ExtendTestSuite) TestFindContainerUsersUsesMetadataAndImageUserFall
suite.Equal("nonroot", containerUser)
suite.Equal("vscode", remoteUser)
}

func (suite *ExtendTestSuite) TestBuildLegacyIDMap() {
features := map[string]*config.FeatureSet{
"ghcr.io/org/features/current-name": {
ConfigID: "ghcr.io/org/features/current-name",
Config: &config.FeatureConfig{
LegacyIds: []string{
"ghcr.io/org/features/old-name",
"ghcr.io/org/features/ancient-name",
},
},
},
"ghcr.io/org/features/other": {
ConfigID: "ghcr.io/org/features/other",
Config: &config.FeatureConfig{
LegacyIds: []string{},
},
},
"feature-no-config": {
ConfigID: "feature-no-config",
Config: nil,
},
}

legacyMap := buildLegacyIDMap(features)

suite.Equal("ghcr.io/org/features/current-name", legacyMap["ghcr.io/org/features/old-name"])
suite.Equal("ghcr.io/org/features/current-name", legacyMap["ghcr.io/org/features/ancient-name"])
_, hasOther := legacyMap["ghcr.io/org/features/other"]
suite.False(hasOther)
}

func (suite *ExtendTestSuite) TestBuildLegacyIDMap_NormalizesVersionTags() {
features := map[string]*config.FeatureSet{
"ghcr.io/org/features/node": {
ConfigID: "ghcr.io/org/features/node",
Config: &config.FeatureConfig{
LegacyIds: []string{"ghcr.io/org/features/nodejs:1"},
},
},
}

legacyMap := buildLegacyIDMap(features)

suite.Equal("ghcr.io/org/features/node", legacyMap["ghcr.io/org/features/nodejs"])
}

func (suite *ExtendTestSuite) TestResolveDependencies_LegacyIDResolution() {
features := map[string]*config.FeatureSet{
"current-feature": {
ConfigID: "current-feature",
Config: &config.FeatureConfig{
LegacyIds: []string{"old-feature-name"},
DependsOn: config.DependsOnField{},
},
},
"consumer-feature": {
ConfigID: "consumer-feature",
Config: &config.FeatureConfig{
DependsOn: config.DependsOnField{
"old-feature-name": map[string]any{},
},
},
},
}

resolved, err := resolveDependencies(&featureProcessor{}, features)
suite.Require().NoError(err)
suite.Len(resolved, 2)
suite.NotNil(resolved["current-feature"])
suite.NotNil(resolved["consumer-feature"])
}

func (suite *ExtendTestSuite) TestResolveDependencies_LegacyIDNotUsedWhenPrimaryExists() {
features := map[string]*config.FeatureSet{
"feature-a": {
ConfigID: "feature-a",
Config: &config.FeatureConfig{
LegacyIds: []string{"feature-b"},
DependsOn: config.DependsOnField{},
},
},
"feature-b": {
ConfigID: "feature-b",
Config: &config.FeatureConfig{
DependsOn: config.DependsOnField{},
},
},
"consumer": {
ConfigID: "consumer",
Config: &config.FeatureConfig{
DependsOn: config.DependsOnField{
"feature-b": map[string]any{},
},
},
},
}

resolved, err := resolveDependencies(&featureProcessor{}, features)
suite.Require().NoError(err)
suite.Len(resolved, 3)
suite.NotNil(resolved["feature-b"])
}
Loading