From 9c13dbfa3e9b5363626b75e2e345a3b1bc032ab6 Mon Sep 17 00:00:00 2001 From: Anatolii Bazko Date: Mon, 15 Jun 2026 14:30:56 +0200 Subject: [PATCH 1/9] chore: Allow DWO to read certificates configured in DWOC to initialize http client Signed-off-by: Anatolii Bazko --- .../workspace/devworkspace_controller.go | 7 +- controllers/workspace/http.go | 128 +++++++++++------- controllers/workspace/http_test.go | 25 +++- controllers/workspace/status.go | 2 +- 4 files changed, 101 insertions(+), 61 deletions(-) diff --git a/controllers/workspace/devworkspace_controller.go b/controllers/workspace/devworkspace_controller.go index 6a25994da..01471b7de 100644 --- a/controllers/workspace/devworkspace_controller.go +++ b/controllers/workspace/devworkspace_controller.go @@ -144,9 +144,6 @@ func (r *DevWorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request reqLogger = reqLogger.WithValues(constants.DevWorkspaceIDLoggerKey, workspace.Status.DevWorkspaceId) reqLogger.Info("Reconciling Workspace", "resolvedConfig", configString) - // Inject ca certificates to the http client, if the certificates configmap is created and defined in the config. - InjectCertificates(r.Client, r.Log) - // Check if the DevWorkspaceRouting instance is marked to be deleted, which is // indicated by the deletion timestamp being set. if workspace.GetDeletionTimestamp() != nil { @@ -264,7 +261,7 @@ func (r *DevWorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request WorkspaceNamespace: workspace.Namespace, Context: ctx, K8sClient: r.Client, - HttpClient: httpClient, + HttpClient: httpClientsHolder.GetHttpClient(config.Routing), DefaultResourceRequirements: workspace.Config.Workspace.DefaultContainerResources, } @@ -788,7 +785,7 @@ func (r *DevWorkspaceReconciler) getWorkspaceId(ctx context.Context, workspace * } func (r *DevWorkspaceReconciler) SetupWithManager(mgr ctrl.Manager) error { - setupHttpClients(mgr.GetClient(), mgr.GetLogger()) + httpClientsHolder = NewDefaultHttpsClientHolder(mgr.GetClient(), mgr.GetLogger()) maxConcurrentReconciles, err := wkspConfig.GetMaxConcurrentReconciles() if err != nil { diff --git a/controllers/workspace/http.go b/controllers/workspace/http.go index d4df30e3e..8ac32c2dd 100644 --- a/controllers/workspace/http.go +++ b/controllers/workspace/http.go @@ -21,7 +21,7 @@ import ( "net/url" "time" - "github.com/devfile/devworkspace-operator/pkg/config" + controller "github.com/devfile/devworkspace-operator/apis/controller/v1alpha1" "k8s.io/apimachinery/pkg/types" @@ -32,87 +32,111 @@ import ( "golang.org/x/net/http/httpproxy" ) -var ( - httpClient *http.Client +type HttpClientsHolder interface { + GetHttpClient(routingConfig *controller.RoutingConfig) *http.Client + GetHealthCheckHttpClient(routingConfig *controller.RoutingConfig) *http.Client +} + +type DefaultHttpsClientHolder struct { + k8s client.Client + logger logr.Logger + + client *http.Client healthCheckHttpClient *http.Client -) +} + +var httpClientsHolder HttpClientsHolder -func setupHttpClients(k8s client.Client, logger logr.Logger) { +func NewDefaultHttpsClientHolder(k8s client.Client, logger logr.Logger) *DefaultHttpsClientHolder { + return &DefaultHttpsClientHolder{k8s: k8s, logger: logger} +} + +func (h *DefaultHttpsClientHolder) GetHttpClient(routingConfig *controller.RoutingConfig) *http.Client { transport := http.DefaultTransport.(*http.Transport).Clone() - healthCheckTransport := http.DefaultTransport.(*http.Transport).Clone() - healthCheckTransport.TLSClientConfig = &tls.Config{ + transport.Proxy = h.getProxyFunc(routingConfig) + transport.TLSClientConfig = &tls.Config{ + RootCAs: h.getCaCertPool(routingConfig), + } + + return &http.Client{ + Transport: transport, + } +} + +func (h *DefaultHttpsClientHolder) GetHealthCheckHttpClient(routingConfig *controller.RoutingConfig) *http.Client { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = h.getProxyFunc(routingConfig) + transport.TLSClientConfig = &tls.Config{ InsecureSkipVerify: true, } - globalConfig := config.GetGlobalConfig() + return &http.Client{ + Transport: transport, + Timeout: 500 * time.Millisecond, + } +} - if globalConfig.Routing != nil && globalConfig.Routing.ProxyConfig != nil { +func (h *DefaultHttpsClientHolder) getProxyFunc(routingConfig *controller.RoutingConfig) func(*http.Request) (*url.URL, error) { + if routingConfig != nil && routingConfig.ProxyConfig != nil { proxyConf := httpproxy.Config{} - if globalConfig.Routing.ProxyConfig.HttpProxy != nil { - proxyConf.HTTPProxy = *globalConfig.Routing.ProxyConfig.HttpProxy + if routingConfig.ProxyConfig.HttpProxy != nil { + proxyConf.HTTPProxy = *routingConfig.ProxyConfig.HttpProxy } - if globalConfig.Routing.ProxyConfig.HttpsProxy != nil { - proxyConf.HTTPSProxy = *globalConfig.Routing.ProxyConfig.HttpsProxy + if routingConfig.ProxyConfig.HttpsProxy != nil { + proxyConf.HTTPSProxy = *routingConfig.ProxyConfig.HttpsProxy } - if globalConfig.Routing.ProxyConfig.NoProxy != nil { - proxyConf.NoProxy = *globalConfig.Routing.ProxyConfig.NoProxy + if routingConfig.ProxyConfig.NoProxy != nil { + proxyConf.NoProxy = *routingConfig.ProxyConfig.NoProxy } - proxyFunc := func(req *http.Request) (*url.URL, error) { + return func(req *http.Request) (*url.URL, error) { return proxyConf.ProxyFunc()(req.URL) } - transport.Proxy = proxyFunc - healthCheckTransport.Proxy = proxyFunc } - httpClient = &http.Client{ - Transport: transport, - } - healthCheckHttpClient = &http.Client{ - Transport: healthCheckTransport, - Timeout: 500 * time.Millisecond, - } - InjectCertificates(k8s, logger) + return nil } -func InjectCertificates(k8s client.Client, logger logr.Logger) { - if certs, ok := readCertificates(k8s, logger); ok { +func (h *DefaultHttpsClientHolder) getCaCertPool(routingConfig *controller.RoutingConfig) *x509.CertPool { + if certs, ok := h.readCertificates(routingConfig); ok { + var caCertPool *x509.CertPool + + systemCertPool, err := x509.SystemCertPool() + if err != nil { + h.logger.Error(err, "Failed to load system cert pool") + caCertPool = x509.NewCertPool() + } else { + caCertPool = systemCertPool + } + for _, certsPem := range certs { - injectCertificates([]byte(certsPem), httpClient.Transport.(*http.Transport), logger) + caCertPool.AppendCertsFromPEM([]byte(certsPem)) } + + return caCertPool } + + return nil } -func readCertificates(k8s client.Client, logger logr.Logger) (map[string]string, bool) { - configmapRef := config.GetGlobalConfig().Routing.TLSCertificateConfigmapRef - if configmapRef == nil { +func (h *DefaultHttpsClientHolder) readCertificates(routingConfig *controller.RoutingConfig) (map[string]string, bool) { + if routingConfig == nil || routingConfig.TLSCertificateConfigmapRef == nil { return nil, false } - configMap := &corev1.ConfigMap{} - namespacedName := &types.NamespacedName{ + + configmapRef := routingConfig.TLSCertificateConfigmapRef + + namespacedName := types.NamespacedName{ Name: configmapRef.Name, Namespace: configmapRef.Namespace, } - err := k8s.Get(context.Background(), *namespacedName, configMap) + + configMap := &corev1.ConfigMap{} + err := h.k8s.Get(context.Background(), namespacedName, configMap) if err != nil { - logger.Error(err, "Failed to read configmap with certificates") + h.logger.Error(err, "Failed to read configmap with certificates") return nil, false } - return configMap.Data, true -} -func injectCertificates(certsPem []byte, transport *http.Transport, logger logr.Logger) { - caCertPool := transport.TLSClientConfig.RootCAs - if caCertPool == nil { - systemCertPool, err := x509.SystemCertPool() - if err != nil { - logger.Error(err, "Failed to load system cert pool") - caCertPool = x509.NewCertPool() - } else { - caCertPool = systemCertPool - } - } - if ok := caCertPool.AppendCertsFromPEM(certsPem); ok { - transport.TLSClientConfig = &tls.Config{RootCAs: caCertPool} - } + return configMap.Data, true } diff --git a/controllers/workspace/http_test.go b/controllers/workspace/http_test.go index a32d3c196..ce84a64ba 100644 --- a/controllers/workspace/http_test.go +++ b/controllers/workspace/http_test.go @@ -13,9 +13,28 @@ package controllers -import "net/http" +import ( + "net/http" + + controller "github.com/devfile/devworkspace-operator/apis/controller/v1alpha1" +) + +type TestHttpsClientHolder struct { + client *http.Client + healthCheckHttpClient *http.Client +} + +func (t *TestHttpsClientHolder) GetHttpClient(_ *controller.RoutingConfig) *http.Client { + return t.client +} + +func (t *TestHttpsClientHolder) GetHealthCheckHttpClient(_ *controller.RoutingConfig) *http.Client { + return t.healthCheckHttpClient +} func SetupHttpClientsForTesting(client *http.Client) { - httpClient = client - healthCheckHttpClient = client + httpClientsHolder = &TestHttpsClientHolder{ + client: client, + healthCheckHttpClient: client, + } } diff --git a/controllers/workspace/status.go b/controllers/workspace/status.go index 6f359302e..c7d78d800 100644 --- a/controllers/workspace/status.go +++ b/controllers/workspace/status.go @@ -210,7 +210,7 @@ func checkServerStatus(workspace *common.DevWorkspaceWithConfig) (ok bool, respo } healthz.Path = path.Join(healthz.Path, "healthz") - resp, err := healthCheckHttpClient.Get(healthz.String()) + resp, err := httpClientsHolder.GetHealthCheckHttpClient(workspace.Config.Routing).Get(healthz.String()) if err != nil { return false, nil, &dwerrors.RetryError{Err: err, Message: "Failed to check server status", RequeueAfter: 1 * time.Second} } From de8aa1d3ab94f99d0abd109c0c75b33dfc20a35c Mon Sep 17 00:00:00 2001 From: Anatolii Bazko Date: Mon, 15 Jun 2026 14:38:29 +0200 Subject: [PATCH 2/9] chore: Allow DWO to read certificates configured in DWOC to initialize http client Signed-off-by: Anatolii Bazko --- controllers/workspace/http.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/controllers/workspace/http.go b/controllers/workspace/http.go index 8ac32c2dd..7b17aaf44 100644 --- a/controllers/workspace/http.go +++ b/controllers/workspace/http.go @@ -40,9 +40,6 @@ type HttpClientsHolder interface { type DefaultHttpsClientHolder struct { k8s client.Client logger logr.Logger - - client *http.Client - healthCheckHttpClient *http.Client } var httpClientsHolder HttpClientsHolder @@ -110,7 +107,9 @@ func (h *DefaultHttpsClientHolder) getCaCertPool(routingConfig *controller.Routi } for _, certsPem := range certs { - caCertPool.AppendCertsFromPEM([]byte(certsPem)) + if !caCertPool.AppendCertsFromPEM([]byte(certsPem)) { + h.logger.Info("Warning: failed to parse one or more certificates from ConfigMap") + } } return caCertPool From b1b3bace1c942074289ba376f66388a3243f16a9 Mon Sep 17 00:00:00 2001 From: Anatolii Bazko Date: Mon, 15 Jun 2026 16:27:35 +0200 Subject: [PATCH 3/9] chore: Allow DWO to read certificates configured in DWOC to initialize http client Signed-off-by: Anatolii Bazko --- .../workspace/devworkspace_controller.go | 6 +- controllers/workspace/http.go | 90 ++++++++++++------- controllers/workspace/http_test.go | 6 +- controllers/workspace/status.go | 9 +- 4 files changed, 75 insertions(+), 36 deletions(-) diff --git a/controllers/workspace/devworkspace_controller.go b/controllers/workspace/devworkspace_controller.go index 01471b7de..1573d577d 100644 --- a/controllers/workspace/devworkspace_controller.go +++ b/controllers/workspace/devworkspace_controller.go @@ -144,6 +144,8 @@ func (r *DevWorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request reqLogger = reqLogger.WithValues(constants.DevWorkspaceIDLoggerKey, workspace.Status.DevWorkspaceId) reqLogger.Info("Reconciling Workspace", "resolvedConfig", configString) + httpClientsHolder.ConfigureHttpClients(config.Routing) + // Check if the DevWorkspaceRouting instance is marked to be deleted, which is // indicated by the deletion timestamp being set. if workspace.GetDeletionTimestamp() != nil { @@ -261,7 +263,7 @@ func (r *DevWorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request WorkspaceNamespace: workspace.Namespace, Context: ctx, K8sClient: r.Client, - HttpClient: httpClientsHolder.GetHttpClient(config.Routing), + HttpClient: httpClientsHolder.GetHttpClient(), DefaultResourceRequirements: workspace.Config.Workspace.DefaultContainerResources, } @@ -785,7 +787,7 @@ func (r *DevWorkspaceReconciler) getWorkspaceId(ctx context.Context, workspace * } func (r *DevWorkspaceReconciler) SetupWithManager(mgr ctrl.Manager) error { - httpClientsHolder = NewDefaultHttpsClientHolder(mgr.GetClient(), mgr.GetLogger()) + httpClientsHolder = NewDefaultHttpClientsHolder(mgr.GetClient(), mgr.GetLogger()) maxConcurrentReconciles, err := wkspConfig.GetMaxConcurrentReconciles() if err != nil { diff --git a/controllers/workspace/http.go b/controllers/workspace/http.go index 7b17aaf44..3b8a4a82b 100644 --- a/controllers/workspace/http.go +++ b/controllers/workspace/http.go @@ -22,6 +22,7 @@ import ( "time" controller "github.com/devfile/devworkspace-operator/apis/controller/v1alpha1" + "github.com/devfile/devworkspace-operator/pkg/config" "k8s.io/apimachinery/pkg/types" @@ -32,48 +33,83 @@ import ( "golang.org/x/net/http/httpproxy" ) +var httpClientsHolder HttpClientsHolder + type HttpClientsHolder interface { - GetHttpClient(routingConfig *controller.RoutingConfig) *http.Client - GetHealthCheckHttpClient(routingConfig *controller.RoutingConfig) *http.Client + GetHttpClient() *http.Client + GetHealthCheckHttpClient() *http.Client + ConfigureHttpClients(routingConfig *controller.RoutingConfig) } -type DefaultHttpsClientHolder struct { +type DefaultHttpClientsHolder struct { k8s client.Client logger logr.Logger -} -var httpClientsHolder HttpClientsHolder + client *http.Client + healthCheckHttpClient *http.Client -func NewDefaultHttpsClientHolder(k8s client.Client, logger logr.Logger) *DefaultHttpsClientHolder { - return &DefaultHttpsClientHolder{k8s: k8s, logger: logger} + defaultCertPool *x509.CertPool } -func (h *DefaultHttpsClientHolder) GetHttpClient(routingConfig *controller.RoutingConfig) *http.Client { - transport := http.DefaultTransport.(*http.Transport).Clone() - transport.Proxy = h.getProxyFunc(routingConfig) - transport.TLSClientConfig = &tls.Config{ - RootCAs: h.getCaCertPool(routingConfig), +func NewDefaultHttpClientsHolder(k8s client.Client, logger logr.Logger) *DefaultHttpClientsHolder { + defaultCertPool, err := x509.SystemCertPool() + if err != nil { + logger.Error(err, "Failed to load system cert pool") + defaultCertPool = x509.NewCertPool() } - return &http.Client{ - Transport: transport, + clientsHolder := &DefaultHttpClientsHolder{ + k8s: k8s, + logger: logger, + + defaultCertPool: defaultCertPool, } + + clientsHolder.setupHttpClients() + clientsHolder.ConfigureHttpClients(config.GetGlobalConfig().Routing) + + return clientsHolder } -func (h *DefaultHttpsClientHolder) GetHealthCheckHttpClient(routingConfig *controller.RoutingConfig) *http.Client { +func (h *DefaultHttpClientsHolder) GetHttpClient() *http.Client { + return h.client +} + +func (h *DefaultHttpClientsHolder) GetHealthCheckHttpClient() *http.Client { + return h.healthCheckHttpClient +} + +func (t *DefaultHttpClientsHolder) ConfigureHttpClients(routingConfig *controller.RoutingConfig) { + proxyFunc := t.getProxyFunc(routingConfig) + caCertPool := t.getCaCertPool(routingConfig) + + t.client.Transport.(*http.Transport).Proxy = proxyFunc + t.client.Transport.(*http.Transport).TLSClientConfig = &tls.Config{ + RootCAs: caCertPool, + } + + t.healthCheckHttpClient.Transport.(*http.Transport).Proxy = proxyFunc +} + +func (t *DefaultHttpClientsHolder) setupHttpClients() { transport := http.DefaultTransport.(*http.Transport).Clone() - transport.Proxy = h.getProxyFunc(routingConfig) - transport.TLSClientConfig = &tls.Config{ + + t.client = &http.Client{ + Transport: transport, + } + + healthCheckTransport := http.DefaultTransport.(*http.Transport).Clone() + healthCheckTransport.TLSClientConfig = &tls.Config{ InsecureSkipVerify: true, } - return &http.Client{ - Transport: transport, + t.healthCheckHttpClient = &http.Client{ + Transport: healthCheckTransport, Timeout: 500 * time.Millisecond, } } -func (h *DefaultHttpsClientHolder) getProxyFunc(routingConfig *controller.RoutingConfig) func(*http.Request) (*url.URL, error) { +func (h *DefaultHttpClientsHolder) getProxyFunc(routingConfig *controller.RoutingConfig) func(*http.Request) (*url.URL, error) { if routingConfig != nil && routingConfig.ProxyConfig != nil { proxyConf := httpproxy.Config{} if routingConfig.ProxyConfig.HttpProxy != nil { @@ -94,17 +130,9 @@ func (h *DefaultHttpsClientHolder) getProxyFunc(routingConfig *controller.Routin return nil } -func (h *DefaultHttpsClientHolder) getCaCertPool(routingConfig *controller.RoutingConfig) *x509.CertPool { +func (h *DefaultHttpClientsHolder) getCaCertPool(routingConfig *controller.RoutingConfig) *x509.CertPool { if certs, ok := h.readCertificates(routingConfig); ok { - var caCertPool *x509.CertPool - - systemCertPool, err := x509.SystemCertPool() - if err != nil { - h.logger.Error(err, "Failed to load system cert pool") - caCertPool = x509.NewCertPool() - } else { - caCertPool = systemCertPool - } + caCertPool := h.defaultCertPool.Clone() for _, certsPem := range certs { if !caCertPool.AppendCertsFromPEM([]byte(certsPem)) { @@ -118,7 +146,7 @@ func (h *DefaultHttpsClientHolder) getCaCertPool(routingConfig *controller.Routi return nil } -func (h *DefaultHttpsClientHolder) readCertificates(routingConfig *controller.RoutingConfig) (map[string]string, bool) { +func (h *DefaultHttpClientsHolder) readCertificates(routingConfig *controller.RoutingConfig) (map[string]string, bool) { if routingConfig == nil || routingConfig.TLSCertificateConfigmapRef == nil { return nil, false } diff --git a/controllers/workspace/http_test.go b/controllers/workspace/http_test.go index ce84a64ba..3c450bbbc 100644 --- a/controllers/workspace/http_test.go +++ b/controllers/workspace/http_test.go @@ -24,14 +24,16 @@ type TestHttpsClientHolder struct { healthCheckHttpClient *http.Client } -func (t *TestHttpsClientHolder) GetHttpClient(_ *controller.RoutingConfig) *http.Client { +func (t *TestHttpsClientHolder) GetHttpClient() *http.Client { return t.client } -func (t *TestHttpsClientHolder) GetHealthCheckHttpClient(_ *controller.RoutingConfig) *http.Client { +func (t *TestHttpsClientHolder) GetHealthCheckHttpClient() *http.Client { return t.healthCheckHttpClient } +func (t *TestHttpsClientHolder) ConfigureHttpClients(_ *controller.RoutingConfig) {} + func SetupHttpClientsForTesting(client *http.Client) { httpClientsHolder = &TestHttpsClientHolder{ client: client, diff --git a/controllers/workspace/status.go b/controllers/workspace/status.go index c7d78d800..494ca9487 100644 --- a/controllers/workspace/status.go +++ b/controllers/workspace/status.go @@ -210,7 +210,14 @@ func checkServerStatus(workspace *common.DevWorkspaceWithConfig) (ok bool, respo } healthz.Path = path.Join(healthz.Path, "healthz") - resp, err := httpClientsHolder.GetHealthCheckHttpClient(workspace.Config.Routing).Get(healthz.String()) + resp, err := httpClientsHolder.GetHealthCheckHttpClient().Get(healthz.String()) + + defer func() { + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + }() + if err != nil { return false, nil, &dwerrors.RetryError{Err: err, Message: "Failed to check server status", RequeueAfter: 1 * time.Second} } From d1fa4a5e0cf38f596587c2568b48b27bb1bf11d1 Mon Sep 17 00:00:00 2001 From: Anatolii Bazko Date: Tue, 16 Jun 2026 10:36:54 +0200 Subject: [PATCH 4/9] chore: Allow DWO to read certificates configured in DWOC to initialize http client Signed-off-by: Anatolii Bazko --- .../workspace/devworkspace_controller.go | 4 +- controllers/workspace/http.go | 172 ++++++++++++------ 2 files changed, 123 insertions(+), 53 deletions(-) diff --git a/controllers/workspace/devworkspace_controller.go b/controllers/workspace/devworkspace_controller.go index 1573d577d..4e1af1b47 100644 --- a/controllers/workspace/devworkspace_controller.go +++ b/controllers/workspace/devworkspace_controller.go @@ -141,11 +141,11 @@ func (r *DevWorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request workspace.DevWorkspace = rawWorkspace workspace.Config = config + httpClientsHolder.ConfigureHttpClients(config.Routing) + reqLogger = reqLogger.WithValues(constants.DevWorkspaceIDLoggerKey, workspace.Status.DevWorkspaceId) reqLogger.Info("Reconciling Workspace", "resolvedConfig", configString) - httpClientsHolder.ConfigureHttpClients(config.Routing) - // Check if the DevWorkspaceRouting instance is marked to be deleted, which is // indicated by the deletion timestamp being set. if workspace.GetDeletionTimestamp() != nil { diff --git a/controllers/workspace/http.go b/controllers/workspace/http.go index 3b8a4a82b..a39478c72 100644 --- a/controllers/workspace/http.go +++ b/controllers/workspace/http.go @@ -1,4 +1,4 @@ -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -19,11 +19,12 @@ import ( "crypto/x509" "net/http" "net/url" + "reflect" + "sync" "time" controller "github.com/devfile/devworkspace-operator/apis/controller/v1alpha1" "github.com/devfile/devworkspace-operator/pkg/config" - "k8s.io/apimachinery/pkg/types" "github.com/go-logr/logr" @@ -48,6 +49,11 @@ type DefaultHttpClientsHolder struct { client *http.Client healthCheckHttpClient *http.Client + mu sync.RWMutex + + lastProxyConfig *controller.Proxy + lastCertsCMVersion string + defaultCertPool *x509.CertPool } @@ -59,67 +65,133 @@ func NewDefaultHttpClientsHolder(k8s client.Client, logger logr.Logger) *Default } clientsHolder := &DefaultHttpClientsHolder{ - k8s: k8s, - logger: logger, - + k8s: k8s, + logger: logger, defaultCertPool: defaultCertPool, } - clientsHolder.setupHttpClients() clientsHolder.ConfigureHttpClients(config.GetGlobalConfig().Routing) return clientsHolder } func (h *DefaultHttpClientsHolder) GetHttpClient() *http.Client { + h.mu.RLock() + defer h.mu.RUnlock() + return h.client } func (h *DefaultHttpClientsHolder) GetHealthCheckHttpClient() *http.Client { + h.mu.RLock() + defer h.mu.RUnlock() + return h.healthCheckHttpClient } -func (t *DefaultHttpClientsHolder) ConfigureHttpClients(routingConfig *controller.RoutingConfig) { - proxyFunc := t.getProxyFunc(routingConfig) - caCertPool := t.getCaCertPool(routingConfig) +func (h *DefaultHttpClientsHolder) ConfigureHttpClients(routingConfig *controller.RoutingConfig) { + var proxyConfig *controller.Proxy + var certsCM *corev1.ConfigMap + + var buildNewHttpClient bool + var buildNewHealthCheckHttpClient bool + + var newClient *http.Client + var newHealthCheckClient *http.Client - t.client.Transport.(*http.Transport).Proxy = proxyFunc - t.client.Transport.(*http.Transport).TLSClientConfig = &tls.Config{ - RootCAs: caCertPool, + if routingConfig != nil { + if routingConfig.ProxyConfig != nil { + proxyConfig = routingConfig.ProxyConfig + } + if routingConfig.TLSCertificateConfigmapRef != nil { + certsCM = h.readCertCM(routingConfig.TLSCertificateConfigmapRef) + } } - t.healthCheckHttpClient.Transport.(*http.Transport).Proxy = proxyFunc -} + h.mu.RLock() -func (t *DefaultHttpClientsHolder) setupHttpClients() { - transport := http.DefaultTransport.(*http.Transport).Clone() + certsCMVersion := "" + if certsCM != nil { + certsCMVersion = certsCM.ResourceVersion + } - t.client = &http.Client{ - Transport: transport, + if certsCMVersion != h.lastCertsCMVersion { + buildNewHttpClient = true } - healthCheckTransport := http.DefaultTransport.(*http.Transport).Clone() - healthCheckTransport.TLSClientConfig = &tls.Config{ - InsecureSkipVerify: true, + if !reflect.DeepEqual(proxyConfig, h.lastProxyConfig) { + buildNewHealthCheckHttpClient = true + buildNewHttpClient = true } - t.healthCheckHttpClient = &http.Client{ - Transport: healthCheckTransport, - Timeout: 500 * time.Millisecond, + h.mu.RUnlock() + + if buildNewHttpClient || buildNewHealthCheckHttpClient { + proxyFunc := h.getProxyFunc(proxyConfig) + caCertPool := h.getCaCertPool(certsCM) + + if buildNewHttpClient { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = proxyFunc + transport.TLSClientConfig = &tls.Config{ + RootCAs: caCertPool, + } + + newClient = &http.Client{ + Transport: transport, + } + } + + if buildNewHealthCheckHttpClient { + healthCheckTransport := http.DefaultTransport.(*http.Transport).Clone() + healthCheckTransport.Proxy = proxyFunc + healthCheckTransport.TLSClientConfig = &tls.Config{ + InsecureSkipVerify: true, + } + + newHealthCheckClient = &http.Client{ + Transport: healthCheckTransport, + Timeout: 500 * time.Millisecond, + } + } + } + + h.mu.Lock() + + if newClient != nil { + h.client = newClient + } + + if newHealthCheckClient != nil { + h.healthCheckHttpClient = newHealthCheckClient + } + + if proxyConfig != nil { + h.lastProxyConfig = proxyConfig.DeepCopy() + } else { + h.lastProxyConfig = nil } + + if certsCM != nil { + h.lastCertsCMVersion = certsCM.ResourceVersion + } else { + h.lastCertsCMVersion = "" + } + + h.mu.Unlock() } -func (h *DefaultHttpClientsHolder) getProxyFunc(routingConfig *controller.RoutingConfig) func(*http.Request) (*url.URL, error) { - if routingConfig != nil && routingConfig.ProxyConfig != nil { +func (h *DefaultHttpClientsHolder) getProxyFunc(proxyConfig *controller.Proxy) func(*http.Request) (*url.URL, error) { + if proxyConfig != nil { proxyConf := httpproxy.Config{} - if routingConfig.ProxyConfig.HttpProxy != nil { - proxyConf.HTTPProxy = *routingConfig.ProxyConfig.HttpProxy + if proxyConfig.HttpProxy != nil { + proxyConf.HTTPProxy = *proxyConfig.HttpProxy } - if routingConfig.ProxyConfig.HttpsProxy != nil { - proxyConf.HTTPSProxy = *routingConfig.ProxyConfig.HttpsProxy + if proxyConfig.HttpsProxy != nil { + proxyConf.HTTPSProxy = *proxyConfig.HttpsProxy } - if routingConfig.ProxyConfig.NoProxy != nil { - proxyConf.NoProxy = *routingConfig.ProxyConfig.NoProxy + if proxyConfig.NoProxy != nil { + proxyConf.NoProxy = *proxyConfig.NoProxy } return func(req *http.Request) (*url.URL, error) { @@ -130,40 +202,38 @@ func (h *DefaultHttpClientsHolder) getProxyFunc(routingConfig *controller.Routin return nil } -func (h *DefaultHttpClientsHolder) getCaCertPool(routingConfig *controller.RoutingConfig) *x509.CertPool { - if certs, ok := h.readCertificates(routingConfig); ok { - caCertPool := h.defaultCertPool.Clone() +func (h *DefaultHttpClientsHolder) getCaCertPool(cm *corev1.ConfigMap) *x509.CertPool { + if cm == nil { + return nil + } - for _, certsPem := range certs { - if !caCertPool.AppendCertsFromPEM([]byte(certsPem)) { - h.logger.Info("Warning: failed to parse one or more certificates from ConfigMap") - } - } + caCertPool := h.defaultCertPool.Clone() - return caCertPool + for _, certsPem := range cm.Data { + if !caCertPool.AppendCertsFromPEM([]byte(certsPem)) { + h.logger.Info("Warning: failed to parse one or more certificates from ConfigMap") + } } - return nil + return caCertPool } -func (h *DefaultHttpClientsHolder) readCertificates(routingConfig *controller.RoutingConfig) (map[string]string, bool) { - if routingConfig == nil || routingConfig.TLSCertificateConfigmapRef == nil { - return nil, false +func (h *DefaultHttpClientsHolder) readCertCM(cmReference *controller.ConfigmapReference) *corev1.ConfigMap { + if cmReference == nil { + return nil } - configmapRef := routingConfig.TLSCertificateConfigmapRef - namespacedName := types.NamespacedName{ - Name: configmapRef.Name, - Namespace: configmapRef.Namespace, + Name: cmReference.Name, + Namespace: cmReference.Namespace, } configMap := &corev1.ConfigMap{} err := h.k8s.Get(context.Background(), namespacedName, configMap) if err != nil { h.logger.Error(err, "Failed to read configmap with certificates") - return nil, false + return nil } - return configMap.Data, true + return configMap } From 8e5c4d59ea1c8d6d6846cbfb349d380ab5d249c3 Mon Sep 17 00:00:00 2001 From: Anatolii Bazko Date: Tue, 16 Jun 2026 11:30:12 +0200 Subject: [PATCH 5/9] chore: Allow DWO to read certificates configured in DWOC to initialize http client Signed-off-by: Anatolii Bazko --- controllers/workspace/http.go | 130 +++++++++++++++++++++------------- 1 file changed, 81 insertions(+), 49 deletions(-) diff --git a/controllers/workspace/http.go b/controllers/workspace/http.go index a39478c72..bb1020f27 100644 --- a/controllers/workspace/http.go +++ b/controllers/workspace/http.go @@ -90,73 +90,107 @@ func (h *DefaultHttpClientsHolder) GetHealthCheckHttpClient() *http.Client { } func (h *DefaultHttpClientsHolder) ConfigureHttpClients(routingConfig *controller.RoutingConfig) { - var proxyConfig *controller.Proxy - var certsCM *corev1.ConfigMap - - var buildNewHttpClient bool - var buildNewHealthCheckHttpClient bool - - var newClient *http.Client - var newHealthCheckClient *http.Client + var newProxyConfig *controller.Proxy + var newCertsCM *corev1.ConfigMap if routingConfig != nil { if routingConfig.ProxyConfig != nil { - proxyConfig = routingConfig.ProxyConfig + newProxyConfig = routingConfig.ProxyConfig } if routingConfig.TLSCertificateConfigmapRef != nil { - certsCM = h.readCertCM(routingConfig.TLSCertificateConfigmapRef) + newCertsCM = h.readCertCM(routingConfig.TLSCertificateConfigmapRef) } } + buildNewHttpClient, buildNewHealthCheckHttpClient := h.shouldRebuildClients(newProxyConfig, newCertsCM) + + if buildNewHttpClient || buildNewHealthCheckHttpClient { + newClient, newHealthCheckClient := h.buildNewClients( + buildNewHttpClient, + buildNewHealthCheckHttpClient, + newProxyConfig, + newCertsCM, + ) + + h.setNewClients( + newClient, + newHealthCheckClient, + newProxyConfig, + newCertsCM, + ) + } +} + +func (h *DefaultHttpClientsHolder) shouldRebuildClients(newProxyConfig *controller.Proxy, newCertsCM *corev1.ConfigMap) (bool, bool) { h.mu.RLock() + defer h.mu.RUnlock() certsCMVersion := "" - if certsCM != nil { - certsCMVersion = certsCM.ResourceVersion + if newCertsCM != nil { + certsCMVersion = newCertsCM.ResourceVersion } - if certsCMVersion != h.lastCertsCMVersion { - buildNewHttpClient = true + if !reflect.DeepEqual(newProxyConfig, h.lastProxyConfig) { + return true, true } - if !reflect.DeepEqual(proxyConfig, h.lastProxyConfig) { - buildNewHealthCheckHttpClient = true - buildNewHttpClient = true + if certsCMVersion != h.lastCertsCMVersion { + return true, false } - h.mu.RUnlock() + return false, false +} - if buildNewHttpClient || buildNewHealthCheckHttpClient { - proxyFunc := h.getProxyFunc(proxyConfig) - caCertPool := h.getCaCertPool(certsCM) - - if buildNewHttpClient { - transport := http.DefaultTransport.(*http.Transport).Clone() - transport.Proxy = proxyFunc - transport.TLSClientConfig = &tls.Config{ - RootCAs: caCertPool, - } - - newClient = &http.Client{ - Transport: transport, - } +func (h *DefaultHttpClientsHolder) buildNewClients( + buildNewHttpClient bool, + buildNewHealthCheckHttpClient bool, + newProxyConfig *controller.Proxy, + newCertsCM *corev1.ConfigMap, +) (*http.Client, *http.Client) { + + var newClient *http.Client + var newHealthCheckClient *http.Client + + proxyFunc := h.getProxyFunc(newProxyConfig) + caCertPool := h.getCaCertPool(newCertsCM) + + if buildNewHttpClient { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = proxyFunc + transport.TLSClientConfig = &tls.Config{ + RootCAs: caCertPool, + } + + newClient = &http.Client{ + Transport: transport, + Timeout: 5 * time.Second, + } + } + + if buildNewHealthCheckHttpClient { + healthCheckTransport := http.DefaultTransport.(*http.Transport).Clone() + healthCheckTransport.Proxy = proxyFunc + healthCheckTransport.TLSClientConfig = &tls.Config{ + InsecureSkipVerify: true, } - if buildNewHealthCheckHttpClient { - healthCheckTransport := http.DefaultTransport.(*http.Transport).Clone() - healthCheckTransport.Proxy = proxyFunc - healthCheckTransport.TLSClientConfig = &tls.Config{ - InsecureSkipVerify: true, - } - - newHealthCheckClient = &http.Client{ - Transport: healthCheckTransport, - Timeout: 500 * time.Millisecond, - } + newHealthCheckClient = &http.Client{ + Transport: healthCheckTransport, + Timeout: 500 * time.Millisecond, } } + return newClient, newHealthCheckClient +} + +func (h *DefaultHttpClientsHolder) setNewClients( + newClient *http.Client, + newHealthCheckClient *http.Client, + newProxyConfig *controller.Proxy, + newCertsCM *corev1.ConfigMap, +) { h.mu.Lock() + defer h.mu.Unlock() if newClient != nil { h.client = newClient @@ -166,19 +200,17 @@ func (h *DefaultHttpClientsHolder) ConfigureHttpClients(routingConfig *controlle h.healthCheckHttpClient = newHealthCheckClient } - if proxyConfig != nil { - h.lastProxyConfig = proxyConfig.DeepCopy() + if newProxyConfig != nil { + h.lastProxyConfig = newProxyConfig.DeepCopy() } else { h.lastProxyConfig = nil } - if certsCM != nil { - h.lastCertsCMVersion = certsCM.ResourceVersion + if newCertsCM != nil { + h.lastCertsCMVersion = newCertsCM.ResourceVersion } else { h.lastCertsCMVersion = "" } - - h.mu.Unlock() } func (h *DefaultHttpClientsHolder) getProxyFunc(proxyConfig *controller.Proxy) func(*http.Request) (*url.URL, error) { From 2669f171eb6022a04e4df3e57391e14ae9abaa3a Mon Sep 17 00:00:00 2001 From: Anatolii Bazko Date: Tue, 16 Jun 2026 11:57:13 +0200 Subject: [PATCH 6/9] chore: Allow DWO to read certificates configured in DWOC to initialize http client Signed-off-by: Anatolii Bazko --- controllers/workspace/http.go | 30 +++++++++++++++++------------- controllers/workspace/http_test.go | 4 +++- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/controllers/workspace/http.go b/controllers/workspace/http.go index bb1020f27..daba415a7 100644 --- a/controllers/workspace/http.go +++ b/controllers/workspace/http.go @@ -17,6 +17,7 @@ import ( "context" "crypto/tls" "crypto/x509" + "fmt" "net/http" "net/url" "reflect" @@ -39,7 +40,7 @@ var httpClientsHolder HttpClientsHolder type HttpClientsHolder interface { GetHttpClient() *http.Client GetHealthCheckHttpClient() *http.Client - ConfigureHttpClients(routingConfig *controller.RoutingConfig) + ConfigureHttpClients(context.Context, *controller.RoutingConfig) } type DefaultHttpClientsHolder struct { @@ -70,7 +71,7 @@ func NewDefaultHttpClientsHolder(k8s client.Client, logger logr.Logger) *Default defaultCertPool: defaultCertPool, } - clientsHolder.ConfigureHttpClients(config.GetGlobalConfig().Routing) + clientsHolder.ConfigureHttpClients(context.Background(), config.GetGlobalConfig().Routing) return clientsHolder } @@ -89,7 +90,7 @@ func (h *DefaultHttpClientsHolder) GetHealthCheckHttpClient() *http.Client { return h.healthCheckHttpClient } -func (h *DefaultHttpClientsHolder) ConfigureHttpClients(routingConfig *controller.RoutingConfig) { +func (h *DefaultHttpClientsHolder) ConfigureHttpClients(ctx context.Context, routingConfig *controller.RoutingConfig) { var newProxyConfig *controller.Proxy var newCertsCM *corev1.ConfigMap @@ -98,7 +99,13 @@ func (h *DefaultHttpClientsHolder) ConfigureHttpClients(routingConfig *controlle newProxyConfig = routingConfig.ProxyConfig } if routingConfig.TLSCertificateConfigmapRef != nil { - newCertsCM = h.readCertCM(routingConfig.TLSCertificateConfigmapRef) + certsCM, err := h.readCertCM(ctx, routingConfig.TLSCertificateConfigmapRef) + if err != nil { + h.logger.Error(err, "Failed to read TLS certificate ConfigMap") + return + } + + newCertsCM = certsCM } } @@ -163,7 +170,6 @@ func (h *DefaultHttpClientsHolder) buildNewClients( newClient = &http.Client{ Transport: transport, - Timeout: 5 * time.Second, } } @@ -243,16 +249,16 @@ func (h *DefaultHttpClientsHolder) getCaCertPool(cm *corev1.ConfigMap) *x509.Cer for _, certsPem := range cm.Data { if !caCertPool.AppendCertsFromPEM([]byte(certsPem)) { - h.logger.Info("Warning: failed to parse one or more certificates from ConfigMap") + h.logger.V(1).Info("Warning: failed to parse one or more certificates from ConfigMap") } } return caCertPool } -func (h *DefaultHttpClientsHolder) readCertCM(cmReference *controller.ConfigmapReference) *corev1.ConfigMap { +func (h *DefaultHttpClientsHolder) readCertCM(ctx context.Context, cmReference *controller.ConfigmapReference) (*corev1.ConfigMap, error) { if cmReference == nil { - return nil + return nil, nil } namespacedName := types.NamespacedName{ @@ -261,11 +267,9 @@ func (h *DefaultHttpClientsHolder) readCertCM(cmReference *controller.ConfigmapR } configMap := &corev1.ConfigMap{} - err := h.k8s.Get(context.Background(), namespacedName, configMap) - if err != nil { - h.logger.Error(err, "Failed to read configmap with certificates") - return nil + if err := h.k8s.Get(ctx, namespacedName, configMap); err != nil { + return nil, fmt.Errorf("failed to read ConfigMap %s/%s containing certificates: %w", cmReference.Namespace, cmReference.Name, err) } - return configMap + return configMap, nil } diff --git a/controllers/workspace/http_test.go b/controllers/workspace/http_test.go index 3c450bbbc..fa22b8e46 100644 --- a/controllers/workspace/http_test.go +++ b/controllers/workspace/http_test.go @@ -14,6 +14,7 @@ package controllers import ( + "context" "net/http" controller "github.com/devfile/devworkspace-operator/apis/controller/v1alpha1" @@ -32,7 +33,8 @@ func (t *TestHttpsClientHolder) GetHealthCheckHttpClient() *http.Client { return t.healthCheckHttpClient } -func (t *TestHttpsClientHolder) ConfigureHttpClients(_ *controller.RoutingConfig) {} +func (t *TestHttpsClientHolder) ConfigureHttpClients(_ context.Context, _ *controller.RoutingConfig) { +} func SetupHttpClientsForTesting(client *http.Client) { httpClientsHolder = &TestHttpsClientHolder{ From 6fb6b8355617ab395b9063d4c90f03c4a6b47c22 Mon Sep 17 00:00:00 2001 From: Anatolii Bazko Date: Tue, 16 Jun 2026 12:06:16 +0200 Subject: [PATCH 7/9] chore: Allow DWO to read certificates configured in DWOC to initialize http client Signed-off-by: Anatolii Bazko --- controllers/workspace/devworkspace_controller.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controllers/workspace/devworkspace_controller.go b/controllers/workspace/devworkspace_controller.go index 4e1af1b47..bd49f22a5 100644 --- a/controllers/workspace/devworkspace_controller.go +++ b/controllers/workspace/devworkspace_controller.go @@ -141,7 +141,7 @@ func (r *DevWorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request workspace.DevWorkspace = rawWorkspace workspace.Config = config - httpClientsHolder.ConfigureHttpClients(config.Routing) + httpClientsHolder.ConfigureHttpClients(ctx, config.Routing) reqLogger = reqLogger.WithValues(constants.DevWorkspaceIDLoggerKey, workspace.Status.DevWorkspaceId) reqLogger.Info("Reconciling Workspace", "resolvedConfig", configString) From 1348318e4d2f018e55bc3d21024cf1a6219b790b Mon Sep 17 00:00:00 2001 From: Anatolii Bazko Date: Tue, 16 Jun 2026 13:30:35 +0200 Subject: [PATCH 8/9] chore: Allow DWO to read certificates configured in DWOC to initialize http client Signed-off-by: Anatolii Bazko --- controllers/workspace/http.go | 14 +- .../workspace/http_clients_holder_test.go | 44 +++ controllers/workspace/http_test.go | 258 +++++++++++++++++- 3 files changed, 298 insertions(+), 18 deletions(-) create mode 100644 controllers/workspace/http_clients_holder_test.go diff --git a/controllers/workspace/http.go b/controllers/workspace/http.go index daba415a7..808774168 100644 --- a/controllers/workspace/http.go +++ b/controllers/workspace/http.go @@ -102,7 +102,6 @@ func (h *DefaultHttpClientsHolder) ConfigureHttpClients(ctx context.Context, rou certsCM, err := h.readCertCM(ctx, routingConfig.TLSCertificateConfigmapRef) if err != nil { h.logger.Error(err, "Failed to read TLS certificate ConfigMap") - return } newCertsCM = certsCM @@ -129,18 +128,23 @@ func (h *DefaultHttpClientsHolder) ConfigureHttpClients(ctx context.Context, rou } func (h *DefaultHttpClientsHolder) shouldRebuildClients(newProxyConfig *controller.Proxy, newCertsCM *corev1.ConfigMap) (bool, bool) { - h.mu.RLock() defer h.mu.RUnlock() + h.mu.RLock() - certsCMVersion := "" - if newCertsCM != nil { - certsCMVersion = newCertsCM.ResourceVersion + // Always rebuild if clients haven't been initialized yet + if h.client == nil || h.healthCheckHttpClient == nil { + return true, true } if !reflect.DeepEqual(newProxyConfig, h.lastProxyConfig) { return true, true } + certsCMVersion := "" + if newCertsCM != nil { + certsCMVersion = newCertsCM.ResourceVersion + } + if certsCMVersion != h.lastCertsCMVersion { return true, false } diff --git a/controllers/workspace/http_clients_holder_test.go b/controllers/workspace/http_clients_holder_test.go new file mode 100644 index 000000000..4b20726af --- /dev/null +++ b/controllers/workspace/http_clients_holder_test.go @@ -0,0 +1,44 @@ +// Copyright (c) 2019-2025 Red Hat, Inc. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controllers + +import ( + "context" + "net/http" + + controller "github.com/devfile/devworkspace-operator/apis/controller/v1alpha1" +) + +type TestHttpClientsHolder struct { + client *http.Client + healthCheckHttpClient *http.Client +} + +func (t *TestHttpClientsHolder) GetHttpClient() *http.Client { + return t.client +} + +func (t *TestHttpClientsHolder) GetHealthCheckHttpClient() *http.Client { + return t.healthCheckHttpClient +} + +func (t *TestHttpClientsHolder) ConfigureHttpClients(_ context.Context, _ *controller.RoutingConfig) { +} + +func SetupHttpClientsForTesting(client *http.Client) { + httpClientsHolder = &TestHttpClientsHolder{ + client: client, + healthCheckHttpClient: client, + } +} diff --git a/controllers/workspace/http_test.go b/controllers/workspace/http_test.go index fa22b8e46..9278b8f8f 100644 --- a/controllers/workspace/http_test.go +++ b/controllers/workspace/http_test.go @@ -1,4 +1,4 @@ -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -15,30 +15,262 @@ package controllers import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" "net/http" + "testing" + "time" controller "github.com/devfile/devworkspace-operator/apis/controller/v1alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/pointer" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/log/zap" ) -type TestHttpsClientHolder struct { - client *http.Client - healthCheckHttpClient *http.Client +func TestConfigureHttpClients_InitializesClientsWithNilRoutingConfig(t *testing.T) { + h := newTestHolder(nil) + + h.ConfigureHttpClients(context.Background(), nil) + + assert.NotNil(t, h.GetHttpClient()) + assert.NotNil(t, h.GetHealthCheckHttpClient()) +} + +func TestConfigureHttpClients_InitializesClientsWithEmptyRoutingConfig(t *testing.T) { + h := newTestHolder(nil) + + h.ConfigureHttpClients(context.Background(), &controller.RoutingConfig{}) + + assert.NotNil(t, h.GetHttpClient()) + assert.NotNil(t, h.GetHealthCheckHttpClient()) +} + +func TestConfigureHttpClients_SetsProxyOnBothClients(t *testing.T) { + h := newTestHolder(nil) + + routingConfig := &controller.RoutingConfig{ + ProxyConfig: &controller.Proxy{ + HttpProxy: pointer.String("http://proxy:8080"), + }, + } + + h.ConfigureHttpClients(context.Background(), routingConfig) + + httpTransport := getHttpClientTransport(t, h.GetHttpClient()) + assert.NotNil(t, httpTransport.Proxy) + + healthTransport := getHttpClientTransport(t, h.GetHealthCheckHttpClient()) + assert.NotNil(t, healthTransport.Proxy) +} + +func TestConfigureHttpClients_LoadsCertificatesFromConfigMap(t *testing.T) { + certPEM := generateSelfSignedCertPEM(t) + certCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tls-certs", + Namespace: "cert-ns", + ResourceVersion: "1", + }, + Data: map[string]string{ + "ca.crt": string(certPEM), + }, + } + + h := newTestHolder(certCM) + + routingConfig := &controller.RoutingConfig{ + TLSCertificateConfigmapRef: &controller.ConfigmapReference{ + Name: "tls-certs", + Namespace: "cert-ns", + }, + } + + h.ConfigureHttpClients(context.Background(), routingConfig) + + tlsCfg := getHttpClientTransport(t, h.GetHttpClient()).TLSClientConfig + assert.NotNil(t, tlsCfg.RootCAs) +} + +func TestConfigureHttpClients_SkipsRebuildWhenNothingChanged(t *testing.T) { + h := newTestHolder(nil) + + routingConfig := &controller.RoutingConfig{ + ProxyConfig: &controller.Proxy{ + HttpProxy: pointer.String("http://proxy:8080"), + }, + } + + h.ConfigureHttpClients(context.Background(), routingConfig) + firstClient := h.GetHttpClient() + firstHealthCheck := h.GetHealthCheckHttpClient() + + h.ConfigureHttpClients(context.Background(), routingConfig) + + assert.Same(t, firstClient, h.GetHttpClient()) + assert.Same(t, firstHealthCheck, h.GetHealthCheckHttpClient()) +} + +func TestConfigureHttpClients_RebuildsBothWhenProxyChanges(t *testing.T) { + h := newTestHolder(nil) + + h.ConfigureHttpClients(context.Background(), &controller.RoutingConfig{ + ProxyConfig: &controller.Proxy{ + HttpProxy: pointer.String("http://old-proxy:8080"), + }, + }) + firstClient := h.GetHttpClient() + firstHealthCheck := h.GetHealthCheckHttpClient() + + h.ConfigureHttpClients(context.Background(), &controller.RoutingConfig{ + ProxyConfig: &controller.Proxy{ + HttpProxy: pointer.String("http://new-proxy:8080"), + }, + }) + + assert.NotSame(t, firstClient, h.GetHttpClient()) + assert.NotSame(t, firstHealthCheck, h.GetHealthCheckHttpClient()) } -func (t *TestHttpsClientHolder) GetHttpClient() *http.Client { - return t.client +func TestConfigureHttpClients_RebuildOnlyHttpClientWhenCertCMChanges(t *testing.T) { + certCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tls-certs", + Namespace: "cert-ns", + ResourceVersion: "1", + }, + Data: map[string]string{ + "ca.crt": string(generateSelfSignedCertPEM(t)), + }, + } + + h := newTestHolder(certCM) + routingConfig := &controller.RoutingConfig{ + TLSCertificateConfigmapRef: &controller.ConfigmapReference{ + Name: "tls-certs", + Namespace: "cert-ns", + }, + } + + h.ConfigureHttpClients(context.Background(), routingConfig) + firstClient := h.GetHttpClient() + firstHealthCheck := h.GetHealthCheckHttpClient() + + // Simulate configmap update: read current, update data, write back + currentCM := &corev1.ConfigMap{} + require.NoError(t, h.k8s.Get(context.Background(), client.ObjectKeyFromObject(certCM), currentCM)) + + currentCM.Data["ca.crt"] = string(generateSelfSignedCertPEM(t)) + require.NoError(t, h.k8s.Update(context.Background(), currentCM)) + + h.ConfigureHttpClients(context.Background(), routingConfig) + + assert.NotSame(t, firstClient, h.GetHttpClient()) + assert.Same(t, firstHealthCheck, h.GetHealthCheckHttpClient()) } -func (t *TestHttpsClientHolder) GetHealthCheckHttpClient() *http.Client { - return t.healthCheckHttpClient +func TestConfigureHttpClients_HandlesInvalidCertDataGracefully(t *testing.T) { + invalidCertCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bad-certs", + Namespace: "cert-ns", + ResourceVersion: "1", + }, + Data: map[string]string{ + "ca.crt": "not-a-valid-certificate", + }, + } + + h := newTestHolder(invalidCertCM) + + routingConfig := &controller.RoutingConfig{ + TLSCertificateConfigmapRef: &controller.ConfigmapReference{ + Name: "bad-certs", + Namespace: "cert-ns", + }, + } + + h.ConfigureHttpClients(context.Background(), routingConfig) + + assert.NotNil(t, h.GetHttpClient()) + assert.NotNil(t, h.GetHealthCheckHttpClient()) } -func (t *TestHttpsClientHolder) ConfigureHttpClients(_ context.Context, _ *controller.RoutingConfig) { +func TestConfigureHttpClients_HandlesMissingCertConfigMapGracefully(t *testing.T) { + h := newTestHolder(nil) + + routingConfig := &controller.RoutingConfig{ + TLSCertificateConfigmapRef: &controller.ConfigmapReference{ + Name: "nonexistent", + Namespace: "cert-ns", + }, + } + + h.ConfigureHttpClients(context.Background(), routingConfig) + + assert.NotNil(t, h.GetHttpClient()) + assert.NotNil(t, h.GetHealthCheckHttpClient()) +} + +func newTestHolder(certCM *corev1.ConfigMap) *DefaultHttpClientsHolder { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + + h := &DefaultHttpClientsHolder{ + logger: zap.New(zap.UseDevMode(true)), + defaultCertPool: x509.NewCertPool(), + } + + if certCM != nil { + h.k8s = fake.NewClientBuilder(). + WithScheme(scheme). + WithRuntimeObjects(certCM). + Build() + } else { + h.k8s = fake.NewClientBuilder(). + WithScheme(scheme). + Build() + } + + return h } -func SetupHttpClientsForTesting(client *http.Client) { - httpClientsHolder = &TestHttpsClientHolder{ - client: client, - healthCheckHttpClient: client, +func generateSelfSignedCertPEM(t *testing.T) []byte { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test-ca"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign, } + + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) + require.NoError(t, err) + + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) +} + +func getHttpClientTransport(t *testing.T, c *http.Client) *http.Transport { + t.Helper() + + transport, ok := c.Transport.(*http.Transport) + require.True(t, ok) + + return transport } From 271ee317767a1dfee0af502afdc0811fd3a80d52 Mon Sep 17 00:00:00 2001 From: Anatolii Bazko Date: Tue, 16 Jun 2026 14:17:52 +0200 Subject: [PATCH 9/9] chore: Allow DWO to read certificates configured in DWOC to initialize http client Signed-off-by: Anatolii Bazko --- .../workspace/devworkspace_controller.go | 5 ++- controllers/workspace/http.go | 36 +++++++++++-------- controllers/workspace/http_test.go | 4 +-- 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/controllers/workspace/devworkspace_controller.go b/controllers/workspace/devworkspace_controller.go index bd49f22a5..16921efc7 100644 --- a/controllers/workspace/devworkspace_controller.go +++ b/controllers/workspace/devworkspace_controller.go @@ -787,7 +787,10 @@ func (r *DevWorkspaceReconciler) getWorkspaceId(ctx context.Context, workspace * } func (r *DevWorkspaceReconciler) SetupWithManager(mgr ctrl.Manager) error { - httpClientsHolder = NewDefaultHttpClientsHolder(mgr.GetClient(), mgr.GetLogger()) + err := SetupHttpClients(mgr.GetClient(), mgr.GetLogger()) + if err != nil { + return err + } maxConcurrentReconciles, err := wkspConfig.GetMaxConcurrentReconciles() if err != nil { diff --git a/controllers/workspace/http.go b/controllers/workspace/http.go index 808774168..123470ad9 100644 --- a/controllers/workspace/http.go +++ b/controllers/workspace/http.go @@ -39,7 +39,12 @@ var httpClientsHolder HttpClientsHolder type HttpClientsHolder interface { GetHttpClient() *http.Client + + // GetHealthCheckHttpClient returns an HTTP client that skips TLS verification. + // This client MUST only be used for workspace health/readiness checks, not for + // fetching external content or making security-sensitive requests. GetHealthCheckHttpClient() *http.Client + ConfigureHttpClients(context.Context, *controller.RoutingConfig) } @@ -55,25 +60,23 @@ type DefaultHttpClientsHolder struct { lastProxyConfig *controller.Proxy lastCertsCMVersion string - defaultCertPool *x509.CertPool + systemCertPool *x509.CertPool } -func NewDefaultHttpClientsHolder(k8s client.Client, logger logr.Logger) *DefaultHttpClientsHolder { - defaultCertPool, err := x509.SystemCertPool() +func SetupHttpClients(k8s client.Client, logger logr.Logger) error { + systemCertPool, err := x509.SystemCertPool() if err != nil { - logger.Error(err, "Failed to load system cert pool") - defaultCertPool = x509.NewCertPool() + return fmt.Errorf("failed to load system cert pool: %w", err) } - clientsHolder := &DefaultHttpClientsHolder{ - k8s: k8s, - logger: logger, - defaultCertPool: defaultCertPool, + httpClientsHolder = &DefaultHttpClientsHolder{ + k8s: k8s, + logger: logger, + systemCertPool: systemCertPool, } + httpClientsHolder.ConfigureHttpClients(context.Background(), config.GetGlobalConfig().Routing) - clientsHolder.ConfigureHttpClients(context.Background(), config.GetGlobalConfig().Routing) - - return clientsHolder + return nil } func (h *DefaultHttpClientsHolder) GetHttpClient() *http.Client { @@ -98,10 +101,14 @@ func (h *DefaultHttpClientsHolder) ConfigureHttpClients(ctx context.Context, rou if routingConfig.ProxyConfig != nil { newProxyConfig = routingConfig.ProxyConfig } + if routingConfig.TLSCertificateConfigmapRef != nil { certsCM, err := h.readCertCM(ctx, routingConfig.TLSCertificateConfigmapRef) if err != nil { h.logger.Error(err, "Failed to read TLS certificate ConfigMap") + + // certsCM == nil, + // http clients will be rebuilt with a system cert pool, not an issue at all } newCertsCM = certsCM @@ -128,8 +135,8 @@ func (h *DefaultHttpClientsHolder) ConfigureHttpClients(ctx context.Context, rou } func (h *DefaultHttpClientsHolder) shouldRebuildClients(newProxyConfig *controller.Proxy, newCertsCM *corev1.ConfigMap) (bool, bool) { - defer h.mu.RUnlock() h.mu.RLock() + defer h.mu.RUnlock() // Always rebuild if clients haven't been initialized yet if h.client == nil || h.healthCheckHttpClient == nil { @@ -174,6 +181,7 @@ func (h *DefaultHttpClientsHolder) buildNewClients( newClient = &http.Client{ Transport: transport, + Timeout: 5 * time.Second, } } @@ -249,7 +257,7 @@ func (h *DefaultHttpClientsHolder) getCaCertPool(cm *corev1.ConfigMap) *x509.Cer return nil } - caCertPool := h.defaultCertPool.Clone() + caCertPool := h.systemCertPool.Clone() for _, certsPem := range cm.Data { if !caCertPool.AppendCertsFromPEM([]byte(certsPem)) { diff --git a/controllers/workspace/http_test.go b/controllers/workspace/http_test.go index 9278b8f8f..bd3315cfe 100644 --- a/controllers/workspace/http_test.go +++ b/controllers/workspace/http_test.go @@ -227,8 +227,8 @@ func newTestHolder(certCM *corev1.ConfigMap) *DefaultHttpClientsHolder { _ = corev1.AddToScheme(scheme) h := &DefaultHttpClientsHolder{ - logger: zap.New(zap.UseDevMode(true)), - defaultCertPool: x509.NewCertPool(), + logger: zap.New(zap.UseDevMode(true)), + systemCertPool: x509.NewCertPool(), } if certCM != nil {