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
243 changes: 243 additions & 0 deletions cmd/outdated.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
package cmd

import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"

"github.com/blang/semver/v4"
"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/devsy-org/devsy/pkg/table"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/spf13/cobra"
)

const (
tagLatest = "latest"
noFeaturesMessage = "No features found."
allUpToDateMessage = "All features are up to date."
)

// OutdatedCmd checks for newer versions of installed devcontainer features.
type OutdatedCmd struct {
*flags.GlobalFlags

WorkspaceFolder string
Config string
}

// NewOutdatedCmd creates a new outdated command.
func NewOutdatedCmd(f *flags.GlobalFlags) *cobra.Command {
cmd := &OutdatedCmd{GlobalFlags: f}
outdatedCmd := &cobra.Command{
Use: "outdated",
Short: "Checks for newer versions of installed devcontainer features",
Args: cobra.NoArgs,
RunE: func(cobraCmd *cobra.Command, _ []string) error {
return cmd.Run(cobraCmd.Context())
},
}

outdatedCmd.Flags().
StringVar(&cmd.WorkspaceFolder, "workspace-folder", "", "Path to the workspace folder")
outdatedCmd.Flags().
StringVar(&cmd.Config, "config", "", "Path to a specific devcontainer.json")

return outdatedCmd
}

// Run runs the command logic.
func (cmd *OutdatedCmd) Run(_ context.Context) error {
parsedConfig, err := cmd.loadConfig()
if err != nil {
return err
}

if len(parsedConfig.Features) == 0 {
_, _ = fmt.Fprintln(os.Stdout, noFeaturesMessage)
return nil
}

outdated := checkOutdatedFeatures(parsedConfig.Features)
if len(outdated) == 0 {
_, _ = fmt.Fprintln(os.Stdout, allUpToDateMessage)
return nil
}

printOutdatedTable(outdated)
return nil
}

func (cmd *OutdatedCmd) loadConfig() (*devconfig.DevContainerConfig, error) {
workspaceFolder := cmd.WorkspaceFolder
if workspaceFolder == "" {
cwd, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("get working directory: %w", err)
}
workspaceFolder = cwd
}

workspaceFolder, err := filepath.Abs(workspaceFolder)
if err != nil {
return nil, fmt.Errorf("resolve workspace folder: %w", err)
}

var parsedConfig *devconfig.DevContainerConfig
if cmd.Config != "" {
parsedConfig, err = devconfig.ParseDevContainerJSONFile(cmd.Config)
} else {
parsedConfig, err = devconfig.ParseDevContainerJSON(workspaceFolder, "")
}
if err != nil {
return nil, fmt.Errorf("parse devcontainer config: %w", err)
}
if parsedConfig == nil {
return nil, fmt.Errorf("no devcontainer configuration found in %s", workspaceFolder)
}

return parsedConfig, nil
}

func printOutdatedTable(outdated []outdatedEntry) {
sort.Slice(outdated, func(i, j int) bool {
return outdated[i].repo < outdated[j].repo
})

rows := make([][]string, 0, len(outdated))
for _, entry := range outdated {
rows = append(rows, []string{entry.repo, entry.current, entry.latest})
}

table.Print([]string{"Feature", "Current", "Latest"}, rows)
}

type outdatedEntry struct {
repo string
current string
latest string
}

func checkOutdatedFeatures(features map[string]any) []outdatedEntry {
var results []outdatedEntry
for featureID := range features {
entry, ok := checkFeatureVersion(featureID)
if ok {
results = append(results, entry)
}
}
return results
}

func isNonOCIFeature(featureID string) bool {
if strings.HasPrefix(featureID, "./") || strings.HasPrefix(featureID, "../") {
return true
}
return strings.HasPrefix(featureID, "https://") || strings.HasPrefix(featureID, "http://")
}

func parseFeatureTag(featureID string) (name.Tag, bool) {
ref, err := name.ParseReference(featureID)
if err != nil {
log.Debugf("failed to parse feature reference %s: %v", featureID, err)
return name.Tag{}, false
}

tag, ok := ref.(name.Tag)
if !ok {
return name.Tag{}, false
}

if tag.TagStr() == tagLatest {
return name.Tag{}, false
}

return tag, true
}

func checkFeatureVersion(featureID string) (outdatedEntry, bool) {
if isNonOCIFeature(featureID) {
return outdatedEntry{}, false
}

tag, ok := parseFeatureTag(featureID)
if !ok {
return outdatedEntry{}, false
}

currentTag := tag.TagStr()
repo := tag.Repository

tags, err := remote.List(repo, remote.WithAuthFromKeychain(authn.DefaultKeychain))
if err != nil {
log.Warnf("failed to list tags for %s: %v", repo.Name(), err)
return outdatedEntry{}, false
}

latestTag := findLatestVersion(currentTag, tags)
if latestTag == "" || latestTag == currentTag {
return outdatedEntry{}, false
}

return outdatedEntry{
repo: repo.Name(),
current: currentTag,
latest: latestTag,
}, true
}

// findLatestVersion finds the highest semver tag from the list that is newer
// than the current tag. Returns empty string if current is already the latest.
func findLatestVersion(current string, tags []string) string {
currentVer, currentErr := parseSemver(current)

var best semver.Version
var bestTag string
hasBest := false

for _, t := range tags {
if t == tagLatest {
continue
}

ver, err := parseSemver(t)
if err != nil {
continue
}

// If current tag is not valid semver, we cannot compare
if currentErr != nil {
continue
}

if ver.GT(currentVer) && (!hasBest || ver.GT(best)) {
best = ver
bestTag = t
hasBest = true
}
}

return bestTag
}

// parseSemver parses a version string that may be a bare major ("1"),
// major.minor ("1.21"), or full semver ("1.21.0").
func parseSemver(tag string) (semver.Version, error) {
parts := strings.Split(tag, ".")
switch len(parts) {
case 1:
// Bare major version like "1" or "20"
tag += ".0.0"
case 2:
// Major.minor like "1.21"
tag += ".0"
}
return semver.Parse(tag)
}
135 changes: 135 additions & 0 deletions cmd/outdated_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package cmd

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestParseSemver_BareMajor(t *testing.T) {
v, err := parseSemver("2")
require.NoError(t, err)
assert.Equal(t, uint64(2), v.Major)
assert.Equal(t, uint64(0), v.Minor)
assert.Equal(t, uint64(0), v.Patch)
}

func TestParseSemver_MajorMinor(t *testing.T) {
v, err := parseSemver("1.21")
require.NoError(t, err)
assert.Equal(t, uint64(1), v.Major)
assert.Equal(t, uint64(21), v.Minor)
assert.Equal(t, uint64(0), v.Patch)
}

func TestParseSemver_Full(t *testing.T) {
v, err := parseSemver("3.2.1")
require.NoError(t, err)
assert.Equal(t, uint64(3), v.Major)
assert.Equal(t, uint64(2), v.Minor)
assert.Equal(t, uint64(1), v.Patch)
}

func TestParseSemver_Invalid(t *testing.T) {
_, err := parseSemver("abc")
assert.Error(t, err)
}

func TestFindLatestVersion_NewerAvailable(t *testing.T) {
tags := []string{"1", "2", "3", tagLatest}
result := findLatestVersion("1", tags)
assert.Equal(t, "3", result)
}

func TestFindLatestVersion_AlreadyLatest(t *testing.T) {
tags := []string{"1", "2", "3"}
result := findLatestVersion("3", tags)
assert.Empty(t, result)
}

func TestFindLatestVersion_SemverTags(t *testing.T) {
tags := []string{"1.20", "1.21", "1.22", "1.23"}
result := findLatestVersion("1.21", tags)
assert.Equal(t, "1.23", result)
}

func TestFindLatestVersion_FullSemver(t *testing.T) {
tags := []string{"1.0.0", "1.1.0", "2.0.0", "2.1.0"}
result := findLatestVersion("1.1.0", tags)
assert.Equal(t, "2.1.0", result)
}

func TestFindLatestVersion_SkipsLatestTag(t *testing.T) {
tags := []string{"1", "2", tagLatest}
result := findLatestVersion("2", tags)
assert.Empty(t, result)
}

func TestFindLatestVersion_InvalidCurrentTag(t *testing.T) {
tags := []string{"1", "2", "3"}
result := findLatestVersion("abc", tags)
assert.Empty(t, result)
}

func TestFindLatestVersion_MixedValidInvalid(t *testing.T) {
tags := []string{"1", "abc", "2", "def", "3"}
result := findLatestVersion("1", tags)
assert.Equal(t, "3", result)
}

func TestCheckFeatureVersion_SkipsLocalPath(t *testing.T) {
_, ok := checkFeatureVersion("./local-feature")
assert.False(t, ok)
}

func TestCheckFeatureVersion_SkipsRelativePath(t *testing.T) {
_, ok := checkFeatureVersion("../relative-feature")
assert.False(t, ok)
}

func TestCheckFeatureVersion_SkipsHTTPURL(t *testing.T) {
_, ok := checkFeatureVersion("https://example.com/feature.tgz")
assert.False(t, ok)
}

func TestCheckFeatureVersion_SkipsHTTPURLLowercase(t *testing.T) {
_, ok := checkFeatureVersion("http://example.com/feature.tgz")
assert.False(t, ok)
}

func TestIsNonOCIFeature_LocalPath(t *testing.T) {
assert.True(t, isNonOCIFeature("./my-feature"))
assert.True(t, isNonOCIFeature("../my-feature"))
}

func TestIsNonOCIFeature_URL(t *testing.T) {
assert.True(t, isNonOCIFeature("https://example.com/feature.tgz"))
assert.True(t, isNonOCIFeature("http://example.com/feature.tgz"))
}

func TestIsNonOCIFeature_OCIReference(t *testing.T) {
assert.False(t, isNonOCIFeature("ghcr.io/devcontainers/features/go:1.21"))
}

func TestParseFeatureTag_ValidOCI(t *testing.T) {
tag, ok := parseFeatureTag("ghcr.io/devcontainers/features/go:1.21")
assert.True(t, ok)
assert.Equal(t, "1.21", tag.TagStr())
}

func TestParseFeatureTag_LatestTag(t *testing.T) {
_, ok := parseFeatureTag("ghcr.io/devcontainers/features/go:latest")
assert.False(t, ok)
}

func TestParseFeatureTag_InvalidReference(t *testing.T) {
_, ok := parseFeatureTag(":::invalid")
assert.False(t, ok)
}

func TestNewOutdatedCmd_CreatesCommand(t *testing.T) {
cmd := NewOutdatedCmd(nil)
assert.Equal(t, "outdated", cmd.Use)
assert.NotEmpty(t, cmd.Short)
}
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ func BuildRoot() *cobra.Command {
rootCmd.AddCommand(NewPingCmd(globalFlags))
rootCmd.AddCommand(NewReadConfigurationCmd(globalFlags))
rootCmd.AddCommand(NewExecCmd(globalFlags))
rootCmd.AddCommand(NewOutdatedCmd(globalFlags))

inheritCommandFlagsFromEnvironment(rootCmd)

Expand Down
Loading