Skip to content

Commit 0f3594a

Browse files
committed
fix(object-controller): retry referenced Secret failures
Read each referenced Secret once per reconciliation. Return read errors, including missing Secrets, so retries verify immutability before decoding. Requeue while referenced Secrets remain mutable and log this expected waiting state at debug level. Deduplicate and verify references in one pass. Watch metadata changes on ClusterObjectSet-owned Secrets across namespaces without caching their contents or relying on the pull-secret cache. Cover missing-Secret recovery and owned-Secret replacement in regression tests. Signed-off-by: Fabricio Aguiar <fabricio.aguiar@gmail.com> rh-pre-commit.version: 2.3.2 rh-pre-commit.check-secrets: ENABLED
1 parent 18dfd75 commit 0f3594a

8 files changed

Lines changed: 484 additions & 67 deletions

File tree

‎cmd/object-controller/main_test.go‎

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"k8s.io/utils/ptr"
2020
ctrl "sigs.k8s.io/controller-runtime"
2121
"sigs.k8s.io/controller-runtime/pkg/client"
22+
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
2223

2324
ocv1 "github.com/operator-framework/operator-controller/api/v1"
2425
"github.com/operator-framework/operator-controller/internal/object-controller/scheme"
@@ -93,7 +94,7 @@ func TestStandaloneController(t *testing.T) {
9394
defer syncCancel()
9495
require.True(t, mgr.GetCache().WaitForCacheSync(syncCtx), "manager cache did not synchronize")
9596

96-
for _, name := range []string{"inline", "secret-ref"} {
97+
for _, name := range []string{"inline", "secret-ref", "mutable-secret-ref"} {
9798
t.Run(name, func(t *testing.T) {
9899
ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{GenerateName: "standalone-"}}
99100
require.NoError(t, cl.Create(ctx, ns))
@@ -103,12 +104,13 @@ func TestStandaloneController(t *testing.T) {
103104
"data": map[string]any{"hello": "world"},
104105
}}
105106
obj := ocv1.ClusterObjectSetObject{Object: manifest}
106-
if name == "secret-ref" {
107+
var secret *corev1.Secret
108+
if name != "inline" {
107109
data, err := json.Marshal(manifest.Object)
108110
require.NoError(t, err)
109-
secret := &corev1.Secret{
111+
secret = &corev1.Secret{
110112
ObjectMeta: metav1.ObjectMeta{Name: "content", Namespace: ns.Name},
111-
Immutable: ptr.To(true), Data: map[string][]byte{"object": data},
113+
Immutable: ptr.To(name != "mutable-secret-ref"), Data: map[string][]byte{"object": data},
112114
}
113115
require.NoError(t, cl.Create(ctx, secret))
114116
obj = ocv1.ClusterObjectSetObject{Ref: ocv1.ObjectSourceRef{Name: secret.Name, Namespace: secret.Namespace, Key: "object"}}
@@ -122,6 +124,39 @@ func TestStandaloneController(t *testing.T) {
122124
},
123125
}
124126
require.NoError(t, cl.Create(ctx, cos))
127+
if secret != nil {
128+
require.NoError(t, controllerutil.SetControllerReference(cos, secret, scheme.Scheme))
129+
require.NoError(t, cl.Update(ctx, secret))
130+
}
131+
rolloutTimeout := time.Minute
132+
if name == "mutable-secret-ref" {
133+
require.EventuallyWithT(t, func(collect *assert.CollectT) {
134+
if !assert.NoError(collect, cl.Get(ctx, client.ObjectKeyFromObject(cos), cos)) {
135+
return
136+
}
137+
condition := meta.FindStatusCondition(cos.Status.Conditions, ocv1.ClusterObjectSetTypeProgressing)
138+
if assert.NotNil(collect, condition) {
139+
assert.Equal(collect, ocv1.ClusterObjectSetReasonBlocked, condition.Reason)
140+
assert.Contains(collect, condition.Message, "not immutable")
141+
}
142+
}, 5*time.Second, 100*time.Millisecond)
143+
144+
// Let status-triggered reconciliations settle before changing only
145+
// the Secret. Recovery must precede the 10-second polling retry.
146+
lastVersion, unchangedSince := cos.ResourceVersion, time.Now()
147+
require.Eventually(t, func() bool {
148+
if err := cl.Get(ctx, client.ObjectKeyFromObject(cos), cos); err != nil {
149+
return false
150+
}
151+
if cos.ResourceVersion != lastVersion {
152+
lastVersion, unchangedSince = cos.ResourceVersion, time.Now()
153+
}
154+
return time.Since(unchangedSince) >= time.Second
155+
}, 3*time.Second, 100*time.Millisecond)
156+
secret.Immutable = ptr.To(true)
157+
require.NoError(t, cl.Update(ctx, secret))
158+
rolloutTimeout = 5 * time.Second
159+
}
125160
require.EventuallyWithT(t, func(collect *assert.CollectT) {
126161
if !assert.NoError(collect, cl.Get(ctx, client.ObjectKeyFromObject(cos), cos)) {
127162
return
@@ -136,13 +171,52 @@ func TestStandaloneController(t *testing.T) {
136171
if assert.NotNil(collect, progressing) {
137172
assert.Equal(collect, ocv1.ReasonSucceeded, progressing.Reason)
138173
}
139-
}, time.Minute, 100*time.Millisecond)
174+
}, rolloutTimeout, 100*time.Millisecond)
140175
cm := &corev1.ConfigMap{}
141176
require.NoError(t, cl.Get(ctx, client.ObjectKey{Name: name, Namespace: ns.Name}, cm))
142177
require.Equal(t, "world", cm.Data["hello"])
143178
require.NotNil(t, metav1.GetControllerOf(cm))
144179
require.Equal(t, cos.UID, metav1.GetControllerOf(cm).UID)
145180

181+
if secret != nil {
182+
// A completed COS does not poll. Replacing an owned source Secret
183+
// must trigger content verification, and restoring it must unblock
184+
// reconciliation without changing the COS or its managed objects.
185+
original := secret.DeepCopy()
186+
require.NoError(t, cl.Delete(ctx, secret))
187+
secret.ResourceVersion = ""
188+
secret.UID = ""
189+
changed := manifest.DeepCopy()
190+
changed.Object["data"] = map[string]any{"hello": "changed"}
191+
secret.Data["object"], err = json.Marshal(changed.Object)
192+
require.NoError(t, err)
193+
require.NoError(t, cl.Create(ctx, secret))
194+
require.EventuallyWithT(t, func(collect *assert.CollectT) {
195+
if !assert.NoError(collect, cl.Get(ctx, client.ObjectKeyFromObject(cos), cos)) {
196+
return
197+
}
198+
condition := meta.FindStatusCondition(cos.Status.Conditions, ocv1.ClusterObjectSetTypeProgressing)
199+
if assert.NotNil(collect, condition) {
200+
assert.Equal(collect, ocv1.ClusterObjectSetReasonBlocked, condition.Reason)
201+
assert.Contains(collect, condition.Message, "resolved content of 1 phase(s) has changed")
202+
}
203+
}, 30*time.Second, 100*time.Millisecond)
204+
205+
require.NoError(t, cl.Delete(ctx, secret))
206+
original.ResourceVersion = ""
207+
original.UID = ""
208+
require.NoError(t, cl.Create(ctx, original))
209+
require.EventuallyWithT(t, func(collect *assert.CollectT) {
210+
if !assert.NoError(collect, cl.Get(ctx, client.ObjectKeyFromObject(cos), cos)) {
211+
return
212+
}
213+
condition := meta.FindStatusCondition(cos.Status.Conditions, ocv1.ClusterObjectSetTypeProgressing)
214+
if assert.NotNil(collect, condition) {
215+
assert.Equal(collect, ocv1.ReasonSucceeded, condition.Reason)
216+
}
217+
}, 30*time.Second, 100*time.Millisecond)
218+
}
219+
146220
// Observe managed-object changes without updating the ClusterObjectSet.
147221
originalUID := cm.UID
148222
require.NoError(t, cl.Delete(ctx, cm))

‎cmd/operator-controller/main.go‎

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -685,10 +685,9 @@ func (c *boxcutterReconcilerConfigurator) Configure(ceReconciler *controllers.Cl
685685
return fmt.Errorf("unable to create revision engine factory: %w", err)
686686
}
687687

688-
cosClient := &secretFallbackClient{
689-
Client: c.mgr.GetClient(),
690-
apiReader: c.mgr.GetAPIReader(),
691-
systemNamespace: cfg.systemNamespace,
688+
cosClient := &uncachedSecretClient{
689+
Client: c.mgr.GetClient(),
690+
apiReader: c.mgr.GetAPIReader(),
692691
}
693692
if err = (&clusterobjctrl.ClusterObjectSetReconciler{
694693
Client: cosClient,
@@ -762,16 +761,14 @@ func main() {
762761
}
763762
}
764763

765-
// secretFallbackClient wraps a cached client.Client and falls back to direct
766-
// API reads for Secrets outside the system namespace, where the cache does not watch.
767-
type secretFallbackClient struct {
764+
// uncachedSecretClient bypasses the cache for Secret reads.
765+
type uncachedSecretClient struct {
768766
client.Client
769-
apiReader client.Reader
770-
systemNamespace string
767+
apiReader client.Reader
771768
}
772769

773-
func (c *secretFallbackClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
774-
if _, isSecret := obj.(*corev1.Secret); isSecret && key.Namespace != c.systemNamespace {
770+
func (c *uncachedSecretClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
771+
if _, isSecret := obj.(*corev1.Secret); isSecret {
775772
return c.apiReader.Get(ctx, key, obj, opts...)
776773
}
777774
return c.Client.Get(ctx, key, obj, opts...)
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package main
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
corev1 "k8s.io/api/core/v1"
8+
apierrors "k8s.io/apimachinery/pkg/api/errors"
9+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
10+
"k8s.io/apimachinery/pkg/runtime"
11+
"sigs.k8s.io/controller-runtime/pkg/client"
12+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
13+
14+
ocv1 "github.com/operator-framework/operator-controller/api/v1"
15+
)
16+
17+
func TestSecretClientReadsCurrentSecrets(t *testing.T) {
18+
testScheme := runtime.NewScheme()
19+
require.NoError(t, corev1.AddToScheme(testScheme))
20+
for _, namespace := range []string{"olmv1-system", "extension"} {
21+
for _, deleted := range []bool{false, true} {
22+
name := namespace + "/updated"
23+
if deleted {
24+
name = namespace + "/deleted"
25+
}
26+
t.Run(name, func(t *testing.T) {
27+
stale := &corev1.Secret{
28+
ObjectMeta: metav1.ObjectMeta{Name: "content", Namespace: namespace},
29+
Data: map[string][]byte{"object": []byte("old content")},
30+
}
31+
cachedClient := fake.NewClientBuilder().WithScheme(testScheme).WithObjects(stale).Build()
32+
apiBuilder := fake.NewClientBuilder().WithScheme(testScheme)
33+
if !deleted {
34+
current := stale.DeepCopy()
35+
current.Data["object"] = []byte("new content")
36+
apiBuilder.WithObjects(current)
37+
}
38+
cl := &uncachedSecretClient{
39+
Client: cachedClient, apiReader: apiBuilder.Build(),
40+
}
41+
secret := &corev1.Secret{}
42+
err := cl.Get(t.Context(), client.ObjectKeyFromObject(stale), secret)
43+
if deleted {
44+
require.True(t, apierrors.IsNotFound(err), "must not return a cached Secret after deletion, got %v", err)
45+
return
46+
}
47+
require.NoError(t, err)
48+
require.Equal(t, []byte("new content"), secret.Data["object"])
49+
})
50+
}
51+
}
52+
}
53+
54+
func TestSecretClientUsesCacheForOtherObjects(t *testing.T) {
55+
testScheme := runtime.NewScheme()
56+
require.NoError(t, ocv1.AddToScheme(testScheme))
57+
cached := &ocv1.ClusterObjectSet{
58+
ObjectMeta: metav1.ObjectMeta{Name: "revision"},
59+
Spec: ocv1.ClusterObjectSetSpec{Revision: 1},
60+
}
61+
current := cached.DeepCopy()
62+
current.Spec.Revision = 2
63+
cl := &uncachedSecretClient{
64+
Client: fake.NewClientBuilder().WithScheme(testScheme).WithObjects(cached).Build(),
65+
apiReader: fake.NewClientBuilder().WithScheme(testScheme).WithObjects(current).Build(),
66+
}
67+
cos := &ocv1.ClusterObjectSet{}
68+
require.NoError(t, cl.Get(t.Context(), client.ObjectKeyFromObject(cached), cos))
69+
require.Equal(t, int64(1), cos.Spec.Revision)
70+
}

0 commit comments

Comments
 (0)