Skip to content

Commit 9e92a12

Browse files
committed
Add HTTP proxy support for operator deployments
Fixes: OCPBUGS-61082 Add support for HTTP_PROXY, HTTPS_PROXY, and NO_PROXY environment variables in operator deployments. Proxy configuration is read from operator-controller's environment at startup and injected into all containers in deployed operator Deployments. When operator-controller restarts with changed proxy configuration, existing operator deployments are automatically updated via triggered reconciliation. Supports both helm and boxcutter appliers. Includes unit and e2e tests. Signed-off-by: Todd Short <tshort@redhat.com> Assisted-By: Claude
1 parent 75393e1 commit 9e92a12

14 files changed

Lines changed: 963 additions & 18 deletions

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ test: manifests generate fmt lint test-unit test-e2e test-regression #HELP Run a
243243

244244
.PHONY: e2e
245245
e2e: #EXHELP Run the e2e tests.
246-
go test -count=1 -v ./test/e2e/features_test.go
246+
$(if $(GODOG_TAGS),go test -timeout=30m -count=1 -v ./test/e2e/features_test.go --godog.tags="$(GODOG_TAGS)",go test -timeout=30m -count=1 -v ./test/e2e/features_test.go)
247247

248248
E2E_REGISTRY_NAME := docker-registry
249249
E2E_REGISTRY_NAMESPACE := operator-controller-e2e

cmd/operator-controller/main.go

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import (
6868
"github.com/operator-framework/operator-controller/internal/operator-controller/controllers"
6969
"github.com/operator-framework/operator-controller/internal/operator-controller/features"
7070
"github.com/operator-framework/operator-controller/internal/operator-controller/finalizers"
71+
"github.com/operator-framework/operator-controller/internal/operator-controller/proxy"
7172
"github.com/operator-framework/operator-controller/internal/operator-controller/resolve"
7273
"github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/preflights/crdupgradesafety"
7374
"github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render"
@@ -219,6 +220,7 @@ func validateMetricsFlags() error {
219220
}
220221
return nil
221222
}
223+
222224
func run() error {
223225
setupLog.Info("starting up the controller", "version info", version.String())
224226

@@ -474,11 +476,26 @@ func run() error {
474476
}
475477

476478
certProvider := getCertificateProvider()
479+
480+
// Read proxy configuration from environment variables
481+
var proxyConfig *proxy.Proxy
482+
httpProxy := os.Getenv("HTTP_PROXY")
483+
httpsProxy := os.Getenv("HTTPS_PROXY")
484+
noProxy := os.Getenv("NO_PROXY")
485+
if httpProxy != "" || httpsProxy != "" || noProxy != "" {
486+
proxyConfig = &proxy.Proxy{
487+
HTTPProxy: httpProxy,
488+
HTTPSProxy: httpsProxy,
489+
NoProxy: noProxy,
490+
}
491+
}
492+
477493
regv1ManifestProvider := &applier.RegistryV1ManifestProvider{
478494
BundleRenderer: registryv1.Renderer,
479495
CertificateProvider: certProvider,
480496
IsWebhookSupportEnabled: certProvider != nil,
481497
IsSingleOwnNamespaceEnabled: features.OperatorControllerFeatureGate.Enabled(features.SingleOwnNamespaceInstallSupport),
498+
Proxy: proxyConfig,
482499
}
483500
var cerCfg reconcilerConfigurator
484501
if features.OperatorControllerFeatureGate.Enabled(features.BoxcutterRuntime) {
@@ -540,6 +557,50 @@ func run() error {
540557
return err
541558
}
542559

560+
// Add a runnable to trigger reconciliation of all ClusterExtensions on startup.
561+
// This ensures existing deployments get updated when proxy configuration changes
562+
// (added, modified, or removed).
563+
if err := mgr.Add(manager.RunnableFunc(func(ctx context.Context) error {
564+
// Wait for the cache to sync
565+
if !mgr.GetCache().WaitForCacheSync(ctx) {
566+
return fmt.Errorf("failed to wait for cache sync")
567+
}
568+
569+
// Always trigger reconciliation on startup to handle proxy config changes
570+
if proxyConfig != nil {
571+
setupLog.Info("proxy configuration detected, triggering reconciliation of all ClusterExtensions",
572+
"httpProxy", proxy.SanitizeURL(proxyConfig.HTTPProxy), "httpsProxy", proxy.SanitizeURL(proxyConfig.HTTPSProxy), "noProxy", proxyConfig.NoProxy)
573+
} else {
574+
setupLog.Info("no proxy configuration detected, triggering reconciliation to remove proxy vars from existing deployments")
575+
}
576+
577+
extList := &ocv1.ClusterExtensionList{}
578+
if err := cl.List(ctx, extList); err != nil {
579+
setupLog.Error(err, "failed to list ClusterExtensions for proxy update")
580+
return nil // Don't fail startup
581+
}
582+
583+
for i := range extList.Items {
584+
ext := &extList.Items[i]
585+
// Trigger reconciliation by adding an annotation
586+
if ext.Annotations == nil {
587+
ext.Annotations = make(map[string]string)
588+
}
589+
ext.Annotations["olm.operatorframework.io/proxy-reconcile"] = time.Now().Format(time.RFC3339)
590+
if err := cl.Update(ctx, ext); err != nil {
591+
setupLog.Error(err, "failed to trigger reconciliation for ClusterExtension", "name", ext.Name)
592+
// Continue with other ClusterExtensions
593+
}
594+
}
595+
596+
setupLog.Info("triggered reconciliation for existing ClusterExtensions", "count", len(extList.Items))
597+
598+
return nil
599+
})); err != nil {
600+
setupLog.Error(err, "unable to add startup reconciliation trigger")
601+
return err
602+
}
603+
543604
setupLog.Info("starting manager")
544605
ctx := ctrl.SetupSignalHandler()
545606
if err := mgr.Start(ctx); err != nil {
@@ -625,13 +686,18 @@ func (c *boxcutterReconcilerConfigurator) Configure(ceReconciler *controllers.Cl
625686
ActionClientGetter: acg,
626687
RevisionGenerator: rg,
627688
}
689+
// Get the ManifestProvider to extract proxy fingerprint
690+
regv1Provider, ok := c.regv1ManifestProvider.(*applier.RegistryV1ManifestProvider)
691+
if !ok {
692+
return fmt.Errorf("manifest provider is not of type *applier.RegistryV1ManifestProvider")
693+
}
628694
ceReconciler.ReconcileSteps = []controllers.ReconcileStepFunc{
629695
controllers.HandleFinalizers(c.finalizers),
630696
controllers.MigrateStorage(storageMigrator),
631697
controllers.RetrieveRevisionStates(revisionStatesGetter),
632698
controllers.ResolveBundle(c.resolver, c.mgr.GetClient()),
633699
controllers.UnpackBundle(c.imagePuller, c.imageCache),
634-
controllers.ApplyBundleWithBoxcutter(appl.Apply),
700+
controllers.ApplyBundleWithBoxcutter(appl.Apply, regv1Provider.ProxyFingerprint),
635701
}
636702

637703
baseDiscoveryClient, err := discovery.NewDiscoveryClientForConfig(c.mgr.GetConfig())

internal/operator-controller/applier/boxcutter.go

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -485,17 +485,28 @@ func (bc *Boxcutter) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1.Clust
485485
desiredRevision.Spec.Revision = currentRevision.Spec.Revision
486486
desiredRevision.Name = currentRevision.Name
487487

488-
err := bc.createOrUpdate(ctx, getUserInfo(ext), desiredRevision)
489-
switch {
490-
case apierrors.IsInvalid(err):
491-
// We could not update the current revision due to trying to update an immutable field.
492-
// Therefore, we need to create a new revision.
488+
// Check if proxy configuration changed by comparing annotations.
489+
// The Phases field has CEL immutability validation (self == oldSelf) which prevents
490+
// updating nested content like env vars. When proxy config changes, we must create
491+
// a new revision rather than attempting to patch the existing one.
492+
// Comparing proxy hash annotations is more reliable than comparing phases content,
493+
// which has false positives due to serialization differences in unstructured objects.
494+
currentProxyHash := currentRevision.Annotations[labels.ProxyConfigHashKey]
495+
desiredProxyHash := desiredRevision.Annotations[labels.ProxyConfigHashKey]
496+
if currentProxyHash != desiredProxyHash {
493497
state = StateNeedsUpgrade
494-
case err == nil:
495-
// inplace patch was successful, no changes in phases
496-
state = StateUnchanged
497-
default:
498-
return false, "", fmt.Errorf("patching %s Revision: %w", desiredRevision.Name, err)
498+
} else {
499+
err = bc.createOrUpdate(ctx, getUserInfo(ext), desiredRevision)
500+
switch {
501+
case apierrors.IsInvalid(err):
502+
// We could not update the current revision due to trying to update an immutable field.
503+
// Therefore, we need to create a new revision.
504+
state = StateNeedsUpgrade
505+
case err == nil:
506+
state = StateUnchanged
507+
default:
508+
return false, "", fmt.Errorf("patching %s Revision: %w", desiredRevision.Name, err)
509+
}
499510
}
500511
}
501512

@@ -530,6 +541,10 @@ func (bc *Boxcutter) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1.Clust
530541

531542
desiredRevision.Name = fmt.Sprintf("%s-%d", ext.Name, revisionNumber)
532543
desiredRevision.Spec.Revision = revisionNumber
544+
// Clear server-added metadata fields from previous patch operation
545+
desiredRevision.SetResourceVersion("")
546+
desiredRevision.SetUID("")
547+
desiredRevision.SetManagedFields(nil)
533548

534549
if err = bc.garbageCollectOldRevisions(ctx, prevRevisions); err != nil {
535550
return false, "", fmt.Errorf("garbage collecting old revisions: %w", err)

internal/operator-controller/applier/provider.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414

1515
ocv1 "github.com/operator-framework/operator-controller/api/v1"
1616
"github.com/operator-framework/operator-controller/internal/operator-controller/config"
17+
"github.com/operator-framework/operator-controller/internal/operator-controller/proxy"
1718
"github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle"
1819
"github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle/source"
1920
"github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render"
@@ -33,6 +34,7 @@ type RegistryV1ManifestProvider struct {
3334
CertificateProvider render.CertificateProvider
3435
IsWebhookSupportEnabled bool
3536
IsSingleOwnNamespaceEnabled bool
37+
Proxy *proxy.Proxy
3638
}
3739

3840
func (r *RegistryV1ManifestProvider) Get(bundleFS fs.FS, ext *ocv1.ClusterExtension) ([]client.Object, error) {
@@ -70,6 +72,14 @@ func (r *RegistryV1ManifestProvider) Get(bundleFS fs.FS, ext *ocv1.ClusterExtens
7072
render.WithCertificateProvider(r.CertificateProvider),
7173
}
7274

75+
// Always include proxy option to ensure manifests reflect current proxy state
76+
// When r.Proxy is nil, this ensures proxy env vars are removed from manifests
77+
if r.Proxy != nil {
78+
opts = append(opts, render.WithProxy(*r.Proxy))
79+
} else {
80+
opts = append(opts, render.WithProxy(proxy.Proxy{}))
81+
}
82+
7383
if r.IsSingleOwnNamespaceEnabled {
7484
configOpts, err := r.extractBundleConfigOptions(&rv1, ext)
7585
if err != nil {
@@ -111,6 +121,12 @@ func (r *RegistryV1ManifestProvider) extractBundleConfigOptions(rv1 *bundle.Regi
111121
return opts, nil
112122
}
113123

124+
// ProxyFingerprint returns a stable hash of the proxy configuration.
125+
// This is used to detect when proxy settings change and a new revision is needed.
126+
func (r *RegistryV1ManifestProvider) ProxyFingerprint() string {
127+
return r.Proxy.Fingerprint()
128+
}
129+
114130
// RegistryV1HelmChartProvider creates a Helm-Chart from a registry+v1 bundle and its associated ClusterExtension
115131
type RegistryV1HelmChartProvider struct {
116132
ManifestProvider ManifestProvider

internal/operator-controller/controllers/boxcutter_reconcile_steps.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ func MigrateStorage(m StorageMigrator) ReconcileStepFunc {
9696
}
9797
}
9898

99-
func ApplyBundleWithBoxcutter(apply func(ctx context.Context, contentFS fs.FS, ext *ocv1.ClusterExtension, objectLabels, revisionAnnotations map[string]string) (bool, string, error)) ReconcileStepFunc {
99+
func ApplyBundleWithBoxcutter(apply func(ctx context.Context, contentFS fs.FS, ext *ocv1.ClusterExtension, objectLabels, revisionAnnotations map[string]string) (bool, string, error), proxyFingerprint func() string) ReconcileStepFunc {
100100
return func(ctx context.Context, state *reconcileState, ext *ocv1.ClusterExtension) (*ctrl.Result, error) {
101101
l := log.FromContext(ctx)
102102
revisionAnnotations := map[string]string{
@@ -105,6 +105,10 @@ func ApplyBundleWithBoxcutter(apply func(ctx context.Context, contentFS fs.FS, e
105105
labels.BundleVersionKey: state.resolvedRevisionMetadata.Version,
106106
labels.BundleReferenceKey: state.resolvedRevisionMetadata.Image,
107107
}
108+
// Add proxy config fingerprint to detect when proxy settings change
109+
if fp := proxyFingerprint(); fp != "" {
110+
revisionAnnotations[labels.ProxyConfigHashKey] = fp
111+
}
108112
objLbls := map[string]string{
109113
labels.OwnerKindKey: ocv1.ClusterExtensionKind,
110114
labels.OwnerNameKey: ext.GetName(),

internal/operator-controller/controllers/boxcutter_reconcile_steps_apply_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ func TestApplyBundleWithBoxcutter(t *testing.T) {
135135

136136
stepFunc := ApplyBundleWithBoxcutter(func(_ context.Context, _ fs.FS, _ *ocv1.ClusterExtension, _, _ map[string]string) (bool, string, error) {
137137
return true, "", nil
138+
}, func() string {
139+
return "" // empty proxy fingerprint for tests
138140
})
139141
result, err := stepFunc(ctx, state, ext)
140142
require.NoError(t, err)

internal/operator-controller/labels/labels.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,9 @@ const (
4444
// that were created during migration from Helm releases. This label is used
4545
// to distinguish migrated revisions from those created by normal Boxcutter operation.
4646
MigratedFromHelmKey = "olm.operatorframework.io/migrated-from-helm"
47+
48+
// ProxyConfigHashKey is the annotation key used to record a hash of the proxy configuration
49+
// (HTTP_PROXY, HTTPS_PROXY, NO_PROXY) that was used when generating the revision manifests.
50+
// This allows detection of proxy configuration changes that require creating a new revision.
51+
ProxyConfigHashKey = "olm.operatorframework.io/proxy-config-hash"
4752
)
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Package proxy defines HTTP proxy configuration types used across applier implementations.
2+
package proxy
3+
4+
import (
5+
"crypto/sha256"
6+
"encoding/json"
7+
"fmt"
8+
"net/url"
9+
)
10+
11+
// Proxy holds HTTP proxy configuration values that are applied to rendered resources.
12+
// These values are typically set as environment variables on generated Pods to enable
13+
// operators to function correctly in environments that require HTTP proxies for outbound
14+
// connections.
15+
type Proxy struct {
16+
// HTTPProxy is the HTTP proxy URL (e.g., "http://proxy.example.com:8080").
17+
// An empty value means no HTTP proxy is configured.
18+
HTTPProxy string
19+
// HTTPSProxy is the HTTPS proxy URL (e.g., "https://proxy.example.com:8443").
20+
// An empty value means no HTTPS proxy is configured.
21+
HTTPSProxy string
22+
// NoProxy is a comma-separated list of hosts, domains, or CIDR ranges that should
23+
// bypass the proxy (e.g., "localhost,127.0.0.1,.cluster.local").
24+
// An empty value means all traffic will use the proxy (if configured).
25+
NoProxy string
26+
}
27+
28+
// Fingerprint returns a stable hash of the proxy configuration.
29+
// This is used to detect when proxy settings change and a new revision is needed.
30+
// Returns an empty string if the proxy is nil.
31+
func (p *Proxy) Fingerprint() string {
32+
if p == nil {
33+
return ""
34+
}
35+
data, err := json.Marshal(p)
36+
if err != nil {
37+
// This should never happen for a simple struct with string fields,
38+
// but return empty string if it does
39+
return ""
40+
}
41+
hash := sha256.Sum256(data)
42+
return fmt.Sprintf("%x", hash[:8])
43+
}
44+
45+
// SanitizeURL removes credentials from a proxy URL for safe logging.
46+
// Returns the original string if it's not a valid URL or doesn't contain credentials.
47+
func SanitizeURL(proxyURL string) string {
48+
if proxyURL == "" {
49+
return ""
50+
}
51+
52+
u, err := url.Parse(proxyURL)
53+
if err != nil {
54+
// If we can't parse it, return as-is (might be a hostname or other format)
55+
return proxyURL
56+
}
57+
58+
// If there's no user info, return as-is
59+
if u.User == nil {
60+
return proxyURL
61+
}
62+
63+
// Remove user info and return sanitized URL
64+
u.User = nil
65+
return u.String()
66+
}

internal/operator-controller/rukpak/render/registryv1/generators/generators.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,23 @@ func BundleCSVDeploymentGenerator(rv1 *bundle.RegistryV1, opts render.Options) (
8888
// See https://github.com/operator-framework/operator-lifecycle-manager/blob/dfd0b2bea85038d3c0d65348bc812d297f16b8d2/pkg/controller/install/deployment.go#L177-L180
8989
depSpec.Spec.RevisionHistoryLimit = ptr.To(int32(1))
9090

91+
// Always call WithProxy to ensure proxy env vars are managed correctly
92+
// When proxy is nil, this will remove any existing proxy env vars
93+
var httpProxy, httpsProxy, noProxy string
94+
if opts.Proxy != nil {
95+
httpProxy = opts.Proxy.HTTPProxy
96+
httpsProxy = opts.Proxy.HTTPSProxy
97+
noProxy = opts.Proxy.NoProxy
98+
}
99+
deploymentOpts := []ResourceCreatorOption{
100+
WithDeploymentSpec(depSpec.Spec),
101+
WithLabels(depSpec.Label),
102+
WithProxy(httpProxy, httpsProxy, noProxy),
103+
}
91104
deploymentResource := CreateDeploymentResource(
92105
depSpec.Name,
93106
opts.InstallNamespace,
94-
WithDeploymentSpec(depSpec.Spec),
95-
WithLabels(depSpec.Label),
107+
deploymentOpts...,
96108
)
97109

98110
secretInfo := render.CertProvisionerFor(depSpec.Name, opts).GetCertSecretInfo()

0 commit comments

Comments
 (0)