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"
@@ -658,9 +660,126 @@ func WaitUntilAPIServicesReady(ctx context.Context, ext *EnvironmentExtensions,
658660 return fmt .Errorf ("error waiting for group versions to be discoverable: %w" , err )
659661 }
660662
663+ if err := waitUntilAPIGroupsServe (ctx , c , scheme , groupVersions ... ); err != nil {
664+ return fmt .Errorf ("error waiting for aggregated api server to serve requests: %w" , err )
665+ }
666+
667+ return nil
668+ }
669+
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+ // waitUntilAPIGroupsServe waits until the aggregated API server actually serves
685+ // resource round-trips for each of the given group versions by issuing a real
686+ // List per group. Unlike the APIService Available condition and discovery
687+ // (which are reported by / served from the kube-apiserver), a List is proxied
688+ // to the aggregated server, so it observes the transient 503 startup window and
689+ // absorbs it before the test suite proceeds.
690+ func waitUntilAPIGroupsServe (ctx context.Context , c client.Client , scheme * runtime.Scheme , gvs ... schema.GroupVersion ) error {
691+ targets , err := newListRoundTripTargets (scheme , gvs )
692+ if err != nil {
693+ return fmt .Errorf ("error determining list round-trip targets: %w" , err )
694+ }
695+ if len (targets ) == 0 {
696+ return nil
697+ }
698+
699+ requiredSuccesses := int (serveReadinessStableDuration / serveReadinessPollInterval )
700+ if requiredSuccesses < 1 {
701+ requiredSuccesses = 1
702+ }
703+
704+ successes := 0
705+ var lastErr error
706+ if err := wait .PollUntilContextCancel (ctx , serveReadinessPollInterval , true , func (ctx context.Context ) (bool , error ) {
707+ for _ , target := range targets {
708+ if err := c .List (ctx , target .list ); err != nil {
709+ if ! isTransientServeError (err ) {
710+ return false , fmt .Errorf ("unexpected error listing %s: %w" , target .gv , err )
711+ }
712+ lastErr = err
713+ successes = 0
714+ return false , nil
715+ }
716+ }
717+ successes ++
718+ return successes >= requiredSuccesses , nil
719+ }); err != nil {
720+ if lastErr != nil {
721+ return fmt .Errorf ("%w (last error: %v)" , err , lastErr )
722+ }
723+ return err
724+ }
661725 return nil
662726}
663727
728+ // listRoundTripTarget pairs a group version with a reusable list object used to
729+ // issue a real List round-trip against the aggregated API server.
730+ type listRoundTripTarget struct {
731+ gv schema.GroupVersion
732+ list client.ObjectList
733+ }
734+
735+ func newListRoundTripTargets (scheme * runtime.Scheme , gvs []schema.GroupVersion ) ([]listRoundTripTarget , error ) {
736+ var targets []listRoundTripTarget
737+ for _ , gv := range gvs {
738+ list , ok := newListForGroupVersion (scheme , gv )
739+ if ! ok {
740+ continue
741+ }
742+ targets = append (targets , listRoundTripTarget {gv : gv , list : list })
743+ }
744+ return targets , nil
745+ }
746+
747+ // newListForGroupVersion returns a fresh list object for the first list kind
748+ // registered for the given group version (e.g. *VolumeList), constructed via the
749+ // scheme rather than reflect so the scheme stays the single source of truth for
750+ // type instantiation.
751+ func newListForGroupVersion (scheme * runtime.Scheme , gv schema.GroupVersion ) (client.ObjectList , bool ) {
752+ for kind := range scheme .KnownTypes (gv ) {
753+ obj , err := scheme .New (gv .WithKind (kind ))
754+ if err != nil {
755+ continue
756+ }
757+ if list , ok := obj .(client.ObjectList ); ok {
758+ return list , true
759+ }
760+ }
761+ return nil , false
762+ }
763+
764+ // isTransientServeError reports whether an error returned while probing the
765+ // aggregated API server is a transient startup condition worth retrying.
766+ func isTransientServeError (err error ) bool {
767+ if err == nil {
768+ return false
769+ }
770+ if apierrors .IsServiceUnavailable (err ) ||
771+ apierrors .IsServerTimeout (err ) ||
772+ apierrors .IsTimeout (err ) ||
773+ apierrors .IsTooManyRequests (err ) ||
774+ apierrors .IsInternalError (err ) {
775+ return true
776+ }
777+ // Transport-level errors (connection refused, EOF, reset) while the
778+ // aggregated server is still coming up.
779+ var urlErr * url.Error
780+ return errors .As (err , & urlErr )
781+ }
782+
664783func WaitUntilAPIServicesReadyWithTimeout (timeout time.Duration , ext * EnvironmentExtensions , cfg * rest.Config , c client.Client , scheme * runtime.Scheme ) error {
665784 ctx , cancel := context .WithTimeout (context .Background (), timeout )
666785 defer cancel ()
0 commit comments