Add tests for status cli

This commit is contained in:
Morten Torkildsen
2019-12-30 21:38:06 -08:00
parent 3577a7e174
commit f0d81c4fac
14 changed files with 1019 additions and 24 deletions

View File

@@ -15,7 +15,7 @@ linters:
- dogsled - dogsled
- dupl - dupl
- errcheck - errcheck
- funlen # - funlen
# - gochecknoinits # - gochecknoinits
- goconst - goconst
- gocritic - gocritic

View File

@@ -3,9 +3,12 @@ module sigs.k8s.io/kustomize/cmd/resource
go 1.12 go 1.12
require ( require (
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d
github.com/pkg/errors v0.8.1 github.com/pkg/errors v0.8.1
github.com/spf13/cobra v0.0.5 github.com/spf13/cobra v0.0.5
github.com/stretchr/testify v1.4.0
golang.org/x/net v0.0.0-20191004110552-13f9640d40b9 // indirect golang.org/x/net v0.0.0-20191004110552-13f9640d40b9 // indirect
k8s.io/api v0.0.0-20190918155943-95b840bb6a1f
k8s.io/apimachinery v0.0.0-20190913080033-27d36303b655 k8s.io/apimachinery v0.0.0-20190913080033-27d36303b655
k8s.io/client-go v0.0.0-20190918160344-1fbdaa4c8d90 k8s.io/client-go v0.0.0-20190918160344-1fbdaa4c8d90
sigs.k8s.io/controller-runtime v0.4.0 sigs.k8s.io/controller-runtime v0.4.0

View File

@@ -18,6 +18,8 @@ github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbt
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/asaskevich/govalidator v0.0.0-20180720115003-f9ffefc3facf/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= github.com/asaskevich/govalidator v0.0.0-20180720115003-f9ffefc3facf/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY=
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY=
@@ -349,8 +351,10 @@ google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873 h1:nfPFGzJkUDX6uBmpN/pSw7MbOAWegH5QDQuoXFHedLg=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0 h1:AzbTB6ux+okLTzP8Ru1Xs41C303zdcfEht7MQnYJt5A=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -16,7 +16,9 @@ import (
// GetEventsRunner returns a command EventsRunner. // GetEventsRunner returns a command EventsRunner.
func GetEventsRunner() *EventsRunner { func GetEventsRunner() *EventsRunner {
r := &EventsRunner{} r := &EventsRunner{
createClientFunc: createClient,
}
c := &cobra.Command{ c := &cobra.Command{
Use: "events DIR...", Use: "events DIR...",
Short: commands.EventsShort, Short: commands.EventsShort,
@@ -46,13 +48,15 @@ type EventsRunner struct {
Interval time.Duration Interval time.Duration
Timeout time.Duration Timeout time.Duration
Command *cobra.Command Command *cobra.Command
createClientFunc createClientFunc
} }
func (r *EventsRunner) runE(c *cobra.Command, args []string) error { func (r *EventsRunner) runE(c *cobra.Command, args []string) error {
ctx := context.Background() ctx := context.Background()
// Create a client and use it to set up a new resolver. // Create a client and use it to set up a new resolver.
client, err := getClient() client, err := r.createClientFunc()
if err != nil { if err != nil {
return errors.Wrap(err, "error creating client") return errors.Wrap(err, "error creating client")
} }

View File

@@ -0,0 +1,298 @@
package cmd
import (
"bytes"
"fmt"
"reflect"
"regexp"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"sigs.k8s.io/kustomize/kstatus/status"
"sigs.k8s.io/kustomize/kstatus/wait"
)
func TestEventsNoResources(t *testing.T) {
inBuffer := &bytes.Buffer{}
outBuffer := &bytes.Buffer{}
fakeClient := &FakeClient{}
r := GetEventsRunner()
r.createClientFunc = newClientFunc(fakeClient)
r.Command.SetArgs([]string{})
r.Command.SetIn(inBuffer)
r.Command.SetOut(outBuffer)
err := r.Command.Execute()
if !assert.NoError(t, err) {
return
}
eventOutput := parseEventOutput(t, outBuffer.String())
if want, got := 1, len(eventOutput.events); want != got {
t.Errorf("expected %d events, but got %d", want, got)
}
event := eventOutput.events[0]
if want, got := status.CurrentStatus, event.aggStatus; want != got {
t.Errorf("expected agg status %s, but got %s", want, got)
}
}
func TestEventsMultipleUpdates(t *testing.T) {
inBuffer := &bytes.Buffer{}
outBuffer := &bytes.Buffer{}
_, err := fmt.Fprint(inBuffer, `
apiVersion: apps/v1
kind: Deployment
metadata:
name: bar
namespace: default
`)
if !assert.NoError(t, err) {
return
}
fakeClient := &FakeClient{
resourceCallbackMap: map[string]ResourceGetCallback{
"Deployment": createDeploymentStatusFunc(),
},
}
r := GetEventsRunner()
r.createClientFunc = newClientFunc(fakeClient)
r.Command.SetArgs([]string{})
r.Command.SetIn(inBuffer)
r.Command.SetOut(outBuffer)
err = r.Command.Execute()
if !assert.NoError(t, err) {
return
}
eventOutput := parseEventOutput(t, outBuffer.String())
aggStatuses := eventOutput.allAggStatuses()
expectedAggStatuses := []status.Status{
status.InProgressStatus,
status.InProgressStatus,
status.InProgressStatus,
status.InProgressStatus,
status.CurrentStatus,
status.CurrentStatus,
}
if !reflect.DeepEqual(aggStatuses, expectedAggStatuses) {
t.Errorf("expected agg statuses to be %s, but got %s", joinStatuses(expectedAggStatuses),
joinStatuses(aggStatuses))
}
resources := eventOutput.allResources()
if want, got := 1, len(resources); want != got {
t.Errorf("expected %d resource, but got %d", want, got)
}
resource := resources[0]
resourceStatuses := eventOutput.statusesForResource(resource)
expectedResourceStatuses := []status.Status{
status.InProgressStatus,
status.InProgressStatus,
status.InProgressStatus,
status.InProgressStatus,
status.CurrentStatus,
}
if !reflect.DeepEqual(resourceStatuses, expectedResourceStatuses) {
t.Errorf("expected statuses to be %s, but got %s", joinStatuses(expectedResourceStatuses),
joinStatuses(resourceStatuses))
}
}
func TestEventsMultipleResources(t *testing.T) {
inBuffer := &bytes.Buffer{}
outBuffer := &bytes.Buffer{}
_, err := fmt.Fprint(inBuffer, `
apiVersion: v1
kind: List
items:
- apiVersion: v1
kind: Pod
metadata:
name: foo
namespace: default
- apiVersion: v1
kind: Service
metadata:
name: bar
namespace: default
`)
if !assert.NoError(t, err) {
return
}
fakeClient := &FakeClient{
resourceCallbackMap: map[string]ResourceGetCallback{
"Pod": createPodStatusFunc(),
"Service": createServiceStatusFunc(),
},
}
r := GetEventsRunner()
r.createClientFunc = newClientFunc(fakeClient)
r.Command.SetArgs([]string{})
r.Command.SetIn(inBuffer)
r.Command.SetOut(outBuffer)
err = r.Command.Execute()
if !assert.NoError(t, err) {
return
}
eventOutput := parseEventOutput(t, outBuffer.String())
aggStatuses := eventOutput.allAggStatuses()
expectedAggStatuses := []status.Status{
status.UnknownStatus,
status.CurrentStatus,
status.CurrentStatus,
}
if !reflect.DeepEqual(aggStatuses, expectedAggStatuses) {
t.Errorf("expected agg statuses to be %s, but got %s", joinStatuses(expectedAggStatuses),
joinStatuses(aggStatuses))
}
resources := eventOutput.allResources()
if want, got := 2, len(resources); got != want {
t.Errorf("expected %d resource, but got %d", want, got)
}
for _, resource := range resources {
resourceStatuses := eventOutput.statusesForResource(resource)
if want, got := status.CurrentStatus, resourceStatuses[len(resourceStatuses)-1]; want != got {
t.Errorf("expected resource %q to have final status %s, but got %s", resource.name, want, got)
}
}
}
type EventOutput struct {
events []EventOutputLine
unknownLines []string
}
func (e *EventOutput) allAggStatuses() []status.Status {
var aggStatuses []status.Status
for _, event := range e.events {
aggStatuses = append(aggStatuses, event.aggStatus)
}
return aggStatuses
}
func (e *EventOutput) allResources() []ResourceIdentifier {
var resources []ResourceIdentifier
seenResources := make(map[ResourceIdentifier]bool)
for _, event := range e.events {
if !event.isResourceUpdateEvent() {
continue
}
r := event.identifier
if _, found := seenResources[r]; !found {
resources = append(resources, r)
seenResources[r] = true
}
}
return resources
}
func (e *EventOutput) statusesForResource(resource ResourceIdentifier) []status.Status {
var statuses []status.Status
for _, event := range e.events {
if !event.isResourceUpdateEvent() {
continue
}
if event.identifier.Equals(resource) {
statuses = append(statuses, event.status)
}
}
return statuses
}
type EventOutputLine struct {
eventType string
aggStatus status.Status
identifier ResourceIdentifier
status status.Status
message string
}
func (e *EventOutputLine) isResourceUpdateEvent() bool {
return e.eventType == string(wait.ResourceUpdate)
}
var (
eventRegex = regexp.MustCompile(`^\s*` +
`(?P<eventType>\S+)\s+` +
`(?P<aggStatus>\S+)\s+` +
`((?P<resourceType>\S+)\s+` +
`(?P<namespace>\S+)\s+` +
`(?P<name>\S+)\s+` +
`(?P<status>\S+)\s+` +
`(?P<message>.*\S)){0,1}` +
`\s*$`)
eventHeaderRegex = regexp.MustCompile(`^\s*` +
`EVENT TYPE\s+` +
`AGG STATUS\s+` +
`TYPE\s+` +
`NAMESPACE\s+` +
`NAME\s+` +
`STATUS\s+` +
`MESSAGE` +
`\s*$`)
)
func parseEventOutput(_ *testing.T, output string) EventOutput {
var eventOutput EventOutput
lines := strings.Split(output, "\n")
for _, line := range lines {
if len(line) == 0 {
continue // Ignore empty lines
}
match := eventHeaderRegex.FindStringSubmatch(line)
if match != nil {
continue // Ignore headers
}
match = eventRegex.FindStringSubmatch(line)
if match == nil {
eventOutput.unknownLines = append(eventOutput.unknownLines, line)
continue
}
eventOutputLine := EventOutputLine{
eventType: match[1],
aggStatus: status.FromStringOrDie(match[2]),
}
if eventOutputLine.eventType == string(wait.ResourceUpdate) {
resourceType := match[4]
parts := strings.Split(resourceType, "/")
var identifier ResourceIdentifier
if len(parts) == 2 {
identifier.apiVersion = parts[0]
identifier.kind = parts[1]
} else {
identifier.apiVersion = strings.Join(parts[:2], "/")
identifier.kind = parts[2]
}
identifier.namespace = match[5]
identifier.name = match[6]
eventOutputLine.identifier = identifier
eventOutputLine.status = status.FromStringOrDie(match[7])
eventOutputLine.message = match[8]
}
eventOutput.events = append(eventOutput.events, eventOutputLine)
}
return eventOutput
}

View File

@@ -17,7 +17,9 @@ import (
// GetFetchRunner returns a command FetchRunner. // GetFetchRunner returns a command FetchRunner.
func GetFetchRunner() *FetchRunner { func GetFetchRunner() *FetchRunner {
r := &FetchRunner{} r := &FetchRunner{
createClientFunc: createClient,
}
c := &cobra.Command{ c := &cobra.Command{
Use: "fetch DIR...", Use: "fetch DIR...",
Short: commands.FetchShort, Short: commands.FetchShort,
@@ -41,18 +43,20 @@ func FetchCommand() *cobra.Command {
type FetchRunner struct { type FetchRunner struct {
IncludeSubpackages bool IncludeSubpackages bool
Command *cobra.Command Command *cobra.Command
createClientFunc createClientFunc
} }
func (r *FetchRunner) runE(c *cobra.Command, args []string) error { func (r *FetchRunner) runE(c *cobra.Command, args []string) error {
ctx := context.Background() ctx := context.Background()
// Create a new client and use it to set up a resolver. // Create a new client and use it to set up a resolver.
client, err := getClient() k8sClient, err := r.createClientFunc()
if err != nil { if err != nil {
return errors.Wrap(err, "error creating client") return errors.Wrap(err, "error creating k8sClient")
} }
resolver := wait.NewResolver(client, time.Minute) resolver := wait.NewResolver(k8sClient, time.Minute)
// Set up a CaptureIdentifierFilter and run all inputs through the // Set up a CaptureIdentifierFilter and run all inputs through the
// filter with the pipeline to capture the inventory of resources // filter with the pipeline to capture the inventory of resources

View File

@@ -0,0 +1,230 @@
package cmd
import (
"bytes"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
"testing"
"github.com/acarl005/stripansi"
"github.com/stretchr/testify/assert"
appsv1 "k8s.io/api/apps/v1"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/kustomize/kstatus/status"
)
func TestEmptyManifest(t *testing.T) {
inBuffer := &bytes.Buffer{}
outBuffer := &bytes.Buffer{}
fakeClient := fake.NewFakeClientWithScheme(scheme)
r := GetFetchRunner()
r.createClientFunc = newClientFunc(fakeClient)
r.Command.SetArgs([]string{})
r.Command.SetIn(inBuffer)
r.Command.SetOut(outBuffer)
err := r.Command.Execute()
if !assert.NoError(t, err) {
return
}
output := outBuffer.String()
lines := strings.Split(output, "\n")
if want, got := 2, len(lines); want != got {
t.Errorf("Expected %d lines, but got %d", want, got)
}
}
func TestFetchStatusFromManifestStdIn(t *testing.T) {
inBuffer := &bytes.Buffer{}
outBuffer := &bytes.Buffer{}
_, err := fmt.Fprint(inBuffer, `
apiVersion: apps/v1
kind: Deployment
metadata:
name: bar
namespace: default
`)
if !assert.NoError(t, err) {
return
}
deployment := createDeployment("bar", "default", 42, appsv1.DeploymentStatus{
ObservedGeneration: 1,
})
fakeClient := fake.NewFakeClientWithScheme(scheme, deployment)
r := GetFetchRunner()
r.createClientFunc = newClientFunc(fakeClient)
r.Command.SetArgs([]string{})
r.Command.SetIn(inBuffer)
r.Command.SetOut(outBuffer)
err = r.Command.Execute()
if !assert.NoError(t, err) {
return
}
cleanOutput := stripansi.Strip(outBuffer.String())
tableOutput := parseTableOutput(t, cleanOutput)
expectedResource := ResourceIdentifier{
apiVersion: "apps/v1",
kind: "Deployment",
namespace: "default",
name: "bar",
}
expectedStatus := status.InProgressStatus
expectedMessage := "Deployment generation is 2, but latest observed generation is 1"
verifyOutputContains(t, tableOutput, expectedResource, expectedStatus, expectedMessage)
}
//nolint:funlen
func TestFetchStatusFromManifestsFiles(t *testing.T) {
d, err := ioutil.TempDir("", "status-fetch-test")
if !assert.NoError(t, err) {
return
}
defer os.RemoveAll(d)
err = ioutil.WriteFile(filepath.Join(d, "dep.yaml"), []byte(`
apiVersion: apps/v1
kind: Deployment
metadata:
name: foo
namespace: default
`), 0600)
if !assert.NoError(t, err) {
return
}
err = ioutil.WriteFile(filepath.Join(d, "svc.yaml"), []byte(`
apiVersion: v1
kind: Service
metadata:
name: foo
namespace: default
`), 0600)
if !assert.NoError(t, err) {
return
}
replicas := int32(42)
deployment := createDeployment("foo", "default", replicas, appsv1.DeploymentStatus{
ObservedGeneration: 2,
Replicas: replicas,
ReadyReplicas: replicas,
AvailableReplicas: replicas,
UpdatedReplicas: replicas,
Conditions: []appsv1.DeploymentCondition{
{
Type: appsv1.DeploymentAvailable,
Status: v1.ConditionTrue,
},
},
})
service := createService("foo", "default")
fakeClient := fake.NewFakeClientWithScheme(scheme, deployment, service)
outBuffer := &bytes.Buffer{}
r := GetFetchRunner()
r.createClientFunc = newClientFunc(fakeClient)
r.Command.SetArgs([]string{d})
r.Command.SetOut(outBuffer)
err = r.Command.Execute()
if !assert.NoError(t, err) {
return
}
cleanOutput := stripansi.Strip(outBuffer.String())
tableOutput := parseTableOutput(t, cleanOutput)
expectedDeploymentResource := ResourceIdentifier{
apiVersion: "apps/v1",
kind: "Deployment",
namespace: "default",
name: "foo",
}
expectedDeploymentStatus := status.CurrentStatus
expectedDeploymentMessage := "Deployment is available. Replicas: 42"
verifyOutputContains(t, tableOutput, expectedDeploymentResource, expectedDeploymentStatus, expectedDeploymentMessage)
expectedServiceResource := ResourceIdentifier{
apiVersion: "v1",
kind: "Service",
namespace: "default",
name: "foo",
}
expectedServiceStatus := status.CurrentStatus
expectedServiceMessage := "Service is ready"
verifyOutputContains(t, tableOutput, expectedServiceResource, expectedServiceStatus, expectedServiceMessage)
}
func createDeployment(name, namespace string, replicas int32, status appsv1.DeploymentStatus) *appsv1.Deployment {
return &appsv1.Deployment{
TypeMeta: metav1.TypeMeta{
APIVersion: "apps/v1",
Kind: "Deployment",
},
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
Generation: 2,
},
Spec: appsv1.DeploymentSpec{
Replicas: &replicas,
},
Status: status,
}
}
func createService(name, namespace string) *v1.Service {
return &v1.Service{
TypeMeta: metav1.TypeMeta{
APIVersion: "v1",
Kind: "Service",
},
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
},
}
}
func verifyOutputContains(t *testing.T, tableOutput TableOutput, resource ResourceIdentifier, status status.Status, message string) {
if len(tableOutput.Frames) == 0 {
t.Fatalf("expected match for resource %s, but output had no frames", resource.name)
}
firstFrame := tableOutput.Frames[0]
var foundResource ResourceOutput
match := false
for _, resourceOutput := range firstFrame.Resources {
if resourceOutput.identifier.Equals(resource) {
foundResource = resourceOutput
match = true
break
}
}
if !match {
t.Errorf("expected match for resource %s, but didn't find it", resource.name)
}
if want, got := status, foundResource.status; want != got {
t.Errorf("expected status %s for resource %s, but got %s", want, resource.name, got)
}
if want, got := message, foundResource.message; !strings.HasPrefix(want, got) {
t.Errorf("expected message %s for resource %s, but got %s", want, resource.name, got)
}
}

View File

@@ -0,0 +1,245 @@
package cmd
import (
"context"
"fmt"
"regexp"
"strings"
"testing"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/kustomize/kstatus/status"
)
type TableOutput struct {
Frames []TableOutputFrame
UnknownRows []string
}
func (t *TableOutput) allAggStatuses() []status.Status {
var statuses []status.Status
for _, frame := range t.Frames {
if frame.AggregateStatus != "" {
statuses = append(statuses, frame.AggregateStatus)
}
}
return statuses
}
func (t *TableOutput) dedupedAggStatuses() []status.Status {
var dedupedStatuses []status.Status
statuses := t.allAggStatuses()
var previousStatus status.Status
for _, s := range statuses {
if s != previousStatus {
dedupedStatuses = append(dedupedStatuses, s)
previousStatus = s
}
}
return dedupedStatuses
}
func (t *TableOutput) resources() []ResourceIdentifier {
seenResources := make(map[ResourceIdentifier]bool)
var resources []ResourceIdentifier
for _, frame := range t.Frames {
for _, resource := range frame.Resources {
r := resource.identifier
_, found := seenResources[r]
if !found {
seenResources[r] = true
resources = append(resources, r)
}
}
}
return resources
}
func (t *TableOutput) dedupedStatusesForResource(resource ResourceIdentifier) []status.Status {
var dedupedStatuses []status.Status
var previousStatus status.Status
for _, frame := range t.Frames {
for _, r := range frame.Resources {
if r.identifier.Equals(resource) {
if r.status != previousStatus {
previousStatus = r.status
dedupedStatuses = append(dedupedStatuses, r.status)
}
}
}
}
return dedupedStatuses
}
type TableOutputFrame struct {
AggregateStatus status.Status
Resources []ResourceOutput
}
type ResourceIdentifier struct {
apiVersion string
kind string
name string
namespace string
}
func (r ResourceIdentifier) Equals(identifier ResourceIdentifier) bool {
return r.apiVersion == identifier.apiVersion &&
r.kind == identifier.kind &&
r.namespace == identifier.namespace &&
r.name == identifier.name
}
type ResourceOutput struct {
identifier ResourceIdentifier
status status.Status
message string
}
var (
headerRegex = regexp.MustCompile(`^\s*TYPE\s+NAMESPACE\s+NAME\s+STATUS\s+MESSAGE\s*$`)
resourceRegex = regexp.MustCompile(`^(?P<resourceType>\S+)\s+(?P<namespace>\S+)\s+(?P<name>\S+)\s+(?P<status>\S+)\s+(?P<message>.*\S)\s*$`)
aggStatusRegex = regexp.MustCompile(`^\s*AggregateStatus: (?P<aggregateStatus>\S+)\s*$`)
)
func parseTableOutput(_ *testing.T, output string) TableOutput {
tableOutput := TableOutput{}
lines := strings.Split(output, "\n")
hasAggStatus := false
var currentFrame TableOutputFrame
for i, line := range lines {
if len(line) == 0 {
continue // We don't care about empty lines.
}
// Check for lines with aggregate status. They are not always present, but if they are,
// they always start a new frame of output.
match := aggStatusRegex.FindStringSubmatch(line)
if match != nil {
hasAggStatus = true
if i != 0 {
tableOutput.Frames = append(tableOutput.Frames, currentFrame)
}
currentFrame = TableOutputFrame{
AggregateStatus: status.FromStringOrDie(match[1]),
}
continue
}
match = headerRegex.FindStringSubmatch(line)
if match != nil {
if !hasAggStatus {
if i != 0 {
tableOutput.Frames = append(tableOutput.Frames, currentFrame)
}
currentFrame = TableOutputFrame{}
}
continue
}
match = resourceRegex.FindStringSubmatch(line)
if match != nil {
var identifier ResourceIdentifier
resourceType := match[1]
parts := strings.Split(resourceType, "/")
if len(parts) == 2 {
identifier.apiVersion = parts[0]
identifier.kind = parts[1]
} else {
identifier.apiVersion = strings.Join(parts[:2], "/")
identifier.kind = parts[2]
}
identifier.namespace = match[2]
identifier.name = match[3]
res := ResourceOutput{
identifier: identifier,
}
res.status = status.FromStringOrDie(match[4])
res.message = match[5]
currentFrame.Resources = append(currentFrame.Resources, res)
continue
}
tableOutput.UnknownRows = append(tableOutput.UnknownRows, line)
}
tableOutput.Frames = append(tableOutput.Frames, currentFrame)
return tableOutput
}
func createDeploymentStatusFunc() func(*unstructured.Unstructured) error {
metadataMap := map[string]interface{}{
"generation": int64(2),
}
specMap := map[string]interface{}{
"replicas": int64(2),
}
statusMap := map[string]interface{}{
"observedGeneration": int64(2),
"replicas": int64(4),
"updatedReplicas": int64(4),
"readyReplicas": int64(4),
}
var conditions = make([]interface{}, 0)
conditions = append(conditions, map[string]interface{}{
"type": "Available",
"status": "True",
})
callbackCount := int64(0)
return func(deployment *unstructured.Unstructured) error {
_ = unstructured.SetNestedMap(deployment.Object, metadataMap, "metadata")
_ = unstructured.SetNestedMap(deployment.Object, specMap, "spec")
statusMap["availableReplicas"] = callbackCount
_ = unstructured.SetNestedMap(deployment.Object, statusMap, "status")
_ = unstructured.SetNestedSlice(deployment.Object, conditions, "status", "conditions")
callbackCount++
return nil
}
}
func createServiceStatusFunc() func(*unstructured.Unstructured) error {
return func(*unstructured.Unstructured) error {
return nil
}
}
func createPodStatusFunc() func(*unstructured.Unstructured) error {
statusMap := map[string]interface{}{
"phase": "Succeeded",
}
return func(pod *unstructured.Unstructured) error {
_ = unstructured.SetNestedMap(pod.Object, statusMap, "status")
return nil
}
}
type ResourceGetCallback func(resource *unstructured.Unstructured) error
type FakeClient struct {
resourceCallbackMap map[string]ResourceGetCallback
}
func (f *FakeClient) Get(_ context.Context, _ client.ObjectKey, obj runtime.Object) error {
kind := obj.GetObjectKind().GroupVersionKind().Kind
callbackFunc, found := f.resourceCallbackMap[kind]
if !found {
return fmt.Errorf("no callback func found for kind %s", kind)
}
u := obj.(*unstructured.Unstructured)
return callbackFunc(u)
}
func (f *FakeClient) List(ctx context.Context, list runtime.Object, opts ...client.ListOption) error {
return nil
}
func joinStatuses(statuses []status.Status) string {
var stringStatuses []string
for _, s := range statuses {
stringStatuses = append(stringStatuses, s.String())
}
return strings.Join(stringStatuses, ",")
}

View File

@@ -179,10 +179,13 @@ func (s *TablePrinter) printTable(data StatusData, deleteUp bool) {
} }
func (s *TablePrinter) printTableRow(rowData []RowData) { func (s *TablePrinter) printTableRow(rowData []RowData) {
for _, row := range rowData { for i, row := range rowData {
setColor(s.out, row.color) setColor(s.out, row.color)
format := fmt.Sprintf("%%-%ds ", row.width) format := fmt.Sprintf("%%-%ds", row.width)
printOrDie(s.out, format, trimString(row.content, row.width)) printOrDie(s.out, format, trimString(row.content, row.width))
if i != len(rowData)-1 {
printOrDie(s.out, " ")
}
setColor(s.out, WHITE) setColor(s.out, WHITE)
} }
printOrDie(s.out, "\n") printOrDie(s.out, "\n")

View File

@@ -22,9 +22,11 @@ func init() {
_ = clientgoscheme.AddToScheme(scheme) _ = clientgoscheme.AddToScheme(scheme)
} }
// getClient returns a client for talking to a Kubernetes cluster. The client type createClientFunc func() (client.Reader, error)
// createClient returns a client for talking to a Kubernetes cluster. The client
// is from controller-runtime. // is from controller-runtime.
func getClient() (client.Client, error) { func createClient() (client.Reader, error) {
config := ctrl.GetConfigOrDie() config := ctrl.GetConfigOrDie()
mapper, err := apiutil.NewDiscoveryRESTMapper(config) mapper, err := apiutil.NewDiscoveryRESTMapper(config)
if err != nil { if err != nil {
@@ -33,6 +35,12 @@ func getClient() (client.Client, error) {
return client.New(config, client.Options{Scheme: scheme, Mapper: mapper}) return client.New(config, client.Options{Scheme: scheme, Mapper: mapper})
} }
func newClientFunc(c client.Reader) func() (client.Reader, error) {
return func() (client.Reader, error) {
return c, nil
}
}
// CaptureIdentifiersFilter implements the Filter interface in the kio package. It // CaptureIdentifiersFilter implements the Filter interface in the kio package. It
// captures the identifiers for all resources passed through the pipeline. // captures the identifiers for all resources passed through the pipeline.
type CaptureIdentifiersFilter struct { type CaptureIdentifiersFilter struct {

View File

@@ -18,7 +18,9 @@ import (
// GetWaitRunner return a command WaitRunner. // GetWaitRunner return a command WaitRunner.
func GetWaitRunner() *WaitRunner { func GetWaitRunner() *WaitRunner {
r := &WaitRunner{} r := &WaitRunner{
createClientFunc: createClient,
}
c := &cobra.Command{ c := &cobra.Command{
Use: "wait DIR...", Use: "wait DIR...",
Short: commands.WaitShort, Short: commands.WaitShort,
@@ -48,6 +50,8 @@ type WaitRunner struct {
Interval time.Duration Interval time.Duration
Timeout time.Duration Timeout time.Duration
Command *cobra.Command Command *cobra.Command
createClientFunc createClientFunc
} }
// runE implements the logic of the command and will call the Wait command in the wait // runE implements the logic of the command and will call the Wait command in the wait
@@ -55,7 +59,7 @@ type WaitRunner struct {
// TablePrinter to display the information. // TablePrinter to display the information.
func (r *WaitRunner) runE(c *cobra.Command, args []string) error { func (r *WaitRunner) runE(c *cobra.Command, args []string) error {
ctx := context.Background() ctx := context.Background()
client, err := getClient() client, err := r.createClientFunc()
if err != nil { if err != nil {
return errors.Wrap(err, "error creating client") return errors.Wrap(err, "error creating client")
} }

View File

@@ -0,0 +1,176 @@
package cmd
import (
"bytes"
"fmt"
"reflect"
"testing"
"github.com/acarl005/stripansi"
"github.com/stretchr/testify/assert"
"sigs.k8s.io/kustomize/kstatus/status"
)
func TestWaitNoResources(t *testing.T) {
inBuffer := &bytes.Buffer{}
outBuffer := &bytes.Buffer{}
fakeClient := &FakeClient{}
r := GetWaitRunner()
r.createClientFunc = newClientFunc(fakeClient)
r.Command.SetArgs([]string{})
r.Command.SetIn(inBuffer)
r.Command.SetOut(outBuffer)
err := r.Command.Execute()
if !assert.NoError(t, err) {
return
}
cleanOutput := stripansi.Strip(outBuffer.String())
tableOutput := parseTableOutput(t, cleanOutput)
if want, got := 2, len(tableOutput.Frames); want != got {
t.Errorf("expected %d frames, but found %d", want, got)
}
aggStatuses := tableOutput.allAggStatuses()
expectedAggStatuses := []status.Status{
status.CurrentStatus,
status.CurrentStatus,
}
if !reflect.DeepEqual(aggStatuses, expectedAggStatuses) {
t.Errorf("expected agg statuses to be %s, but got %s", joinStatuses(expectedAggStatuses),
joinStatuses(aggStatuses))
}
resources := tableOutput.resources()
if want, got := 0, len(resources); want != got {
t.Errorf("expected %d resources, but found %d", want, got)
}
}
func TestWaitMultipleUpdates(t *testing.T) {
inBuffer := &bytes.Buffer{}
outBuffer := &bytes.Buffer{}
_, err := fmt.Fprint(inBuffer, `
apiVersion: apps/v1
kind: Deployment
metadata:
name: bar
namespace: default
`)
if !assert.NoError(t, err) {
return
}
fakeClient := &FakeClient{
resourceCallbackMap: map[string]ResourceGetCallback{
"Deployment": createDeploymentStatusFunc(),
},
}
r := GetWaitRunner()
r.createClientFunc = newClientFunc(fakeClient)
r.Command.SetArgs([]string{})
r.Command.SetIn(inBuffer)
r.Command.SetOut(outBuffer)
err = r.Command.Execute()
if !assert.NoError(t, err) {
return
}
cleanOutput := stripansi.Strip(outBuffer.String())
tableOutput := parseTableOutput(t, cleanOutput)
aggStatuses := tableOutput.dedupedAggStatuses()
expectedStatuses := []status.Status{
status.UnknownStatus,
status.InProgressStatus,
status.CurrentStatus,
}
if !reflect.DeepEqual(aggStatuses, expectedStatuses) {
t.Errorf("expected deduped agg statuses to be %s, but got %s", joinStatuses(expectedStatuses),
joinStatuses(aggStatuses))
}
resources := tableOutput.resources()
if want, got := 1, len(resources); got != want {
t.Errorf("expected %d resource, but got %d", want, got)
}
resource := resources[0]
resourceStatuses := tableOutput.dedupedStatusesForResource(resource)
expectedResourceStatuses := []status.Status{
status.InProgressStatus,
status.CurrentStatus,
}
if !reflect.DeepEqual(expectedResourceStatuses, resourceStatuses) {
t.Errorf("expected resource %q to have statuses %s, but got %s", resource.name,
joinStatuses(expectedResourceStatuses), joinStatuses(resourceStatuses))
}
}
func TestWaitMultipleResources(t *testing.T) {
inBuffer := &bytes.Buffer{}
outBuffer := &bytes.Buffer{}
_, err := fmt.Fprint(inBuffer, `
apiVersion: v1
kind: List
items:
- apiVersion: v1
kind: Pod
metadata:
name: foo
namespace: default
- apiVersion: v1
kind: Service
metadata:
name: bar
namespace: default
`)
if !assert.NoError(t, err) {
return
}
fakeClient := &FakeClient{
resourceCallbackMap: map[string]ResourceGetCallback{
"Pod": createPodStatusFunc(),
"Service": createServiceStatusFunc(),
},
}
r := GetWaitRunner()
r.createClientFunc = newClientFunc(fakeClient)
r.Command.SetArgs([]string{})
r.Command.SetIn(inBuffer)
r.Command.SetOut(outBuffer)
err = r.Command.Execute()
if !assert.NoError(t, err) {
return
}
cleanOutput := stripansi.Strip(outBuffer.String())
tableOutput := parseTableOutput(t, cleanOutput)
aggStatuses := tableOutput.dedupedAggStatuses()
if want, got := status.CurrentStatus, aggStatuses[len(aggStatuses)-1]; want != got {
t.Errorf("expected final agg statuses to be %s, but got %s", want, got)
}
resources := tableOutput.resources()
if want, got := 2, len(resources); got != want {
t.Errorf("expected %d resource, but got %d", want, got)
}
for _, resource := range resources {
resourceStatuses := tableOutput.dedupedStatusesForResource(resource)
if want, got := status.CurrentStatus, resourceStatuses[len(resourceStatuses)-1]; want != got {
t.Errorf("expected resource %q to have final status %s, but got %s", resource.name, want, got)
}
}
}

View File

@@ -1,10 +1,8 @@
// Code generated by "mdtogo"; DO NOT EDIT. // Code generated by "mdtogo"; DO NOT EDIT.
package commands package commands
var EventsShort=`[Alpha] Poll the cluster until all provided resources have become Current and list the status change events.` var EventsShort = `[Alpha] Poll the cluster until all provided resources have become Current and list the status change events.`
var EventsLong=` var EventsLong = `
[Alpha] Poll the cluster for the state of all the provided resources until either they have all become [Alpha] Poll the cluster for the state of all the provided resources until either they have all become
Current or the timeout is reached. The output will be status change events. Current or the timeout is reached. The output will be status change events.
@@ -14,15 +12,15 @@ on StdIn.
DIR: DIR:
Path to local directory. If not provided, input is expected on StdIn. Path to local directory. If not provided, input is expected on StdIn.
` `
var EventsExamples=` var EventsExamples = `
# Read resources from the filesystem and wait up to 1 minute for all of them to become Current # Read resources from the filesystem and wait up to 1 minute for all of them to become Current
resource status events my-dir/ resource status events my-dir/
# Fetch all resources in the cluster and wait up to 5 minutes for all of them to become Current # Fetch all resources in the cluster and wait up to 5 minutes for all of them to become Current
kubectl get all --all-namespaces -oyaml | resource status events --timeout=5m` kubectl get all --all-namespaces -oyaml | resource status events --timeout=5m`
var FetchShort=`[Alpha] Fetch the state of the provided resources from the cluster and display status in a table.` var FetchShort = `[Alpha] Fetch the state of the provided resources from the cluster and display status in a table.`
var FetchLong=` var FetchLong = `
[Alpha] Fetches the state of all provided resources from the cluster and displays the status in [Alpha] Fetches the state of all provided resources from the cluster and displays the status in
a table. a table.
@@ -31,15 +29,15 @@ The list of resources are provided as manifests either on the filesystem or on S
DIR: DIR:
Path to local directory. Path to local directory.
` `
var FetchExamples=` var FetchExamples = `
# Read resources from the filesystem and wait up to 1 minute for all of them to become Current # Read resources from the filesystem and wait up to 1 minute for all of them to become Current
resource status fetch my-dir/ resource status fetch my-dir/
# Fetch all resources in the cluster and wait up to 5 minutes for all of them to become Current # Fetch all resources in the cluster and wait up to 5 minutes for all of them to become Current
kubectl get all --all-namespaces -oyaml | resource status fetch` kubectl get all --all-namespaces -oyaml | resource status fetch`
var WaitShort=`[Alpha] Poll the cluster until all provided resources have become Current and display progress in a table. ` var WaitShort = `[Alpha] Poll the cluster until all provided resources have become Current and display progress in a table. `
var WaitLong=` var WaitLong = `
[Alpha] Poll the cluster for the state of all the provided resources until either they have all become [Alpha] Poll the cluster for the state of all the provided resources until either they have all become
Current or the timeout is reached. The output will be presented as a table. Current or the timeout is reached. The output will be presented as a table.
@@ -49,7 +47,7 @@ on StdIn.
DIR: DIR:
Path to local directory. If not provided, input is expected on StdIn. Path to local directory. If not provided, input is expected on StdIn.
` `
var WaitExamples=` var WaitExamples = `
# Read resources from the filesystem and wait up to 1 minute for all of them to become Current # Read resources from the filesystem and wait up to 1 minute for all of them to become Current
resource status wait my-dir/ resource status wait my-dir/

View File

@@ -4,6 +4,7 @@
package status package status
import ( import (
"fmt"
"time" "time"
"github.com/pkg/errors" "github.com/pkg/errors"
@@ -26,6 +27,11 @@ const (
UnknownStatus Status = "Unknown" UnknownStatus Status = "Unknown"
) )
var (
Statuses = []Status{InProgressStatus, FailedStatus, CurrentStatus, TerminatingStatus, UnknownStatus}
ConditionTypes = []ConditionType{ConditionFailed, ConditionInProgress}
)
// ConditionType defines the set of condition types allowed inside a Condition struct. // ConditionType defines the set of condition types allowed inside a Condition struct.
type ConditionType string type ConditionType string
@@ -42,6 +48,18 @@ func (s Status) String() string {
return string(s) return string(s)
} }
// StatusFromString turns a string into a Status. Will panic if the provided string is
// not a valid status.
func FromStringOrDie(text string) Status {
s := Status(text)
for _, r := range Statuses {
if s == r {
return s
}
}
panic(fmt.Errorf("string has invalid status: %s", s))
}
// Result contains the results of a call to compute the status of // Result contains the results of a call to compute the status of
// a resource. // a resource.
type Result struct { type Result struct {