Files
helmfile/pkg/kubedog/watchdog.go
T
yxxheroandRoman Mykhailiuk 9d3a9ad6c7 feat: parallel kubedog tracking with progress printer and safety valves (#2654)
* feat: parallel kubedog tracking with progress printer and safety valves

Rework kubedog integration so resource tracking runs in parallel with
helm upgrade/install, giving live progress output and recovering from
known helm/kubedog wedge conditions.

Core:
- kubedogTrackingHandle runs tracking in a background goroutine alongside
  the helm subprocess (startBackgroundKubedogTracking); helm output is
  buffered and replayed as a single block so it no longer interleaves
  with progress ticks.
- Capture UID+generation baselines before handing off to helm so each
  tracker waits until the resource actually changes (freshness gate).

Progress printer (pkg/kubedog/printer.go):
- Styled, auto-sized progress table with a heartbeat flusher, child (pod)
  status roll-up, pre-ready pod-phase handling, multi-namespace support,
  and optional color. PreviewBreakdown summarizes kept/filtered resources.

Safety valves (verify cluster state via the live API):
- Tracker-race valve (always on): when helm succeeds but a dyntracker
  goroutine is wedged, poll the API and cancel the tracker so wait()
  returns success instead of blocking until --track-timeout.
- Helm-stuck killer (opt-in via helmStuckGrace): if the cluster stays
  converged while helm v4's hook waiter is wedged, SIGINT the helm
  subprocess to recover.
- Failure watchdog (pkg/kubedog/watchdog.go): surface failing pods that
  never made it into dyntracker's resource graph.

Options: trackFailedLogs, helmStuckGrace, trackTimeout, color
(Color/NoColor), and resource filtering (trackKinds/skipKinds/
trackResources). PersistentVolumeClaim support in resource classification.

Signed-off-by: Roman Mykhailiuk <romanm@cybellum.com>

* test: add unit tests for kubedog tracking

Cover the progress printer, resource classification, the failure
watchdog, helm-output trimming/dedup, the release hard-timeout helper,
and the color/track option plumbing.

Signed-off-by: Roman Mykhailiuk <romanm@cybellum.com>

* test: update golden logs, e2e snapshots, and values-id fixtures

Refresh pkg/app testapply/testdestroy golden logs, e2e template
snapshots, and the TestGenerateID values-id golden hashes for the new
kubedog progress output and the merged release struct layout.

Signed-off-by: Roman Mykhailiuk <romanm@cybellum.com>

* refactor: remove dead kubedog display code and dedupe tracker setup

Deep-review pass on the parallel kubedog tracking changes:

- Remove pkg/kubedog/display.go (308 lines) and display_test.go (453 lines).
  These rendered progress for the legacy per-kind trackers that this PR
  replaces; in the merged tree every function is unreferenced outside its
  own tests. The new progressPrinter (printer.go) supersedes them.
  TestMain (color.ForceColor for deterministic ANSI in tests) is preserved
  in a new main_test.go.
- Dedupe trackWithKubedog: the post-helm fallback rebuilt the exact same
  tracker options as buildReleaseTracker. Reuse buildReleaseTracker instead,
  dropping ~40 lines of duplicated timeout/log/filter/tracker construction.

No behavior change; build, go vet, golangci-lint, and the kubedog/state
unit tests all pass.

Signed-off-by: yxxhero <aiopsclub@163.com>

* fix: stop waitForFreshness busy-looping the API after helm finishes

Once upstreamDoneCh closes it is always ready, so the select in
waitForFreshness stopped blocking on the ticker and re-ran probe() (a
live GET) as fast as the round-trip allowed for the whole 3s grace
window — hammering the API server once per tracked resource.

Track a local view of the channel and nil it out on first delivery so
the first hit records the timestamp (one fast retry, as intended) and
all subsequent polls are ticker-throttled. Functional behavior is
unchanged: return nil when fresh, errUpstreamDoneNoChange after grace.

Signed-off-by: yxxhero <aiopsclub@163.com>

* test: drop trailing blank line from helm4 OCI pull snapshots

The trailing-newline trim in helmexec.info() removes the blank line helm
prints after the OCI chart "Digest:" line. The helm3 (output.yaml)
snapshots never captured that line, but the helm4 (output-helm4.yaml)
snapshots for oci_chart_pull{,_direct,_once,_once2} and
issue_473_oci_chart_url_fetch still expected it, so they failed under
helm 4. Remove the blank line so the snapshots match the trimmed output.

Signed-off-by: yxxhero <aiopsclub@163.com>

* test: refresh diff-args integration goldens for styled release headers

DisplayAffectedReleases now emits a styled "========== Updated Releases
==========" header (matching the app/e2e goldens already updated by this
PR) and helmexec.info() trims the trailing blank after helm's install
status. Update the diff-args apply-stderr{,-helm4} and apply-live-
stderr{,-helm4} goldens accordingly so they match the actual stderr.

Signed-off-by: yxxhero <aiopsclub@163.com>

* test: drop trimmed blank line from v1-subhelmfile template golden

The trailing-newline trim in helmexec.info() removes the blank line helm
prints after '"incubator" has been added to your repositories'. Update
the v1-subhelmfile-multi-bases-with-array-values result and result-live
goldens so the template stdout comparison matches.

Signed-off-by: yxxhero <aiopsclub@163.com>

---------

Signed-off-by: Roman Mykhailiuk <romanm@cybellum.com>
Signed-off-by: yxxhero <aiopsclub@163.com>
Co-authored-by: Roman Mykhailiuk <romanm@cybellum.com>
2026-06-21 13:43:17 +08:00

309 lines
11 KiB
Go

package kubedog
import (
"context"
"fmt"
"time"
"github.com/werf/kubedog/pkg/trackers/dyntracker/statestore"
kdutil "github.com/werf/kubedog/pkg/trackers/dyntracker/util"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"github.com/helmfile/helmfile/pkg/resource"
)
// failureWatchdogInterval is how often we re-scan the cluster for failing
// pods that dyntracker hasn't surfaced. 60s keeps API server pressure
// negligible (one LIST per tracked workload per minute) while still
// catching CrashLoopBackOff loops well before --track-timeout fires.
const failureWatchdogInterval = 60 * time.Second
var (
watchdogPodGVK = schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"}
watchdogPodGVR = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}
watchdogReplicaSetGVR = schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "replicasets"}
)
// watchdogWorkload identifies a workload whose pods we'll independently
// LIST against the live API.
type watchdogWorkload struct {
kind string
gvk schema.GroupVersionKind
name string
namespace string
}
// runFailureWatchdog watches for pods that genuinely failed in the cluster
// but never made it into dyntracker's resource graph. This case happens
// when dyntracker loses the Pod-to-Deployment linkage race (Pod CREATE
// event arriving before its ReplicaSet CREATE event) — the Pod is
// completely invisible to kubedog's tracking pipeline: no progress row,
// no log stream, no failed-only-mode entry. Without this watchdog, a Pod
// in CrashLoopBackOff under a missed link would be silent until
// --track-timeout, which is a real safety gap for CI pipelines that rely
// on helmfile to surface failures.
//
// The watchdog runs alongside dyntracker, does not gate any decisions, and
// only emits warnings. It cannot fix the linkage — but it can make sure
// failures are at least visible to the operator.
func (t *Tracker) runFailureWatchdog(ctx context.Context, taskStore *kdutil.Concurrent[*statestore.TaskStore], resources []*resource.Resource) {
workloads := watchdogWorkloads(resources)
if len(workloads) == 0 {
return
}
// warned tracks pod IDs we've already surfaced so we don't repeat the
// same warning every minute for the same failing pod.
warned := map[string]struct{}{}
ticker := time.NewTicker(failureWatchdogInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
t.scanForMissedFailures(ctx, taskStore, workloads, warned)
}
}
}
// watchdogWorkloads filters tracked resources down to the kinds whose
// pods we can reasonably LIST by selector. PVCs have no pods; Canaries
// have complex multi-revision pod sets we shouldn't second-guess.
func watchdogWorkloads(resources []*resource.Resource) []watchdogWorkload {
var out []watchdogWorkload
for _, res := range resources {
kind, gvk, ok := classifyResource(res.Kind)
if !ok {
continue
}
if kind == "pvc" || kind == "canary" {
continue
}
out = append(out, watchdogWorkload{kind: kind, gvk: gvk, name: res.Name, namespace: res.Namespace})
}
return out
}
func (t *Tracker) scanForMissedFailures(ctx context.Context, taskStore *kdutil.Concurrent[*statestore.TaskStore], workloads []watchdogWorkload, warned map[string]struct{}) {
for _, wl := range workloads {
gvr, err := t.gvrFor(wl.gvk)
if err != nil {
continue
}
obj, err := t.dynamicClient.Resource(gvr).Namespace(wl.namespace).Get(ctx, wl.name, metav1.GetOptions{})
if err != nil {
if !apierrors.IsNotFound(err) {
t.logger.Debugf("kubedog watchdog: GET %s/%s/%s failed: %v", wl.kind, wl.namespace, wl.name, err)
}
continue
}
workloadUID := obj.GetUID()
selector := extractPodSelector(obj)
if selector == "" {
continue
}
pods, err := t.dynamicClient.Resource(watchdogPodGVR).Namespace(wl.namespace).List(ctx, metav1.ListOptions{LabelSelector: selector})
if err != nil {
t.logger.Debugf("kubedog watchdog: LIST pods for %s/%s/%s failed: %v", wl.kind, wl.namespace, wl.name, err)
continue
}
// For Deployments, pods are not directly owned by the workload —
// they're owned by a ReplicaSet that's in turn owned by the
// Deployment. We lazy-load the RS UIDs only if we actually have a
// failing pod that doesn't match the workload UID directly.
var deploymentRSUIDs map[string]struct{}
for i := range pods.Items {
pod := &pods.Items[i]
podName := pod.GetName()
podID := kdutil.ResourceID(podName, wl.namespace, watchdogPodGVK)
if _, seen := warned[podID]; seen {
continue
}
reason := detectPodFailureReason(pod)
if reason == "" {
continue
}
if podIsInTaskStore(taskStore, podName, wl.namespace) {
continue
}
// Ownership check: the pod must belong to the current workload,
// not to a previous instance whose pods still share the same
// labels (e.g., a failed pre-install hook whose Job hasn't been
// cleaned up yet). Without this check, the watchdog cries wolf
// every time a stale failing pod is still around at the start
// of a new install.
if !podOwnedBy(pod, workloadUID, wl.kind, func() map[string]struct{} {
if deploymentRSUIDs == nil {
deploymentRSUIDs = t.rsUIDsOwnedBy(ctx, wl.namespace, workloadUID)
}
return deploymentRSUIDs
}) {
continue
}
msg := fmt.Sprintf("[watchdog] Pod %s/%s is failing (%s) under %s/%s/%s but kubedog tracker is not tracking it. Inspect with: kubectl -n %s logs %s",
wl.namespace, podName, reason, wl.kind, wl.namespace, wl.name, wl.namespace, podName)
t.logger.Warnf("%s", StyleWarning(msg, t.trackOptions.Color))
warned[podID] = struct{}{}
}
}
}
// podOwnedBy reports whether the pod's ownerReferences chain leads to the
// given workload UID. For Job/StatefulSet/DaemonSet the workload directly
// owns the pod, so a direct UID match suffices. For Deployment the pod is
// owned by an intermediate ReplicaSet — the caller passes a lazy lookup of
// "RS UIDs owned by this Deployment" so we only LIST RSes when we actually
// need to disambiguate.
func podOwnedBy(pod *unstructured.Unstructured, workloadUID types.UID, workloadKind string, deploymentRSUIDs func() map[string]struct{}) bool {
for _, uid := range podOwnerUIDs(pod) {
if uid == workloadUID {
return true
}
}
if workloadKind != "deploy" {
return false
}
rsUIDs := deploymentRSUIDs()
for _, uid := range podOwnerUIDs(pod) {
if _, ok := rsUIDs[string(uid)]; ok {
return true
}
}
return false
}
func podOwnerUIDs(obj *unstructured.Unstructured) []types.UID {
refs := obj.GetOwnerReferences()
out := make([]types.UID, 0, len(refs))
for _, r := range refs {
out = append(out, r.UID)
}
return out
}
// rsUIDsOwnedBy returns the set of ReplicaSet UIDs in the namespace whose
// metadata.ownerReferences contain the given workload UID. Used to validate
// pod ownership for Deployments, whose pods are one indirection away from
// the workload (Pod → RS → Deployment).
func (t *Tracker) rsUIDsOwnedBy(ctx context.Context, namespace string, workloadUID types.UID) map[string]struct{} {
rsList, err := t.dynamicClient.Resource(watchdogReplicaSetGVR).Namespace(namespace).List(ctx, metav1.ListOptions{})
if err != nil {
t.logger.Debugf("kubedog watchdog: LIST replicasets in %s failed: %v", namespace, err)
return map[string]struct{}{}
}
out := map[string]struct{}{}
for i := range rsList.Items {
rs := &rsList.Items[i]
for _, uid := range podOwnerUIDs(rs) {
if uid == workloadUID {
out[string(rs.GetUID())] = struct{}{}
break
}
}
}
return out
}
// extractPodSelector pulls the workload's pod label selector. Deployments,
// StatefulSets, DaemonSets, and Jobs all use spec.selector.matchLabels by
// convention; we ignore matchExpressions for simplicity (rare in practice
// and the watchdog is best-effort). Returns "" when nothing usable is
// present.
func extractPodSelector(obj *unstructured.Unstructured) string {
matchLabels, found, _ := unstructured.NestedStringMap(obj.Object, "spec", "selector", "matchLabels")
if !found || len(matchLabels) == 0 {
return ""
}
return labels.SelectorFromSet(matchLabels).String()
}
// detectPodFailureReason returns a short reason string for the pod's
// failing state, or "" if the pod looks healthy. Mirrors the phases
// isFailingPodPhase classifies as failing, plus the additional
// terminated-with-error and OOMKilled signals available from container
// statuses.
func detectPodFailureReason(pod *unstructured.Unstructured) string {
if phase, _, _ := unstructured.NestedString(pod.Object, "status", "phase"); phase == "Failed" {
return "Failed"
}
containers, found, _ := unstructured.NestedSlice(pod.Object, "status", "containerStatuses")
if found {
if r := scanContainerStatusesForFailure(containers); r != "" {
return r
}
}
initContainers, found, _ := unstructured.NestedSlice(pod.Object, "status", "initContainerStatuses")
if found {
if r := scanContainerStatusesForFailure(initContainers); r != "" {
return fmt.Sprintf("init: %s", r)
}
}
return ""
}
func scanContainerStatusesForFailure(containers []any) string {
for _, c := range containers {
cMap, ok := c.(map[string]any)
if !ok {
continue
}
state, hasState, _ := unstructured.NestedMap(cMap, "state")
if !hasState {
continue
}
if waiting, ok := state["waiting"].(map[string]any); ok {
if reason, _ := waiting["reason"].(string); isFailingPodPhase(reason) {
return reason
}
}
if terminated, ok := state["terminated"].(map[string]any); ok {
if reason, _ := terminated["reason"].(string); reason == "OOMKilled" || reason == "Error" {
return reason
}
}
}
return ""
}
// podIsInTaskStore returns true if dyntracker has any ResourceState for a
// Pod with the given name/namespace anywhere in the task store. A "true"
// result means kubedog already sees this pod and will surface its state
// through the normal progress/log pipeline; the watchdog should stay
// quiet.
func podIsInTaskStore(taskStore *kdutil.Concurrent[*statestore.TaskStore], podName, namespace string) bool {
var present bool
taskStore.RTransaction(func(s *statestore.TaskStore) {
for _, taskC := range s.ReadinessTasksStates() {
if present {
return
}
taskC.RTransaction(func(ts *statestore.ReadinessTaskState) {
for _, rsC := range ts.ResourceStates() {
if present {
return
}
rsC.RTransaction(func(rs *statestore.ResourceState) {
if rs.GroupVersionKind().Kind == "Pod" && rs.Name() == podName && rs.Namespace() == namespace {
present = true
}
})
}
})
}
})
return present
}