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 }