From 27015e8d5316dc61fbb18ff0606552ce4c809e67 Mon Sep 17 00:00:00 2001 From: yxxhero <11087727+yxxhero@users.noreply.github.com> Date: Wed, 20 May 2026 20:53:03 +0800 Subject: [PATCH] fix: restore kubedog status progress output during tracking (#2602) * fix: restore kubedog status progress output during tracking The refactor in commit bda57b74 that replaced multitrack.Multitrack() with individual resource trackers only read from Ready/Failed/Succeeded channels, ignoring Status, Added, EventMsg, PodLogChunk, PodError, and AddedPod channels. This caused kubedog status messages to no longer be displayed. Additionally, IgnoreLogs was not passed to tracker.Options, so the trackLogs setting was effectively ignored. This fix restores the original multitrack-style table display using the same kubedog utils.Table and indicators packages for: - Formatted status tables with DEPLOYMENT/REPLICAS/AVAILABLE/UP-TO-DATE columns - Pod sub-tables showing POD/READY/RESTARTS/STATUS with tree structure - ANSI color coding (green=ready, yellow=in-progress, red=failed) - Progress indicators showing value transitions (e.g. 1->3) - Waiting messages in blue Fixes #2601 Signed-off-by: yxxhero * fix: address review feedback - caption coloring, termWidth, O(1) pod detection, display tests Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/147fc763-c3f2-4a7e-9591-6f972fb62667 Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> * fix: use status.FailedReason for canary final display, fix test name typo Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/147fc763-c3f2-4a7e-9591-6f972fb62667 Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> * fix: correct gci import grouping in display.go and display_test.go Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/7e8f8219-5979-44fb-9729-6138c3aae08b Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> * fix: force ANSI color output in display_test.go for CI non-TTY environments Agent-Logs-Url: https://github.com/helmfile/helmfile/sessions/ff37ccd9-f4d1-4d42-a7d0-4903e2b9d253 Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> --------- Signed-off-by: yxxhero Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- go.sum | 14 -- pkg/kubedog/display.go | 312 +++++++++++++++++++++++++ pkg/kubedog/display_test.go | 453 ++++++++++++++++++++++++++++++++++++ pkg/kubedog/tracker.go | 134 ++++++++++- 4 files changed, 894 insertions(+), 19 deletions(-) create mode 100644 pkg/kubedog/display.go create mode 100644 pkg/kubedog/display_test.go diff --git a/go.sum b/go.sum index 2b2ffcea..711cd6c9 100644 --- a/go.sum +++ b/go.sum @@ -233,8 +233,6 @@ github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= -github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= -github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= @@ -269,7 +267,6 @@ github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5 github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/cli v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM= -github.com/docker/cli v29.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY= github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= @@ -482,7 +479,6 @@ github.com/goware/prefixer v0.0.0-20160118172347-395022866408/go.mod h1:PE1ycukg github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -617,12 +613,6 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= -github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/moby/api v1.53.0 h1:PihqG1ncw4W+8mZs69jlwGXdaYBeb5brF6BL7mPIS/w= -github.com/moby/moby/api v1.53.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc= -github.com/moby/moby/client v0.2.2 h1:Pt4hRMCAIlyjL3cr8M5TrXCwKzguebPAc2do2ur7dEM= -github.com/moby/moby/client v0.2.2/go.mod h1:2EkIPVNCqR05CMIzL1mfA07t0HvVUUOl85pasRz/GmQ= github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= @@ -789,10 +779,6 @@ github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3k github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= -github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= -github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= diff --git a/pkg/kubedog/display.go b/pkg/kubedog/display.go new file mode 100644 index 00000000..79a08436 --- /dev/null +++ b/pkg/kubedog/display.go @@ -0,0 +1,312 @@ +package kubedog + +import ( + "fmt" + "io" + "os" + "sort" + "strings" + + "github.com/werf/kubedog/pkg/tracker/daemonset" + "github.com/werf/kubedog/pkg/tracker/deployment" + "github.com/werf/kubedog/pkg/tracker/indicators" + "github.com/werf/kubedog/pkg/tracker/job" + "github.com/werf/kubedog/pkg/tracker/pod" + "github.com/werf/kubedog/pkg/tracker/statefulset" + "github.com/werf/kubedog/pkg/utils" + "golang.org/x/term" +) + +var statusProgressTableRatio = []float64{.58, .11, .12, .19} +var statusProgressSubTableRatio = []float64{.40, .15, .20, .25} + +func writeOut(out io.Writer, s string) { + _, _ = fmt.Fprint(out, s) +} + +func displayDeploymentStatusProgress(out io.Writer, resourceCaption string, status deployment.DeploymentStatus, prevStatus *deployment.DeploymentStatus) { + t := utils.NewTable(statusProgressTableRatio...) + t.SetWidth(termWidth()) + + showProgress := status.StatusGeneration > prevStatus.StatusGeneration + + replicas := "-" + if status.ReplicasIndicator != nil { + replicas = status.ReplicasIndicator.FormatTableElem(prevStatus.ReplicasIndicator, indicators.FormatTableElemOptions{ + ShowProgress: showProgress, + WithTargetValue: true, + }) + } + available := "-" + if status.AvailableIndicator != nil { + available = status.AvailableIndicator.FormatTableElem(prevStatus.AvailableIndicator, indicators.FormatTableElemOptions{ + ShowProgress: showProgress, + }) + } + uptodate := "-" + if status.UpToDateIndicator != nil { + uptodate = status.UpToDateIndicator.FormatTableElem(prevStatus.UpToDateIndicator, indicators.FormatTableElemOptions{ + ShowProgress: showProgress, + }) + } + + t.Header("DEPLOYMENT", "REPLICAS", "AVAILABLE", "UP-TO-DATE") + + args := []interface{}{resourceCaption, replicas, available, uptodate} + if status.IsFailed { + args = append(args, formatResourceError(status.FailedReason)) + } + t.Row(args...) + + displayChildPodsAndWaiting(&t, prevStatus.Pods, status.Pods, status.NewPodsNames, status.WaitingForMessages) + + writeOut(out, t.Render()) +} + +func displayStatefulSetStatusProgress(out io.Writer, resourceCaption string, status statefulset.StatefulSetStatus, prevStatus *statefulset.StatefulSetStatus) { + t := utils.NewTable(statusProgressTableRatio...) + t.SetWidth(termWidth()) + + showProgress := status.StatusGeneration > prevStatus.StatusGeneration + + replicas := "-" + if status.ReplicasIndicator != nil { + replicas = status.ReplicasIndicator.FormatTableElem(prevStatus.ReplicasIndicator, indicators.FormatTableElemOptions{ + ShowProgress: showProgress, + WithTargetValue: true, + }) + } + ready := "-" + if status.ReadyIndicator != nil { + ready = status.ReadyIndicator.FormatTableElem(prevStatus.ReadyIndicator, indicators.FormatTableElemOptions{ + ShowProgress: showProgress, + }) + } + uptodate := "-" + if status.UpToDateIndicator != nil { + uptodate = status.UpToDateIndicator.FormatTableElem(prevStatus.UpToDateIndicator, indicators.FormatTableElemOptions{ + ShowProgress: showProgress, + }) + } + + t.Header("STATEFULSET", "REPLICAS", "READY", "UP-TO-DATE") + + args := []interface{}{resourceCaption, replicas, ready, uptodate} + if status.IsFailed { + args = append(args, formatResourceError(status.FailedReason)) + } else { + for _, w := range status.WarningMessages { + args = append(args, formatResourceWarning(w)) + } + } + t.Row(args...) + + displayChildPodsAndWaiting(&t, prevStatus.Pods, status.Pods, status.NewPodsNames, status.WaitingForMessages) + + writeOut(out, t.Render()) +} + +func displayDaemonSetStatusProgress(out io.Writer, resourceCaption string, status daemonset.DaemonSetStatus, prevStatus *daemonset.DaemonSetStatus) { + t := utils.NewTable(statusProgressTableRatio...) + t.SetWidth(termWidth()) + + showProgress := status.StatusGeneration > prevStatus.StatusGeneration + + replicas := "-" + if status.ReplicasIndicator != nil { + replicas = status.ReplicasIndicator.FormatTableElem(prevStatus.ReplicasIndicator, indicators.FormatTableElemOptions{ + ShowProgress: showProgress, + WithTargetValue: true, + }) + } + available := "-" + if status.AvailableIndicator != nil { + available = status.AvailableIndicator.FormatTableElem(prevStatus.AvailableIndicator, indicators.FormatTableElemOptions{ + ShowProgress: showProgress, + }) + } + uptodate := "-" + if status.UpToDateIndicator != nil { + uptodate = status.UpToDateIndicator.FormatTableElem(prevStatus.UpToDateIndicator, indicators.FormatTableElemOptions{ + ShowProgress: showProgress, + }) + } + + t.Header("DAEMONSET", "REPLICAS", "AVAILABLE", "UP-TO-DATE") + + args := []interface{}{resourceCaption, replicas, available, uptodate} + if status.IsFailed { + args = append(args, formatResourceError(status.FailedReason)) + } + t.Row(args...) + + displayChildPodsAndWaiting(&t, prevStatus.Pods, status.Pods, status.NewPodsNames, status.WaitingForMessages) + + writeOut(out, t.Render()) +} + +func displayJobStatusProgress(out io.Writer, resourceCaption string, status job.JobStatus, prevStatus *job.JobStatus) { + t := utils.NewTable(statusProgressTableRatio...) + t.SetWidth(termWidth()) + + showProgress := status.StatusGeneration > prevStatus.StatusGeneration + + succeeded := "-" + if status.SucceededIndicator != nil { + succeeded = status.SucceededIndicator.FormatTableElem(prevStatus.SucceededIndicator, indicators.FormatTableElemOptions{ + ShowProgress: showProgress, + }) + } + + t.Header("JOB", "ACTIVE", "DURATION", "SUCCEEDED/FAILED") + + var active interface{} = "-" + if status.Active != 0 { + active = status.Active + } + failed := fmt.Sprintf("%d", status.Failed) + + args := []interface{}{resourceCaption, active, status.Age, strings.Join([]string{succeeded, failed}, "/")} + if status.IsFailed { + args = append(args, formatResourceError(status.FailedReason)) + } + t.Row(args...) + + if len(status.Pods) > 0 { + st := displayChildPodsStatusProgress(&t, prevStatus.Pods, status.Pods, nil, showProgress) + extraMsg := "" + if len(status.WaitingForMessages) > 0 { + extraMsg += "---\n" + extraMsg += utils.BlueF("Waiting for: %s", strings.Join(status.WaitingForMessages, ", ")) + } + st.Commit(extraMsg) + } + + writeOut(out, t.Render()) +} + +func displayChildPodsAndWaiting(t *utils.Table, prevPods, pods map[string]pod.PodStatus, newPodsNames []string, waitingForMessages []string) { + if len(pods) > 0 { + st := displayChildPodsStatusProgress(t, prevPods, pods, newPodsNames, true) + extraMsg := "" + if len(waitingForMessages) > 0 { + extraMsg += "---\n" + extraMsg += utils.BlueF("Waiting for: %s", strings.Join(waitingForMessages, ", ")) + } + st.Commit(extraMsg) + } +} + +func displayChildPodsStatusProgress(t *utils.Table, prevPods, pods map[string]pod.PodStatus, newPodsNames []string, showProgress bool) *utils.Table { + subT := t.SubTable(statusProgressSubTableRatio...) + st := &subT + + st.Header("POD", "READY", "RESTARTS", "STATUS") + + podsNames := make([]string, 0, len(pods)) + for podName := range pods { + podsNames = append(podsNames, podName) + } + sort.Strings(podsNames) + + var podRows [][]interface{} + + newPodSet := make(map[string]struct{}, len(newPodsNames)) + for _, name := range newPodsNames { + newPodSet[name] = struct{}{} + } + + for _, podName := range podsNames { + var podRow []interface{} + + _, isPodNew := newPodSet[podName] + + prevPodStatus := prevPods[podName] + podStatus := pods[podName] + + isReady := false + if podStatus.StatusIndicator != nil { + isReady = podStatus.StatusIndicator.IsReady() + } + + resource := formatPodResourceCaption(podName, isReady, podStatus.IsFailed, isPodNew) + ready := fmt.Sprintf("%d/%d", podStatus.ReadyContainers, podStatus.TotalContainers) + + status := "-" + if podStatus.StatusIndicator != nil { + status = podStatus.StatusIndicator.FormatTableElem(prevPodStatus.StatusIndicator, indicators.FormatTableElemOptions{ + ShowProgress: showProgress, + IsResourceNew: isPodNew, + }) + } + + podRow = append(podRow, resource, ready, podStatus.Restarts, status) + if podStatus.IsFailed { + podRow = append(podRow, formatResourceError(podStatus.FailedReason)) + } + + podRows = append(podRows, podRow) + } + + st.Rows(podRows...) + + return st +} + +func formatResourceCaption(caption string, isReady, isFailed bool) string { + switch { + case isReady: + return utils.GreenF("%s", caption) + case isFailed: + return utils.RedF("%s", caption) + default: + return utils.YellowF("%s", caption) + } +} + +func formatPodResourceCaption(podName string, isReady, isFailed, isNew bool) string { + if !isNew { + return podName + } + return formatResourceCaption(podName, isReady, isFailed) +} + +func formatResourceError(reason string) string { + return utils.RedF("error: %s", reason) +} + +func formatResourceWarning(reason string) string { + return utils.YellowF("warning: %s", reason) +} + +func termWidth() int { + if w, _, err := term.GetSize(int(os.Stderr.Fd())); err == nil && w > 0 { + return w + } + return 140 +} + +func displayCanaryStatus(out io.Writer, resourceCaption string, status CanaryStatusView) { + var parts []string + if status.Phase != "" { + parts = append(parts, fmt.Sprintf("phase %s", status.Phase)) + } + if status.Age != "" { + parts = append(parts, fmt.Sprintf("age %s", status.Age)) + } + msg := fmt.Sprintf("%s: %s", resourceCaption, strings.Join(parts, ", ")) + if status.IsFailed { + msg = utils.RedF("%s", msg) + } + _, _ = fmt.Fprintln(out, msg) +} + +type CanaryStatusView struct { + Phase string + Age string + IsFailed bool +} + +func statusOutput() io.Writer { + return os.Stderr +} diff --git a/pkg/kubedog/display_test.go b/pkg/kubedog/display_test.go new file mode 100644 index 00000000..254f3522 --- /dev/null +++ b/pkg/kubedog/display_test.go @@ -0,0 +1,453 @@ +package kubedog + +import ( + "bytes" + "os" + "strings" + "testing" + + "github.com/gookit/color" + "github.com/stretchr/testify/assert" + "github.com/werf/kubedog/pkg/tracker/daemonset" + "github.com/werf/kubedog/pkg/tracker/deployment" + "github.com/werf/kubedog/pkg/tracker/job" + "github.com/werf/kubedog/pkg/tracker/pod" + "github.com/werf/kubedog/pkg/tracker/statefulset" +) + +// TestMain forces ANSI color output so that tests asserting on escape codes +// pass in non-TTY environments such as CI runners. +func TestMain(m *testing.M) { + color.ForceColor() + os.Exit(m.Run()) +} + +// --- formatResourceCaption --- + +func TestFormatResourceCaption_Ready(t *testing.T) { + result := formatResourceCaption("deploy/myapp", true, false) + assert.Contains(t, result, "deploy/myapp") + // Green ANSI escape should be present + assert.Contains(t, result, "\033[") +} + +func TestFormatResourceCaption_Failed(t *testing.T) { + result := formatResourceCaption("deploy/myapp", false, true) + assert.Contains(t, result, "deploy/myapp") + assert.Contains(t, result, "\033[") +} + +func TestFormatResourceCaption_InProgress(t *testing.T) { + result := formatResourceCaption("deploy/myapp", false, false) + assert.Contains(t, result, "deploy/myapp") + // Yellow for in-progress + assert.Contains(t, result, "\033[") +} + +func TestFormatResourceCaption_ReadyTakesPrecedence(t *testing.T) { + // isReady=true should win over isFailed=true + resultReady := formatResourceCaption("x", true, false) + resultFailed := formatResourceCaption("x", false, true) + // Colors should differ + assert.NotEqual(t, resultReady, resultFailed) +} + +// --- formatPodResourceCaption --- + +func TestFormatPodResourceCaption_NotNew(t *testing.T) { + result := formatPodResourceCaption("my-pod-abc", true, false, false) + // Not a new pod: no coloring applied, just the plain name + assert.Equal(t, "my-pod-abc", result) +} + +func TestFormatPodResourceCaption_NewAndReady(t *testing.T) { + result := formatPodResourceCaption("my-pod-abc", true, false, true) + assert.Contains(t, result, "my-pod-abc") + assert.Contains(t, result, "\033[") +} + +func TestFormatPodResourceCaption_NewAndFailed(t *testing.T) { + result := formatPodResourceCaption("my-pod-abc", false, true, true) + assert.Contains(t, result, "my-pod-abc") + assert.Contains(t, result, "\033[") +} + +func TestFormatPodResourceCaption_NewInProgress(t *testing.T) { + result := formatPodResourceCaption("my-pod-abc", false, false, true) + assert.Contains(t, result, "my-pod-abc") + assert.Contains(t, result, "\033[") +} + +// --- formatResourceError / formatResourceWarning --- + +func TestFormatResourceError(t *testing.T) { + result := formatResourceError("CrashLoopBackOff") + assert.Contains(t, result, "error:") + assert.Contains(t, result, "CrashLoopBackOff") +} + +func TestFormatResourceWarning(t *testing.T) { + result := formatResourceWarning("PodNotScheduled") + assert.Contains(t, result, "warning:") + assert.Contains(t, result, "PodNotScheduled") +} + +// --- termWidth --- + +func TestTermWidth_ReturnsPositive(t *testing.T) { + w := termWidth() + assert.Greater(t, w, 0) +} + +// --- displayDeploymentStatusProgress --- + +func TestDisplayDeploymentStatusProgress_ZeroStatus(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("deploy/myapp", false, false) + var prev deployment.DeploymentStatus + status := deployment.DeploymentStatus{} + + // Must not panic and must produce some output + assert.NotPanics(t, func() { + displayDeploymentStatusProgress(&buf, caption, status, &prev) + }) + out := buf.String() + assert.NotEmpty(t, out) + assert.Contains(t, out, "DEPLOYMENT") +} + +func TestDisplayDeploymentStatusProgress_Failed(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("deploy/myapp", false, true) + var prev deployment.DeploymentStatus + status := deployment.DeploymentStatus{ + IsFailed: true, + FailedReason: "ImagePullBackOff", + } + + assert.NotPanics(t, func() { + displayDeploymentStatusProgress(&buf, caption, status, &prev) + }) + out := buf.String() + assert.Contains(t, out, "error:") + assert.Contains(t, out, "ImagePullBackOff") +} + +func TestDisplayDeploymentStatusProgress_WithWaitingMessage(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("deploy/myapp", false, false) + var prev deployment.DeploymentStatus + // WaitingForMessages is only rendered when there are pods + status := deployment.DeploymentStatus{ + StatusGeneration: 1, + WaitingForMessages: []string{"up-to-date 1->3"}, + Pods: map[string]pod.PodStatus{ + "myapp-pod-abc": {ReadyContainers: 1, TotalContainers: 1}, + }, + } + + assert.NotPanics(t, func() { + displayDeploymentStatusProgress(&buf, caption, status, &prev) + }) + out := buf.String() + assert.Contains(t, out, "Waiting for:") + assert.Contains(t, out, "up-to-date 1->3") +} + +func TestDisplayDeploymentStatusProgress_WithPods(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("deploy/myapp", false, false) + prev := deployment.DeploymentStatus{} + status := deployment.DeploymentStatus{ + StatusGeneration: 1, + Pods: map[string]pod.PodStatus{ + "myapp-abc-123": {ReadyContainers: 1, TotalContainers: 1}, + }, + NewPodsNames: []string{"myapp-abc-123"}, + } + + assert.NotPanics(t, func() { + displayDeploymentStatusProgress(&buf, caption, status, &prev) + }) + out := buf.String() + assert.Contains(t, out, "POD") + assert.Contains(t, out, "myapp-abc-123") +} + +// --- displayStatefulSetStatusProgress --- + +func TestDisplayStatefulSetStatusProgress_ZeroStatus(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("sts/myapp", false, false) + var prev statefulset.StatefulSetStatus + status := statefulset.StatefulSetStatus{} + + assert.NotPanics(t, func() { + displayStatefulSetStatusProgress(&buf, caption, status, &prev) + }) + out := buf.String() + assert.Contains(t, out, "STATEFULSET") +} + +func TestDisplayStatefulSetStatusProgress_WithWarnings(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("sts/myapp", false, false) + var prev statefulset.StatefulSetStatus + status := statefulset.StatefulSetStatus{ + WarningMessages: []string{"PodNotScheduled: insufficient resources"}, + } + + assert.NotPanics(t, func() { + displayStatefulSetStatusProgress(&buf, caption, status, &prev) + }) + out := buf.String() + assert.Contains(t, out, "warning:") + assert.Contains(t, out, "PodNotScheduled") +} + +func TestDisplayStatefulSetStatusProgress_Failed(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("sts/myapp", false, true) + var prev statefulset.StatefulSetStatus + status := statefulset.StatefulSetStatus{ + IsFailed: true, + FailedReason: "timeout waiting for ready", + } + + assert.NotPanics(t, func() { + displayStatefulSetStatusProgress(&buf, caption, status, &prev) + }) + out := buf.String() + assert.Contains(t, out, "error:") + assert.Contains(t, out, "timeout waiting for ready") +} + +// --- displayDaemonSetStatusProgress --- + +func TestDisplayDaemonSetStatusProgress_ZeroStatus(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("ds/myapp", false, false) + var prev daemonset.DaemonSetStatus + status := daemonset.DaemonSetStatus{} + + assert.NotPanics(t, func() { + displayDaemonSetStatusProgress(&buf, caption, status, &prev) + }) + out := buf.String() + assert.Contains(t, out, "DAEMONSET") +} + +func TestDisplayDaemonSetStatusProgress_Failed(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("ds/myapp", false, true) + var prev daemonset.DaemonSetStatus + status := daemonset.DaemonSetStatus{ + IsFailed: true, + FailedReason: "node not ready", + } + + assert.NotPanics(t, func() { + displayDaemonSetStatusProgress(&buf, caption, status, &prev) + }) + out := buf.String() + assert.Contains(t, out, "error:") + assert.Contains(t, out, "node not ready") +} + +// --- displayJobStatusProgress --- + +func TestDisplayJobStatusProgress_ZeroStatus(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("job/myjob", false, false) + var prev job.JobStatus + status := job.JobStatus{} + + assert.NotPanics(t, func() { + displayJobStatusProgress(&buf, caption, status, &prev) + }) + out := buf.String() + assert.Contains(t, out, "JOB") +} + +func TestDisplayJobStatusProgress_Active(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("job/myjob", false, false) + var prev job.JobStatus + status := job.JobStatus{ + StatusGeneration: 1, + } + + assert.NotPanics(t, func() { + displayJobStatusProgress(&buf, caption, status, &prev) + }) + out := buf.String() + assert.Contains(t, out, "ACTIVE") +} + +func TestDisplayJobStatusProgress_Failed(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("job/myjob", false, true) + var prev job.JobStatus + status := job.JobStatus{ + IsFailed: true, + FailedReason: "BackoffLimitExceeded", + } + + assert.NotPanics(t, func() { + displayJobStatusProgress(&buf, caption, status, &prev) + }) + out := buf.String() + assert.Contains(t, out, "error:") + assert.Contains(t, out, "BackoffLimitExceeded") +} + +func TestDisplayJobStatusProgress_WithWaitingMessage(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("job/myjob", false, false) + var prev job.JobStatus + status := job.JobStatus{ + WaitingForMessages: []string{"succeeded 0->1"}, + Pods: map[string]pod.PodStatus{ + "myjob-abc": {ReadyContainers: 0, TotalContainers: 1}, + }, + } + + assert.NotPanics(t, func() { + displayJobStatusProgress(&buf, caption, status, &prev) + }) + out := buf.String() + assert.Contains(t, out, "Waiting for:") + assert.Contains(t, out, "succeeded 0->1") +} + +// --- displayChildPodsStatusProgress --- + +func TestDisplayChildPodsStatusProgress_Empty(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("deploy/myapp", false, false) + // With no pods, only the header should be rendered + prev := deployment.DeploymentStatus{} + status := deployment.DeploymentStatus{ + Pods: map[string]pod.PodStatus{}, + } + assert.NotPanics(t, func() { + displayDeploymentStatusProgress(&buf, caption, status, &prev) + }) + // No POD sub-table header when pods is empty + out := buf.String() + assert.NotContains(t, out, "POD") +} + +func TestDisplayChildPodsStatusProgress_NewPodSet(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("deploy/myapp", false, false) + prev := deployment.DeploymentStatus{} + // Two pods: one new, one old + status := deployment.DeploymentStatus{ + StatusGeneration: 1, + Pods: map[string]pod.PodStatus{ + "pod-new-abc": {ReadyContainers: 0, TotalContainers: 1}, + "pod-old-xyz": {ReadyContainers: 1, TotalContainers: 1}, + }, + NewPodsNames: []string{"pod-new-abc"}, + } + + assert.NotPanics(t, func() { + displayDeploymentStatusProgress(&buf, caption, status, &prev) + }) + out := buf.String() + assert.Contains(t, out, "pod-new-abc") + assert.Contains(t, out, "pod-old-xyz") +} + +func TestDisplayChildPodsStatusProgress_ManyPodsO1Check(t *testing.T) { + // Verifies O(1) new-pod detection works correctly for many pods + var buf bytes.Buffer + caption := formatResourceCaption("deploy/myapp", false, false) + prev := deployment.DeploymentStatus{} + + pods := make(map[string]pod.PodStatus) + newNames := make([]string, 0, 10) + for i := 0; i < 20; i++ { + name := strings.Repeat("a", i+1) + pods[name] = pod.PodStatus{ReadyContainers: 1, TotalContainers: 1} + if i%2 == 0 { + newNames = append(newNames, name) + } + } + status := deployment.DeploymentStatus{ + StatusGeneration: 1, + Pods: pods, + NewPodsNames: newNames, + } + + assert.NotPanics(t, func() { + displayDeploymentStatusProgress(&buf, caption, status, &prev) + }) + assert.NotEmpty(t, buf.String()) +} + +// --- displayCanaryStatus --- + +func TestDisplayCanaryStatus_Normal(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("canary/myapp", false, false) + view := CanaryStatusView{Phase: "Progressing", Age: "1m"} + + assert.NotPanics(t, func() { + displayCanaryStatus(&buf, caption, view) + }) + out := buf.String() + assert.Contains(t, out, "Progressing") + assert.Contains(t, out, "1m") +} + +func TestDisplayCanaryStatus_Failed(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("canary/myapp", false, true) + view := CanaryStatusView{Phase: "Failed", IsFailed: true} + + assert.NotPanics(t, func() { + displayCanaryStatus(&buf, caption, view) + }) + out := buf.String() + assert.Contains(t, out, "Failed") +} + +func TestDisplayCanaryStatus_Succeeded(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("canary/myapp", true, false) + view := CanaryStatusView{Phase: "Succeeded"} + + assert.NotPanics(t, func() { + displayCanaryStatus(&buf, caption, view) + }) + out := buf.String() + assert.Contains(t, out, "Succeeded") +} + +func TestDisplayCanaryStatus_EmptyPhaseAndAge(t *testing.T) { + var buf bytes.Buffer + caption := formatResourceCaption("canary/myapp", false, false) + view := CanaryStatusView{} + + assert.NotPanics(t, func() { + displayCanaryStatus(&buf, caption, view) + }) + // Should still produce output (at least the caption + newline) + assert.NotEmpty(t, buf.String()) +} + +// --- writeOut --- + +func TestWriteOut(t *testing.T) { + var buf bytes.Buffer + writeOut(&buf, "hello world") + assert.Equal(t, "hello world", buf.String()) +} + +func TestWriteOut_Empty(t *testing.T) { + var buf bytes.Buffer + writeOut(&buf, "") + assert.Equal(t, "", buf.String()) +} diff --git a/pkg/kubedog/tracker.go b/pkg/kubedog/tracker.go index 867116f8..cc4e11e5 100644 --- a/pkg/kubedog/tracker.go +++ b/pkg/kubedog/tracker.go @@ -9,6 +9,7 @@ import ( "sync" "time" + "github.com/werf/kubedog/pkg/display" "github.com/werf/kubedog/pkg/informer" "github.com/werf/kubedog/pkg/tracker" "github.com/werf/kubedog/pkg/tracker/canary" @@ -234,6 +235,7 @@ func (t *Tracker) TrackResources(ctx context.Context, resources []*resource.Reso ParentContext: ctx, Timeout: t.trackOptions.Timeout, LogsFromTime: time.Now().Add(-t.trackOptions.LogsSince), + IgnoreLogs: !t.trackOptions.Logs, } var wg sync.WaitGroup @@ -312,13 +314,35 @@ func (t *Tracker) runDeploymentTracker(ctx context.Context, tr *deployment.Track } func (t *Tracker) waitDeploymentTracker(ctx context.Context, tr *deployment.Tracker, trackErrCh <-chan error, doneCh <-chan struct{}) error { + var prevStatus deployment.DeploymentStatus + out := statusOutput() + resourceName := fmt.Sprintf("deploy/%s", tr.ResourceName) + for { select { + case status := <-tr.Added: + displayDeploymentStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus) + prevStatus = status case <-tr.Ready: - t.logger.Debugf("Deployment %s/%s is ready", tr.Namespace, tr.ResourceName) + displayDeploymentStatusProgress(out, formatResourceCaption(resourceName, true, false), prevStatus, &prevStatus) + t.logger.Infof("Deployment %s/%s is ready", tr.Namespace, tr.ResourceName) return nil case status := <-tr.Failed: + displayDeploymentStatusProgress(out, formatResourceCaption(resourceName, false, true), status, &prevStatus) return fmt.Errorf("deployment %s/%s failed: %s", tr.Namespace, tr.ResourceName, status.FailedReason) + case status := <-tr.Status: + if status.StatusGeneration > prevStatus.StatusGeneration { + displayDeploymentStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus) + prevStatus = status + } + case msg := <-tr.EventMsg: + t.logger.Infof("deploy/%s: %s", tr.ResourceName, msg) + case chunk := <-tr.PodLogChunk: + t.logPodLogChunk(chunk.PodName, chunk.LogLines) + case report := <-tr.PodError: + t.logger.Warnf("deploy/%s pod %s: %s: %s", tr.ResourceName, report.ReplicaSetPodError.PodName, report.ReplicaSetPodError.ContainerName, report.ReplicaSetPodError.Message) + case <-tr.AddedReplicaSet: + case <-tr.AddedPod: case err := <-trackErrCh: return err case <-doneCh: @@ -338,13 +362,34 @@ func (t *Tracker) runStatefulSetTracker(ctx context.Context, tr *statefulset.Tra } func (t *Tracker) waitStatefulSetTracker(ctx context.Context, tr *statefulset.Tracker, trackErrCh <-chan error, doneCh <-chan struct{}) error { + var prevStatus statefulset.StatefulSetStatus + out := statusOutput() + resourceName := fmt.Sprintf("sts/%s", tr.ResourceName) + for { select { + case status := <-tr.Added: + displayStatefulSetStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus) + prevStatus = status case <-tr.Ready: - t.logger.Debugf("StatefulSet %s/%s is ready", tr.Namespace, tr.ResourceName) + displayStatefulSetStatusProgress(out, formatResourceCaption(resourceName, true, false), prevStatus, &prevStatus) + t.logger.Infof("StatefulSet %s/%s is ready", tr.Namespace, tr.ResourceName) return nil case status := <-tr.Failed: + displayStatefulSetStatusProgress(out, formatResourceCaption(resourceName, false, true), status, &prevStatus) return fmt.Errorf("statefulset %s/%s failed: %s", tr.Namespace, tr.ResourceName, status.FailedReason) + case status := <-tr.Status: + if status.StatusGeneration > prevStatus.StatusGeneration { + displayStatefulSetStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus) + prevStatus = status + } + case msg := <-tr.EventMsg: + t.logger.Infof("sts/%s: %s", tr.ResourceName, msg) + case chunk := <-tr.PodLogChunk: + t.logPodLogChunk(chunk.PodName, chunk.LogLines) + case report := <-tr.PodError: + t.logger.Warnf("sts/%s pod %s: %s: %s", tr.ResourceName, report.ReplicaSetPodError.PodName, report.ReplicaSetPodError.ContainerName, report.ReplicaSetPodError.Message) + case <-tr.AddedPod: case err := <-trackErrCh: return err case <-doneCh: @@ -364,13 +409,34 @@ func (t *Tracker) runDaemonSetTracker(ctx context.Context, tr *daemonset.Tracker } func (t *Tracker) waitDaemonSetTracker(ctx context.Context, tr *daemonset.Tracker, trackErrCh <-chan error, doneCh <-chan struct{}) error { + var prevStatus daemonset.DaemonSetStatus + out := statusOutput() + resourceName := fmt.Sprintf("ds/%s", tr.ResourceName) + for { select { + case status := <-tr.Added: + displayDaemonSetStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus) + prevStatus = status case <-tr.Ready: - t.logger.Debugf("DaemonSet %s/%s is ready", tr.Namespace, tr.ResourceName) + displayDaemonSetStatusProgress(out, formatResourceCaption(resourceName, true, false), prevStatus, &prevStatus) + t.logger.Infof("DaemonSet %s/%s is ready", tr.Namespace, tr.ResourceName) return nil case status := <-tr.Failed: + displayDaemonSetStatusProgress(out, formatResourceCaption(resourceName, false, true), status, &prevStatus) return fmt.Errorf("daemonset %s/%s failed: %s", tr.Namespace, tr.ResourceName, status.FailedReason) + case status := <-tr.Status: + if status.StatusGeneration > prevStatus.StatusGeneration { + displayDaemonSetStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus) + prevStatus = status + } + case msg := <-tr.EventMsg: + t.logger.Infof("ds/%s: %s", tr.ResourceName, msg) + case chunk := <-tr.PodLogChunk: + t.logPodLogChunk(chunk.PodName, chunk.LogLines) + case report := <-tr.PodError: + t.logger.Warnf("ds/%s pod %s: %s: %s", tr.ResourceName, report.PodError.PodName, report.PodError.ContainerName, report.PodError.Message) + case <-tr.AddedPod: case err := <-trackErrCh: return err case <-doneCh: @@ -390,13 +456,34 @@ func (t *Tracker) runJobTracker(ctx context.Context, tr *job.Tracker, errCh chan } func (t *Tracker) waitJobTracker(ctx context.Context, tr *job.Tracker, trackErrCh <-chan error, doneCh <-chan struct{}) error { + var prevStatus job.JobStatus + out := statusOutput() + resourceName := fmt.Sprintf("job/%s", tr.ResourceName) + for { select { + case status := <-tr.Added: + displayJobStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus) + prevStatus = status case <-tr.Succeeded: - t.logger.Debugf("Job %s/%s succeeded", tr.Namespace, tr.ResourceName) + displayJobStatusProgress(out, formatResourceCaption(resourceName, true, false), prevStatus, &prevStatus) + t.logger.Infof("Job %s/%s succeeded", tr.Namespace, tr.ResourceName) return nil case status := <-tr.Failed: + displayJobStatusProgress(out, formatResourceCaption(resourceName, false, true), status, &prevStatus) return fmt.Errorf("job %s/%s failed: %s", tr.Namespace, tr.ResourceName, status.FailedReason) + case status := <-tr.Status: + if status.StatusGeneration > prevStatus.StatusGeneration { + displayJobStatusProgress(out, formatResourceCaption(resourceName, false, false), status, &prevStatus) + prevStatus = status + } + case msg := <-tr.EventMsg: + t.logger.Infof("job/%s: %s", tr.ResourceName, msg) + case chunk := <-tr.PodLogChunk: + t.logPodLogChunk(chunk.PodName, chunk.LogLines) + case report := <-tr.PodError: + t.logger.Warnf("job/%s pod %s: %s: %s", tr.ResourceName, report.PodError.PodName, report.PodError.ContainerName, report.PodError.Message) + case <-tr.AddedPod: case err := <-trackErrCh: return err case <-doneCh: @@ -416,13 +503,44 @@ func (t *Tracker) runCanaryTracker(ctx context.Context, tr *canary.Tracker, errC } func (t *Tracker) waitCanaryTracker(ctx context.Context, tr *canary.Tracker, trackErrCh <-chan error, doneCh <-chan struct{}) error { + out := statusOutput() + resourceName := fmt.Sprintf("canary/%s", tr.ResourceName) + var lastView CanaryStatusView + for { select { + case status := <-tr.Added: + view := CanaryStatusView{ + Phase: string(status.CanaryStatus.Phase), + IsFailed: status.IsFailed, + } + displayCanaryStatus(out, formatResourceCaption(resourceName, false, false), view) + lastView = view case <-tr.Succeeded: - t.logger.Debugf("Canary %s/%s succeeded", tr.Namespace, tr.ResourceName) + displayCanaryStatus(out, formatResourceCaption(resourceName, true, false), CanaryStatusView{Phase: lastView.Phase}) + t.logger.Infof("Canary %s/%s succeeded", tr.Namespace, tr.ResourceName) return nil case status := <-tr.Failed: + displayCanaryStatus(out, formatResourceCaption(resourceName, false, true), CanaryStatusView{ + Phase: status.FailedReason, + IsFailed: true, + }) return fmt.Errorf("canary %s/%s failed: %s", tr.Namespace, tr.ResourceName, status.FailedReason) + case status := <-tr.Status: + view := CanaryStatusView{ + Phase: func() string { + if status.StatusIndicator != nil { + return status.StatusIndicator.Value + } + return "" + }(), + Age: status.Age, + IsFailed: status.IsFailed, + } + displayCanaryStatus(out, formatResourceCaption(resourceName, false, false), view) + lastView = view + case msg := <-tr.EventMsg: + t.logger.Infof("canary/%s: %s", tr.ResourceName, msg) case err := <-trackErrCh: return err case <-doneCh: @@ -433,6 +551,12 @@ func (t *Tracker) waitCanaryTracker(ctx context.Context, tr *canary.Tracker, tra } } +func (t *Tracker) logPodLogChunk(podName string, logLines []display.LogLine) { + for _, line := range logLines { + t.logger.Infof("po/%s [%s] %s", podName, line.Timestamp, line.Message) + } +} + func (t *Tracker) buildTargets(resources []*resource.Resource) []trackTarget { var targets []trackTarget for _, res := range resources {