77 "context"
88 "errors"
99 "fmt"
10+ "net/url"
1011 "os"
1112 "path/filepath"
1213 "reflect"
@@ -18,6 +19,7 @@ import (
1819 "github.com/ironcore-dev/ironcore/utils/envtest/internal/testing/addr"
1920 "github.com/ironcore-dev/ironcore/utils/envtest/internal/testing/certs"
2021 corev1 "k8s.io/api/core/v1"
22+ apierrors "k8s.io/apimachinery/pkg/api/errors"
2123 "k8s.io/apimachinery/pkg/api/meta"
2224 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2325 "k8s.io/apimachinery/pkg/runtime"
@@ -591,6 +593,10 @@ func WaitUntilGroupVersionsDiscoverable(ctx context.Context, cfg *rest.Config, c
591593 return fmt .Errorf ("error waiting for GVKs to be discoverable: %w" , err )
592594 }
593595
596+ if err := waitUntilGroupVersionsServe (ctx , c , scheme , gvs ... ); err != nil {
597+ return fmt .Errorf ("error waiting for aggregated api server to serve requests: %w" , err )
598+ }
599+
594600 log .Info ("All group versions and REST mappings are discoverable" , "gvs" , gvs )
595601 return nil
596602}
@@ -661,6 +667,110 @@ func WaitUntilAPIServicesReady(ctx context.Context, ext *EnvironmentExtensions,
661667 return nil
662668}
663669
670+ // serveReadinessPollInterval is the interval at which the aggregated API server
671+ // is probed for serving real resource round-trips.
672+ const serveReadinessPollInterval = 100 * time .Millisecond
673+
674+ // serveReadinessStableDuration is how long the aggregated API server must
675+ // continuously serve real resource round-trips before startup is considered
676+ // complete.
677+ //
678+ // The APIService Available condition and group discovery can report ready
679+ // while the aggregated server still returns transient 503 ServiceUnavailable
680+ // responses for a short window afterwards. Requiring sustained success absorbs
681+ // that window so tests observe a genuinely serving API server.
682+ const serveReadinessStableDuration = 500 * time .Millisecond
683+
684+ // waitUntilGroupVersionsServe waits until the aggregated API server actually
685+ // serves resource round-trips for each of the given group versions by issuing a
686+ // real List per group.
687+ //
688+ // Unlike the APIService Available condition and group discovery (which are
689+ // reported by / served from the kube-apiserver), a List is proxied to the
690+ // aggregated server, so it observes the transient 503 startup window and absorbs
691+ // it before the test suite proceeds.
692+ func waitUntilGroupVersionsServe (ctx context.Context , c client.Client , scheme * runtime.Scheme , gvs ... schema.GroupVersion ) error {
693+ var lists []client.ObjectList
694+ for _ , gv := range gvs {
695+ list , ok := newListForGroupVersion (scheme , gv )
696+ if ! ok {
697+ continue
698+ }
699+ lists = append (lists , list )
700+ }
701+ if len (lists ) == 0 {
702+ return nil
703+ }
704+
705+ requiredSuccesses := int (serveReadinessStableDuration / serveReadinessPollInterval )
706+ if requiredSuccesses < 1 {
707+ requiredSuccesses = 1
708+ }
709+
710+ successes := 0
711+ var lastErr error
712+ if err := wait .PollUntilContextCancel (ctx , serveReadinessPollInterval , true , func (ctx context.Context ) (bool , error ) {
713+ for _ , list := range lists {
714+ if err := c .List (ctx , list ); err != nil {
715+ if ! isTransientServeError (err ) {
716+ return false , fmt .Errorf ("unexpected error probing aggregated api server: %w" , err )
717+ }
718+ lastErr = err
719+ successes = 0
720+ return false , nil
721+ }
722+ }
723+ successes ++
724+ return successes >= requiredSuccesses , nil
725+ }); err != nil {
726+ if lastErr != nil {
727+ return fmt .Errorf ("%w (last error: %v)" , err , lastErr )
728+ }
729+ return err
730+ }
731+ return nil
732+ }
733+
734+ // newListForGroupVersion returns a fresh list object for the first list kind
735+ // registered for the given group version (e.g. *VolumeList), constructed via the
736+ // scheme so the scheme stays the single source of truth for type instantiation.
737+ func newListForGroupVersion (scheme * runtime.Scheme , gv schema.GroupVersion ) (client.ObjectList , bool ) {
738+ kinds := make ([]string , 0 , len (scheme .KnownTypes (gv )))
739+ for kind := range scheme .KnownTypes (gv ) {
740+ kinds = append (kinds , kind )
741+ }
742+ sort .Strings (kinds )
743+ for _ , kind := range kinds {
744+ obj , err := scheme .New (gv .WithKind (kind ))
745+ if err != nil {
746+ continue
747+ }
748+ if list , ok := obj .(client.ObjectList ); ok {
749+ return list , true
750+ }
751+ }
752+ return nil , false
753+ }
754+
755+ // isTransientServeError reports whether an error returned while probing the
756+ // aggregated API server is a transient startup condition worth retrying.
757+ func isTransientServeError (err error ) bool {
758+ if err == nil {
759+ return false
760+ }
761+ if apierrors .IsServiceUnavailable (err ) ||
762+ apierrors .IsServerTimeout (err ) ||
763+ apierrors .IsTimeout (err ) ||
764+ apierrors .IsTooManyRequests (err ) ||
765+ apierrors .IsInternalError (err ) {
766+ return true
767+ }
768+ // Transport-level errors (connection refused, EOF, reset) while the
769+ // aggregated server is still coming up.
770+ var urlErr * url.Error
771+ return errors .As (err , & urlErr )
772+ }
773+
664774func WaitUntilAPIServicesReadyWithTimeout (timeout time.Duration , ext * EnvironmentExtensions , cfg * rest.Config , c client.Client , scheme * runtime.Scheme ) error {
665775 ctx , cancel := context .WithTimeout (context .Background (), timeout )
666776 defer cancel ()
0 commit comments