diff --git a/cmd/status/cmdstatus_test.go b/cmd/status/cmdstatus_test.go index 47702b8b..ab4d4b6b 100644 --- a/cmd/status/cmdstatus_test.go +++ b/cmd/status/cmdstatus_test.go @@ -6,6 +6,7 @@ package status import ( "bytes" "context" + "encoding/json" "strings" "testing" "time" @@ -53,6 +54,23 @@ metadata: } ) +type fakePoller struct { + events []pollevent.Event +} + +func (f *fakePoller) Poll(ctx context.Context, _ object.ObjMetadataSet, + _ polling.PollOptions) <-chan pollevent.Event { + eventChannel := make(chan pollevent.Event) + go func() { + defer close(eventChannel) + for _, e := range f.events { + eventChannel <- e + } + <-ctx.Done() + }() + return eventChannel +} + func TestCommand(t *testing.T) { testCases := map[string]struct { pollUntil string @@ -217,6 +235,261 @@ foo/deployment.apps/default/foo is InProgress: inProgress }, } + jsonTestCases := map[string]struct { + pollUntil string + printer string + timeout time.Duration + input string + inventory object.ObjMetadataSet + events []pollevent.Event + expectedErrMsg string + expectedOutput []map[string]interface{} + }{ + "wait for all known json": { + pollUntil: "known", + printer: "json", + input: inventoryTemplate, + inventory: object.ObjMetadataSet{ + depObject, + stsObject, + }, + events: []pollevent.Event{ + { + Type: pollevent.ResourceUpdateEvent, + Resource: &pollevent.ResourceStatus{ + Identifier: depObject, + Status: status.InProgressStatus, + Message: "inProgress", + }, + }, + { + Type: pollevent.ResourceUpdateEvent, + Resource: &pollevent.ResourceStatus{ + Identifier: stsObject, + Status: status.CurrentStatus, + Message: "current", + }, + }, + }, + expectedOutput: []map[string]interface{}{ + { + "group": "apps", + "kind": "Deployment", + "namespace": "default", + "name": "foo", + "timestamp": "", + "type": "status", + "inventory-name": "foo", + "status": "InProgress", + "message": "inProgress", + }, + { + "group": "apps", + "kind": "StatefulSet", + "namespace": "default", + "name": "bar", + "timestamp": "", + "type": "status", + "inventory-name": "foo", + "status": "Current", + "message": "current", + }, + }, + }, + "wait for all current json": { + pollUntil: "current", + printer: "json", + input: inventoryTemplate, + inventory: object.ObjMetadataSet{ + depObject, + stsObject, + }, + events: []pollevent.Event{ + { + Type: pollevent.ResourceUpdateEvent, + Resource: &pollevent.ResourceStatus{ + Identifier: depObject, + Status: status.InProgressStatus, + Message: "inProgress", + }, + }, + { + Type: pollevent.ResourceUpdateEvent, + Resource: &pollevent.ResourceStatus{ + Identifier: stsObject, + Status: status.InProgressStatus, + Message: "inProgress", + }, + }, + { + Type: pollevent.ResourceUpdateEvent, + Resource: &pollevent.ResourceStatus{ + Identifier: stsObject, + Status: status.CurrentStatus, + Message: "current", + }, + }, + { + Type: pollevent.ResourceUpdateEvent, + Resource: &pollevent.ResourceStatus{ + Identifier: depObject, + Status: status.CurrentStatus, + Message: "current", + }, + }, + }, + expectedOutput: []map[string]interface{}{ + { + "group": "apps", + "kind": "Deployment", + "namespace": "default", + "name": "foo", + "timestamp": "", + "type": "status", + "inventory-name": "foo", + "status": "InProgress", + "message": "inProgress", + }, + { + "group": "apps", + "kind": "StatefulSet", + "namespace": "default", + "name": "bar", + "timestamp": "", + "type": "status", + "inventory-name": "foo", + "status": "InProgress", + "message": "inProgress", + }, + { + "group": "apps", + "kind": "StatefulSet", + "namespace": "default", + "name": "bar", + "timestamp": "", + "type": "status", + "inventory-name": "foo", + "status": "Current", + "message": "current", + }, + { + "group": "apps", + "kind": "Deployment", + "namespace": "default", + "name": "foo", + "timestamp": "", + "type": "status", + "inventory-name": "foo", + "status": "Current", + "message": "current", + }, + }, + }, + "wait for all deleted json": { + pollUntil: "deleted", + printer: "json", + input: inventoryTemplate, + inventory: object.ObjMetadataSet{ + depObject, + stsObject, + }, + events: []pollevent.Event{ + { + Type: pollevent.ResourceUpdateEvent, + Resource: &pollevent.ResourceStatus{ + Identifier: stsObject, + Status: status.NotFoundStatus, + Message: "notFound", + }, + }, + { + Type: pollevent.ResourceUpdateEvent, + Resource: &pollevent.ResourceStatus{ + Identifier: depObject, + Status: status.NotFoundStatus, + Message: "notFound", + }, + }, + }, + expectedOutput: []map[string]interface{}{ + { + "group": "apps", + "kind": "StatefulSet", + "namespace": "default", + "name": "bar", + "timestamp": "", + "type": "status", + "inventory-name": "foo", + "status": "NotFound", + "message": "notFound", + }, + { + "group": "apps", + "kind": "Deployment", + "namespace": "default", + "name": "foo", + "timestamp": "", + "type": "status", + "inventory-name": "foo", + "status": "NotFound", + "message": "notFound", + }, + }, + }, + "forever with timeout json": { + pollUntil: "forever", + printer: "json", + timeout: 2 * time.Second, + input: inventoryTemplate, + inventory: object.ObjMetadataSet{ + depObject, + stsObject, + }, + events: []pollevent.Event{ + { + Type: pollevent.ResourceUpdateEvent, + Resource: &pollevent.ResourceStatus{ + Identifier: stsObject, + Status: status.InProgressStatus, + Message: "inProgress", + }, + }, + { + Type: pollevent.ResourceUpdateEvent, + Resource: &pollevent.ResourceStatus{ + Identifier: depObject, + Status: status.InProgressStatus, + Message: "inProgress", + }, + }, + }, + expectedOutput: []map[string]interface{}{ + { + "group": "apps", + "kind": "StatefulSet", + "namespace": "default", + "name": "bar", + "timestamp": "", + "type": "status", + "inventory-name": "foo", + "status": "InProgress", + "message": "inProgress", + }, + { + "group": "apps", + "kind": "Deployment", + "namespace": "default", + "name": "foo", + "timestamp": "", + "type": "status", + "inventory-name": "foo", + "status": "InProgress", + "message": "inProgress", + }, + }, + }, + } + for tn, tc := range testCases { t.Run(tn, func(t *testing.T) { tf := cmdtesting.NewTestFactory().WithNamespace("namespace") @@ -258,21 +531,75 @@ foo/deployment.apps/default/foo is InProgress: inProgress assert.Equal(t, strings.TrimSpace(buf.String()), strings.TrimSpace(tc.expectedOutput)) }) } -} -type fakePoller struct { - events []pollevent.Event + for tn, tc := range jsonTestCases { + t.Run(tn, func(t *testing.T) { + tf := cmdtesting.NewTestFactory().WithNamespace("namespace") + defer tf.Cleanup() + + loader := manifestreader.NewFakeLoader(tf, tc.inventory) + runner := &Runner{ + factory: tf, + invFactory: inventory.FakeClientFactory(tc.inventory), + loader: loader, + pollerFactoryFunc: func(c cmdutil.Factory) (poller.Poller, error) { + return &fakePoller{tc.events}, nil + }, + + pollUntil: tc.pollUntil, + output: tc.printer, + timeout: tc.timeout, + } + + cmd := &cobra.Command{ + RunE: runner.runE, + } + cmd.SetIn(strings.NewReader(tc.input)) + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetArgs([]string{}) + + err := cmd.Execute() + if tc.expectedErrMsg != "" { + if !assert.Error(t, err) { + t.FailNow() + } + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + assert.NoError(t, err) + actual := strings.Split(buf.String(), "\n") + assertOutput(t, tc.expectedOutput, actual) + }) + } } -func (f *fakePoller) Poll(ctx context.Context, _ object.ObjMetadataSet, - _ polling.PollOptions) <-chan pollevent.Event { - eventChannel := make(chan pollevent.Event) - go func() { - defer close(eventChannel) - for _, e := range f.events { - eventChannel <- e +// nolint:unparam +func assertOutput(t *testing.T, expectedOutput []map[string]interface{}, actual []string) bool { + for i, expectedMap := range expectedOutput { + if len(expectedMap) == 0 { + return assert.Empty(t, actual[i]) } - <-ctx.Done() - }() - return eventChannel + + var m map[string]interface{} + err := json.Unmarshal([]byte(actual[i]), &m) + if !assert.NoError(t, err) { + return false + } + + if _, found := expectedMap["timestamp"]; found { + if _, ok := m["timestamp"]; ok { + delete(expectedMap, "timestamp") + delete(m, "timestamp") + } else { + t.Error("expected to find key 'timestamp', but didn't") + return false + } + } + if !assert.Equal(t, expectedMap, m) { + return false + } + } + return true } diff --git a/cmd/status/printers/json/printer.go b/cmd/status/printers/json/printer.go new file mode 100644 index 00000000..f644c029 --- /dev/null +++ b/cmd/status/printers/json/printer.go @@ -0,0 +1,109 @@ +// Copyright 2020 The Kubernetes Authors. +// SPDX-License-Identifier: Apache-2.0 + +package json + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "k8s.io/cli-runtime/pkg/genericclioptions" + "sigs.k8s.io/cli-utils/cmd/status/printers/printer" + "sigs.k8s.io/cli-utils/pkg/apply/event" + "sigs.k8s.io/cli-utils/pkg/common" + "sigs.k8s.io/cli-utils/pkg/kstatus/polling/collector" + pollevent "sigs.k8s.io/cli-utils/pkg/kstatus/polling/event" + "sigs.k8s.io/cli-utils/pkg/object" + "sigs.k8s.io/cli-utils/pkg/print/list" + jsonprinter "sigs.k8s.io/cli-utils/pkg/printers/json" +) + +// Printer implements the Printer interface and outputs the resource +// status information as a list of events as they happen. +type Printer struct { + Formatter list.Formatter + IOStreams genericclioptions.IOStreams + Data *printer.PrintData +} + +// NewPrinter returns a new instance of the eventPrinter. +func NewPrinter(ioStreams genericclioptions.IOStreams, printData *printer.PrintData) *Printer { + return &Printer{ + Formatter: jsonprinter.NewFormatter(ioStreams, common.DryRunNone), + IOStreams: ioStreams, + Data: printData, + } +} + +// Print takes an event channel and outputs the status events on the channel +// until the channel is closed. The provided cancelFunc is consulted on +// every event and is responsible for stopping the poller when appropriate. +// This function will block. +func (ep *Printer) Print(ch <-chan pollevent.Event, identifiers object.ObjMetadataSet, + cancelFunc collector.ObserverFunc) error { + coll := collector.NewResourceStatusCollector(identifiers) + // The actual work is done by the collector, which will invoke the + // callback on every event. In the callback we print the status + // information and call the cancelFunc which is responsible for + // stopping the poller at the correct time. + done := coll.ListenWithObserver(ch, collector.ObserverFunc( + func(statusCollector *collector.ResourceStatusCollector, e pollevent.Event) { + err := ep.printStatusEvent(e) + if err != nil { + panic(err) + } + cancelFunc(statusCollector, e) + }), + ) + // Listen to the channel until it is closed. + var err error + for msg := range done { + err = msg.Err + } + return err +} + +func (ep *Printer) printStatusEvent(se pollevent.Event) error { + switch se.Type { + case pollevent.ResourceUpdateEvent: + id := se.Resource.Identifier + var invName string + var ok bool + if invName, ok = ep.Data.InvNameMap[id]; !ok { + return fmt.Errorf("%s: resource not found", id) + } + // filter out status that are not assigned + statusString := se.Resource.Status.String() + if _, ok := ep.Data.StatusSet[strings.ToLower(statusString)]; len(ep.Data.StatusSet) != 0 && !ok { + return nil + } + eventInfo := ep.createJSONObj(id) + eventInfo["inventory-name"] = invName + eventInfo["status"] = statusString + eventInfo["message"] = se.Resource.Message + b, err := json.Marshal(eventInfo) + if err != nil { + return err + } + _, err = fmt.Fprintf(ep.IOStreams.Out, "%s\n", string(b)) + return err + case pollevent.ErrorEvent: + return ep.Formatter.FormatErrorEvent(event.ErrorEvent{ + Err: se.Error, + }) + } + return nil +} + +func (ep *Printer) createJSONObj(id object.ObjMetadata) map[string]interface{} { + return map[string]interface{}{ + "group": id.GroupKind.Group, + "kind": id.GroupKind.Kind, + "namespace": id.Namespace, + "name": id.Name, + "timestamp": time.Now().UTC().Format(time.RFC3339), + "type": "status", + } +} diff --git a/cmd/status/printers/printers.go b/cmd/status/printers/printers.go index 6fb81caf..2edf745c 100644 --- a/cmd/status/printers/printers.go +++ b/cmd/status/printers/printers.go @@ -6,6 +6,7 @@ package printers import ( "k8s.io/cli-runtime/pkg/genericclioptions" "sigs.k8s.io/cli-utils/cmd/status/printers/event" + "sigs.k8s.io/cli-utils/cmd/status/printers/json" "sigs.k8s.io/cli-utils/cmd/status/printers/printer" "sigs.k8s.io/cli-utils/cmd/status/printers/table" ) @@ -16,6 +17,8 @@ func CreatePrinter(printerType string, ioStreams genericclioptions.IOStreams, pr switch printerType { case "table": return table.NewPrinter(ioStreams, printData), nil + case "json": + return json.NewPrinter(ioStreams, printData), nil default: return event.NewPrinter(ioStreams, printData), nil }