Skip to content

Commit 80a5f13

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 80a5f13

14 files changed

Lines changed: 1242 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 -args --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: 81 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,17 @@ func run() error {
474476
}
475477

476478
certProvider := getCertificateProvider()
479+
480+
// Create proxy configuration from environment variables
481+
// The fingerprint is calculated once during construction and cached
482+
proxyConfig := proxy.NewFromEnv()
483+
477484
regv1ManifestProvider := &applier.RegistryV1ManifestProvider{
478485
BundleRenderer: registryv1.Renderer,
479486
CertificateProvider: certProvider,
480487
IsWebhookSupportEnabled: certProvider != nil,
481488
IsSingleOwnNamespaceEnabled: features.OperatorControllerFeatureGate.Enabled(features.SingleOwnNamespaceInstallSupport),
489+
Proxy: proxyConfig,
482490
}
483491
var cerCfg reconcilerConfigurator
484492
if features.OperatorControllerFeatureGate.Enabled(features.BoxcutterRuntime) {
@@ -540,6 +548,73 @@ func run() error {
540548
return err
541549
}
542550

551+
// Add a runnable to trigger reconciliation of all ClusterExtensions on startup.
552+
// This ensures existing deployments get updated when proxy configuration changes
553+
// (added, modified, or removed).
554+
if err := mgr.Add(manager.RunnableFunc(func(ctx context.Context) error {
555+
// Wait for the cache to sync
556+
if !mgr.GetCache().WaitForCacheSync(ctx) {
557+
return fmt.Errorf("failed to wait for cache sync")
558+
}
559+
560+
// Always trigger reconciliation on startup to handle proxy config changes
561+
if proxyConfig != nil {
562+
setupLog.Info("proxy configuration detected, triggering reconciliation of all ClusterExtensions",
563+
"httpProxy", proxy.SanitizeURL(proxyConfig.HTTPProxy), "httpsProxy", proxy.SanitizeURL(proxyConfig.HTTPSProxy), "noProxy", proxyConfig.NoProxy)
564+
} else {
565+
setupLog.Info("no proxy configuration detected, triggering reconciliation to remove proxy vars from existing deployments")
566+
}
567+
568+
extList := &ocv1.ClusterExtensionList{}
569+
if err := cl.List(ctx, extList); err != nil {
570+
setupLog.Error(err, "failed to list ClusterExtensions for proxy update")
571+
return nil // Don't fail startup
572+
}
573+
574+
for i := range extList.Items {
575+
ext := &extList.Items[i]
576+
577+
// Get current and desired proxy hash
578+
currentHash, exists := ext.Annotations[proxy.ConfigHashKey]
579+
desiredHash := proxyConfig.Fingerprint()
580+
581+
// Skip if annotation matches desired state
582+
if exists && currentHash == desiredHash {
583+
continue
584+
}
585+
// Skip if neither proxy nor annotation exists
586+
if !exists && desiredHash == "" {
587+
continue
588+
}
589+
590+
// Use Patch instead of Update to avoid resourceVersion conflicts
591+
// This ensures the annotation is set even if the ClusterExtension is modified concurrently
592+
patch := client.MergeFrom(ext.DeepCopy())
593+
if ext.Annotations == nil {
594+
ext.Annotations = make(map[string]string)
595+
}
596+
597+
// Delete annotation if no proxy is configured, otherwise set it
598+
if desiredHash == "" {
599+
delete(ext.Annotations, proxy.ConfigHashKey)
600+
} else {
601+
ext.Annotations[proxy.ConfigHashKey] = desiredHash
602+
}
603+
604+
if err := cl.Patch(ctx, ext, patch); err != nil {
605+
setupLog.Error(err, "failed to set proxy hash on ClusterExtension", "name", ext.Name)
606+
// Continue with other ClusterExtensions
607+
}
608+
}
609+
610+
setupLog.Info("triggered reconciliation for existing ClusterExtensions", "count", len(extList.Items))
611+
612+
return nil
613+
})); err != nil {
614+
setupLog.Error(err, "unable to add startup reconciliation trigger")
615+
return err
616+
}
617+
543618
setupLog.Info("starting manager")
544619
ctx := ctrl.SetupSignalHandler()
545620
if err := mgr.Start(ctx); err != nil {
@@ -625,13 +700,18 @@ func (c *boxcutterReconcilerConfigurator) Configure(ceReconciler *controllers.Cl
625700
ActionClientGetter: acg,
626701
RevisionGenerator: rg,
627702
}
703+
// Get the ManifestProvider to extract proxy fingerprint
704+
regv1Provider, ok := c.regv1ManifestProvider.(*applier.RegistryV1ManifestProvider)
705+
if !ok {
706+
return fmt.Errorf("manifest provider is not of type *applier.RegistryV1ManifestProvider")
707+
}
628708
ceReconciler.ReconcileSteps = []controllers.ReconcileStepFunc{
629709
controllers.HandleFinalizers(c.finalizers),
630710
controllers.MigrateStorage(storageMigrator),
631711
controllers.RetrieveRevisionStates(revisionStatesGetter),
632712
controllers.ResolveBundle(c.resolver, c.mgr.GetClient()),
633713
controllers.UnpackBundle(c.imagePuller, c.imageCache),
634-
controllers.ApplyBundleWithBoxcutter(appl.Apply),
714+
controllers.ApplyBundleWithBoxcutter(appl.Apply, regv1Provider.ProxyFingerprint),
635715
}
636716

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

internal/operator-controller/applier/boxcutter.go

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import (
3333
ocv1 "github.com/operator-framework/operator-controller/api/v1"
3434
"github.com/operator-framework/operator-controller/internal/operator-controller/authorization"
3535
"github.com/operator-framework/operator-controller/internal/operator-controller/labels"
36+
"github.com/operator-framework/operator-controller/internal/operator-controller/proxy"
3637
"github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle/source"
3738
"github.com/operator-framework/operator-controller/internal/shared/util/cache"
3839
)
@@ -485,17 +486,28 @@ func (bc *Boxcutter) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1.Clust
485486
desiredRevision.Spec.Revision = currentRevision.Spec.Revision
486487
desiredRevision.Name = currentRevision.Name
487488

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

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

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

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

internal/operator-controller/applier/provider.go

Lines changed: 12 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) {
@@ -68,6 +70,9 @@ func (r *RegistryV1ManifestProvider) Get(bundleFS fs.FS, ext *ocv1.ClusterExtens
6870

6971
opts := []render.Option{
7072
render.WithCertificateProvider(r.CertificateProvider),
73+
// Always include proxy option to ensure manifests reflect current proxy state
74+
// When r.Proxy is nil, this ensures proxy env vars are removed from manifests
75+
render.WithProxy(r.Proxy),
7176
}
7277

7378
if r.IsSingleOwnNamespaceEnabled {
@@ -111,6 +116,13 @@ func (r *RegistryV1ManifestProvider) extractBundleConfigOptions(rv1 *bundle.Regi
111116
return opts, nil
112117
}
113118

119+
// ProxyFingerprint returns a stable hash of the proxy configuration.
120+
// This is used to detect when proxy settings change and a new revision is needed.
121+
// The hash is calculated once during proxy construction and cached in the Proxy itself.
122+
func (r *RegistryV1ManifestProvider) ProxyFingerprint() string {
123+
return r.Proxy.Fingerprint()
124+
}
125+
114126
// RegistryV1HelmChartProvider creates a Helm-Chart from a registry+v1 bundle and its associated ClusterExtension
115127
type RegistryV1HelmChartProvider struct {
116128
ManifestProvider ManifestProvider

internal/operator-controller/controllers/boxcutter_reconcile_steps.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import (
3232

3333
ocv1 "github.com/operator-framework/operator-controller/api/v1"
3434
"github.com/operator-framework/operator-controller/internal/operator-controller/labels"
35+
"github.com/operator-framework/operator-controller/internal/operator-controller/proxy"
3536
)
3637

3738
type BoxcutterRevisionStatesGetter struct {
@@ -96,7 +97,7 @@ func MigrateStorage(m StorageMigrator) ReconcileStepFunc {
9697
}
9798
}
9899

99-
func ApplyBundleWithBoxcutter(apply func(ctx context.Context, contentFS fs.FS, ext *ocv1.ClusterExtension, objectLabels, revisionAnnotations map[string]string) (bool, string, error)) ReconcileStepFunc {
100+
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 {
100101
return func(ctx context.Context, state *reconcileState, ext *ocv1.ClusterExtension) (*ctrl.Result, error) {
101102
l := log.FromContext(ctx)
102103
revisionAnnotations := map[string]string{
@@ -105,6 +106,10 @@ func ApplyBundleWithBoxcutter(apply func(ctx context.Context, contentFS fs.FS, e
105106
labels.BundleVersionKey: state.resolvedRevisionMetadata.Version,
106107
labels.BundleReferenceKey: state.resolvedRevisionMetadata.Image,
107108
}
109+
// Add proxy config fingerprint to detect when proxy settings change
110+
if fp := proxyFingerprint(); fp != "" {
111+
revisionAnnotations[proxy.ConfigHashKey] = fp
112+
}
108113
objLbls := map[string]string{
109114
labels.OwnerKindKey: ocv1.ClusterExtensionKind,
110115
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)
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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+
"os"
10+
"strings"
11+
)
12+
13+
const (
14+
// ConfigHashKey is the annotation key used to record the proxy configuration hash.
15+
// This annotation is set on both ClusterExtensions and ClusterExtensionRevisions.
16+
// When the hash changes on a ClusterExtension, it triggers reconciliation.
17+
// Comparing hashes between ClusterExtension and its current revision determines
18+
// if a new revision is needed due to proxy configuration changes.
19+
ConfigHashKey = "olm.operatorframework.io/proxy-config-hash"
20+
)
21+
22+
// Proxy holds HTTP proxy configuration values that are applied to rendered resources.
23+
// These values are typically set as environment variables on generated Pods to enable
24+
// operators to function correctly in environments that require HTTP proxies for outbound
25+
// connections.
26+
type Proxy struct {
27+
// HTTPProxy is the HTTP proxy URL (e.g., "http://proxy.example.com:8080").
28+
// An empty value means no HTTP proxy is configured.
29+
HTTPProxy string
30+
// HTTPSProxy is the HTTPS proxy URL (e.g., "https://proxy.example.com:8443").
31+
// An empty value means no HTTPS proxy is configured.
32+
HTTPSProxy string
33+
// NoProxy is a comma-separated list of hosts, domains, or CIDR ranges that should
34+
// bypass the proxy (e.g., "localhost,127.0.0.1,.cluster.local").
35+
// An empty value means all traffic will use the proxy (if configured).
36+
NoProxy string
37+
// fingerprint is a cached hash of the proxy configuration, calculated once during construction.
38+
// This is used to detect when proxy settings change and a new revision is needed.
39+
fingerprint string
40+
}
41+
42+
// NewFromEnv creates a new Proxy from environment variables.
43+
// Returns nil if no proxy environment variables are set.
44+
// The fingerprint is calculated once during construction and cached.
45+
func NewFromEnv() *Proxy {
46+
httpProxy := os.Getenv("HTTP_PROXY")
47+
httpsProxy := os.Getenv("HTTPS_PROXY")
48+
noProxy := os.Getenv("NO_PROXY")
49+
50+
// If no proxy variables are set, return nil
51+
if httpProxy == "" && httpsProxy == "" && noProxy == "" {
52+
return nil
53+
}
54+
55+
p := &Proxy{
56+
HTTPProxy: httpProxy,
57+
HTTPSProxy: httpsProxy,
58+
NoProxy: noProxy,
59+
}
60+
61+
// Calculate and cache the fingerprint
62+
p.fingerprint = calculateFingerprint(p)
63+
64+
return p
65+
}
66+
67+
// calculateFingerprint computes a stable hash of the proxy configuration.
68+
func calculateFingerprint(p *Proxy) string {
69+
if p == nil {
70+
return ""
71+
}
72+
data, err := json.Marshal(p)
73+
if err != nil {
74+
// This should never happen for a simple struct with string fields,
75+
// but return empty string if it does
76+
return ""
77+
}
78+
hash := sha256.Sum256(data)
79+
return fmt.Sprintf("%x", hash[:8])
80+
}
81+
82+
// Fingerprint returns the cached hash of the proxy configuration.
83+
// This is used to detect when proxy settings change and a new revision is needed.
84+
// Returns an empty string if the proxy is nil.
85+
func (p *Proxy) Fingerprint() string {
86+
if p == nil {
87+
return ""
88+
}
89+
return p.fingerprint
90+
}
91+
92+
// SanitizeURL removes credentials from a proxy URL for safe logging.
93+
// Returns the original string if it's not a valid URL or doesn't contain credentials.
94+
// If the string contains @ but credentials can't be parsed out, returns a redacted version.
95+
func SanitizeURL(proxyURL string) string {
96+
if proxyURL == "" {
97+
return ""
98+
}
99+
100+
u, err := url.Parse(proxyURL)
101+
if err != nil {
102+
// If we can't parse it, check if it might contain credentials (user:pass@host pattern)
103+
// If so, redact it to avoid leaking credentials in logs
104+
if strings.Contains(proxyURL, "@") {
105+
return "<redacted>"
106+
}
107+
// Otherwise return as-is (might be a hostname or other format without credentials)
108+
return proxyURL
109+
}
110+
111+
// If there's user info, remove it and return sanitized URL
112+
if u.User != nil {
113+
u.User = nil
114+
return u.String()
115+
}
116+
117+
// If no user info was parsed but the string contains @, it might be a schemelessly-formatted
118+
// URL like "user:pass@host:port" which url.Parse doesn't recognize as having credentials.
119+
// Redact it to be safe.
120+
if strings.Contains(proxyURL, "@") {
121+
return "<redacted>"
122+
}
123+
124+
// No credentials detected, return as-is
125+
return proxyURL
126+
}

0 commit comments

Comments
 (0)