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 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.go b/cmd/features/package.go new file mode 100644 index 000000000..8dbaa651e --- /dev/null +++ b/cmd/features/package.go @@ -0,0 +1,250 @@ +package features + +import ( + "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" +) + +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 +} + +var validFeatureID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + +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 { + log.Warnf("skipping %s: %v", entry.Name(), parseErr) + 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 validateFeatureSource(feat featureSource, featureDir string) error { + if !validFeatureID.MatchString(feat.config.ID) { + 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) + + tmpFile, err := os.CreateTemp(outputFolder, ".devcontainer-feature-*.tgz.tmp") + if err != nil { + return packageResult{}, fmt.Errorf("create temp file for %s: %w", filename, err) + } + tmpPath := tmpFile.Name() + + 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 := 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{ + 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..0475abf24 --- /dev/null +++ b/cmd/features/package_test.go @@ -0,0 +1,315 @@ +package features + +import ( + "archive/tar" + "compress/gzip" + "io" + "os" + "path/filepath" + "testing" + + "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testFeatureIDPkg = "my-feature" + +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, 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.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, testFeatureIDPkg, 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","name":"Feat"}`), + 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: outputText, + } + + 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: outputYAML, + } + err := cmd.Run() + require.Error(t, err) + 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_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{testFeatureIDPkg, "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() + + 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..919dd48bc --- /dev/null +++ b/e2e/tests/features/features_package_cmd.go @@ -0,0 +1,265 @@ +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" + flagOutput = "--output" + outputJSON = "json" + featureNameGo = "go" + featureNameNode = "node" + featureVersion100 = "1.0.0" +) + +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, featureVersion100}, + {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+`","name":"`+feat.id+`"}`, + ), + 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, + flagOutput, outputJSON, + }) + 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(featureVersion100)) + }, + 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","name":"Go"}`), + 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 +} 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)