mirror of
https://github.com/helmfile/helmfile.git
synced 2026-09-30 10:31:24 +02:00
* 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>
822 lines
27 KiB
Go
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
|
|
}
|