diff --git a/cmd/root.go b/cmd/root.go index a81cd1670..ed8c5f774 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -134,6 +134,7 @@ func BuildRoot() *cobra.Command { rootCmd.AddCommand(NewReadConfigurationCmd(globalFlags)) rootCmd.AddCommand(NewExecCmd(globalFlags)) rootCmd.AddCommand(NewOutdatedCmd(globalFlags)) + rootCmd.AddCommand(NewUpgradeCmd(globalFlags)) rootCmd.AddCommand(NewSetUpCmd(globalFlags)) rootCmd.AddCommand(NewRunUserCommandsCmd(globalFlags)) rootCmd.AddCommand(NewRunUserCommandsCmdAlias(globalFlags)) diff --git a/cmd/upgrade.go b/cmd/upgrade.go new file mode 100644 index 000000000..00af52d3c --- /dev/null +++ b/cmd/upgrade.go @@ -0,0 +1,142 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/devsy-org/devsy/cmd/flags" + devconfig "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/log" + "github.com/spf13/cobra" +) + +// UpgradeCmd upgrades devcontainer feature versions to the latest available. +type UpgradeCmd struct { + *flags.GlobalFlags + + WorkspaceFolder string + Config string + DryRun bool +} + +// NewUpgradeCmd creates a new upgrade command. +func NewUpgradeCmd(f *flags.GlobalFlags) *cobra.Command { + cmd := &UpgradeCmd{GlobalFlags: f} + upgradeCmd := &cobra.Command{ + Use: "upgrade [feature...]", + Short: "Upgrades devcontainer feature versions to the latest available", + Long: `Upgrades devcontainer feature versions in devcontainer.json to the latest +available versions. If specific features are provided as arguments, only those +features are upgraded. Otherwise, all outdated features are upgraded.`, + RunE: func(cobraCmd *cobra.Command, args []string) error { + return cmd.Run(cobraCmd.Context(), args) + }, + } + + upgradeCmd.Flags(). + StringVar(&cmd.WorkspaceFolder, "workspace-folder", "", "Path to the workspace folder") + upgradeCmd.Flags(). + StringVar(&cmd.Config, "config", "", "Path to a specific devcontainer.json") + upgradeCmd.Flags(). + BoolVar(&cmd.DryRun, "dry-run", false, "Preview upgrades without applying them") + + return upgradeCmd +} + +// Run runs the command logic. +func (cmd *UpgradeCmd) Run(_ context.Context, targets []string) error { + parsedConfig, err := cmd.loadConfig() + if err != nil { + return err + } + + if len(parsedConfig.Features) == 0 { + _, _ = fmt.Fprintln(os.Stdout, noFeaturesMessage) + return nil + } + + outdated := cmd.findUpgradeable(parsedConfig.Features, targets) + if len(outdated) == 0 { + _, _ = fmt.Fprintln(os.Stdout, allUpToDateMessage) + return nil + } + + if cmd.DryRun { + printOutdatedTable(outdated) + return nil + } + + return cmd.applyUpgrades(parsedConfig.Origin, outdated) +} + +func (cmd *UpgradeCmd) loadConfig() (*devconfig.DevContainerConfig, error) { + loader := &OutdatedCmd{ + GlobalFlags: cmd.GlobalFlags, + WorkspaceFolder: cmd.WorkspaceFolder, + Config: cmd.Config, + } + return loader.loadConfig() +} + +func (cmd *UpgradeCmd) findUpgradeable(features map[string]any, targets []string) []outdatedEntry { + targetSet := make(map[string]bool, len(targets)) + for _, t := range targets { + targetSet[normalizeFeatureRef(t)] = true + } + + var results []outdatedEntry + for featureID := range features { + if len(targetSet) > 0 && !matchesTarget(featureID, targetSet) { + continue + } + + entry, ok := checkFeatureVersion(featureID) + if ok { + results = append(results, entry) + } + } + return results +} + +// normalizeFeatureRef strips the tag from a feature reference for matching. +func normalizeFeatureRef(ref string) string { + if idx := strings.LastIndex(ref, ":"); idx != -1 { + candidate := ref[:idx] + if strings.Contains(candidate, "/") { + return candidate + } + } + return ref +} + +// matchesTarget checks if a feature ID matches any of the target refs. +func matchesTarget(featureID string, targets map[string]bool) bool { + normalized := normalizeFeatureRef(featureID) + return targets[normalized] || targets[featureID] +} + +func (cmd *UpgradeCmd) applyUpgrades(configPath string, outdated []outdatedEntry) error { + //nolint:gosec // G304 -- configPath is from parsed devcontainer config + raw, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("read config file: %w", err) + } + + content := string(raw) + for _, entry := range outdated { + old := entry.repo + ":" + entry.current + updated := entry.repo + ":" + entry.latest + content = strings.ReplaceAll(content, old, updated) + log.Infof("Upgraded %s: %s → %s", entry.repo, entry.current, entry.latest) + } + + //nolint:gosec // G306 -- matching existing file permissions in the codebase + if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { + return fmt.Errorf("write config file: %w", err) + } + + _, _ = fmt.Fprintf(os.Stdout, "Upgraded %d feature(s).\n", len(outdated)) + return nil +} diff --git a/cmd/upgrade_test.go b/cmd/upgrade_test.go new file mode 100644 index 000000000..1fde796cc --- /dev/null +++ b/cmd/upgrade_test.go @@ -0,0 +1,168 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testFeatureGo = "ghcr.io/devcontainers/features/go" + testFeatureGoV121 = "1.21" + testFeatureGoV123 = "1.23" +) + +func TestNewUpgradeCmd_CreatesCommand(t *testing.T) { + cmd := NewUpgradeCmd(nil) + assert.Equal(t, "upgrade [feature...]", cmd.Use) + assert.NotEmpty(t, cmd.Short) +} + +func TestNewUpgradeCmd_HasDryRunFlag(t *testing.T) { + cmd := NewUpgradeCmd(nil) + flag := cmd.Flags().Lookup("dry-run") + require.NotNil(t, flag) + assert.Equal(t, "false", flag.DefValue) +} + +func TestNewUpgradeCmd_HasWorkspaceFolderFlag(t *testing.T) { + cmd := NewUpgradeCmd(nil) + flag := cmd.Flags().Lookup("workspace-folder") + require.NotNil(t, flag) +} + +func TestNewUpgradeCmd_HasConfigFlag(t *testing.T) { + cmd := NewUpgradeCmd(nil) + flag := cmd.Flags().Lookup("config") + require.NotNil(t, flag) +} + +func TestNormalizeFeatureRef_WithTag(t *testing.T) { + result := normalizeFeatureRef(testFeatureGo + ":" + testFeatureGoV121) + assert.Equal(t, testFeatureGo, result) +} + +func TestNormalizeFeatureRef_WithoutTag(t *testing.T) { + result := normalizeFeatureRef(testFeatureGo) + assert.Equal(t, testFeatureGo, result) +} + +func TestNormalizeFeatureRef_BarePort(t *testing.T) { + result := normalizeFeatureRef("localhost:5000") + assert.Equal(t, "localhost:5000", result) +} + +func TestMatchesTarget_ExactMatch(t *testing.T) { + targets := map[string]bool{testFeatureGo: true} + assert.True(t, matchesTarget(testFeatureGo+":"+testFeatureGoV121, targets)) +} + +func TestMatchesTarget_NoMatch(t *testing.T) { + targets := map[string]bool{"ghcr.io/devcontainers/features/node": true} + assert.False(t, matchesTarget(testFeatureGo+":"+testFeatureGoV121, targets)) +} + +func TestMatchesTarget_FullRefWithTag(t *testing.T) { + targets := map[string]bool{testFeatureGo: true} + assert.True(t, matchesTarget(testFeatureGo+":"+testFeatureGoV121, targets)) +} + +func TestMatchesTarget_RawFeatureID(t *testing.T) { + targets := map[string]bool{testFeatureGo + ":" + testFeatureGoV121: true} + assert.True(t, matchesTarget(testFeatureGo+":"+testFeatureGoV121, targets)) +} + +func TestUpgradeCmd_FindUpgradeable_FiltersTargets(t *testing.T) { + cmd := &UpgradeCmd{} + features := map[string]any{ + "./local-feature": map[string]any{}, + } + + results := cmd.findUpgradeable(features, nil) + assert.Empty(t, results) +} + +func writeTestConfig(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + configPath := filepath.Join(dir, "devcontainer.json") + + err := os.WriteFile(configPath, []byte(content), 0o644) //nolint:gosec // G306 + require.NoError(t, err) + + return configPath +} + +func TestUpgradeCmd_ApplyUpgrades_WritesFile(t *testing.T) { + configPath := writeTestConfig(t, `{ + "features": { + "ghcr.io/devcontainers/features/go:1.21": {}, + "ghcr.io/devcontainers/features/node:18": {} + } +}`) + + cmd := &UpgradeCmd{} + outdated := []outdatedEntry{ + {repo: testFeatureGo, current: testFeatureGoV121, latest: testFeatureGoV123}, + } + + err := cmd.applyUpgrades(configPath, outdated) + require.NoError(t, err) + + result, err := os.ReadFile(configPath) //nolint:gosec // G304 -- test file + require.NoError(t, err) + + assert.Contains(t, string(result), testFeatureGo+":"+testFeatureGoV123) + assert.Contains(t, string(result), "ghcr.io/devcontainers/features/node:18") + assert.NotContains(t, string(result), testFeatureGo+":"+testFeatureGoV121) +} + +func TestUpgradeCmd_ApplyUpgrades_PreservesFormatting(t *testing.T) { + configPath := writeTestConfig(t, `{ + // This is a comment + "features": { + "ghcr.io/devcontainers/features/go:1.21": {} + } +}`) + + cmd := &UpgradeCmd{} + outdated := []outdatedEntry{ + {repo: testFeatureGo, current: testFeatureGoV121, latest: testFeatureGoV123}, + } + + err := cmd.applyUpgrades(configPath, outdated) + require.NoError(t, err) + + result, err := os.ReadFile(configPath) //nolint:gosec // G304 -- test file + require.NoError(t, err) + + assert.Contains(t, string(result), "// This is a comment") + assert.Contains(t, string(result), testFeatureGo+":"+testFeatureGoV123) +} + +func TestUpgradeCmd_ApplyUpgrades_MultipleFeatures(t *testing.T) { + configPath := writeTestConfig(t, `{ + "features": { + "ghcr.io/devcontainers/features/go:1.21": {}, + "ghcr.io/devcontainers/features/node:18": {} + } +}`) + + cmd := &UpgradeCmd{} + outdated := []outdatedEntry{ + {repo: testFeatureGo, current: testFeatureGoV121, latest: testFeatureGoV123}, + {repo: "ghcr.io/devcontainers/features/node", current: "18", latest: "22"}, + } + + err := cmd.applyUpgrades(configPath, outdated) + require.NoError(t, err) + + result, err := os.ReadFile(configPath) //nolint:gosec // G304 -- test file + require.NoError(t, err) + + assert.Contains(t, string(result), testFeatureGo+":"+testFeatureGoV123) + assert.Contains(t, string(result), "ghcr.io/devcontainers/features/node:22") +} diff --git a/e2e/e2e_suite_test.go b/e2e/e2e_suite_test.go index 058e3fa72..8b880e9fc 100644 --- a/e2e/e2e_suite_test.go +++ b/e2e/e2e_suite_test.go @@ -32,6 +32,7 @@ import ( _ "github.com/devsy-org/devsy/e2e/tests/tunnel" _ "github.com/devsy-org/devsy/e2e/tests/up" _ "github.com/devsy-org/devsy/e2e/tests/up-features" + _ "github.com/devsy-org/devsy/e2e/tests/upgrade" "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" ) diff --git a/e2e/tests/upgrade/upgrade.go b/e2e/tests/upgrade/upgrade.go new file mode 100644 index 000000000..59b622b4d --- /dev/null +++ b/e2e/tests/upgrade/upgrade.go @@ -0,0 +1,289 @@ +package upgrade + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "fmt" + "net/http/httptest" + "os" + "path/filepath" + "strings" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/registry" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/static" + "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +const ( + cmdUpgrade = "upgrade" + flagWorkspaceFolder = "--workspace-folder" + flagDryRun = "--dry-run" +) + +var _ = ginkgo.Describe("upgrade command", ginkgo.Label("upgrade"), func() { + var initialDir string + + ginkgo.BeforeEach(func() { + var err error + initialDir, err = os.Getwd() + framework.ExpectNoError(err) + }) + + ginkgo.It( + "upgrades outdated feature version in devcontainer.json", + func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + srv := httptest.NewServer(registry.New()) + ginkgo.DeferCleanup(func() { srv.Close() }) + + regHost := strings.TrimPrefix(srv.URL, "http://") + featureRepo := regHost + "/test/my-feature" + + pushFeatureTag(featureRepo + ":1.0.0") + pushFeatureTag(featureRepo + ":1.1.0") + pushFeatureTag(featureRepo + ":2.0.0") + + tempDir := writeDevcontainerJSON(fmt.Sprintf(`{ + "image": "ubuntu:22.04", + "features": {"%s:1.0.0": {}} +}`, featureRepo)) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(tempDir) }) + + _, _, err := f.ExecCommandCapture(ctx, []string{ + cmdUpgrade, + flagWorkspaceFolder, tempDir, + }) + framework.ExpectNoError(err) + + updated := readDevcontainerJSON(tempDir) + gomega.Expect(updated).To(gomega.ContainSubstring(featureRepo + ":2.0.0")) + gomega.Expect(updated).NotTo(gomega.ContainSubstring(featureRepo + ":1.0.0")) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + + ginkgo.It( + "dry-run previews changes without modifying the file", + func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + srv := httptest.NewServer(registry.New()) + ginkgo.DeferCleanup(func() { srv.Close() }) + + regHost := strings.TrimPrefix(srv.URL, "http://") + featureRepo := regHost + "/test/my-feature" + + pushFeatureTag(featureRepo + ":1.0.0") + pushFeatureTag(featureRepo + ":2.0.0") + + originalContent := fmt.Sprintf(`{ + "image": "ubuntu:22.04", + "features": {"%s:1.0.0": {}} +}`, featureRepo) + tempDir := writeDevcontainerJSON(originalContent) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(tempDir) }) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + cmdUpgrade, + flagDryRun, + flagWorkspaceFolder, tempDir, + }) + framework.ExpectNoError(err) + + gomega.Expect(stdout).To(gomega.ContainSubstring("2.0.0")) + + afterContent := readDevcontainerJSON(tempDir) + gomega.Expect(afterContent).To( + gomega.ContainSubstring(featureRepo + ":1.0.0"), + ) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + + ginkgo.It( + "upgrades only targeted features when arguments are provided", + func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + srv := httptest.NewServer(registry.New()) + ginkgo.DeferCleanup(func() { srv.Close() }) + + regHost := strings.TrimPrefix(srv.URL, "http://") + featureA := regHost + "/test/feature-a" + featureB := regHost + "/test/feature-b" + + pushFeatureTag(featureA + ":1.0.0") + pushFeatureTag(featureA + ":2.0.0") + pushFeatureTag(featureB + ":1.0.0") + pushFeatureTag(featureB + ":2.0.0") + + tempDir := writeDevcontainerJSON(fmt.Sprintf(`{ + "image": "ubuntu:22.04", + "features": { + "%s:1.0.0": {}, + "%s:1.0.0": {} + } +}`, featureA, featureB)) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(tempDir) }) + + _, _, err := f.ExecCommandCapture(ctx, []string{ + cmdUpgrade, + flagWorkspaceFolder, tempDir, + featureA, + }) + framework.ExpectNoError(err) + + updated := readDevcontainerJSON(tempDir) + gomega.Expect(updated).To( + gomega.ContainSubstring(featureA + ":2.0.0"), + ) + gomega.Expect(updated).To( + gomega.ContainSubstring(featureB + ":1.0.0"), + ) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + + ginkgo.It( + "reports all up to date when no upgrades are available", + func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + srv := httptest.NewServer(registry.New()) + ginkgo.DeferCleanup(func() { srv.Close() }) + + regHost := strings.TrimPrefix(srv.URL, "http://") + featureRepo := regHost + "/test/my-feature" + + pushFeatureTag(featureRepo + ":1.0.0") + + tempDir := writeDevcontainerJSON(fmt.Sprintf(`{ + "image": "ubuntu:22.04", + "features": {"%s:1.0.0": {}} +}`, featureRepo)) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(tempDir) }) + + stdout, _, err := f.ExecCommandCapture(ctx, []string{ + cmdUpgrade, + flagWorkspaceFolder, tempDir, + }) + framework.ExpectNoError(err) + + gomega.Expect(stdout).To( + gomega.ContainSubstring("All features are up to date."), + ) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + + ginkgo.It( + "preserves JSONC comments when upgrading", + func(ctx context.Context) { + f := framework.NewDefaultFramework(initialDir + "/bin") + + srv := httptest.NewServer(registry.New()) + ginkgo.DeferCleanup(func() { srv.Close() }) + + regHost := strings.TrimPrefix(srv.URL, "http://") + featureRepo := regHost + "/test/my-feature" + + pushFeatureTag(featureRepo + ":1.0.0") + pushFeatureTag(featureRepo + ":2.0.0") + + tempDir := writeDevcontainerJSON(fmt.Sprintf(`{ + // Important comment + "image": "ubuntu:22.04", + "features": {"%s:1.0.0": {}} +}`, featureRepo)) + ginkgo.DeferCleanup(func() { _ = os.RemoveAll(tempDir) }) + + _, _, err := f.ExecCommandCapture(ctx, []string{ + cmdUpgrade, + flagWorkspaceFolder, tempDir, + }) + framework.ExpectNoError(err) + + updated := readDevcontainerJSON(tempDir) + gomega.Expect(updated).To(gomega.ContainSubstring("// Important comment")) + gomega.Expect(updated).To( + gomega.ContainSubstring(featureRepo + ":2.0.0"), + ) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) +}) + +func writeDevcontainerJSON(content string) string { + tempDir, err := os.MkdirTemp("", "upgrade-test-*") + framework.ExpectNoError(err) + + devcontainerDir := filepath.Join(tempDir, ".devcontainer") + framework.ExpectNoError(os.MkdirAll(devcontainerDir, 0o750)) + framework.ExpectNoError( + os.WriteFile( + filepath.Join(devcontainerDir, "devcontainer.json"), + []byte(content), + 0o600, + ), + ) + + return tempDir +} + +func readDevcontainerJSON(tempDir string) string { + // #nosec G304 -- path constructed from test temp directory + data, err := os.ReadFile( + filepath.Join(tempDir, ".devcontainer", "devcontainer.json"), + ) + framework.ExpectNoError(err) + + return string(data) +} + +func pushFeatureTag(refStr string) { + layer := static.NewLayer( + buildFeatureTarGz( + "devcontainer-feature.json", + `{"id":"my-feature","version":"1.0.0"}`, + ), + types.OCILayer, + ) + + img, err := mutate.AppendLayers(empty.Image, layer) + framework.ExpectNoError(err) + + ref, err := name.ParseReference(refStr, name.Insecure) + framework.ExpectNoError(err) + + framework.ExpectNoError(remote.Write(ref, img)) +} + +func buildFeatureTarGz(filename, content string) []byte { + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + + hdr := &tar.Header{ + Name: filename, + Mode: 0o644, + Size: int64(len(content)), + } + gomega.Expect(tw.WriteHeader(hdr)).To(gomega.Succeed()) + + _, err := tw.Write([]byte(content)) + framework.ExpectNoError(err) + gomega.Expect(tw.Close()).To(gomega.Succeed()) + gomega.Expect(gz.Close()).To(gomega.Succeed()) + + return buf.Bytes() +}