diff --git a/pkg/print/common/errors.go b/pkg/print/common/errors.go new file mode 100644 index 00000000..1f8853f6 --- /dev/null +++ b/pkg/print/common/errors.go @@ -0,0 +1,45 @@ +// Copyright 2022 The Kubernetes Authors. +// SPDX-License-Identifier: Apache-2.0 + +package common + +import ( + "fmt" + + "sigs.k8s.io/cli-utils/pkg/print/stats" +) + +// ResultErrorFromStats takes a stats object and returns either a ResultError or +// nil depending on whether the stats reports that resources failed apply/prune/delete +// or reconciliation. +func ResultErrorFromStats(s stats.Stats) error { + if s.FailedActuationSum() > 0 || s.FailedReconciliationSum() > 0 { + return &ResultError{ + Stats: s, + } + } + return nil +} + +// ResultError is returned from printers when the apply/destroy operations completed, but one or +// more resources either failed apply/prune/delete, or failed to reconcile. +type ResultError struct { + Stats stats.Stats +} + +func (a *ResultError) Error() string { + switch { + case a.Stats.FailedActuationSum() > 0 && a.Stats.FailedReconciliationSum() > 0: + return fmt.Sprintf("%d resources failed, %d resources failed to reconcile before timeout", + a.Stats.FailedActuationSum(), a.Stats.FailedReconciliationSum()) + case a.Stats.FailedActuationSum() > 0: + return fmt.Sprintf("%d resources failed", a.Stats.FailedActuationSum()) + case a.Stats.FailedReconciliationSum() > 0: + return fmt.Sprintf("%d resources failed to reconcile before timeout", + a.Stats.FailedReconciliationSum()) + default: + // Should not happen as this error is only used when at least one resource + // either failed to apply/prune/delete or reconcile. + return "unknown error" + } +} diff --git a/pkg/print/list/base.go b/pkg/print/list/base.go index daf1b39f..05737ed9 100644 --- a/pkg/print/list/base.go +++ b/pkg/print/list/base.go @@ -4,11 +4,11 @@ package list import ( - "fmt" - "sigs.k8s.io/cli-utils/pkg/apply/event" "sigs.k8s.io/cli-utils/pkg/common" "sigs.k8s.io/cli-utils/pkg/object" + printcommon "sigs.k8s.io/cli-utils/pkg/print/common" + "sigs.k8s.io/cli-utils/pkg/print/stats" ) type Formatter interface { @@ -21,10 +21,7 @@ type Formatter interface { FormatActionGroupEvent( age event.ActionGroupEvent, ags []event.ActionGroup, - as *ApplyStats, - ps *PruneStats, - ds *DeleteStats, - ws *WaitStats, + s stats.Stats, c Collector, ) error } @@ -35,97 +32,6 @@ type BaseListPrinter struct { FormatterFactory FormatterFactory } -type ApplyStats struct { - ServersideApplied int - Created int - Unchanged int - Configured int - Failed int -} - -func (a *ApplyStats) inc(op event.ApplyEventOperation) { - switch op { - case event.ApplyUnspecified: - case event.ServersideApplied: - a.ServersideApplied++ - case event.Created: - a.Created++ - case event.Unchanged: - a.Unchanged++ - case event.Configured: - a.Configured++ - default: - panic(fmt.Errorf("unknown apply operation %s", op.String())) - } -} - -func (a *ApplyStats) incFailed() { - a.Failed++ -} - -func (a *ApplyStats) Sum() int { - return a.ServersideApplied + a.Configured + a.Unchanged + a.Created + a.Failed -} - -type PruneStats struct { - Pruned int - Skipped int - Failed int -} - -func (p *PruneStats) incPruned() { - p.Pruned++ -} - -func (p *PruneStats) incSkipped() { - p.Skipped++ -} - -func (p *PruneStats) incFailed() { - p.Failed++ -} - -type DeleteStats struct { - Deleted int - Skipped int - Failed int -} - -func (d *DeleteStats) incDeleted() { - d.Deleted++ -} - -func (d *DeleteStats) incSkipped() { - d.Skipped++ -} - -func (d *DeleteStats) incFailed() { - d.Failed++ -} - -type WaitStats struct { - Reconciled int - Timeout int - Failed int - Skipped int -} - -func (w *WaitStats) incReconciled() { - w.Reconciled++ -} - -func (w *WaitStats) incTimeout() { - w.Timeout++ -} - -func (w *WaitStats) incFailed() { - w.Failed++ -} - -func (w *WaitStats) incSkipped() { - w.Skipped++ -} - type Collector interface { LatestStatus() map[object.ObjMetadata]event.StatusEvent } @@ -149,15 +55,13 @@ func (sc *StatusCollector) LatestStatus() map[object.ObjMetadata]event.StatusEve //nolint:gocyclo func (b *BaseListPrinter) Print(ch <-chan event.Event, previewStrategy common.DryRunStrategy, printStatus bool) error { var actionGroups []event.ActionGroup - applyStats := &ApplyStats{} - pruneStats := &PruneStats{} - deleteStats := &DeleteStats{} - waitStats := &WaitStats{} + var statsCollector stats.Stats statusCollector := &StatusCollector{ latestStatus: make(map[object.ObjMetadata]event.StatusEvent), } formatter := b.FormatterFactory(previewStrategy) for e := range ch { + statsCollector.Handle(e) switch e.Type { case event.InitType: actionGroups = e.InitEvent.ActionGroups @@ -165,10 +69,6 @@ func (b *BaseListPrinter) Print(ch <-chan event.Event, previewStrategy common.Dr _ = formatter.FormatErrorEvent(e.ErrorEvent) return e.ErrorEvent.Err case event.ApplyType: - applyStats.inc(e.ApplyEvent.Operation) - if e.ApplyEvent.Error != nil { - applyStats.incFailed() - } if err := formatter.FormatApplyEvent(e.ApplyEvent); err != nil { return err } @@ -180,42 +80,14 @@ func (b *BaseListPrinter) Print(ch <-chan event.Event, previewStrategy common.Dr } } case event.PruneType: - switch e.PruneEvent.Operation { - case event.Pruned: - pruneStats.incPruned() - case event.PruneSkipped: - pruneStats.incSkipped() - } - if e.PruneEvent.Error != nil { - pruneStats.incFailed() - } if err := formatter.FormatPruneEvent(e.PruneEvent); err != nil { return err } case event.DeleteType: - switch e.DeleteEvent.Operation { - case event.Deleted: - deleteStats.incDeleted() - case event.DeleteSkipped: - deleteStats.incSkipped() - } - if e.DeleteEvent.Error != nil { - deleteStats.incFailed() - } if err := formatter.FormatDeleteEvent(e.DeleteEvent); err != nil { return err } case event.WaitType: - switch e.WaitEvent.Operation { - case event.Reconciled: - waitStats.incReconciled() - case event.ReconcileSkipped: - waitStats.incSkipped() - case event.ReconcileTimeout: - waitStats.incTimeout() - case event.ReconcileFailed: - waitStats.incFailed() - } if err := formatter.FormatWaitEvent(e.WaitEvent); err != nil { return err } @@ -223,39 +95,14 @@ func (b *BaseListPrinter) Print(ch <-chan event.Event, previewStrategy common.Dr if err := formatter.FormatActionGroupEvent( e.ActionGroupEvent, actionGroups, - applyStats, - pruneStats, - deleteStats, - waitStats, + statsCollector, statusCollector, ); err != nil { return err } } } - failedActuateSum := applyStats.Failed + pruneStats.Failed + deleteStats.Failed - failedReconcileSum := waitStats.Timeout + waitStats.Failed - switch { - case failedActuateSum > 0 && failedReconcileSum > 0: - return fmt.Errorf("%d resources failed, %d resources failed to reconcile before timeout", - failedActuateSum, failedReconcileSum) - case failedActuateSum > 0: - return fmt.Errorf("%d resources failed", failedActuateSum) - case failedReconcileSum > 0: - return fmt.Errorf("%d resources failed to reconcile before timeout", - failedReconcileSum) - default: - return nil - } -} - -func ActionGroupByName(name string, ags []event.ActionGroup) (event.ActionGroup, bool) { - for _, ag := range ags { - if ag.Name == name { - return ag, true - } - } - return event.ActionGroup{}, false + return printcommon.ResultErrorFromStats(statsCollector) } // IsLastActionGroup returns true if the passed ActionGroupEvent is the diff --git a/pkg/print/list/base_test.go b/pkg/print/list/base_test.go new file mode 100644 index 00000000..df756689 --- /dev/null +++ b/pkg/print/list/base_test.go @@ -0,0 +1,74 @@ +// Copyright 2022 The Kubernetes Authors. +// SPDX-License-Identifier: Apache-2.0 + +package list + +import ( + "testing" + + "sigs.k8s.io/cli-utils/pkg/apply/event" + "sigs.k8s.io/cli-utils/pkg/common" + "sigs.k8s.io/cli-utils/pkg/print/stats" + "sigs.k8s.io/cli-utils/pkg/printers/printer" + printertesting "sigs.k8s.io/cli-utils/pkg/printers/testutil" +) + +func TestPrint(t *testing.T) { + printertesting.PrintResultErrorTest(t, func() printer.Printer { + return &BaseListPrinter{ + FormatterFactory: func(previewStrategy common.DryRunStrategy) Formatter { + return newCountingFormatter() + }, + } + }) +} + +func newCountingFormatter() *countingFormatter { + return &countingFormatter{} +} + +type countingFormatter struct { + applyEvents []event.ApplyEvent + statusEvents []event.StatusEvent + pruneEvents []event.PruneEvent + deleteEvents []event.DeleteEvent + waitEvents []event.WaitEvent + errorEvent event.ErrorEvent + actionGroupEvent []event.ActionGroupEvent +} + +func (c *countingFormatter) FormatApplyEvent(e event.ApplyEvent) error { + c.applyEvents = append(c.applyEvents, e) + return nil +} + +func (c *countingFormatter) FormatStatusEvent(e event.StatusEvent) error { + c.statusEvents = append(c.statusEvents, e) + return nil +} + +func (c *countingFormatter) FormatPruneEvent(e event.PruneEvent) error { + c.pruneEvents = append(c.pruneEvents, e) + return nil +} + +func (c *countingFormatter) FormatDeleteEvent(e event.DeleteEvent) error { + c.deleteEvents = append(c.deleteEvents, e) + return nil +} + +func (c *countingFormatter) FormatWaitEvent(e event.WaitEvent) error { + c.waitEvents = append(c.waitEvents, e) + return nil +} + +func (c *countingFormatter) FormatErrorEvent(e event.ErrorEvent) error { + c.errorEvent = e + return nil +} + +func (c *countingFormatter) FormatActionGroupEvent(e event.ActionGroupEvent, _ []event.ActionGroup, _ stats.Stats, + _ Collector) error { + c.actionGroupEvent = append(c.actionGroupEvent, e) + return nil +} diff --git a/pkg/print/stats/stats.go b/pkg/print/stats/stats.go new file mode 100644 index 00000000..74341c30 --- /dev/null +++ b/pkg/print/stats/stats.go @@ -0,0 +1,147 @@ +// Copyright 2022 The Kubernetes Authors. +// SPDX-License-Identifier: Apache-2.0 + +package stats + +import ( + "fmt" + + "sigs.k8s.io/cli-utils/pkg/apply/event" +) + +// Stats captures the summarized numbers from apply/prune/delete and +// reconciliation of resources. +type Stats struct { + ApplyStats ApplyStats + PruneStats PruneStats + DeleteStats DeleteStats + WaitStats WaitStats +} + +// FailedActuationSum returns the number of resources that failed actuation. +func (s *Stats) FailedActuationSum() int { + return s.ApplyStats.Failed + s.PruneStats.Failed + s.DeleteStats.Failed +} + +// FailedReconciliationSum returns the number of resources that failed reconciliation. +func (s *Stats) FailedReconciliationSum() int { + return s.WaitStats.Failed + s.WaitStats.Timeout +} + +// Handle updates the stats based on an event. +func (s *Stats) Handle(e event.Event) { + switch e.Type { + case event.ApplyType: + if e.ApplyEvent.Error != nil { + s.ApplyStats.IncFailed() + return + } + s.ApplyStats.Inc(e.ApplyEvent.Operation) + case event.PruneType: + if e.PruneEvent.Error != nil { + s.PruneStats.IncFailed() + return + } + s.PruneStats.Inc(e.PruneEvent.Operation) + case event.DeleteType: + if e.DeleteEvent.Error != nil { + s.DeleteStats.IncFailed() + return + } + s.DeleteStats.Inc(e.DeleteEvent.Operation) + case event.WaitType: + s.WaitStats.Inc(e.WaitEvent.Operation) + } +} + +type ApplyStats struct { + ServersideApplied int + Created int + Unchanged int + Configured int + Failed int +} + +func (a *ApplyStats) Inc(op event.ApplyEventOperation) { + switch op { + case event.ApplyUnspecified: + case event.ServersideApplied: + a.ServersideApplied++ + case event.Created: + a.Created++ + case event.Unchanged: + a.Unchanged++ + case event.Configured: + a.Configured++ + default: + panic(fmt.Errorf("unknown apply operation %s", op.String())) + } +} + +func (a *ApplyStats) IncFailed() { + a.Failed++ +} + +func (a *ApplyStats) Sum() int { + return a.ServersideApplied + a.Configured + a.Unchanged + a.Created + a.Failed +} + +type PruneStats struct { + Pruned int + Skipped int + Failed int +} + +func (p *PruneStats) Inc(op event.PruneEventOperation) { + switch op { + case event.PruneUnspecified: + case event.Pruned: + p.Pruned++ + case event.PruneSkipped: + p.Skipped++ + } +} + +func (p *PruneStats) IncFailed() { + p.Failed++ +} + +type DeleteStats struct { + Deleted int + Skipped int + Failed int +} + +func (d *DeleteStats) Inc(op event.DeleteEventOperation) { + switch op { + case event.DeleteUnspecified: + case event.Deleted: + d.Deleted++ + case event.DeleteSkipped: + d.Skipped++ + } +} + +func (d *DeleteStats) IncFailed() { + d.Failed++ +} + +type WaitStats struct { + Reconciled int + Timeout int + Failed int + Skipped int +} + +func (w *WaitStats) Inc(op event.WaitEventOperation) { + switch op { + case event.Reconciled: + w.Reconciled++ + case event.ReconcileSkipped: + w.Skipped++ + case event.ReconcileTimeout: + w.Timeout++ + case event.ReconcileFailed: + w.Failed++ + } +} diff --git a/pkg/printers/events/formatter.go b/pkg/printers/events/formatter.go index 20af66a5..97e77d73 100644 --- a/pkg/printers/events/formatter.go +++ b/pkg/printers/events/formatter.go @@ -13,6 +13,7 @@ import ( "sigs.k8s.io/cli-utils/pkg/common" "sigs.k8s.io/cli-utils/pkg/object" "sigs.k8s.io/cli-utils/pkg/print/list" + "sigs.k8s.io/cli-utils/pkg/print/stats" ) func NewFormatter(ioStreams genericclioptions.IOStreams, @@ -107,15 +108,13 @@ func (ef *formatter) FormatErrorEvent(_ event.ErrorEvent) error { func (ef *formatter) FormatActionGroupEvent( age event.ActionGroupEvent, ags []event.ActionGroup, - as *list.ApplyStats, - ps *list.PruneStats, - ds *list.DeleteStats, - ws *list.WaitStats, + s stats.Stats, _ list.Collector, ) error { if age.Action == event.ApplyAction && age.Type == event.Finished && list.IsLastActionGroup(age, ags) { + as := s.ApplyStats output := fmt.Sprintf("%d resource(s) applied. %d created, %d unchanged, %d configured, %d failed", as.Sum(), as.Created, as.Unchanged, as.Configured, as.Failed) // Only print information about serverside apply if some of the @@ -129,19 +128,23 @@ func (ef *formatter) FormatActionGroupEvent( if age.Action == event.PruneAction && age.Type == event.Finished && list.IsLastActionGroup(age, ags) { - ef.print("%d resource(s) pruned, %d skipped, %d failed", ps.Pruned, ps.Skipped, ps.Failed) + ps := s.PruneStats + ef.print("%d resource(s) pruned, %d skipped, %d failed to prune", ps.Pruned, ps.Skipped, ps.Failed) } if age.Action == event.DeleteAction && age.Type == event.Finished && list.IsLastActionGroup(age, ags) { - ef.print("%d resource(s) deleted, %d skipped", ds.Deleted, ds.Skipped) + ds := s.DeleteStats + ef.print("%d resource(s) deleted, %d skipped, %d failed to delete", ds.Deleted, ds.Skipped, ds.Failed) } if age.Action == event.WaitAction && age.Type == event.Finished && list.IsLastActionGroup(age, ags) { - ef.print("%d resource(s) reconciled, %d skipped", ws.Reconciled, ds.Skipped) + ws := s.WaitStats + ef.print("%d resource(s) reconciled, %d skipped, %d failed to reconcile, %d timed out", ws.Reconciled, + ws.Skipped, ws.Failed, ws.Timeout) } return nil } diff --git a/pkg/printers/events/formatter_test.go b/pkg/printers/events/formatter_test.go index ef6fc02a..2174021a 100644 --- a/pkg/printers/events/formatter_test.go +++ b/pkg/printers/events/formatter_test.go @@ -24,7 +24,6 @@ func TestFormatter_FormatApplyEvent(t *testing.T) { testCases := map[string]struct { previewStrategy common.DryRunStrategy event event.ApplyEvent - applyStats *list.ApplyStats statusCollector list.Collector expected string }{ @@ -125,7 +124,6 @@ func TestFormatter_FormatPruneEvent(t *testing.T) { testCases := map[string]struct { previewStrategy common.DryRunStrategy event event.PruneEvent - pruneStats *list.PruneStats expected string }{ "resource pruned without no dryrun": { @@ -170,7 +168,6 @@ func TestFormatter_FormatDeleteEvent(t *testing.T) { testCases := map[string]struct { previewStrategy common.DryRunStrategy event event.DeleteEvent - deleteStats *list.DeleteStats statusCollector list.Collector expected string }{ @@ -219,7 +216,6 @@ func TestFormatter_FormatWaitEvent(t *testing.T) { testCases := map[string]struct { previewStrategy common.DryRunStrategy event event.WaitEvent - waitStats *list.WaitStats statusCollector list.Collector expected string }{ diff --git a/pkg/printers/json/formatter.go b/pkg/printers/json/formatter.go index 0d7bdf27..961a2c6f 100644 --- a/pkg/printers/json/formatter.go +++ b/pkg/printers/json/formatter.go @@ -13,6 +13,7 @@ import ( "sigs.k8s.io/cli-utils/pkg/common" "sigs.k8s.io/cli-utils/pkg/object" "sigs.k8s.io/cli-utils/pkg/print/list" + "sigs.k8s.io/cli-utils/pkg/print/stats" ) func NewFormatter(ioStreams genericclioptions.IOStreams, @@ -82,14 +83,12 @@ func (jf *formatter) FormatErrorEvent(ee event.ErrorEvent) error { func (jf *formatter) FormatActionGroupEvent( age event.ActionGroupEvent, ags []event.ActionGroup, - as *list.ApplyStats, - ps *list.PruneStats, - ds *list.DeleteStats, - ws *list.WaitStats, + s stats.Stats, _ list.Collector, ) error { if age.Action == event.ApplyAction && age.Type == event.Finished && list.IsLastActionGroup(age, ags) { + as := s.ApplyStats if err := jf.printEvent("apply", "completed", map[string]interface{}{ "count": as.Sum(), "createdCount": as.Created, @@ -104,22 +103,27 @@ func (jf *formatter) FormatActionGroupEvent( if age.Action == event.PruneAction && age.Type == event.Finished && list.IsLastActionGroup(age, ags) { + ps := s.PruneStats return jf.printEvent("prune", "completed", map[string]interface{}{ "pruned": ps.Pruned, "skipped": ps.Skipped, + "failed": ps.Failed, }) } if age.Action == event.DeleteAction && age.Type == event.Finished && list.IsLastActionGroup(age, ags) { + ds := s.DeleteStats return jf.printEvent("delete", "completed", map[string]interface{}{ "deleted": ds.Deleted, "skipped": ds.Skipped, + "failed": ds.Failed, }) } if age.Action == event.WaitAction && age.Type == event.Finished && list.IsLastActionGroup(age, ags) { + ws := s.WaitStats return jf.printEvent("wait", "completed", map[string]interface{}{ "reconciled": ws.Reconciled, "skipped": ws.Skipped, diff --git a/pkg/printers/json/formatter_test.go b/pkg/printers/json/formatter_test.go index 0324f03c..e068dfe9 100644 --- a/pkg/printers/json/formatter_test.go +++ b/pkg/printers/json/formatter_test.go @@ -18,6 +18,7 @@ import ( "sigs.k8s.io/cli-utils/pkg/kstatus/status" "sigs.k8s.io/cli-utils/pkg/object" "sigs.k8s.io/cli-utils/pkg/print/list" + "sigs.k8s.io/cli-utils/pkg/print/stats" ) func TestFormatter_FormatApplyEvent(t *testing.T) { @@ -183,7 +184,6 @@ func TestFormatter_FormatPruneEvent(t *testing.T) { testCases := map[string]struct { previewStrategy common.DryRunStrategy event event.PruneEvent - pruneStats *list.PruneStats expected map[string]interface{} }{ "resource pruned without dryrun": { @@ -255,7 +255,6 @@ func TestFormatter_FormatDeleteEvent(t *testing.T) { testCases := map[string]struct { previewStrategy common.DryRunStrategy event event.DeleteEvent - deleteStats *list.DeleteStats statusCollector list.Collector expected map[string]interface{} }{ @@ -476,10 +475,7 @@ func TestFormatter_FormatActionGroupEvent(t *testing.T) { previewStrategy common.DryRunStrategy event event.ActionGroupEvent actionGroups []event.ActionGroup - applyStats *list.ApplyStats - pruneStats *list.PruneStats - deleteStats *list.DeleteStats - waitStats *list.WaitStats + statsCollector stats.Stats statusCollector list.Collector expected map[string]interface{} }{ @@ -519,8 +515,10 @@ func TestFormatter_FormatActionGroupEvent(t *testing.T) { Action: event.ApplyAction, }, }, - applyStats: &list.ApplyStats{ - ServersideApplied: 42, + statsCollector: stats.Stats{ + ApplyStats: stats.ApplyStats{ + ServersideApplied: 42, + }, }, expected: map[string]interface{}{ "eventType": "completed", @@ -559,8 +557,7 @@ func TestFormatter_FormatActionGroupEvent(t *testing.T) { t.Run(tn, func(t *testing.T) { ioStreams, _, out, _ := genericclioptions.NewTestIOStreams() //nolint:dogsled formatter := NewFormatter(ioStreams, tc.previewStrategy) - err := formatter.FormatActionGroupEvent(tc.event, tc.actionGroups, tc.applyStats, tc.pruneStats, - tc.deleteStats, tc.waitStats, tc.statusCollector) + err := formatter.FormatActionGroupEvent(tc.event, tc.actionGroups, tc.statsCollector, tc.statusCollector) assert.NoError(t, err) assertOutput(t, tc.expected, out.String()) diff --git a/pkg/printers/table/collector.go b/pkg/printers/table/collector.go index 1ea3f061..33752ca0 100644 --- a/pkg/printers/table/collector.go +++ b/pkg/printers/table/collector.go @@ -12,6 +12,7 @@ import ( pe "sigs.k8s.io/cli-utils/pkg/kstatus/polling/event" "sigs.k8s.io/cli-utils/pkg/kstatus/status" "sigs.k8s.io/cli-utils/pkg/object" + "sigs.k8s.io/cli-utils/pkg/print/stats" "sigs.k8s.io/cli-utils/pkg/print/table" ) @@ -73,6 +74,10 @@ type ResourceInfo struct { // or Prune. ResourceAction event.ResourceAction + // Error is set if an error occurred trying to perform + // the desired action on the resource. + Error error + // ApplyOpResult contains the result after // a resource has been applied to the cluster. ApplyOpResult event.ApplyEventOperation @@ -211,6 +216,9 @@ func (r *ResourceStateCollector) processApplyEvent(e event.ApplyEvent) { klog.V(4).Infof("%s apply event not found in ResourceInfos; no processing", identifier) return } + if e.Error != nil { + previous.Error = e.Error + } previous.ApplyOpResult = e.Operation } @@ -223,6 +231,9 @@ func (r *ResourceStateCollector) processPruneEvent(e event.PruneEvent) { klog.V(4).Infof("%s prune event not found in ResourceInfos; no processing", identifier) return } + if e.Error != nil { + previous.Error = e.Error + } previous.PruneOpResult = e.Operation } @@ -285,6 +296,33 @@ func (r *ResourceStateCollector) LatestState() *ResourceState { } } +// Stats returns a summary of the results from the actuation operation +// as a stats.Stats object. +func (r *ResourceStateCollector) Stats() stats.Stats { + var s stats.Stats + for _, res := range r.resourceInfos { + switch res.ResourceAction { + case event.ApplyAction: + if res.Error != nil { + s.ApplyStats.IncFailed() + } + s.ApplyStats.Inc(res.ApplyOpResult) + case event.PruneAction: + if res.Error != nil { + s.PruneStats.IncFailed() + } + s.PruneStats.Inc(res.PruneOpResult) + case event.DeleteAction: + if res.Error != nil { + s.DeleteStats.IncFailed() + } + s.DeleteStats.Inc(res.DeleteOpResult) + } + s.WaitStats.Inc(res.WaitOpResult) + } + return s +} + type ResourceInfos []*ResourceInfo func (g ResourceInfos) Len() int { diff --git a/pkg/printers/table/printer.go b/pkg/printers/table/printer.go index 2dbdc299..ce491c28 100644 --- a/pkg/printers/table/printer.go +++ b/pkg/printers/table/printer.go @@ -11,6 +11,7 @@ import ( "k8s.io/cli-runtime/pkg/genericclioptions" "sigs.k8s.io/cli-utils/pkg/apply/event" "sigs.k8s.io/cli-utils/pkg/common" + printcommon "sigs.k8s.io/cli-utils/pkg/print/common" "sigs.k8s.io/cli-utils/pkg/print/table" ) @@ -18,7 +19,7 @@ type Printer struct { IOStreams genericclioptions.IOStreams } -func (t *Printer) Print(ch <-chan event.Event, _ common.DryRunStrategy, printStatus bool) error { +func (t *Printer) Print(ch <-chan event.Event, _ common.DryRunStrategy, _ bool) error { // Wait for the init event that will give us the set of // resources. var initEvent event.InitEvent @@ -61,7 +62,13 @@ func (t *Printer) Print(ch <-chan event.Event, _ common.DryRunStrategy, printSta // the printer has updated the UI with the latest state and // exited from the goroutine. <-printCompleted - return err + + if err != nil { + return err + } + // If no fatal errors happened, we will return a ResultError if + // one or more resources failed to apply/prune or reconcile. + return printcommon.ResultErrorFromStats(coll.Stats()) } // columns defines the columns we want to print diff --git a/pkg/printers/table/printer_test.go b/pkg/printers/table/printer_test.go index 7af0b5b0..b58ac56d 100644 --- a/pkg/printers/table/printer_test.go +++ b/pkg/printers/table/printer_test.go @@ -7,8 +7,11 @@ import ( "bytes" "testing" + "k8s.io/cli-runtime/pkg/genericclioptions" "sigs.k8s.io/cli-utils/pkg/apply/event" "sigs.k8s.io/cli-utils/pkg/print/table" + "sigs.k8s.io/cli-utils/pkg/printers/printer" + printertesting "sigs.k8s.io/cli-utils/pkg/printers/testutil" ) var ( @@ -72,3 +75,12 @@ func TestActionColumnDef(t *testing.T) { }) } } + +func TestPrint(t *testing.T) { + printertesting.PrintResultErrorTest(t, func() printer.Printer { + ioStreams, _, _, _ := genericclioptions.NewTestIOStreams() + return &Printer{ + IOStreams: ioStreams, + } + }) +} diff --git a/pkg/printers/testutil/common.go b/pkg/printers/testutil/common.go new file mode 100644 index 00000000..7917f01b --- /dev/null +++ b/pkg/printers/testutil/common.go @@ -0,0 +1,205 @@ +// Copyright 2022 The Kubernetes Authors. +// SPDX-License-Identifier: Apache-2.0 + +package testutil + +import ( + "fmt" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/cli-utils/pkg/apply/event" + "sigs.k8s.io/cli-utils/pkg/common" + "sigs.k8s.io/cli-utils/pkg/object" + printcommon "sigs.k8s.io/cli-utils/pkg/print/common" + "sigs.k8s.io/cli-utils/pkg/print/stats" + "sigs.k8s.io/cli-utils/pkg/printers/printer" +) + +type PrinterFactoryFunc func() printer.Printer + +func PrintResultErrorTest(t *testing.T, f PrinterFactoryFunc) { + deploymentIdentifier := object.ObjMetadata{ + GroupKind: schema.GroupKind{ + Group: "apps", + Kind: "Deployment", + }, + Name: "foo", + Namespace: "bar", + } + + testCases := map[string]struct { + events []event.Event + expectedErr error + }{ + "successful apply, prune and reconcile": { + events: []event.Event{ + { + Type: event.InitType, + InitEvent: event.InitEvent{ + ActionGroups: event.ActionGroupList{ + { + Name: "apply-1", + Action: event.ApplyAction, + Identifiers: []object.ObjMetadata{ + deploymentIdentifier, + }, + }, + { + Name: "wait-1", + Action: event.WaitAction, + Identifiers: []object.ObjMetadata{ + deploymentIdentifier, + }, + }, + }, + }, + }, + { + Type: event.ApplyType, + ApplyEvent: event.ApplyEvent{ + Operation: event.Created, + Identifier: deploymentIdentifier, + }, + }, + { + Type: event.WaitType, + WaitEvent: event.WaitEvent{ + Operation: event.Reconciled, + Identifier: deploymentIdentifier, + }, + }, + }, + expectedErr: nil, + }, + "successful apply, failed reconcile": { + events: []event.Event{ + { + Type: event.InitType, + InitEvent: event.InitEvent{ + ActionGroups: event.ActionGroupList{ + { + Name: "apply-1", + Action: event.ApplyAction, + Identifiers: []object.ObjMetadata{ + deploymentIdentifier, + }, + }, + { + Name: "wait-1", + Action: event.WaitAction, + Identifiers: []object.ObjMetadata{ + deploymentIdentifier, + }, + }, + }, + }, + }, + { + Type: event.ApplyType, + ApplyEvent: event.ApplyEvent{ + Operation: event.Created, + Identifier: deploymentIdentifier, + }, + }, + { + Type: event.WaitType, + WaitEvent: event.WaitEvent{ + Operation: event.ReconcileFailed, + Identifier: deploymentIdentifier, + }, + }, + }, + expectedErr: &printcommon.ResultError{ + Stats: stats.Stats{ + ApplyStats: stats.ApplyStats{ + Created: 1, + }, + WaitStats: stats.WaitStats{ + Failed: 1, + }, + }, + }, + }, + "failed apply": { + events: []event.Event{ + { + Type: event.InitType, + InitEvent: event.InitEvent{ + ActionGroups: event.ActionGroupList{ + { + Name: "apply-1", + Action: event.ApplyAction, + Identifiers: []object.ObjMetadata{ + deploymentIdentifier, + }, + }, + { + Name: "wait-1", + Action: event.WaitAction, + Identifiers: []object.ObjMetadata{ + deploymentIdentifier, + }, + }, + }, + }, + }, + { + Type: event.ApplyType, + ApplyEvent: event.ApplyEvent{ + Operation: event.ApplyUnspecified, + Identifier: deploymentIdentifier, + Error: fmt.Errorf("apply failed"), + }, + }, + { + Type: event.WaitType, + WaitEvent: event.WaitEvent{ + Operation: event.ReconcileSkipped, + Identifier: deploymentIdentifier, + }, + }, + }, + expectedErr: &printcommon.ResultError{ + Stats: stats.Stats{ + ApplyStats: stats.ApplyStats{ + Failed: 1, + }, + WaitStats: stats.WaitStats{ + Skipped: 1, + }, + }, + }, + }, + } + + for tn := range testCases { + tc := testCases[tn] + t.Run(tn, func(t *testing.T) { + p := f() + + eventChannel := make(chan event.Event) + + var wg sync.WaitGroup + var err error + + wg.Add(1) + go func() { + err = p.Print(eventChannel, common.DryRunNone, false) + wg.Done() + }() + + for i := range tc.events { + e := tc.events[i] + eventChannel <- e + } + close(eventChannel) + + wg.Wait() + + assert.Equal(t, tc.expectedErr, err) + }) + } +}