From 7695a5b73ec5a5c50cb793a26f4c0233d9261d3d Mon Sep 17 00:00:00 2001 From: Ludovic Cleroux Date: Wed, 3 Sep 2025 08:38:56 -0400 Subject: [PATCH 1/2] ROX-13227: add HTTP reachability check for Central UI host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add HTTP check before marking Central as ready when using routes - Implement isCentralUIHostReachable function with 10s timeout - Check returns false if host is unreachable or returns error status - Prevents premature ready state when Central UI is not accessible 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../pkg/central/reconciler/reconciler.go | 14 ++++++++ fleetshard/pkg/central/reconciler/util.go | 36 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/fleetshard/pkg/central/reconciler/reconciler.go b/fleetshard/pkg/central/reconciler/reconciler.go index 083f982e7f..1a981fc9fd 100644 --- a/fleetshard/pkg/central/reconciler/reconciler.go +++ b/fleetshard/pkg/central/reconciler/reconciler.go @@ -245,6 +245,20 @@ func (r *CentralReconciler) Reconcile(ctx context.Context, remoteCentral private return installingStatus(), nil } + if r.useRoutes && !isRemoteCentralReady(&remoteCentral) { + // Check whether central UI host is reachable over HTTP. + centralUIReachable, err := isCentralUIHostReachable(ctx, remoteCentral.Spec.UiHost) + if err != nil { + return nil, err + } + if !centralUIReachable { + if isRemoteCentralProvisioning(remoteCentral) && !needsReconcile { // no changes detected, wait until central UI becomes reachable + return nil, ErrCentralNotChanged + } + return installingStatus(), nil + } + } + status, err := r.collectReconciliationStatus(ctx, &remoteCentral) if err != nil { return nil, err diff --git a/fleetshard/pkg/central/reconciler/util.go b/fleetshard/pkg/central/reconciler/util.go index ed57211e0c..b1ae3237e4 100644 --- a/fleetshard/pkg/central/reconciler/util.go +++ b/fleetshard/pkg/central/reconciler/util.go @@ -3,7 +3,9 @@ package reconciler import ( "context" "fmt" + "net/http" "strings" + "time" "github.com/stackrox/acs-fleet-manager/fleetshard/pkg/k8s" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -22,6 +24,7 @@ const ( centralDeploymentName = "central" centralServiceName = "central" oidcType = "oidc" + httpCheckTimeout = 10 * time.Second ) func isCentralDeploymentReady(ctx context.Context, client ctrlClient.Client, namespace string) (bool, error) { @@ -38,6 +41,39 @@ func isCentralDeploymentReady(ctx context.Context, client ctrlClient.Client, nam return deployment.Status.AvailableReplicas > 0 && deployment.Status.UnavailableReplicas == 0, nil } +func isCentralUIHostReachable(ctx context.Context, uiHost string) (bool, error) { + if uiHost == "" { + return false, errors.New("UI host is empty") + } + + // Construct the URL with https scheme + url := fmt.Sprintf("https://%s", uiHost) + + // Create HTTP client with timeout + client := &http.Client{ + Timeout: httpCheckTimeout, + } + + // Create request with context + req, err := http.NewRequestWithContext(ctx, "HEAD", url, nil) + if err != nil { + return false, errors.Wrapf(err, "creating HTTP request for %s", url) + } + + // Perform the request + resp, err := client.Do(req) + if err != nil { + return false, errors.Wrapf(err, "HTTP request failed for %s", url) + } + defer func() { + _ = resp.Body.Close() + }() + + // Accept any response status code in the 2xx or 3xx range as reachable + // This allows for redirects and successful responses + return resp.StatusCode >= 200 && resp.StatusCode < 400, nil +} + // TODO: ROX-11644: doesn't work when fleetshard-sync deployed outside of Central's cluster func getServiceAddress(ctx context.Context, namespace string, client ctrlClient.Client) (string, error) { service := &core.Service{} From 12cf049ba55e111eff88e0f159c75f05bb20425f Mon Sep 17 00:00:00 2001 From: Ludovic Cleroux Date: Wed, 3 Sep 2025 08:55:56 -0400 Subject: [PATCH 2/2] ROX-13227: refactor UI reachability check with dependency injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create CentralUIReachabilityChecker interface for testability - Move HTTP reachability logic to HTTPCentralUIReachabilityChecker - Add MockCentralUIReachabilityChecker for testing - Inject checker dependency in reconciler for better testability - Update tests to use mock checker to ensure consistent behavior 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../pkg/central/reconciler/reconciler.go | 4 +- .../pkg/central/reconciler/reconciler_test.go | 2 + .../reconciler/ui_reachability_checker.go | 62 +++++++++++++++++++ .../ui_reachability_checker_mock.go | 34 ++++++++++ fleetshard/pkg/central/reconciler/util.go | 36 ----------- 5 files changed, 101 insertions(+), 37 deletions(-) create mode 100644 fleetshard/pkg/central/reconciler/ui_reachability_checker.go create mode 100644 fleetshard/pkg/central/reconciler/ui_reachability_checker_mock.go diff --git a/fleetshard/pkg/central/reconciler/reconciler.go b/fleetshard/pkg/central/reconciler/reconciler.go index 1a981fc9fd..505009e20f 100644 --- a/fleetshard/pkg/central/reconciler/reconciler.go +++ b/fleetshard/pkg/central/reconciler/reconciler.go @@ -131,6 +131,7 @@ type CentralReconciler struct { environment string auditLogging config.AuditLogging encryptionKeyGenerator cipher.KeyGenerator + uiReachabilityChecker CentralUIReachabilityChecker managedDbReconciler *managedDbReconciler managedDBEnabled bool @@ -247,7 +248,7 @@ func (r *CentralReconciler) Reconcile(ctx context.Context, remoteCentral private if r.useRoutes && !isRemoteCentralReady(&remoteCentral) { // Check whether central UI host is reachable over HTTP. - centralUIReachable, err := isCentralUIHostReachable(ctx, remoteCentral.Spec.UiHost) + centralUIReachable, err := r.uiReachabilityChecker.IsCentralUIHostReachable(ctx, remoteCentral.Spec.UiHost) if err != nil { return nil, err } @@ -1078,6 +1079,7 @@ func NewCentralReconciler(k8sClient ctrlClient.Client, fleetmanagerClient *fleet environment: opts.Environment, auditLogging: opts.AuditLogging, encryptionKeyGenerator: encryptionKeyGenerator, + uiReachabilityChecker: NewHTTPCentralUIReachabilityChecker(), managedDbReconciler: dbReconciler, managedDBEnabled: opts.ManagedDBEnabled, diff --git a/fleetshard/pkg/central/reconciler/reconciler_test.go b/fleetshard/pkg/central/reconciler/reconciler_test.go index 41fd330d8a..09b7ef0c8d 100644 --- a/fleetshard/pkg/central/reconciler/reconciler_test.go +++ b/fleetshard/pkg/central/reconciler/reconciler_test.go @@ -137,6 +137,8 @@ func getClientTrackerAndReconciler( cipher.AES256KeyGenerator{}, reconcilerOptions, ) + // Override with mock for testing - always return reachable + reconciler.uiReachabilityChecker = NewMockCentralUIReachabilityChecker(true, nil) return fakeClient, tracker, reconciler } diff --git a/fleetshard/pkg/central/reconciler/ui_reachability_checker.go b/fleetshard/pkg/central/reconciler/ui_reachability_checker.go new file mode 100644 index 0000000000..dc82187853 --- /dev/null +++ b/fleetshard/pkg/central/reconciler/ui_reachability_checker.go @@ -0,0 +1,62 @@ +package reconciler + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/pkg/errors" +) + +const ( + httpCheckTimeout = 10 * time.Second +) + +// CentralUIReachabilityChecker checks if a Central UI is reachable +type CentralUIReachabilityChecker interface { + IsCentralUIHostReachable(ctx context.Context, uiHost string) (bool, error) +} + +// HTTPCentralUIReachabilityChecker is the default implementation that performs actual HTTP checks +type HTTPCentralUIReachabilityChecker struct { + httpClient *http.Client +} + +// NewHTTPCentralUIReachabilityChecker creates a new HTTP-based reachability checker +func NewHTTPCentralUIReachabilityChecker() *HTTPCentralUIReachabilityChecker { + return &HTTPCentralUIReachabilityChecker{ + httpClient: &http.Client{ + Timeout: httpCheckTimeout, + }, + } +} + +// IsCentralUIHostReachable performs an HTTP check to verify if the Central UI host is reachable +func (c *HTTPCentralUIReachabilityChecker) IsCentralUIHostReachable(ctx context.Context, uiHost string) (bool, error) { + if uiHost == "" { + return false, errors.New("UI host is empty") + } + + // Construct the URL with https scheme + url := fmt.Sprintf("https://%s", uiHost) + + // Create request with context + req, err := http.NewRequestWithContext(ctx, "HEAD", url, nil) + if err != nil { + return false, errors.Wrapf(err, "creating HTTP request for %s", url) + } + + // Perform the request + resp, err := c.httpClient.Do(req) + if err != nil { + return false, errors.Wrapf(err, "HTTP request failed for %s", url) + } + defer func() { + _ = resp.Body.Close() + }() + + // Accept any response status code in the 2xx or 3xx range as reachable + // This allows for redirects and successful responses + return resp.StatusCode >= 200 && resp.StatusCode < 400, nil +} \ No newline at end of file diff --git a/fleetshard/pkg/central/reconciler/ui_reachability_checker_mock.go b/fleetshard/pkg/central/reconciler/ui_reachability_checker_mock.go new file mode 100644 index 0000000000..55853db529 --- /dev/null +++ b/fleetshard/pkg/central/reconciler/ui_reachability_checker_mock.go @@ -0,0 +1,34 @@ +package reconciler + +import ( + "context" +) + +// MockCentralUIReachabilityChecker is a mock implementation for testing +type MockCentralUIReachabilityChecker struct { + reachable bool + err error +} + +// NewMockCentralUIReachabilityChecker creates a new mock checker +func NewMockCentralUIReachabilityChecker(reachable bool, err error) *MockCentralUIReachabilityChecker { + return &MockCentralUIReachabilityChecker{ + reachable: reachable, + err: err, + } +} + +// IsCentralUIHostReachable returns the mocked reachability status +func (m *MockCentralUIReachabilityChecker) IsCentralUIHostReachable(_ context.Context, _ string) (bool, error) { + return m.reachable, m.err +} + +// SetReachable sets the reachability status for the mock +func (m *MockCentralUIReachabilityChecker) SetReachable(reachable bool) { + m.reachable = reachable +} + +// SetError sets the error for the mock +func (m *MockCentralUIReachabilityChecker) SetError(err error) { + m.err = err +} \ No newline at end of file diff --git a/fleetshard/pkg/central/reconciler/util.go b/fleetshard/pkg/central/reconciler/util.go index b1ae3237e4..ed57211e0c 100644 --- a/fleetshard/pkg/central/reconciler/util.go +++ b/fleetshard/pkg/central/reconciler/util.go @@ -3,9 +3,7 @@ package reconciler import ( "context" "fmt" - "net/http" "strings" - "time" "github.com/stackrox/acs-fleet-manager/fleetshard/pkg/k8s" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -24,7 +22,6 @@ const ( centralDeploymentName = "central" centralServiceName = "central" oidcType = "oidc" - httpCheckTimeout = 10 * time.Second ) func isCentralDeploymentReady(ctx context.Context, client ctrlClient.Client, namespace string) (bool, error) { @@ -41,39 +38,6 @@ func isCentralDeploymentReady(ctx context.Context, client ctrlClient.Client, nam return deployment.Status.AvailableReplicas > 0 && deployment.Status.UnavailableReplicas == 0, nil } -func isCentralUIHostReachable(ctx context.Context, uiHost string) (bool, error) { - if uiHost == "" { - return false, errors.New("UI host is empty") - } - - // Construct the URL with https scheme - url := fmt.Sprintf("https://%s", uiHost) - - // Create HTTP client with timeout - client := &http.Client{ - Timeout: httpCheckTimeout, - } - - // Create request with context - req, err := http.NewRequestWithContext(ctx, "HEAD", url, nil) - if err != nil { - return false, errors.Wrapf(err, "creating HTTP request for %s", url) - } - - // Perform the request - resp, err := client.Do(req) - if err != nil { - return false, errors.Wrapf(err, "HTTP request failed for %s", url) - } - defer func() { - _ = resp.Body.Close() - }() - - // Accept any response status code in the 2xx or 3xx range as reachable - // This allows for redirects and successful responses - return resp.StatusCode >= 200 && resp.StatusCode < 400, nil -} - // TODO: ROX-11644: doesn't work when fleetshard-sync deployed outside of Central's cluster func getServiceAddress(ctx context.Context, namespace string, client ctrlClient.Client) (string, error) { service := &core.Service{}