Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions controllers/configuration/ca_bundle_watcher.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/*
Copyright 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

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 configuration

import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"

"github.com/go-logr/logr"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/client-go/tools/record"
"k8s.io/utils/ptr"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller"
"sigs.k8s.io/controller-runtime/pkg/event"
"sigs.k8s.io/controller-runtime/pkg/predicate"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
)

const (
CABundleConfigMapName = "threescale-ca-bundle"
CABundleConfigMapKey = "ca-bundle.crt"
)

// CAValidationError is returned when the CA bundle ConfigMap exists but its
// contents cannot be used to build a valid certificate pool.
type CAValidationError struct {
Reason string
Message string
Err error
}

func (e *CAValidationError) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %s: %v", e.Reason, e.Message, e.Err)
}
return fmt.Sprintf("%s: %s", e.Reason, e.Message)
}

func (e *CAValidationError) Unwrap() error {
return e.Err
}

const (
CAValidationReasonMissingSecret = "MissingCASecret"
CAValidationReasonMissingKey = "MissingCAKey"
CAValidationReasonInvalidFormat = "InvalidCAFormat"
)

// CABundleWatcher watches the threescale-ca-bundle ConfigMap and writes the
// parsed *tls.Config to the package-level variable via SetTLSConfig on every
// successful reconcile.
type CABundleWatcher struct {
client.Client
Recorder record.EventRecorder
Namespace string
}

// SetupWithManager registers the controller with the manager.
func (r *CABundleWatcher) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
Named("cabundlewatcher").
WithOptions(controller.Options{NeedLeaderElection: ptr.To(false)}).
For(&corev1.ConfigMap{}, builder.WithPredicates(predicate.Funcs{
CreateFunc: func(e event.CreateEvent) bool {
return e.Object.GetName() == CABundleConfigMapName &&
e.Object.GetNamespace() == r.Namespace
},
UpdateFunc: func(e event.UpdateEvent) bool {
return e.ObjectNew.GetName() == CABundleConfigMapName &&
e.ObjectNew.GetNamespace() == r.Namespace
},
DeleteFunc: func(e event.DeleteEvent) bool {
return e.Object.GetName() == CABundleConfigMapName &&
e.Object.GetNamespace() == r.Namespace
},
GenericFunc: func(e event.GenericEvent) bool {
return e.Object.GetName() == CABundleConfigMapName &&
e.Object.GetNamespace() == r.Namespace
},
})).
WithLogConstructor(func(_ *reconcile.Request) logr.Logger {
return mgr.GetLogger().WithValues("controller", "cabundlewatcher")
}).
Complete(r)
}

// Reconcile fetches the CA bundle ConfigMap and updates the package-level
// TLS config. On an invalid bundle, the error is logged and recorded as a Warning event on
// the ConfigMap; the existing TLS config is left unchanged so capability
// controllers continue to operate with the last known good CA.
func (r *CABundleWatcher) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := ctrl.LoggerFrom(ctx)

cm := &corev1.ConfigMap{}
err := r.Get(ctx, client.ObjectKey{Namespace: r.Namespace, Name: CABundleConfigMapName}, cm)
if err != nil {
if apierrors.IsNotFound(err) {
SetTLSConfig(nil)
return ctrl.Result{}, nil
}
return ctrl.Result{}, err
}

tlsConfig, parseErr := parseBundleFromConfigMap(cm)
if parseErr != nil {
logger.Error(parseErr, "CA bundle ConfigMap contains an invalid certificate bundle; keeping previous TLS config")
if r.Recorder != nil {
r.Recorder.Eventf(cm, corev1.EventTypeWarning, "InvalidCABundle", "%v", parseErr)
}
return ctrl.Result{}, nil
}

SetTLSConfig(tlsConfig)
return ctrl.Result{}, nil
}

// parseBundleFromConfigMap parses the CA bundle from a ConfigMap and returns a
// *tls.Config with the custom RootCAs pool set. Returns nil and an error if
// the ConfigMap key is absent or the PEM data is invalid.
func parseBundleFromConfigMap(cm *corev1.ConfigMap) (*tls.Config, error) {
val, exists := cm.Data[CABundleConfigMapKey]
if !exists {
return nil, nil // no bundle configured — use system roots
}

if len(val) == 0 {
return nil, &CAValidationError{
Reason: CAValidationReasonInvalidFormat,
Message: fmt.Sprintf("Key %q in ConfigMap %s/%s is empty", CABundleConfigMapKey, cm.Namespace, cm.Name),
}
}

certPool := x509.NewCertPool()
if !certPool.AppendCertsFromPEM([]byte(val)) {
return nil, &CAValidationError{
Reason: CAValidationReasonInvalidFormat,
Message: "No valid PEM-encoded certificates found in CA bundle",
}
}

return &tls.Config{RootCAs: certPool}, nil
}
47 changes: 47 additions & 0 deletions controllers/configuration/tls_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
Copyright 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

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 configuration

import (
"crypto/tls"
"sync"
)

// currentTLSConfig is the package-level TLS configuration derived from the
// most recently reconciled threescale-ca-bundle ConfigMap.
// nil means "no custom CA bundle" — callers should use system roots.
var currentTLSConfig *tls.Config

var tlsConfigMu sync.RWMutex

// SetTLSConfig replaces the current package-level TLS config.
// Pass nil to revert to system roots (e.g. when the ConfigMap is deleted).
func SetTLSConfig(cfg *tls.Config) {
tlsConfigMu.Lock()
currentTLSConfig = cfg
tlsConfigMu.Unlock()
}

// GetTLSConfig returns the current package-level TLS config.
// Returns nil if no bundle has been reconciled yet, or after the ConfigMap
// has been deleted. Callers must not mutate the returned *tls.Config.
func GetTLSConfig() *tls.Config {
tlsConfigMu.RLock()
cfg := currentTLSConfig
tlsConfigMu.RUnlock()
return cfg
}
11 changes: 11 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
capabilitiesv1beta1 "github.com/3scale/3scale-operator/apis/capabilities/v1beta1"
appscontroller "github.com/3scale/3scale-operator/controllers/apps"
capabilitiescontroller "github.com/3scale/3scale-operator/controllers/capabilities"
configurationcontroller "github.com/3scale/3scale-operator/controllers/configuration"
"github.com/3scale/3scale-operator/pkg/reconcilers"
"github.com/3scale/3scale-operator/version"
"github.com/getkin/kin-openapi/openapi3"
Expand Down Expand Up @@ -161,6 +162,16 @@ func main() {
os.Exit(1)
}

watcher := &configurationcontroller.CABundleWatcher{
Client: mgr.GetClient(),
Recorder: mgr.GetEventRecorderFor("CABundleWatcher"),
Namespace: operatorInstallationNamespace,
}
if err = watcher.SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "CABundleWatcher")
os.Exit(1)
}

secretLabelSelector, err := apimachinerymetav1.ParseToLabelSelector("apimanager.apps.3scale.net/watched-by=apimanager")
if err != nil {
setupLog.Error(err, "unable parse apimanager secrets label")
Expand Down
32 changes: 24 additions & 8 deletions pkg/controller/helper/threescale_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"net/http"
"net/url"

configuration "github.com/3scale/3scale-operator/controllers/configuration"
"github.com/3scale/3scale-operator/pkg/helper"

threescaleapi "github.com/3scale/3scale-porta-go-client/client"
Expand All @@ -19,12 +20,15 @@ type ProviderAccount struct {
Token string
}

// PortaClient instantiates porta_client.ThreeScaleClient from ProviderAccount object
// PortaClient instantiates a ThreeScaleClient from a ProviderAccount.
// When insecureSkipVerify is true, the CA bundle is ignored and an insecure
// client is used instead.
func PortaClient(providerAccount *ProviderAccount, insecureSkipVerify bool) (*threescaleapi.ThreeScaleClient, error) {
return PortaClientFromURLString(providerAccount.AdminURLStr, providerAccount.Token, insecureSkipVerify)
}

// PortaClientFromURLString instantiates porta_client.ThreeScaleClient from url string
// PortaClientFromURLString instantiates a ThreeScaleClient from an admin URL string
// and access token. When insecureSkipVerify is true, the CA bundle is ignored.
func PortaClientFromURLString(adminURLStr, token string, insecureSkipVerify bool) (*threescaleapi.ThreeScaleClient, error) {
adminURL, err := url.Parse(adminURLStr)
if err != nil {
Expand All @@ -33,24 +37,36 @@ func PortaClientFromURLString(adminURLStr, token string, insecureSkipVerify bool
return PortaClientFromURL(adminURL, token, insecureSkipVerify)
}

// PortaClientFromURL instantiates porta_client.ThreeScaleClient from admin url object
// PortaClientFromURL instantiates a ThreeScaleClient from an admin URL
// and access token. When insecureSkipVerify is true, the CA bundle is ignored.
func PortaClientFromURL(url *url.URL, token string, insecureSkipVerify bool) (*threescaleapi.ThreeScaleClient, error) {
var httpClient *http.Client
if insecureSkipVerify {
httpClient = buildHTTPClient(&tls.Config{InsecureSkipVerify: true}) //nolint:gosec
} else {
httpClient = buildHTTPClient(configuration.GetTLSConfig())
}
adminPortal, err := threescaleapi.NewAdminPortal(url.Scheme, url.Hostname(), helper.PortFromURL(url))
if err != nil {
return nil, err
}
return threescaleapi.NewThreeScale(adminPortal, token, httpClient), nil
}

// Activated by some env var or Spec param
// buildHTTPClient constructs a fresh *http.Client with the supplied TLS
// configuration. If THREESCALE_DEBUG=1 the verbose transport wrapper is applied.
func buildHTTPClient(tlsConfig *tls.Config) *http.Client {
if tlsConfig == nil {
tlsConfig = &tls.Config{}
}
var transport http.RoundTripper = &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecureSkipVerify},
TLSClientConfig: tlsConfig,
}

if helper.GetEnvVar(HTTP_VERBOSE_ENVVAR, "0") == "1" {
transport = &helper.Transport{Transport: transport}
}

return threescaleapi.NewThreeScale(adminPortal, token, &http.Client{Transport: transport}), nil
return &http.Client{Transport: transport}
}

// GetInsecureSkipVerifyAnnotation extracts the insecure_skip_verify annotation from an object
Expand Down
Loading
Loading