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 {