From 3fc3b5121c98aa7e8f39698346e439c44baef712 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 7 May 2026 08:04:43 -0500 Subject: [PATCH 1/9] feat(cli): implement features package command Add `features package` subcommand that bundles feature source directories into OCI-compliant devcontainer-feature-.tgz tarballs, per the dev container features packaging specification. --- cmd/features/package.go | 201 ++++++++++++++++ cmd/features/package_test.go | 208 +++++++++++++++++ cmd/features/root.go | 1 + e2e/tests/features/features_package_cmd.go | 260 +++++++++++++++++++++ 4 files changed, 670 insertions(+) create mode 100644 cmd/features/package.go create mode 100644 cmd/features/package_test.go create mode 100644 e2e/tests/features/features_package_cmd.go diff --git a/cmd/features/package.go b/cmd/features/package.go new file mode 100644 index 000000000..f5bba8bbb --- /dev/null +++ b/cmd/features/package.go @@ -0,0 +1,201 @@ +package features + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/extract" + "github.com/spf13/cobra" +) + +type PackageCmd struct { + *flags.GlobalFlags + + Target string + OutputFolder string + ForceCleanOutputFolder bool + Output string +} + +type packageResult struct { + FeatureID string `json:"featureId"` + Version string `json:"version"` + Filename string `json:"filename"` + OutputPath string `json:"outputPath"` +} + +func NewPackageCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + cmd := &PackageCmd{GlobalFlags: globalFlags} + packageCmd := &cobra.Command{ + Use: "package", + Short: "Package feature source directories into OCI-compliant tarballs", + Long: `Bundle feature source directories into devcontainer-feature-.tgz archives. + +Scans the target directory for subdirectories containing devcontainer-feature.json +and creates gzipped tar archives suitable for OCI distribution.`, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(_ *cobra.Command, _ []string) error { + return cmd.Run() + }, + } + + packageCmd.Flags().StringVar( + &cmd.Target, "target", "", + "Path to directory containing feature source subdirectories", + ) + packageCmd.Flags().StringVar( + &cmd.OutputFolder, "output-folder", ".", + "Where to write the .tgz files", + ) + packageCmd.Flags().BoolVar( + &cmd.ForceCleanOutputFolder, "force-clean-output-folder", false, + "Clean output folder before writing", + ) + packageCmd.Flags().StringVar( + &cmd.Output, "output", "text", + "Output format (text or json)", + ) + _ = packageCmd.MarkFlagRequired("target") + + return packageCmd +} + +func (cmd *PackageCmd) Run() error { + if err := validateOutputFormat(cmd.Output); err != nil { + return err + } + + target, err := filepath.Abs(cmd.Target) + if err != nil { + return fmt.Errorf("resolve target path: %w", err) + } + + outputFolder, err := cmd.prepareOutputFolder() + if err != nil { + return err + } + + features, err := cmd.discoverFeatures(target) + if err != nil { + return err + } + + var results []packageResult + for _, feat := range features { + result, pkgErr := cmd.packageFeature(feat, target, outputFolder) + if pkgErr != nil { + return pkgErr + } + results = append(results, result) + } + + if cmd.Output == outputJSON { + return writeJSON(os.Stdout, results) + } + + return cmd.printResults(results) +} + +func (cmd *PackageCmd) prepareOutputFolder() (string, error) { + outputFolder, err := filepath.Abs(cmd.OutputFolder) + if err != nil { + return "", fmt.Errorf("resolve output folder: %w", err) + } + + if cmd.ForceCleanOutputFolder { + if err := os.RemoveAll(outputFolder); err != nil { + return "", fmt.Errorf("clean output folder: %w", err) + } + } + + // #nosec G301 -- output directory needs to be accessible + if err := os.MkdirAll(outputFolder, 0o755); err != nil { + return "", fmt.Errorf("create output folder: %w", err) + } + + return outputFolder, nil +} + +type featureSource struct { + dir string + config *config.FeatureConfig +} + +func (cmd *PackageCmd) discoverFeatures(targetDir string) ([]featureSource, error) { + entries, err := os.ReadDir(targetDir) + if err != nil { + return nil, fmt.Errorf("read target directory: %w", err) + } + + var features []featureSource + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + featureDir := filepath.Join(targetDir, entry.Name()) + featureCfg, parseErr := config.ParseDevContainerFeature(featureDir) + if parseErr != nil { + continue + } + + features = append(features, featureSource{ + dir: entry.Name(), + config: featureCfg, + }) + } + + if len(features) == 0 { + return nil, fmt.Errorf("no features found in %s", targetDir) + } + return features, nil +} + +func (cmd *PackageCmd) packageFeature( + feat featureSource, targetDir, outputFolder string, +) (packageResult, error) { + featureDir := filepath.Join(targetDir, feat.dir) + filename := fmt.Sprintf("devcontainer-feature-%s.tgz", feat.config.ID) + outputPath := filepath.Join(outputFolder, filename) + + f, err := os.Create(outputPath) // #nosec G304 -- path constructed from validated inputs + if err != nil { + return packageResult{}, fmt.Errorf("create archive %s: %w", filename, err) + } + defer func() { _ = f.Close() }() + + var buf bytes.Buffer + if err := extract.WriteTar(&buf, featureDir, true); err != nil { + return packageResult{}, fmt.Errorf("create tar for %s: %w", feat.config.ID, err) + } + + if _, err := f.Write(buf.Bytes()); err != nil { + return packageResult{}, fmt.Errorf("write archive %s: %w", filename, err) + } + + return packageResult{ + FeatureID: feat.config.ID, + Version: feat.config.Version, + Filename: filename, + OutputPath: outputPath, + }, nil +} + +func (cmd *PackageCmd) printResults(results []packageResult) error { + w := os.Stdout + _, _ = fmt.Fprintln(w, "Packaged dev container features:") + for _, r := range results { + version := r.Version + if version == "" { + version = "(no version)" + } + _, _ = fmt.Fprintf(w, " %s (%s) -> %s\n", r.FeatureID, version, r.OutputPath) + } + _, _ = fmt.Fprintf(w, "\nTotal: %d feature(s) packaged\n", len(results)) + return nil +} diff --git a/cmd/features/package_test.go b/cmd/features/package_test.go new file mode 100644 index 000000000..56915375e --- /dev/null +++ b/cmd/features/package_test.go @@ -0,0 +1,208 @@ +package features + +import ( + "archive/tar" + "compress/gzip" + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPackageCmd_FlagDefaults(t *testing.T) { + cmd := NewPackageCmd(nil) + + targetFlag := cmd.Flags().Lookup("target") + require.NotNil(t, targetFlag) + assert.Equal(t, "", targetFlag.DefValue) + + outputFolderFlag := cmd.Flags().Lookup("output-folder") + require.NotNil(t, outputFolderFlag) + assert.Equal(t, ".", outputFolderFlag.DefValue) + + forceCleanFlag := cmd.Flags().Lookup("force-clean-output-folder") + require.NotNil(t, forceCleanFlag) + assert.Equal(t, "false", forceCleanFlag.DefValue) + + outputFlag := cmd.Flags().Lookup("output") + require.NotNil(t, outputFlag) + assert.Equal(t, "text", outputFlag.DefValue) +} + +func TestPackageCmd_AllFlagsRegistered(t *testing.T) { + cmd := NewPackageCmd(nil) + expected := []string{ + "target", + "output-folder", + "force-clean-output-folder", + "output", + } + for _, name := range expected { + assert.NotNil(t, cmd.Flags().Lookup(name), "flag %q should be registered", name) + } +} + +func TestPackageCmd_DiscoverFeatures(t *testing.T) { + targetDir := t.TempDir() + + featureADir := filepath.Join(targetDir, "feature-a") + require.NoError(t, os.MkdirAll(featureADir, 0o700)) + require.NoError(t, os.WriteFile( + filepath.Join(featureADir, "devcontainer-feature.json"), + []byte(`{"id":"feature-a","version":"1.0.0","name":"Feature A"}`), + 0o600, + )) + + featureBDir := filepath.Join(targetDir, "feature-b") + require.NoError(t, os.MkdirAll(featureBDir, 0o700)) + require.NoError(t, os.WriteFile( + filepath.Join(featureBDir, "devcontainer-feature.json"), + []byte(`{"id":"feature-b","version":"2.0.0","name":"Feature B"}`), + 0o600, + )) + + require.NoError(t, os.WriteFile( + filepath.Join(targetDir, "not-a-dir.txt"), + []byte("ignored"), + 0o600, + )) + + cmd := &PackageCmd{} + features, err := cmd.discoverFeatures(targetDir) + require.NoError(t, err) + assert.Len(t, features, 2) + + ids := make(map[string]bool) + for _, f := range features { + ids[f.config.ID] = true + } + assert.True(t, ids["feature-a"]) + assert.True(t, ids["feature-b"]) +} + +func TestPackageCmd_DiscoverFeatures_EmptyDir(t *testing.T) { + targetDir := t.TempDir() + + cmd := &PackageCmd{} + _, err := cmd.discoverFeatures(targetDir) + require.Error(t, err) + assert.Contains(t, err.Error(), "no features found") +} + +func TestPackageCmd_DiscoverFeatures_NonexistentDir(t *testing.T) { + cmd := &PackageCmd{} + _, err := cmd.discoverFeatures("/nonexistent/path") + require.Error(t, err) + assert.Contains(t, err.Error(), "read target directory") +} + +func TestPackageCmd_PackageFeature(t *testing.T) { + targetDir := t.TempDir() + outputDir := t.TempDir() + + featureDir := filepath.Join(targetDir, "my-feature") + require.NoError(t, os.MkdirAll(featureDir, 0o700)) + require.NoError(t, os.WriteFile( + filepath.Join(featureDir, "devcontainer-feature.json"), + []byte(`{"id":"my-feature","version":"1.2.3","name":"My Feature"}`), + 0o600, + )) + require.NoError(t, os.WriteFile( + filepath.Join(featureDir, "install.sh"), + []byte("#!/bin/bash\necho hello\n"), + 0o600, + )) + + cmd := &PackageCmd{} + + features, err := cmd.discoverFeatures(targetDir) + require.NoError(t, err) + require.Len(t, features, 1) + + result, err := cmd.packageFeature(features[0], targetDir, outputDir) + require.NoError(t, err) + + assert.Equal(t, "my-feature", result.FeatureID) + assert.Equal(t, "1.2.3", result.Version) + assert.Equal(t, "devcontainer-feature-my-feature.tgz", result.Filename) + + archivePath := filepath.Join(outputDir, result.Filename) + assert.FileExists(t, archivePath) + + files := readTarGzEntries(t, archivePath) + assert.Contains(t, files, "devcontainer-feature.json") + assert.Contains(t, files, "install.sh") +} + +func TestPackageCmd_ForceCleanOutputFolder(t *testing.T) { + targetDir := t.TempDir() + outputDir := t.TempDir() + + featureDir := filepath.Join(targetDir, "feat") + require.NoError(t, os.MkdirAll(featureDir, 0o700)) + require.NoError(t, os.WriteFile( + filepath.Join(featureDir, "devcontainer-feature.json"), + []byte(`{"id":"feat","version":"1.0.0"}`), + 0o600, + )) + require.NoError(t, os.WriteFile( + filepath.Join(featureDir, "install.sh"), + []byte("#!/bin/bash\n"), + 0o600, + )) + + existingFile := filepath.Join(outputDir, "old-file.txt") + require.NoError(t, os.WriteFile(existingFile, []byte("old"), 0o600)) + + cmd := &PackageCmd{ + Target: targetDir, + OutputFolder: outputDir, + ForceCleanOutputFolder: true, + Output: "text", + } + + err := cmd.Run() + require.NoError(t, err) + + _, statErr := os.Stat(existingFile) + assert.True(t, os.IsNotExist(statErr), "old file should be removed") + + assert.FileExists(t, filepath.Join(outputDir, "devcontainer-feature-feat.tgz")) +} + +func TestPackageCmd_InvalidOutputFormat(t *testing.T) { + cmd := &PackageCmd{ + Target: "/tmp", + Output: "yaml", + } + err := cmd.Run() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func readTarGzEntries(t *testing.T, path string) []string { + t.Helper() + + f, err := os.Open(path) // #nosec G304 -- test helper + require.NoError(t, err) + defer func() { _ = f.Close() }() + + gz, err := gzip.NewReader(f) + require.NoError(t, err) + defer func() { _ = gz.Close() }() + + tr := tar.NewReader(gz) + var files []string + for { + hdr, readErr := tr.Next() + if readErr == io.EOF { + break + } + require.NoError(t, readErr) + files = append(files, hdr.Name) + } + return files +} diff --git a/cmd/features/root.go b/cmd/features/root.go index 3d0ffa271..932fa51de 100644 --- a/cmd/features/root.go +++ b/cmd/features/root.go @@ -18,6 +18,7 @@ func NewFeaturesCmd(globalFlags *flags.GlobalFlags) *cobra.Command { featuresCmd.AddCommand(NewResolveDepsCmd(globalFlags)) featuresCmd.AddCommand(NewGenerateDocsCmd(globalFlags)) featuresCmd.AddCommand(NewTestCmd(globalFlags)) + featuresCmd.AddCommand(NewPackageCmd(globalFlags)) return featuresCmd } diff --git a/e2e/tests/features/features_package_cmd.go b/e2e/tests/features/features_package_cmd.go new file mode 100644 index 000000000..a7e014780 --- /dev/null +++ b/e2e/tests/features/features_package_cmd.go @@ -0,0 +1,260 @@ +package features + +import ( + "archive/tar" + "compress/gzip" + "context" + "encoding/json" + "io" + "os" + "path/filepath" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +const ( + cmdPackage = "package" + flagTarget = "--target" + flagOutputFolder = "--output-folder" + flagForceCleanOutput = "--force-clean-output-folder" + featureNameGo = "go" + featureNameNode = "node" +) + +var _ = ginkgo.Describe("features package", ginkgo.Label("features", "features-package"), func() { + var initialDir string + + ginkgo.BeforeEach(func() { + var err error + initialDir, err = os.Getwd() + framework.ExpectNoError(err) + }) + + ginkgo.It( + "packages feature directories into tgz archives", + func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + fileBinSuffix) + + targetDir, err := os.MkdirTemp("", "e2e-features-package-*") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(targetDir) }) + + goDir := filepath.Join(targetDir, featureNameGo) + framework.ExpectNoError(os.MkdirAll(goDir, 0o750)) + framework.ExpectNoError(os.WriteFile( + filepath.Join(goDir, fileDevcontainerJSON), + []byte(`{"id":"go","version":"1.2.0","name":"Go"}`), + 0o600, + )) + framework.ExpectNoError(os.WriteFile( + filepath.Join(goDir, fileInstallSh), + []byte("#!/bin/bash\necho installing go\n"), + 0o600, + )) + + outputDir, err := os.MkdirTemp("", "e2e-features-package-output-*") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(outputDir) }) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + cmdFeatures, cmdPackage, + flagTarget, targetDir, + flagOutputFolder, outputDir, + }) + framework.ExpectNoError(err) + + gomega.Expect(stdout).To(gomega.ContainSubstring("go")) + gomega.Expect(stdout).To(gomega.ContainSubstring("1.2.0")) + + archivePath := filepath.Join(outputDir, "devcontainer-feature-go.tgz") + _, statErr := os.Stat(archivePath) + gomega.Expect(statErr).NotTo(gomega.HaveOccurred()) + + files := e2eReadTarGzEntries(archivePath) + gomega.Expect(files).To(gomega.ContainElement("devcontainer-feature.json")) + gomega.Expect(files).To(gomega.ContainElement("install.sh")) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + + ginkgo.It( + "packages multiple features", + func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + fileBinSuffix) + + targetDir, err := os.MkdirTemp("", "e2e-features-package-multi-*") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(targetDir) }) + + for _, feat := range []struct{ id, version string }{ + {featureNameGo, "1.0.0"}, + {featureNameNode, "2.0.0"}, + } { + dir := filepath.Join(targetDir, feat.id) + framework.ExpectNoError(os.MkdirAll(dir, 0o750)) + framework.ExpectNoError(os.WriteFile( + filepath.Join(dir, fileDevcontainerJSON), + []byte(`{"id":"`+feat.id+`","version":"`+feat.version+`"}`), + 0o600, + )) + framework.ExpectNoError(os.WriteFile( + filepath.Join(dir, fileInstallSh), + []byte("#!/bin/bash\n"), + 0o600, + )) + } + + outputDir, err := os.MkdirTemp("", "e2e-features-package-multi-output-*") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(outputDir) }) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + cmdFeatures, cmdPackage, + flagTarget, targetDir, + flagOutputFolder, outputDir, + }) + framework.ExpectNoError(err) + + gomega.Expect(stdout).To(gomega.ContainSubstring(featureNameGo)) + gomega.Expect(stdout).To(gomega.ContainSubstring(featureNameNode)) + + _, statErr := os.Stat(filepath.Join(outputDir, "devcontainer-feature-go.tgz")) + gomega.Expect(statErr).NotTo(gomega.HaveOccurred()) + _, statErr = os.Stat(filepath.Join(outputDir, "devcontainer-feature-node.tgz")) + gomega.Expect(statErr).NotTo(gomega.HaveOccurred()) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + + ginkgo.It( + "outputs JSON when --output=json is specified", + func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + fileBinSuffix) + + targetDir, err := os.MkdirTemp("", "e2e-features-package-json-*") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(targetDir) }) + + goDir := filepath.Join(targetDir, featureNameGo) + framework.ExpectNoError(os.MkdirAll(goDir, 0o750)) + framework.ExpectNoError(os.WriteFile( + filepath.Join(goDir, fileDevcontainerJSON), + []byte(`{"id":"go","version":"1.0.0","name":"Go"}`), + 0o600, + )) + framework.ExpectNoError(os.WriteFile( + filepath.Join(goDir, fileInstallSh), + []byte("#!/bin/bash\n"), + 0o600, + )) + + outputDir, err := os.MkdirTemp("", "e2e-features-package-json-output-*") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(outputDir) }) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + cmdFeatures, cmdPackage, + flagTarget, targetDir, + flagOutputFolder, outputDir, + "--output", "json", + }) + framework.ExpectNoError(err) + + var results []map[string]any + gomega.Expect(json.Unmarshal([]byte(stdout), &results)).To(gomega.Succeed()) + gomega.Expect(results).To(gomega.HaveLen(1)) + gomega.Expect(results[0]["featureId"]).To(gomega.Equal("go")) + gomega.Expect(results[0]["version"]).To(gomega.Equal("1.0.0")) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + + ginkgo.It( + "cleans output folder with --force-clean-output-folder", + func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + fileBinSuffix) + + targetDir, err := os.MkdirTemp("", "e2e-features-package-clean-*") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(targetDir) }) + + goDir := filepath.Join(targetDir, featureNameGo) + framework.ExpectNoError(os.MkdirAll(goDir, 0o750)) + framework.ExpectNoError(os.WriteFile( + filepath.Join(goDir, fileDevcontainerJSON), + []byte(`{"id":"go","version":"1.0.0"}`), + 0o600, + )) + framework.ExpectNoError(os.WriteFile( + filepath.Join(goDir, fileInstallSh), + []byte("#!/bin/bash\n"), + 0o600, + )) + + outputDir, err := os.MkdirTemp("", "e2e-features-package-clean-output-*") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(outputDir) }) + + oldFile := filepath.Join(outputDir, "stale-artifact.tgz") + framework.ExpectNoError(os.WriteFile(oldFile, []byte("old"), 0o600)) + + _, _, err = f.ExecCommandCapture(ctx, []string{ + cmdFeatures, cmdPackage, + flagTarget, targetDir, + flagOutputFolder, outputDir, + flagForceCleanOutput, + }) + framework.ExpectNoError(err) + + _, statErr := os.Stat(oldFile) + gomega.Expect(os.IsNotExist(statErr)).To(gomega.BeTrue()) + + _, statErr = os.Stat(filepath.Join(outputDir, "devcontainer-feature-go.tgz")) + gomega.Expect(statErr).NotTo(gomega.HaveOccurred()) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + + ginkgo.It( + "fails with no features in target directory", + func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + fileBinSuffix) + + targetDir, err := os.MkdirTemp("", "e2e-features-package-empty-*") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(targetDir) }) + + _, stderr, err := f.ExecCommandCapture(ctx, []string{ + cmdFeatures, cmdPackage, + flagTarget, targetDir, + }) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(stderr).To(gomega.ContainSubstring("no features found")) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) +}) + +func e2eReadTarGzEntries(path string) []string { + f, err := os.Open(path) // #nosec G304 -- test helper + framework.ExpectNoError(err) + defer func() { _ = f.Close() }() + + gz, err := gzip.NewReader(f) + framework.ExpectNoError(err) + defer func() { _ = gz.Close() }() + + tr := tar.NewReader(gz) + var files []string + for { + hdr, readErr := tr.Next() + if readErr == io.EOF { + break + } + framework.ExpectNoError(readErr) + files = append(files, hdr.Name) + } + return files +} From 0e9c96dfd7c6a6309c831611890679d5cfbf6580 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 7 May 2026 08:14:53 -0500 Subject: [PATCH 2/9] fix(cli): harden features package with input validation and atomic writes Validate feature IDs against the dev container spec pattern to prevent path traversal. Write archives to temp files and rename on success to avoid leaving corrupt partial files on failure. Write tar output directly to the file instead of buffering in memory. Emit warnings for skipped unparseable feature directories. --- cmd/features/package.go | 33 ++++++++++++++++++++++------- cmd/features/package_test.go | 41 ++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/cmd/features/package.go b/cmd/features/package.go index f5bba8bbb..41611771e 100644 --- a/cmd/features/package.go +++ b/cmd/features/package.go @@ -1,14 +1,15 @@ package features import ( - "bytes" "fmt" "os" "path/filepath" + "regexp" "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/extract" + "github.com/devsy-org/devsy/pkg/log" "github.com/spf13/cobra" ) @@ -121,6 +122,8 @@ func (cmd *PackageCmd) prepareOutputFolder() (string, error) { return outputFolder, nil } +var validFeatureID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + type featureSource struct { dir string config *config.FeatureConfig @@ -141,6 +144,7 @@ func (cmd *PackageCmd) discoverFeatures(targetDir string) ([]featureSource, erro featureDir := filepath.Join(targetDir, entry.Name()) featureCfg, parseErr := config.ParseDevContainerFeature(featureDir) if parseErr != nil { + log.Warnf("skipping %s: %v", entry.Name(), parseErr) continue } @@ -159,23 +163,36 @@ func (cmd *PackageCmd) discoverFeatures(targetDir string) ([]featureSource, erro func (cmd *PackageCmd) packageFeature( feat featureSource, targetDir, outputFolder string, ) (packageResult, error) { + if !validFeatureID.MatchString(feat.config.ID) { + return packageResult{}, fmt.Errorf( + "invalid feature ID %q: must match [a-z0-9][a-z0-9-]*", feat.config.ID, + ) + } + featureDir := filepath.Join(targetDir, feat.dir) filename := fmt.Sprintf("devcontainer-feature-%s.tgz", feat.config.ID) outputPath := filepath.Join(outputFolder, filename) - f, err := os.Create(outputPath) // #nosec G304 -- path constructed from validated inputs + tmpFile, err := os.CreateTemp(outputFolder, ".devcontainer-feature-*.tgz.tmp") if err != nil { - return packageResult{}, fmt.Errorf("create archive %s: %w", filename, err) + return packageResult{}, fmt.Errorf("create temp file for %s: %w", filename, err) } - defer func() { _ = f.Close() }() + tmpPath := tmpFile.Name() - var buf bytes.Buffer - if err := extract.WriteTar(&buf, featureDir, true); err != nil { + if err := extract.WriteTar(tmpFile, featureDir, true); err != nil { + _ = tmpFile.Close() + _ = os.Remove(tmpPath) return packageResult{}, fmt.Errorf("create tar for %s: %w", feat.config.ID, err) } - if _, err := f.Write(buf.Bytes()); err != nil { - return packageResult{}, fmt.Errorf("write archive %s: %w", filename, err) + if err := tmpFile.Close(); err != nil { + _ = os.Remove(tmpPath) + return packageResult{}, fmt.Errorf("close archive %s: %w", filename, err) + } + + if err := os.Rename(tmpPath, outputPath); err != nil { + _ = os.Remove(tmpPath) + return packageResult{}, fmt.Errorf("finalize archive %s: %w", filename, err) } return packageResult{ diff --git a/cmd/features/package_test.go b/cmd/features/package_test.go index 56915375e..d16704caa 100644 --- a/cmd/features/package_test.go +++ b/cmd/features/package_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "testing" + "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -183,6 +184,46 @@ func TestPackageCmd_InvalidOutputFormat(t *testing.T) { assert.Contains(t, err.Error(), "invalid output format") } +func TestPackageCmd_InvalidFeatureID(t *testing.T) { + tests := []struct { + name string + id string + }{ + {"path traversal", "../../../etc/passwd"}, + {"contains slash", "bad/id"}, + {"starts with hyphen", "-invalid"}, + {"uppercase letters", "MyFeature"}, + {"contains spaces", "bad id"}, + {"empty string", ""}, + } + + cmd := &PackageCmd{} + targetDir := t.TempDir() + outputDir := t.TempDir() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + feat := featureSource{ + dir: "some-dir", + config: &config.FeatureConfig{ID: tt.id}, + } + _, err := cmd.packageFeature(feat, targetDir, outputDir) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid feature ID") + }) + } +} + +func TestPackageCmd_ValidFeatureIDs(t *testing.T) { + tests := []string{"my-feature", "a", "feature-1", "0cool"} + + for _, id := range tests { + t.Run(id, func(t *testing.T) { + assert.True(t, validFeatureID.MatchString(id), "ID %q should be valid", id) + }) + } +} + func readTarGzEntries(t *testing.T, path string) []string { t.Helper() From aee8e0cd3e645fdf09f90ccd85084ed9653f4cea Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 7 May 2026 08:29:47 -0500 Subject: [PATCH 3/9] fix(lint): extract goconst string literals into named constants Fixes goconst violations in package_test.go and features_package_cmd.go by reusing existing outputText/outputJSON constants and introducing outputYAML, flagOutput, featureVersion100 constants. --- cmd/features/output.go | 1 + cmd/features/package_test.go | 4 ++-- e2e/tests/features/features_package_cmd.go | 9 ++++++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/cmd/features/output.go b/cmd/features/output.go index 9de18adf9..0d205afdb 100644 --- a/cmd/features/output.go +++ b/cmd/features/output.go @@ -9,6 +9,7 @@ import ( const ( outputJSON = "json" outputText = "text" + outputYAML = "yaml" ) func validateOutputFormat(format string) error { diff --git a/cmd/features/package_test.go b/cmd/features/package_test.go index d16704caa..f4d8e7b94 100644 --- a/cmd/features/package_test.go +++ b/cmd/features/package_test.go @@ -162,7 +162,7 @@ func TestPackageCmd_ForceCleanOutputFolder(t *testing.T) { Target: targetDir, OutputFolder: outputDir, ForceCleanOutputFolder: true, - Output: "text", + Output: outputText, } err := cmd.Run() @@ -177,7 +177,7 @@ func TestPackageCmd_ForceCleanOutputFolder(t *testing.T) { func TestPackageCmd_InvalidOutputFormat(t *testing.T) { cmd := &PackageCmd{ Target: "/tmp", - Output: "yaml", + Output: outputYAML, } err := cmd.Run() require.Error(t, err) diff --git a/e2e/tests/features/features_package_cmd.go b/e2e/tests/features/features_package_cmd.go index a7e014780..e082f91af 100644 --- a/e2e/tests/features/features_package_cmd.go +++ b/e2e/tests/features/features_package_cmd.go @@ -19,8 +19,11 @@ const ( flagTarget = "--target" flagOutputFolder = "--output-folder" flagForceCleanOutput = "--force-clean-output-folder" + flagOutput = "--output" + outputJSON = "json" featureNameGo = "go" featureNameNode = "node" + featureVersion100 = "1.0.0" ) var _ = ginkgo.Describe("features package", ginkgo.Label("features", "features-package"), func() { @@ -89,7 +92,7 @@ var _ = ginkgo.Describe("features package", ginkgo.Label("features", "features-p ginkgo.DeferCleanup(func() { _ = os.RemoveAll(targetDir) }) for _, feat := range []struct{ id, version string }{ - {featureNameGo, "1.0.0"}, + {featureNameGo, featureVersion100}, {featureNameNode, "2.0.0"}, } { dir := filepath.Join(targetDir, feat.id) @@ -158,7 +161,7 @@ var _ = ginkgo.Describe("features package", ginkgo.Label("features", "features-p cmdFeatures, cmdPackage, flagTarget, targetDir, flagOutputFolder, outputDir, - "--output", "json", + flagOutput, outputJSON, }) framework.ExpectNoError(err) @@ -166,7 +169,7 @@ var _ = ginkgo.Describe("features package", ginkgo.Label("features", "features-p gomega.Expect(json.Unmarshal([]byte(stdout), &results)).To(gomega.Succeed()) gomega.Expect(results).To(gomega.HaveLen(1)) gomega.Expect(results[0]["featureId"]).To(gomega.Equal("go")) - gomega.Expect(results[0]["version"]).To(gomega.Equal("1.0.0")) + gomega.Expect(results[0]["version"]).To(gomega.Equal(featureVersion100)) }, ginkgo.SpecTimeout(framework.TimeoutShort()), ) From 37520534123d6c037f1f2d350d33ee9be3139d10 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 11 May 2026 15:10:26 -0500 Subject: [PATCH 4/9] ci: add features label to e2e test matrix The features e2e tests (features-test, features-package) were never included in the CI matrix, so they never ran in PR checks. Add the features ginkgo label entry to the integration-tests matrix. --- .github/workflows/pr-ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index f3f57aa79..0a575c2bc 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -222,6 +222,12 @@ jobs: install-kind: false requires-secret: false + - label: features + runner: ubuntu-latest + free-disk-space: false + install-kind: false + requires-secret: false + # Up tests - label: up-workspaces From ab06e1b7281c70085dc9e7aa7d9dc54016e390df Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 11 May 2026 15:23:23 -0500 Subject: [PATCH 5/9] fix(cli): enforce devcontainer spec required fields in features package Align features package command with the devcontainer specification by adding validation for required metadata fields and install.sh presence. - Validate feature ID matches directory name (spec: "id must match the name of the directory") - Require version and name fields in devcontainer-feature.json (spec required properties per features-distribution) - Require install.sh exists in feature directory (spec: "entrypoint to install the Feature") - Extract validation into helper to satisfy cyclomatic complexity Add unit tests for each new validation path. --- cmd/features/package.go | 40 +++++++++++++++++-- cmd/features/package_test.go | 74 ++++++++++++++++++++++++++++++++++-- 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/cmd/features/package.go b/cmd/features/package.go index 41611771e..8dbaa651e 100644 --- a/cmd/features/package.go +++ b/cmd/features/package.go @@ -160,16 +160,48 @@ func (cmd *PackageCmd) discoverFeatures(targetDir string) ([]featureSource, erro return features, nil } -func (cmd *PackageCmd) packageFeature( - feat featureSource, targetDir, outputFolder string, -) (packageResult, error) { +func validateFeatureSource(feat featureSource, featureDir string) error { if !validFeatureID.MatchString(feat.config.ID) { - return packageResult{}, fmt.Errorf( + return fmt.Errorf( "invalid feature ID %q: must match [a-z0-9][a-z0-9-]*", feat.config.ID, ) } + if feat.config.ID != feat.dir { + return fmt.Errorf( + "feature ID %q does not match directory name %q", feat.config.ID, feat.dir, + ) + } + + if feat.config.Version == "" { + return fmt.Errorf( + "feature %q is missing required property \"version\"", feat.config.ID, + ) + } + + if feat.config.Name == "" { + return fmt.Errorf( + "feature %q is missing required property \"name\"", feat.config.ID, + ) + } + + installPath := filepath.Join(featureDir, "install.sh") + if _, err := os.Stat(installPath); err != nil { + return fmt.Errorf("feature %q is missing install.sh", feat.config.ID) + } + + return nil +} + +func (cmd *PackageCmd) packageFeature( + feat featureSource, targetDir, outputFolder string, +) (packageResult, error) { featureDir := filepath.Join(targetDir, feat.dir) + + if err := validateFeatureSource(feat, featureDir); err != nil { + return packageResult{}, err + } + filename := fmt.Sprintf("devcontainer-feature-%s.tgz", feat.config.ID) outputPath := filepath.Join(outputFolder, filename) diff --git a/cmd/features/package_test.go b/cmd/features/package_test.go index f4d8e7b94..0475abf24 100644 --- a/cmd/features/package_test.go +++ b/cmd/features/package_test.go @@ -13,6 +13,8 @@ import ( "github.com/stretchr/testify/require" ) +const testFeatureIDPkg = "my-feature" + func TestPackageCmd_FlagDefaults(t *testing.T) { cmd := NewPackageCmd(nil) @@ -104,7 +106,7 @@ func TestPackageCmd_PackageFeature(t *testing.T) { targetDir := t.TempDir() outputDir := t.TempDir() - featureDir := filepath.Join(targetDir, "my-feature") + featureDir := filepath.Join(targetDir, testFeatureIDPkg) require.NoError(t, os.MkdirAll(featureDir, 0o700)) require.NoError(t, os.WriteFile( filepath.Join(featureDir, "devcontainer-feature.json"), @@ -126,7 +128,7 @@ func TestPackageCmd_PackageFeature(t *testing.T) { result, err := cmd.packageFeature(features[0], targetDir, outputDir) require.NoError(t, err) - assert.Equal(t, "my-feature", result.FeatureID) + assert.Equal(t, testFeatureIDPkg, result.FeatureID) assert.Equal(t, "1.2.3", result.Version) assert.Equal(t, "devcontainer-feature-my-feature.tgz", result.Filename) @@ -146,7 +148,7 @@ func TestPackageCmd_ForceCleanOutputFolder(t *testing.T) { require.NoError(t, os.MkdirAll(featureDir, 0o700)) require.NoError(t, os.WriteFile( filepath.Join(featureDir, "devcontainer-feature.json"), - []byte(`{"id":"feat","version":"1.0.0"}`), + []byte(`{"id":"feat","version":"1.0.0","name":"Feat"}`), 0o600, )) require.NoError(t, os.WriteFile( @@ -214,8 +216,72 @@ func TestPackageCmd_InvalidFeatureID(t *testing.T) { } } +func TestPackageCmd_IDMustMatchDirectory(t *testing.T) { + cmd := &PackageCmd{} + targetDir := t.TempDir() + outputDir := t.TempDir() + + feat := featureSource{ + dir: "actual-dir", + config: &config.FeatureConfig{ID: "different-id", Version: "1.0.0", Name: "Test"}, + } + _, err := cmd.packageFeature(feat, targetDir, outputDir) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not match directory name") +} + +func TestPackageCmd_MissingVersion(t *testing.T) { + cmd := &PackageCmd{} + targetDir := t.TempDir() + outputDir := t.TempDir() + + feat := featureSource{ + dir: testFeatureIDPkg, + config: &config.FeatureConfig{ID: testFeatureIDPkg, Name: "Test"}, + } + _, err := cmd.packageFeature(feat, targetDir, outputDir) + require.Error(t, err) + assert.Contains(t, err.Error(), "missing required property \"version\"") +} + +func TestPackageCmd_MissingName(t *testing.T) { + cmd := &PackageCmd{} + targetDir := t.TempDir() + outputDir := t.TempDir() + + feat := featureSource{ + dir: testFeatureIDPkg, + config: &config.FeatureConfig{ID: testFeatureIDPkg, Version: "1.0.0"}, + } + _, err := cmd.packageFeature(feat, targetDir, outputDir) + require.Error(t, err) + assert.Contains(t, err.Error(), "missing required property \"name\"") +} + +func TestPackageCmd_MissingInstallSh(t *testing.T) { + targetDir := t.TempDir() + outputDir := t.TempDir() + + featureDir := filepath.Join(targetDir, testFeatureIDPkg) + require.NoError(t, os.MkdirAll(featureDir, 0o700)) + require.NoError(t, os.WriteFile( + filepath.Join(featureDir, "devcontainer-feature.json"), + []byte(`{"id":"my-feature","version":"1.0.0","name":"My Feature"}`), + 0o600, + )) + + cmd := &PackageCmd{} + features, err := cmd.discoverFeatures(targetDir) + require.NoError(t, err) + require.Len(t, features, 1) + + _, err = cmd.packageFeature(features[0], targetDir, outputDir) + require.Error(t, err) + assert.Contains(t, err.Error(), "missing install.sh") +} + func TestPackageCmd_ValidFeatureIDs(t *testing.T) { - tests := []string{"my-feature", "a", "feature-1", "0cool"} + tests := []string{testFeatureIDPkg, "a", "feature-1", "0cool"} for _, id := range tests { t.Run(id, func(t *testing.T) { From 7649a66222bcad33aa772afc5ecfa9510c839ae0 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 11 May 2026 15:44:55 -0500 Subject: [PATCH 6/9] fix(ci): use compound label filter for features e2e tests The simple 'features' ginkgo label matches tests in both e2e/tests/features/ and e2e/tests/up-features/ (inner It blocks inherit the 'features' label). Use 'features && !up-features' to run only the standalone features command tests. --- .github/workflows/pr-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 0a575c2bc..22fef29fa 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -222,7 +222,7 @@ jobs: install-kind: false requires-secret: false - - label: features + - label: features && !up-features runner: ubuntu-latest free-disk-space: false install-kind: false From ec9069c87c739a1bb9aa8cffca5e2b353128386c Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 11 May 2026 15:56:13 -0500 Subject: [PATCH 7/9] ci: add standalone features test suite to e2e matrix The features label entry used 'features && !up-features' directly in the matrix label, which made the CI job name 'Test features && !up-features on ubuntu-latest' instead of 'Test features on ubuntu-latest'. Split into label (for job name) and label-filter (for ginkgo), falling back to label when label-filter is unset so all existing entries work unchanged. --- .github/workflows/pr-ci.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 22fef29fa..eb30c4ce2 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -222,7 +222,8 @@ jobs: install-kind: false requires-secret: false - - label: features && !up-features + - label: features + label-filter: features && !up-features runner: ubuntu-latest free-disk-space: false install-kind: false @@ -501,7 +502,7 @@ jobs: KUBECONFIG="${KUBECONFIG:-$HOME/.kube/config}" \ PATH="${PATH}" \ GOROOT="${GOROOT}" \ - go test -v -ginkgo.v -timeout 1500s --ginkgo.label-filter="${{ matrix.label }}" + go test -v -ginkgo.v -timeout 1500s --ginkgo.label-filter="${{ matrix.label-filter || matrix.label }}" else GH_USERNAME="${GH_USERNAME}" \ GH_ACCESS_TOKEN="${GH_ACCESS_TOKEN}" \ @@ -510,7 +511,7 @@ jobs: PATH="${PATH}" \ GOROOT="${GOROOT}" \ DOCKER_HOST="npipe:////./pipe/podman-machine-default" \ - go test -v -ginkgo.v -timeout 1500s --ginkgo.label-filter="${{ matrix.label }}" + go test -v -ginkgo.v -timeout 1500s --ginkgo.label-filter="${{ matrix.label-filter || matrix.label }}" fi - name: verify docker is installed From 2bba9cbcda25b00c0c62abc43b3413ac84eedcb0 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 11 May 2026 15:59:01 -0500 Subject: [PATCH 8/9] fix(e2e): use up-features label on up-features inner blocks for CI isolation Inner It blocks in e2e/tests/up-features/ used Label("features", ...) which collided with the standalone features suite label filter. Changed all inner block labels to "up-features" so CI's simple label filters correctly isolate the two suites. Removed the compound label-filter workaround and matrix.label-filter property from pr-ci.yml. --- .github/workflows/pr-ci.yml | 5 ++--- e2e/tests/up-features/up_features.go | 32 ++++++++++++++-------------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index eb30c4ce2..0a575c2bc 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -223,7 +223,6 @@ jobs: requires-secret: false - label: features - label-filter: features && !up-features runner: ubuntu-latest free-disk-space: false install-kind: false @@ -502,7 +501,7 @@ jobs: KUBECONFIG="${KUBECONFIG:-$HOME/.kube/config}" \ PATH="${PATH}" \ GOROOT="${GOROOT}" \ - go test -v -ginkgo.v -timeout 1500s --ginkgo.label-filter="${{ matrix.label-filter || matrix.label }}" + go test -v -ginkgo.v -timeout 1500s --ginkgo.label-filter="${{ matrix.label }}" else GH_USERNAME="${GH_USERNAME}" \ GH_ACCESS_TOKEN="${GH_ACCESS_TOKEN}" \ @@ -511,7 +510,7 @@ jobs: PATH="${PATH}" \ GOROOT="${GOROOT}" \ DOCKER_HOST="npipe:////./pipe/podman-machine-default" \ - go test -v -ginkgo.v -timeout 1500s --ginkgo.label-filter="${{ matrix.label-filter || matrix.label }}" + go test -v -ginkgo.v -timeout 1500s --ginkgo.label-filter="${{ matrix.label }}" fi - name: verify docker is installed diff --git a/e2e/tests/up-features/up_features.go b/e2e/tests/up-features/up_features.go index 0fd7e2fa7..0238ace54 100644 --- a/e2e/tests/up-features/up_features.go +++ b/e2e/tests/up-features/up_features.go @@ -245,7 +245,7 @@ var _ = ginkgo.Describe("testing up command", ginkgo.Label("up-features", "suite ginkgo.It( "should automatically install dependsOn features", - ginkgo.Label("features", "depends-on"), + ginkgo.Label("up-features", "depends-on"), func(ctx context.Context) { f, err := setupDockerProvider(initialDir+"/bin", "docker") framework.ExpectNoError(err) @@ -272,7 +272,7 @@ var _ = ginkgo.Describe("testing up command", ginkgo.Label("up-features", "suite ginkgo.It( "should not fail if same feature exists in dependsOn and installsAfter", - ginkgo.Label("features", "depends-on"), + ginkgo.Label("up-features", "depends-on"), func(ctx context.Context) { f, err := setupDockerProvider(initialDir+"/bin", "docker") framework.ExpectNoError(err) @@ -299,7 +299,7 @@ var _ = ginkgo.Describe("testing up command", ginkgo.Label("up-features", "suite ginkgo.It( "should handle nested dependencies", - ginkgo.Label("features", "depends-on"), + ginkgo.Label("up-features", "depends-on"), func(ctx context.Context) { f, err := setupDockerProvider(initialDir+"/bin", "docker") framework.ExpectNoError(err) @@ -326,7 +326,7 @@ var _ = ginkgo.Describe("testing up command", ginkgo.Label("up-features", "suite ginkgo.It( "should detect circular dependencies", - ginkgo.Label("features", "depends-on"), + ginkgo.Label("up-features", "depends-on"), func(ctx context.Context) { f, err := setupDockerProvider(initialDir+"/bin", "docker") framework.ExpectNoError(err) @@ -350,7 +350,7 @@ var _ = ginkgo.Describe("testing up command", ginkgo.Label("up-features", "suite ginkgo.It( "should handle dependsOn with options", - ginkgo.Label("features", "depends-on"), + ginkgo.Label("up-features", "depends-on"), func(ctx context.Context) { f, err := setupDockerProvider(initialDir+"/bin", "docker") framework.ExpectNoError(err) @@ -377,7 +377,7 @@ var _ = ginkgo.Describe("testing up command", ginkgo.Label("up-features", "suite ginkgo.It( "should handle mixed dependsOn and installsAfter", - ginkgo.Label("features", "mixed"), + ginkgo.Label("up-features", "mixed"), func(ctx context.Context) { f, err := setupDockerProvider(initialDir+"/bin", "docker") framework.ExpectNoError(err) @@ -404,7 +404,7 @@ var _ = ginkgo.Describe("testing up command", ginkgo.Label("up-features", "suite ginkgo.It( "should detect self-dependency", - ginkgo.Label("features", "depends-on"), + ginkgo.Label("up-features", "depends-on"), func(ctx context.Context) { f, err := setupDockerProvider(initialDir+"/bin", "docker") framework.ExpectNoError(err) @@ -427,7 +427,7 @@ var _ = ginkgo.Describe("testing up command", ginkgo.Label("up-features", "suite ginkgo.It( "should handle non-existent dependency gracefully", - ginkgo.Label("features", "depends-on"), + ginkgo.Label("up-features", "depends-on"), func(ctx context.Context) { f, err := setupDockerProvider(initialDir+"/bin", "docker") framework.ExpectNoError(err) @@ -450,7 +450,7 @@ var _ = ginkgo.Describe("testing up command", ginkgo.Label("up-features", "suite ginkgo.It( "should handle shared dependencies correctly", - ginkgo.Label("features", "depends-on"), + ginkgo.Label("up-features", "depends-on"), func(ctx context.Context) { f, err := setupDockerProvider(initialDir+"/bin", "docker") framework.ExpectNoError(err) @@ -478,7 +478,7 @@ var _ = ginkgo.Describe("testing up command", ginkgo.Label("up-features", "suite ginkgo.It( "should handle forward reference dependencies", - ginkgo.Label("features", "depends-on"), + ginkgo.Label("up-features", "depends-on"), func(ctx context.Context) { f, err := setupDockerProvider(initialDir+"/bin", "docker") framework.ExpectNoError(err) @@ -506,7 +506,7 @@ var _ = ginkgo.Describe("testing up command", ginkgo.Label("up-features", "suite ginkgo.It( "should handle same feature in dependsOn and installsAfter", - ginkgo.Label("features", "depends-on"), + ginkgo.Label("up-features", "depends-on"), func(ctx context.Context) { f, err := setupDockerProvider(initialDir+"/bin", "docker") framework.ExpectNoError(err) @@ -578,7 +578,7 @@ var _ = ginkgo.Describe("testing up command", ginkgo.Label("up-features", "suite ginkgo.It( "should resolve legacy feature IDs in dependsOn", - ginkgo.Label("features", "depends-on", "legacy-id"), + ginkgo.Label("up-features", "depends-on", "legacy-id"), func(ctx context.Context) { f, err := setupDockerProvider(initialDir+"/bin", "docker") framework.ExpectNoError(err) @@ -605,7 +605,7 @@ var _ = ginkgo.Describe("testing up command", ginkgo.Label("up-features", "suite ginkgo.It( "should reject overrideFeatureInstallOrder that violates dependsOn", - ginkgo.Label("features", "override"), + ginkgo.Label("up-features", "override"), func(ctx context.Context) { f, err := setupDockerProvider(initialDir+"/bin", "docker") framework.ExpectNoError(err) @@ -627,7 +627,7 @@ var _ = ginkgo.Describe("testing up command", ginkgo.Label("up-features", "suite ginkgo.It( "should respect overrideFeatureInstallOrder when it satisfies dependsOn", - ginkgo.Label("features", "override"), + ginkgo.Label("up-features", "override"), func(ctx context.Context) { f, err := setupDockerProvider(initialDir+"/bin", "docker") framework.ExpectNoError(err) @@ -653,7 +653,7 @@ var _ = ginkgo.Describe("testing up command", ginkgo.Label("up-features", "suite ginkgo.It( "should install both versions when same feature has different versions", - ginkgo.Label("features", "version-aware"), + ginkgo.Label("up-features", "version-aware"), func(ctx context.Context) { f, err := setupDockerProvider(initialDir+"/bin", "docker") framework.ExpectNoError(err) @@ -683,7 +683,7 @@ var _ = ginkgo.Describe("testing up command", ginkgo.Label("up-features", "suite ginkgo.It( "should resolve secret options from environment variables", - ginkgo.Label("features", "secret-option"), + ginkgo.Label("up-features", "secret-option"), func(ctx context.Context) { f, err := setupDockerProvider(initialDir+"/bin", "docker") framework.ExpectNoError(err) From b0771cd135f8c2236ff66593e65b6a2f864fb6a6 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 11 May 2026 16:31:58 -0500 Subject: [PATCH 9/9] fix(e2e): add missing name field to package test fixtures Two e2e test fixtures were missing the required "name" property in their devcontainer-feature.json data, causing spec validation to fail. --- e2e/tests/features/features_package_cmd.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/e2e/tests/features/features_package_cmd.go b/e2e/tests/features/features_package_cmd.go index e082f91af..919dd48bc 100644 --- a/e2e/tests/features/features_package_cmd.go +++ b/e2e/tests/features/features_package_cmd.go @@ -99,7 +99,9 @@ var _ = ginkgo.Describe("features package", ginkgo.Label("features", "features-p framework.ExpectNoError(os.MkdirAll(dir, 0o750)) framework.ExpectNoError(os.WriteFile( filepath.Join(dir, fileDevcontainerJSON), - []byte(`{"id":"`+feat.id+`","version":"`+feat.version+`"}`), + []byte( + `{"id":"`+feat.id+`","version":"`+feat.version+`","name":"`+feat.id+`"}`, + ), 0o600, )) framework.ExpectNoError(os.WriteFile( @@ -187,7 +189,7 @@ var _ = ginkgo.Describe("features package", ginkgo.Label("features", "features-p framework.ExpectNoError(os.MkdirAll(goDir, 0o750)) framework.ExpectNoError(os.WriteFile( filepath.Join(goDir, fileDevcontainerJSON), - []byte(`{"id":"go","version":"1.0.0"}`), + []byte(`{"id":"go","version":"1.0.0","name":"Go"}`), 0o600, )) framework.ExpectNoError(os.WriteFile(