diff --git a/README.md b/README.md index 84042f0..57ac2a2 100644 --- a/README.md +++ b/README.md @@ -1 +1,18 @@ -# Outdated: A kubectl Plugin +# `kubectl outdated` + +A `kubectl` plugin to show out-of-date images running in a cluster. + +## Quick Start + +``` +kubectl krew install outdated +kubectl outdated +``` + +The plugin will scan for all pods in all namespaces that you have at least read access to. It will then connect to the registry that hosts the image, and (if there's permission), it will analyze your tag to the list of current tags. + +The output is a list of all images, with the most out-of-date images in red, slightly outdated in yellow, and up-to-date in green. + +### Example + +[![kuebct; ourdated example](https://asciinema.org/a/ExaFOk6ap0GL17GJsJWpExGnM.svg)](https://asciinema.org/a/ExaFOk6ap0GL17GJsJWpExGnM) diff --git a/cmd/outdated/cli/root.go b/cmd/outdated/cli/root.go index 6a077cc..9ba7955 100644 --- a/cmd/outdated/cli/root.go +++ b/cmd/outdated/cli/root.go @@ -5,11 +5,13 @@ import ( "os" "path" "strings" + "time" "github.com/replicatedhq/outdated/pkg/logger" "github.com/replicatedhq/outdated/pkg/outdated" "github.com/spf13/cobra" "github.com/spf13/viper" + "github.com/tj/go-spin" ) func RootCmd() *cobra.Command { @@ -29,16 +31,40 @@ func RootCmd() *cobra.Command { o := outdated.Outdated{} - log.Info("Finding images in cluster") - images, err := o.ListImages(v.GetString("kubeconfig")) + s := spin.New() + finishedCh := make(chan bool, 1) + foundImageName := make(chan string, 1) + go func() { + lastImageName := "" + for { + select { + case <-finishedCh: + fmt.Printf("\r") + return + case i := <-foundImageName: + lastImageName = i + case <-time.After(time.Millisecond * 100): + if lastImageName == "" { + fmt.Printf("\r \033[36mSearching for images\033[m %s", s.Next()) + } else { + fmt.Printf("\r \033[36mSearching for images\033[m %s (%s)", s.Next(), lastImageName) + } + } + } + }() + defer func() { + finishedCh <- true + }() + + images, err := o.ListImages(v.GetString("kubeconfig"), foundImageName) if err != nil { log.Error(err) log.Info("") os.Exit(1) return nil } + finishedCh <- true - log.Info("") head, imageColumnWidth, tagColumnWidth := headerLine(images) log.Header(head) @@ -58,6 +84,9 @@ func RootCmd() *cobra.Command { log.FinalizeImageLineWithError(erroredImage(image, checkResult, imageColumnWidth, tagColumnWidth)) } } + + log.Info("") + return nil }, } diff --git a/go.mod b/go.mod index 539bcac..dfbd49e 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.12 require ( github.com/Masterminds/goutils v1.1.0 // indirect - github.com/Masterminds/semver v1.4.2 // indirect + github.com/Masterminds/semver v1.4.2 github.com/Masterminds/sprig v2.20.0+incompatible // indirect github.com/andrewchambers/go-jqpipe v0.0.0-20180509223707-2d54cef8cd94 // indirect github.com/blang/semver v3.5.1+incompatible diff --git a/go.sum b/go.sum index 7334fd1..c18163f 100644 --- a/go.sum +++ b/go.sum @@ -480,6 +480,7 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= +github.com/tj/go-spin v1.1.0 h1:lhdWZsvImxvZ3q1C5OIB7d72DuOwP4O2NdBg9PyzNds= github.com/tj/go-spin v1.1.0/go.mod h1:Mg1mzmePZm4dva8Qz60H2lHwmJ2loum4VIrLgVnKwh4= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tsenart/deadcode v0.0.0-20160724212837-210d2dc333e9/go.mod h1:q+QjxYvZ+fpjMXqs+XEriussHjSYqeXVnAdSV1tkMYk= diff --git a/pkg/outdated/list.go b/pkg/outdated/list.go index 2e8e485..54141be 100644 --- a/pkg/outdated/list.go +++ b/pkg/outdated/list.go @@ -1,6 +1,7 @@ package outdated import ( + "fmt" "strings" "github.com/pkg/errors" @@ -18,7 +19,7 @@ type RunningImage struct { PullableImage string } -func (o Outdated) ListImages(kubeconfigPath string) ([]RunningImage, error) { +func (o Outdated) ListImages(kubeconfigPath string, imageNameCh chan string) ([]RunningImage, error) { config, err := clientcmd.BuildConfigFromFlags("", kubeconfigPath) if err != nil { return nil, errors.Wrap(err, "failed to read kubeconfig") @@ -36,6 +37,8 @@ func (o Outdated) ListImages(kubeconfigPath string) ([]RunningImage, error) { runningImages := []RunningImage{} for _, namespace := range namespaces.Items { + imageNameCh <- fmt.Sprintf("%s/", namespace.Name) + pods, err := clientset.CoreV1().Pods(namespace.Name).List(metav1.ListOptions{}) if err != nil { return nil, errors.Wrap(err, "failed to list pods") @@ -55,6 +58,7 @@ func (o Outdated) ListImages(kubeconfigPath string) ([]RunningImage, error) { PullableImage: pullable, } + imageNameCh <- fmt.Sprintf("%s/%s", namespace.Name, runningImage.Image) runningImages = append(runningImages, runningImage) } @@ -71,6 +75,7 @@ func (o Outdated) ListImages(kubeconfigPath string) ([]RunningImage, error) { PullableImage: pullable, } + imageNameCh <- fmt.Sprintf("%s/%s", namespace.Name, runningImage.Image) runningImages = append(runningImages, runningImage) } } diff --git a/pkg/outdated/outdated.go b/pkg/outdated/outdated.go index a375d68..570e472 100644 --- a/pkg/outdated/outdated.go +++ b/pkg/outdated/outdated.go @@ -49,6 +49,7 @@ func (o Outdated) ParseImage(image string, pullableImage string) (*CheckResult, return o.parseNonSemverImage(reg, imageName, tag, nonSemverTags) } + // From here on, we can assume that we are on a semver tag semverTags = append(semverTags, detectedSemver) collection := SemverTagCollection(semverTags) @@ -57,6 +58,7 @@ func (o Outdated) ParseImage(image string, pullableImage string) (*CheckResult, return nil, errors.Wrap(err, "failed to calculate versions behind") } trueVersionsBehind := SemverTagCollection(versionsBehind).RemoveLeastSpecific() + behind := len(trueVersionsBehind) - 1 checkResult := CheckResult{ diff --git a/pkg/outdated/registry.go b/pkg/outdated/registry.go index 1853bc7..ad4fb29 100644 --- a/pkg/outdated/registry.go +++ b/pkg/outdated/registry.go @@ -17,7 +17,8 @@ func initRegistryClient(hostname string) (*registry.Registry, error) { } reg, err := registry.New(auth, registry.Opt{ - Timeout: time.Duration(time.Second * 5), + SkipPing: true, + Timeout: time.Duration(time.Second * 5), }) if err != nil { return nil, errors.Wrap(err, "failed to create registry client") diff --git a/pkg/outdated/version.go b/pkg/outdated/version.go index 7ce5e27..2273d74 100644 --- a/pkg/outdated/version.go +++ b/pkg/outdated/version.go @@ -34,34 +34,9 @@ func (c SemverTagCollection) Less(i, j int) bool { } func compareVersions(verI *semver.Version, verJ *semver.Version) int { - splitI := strings.Split(verI.Original(), ".") - splitJ := strings.Split(verJ.Original(), ".") - - iSegments := verI.Segments() - jSegments := verJ.Segments() - - for idx := range splitI { - if idx <= len(splitJ)-1 { - splitIPartInt := iSegments[idx] - splitJPartInt := jSegments[idx] - - if splitIPartInt != splitJPartInt { - if splitIPartInt > splitJPartInt { - return 1 - } - if splitIPartInt < splitJPartInt { - return -1 - } - } - } else { - return -1 - } - } - - if len(splitI) > len(splitJ) { + if verI.LessThan(verJ) { return -1 - } - if len(splitI) < len(splitJ) { + } else if verI.GreaterThan(verJ) { return 1 } @@ -75,15 +50,21 @@ func (c SemverTagCollection) Swap(i, j int) { func (c SemverTagCollection) VersionsBehind(currentVersion *semver.Version) ([]*semver.Version, error) { cleaned, err := c.Unique() if err != nil { - return []*semver.Version{}, errors.Wrap(err, "deduplicate versions") + return []*semver.Version{}, errors.Wrap(err, "failed to deduplicate versions") } - for idx := range cleaned { - if compareVersions(cleaned[idx], currentVersion) == 0 { - return cleaned[idx:], nil + sortable := SemverTagCollection(cleaned) + sort.Sort(sortable) + + for idx := range sortable { + if sortable[idx].Original() == currentVersion.Original() { + return sortable[idx:], nil } } - return []*semver.Version{}, errors.New("no matching version found") + + return []*semver.Version{ + currentVersion, + }, nil // /shrug } // Unique will create a new sorted slice with the same versions that have different tags removed. @@ -135,6 +116,10 @@ func (c SemverTagCollection) Unique() ([]*semver.Version, error) { // RemoveLeastSpecific given a sorted collection will remove the least specific version func (c SemverTagCollection) RemoveLeastSpecific() []*semver.Version { + if c.Len() == 0 { + return []*semver.Version{} + } + cleanedVersions := []*semver.Version{c[0]} for i := 0; i < len(c)-1; i++ { j := i + 1 diff --git a/pkg/outdated/version_test.go b/pkg/outdated/version_test.go index b6b2dba..eacf319 100644 --- a/pkg/outdated/version_test.go +++ b/pkg/outdated/version_test.go @@ -179,7 +179,7 @@ func TestCompareVersions(t *testing.T) { { name: "minor versions is less than", versions: []string{"10.1", "10"}, - expect: -1, + expect: 1, }, { name: "minor versions is greater than", @@ -209,7 +209,7 @@ func TestCompareVersions(t *testing.T) { { name: "major version only with patch", versions: []string{"10", "10.1.2"}, - expect: 1, + expect: -1, }, { name: "minor version greater, patch version less", @@ -226,6 +226,11 @@ func TestCompareVersions(t *testing.T) { versions: []string{"9.1", "10.2.2"}, expect: -1, }, + { + name: "minor with patches", + versions: []string{"v3.0.4-beta.0", "v3.0.4-alpha.1"}, + expect: 1, + }, } for _, test := range tests { @@ -307,6 +312,11 @@ func TestRemoveLeastSpecific(t *testing.T) { versions: []string{"3.5.1.1", "3.5.1", "4.5.1"}, expectVersions: []string{"3.5.1.1", "4.5.1"}, }, + { + name: "opa style", + versions: []string{"v3.0.4-beta.0", "v3.0.4-beta.1"}, + expectVersions: []string{"v3.0.4-beta.0", "v3.0.4-beta.1"}, + }, } for _, test := range tests { @@ -322,19 +332,42 @@ func TestRemoveLeastSpecific(t *testing.T) { } func TestResolveTagDates(t *testing.T) { - hostname := "index.docker.io" - imageName := "library/postgres" - versions := []string{"10.0", "10.1", "10.2"} - allVersions := makeVersions(versions) + tests := []struct { + name string + hostname string + imageName string + versions []string + }{ + { + name: "postgres", + hostname: "index.docker.io", + imageName: "library/postgres", + versions: []string{"10.0", "10.1", "10.2"}, + }, + { + name: "tiller", + hostname: "gcr.io", + imageName: "kubernetes-helm/tiller", + versions: []string{"v2.14.1"}, + }, + } - reg, err := initRegistryClient(hostname) - require.NoError(t, err) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + req := require.New(t) + + allVersions := makeVersions(test.versions) - versionTags, err := resolveTagDates(reg, imageName, allVersions) - require.NoError(t, err) + reg, err := initRegistryClient(test.hostname) + req.NoError(err) - for _, versionTag := range versionTags { - _, err = time.Parse(time.RFC3339, versionTag.Date) - require.NoError(t, err) + versionTags, err := resolveTagDates(reg, test.imageName, allVersions) + req.NoError(err) + + for _, versionTag := range versionTags { + _, err = time.Parse(time.RFC3339, versionTag.Date) + req.NoError(err) + } + }) } }