From f0d81c4facd992126557a27334b138e631401e96 Mon Sep 17 00:00:00 2001 From: Morten Torkildsen Date: Mon, 30 Dec 2019 21:38:06 -0800 Subject: [PATCH] Add tests for status cli --- cmd/resource/.golangci.yml | 2 +- cmd/resource/go.mod | 3 + cmd/resource/go.sum | 4 + cmd/resource/status/cmd/events.go | 8 +- cmd/resource/status/cmd/events_test.go | 298 ++++++++++++++++++ cmd/resource/status/cmd/fetch.go | 12 +- cmd/resource/status/cmd/fetch_test.go | 230 ++++++++++++++ cmd/resource/status/cmd/helpers_test.go | 245 ++++++++++++++ cmd/resource/status/cmd/print.go | 7 +- cmd/resource/status/cmd/util.go | 12 +- cmd/resource/status/cmd/wait.go | 8 +- cmd/resource/status/cmd/wait_test.go | 176 +++++++++++ .../status/generateddocs/commands/docs.go | 20 +- kstatus/status/status.go | 18 ++ 14 files changed, 1019 insertions(+), 24 deletions(-) create mode 100644 cmd/resource/status/cmd/events_test.go create mode 100644 cmd/resource/status/cmd/fetch_test.go create mode 100644 cmd/resource/status/cmd/helpers_test.go create mode 100644 cmd/resource/status/cmd/wait_test.go diff --git a/cmd/resource/.golangci.yml b/cmd/resource/.golangci.yml index 748d70256..822522f88 100644 --- a/cmd/resource/.golangci.yml +++ b/cmd/resource/.golangci.yml @@ -15,7 +15,7 @@ linters: - dogsled - dupl - errcheck - - funlen + # - funlen # - gochecknoinits - goconst - gocritic diff --git a/cmd/resource/go.mod b/cmd/resource/go.mod index 696f1b1f6..5275e19b3 100644 --- a/cmd/resource/go.mod +++ b/cmd/resource/go.mod @@ -3,9 +3,12 @@ module sigs.k8s.io/kustomize/cmd/resource go 1.12 require ( + github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d github.com/pkg/errors v0.8.1 github.com/spf13/cobra v0.0.5 + github.com/stretchr/testify v1.4.0 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/client-go v0.0.0-20190918160344-1fbdaa4c8d90 sigs.k8s.io/controller-runtime v0.4.0 diff --git a/cmd/resource/go.sum b/cmd/resource/go.sum index e3033136c..75b7ebecd 100644 --- a/cmd/resource/go.sum +++ b/cmd/resource/go.sum @@ -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/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/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/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= @@ -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-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-20190502173448-54afdca5d873 h1:nfPFGzJkUDX6uBmpN/pSw7MbOAWegH5QDQuoXFHedLg= 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.23.0 h1:AzbTB6ux+okLTzP8Ru1Xs41C303zdcfEht7MQnYJt5A= 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 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/cmd/resource/status/cmd/events.go b/cmd/resource/status/cmd/events.go index 9102f707a..818f2d333 100644 --- a/cmd/resource/status/cmd/events.go +++ b/cmd/resource/status/cmd/events.go @@ -16,7 +16,9 @@ import ( // GetEventsRunner returns a command EventsRunner. func GetEventsRunner() *EventsRunner { - r := &EventsRunner{} + r := &EventsRunner{ + createClientFunc: createClient, + } c := &cobra.Command{ Use: "events DIR...", Short: commands.EventsShort, @@ -46,13 +48,15 @@ type EventsRunner struct { Interval time.Duration Timeout time.Duration Command *cobra.Command + + createClientFunc createClientFunc } func (r *EventsRunner) runE(c *cobra.Command, args []string) error { ctx := context.Background() // Create a client and use it to set up a new resolver. - client, err := getClient() + client, err := r.createClientFunc() if err != nil { return errors.Wrap(err, "error creating client") } diff --git a/cmd/resource/status/cmd/events_test.go b/cmd/resource/status/cmd/events_test.go new file mode 100644 index 000000000..ee3225079 --- /dev/null +++ b/cmd/resource/status/cmd/events_test.go @@ -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\S+)\s+` + + `(?P\S+)\s+` + + `((?P\S+)\s+` + + `(?P\S+)\s+` + + `(?P\S+)\s+` + + `(?P\S+)\s+` + + `(?P.*\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 +} diff --git a/cmd/resource/status/cmd/fetch.go b/cmd/resource/status/cmd/fetch.go index d49bd0f68..8bc100766 100644 --- a/cmd/resource/status/cmd/fetch.go +++ b/cmd/resource/status/cmd/fetch.go @@ -17,7 +17,9 @@ import ( // GetFetchRunner returns a command FetchRunner. func GetFetchRunner() *FetchRunner { - r := &FetchRunner{} + r := &FetchRunner{ + createClientFunc: createClient, + } c := &cobra.Command{ Use: "fetch DIR...", Short: commands.FetchShort, @@ -41,18 +43,20 @@ func FetchCommand() *cobra.Command { type FetchRunner struct { IncludeSubpackages bool Command *cobra.Command + + createClientFunc createClientFunc } func (r *FetchRunner) runE(c *cobra.Command, args []string) error { ctx := context.Background() // Create a new client and use it to set up a resolver. - client, err := getClient() + k8sClient, err := r.createClientFunc() 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 // filter with the pipeline to capture the inventory of resources diff --git a/cmd/resource/status/cmd/fetch_test.go b/cmd/resource/status/cmd/fetch_test.go new file mode 100644 index 000000000..61a5259c4 --- /dev/null +++ b/cmd/resource/status/cmd/fetch_test.go @@ -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) + } +} diff --git a/cmd/resource/status/cmd/helpers_test.go b/cmd/resource/status/cmd/helpers_test.go new file mode 100644 index 000000000..30fb02bcb --- /dev/null +++ b/cmd/resource/status/cmd/helpers_test.go @@ -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\S+)\s+(?P\S+)\s+(?P\S+)\s+(?P\S+)\s+(?P.*\S)\s*$`) + aggStatusRegex = regexp.MustCompile(`^\s*AggregateStatus: (?P\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, ",") +} diff --git a/cmd/resource/status/cmd/print.go b/cmd/resource/status/cmd/print.go index c729c589b..f1171a710 100644 --- a/cmd/resource/status/cmd/print.go +++ b/cmd/resource/status/cmd/print.go @@ -179,10 +179,13 @@ func (s *TablePrinter) printTable(data StatusData, deleteUp bool) { } func (s *TablePrinter) printTableRow(rowData []RowData) { - for _, row := range rowData { + for i, row := range rowData { 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)) + if i != len(rowData)-1 { + printOrDie(s.out, " ") + } setColor(s.out, WHITE) } printOrDie(s.out, "\n") diff --git a/cmd/resource/status/cmd/util.go b/cmd/resource/status/cmd/util.go index 962a8de2e..be3f91523 100644 --- a/cmd/resource/status/cmd/util.go +++ b/cmd/resource/status/cmd/util.go @@ -22,9 +22,11 @@ func init() { _ = 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. -func getClient() (client.Client, error) { +func createClient() (client.Reader, error) { config := ctrl.GetConfigOrDie() mapper, err := apiutil.NewDiscoveryRESTMapper(config) if err != nil { @@ -33,6 +35,12 @@ func getClient() (client.Client, error) { 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 // captures the identifiers for all resources passed through the pipeline. type CaptureIdentifiersFilter struct { diff --git a/cmd/resource/status/cmd/wait.go b/cmd/resource/status/cmd/wait.go index f19840f45..0688b6436 100644 --- a/cmd/resource/status/cmd/wait.go +++ b/cmd/resource/status/cmd/wait.go @@ -18,7 +18,9 @@ import ( // GetWaitRunner return a command WaitRunner. func GetWaitRunner() *WaitRunner { - r := &WaitRunner{} + r := &WaitRunner{ + createClientFunc: createClient, + } c := &cobra.Command{ Use: "wait DIR...", Short: commands.WaitShort, @@ -48,6 +50,8 @@ type WaitRunner struct { Interval time.Duration Timeout time.Duration Command *cobra.Command + + createClientFunc createClientFunc } // 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. func (r *WaitRunner) runE(c *cobra.Command, args []string) error { ctx := context.Background() - client, err := getClient() + client, err := r.createClientFunc() if err != nil { return errors.Wrap(err, "error creating client") } diff --git a/cmd/resource/status/cmd/wait_test.go b/cmd/resource/status/cmd/wait_test.go new file mode 100644 index 000000000..355fe7510 --- /dev/null +++ b/cmd/resource/status/cmd/wait_test.go @@ -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) + } + } +} diff --git a/cmd/resource/status/generateddocs/commands/docs.go b/cmd/resource/status/generateddocs/commands/docs.go index 9ba6e621f..0b5d3b5fb 100644 --- a/cmd/resource/status/generateddocs/commands/docs.go +++ b/cmd/resource/status/generateddocs/commands/docs.go @@ -1,10 +1,8 @@ - - // Code generated by "mdtogo"; DO NOT EDIT. package commands -var EventsShort=`[Alpha] Poll the cluster until all provided resources have become Current and list the status change events.` -var EventsLong=` +var EventsShort = `[Alpha] Poll the cluster until all provided resources have become Current and list the status change events.` +var EventsLong = ` [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. @@ -14,15 +12,15 @@ on StdIn. DIR: 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 resource status events my-dir/ # 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` -var FetchShort=`[Alpha] Fetch the state of the provided resources from the cluster and display status in a table.` -var FetchLong=` +var FetchShort = `[Alpha] Fetch the state of the provided resources from the cluster and display status in a table.` +var FetchLong = ` [Alpha] Fetches the state of all provided resources from the cluster and displays the status in a table. @@ -31,15 +29,15 @@ The list of resources are provided as manifests either on the filesystem or on S DIR: 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 resource status fetch my-dir/ # 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` -var WaitShort=`[Alpha] Poll the cluster until all provided resources have become Current and display progress in a table. ` -var WaitLong=` +var WaitShort = `[Alpha] Poll the cluster until all provided resources have become Current and display progress in a table. ` +var WaitLong = ` [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. @@ -49,7 +47,7 @@ on StdIn. DIR: 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 resource status wait my-dir/ diff --git a/kstatus/status/status.go b/kstatus/status/status.go index 62741f828..80b7a00d1 100644 --- a/kstatus/status/status.go +++ b/kstatus/status/status.go @@ -4,6 +4,7 @@ package status import ( + "fmt" "time" "github.com/pkg/errors" @@ -26,6 +27,11 @@ const ( 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. type ConditionType string @@ -42,6 +48,18 @@ func (s Status) String() string { 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 // a resource. type Result struct {