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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
142 changes: 142 additions & 0 deletions cmd/upgrade.go
Original file line number Diff line number Diff line change
@@ -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
}
168 changes: 168 additions & 0 deletions cmd/upgrade_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
1 change: 1 addition & 0 deletions e2e/e2e_suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down
Loading
Loading