@ -22,6 +22,7 @@ import (
"fmt"
"log/slog"
"sort"
"sync"
"time"
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/aggregator"
@ -33,6 +34,7 @@ import (
"github.com/fluxcd/cli-utils/pkg/kstatus/watcher"
"github.com/fluxcd/cli-utils/pkg/object"
appsv1 "k8s.io/api/apps/v1"
batchv1 "k8s.io/api/batch/v1"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/client-go/dynamic"
@ -95,11 +97,11 @@ func (w *statusWaiter) WatchUntilReady(resourceList ResourceList, timeout time.D
StatusReaders : append ( w . readers , jobSR , podSR , genericSR ) ,
}
sw . StatusReader = sr
// Hook resources may legitimately disappear while Helm is waiting for them.
// A Job hook with .spec.ttlSecondsAfterFinished is removed by the TTL
// controller as soon as it completes, so a hook that is gone is treated as
// done rather than as a resource that never became ready .
return w . waitFor ( ctx , resourceList , sw , true )
// A Job hook that sets .spec.ttlSecondsAfterFinished is removed by the TTL
// controller as soon as it completes, so it can disappear while Helm is
// still waiting for it. Only those hooks may report NotFound instead of
// completing; every other hook that goes away is still an error .
return w . waitFor ( ctx , resourceList , sw , ttlJobs ( resourceList ) )
}
func ( w * statusWaiter ) Wait ( resourceList ResourceList , timeout time . Duration ) error {
@ -158,7 +160,7 @@ func (w *statusWaiter) waitForDelete(ctx context.Context, resourceList ResourceL
RESTScopeStrategy : watcher . RESTScopeNamespace ,
} )
statusCollector := collector . NewResourceStatusCollector ( resources )
done := statusCollector . ListenWithObserver ( eventCh , statusObserver ( cancel , status . NotFoundStatus , false , w . Logger ( ) ) )
done := statusCollector . ListenWithObserver ( eventCh , statusObserver ( cancel , status . NotFoundStatus , nil, & observedResources { } , w . Logger ( ) ) )
<- done
if statusCollector . Error != nil {
@ -184,13 +186,35 @@ func (w *statusWaiter) waitForDelete(ctx context.Context, resourceList ResourceL
}
func ( w * statusWaiter ) wait ( ctx context . Context , resourceList ResourceList , sw watcher . StatusWatcher ) error {
return w . waitFor ( ctx , resourceList , sw , false )
return w . waitFor ( ctx , resourceList , sw , nil )
}
// waitFor waits until every resource reaches the current status. When
// deletedIsDone is set, a resource that is not found is considered done
// instead of blocking until the timeout expires.
func ( w * statusWaiter ) waitFor ( ctx context . Context , resourceList ResourceList , sw watcher . StatusWatcher , deletedIsDone bool ) error {
// ttlJobs returns the identities of the Jobs in resourceList that set
// .spec.ttlSecondsAfterFinished, which the TTL controller deletes once they
// finish.
func ttlJobs ( resourceList ResourceList ) map [ object . ObjMetadata ] struct { } {
var ttl map [ object . ObjMetadata ] struct { }
for _ , r := range resourceList {
job , ok := AsVersioned ( r ) . ( * batchv1 . Job )
if ! ok || job . Spec . TTLSecondsAfterFinished == nil {
continue
}
obj , err := object . RuntimeToObjMeta ( r . Object )
if err != nil {
continue
}
if ttl == nil {
ttl = map [ object . ObjMetadata ] struct { } { }
}
ttl [ obj ] = struct { } { }
}
return ttl
}
// waitFor waits until every resource reaches the current status. Resources in
// deletedIsDone are considered done when they are not found, instead of
// blocking until the timeout expires.
func ( w * statusWaiter ) waitFor ( ctx context . Context , resourceList ResourceList , sw watcher . StatusWatcher , deletedIsDone map [ object . ObjMetadata ] struct { } ) error {
cancelCtx , cancel := context . WithCancel ( ctx )
defer cancel ( )
resources := [ ] object . ObjMetadata { }
@ -209,7 +233,8 @@ func (w *statusWaiter) waitFor(ctx context.Context, resourceList ResourceList, s
RESTScopeStrategy : watcher . RESTScopeNamespace ,
} )
statusCollector := collector . NewResourceStatusCollector ( resources )
done := statusCollector . ListenWithObserver ( eventCh , statusObserver ( cancel , status . CurrentStatus , deletedIsDone , w . Logger ( ) ) )
observed := & observedResources { }
done := statusCollector . ListenWithObserver ( eventCh , statusObserver ( cancel , status . CurrentStatus , deletedIsDone , observed , w . Logger ( ) ) )
<- done
if statusCollector . Error != nil {
@ -222,9 +247,11 @@ func (w *statusWaiter) waitFor(ctx context.Context, resourceList ResourceList, s
if rs . Status == status . CurrentStatus {
continue
}
if deletedIsDone && rs . Status == status . NotFoundStatus {
if rs . Status == status . NotFoundStatus && observed . wasPresent ( id ) {
if _ , ok := deletedIsDone [ id ] ; ok {
continue
}
}
errs = append ( errs , fmt . Errorf ( "resource %s/%s/%s not ready. status: %s, message: %s" ,
rs . Identifier . GroupKind . Kind , rs . Identifier . Namespace , rs . Identifier . Name , rs . Status , rs . Message ) )
}
@ -251,7 +278,31 @@ func contextWithTimeout(ctx context.Context, timeout time.Duration) (context.Con
return watchtools . ContextWithOptionalTimeout ( ctx , timeout )
}
func statusObserver ( cancel context . CancelFunc , desired status . Status , deletedIsDone bool , logger * slog . Logger ) collector . ObserverFunc {
// observedResources records the resources that were seen on the cluster while
// waiting, so that a resource which disappears can be told apart from one that
// was never there.
type observedResources struct {
mu sync . Mutex
present map [ object . ObjMetadata ] struct { }
}
func ( o * observedResources ) markPresent ( id object . ObjMetadata ) {
o . mu . Lock ( )
defer o . mu . Unlock ( )
if o . present == nil {
o . present = map [ object . ObjMetadata ] struct { } { }
}
o . present [ id ] = struct { } { }
}
func ( o * observedResources ) wasPresent ( id object . ObjMetadata ) bool {
o . mu . Lock ( )
defer o . mu . Unlock ( )
_ , ok := o . present [ id ]
return ok
}
func statusObserver ( cancel context . CancelFunc , desired status . Status , deletedIsDone map [ object . ObjMetadata ] struct { } , observed * observedResources , logger * slog . Logger ) collector . ObserverFunc {
return func ( statusCollector * collector . ResourceStatusCollector , _ event . Event ) {
var rss [ ] * event . ResourceStatus
var nonDesiredResources [ ] * event . ResourceStatus
@ -269,12 +320,19 @@ func statusObserver(cancel context.CancelFunc, desired status.Status, deletedIsD
if rs . Status == status . FailedStatus && desired == status . CurrentStatus {
continue
}
// A hook that is gone has finished its job: the TTL controller
// removes completed Jobs that set .spec.ttlSecondsAfterFinished, and
// the legacy waiter treated a delete event as the end of the wait.
if deletedIsDone && rs . Status == status . NotFoundStatus {
if rs . Status != status . NotFoundStatus {
observed . markPresent ( rs . Identifier )
}
// A Job hook that sets .spec.ttlSecondsAfterFinished is deleted by
// the TTL controller once it completes, so its disappearance ends
// the wait rather than blocking it. This only applies to a hook that
// was seen on the cluster first: one that is already gone when the
// wait starts was never observed running and is still an error.
if rs . Status == status . NotFoundStatus && observed . wasPresent ( rs . Identifier ) {
if _ , ok := deletedIsDone [ rs . Identifier ] ; ok {
continue
}
}
rss = append ( rss , rs )
if rs . Status != desired {
nonDesiredResources = append ( nonDesiredResources , rs )