Files
helmfile/pkg/kubedog/tracker.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

822 lines
27 KiB
Go

package kubedog
import (
"context"
"errors"
"fmt"
"math"
"os"
"sort"
"strings"
"sync"
"time"
"github.com/werf/kubedog/pkg/informer"
"github.com/werf/kubedog/pkg/trackers/dyntracker"
"github.com/werf/kubedog/pkg/trackers/dyntracker/logstore"
"github.com/werf/kubedog/pkg/trackers/dyntracker/statestore"
kdutil "github.com/werf/kubedog/pkg/trackers/dyntracker/util"
"go.uber.org/zap"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/discovery"
"k8s.io/client-go/discovery/cached/memory"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/restmapper"
"k8s.io/client-go/tools/clientcmd"
"github.com/helmfile/helmfile/pkg/resource"
)
type cacheKey struct {
kubeContext string
kubeconfig string
qps float32
burst int
}
type clientCacheEntry struct {
clientSet kubernetes.Interface
dynamicClient dynamic.Interface
restConfig *rest.Config
discovery discovery.CachedDiscoveryInterface
mapper meta.ResettableRESTMapper
}
var (
kubeInitMu sync.Mutex
clientCache = make(map[cacheKey]clientCacheEntry)
)
type Tracker struct {
logger *zap.SugaredLogger
clientSet kubernetes.Interface
dynamicClient dynamic.Interface
discovery discovery.CachedDiscoveryInterface
mapper meta.ResettableRESTMapper
trackOptions *TrackOptions
filter *resource.ResourceFilter
namespace string
releaseName string
// upstreamDoneCh is closed when the calling code (e.g. helm.SyncRelease)
// finishes. Per-resource freshness gates use it to give up waiting for a
// generation bump that will never come (e.g. when helm produced no diff
// for that resource).
upstreamDoneCh chan struct{}
upstreamOnce sync.Once
// skipped records resources whose freshness gate exited without ever
// observing a change (helm decided not to create/update them, e.g. a
// post-upgrade hook on an install). Shared between TrackResources and
// VerifyAllConverged so the safety valve doesn't keep polling for
// resources that will never exist.
skipped *skippedKeys
}
type TrackerConfig struct {
Logger *zap.SugaredLogger
Namespace string
KubeContext string
Kubeconfig string
// ReleaseName is shown in the progress header (e.g. "Release 'foo'
// progress:"). When empty the printer falls back to a generic header.
ReleaseName string
TrackOptions *TrackOptions
KubedogQPS *float32
KubedogBurst *int
}
func NewTracker(config *TrackerConfig) (*Tracker, error) {
logger := config.Logger
if logger == nil {
logger = zap.NewNop().Sugar()
}
kubeconfig := config.Kubeconfig
if kubeconfig == "" {
kubeconfig = os.Getenv("KUBECONFIG")
}
options := config.TrackOptions
if options == nil {
options = NewTrackOptions()
}
qps := options.QPS
if config.KubedogQPS != nil {
qps = *config.KubedogQPS
}
burst := options.Burst
if config.KubedogBurst != nil {
burst = *config.KubedogBurst
}
if qps <= 0 || math.IsInf(float64(qps), 0) || math.IsNaN(float64(qps)) {
return nil, fmt.Errorf("invalid kubedog QPS %v: must be > 0 and finite", qps)
}
if burst < 1 {
return nil, fmt.Errorf("invalid kubedog burst %v: must be >= 1", burst)
}
cacheEntry, err := getOrCreateClients(config.KubeContext, kubeconfig, qps, burst)
if err != nil {
return nil, fmt.Errorf("failed to initialize kubernetes clients: %w", err)
}
var filter *resource.ResourceFilter
if options.Filter != nil {
filter = resource.NewResourceFilter(options.Filter, logger)
}
return &Tracker{
logger: logger,
clientSet: cacheEntry.clientSet,
dynamicClient: cacheEntry.dynamicClient,
discovery: cacheEntry.discovery,
mapper: cacheEntry.mapper,
trackOptions: options,
filter: filter,
namespace: config.Namespace,
releaseName: config.ReleaseName,
upstreamDoneCh: make(chan struct{}),
skipped: newSkippedKeys(),
}, nil
}
// MarkUpstreamCompleted signals to any in-flight freshness gates that the
// upstream operation (helm upgrade) has finished. Resources whose baseline
// generation never bumped will then exit their gate without attaching kubedog
// rather than wait indefinitely. Safe to call multiple times.
func (t *Tracker) MarkUpstreamCompleted() {
t.upstreamOnce.Do(func() {
close(t.upstreamDoneCh)
})
}
func getOrCreateClients(kubeContext, kubeconfig string, qps float32, burst int) (clientCacheEntry, error) {
key := cacheKey{
kubeContext: kubeContext,
kubeconfig: kubeconfig,
qps: qps,
burst: burst,
}
kubeInitMu.Lock()
if cache, ok := clientCache[key]; ok {
kubeInitMu.Unlock()
return cache, nil
}
kubeInitMu.Unlock()
loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
if kubeconfig != "" {
loadingRules.ExplicitPath = kubeconfig
}
overrides := &clientcmd.ConfigOverrides{}
if kubeContext != "" {
overrides.CurrentContext = kubeContext
}
cc := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, overrides)
restConfig, err := cc.ClientConfig()
if err != nil {
return clientCacheEntry{}, fmt.Errorf("failed to load kubeconfig: %w", err)
}
restConfig.QPS = qps
restConfig.Burst = burst
clientSet, err := kubernetes.NewForConfig(restConfig)
if err != nil {
return clientCacheEntry{}, fmt.Errorf("failed to create kubernetes client: %w", err)
}
dynamicClient, err := dynamic.NewForConfig(restConfig)
if err != nil {
return clientCacheEntry{}, fmt.Errorf("failed to create dynamic client: %w", err)
}
discoveryClient := memory.NewMemCacheClient(clientSet.Discovery())
mapper := restmapper.NewDeferredDiscoveryRESTMapper(discoveryClient)
cache := clientCacheEntry{
clientSet: clientSet,
dynamicClient: dynamicClient,
restConfig: restConfig,
discovery: discoveryClient,
mapper: mapper,
}
kubeInitMu.Lock()
defer kubeInitMu.Unlock()
if existingCache, ok := clientCache[key]; ok {
return existingCache, nil
}
clientCache[key] = cache
return cache, nil
}
type trackTarget struct {
kind string
name string
namespace string
gvk schema.GroupVersionKind
}
// BaselineKey returns the map key used to associate a resource with its
// pre-change ResourceBaseline.
func BaselineKey(kind, namespace, name string) string {
return kind + "/" + namespace + "/" + name
}
// CaptureBaselines fetches the current UID and metadata.generation for each
// resource via the dynamic client. Resources that don't exist yet are
// recorded with Exists=false so the freshness gate can detect first creation.
// Errors other than NotFound are logged and the baseline is omitted (the
// gate will then attach immediately rather than block on a degraded probe).
func (t *Tracker) CaptureBaselines(ctx context.Context, resources []*resource.Resource) map[string]ResourceBaseline {
baselines := make(map[string]ResourceBaseline, len(resources))
for _, res := range resources {
ns := res.Namespace
if ns == "" {
ns = t.namespace
}
kind, gvk, ok := classifyResource(res.Kind)
if !ok {
continue
}
gvr, err := t.gvrFor(gvk)
if err != nil {
t.logger.Debugf("kubedog: cannot resolve GVR for %s/%s/%s baseline: %v", kind, ns, res.Name, err)
continue
}
key := BaselineKey(kind, ns, res.Name)
obj, err := t.dynamicClient.Resource(gvr).Namespace(ns).Get(ctx, res.Name, metav1.GetOptions{})
if err != nil {
if apierrors.IsNotFound(err) {
baselines[key] = ResourceBaseline{Exists: false}
continue
}
t.logger.Debugf("kubedog: baseline fetch for %s/%s/%s failed: %v", kind, ns, res.Name, err)
continue
}
baselines[key] = ResourceBaseline{
UID: obj.GetUID(),
Generation: obj.GetGeneration(),
Exists: true,
}
}
return baselines
}
func (t *Tracker) gvrFor(gvk schema.GroupVersionKind) (schema.GroupVersionResource, error) {
mapping, err := t.mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
if err != nil {
return schema.GroupVersionResource{}, err
}
return mapping.Resource, nil
}
// errUpstreamDoneNoChange signals that the upstream operation (helm) finished
// without the resource ever changing — the goroutine should skip the dyntracker
// attach and exit cleanly.
var errUpstreamDoneNoChange = errors.New("upstream completed without resource change")
// waitForFreshness blocks until the resource has either appeared (when it
// didn't exist at baseline), been recreated (UID changed), or had its spec
// updated (generation > baseline). Returns nil once the resource is fresh,
// errUpstreamDoneNoChange when the upstream operation completed and no change
// was ever observed, or ctx.Err() on cancellation.
func (t *Tracker) waitForFreshness(ctx context.Context, tgt trackTarget, baseline ResourceBaseline, statusCb func(string)) error {
gvr, err := t.gvrFor(tgt.gvk)
if err != nil {
t.logger.Debugf("kubedog: cannot resolve GVR for %s/%s/%s, skipping freshness gate: %v", tgt.kind, tgt.namespace, tgt.name, err)
return nil
}
if statusCb != nil {
if baseline.Exists {
statusCb(fmt.Sprintf("waiting for update (uid=%s gen=%d)", baseline.UID, baseline.Generation))
} else {
statusCb("waiting for creation")
}
}
const (
pollInterval = 500 * time.Millisecond
// Grace window after helm finishes: keep checking briefly in case the
// API server lags. If nothing observed in this window, give up.
postUpstreamGrace = 3 * time.Second
)
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
probe := func() (fresh bool) {
obj, err := t.dynamicClient.Resource(gvr).Namespace(tgt.namespace).Get(ctx, tgt.name, metav1.GetOptions{})
if err != nil {
if !apierrors.IsNotFound(err) {
t.logger.Debugf("kubedog: freshness probe for %s/%s/%s failed: %v", tgt.kind, tgt.namespace, tgt.name, err)
}
return false
}
if !baseline.Exists {
return true
}
if obj.GetUID() != baseline.UID {
return true
}
if obj.GetGeneration() > baseline.Generation {
return true
}
return false
}
var upstreamDoneAt time.Time
// upstreamCh is a local view of t.upstreamDoneCh. We nil it out the first
// time it fires so the select below can't busy-loop on the closed channel
// (a closed channel is always ready, so without this each iteration would
// re-probe the API as fast as the round-trip allows for the whole grace
// window). After it fires once, polling continues ticker-bound.
upstreamCh := t.upstreamDoneCh
for {
if probe() {
return nil
}
if !upstreamDoneAt.IsZero() && time.Since(upstreamDoneAt) >= postUpstreamGrace {
return errUpstreamDoneNoChange
}
select {
case <-ctx.Done():
return ctx.Err()
case <-upstreamCh:
if upstreamDoneAt.IsZero() {
upstreamDoneAt = time.Now()
}
// Record the timestamp once, then stop selecting on the (now
// closed) channel so subsequent polls are ticker-throttled.
upstreamCh = nil
case <-ticker.C:
}
}
}
func (t *Tracker) TrackResources(ctx context.Context, resources []*resource.Resource) error {
if len(resources) == 0 {
t.logger.Info("No resources to track")
return nil
}
filtered := t.filterResources(resources)
if len(filtered) == 0 {
t.logger.Info("No resources to track after filtering")
return nil
}
// The "Tracking N resources from release X with kubedog" line and the
// "Tracking breakdown" summary are emitted by the caller (helmx) as part
// of the per-release preamble block, so we don't repeat them here. Only
// mention the filter when it actually removed something — that's
// behavioral info, not preamble.
if len(filtered) < len(resources) {
t.logger.Infof("kubedog filter kept %d of %d resources", len(filtered), len(resources))
}
targets := t.buildTargets(filtered)
if len(targets) == 0 {
t.logger.Info("No trackable resources found (only Deployment, StatefulSet, DaemonSet, Job, Canary, and PersistentVolumeClaim are supported)")
return nil
}
trackCtx, cancel := context.WithCancel(ctx)
defer cancel()
taskStore := kdutil.NewConcurrent(statestore.NewTaskStore())
logStore := kdutil.NewConcurrent(logstore.NewLogStore())
watchErrCh := make(chan error, 16)
informerFactory := informer.NewConcurrentInformerFactory(
trackCtx.Done(), watchErrCh, t.dynamicClient,
informer.ConcurrentInformerFactoryOptions{},
)
// Drain informer watch errors so the factory doesn't block.
go func() {
for {
select {
case err, ok := <-watchErrCh:
if !ok {
return
}
if err != nil {
t.logger.Warnf("kubedog informer watch error: %v", err)
}
case <-trackCtx.Done():
return
}
}
}()
captureLogsFromTime := time.Now().Add(-t.trackOptions.LogsSince)
// Capture logs whenever either flag wants them. The kubedog tracker
// records logs into the logStore unconditionally; the failed-only mode
// is enforced at print time by the printer.
wantLogsCapture := t.trackOptions.Logs || t.trackOptions.FailedLogsOnly
ignoreLogs := !wantLogsCapture
// failedLogsOnly takes effect only when full streaming isn't already on.
failedLogsOnly := t.trackOptions.FailedLogsOnly && !t.trackOptions.Logs
// skipLogsInPrinter mutes the printer entirely if neither mode is on.
skipLogsInPrinter := !wantLogsCapture
type trackerEntry struct {
target trackTarget
taskState *kdutil.Concurrent[*statestore.ReadinessTaskState]
}
entries := make([]trackerEntry, 0, len(targets))
for _, tgt := range targets {
taskState := kdutil.NewConcurrent(
statestore.NewReadinessTaskState(tgt.name, tgt.namespace, tgt.gvk, statestore.ReadinessTaskStateOptions{}),
)
taskStore.RWTransaction(func(s *statestore.TaskStore) {
s.AddReadinessTaskState(taskState)
})
entries = append(entries, trackerEntry{target: tgt, taskState: taskState})
}
gateStatuses := newGateStatuses()
printer := newProgressPrinter(t.logger, t.releaseName, taskStore, logStore, skipLogsInPrinter, failedLogsOnly, gateStatuses, t.skipped, t.trackOptions.Color)
printerDone := make(chan struct{})
// Spawn a parallel failure watchdog. It catches pods that genuinely
// failed in the cluster but never made it into dyntracker's graph due
// to the Pod-before-ReplicaSet linkage race, so they're at least
// visible to the operator even when kubedog's tree is missing them.
go t.runFailureWatchdog(trackCtx, taskStore, resources)
var trackerWg sync.WaitGroup
errCh := make(chan error, len(entries))
for _, entry := range entries {
trackerWg.Add(1)
tgt := entry.target
ts := entry.taskState
baselineKey := BaselineKey(tgt.kind, tgt.namespace, tgt.name)
baseline, hasBaseline := t.trackOptions.Baselines[baselineKey]
go func() {
defer trackerWg.Done()
if hasBaseline {
err := t.waitForFreshness(trackCtx, tgt, baseline, func(msg string) {
gateStatuses.set(baselineKey, msg)
})
gateStatuses.clear(baselineKey)
switch {
case err == nil:
// resource changed; proceed to attach the tracker
case errors.Is(err, errUpstreamDoneNoChange):
t.logger.Debugf("kubedog: %s/%s/%s unchanged by upstream; skipping tracker", tgt.kind, tgt.namespace, tgt.name)
// Hide this task from the printer output: it never changed,
// so the persistent "progressing" we'd otherwise show is
// misleading.
t.skipped.add(kdutil.ResourceID(tgt.name, tgt.namespace, tgt.gvk))
return
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
return
default:
errCh <- fmt.Errorf("%s/%s freshness gate failed: %w", tgt.kind, tgt.name, err)
return
}
}
dt, err := dyntracker.NewDynamicReadinessTracker(
trackCtx, ts, logStore, informerFactory,
t.clientSet, t.dynamicClient, t.discovery, t.mapper,
dyntracker.DynamicReadinessTrackerOptions{
Timeout: t.trackOptions.Timeout,
CaptureLogsFromTime: captureLogsFromTime,
IgnoreLogs: ignoreLogs,
// Kubedog uses this as a "stream logs for at most N replicas"
// cap. The zero value means "0 replicas", which silently disables
// log streaming even when IgnoreLogs is false. Use a high value
// so all replicas in any realistic deployment stream their logs.
SaveLogsOnlyForNumberOfReplicas: math.MaxInt32,
},
)
if err != nil {
errCh <- fmt.Errorf("create dynamic tracker for %s/%s: %w", tgt.kind, tgt.name, err)
return
}
if err := dt.Track(trackCtx); err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return
}
errCh <- fmt.Errorf("%s/%s tracking failed: %w", tgt.kind, tgt.name, err)
}
}()
}
trackersDone := make(chan struct{})
go func() {
trackerWg.Wait()
close(trackersDone)
}()
go func() {
printer.run(trackCtx, trackersDone)
close(printerDone)
}()
var firstErr error
select {
case err := <-errCh:
firstErr = err
// Surface the error immediately, while it's happening, instead of
// silently canceling everything and only logging at wait() time.
// Without this, the operator sees the progress block stop dead and
// then nothing for minutes (until helm.SyncRelease returns and
// helmfile drains the result channel). A one-line warning here
// tells them right away that kubedog tracking failed and that
// helmfile is now just waiting for helm to finish on its own.
releaseLabel := "kubedog"
if t.releaseName != "" {
releaseLabel = fmt.Sprintf("release '%s'", t.releaseName)
}
t.logger.Warnf("kubedog tracker stopped for %s: %v — helmfile will continue waiting for helm to finish; no more progress/log output until then",
releaseLabel, err)
cancel()
case <-trackersDone:
case <-trackCtx.Done():
firstErr = trackCtx.Err()
}
cancel()
<-trackersDone
<-printerDone
// Drain remaining tracker errors so they're surfaced in logs.
close(errCh)
for err := range errCh {
if firstErr == nil {
firstErr = err
} else {
t.logger.Warnf("additional tracking error: %v", err)
}
}
if firstErr != nil {
return firstErr
}
t.logger.Info("All resources tracked successfully")
return nil
}
func (t *Tracker) buildTargets(resources []*resource.Resource) []trackTarget {
var targets []trackTarget
for _, res := range resources {
namespace := res.Namespace
if namespace == "" {
namespace = t.namespace
}
kind, gvk, ok := classifyResource(res.Kind)
if !ok {
t.logger.Debugf("Skipping unsupported kind %s for resource %s/%s", res.Kind, namespace, res.Name)
continue
}
targets = append(targets, trackTarget{
kind: kind,
name: res.Name,
namespace: namespace,
gvk: gvk,
})
}
return targets
}
// VerifyAllConverged returns true if every tracked resource in the list is in
// a terminal "done" state per its kind's readiness convention, as reported by
// the live API. It's used as a safety valve in the parallel-tracking flow:
// once helm has signaled success and a grace period has elapsed, if the
// cluster confirms everything healthy but the dyntracker is still wedged
// (a known kubedog race where a Job pod completes faster than the resource
// graph updates ResourceStatus to Ready), the caller can confidently cancel
// the tracker instead of waiting out --track-timeout.
//
// Conservative on errors: any failure to fetch or any unrecognized kind
// returns false so the caller keeps polling rather than declaring premature
// success.
func (t *Tracker) VerifyAllConverged(ctx context.Context, resources []*resource.Resource) bool {
for _, res := range resources {
kind, gvk, ok := classifyResource(res.Kind)
if !ok {
// Resource is not a kind we track — skip it. Same semantics as
// buildTargets, so the verification scope matches what kubedog
// is actually waiting on.
continue
}
// Skip resources whose freshness gate exited via
// errUpstreamDoneNoChange: helm decided not to create or update
// them (e.g. a post-upgrade-only hook on an install). They'll
// never exist in the cluster, so polling Get on them would
// permanently return NotFound and block the safety valve.
if t.skipped != nil && t.skipped.has(kdutil.ResourceID(res.Name, res.Namespace, gvk)) {
continue
}
gvr, err := t.gvrFor(gvk)
if err != nil {
t.logger.Debugf("kubedog safety valve: cannot resolve GVR for %s/%s/%s: %v", kind, res.Namespace, res.Name, err)
return false
}
obj, err := t.dynamicClient.Resource(gvr).Namespace(res.Namespace).Get(ctx, res.Name, metav1.GetOptions{})
if err != nil {
if apierrors.IsNotFound(err) {
// Helm reported success but the resource is missing — that's
// not "converged", it's a different problem. Let kubedog
// continue waiting and surface whatever error it picks up.
return false
}
t.logger.Debugf("kubedog safety valve: GET %s/%s/%s failed: %v", kind, res.Namespace, res.Name, err)
return false
}
if !isResourceConverged(kind, obj) {
return false
}
}
return true
}
// isResourceConverged dispatches to the kind-specific terminal-done check.
// Returns false for kinds whose readiness can't be summarized as a single
// API field (e.g. Canary, which has its own multi-phase state machine) — in
// those cases we defer to dyntracker rather than risk false positives.
func isResourceConverged(kind string, obj *unstructured.Unstructured) bool {
switch kind {
case "deploy":
return isDeploymentConverged(obj)
case "sts":
return isStatefulSetConverged(obj)
case "ds":
return isDaemonSetConverged(obj)
case "job":
return isJobConverged(obj)
case "pvc":
return isPVCConverged(obj)
}
return false
}
// isDeploymentConverged: observedGeneration must have caught up with the
// current spec, and availableReplicas must satisfy the desired replica count.
// availableReplicas is the right field (not readyReplicas) because it accounts
// for minReadySeconds — matching kstatus's Current definition.
func isDeploymentConverged(obj *unstructured.Unstructured) bool {
gen, _, _ := unstructured.NestedInt64(obj.Object, "metadata", "generation")
observed, _, _ := unstructured.NestedInt64(obj.Object, "status", "observedGeneration")
if observed < gen {
return false
}
replicas, found, _ := unstructured.NestedInt64(obj.Object, "spec", "replicas")
if !found {
replicas = 1
}
if replicas == 0 {
return true
}
available, _, _ := unstructured.NestedInt64(obj.Object, "status", "availableReplicas")
return available >= replicas
}
// isStatefulSetConverged: same generation gate as Deployment, plus the rolling
// update must be complete (currentRevision == updateRevision) and readyReplicas
// must meet the desired count.
func isStatefulSetConverged(obj *unstructured.Unstructured) bool {
gen, _, _ := unstructured.NestedInt64(obj.Object, "metadata", "generation")
observed, _, _ := unstructured.NestedInt64(obj.Object, "status", "observedGeneration")
if observed < gen {
return false
}
currentRev, _, _ := unstructured.NestedString(obj.Object, "status", "currentRevision")
updateRev, _, _ := unstructured.NestedString(obj.Object, "status", "updateRevision")
if updateRev != "" && currentRev != updateRev {
return false
}
replicas, found, _ := unstructured.NestedInt64(obj.Object, "spec", "replicas")
if !found {
replicas = 1
}
if replicas == 0 {
return true
}
ready, _, _ := unstructured.NestedInt64(obj.Object, "status", "readyReplicas")
return ready >= replicas
}
// isDaemonSetConverged: numberReady must meet desiredNumberScheduled and the
// rolling update must have observed the current spec.
func isDaemonSetConverged(obj *unstructured.Unstructured) bool {
gen, _, _ := unstructured.NestedInt64(obj.Object, "metadata", "generation")
observed, _, _ := unstructured.NestedInt64(obj.Object, "status", "observedGeneration")
if observed < gen {
return false
}
desired, _, _ := unstructured.NestedInt64(obj.Object, "status", "desiredNumberScheduled")
ready, _, _ := unstructured.NestedInt64(obj.Object, "status", "numberReady")
return desired > 0 && ready >= desired
}
// isJobConverged: succeeded count meets the completions target. This is the
// exact check whose miss in dyntracker motivated the safety valve.
func isJobConverged(obj *unstructured.Unstructured) bool {
completions, found, _ := unstructured.NestedInt64(obj.Object, "spec", "completions")
if !found {
completions = 1
}
succeeded, _, _ := unstructured.NestedInt64(obj.Object, "status", "succeeded")
return succeeded >= completions
}
// isPVCConverged: Bound is the only phase that means "usable by workloads."
func isPVCConverged(obj *unstructured.Unstructured) bool {
phase, _, _ := unstructured.NestedString(obj.Object, "status", "phase")
return phase == "Bound"
}
func classifyResource(rawKind string) (string, schema.GroupVersionKind, bool) {
switch strings.ToLower(rawKind) {
case "deployment", "deploy":
return "deploy", schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"}, true
case "statefulset", "sts":
return "sts", schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "StatefulSet"}, true
case "daemonset", "ds":
return "ds", schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "DaemonSet"}, true
case "job":
return "job", schema.GroupVersionKind{Group: "batch", Version: "v1", Kind: "Job"}, true
case "canary":
return "canary", schema.GroupVersionKind{Group: "flagger.app", Version: "v1beta1", Kind: "Canary"}, true
case "persistentvolumeclaim", "pvc":
return "pvc", schema.GroupVersionKind{Group: "", Version: "v1", Kind: "PersistentVolumeClaim"}, true
}
return "", schema.GroupVersionKind{}, false
}
// PreviewBreakdown applies the same filtering and classification that
// TrackResources will, then returns the kept resources and a human-readable
// breakdown string (e.g. "deploys=3, jobs=2, stss=1") suitable for the
// per-release preamble. Returns an empty summary if no resources survive
// filtering or no resources are of a trackable kind. Pure: no logging, no
// state mutation, no API calls.
func (t *Tracker) PreviewBreakdown(resources []*resource.Resource) (kept []*resource.Resource, summary string) {
filtered := t.filterResources(resources)
if len(filtered) == 0 {
return nil, ""
}
targets := t.buildTargets(filtered)
if len(targets) == 0 {
return filtered, ""
}
return filtered, t.targetSummary(targets)
}
func (t *Tracker) targetSummary(targets []trackTarget) string {
counts := make(map[string]int)
for _, tgt := range targets {
counts[tgt.kind]++
}
parts := make([]string, 0, len(counts))
for kind, count := range counts {
parts = append(parts, fmt.Sprintf("%ss=%d", kind, count))
}
sort.Strings(parts)
return strings.Join(parts, ", ")
}
func (t *Tracker) filterResources(resources []*resource.Resource) []*resource.Resource {
if t.filter == nil {
return resources
}
var result []*resource.Resource
for _, res := range resources {
if t.filter.ShouldTrack(res) {
result = append(result, res)
} else {
t.logger.Debugf("Skipping resource %s/%s (kind: %s) based on configuration", res.Namespace, res.Name, res.Kind)
}
}
return result
}