Skip to content

Commit 3959ccf

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 6e01665 commit 3959ccf

File tree

13 files changed

+847
-18
lines changed

13 files changed

+847
-18
lines changed

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: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -474,11 +474,26 @@ func run() error {
474474
}
475475

476476
certProvider := getCertificateProvider()
477+
478+
// Read proxy configuration from environment variables
479+
var proxy *render.Proxy
480+
httpProxy := os.Getenv("HTTP_PROXY")
481+
httpsProxy := os.Getenv("HTTPS_PROXY")
482+
noProxy := os.Getenv("NO_PROXY")
483+
if httpProxy != "" || httpsProxy != "" || noProxy != "" {
484+
proxy = &render.Proxy{
485+
HTTPProxy: httpProxy,
486+
HTTPSProxy: httpsProxy,
487+
NoProxy: noProxy,
488+
}
489+
}
490+
477491
regv1ManifestProvider := &applier.RegistryV1ManifestProvider{
478492
BundleRenderer: registryv1.Renderer,
479493
CertificateProvider: certProvider,
480494
IsWebhookSupportEnabled: certProvider != nil,
481495
IsSingleOwnNamespaceEnabled: features.OperatorControllerFeatureGate.Enabled(features.SingleOwnNamespaceInstallSupport),
496+
Proxy: proxy,
482497
}
483498
var cerCfg reconcilerConfigurator
484499
if features.OperatorControllerFeatureGate.Enabled(features.BoxcutterRuntime) {
@@ -540,6 +555,50 @@ func run() error {
540555
return err
541556
}
542557

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

637701
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: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ type RegistryV1ManifestProvider struct {
3333
CertificateProvider render.CertificateProvider
3434
IsWebhookSupportEnabled bool
3535
IsSingleOwnNamespaceEnabled bool
36+
Proxy *render.Proxy
3637
}
3738

3839
func (r *RegistryV1ManifestProvider) Get(bundleFS fs.FS, ext *ocv1.ClusterExtension) ([]client.Object, error) {
@@ -70,6 +71,14 @@ func (r *RegistryV1ManifestProvider) Get(bundleFS fs.FS, ext *ocv1.ClusterExtens
7071
render.WithCertificateProvider(r.CertificateProvider),
7172
}
7273

74+
// Always include proxy option to ensure manifests reflect current proxy state
75+
// When r.Proxy is nil, this ensures proxy env vars are removed from manifests
76+
if r.Proxy != nil {
77+
opts = append(opts, render.WithProxy(*r.Proxy))
78+
} else {
79+
opts = append(opts, render.WithProxy(render.Proxy{}))
80+
}
81+
7382
if r.IsSingleOwnNamespaceEnabled {
7483
configOpts, err := r.extractBundleConfigOptions(&rv1, ext)
7584
if err != nil {
@@ -111,6 +120,17 @@ func (r *RegistryV1ManifestProvider) extractBundleConfigOptions(rv1 *bundle.Regi
111120
return opts, nil
112121
}
113122

123+
// ProxyFingerprint returns a stable hash of the proxy configuration.
124+
// This is used to detect when proxy settings change and a new revision is needed.
125+
func (r *RegistryV1ManifestProvider) ProxyFingerprint() string {
126+
if r.Proxy == nil {
127+
return ""
128+
}
129+
data, _ := json.Marshal(r.Proxy)
130+
hash := sha256.Sum256(data)
131+
return fmt.Sprintf("%x", hash[:8])
132+
}
133+
114134
// RegistryV1HelmChartProvider creates a Helm-Chart from a registry+v1 bundle and its associated ClusterExtension
115135
type RegistryV1HelmChartProvider struct {
116136
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
)

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+
deploymentOpts := []ResourceCreatorOption{
92+
WithDeploymentSpec(depSpec.Spec),
93+
WithLabels(depSpec.Label),
94+
}
95+
// Always call WithProxy to ensure proxy env vars are managed correctly
96+
// When proxy is nil, this will remove any existing proxy env vars
97+
var httpProxy, httpsProxy, noProxy string
98+
if opts.Proxy != nil {
99+
httpProxy = opts.Proxy.HTTPProxy
100+
httpsProxy = opts.Proxy.HTTPSProxy
101+
noProxy = opts.Proxy.NoProxy
102+
}
103+
deploymentOpts = append(deploymentOpts, WithProxy(httpProxy, httpsProxy, noProxy))
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()

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,62 @@ func WithMutatingWebhooks(webhooks ...admissionregistrationv1.MutatingWebhook) f
115115
}
116116
}
117117

118+
// WithProxy applies HTTP proxy environment variables to Deployment resources
119+
func WithProxy(httpProxy, httpsProxy, noProxy string) func(client.Object) {
120+
return func(obj client.Object) {
121+
switch o := obj.(type) {
122+
case *appsv1.Deployment:
123+
addProxyEnvVars(httpProxy, httpsProxy, noProxy, o.Spec.Template.Spec.Containers)
124+
}
125+
}
126+
}
127+
128+
func addProxyEnvVars(httpProxy, httpsProxy, noProxy string, containers []corev1.Container) {
129+
proxyEnvNames := []string{"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"}
130+
131+
for i := range containers {
132+
// First, remove any existing proxy env vars to ensure clean slate
133+
// This allows us to remove proxy vars when they're no longer configured
134+
var newEnv []corev1.EnvVar
135+
if len(containers[i].Env) > 0 {
136+
newEnv = make([]corev1.EnvVar, 0, len(containers[i].Env))
137+
for _, env := range containers[i].Env {
138+
isProxyVar := false
139+
for _, proxyName := range proxyEnvNames {
140+
if env.Name == proxyName {
141+
isProxyVar = true
142+
break
143+
}
144+
}
145+
if !isProxyVar {
146+
newEnv = append(newEnv, env)
147+
}
148+
}
149+
}
150+
containers[i].Env = newEnv
151+
152+
// Then add the proxy env vars if they're configured
153+
if len(httpProxy) > 0 {
154+
containers[i].Env = append(containers[i].Env, corev1.EnvVar{
155+
Name: "HTTP_PROXY",
156+
Value: httpProxy,
157+
})
158+
}
159+
if len(httpsProxy) > 0 {
160+
containers[i].Env = append(containers[i].Env, corev1.EnvVar{
161+
Name: "HTTPS_PROXY",
162+
Value: httpsProxy,
163+
})
164+
}
165+
if len(noProxy) > 0 {
166+
containers[i].Env = append(containers[i].Env, corev1.EnvVar{
167+
Name: "NO_PROXY",
168+
Value: noProxy,
169+
})
170+
}
171+
}
172+
}
173+
118174
// CreateServiceAccountResource creates a ServiceAccount resource with name 'name', namespace 'namespace', and applying
119175
// any ServiceAccount related options in opts
120176
func CreateServiceAccountResource(name string, namespace string, opts ...ResourceCreatorOption) *corev1.ServiceAccount {

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import (
66
"strings"
77
"testing"
88

9+
"github.com/stretchr/testify/assert"
910
"github.com/stretchr/testify/require"
1011
admissionregistrationv1 "k8s.io/api/admissionregistration/v1"
12+
appsv1 "k8s.io/api/apps/v1"
1113
corev1 "k8s.io/api/core/v1"
1214
rbacv1 "k8s.io/api/rbac/v1"
1315
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -276,3 +278,60 @@ func Test_WithMutatingWebhook(t *testing.T) {
276278
{Name: "wh-two"},
277279
}, wh.Webhooks)
278280
}
281+
282+
func Test_WithProxy(t *testing.T) {
283+
depSpec := appsv1.DeploymentSpec{
284+
Template: corev1.PodTemplateSpec{
285+
Spec: corev1.PodSpec{
286+
Containers: []corev1.Container{
287+
{
288+
Name: "c1",
289+
Env: []corev1.EnvVar{
290+
{
291+
Name: "TEST",
292+
Value: "xxx",
293+
},
294+
},
295+
},
296+
{
297+
Name: "c2",
298+
Env: []corev1.EnvVar{
299+
{
300+
Name: "TEST",
301+
Value: "xxx",
302+
},
303+
},
304+
},
305+
},
306+
},
307+
},
308+
}
309+
310+
depl := generators.CreateDeploymentResource(
311+
"test",
312+
"test-ns",
313+
generators.WithDeploymentSpec(depSpec),
314+
generators.WithProxy("http,prox", "https,prox", "no,prox"),
315+
)
316+
317+
expected := []corev1.EnvVar{
318+
{
319+
Name: "TEST",
320+
Value: "xxx",
321+
},
322+
{
323+
Name: "HTTP_PROXY",
324+
Value: "http,prox",
325+
},
326+
{
327+
Name: "HTTPS_PROXY",
328+
Value: "https,prox",
329+
},
330+
{
331+
Name: "NO_PROXY",
332+
Value: "no,prox",
333+
},
334+
}
335+
assert.Equal(t, expected, depl.Spec.Template.Spec.Containers[0].Env)
336+
assert.Equal(t, expected, depl.Spec.Template.Spec.Containers[1].Env)
337+
}

0 commit comments

Comments
 (0)