Skip to content

Commit 7193433

Browse files
committed
ESO-50: Add controller to manage annotations on the external-secrets CRD
1 parent 13ce553 commit 7193433

File tree

10 files changed

+805
-4
lines changed

10 files changed

+805
-4
lines changed

api/v1alpha1/conditions.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@ const (
2121
// - Failed
2222
// - Ready: operand successfully deployed and ready
2323
Ready string = "Ready"
24+
25+
// UpdateAnnotation is the condition type used to inform status of
26+
// updating the annotations.
27+
// Status:
28+
// - True
29+
// - False
30+
// Reason:
31+
// - Completed
32+
// - Failed
33+
UpdateAnnotation string = "UpdateAnnotation"
2434
)
2535

2636
const (
@@ -29,4 +39,6 @@ const (
2939
ReasonReady string = "Ready"
3040

3141
ReasonInProgress string = "Progressing"
42+
43+
ReasonCompleted string = "Completed"
3244
)

cmd/external-secrets-operator/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
appsv1 "k8s.io/api/apps/v1"
2525
corev1 "k8s.io/api/core/v1"
2626
rbacv1 "k8s.io/api/rbac/v1"
27+
crdv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
2728
"k8s.io/apimachinery/pkg/runtime"
2829
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
2930
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
@@ -52,6 +53,7 @@ func init() {
5253
utilruntime.Must(corev1.AddToScheme(scheme))
5354
utilruntime.Must(rbacv1.AddToScheme(scheme))
5455
utilruntime.Must(certmanagerv1.AddToScheme(scheme))
56+
utilruntime.Must(crdv1.AddToScheme(scheme))
5557

5658
utilruntime.Must(operatorv1alpha1.AddToScheme(scheme))
5759
// +kubebuilder:scaffold:scheme

pkg/controller/common/utils.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
appsv1 "k8s.io/api/apps/v1"
99
corev1 "k8s.io/api/core/v1"
1010
rbacv1 "k8s.io/api/rbac/v1"
11+
crdv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
1112
"k8s.io/apimachinery/pkg/runtime"
1213
"k8s.io/apimachinery/pkg/runtime/serializer"
1314
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -38,6 +39,9 @@ func init() {
3839
if err := webhook.AddToScheme(scheme); err != nil {
3940
panic(err)
4041
}
42+
if err := crdv1.AddToScheme(scheme); err != nil {
43+
panic(err)
44+
}
4145
}
4246

4347
func UpdateResourceLabels(obj client.Object, labels map[string]string) {
Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
/*
2+
Copyright 2025.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package crd_annotator
18+
19+
import (
20+
"context"
21+
"fmt"
22+
23+
crdv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
24+
"k8s.io/apimachinery/pkg/api/errors"
25+
apimeta "k8s.io/apimachinery/pkg/api/meta"
26+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
27+
"k8s.io/apimachinery/pkg/labels"
28+
"k8s.io/apimachinery/pkg/selection"
29+
"k8s.io/apimachinery/pkg/types"
30+
utilerrors "k8s.io/apimachinery/pkg/util/errors"
31+
"k8s.io/client-go/util/retry"
32+
ctrl "sigs.k8s.io/controller-runtime"
33+
"sigs.k8s.io/controller-runtime/pkg/builder"
34+
"sigs.k8s.io/controller-runtime/pkg/cache"
35+
"sigs.k8s.io/controller-runtime/pkg/client"
36+
"sigs.k8s.io/controller-runtime/pkg/handler"
37+
"sigs.k8s.io/controller-runtime/pkg/manager"
38+
"sigs.k8s.io/controller-runtime/pkg/predicate"
39+
"sigs.k8s.io/controller-runtime/pkg/reconcile"
40+
41+
"github.com/go-logr/logr"
42+
43+
operatorv1alpha1 "github.com/openshift/external-secrets-operator/api/v1alpha1"
44+
operatorclient "github.com/openshift/external-secrets-operator/pkg/controller/client"
45+
"github.com/openshift/external-secrets-operator/pkg/controller/common"
46+
)
47+
48+
const (
49+
ControllerName = "crd-annotator"
50+
51+
// requestEnqueueLabelKey is the label key name used for filtering reconcile
52+
// events to include only the resources created by the controller.
53+
requestEnqueueLabelKey = "external-secrets.io/component"
54+
55+
// requestEnqueueLabelValue is the label value used for filtering reconcile
56+
// events to include only the resources created by the controller.
57+
requestEnqueueLabelValue = "controller"
58+
59+
// reconcileObjectIdentifier is for identifying the object for which reconcile event
60+
// is received, based on which a specific action will be taken.
61+
reconcileObjectIdentifier = "external-secrets-obj"
62+
)
63+
64+
// Reconciler reconciles metadata on the managed CRDs.
65+
type Reconciler struct {
66+
operatorclient.CtrlClient
67+
ctx context.Context
68+
log logr.Logger
69+
}
70+
71+
func NewClient(m manager.Manager) (operatorclient.CtrlClient, error) {
72+
c, err := BuildCustomClient(m)
73+
if err != nil {
74+
return nil, fmt.Errorf("failed to build custom client: %w", err)
75+
}
76+
return &operatorclient.CtrlClientImpl{
77+
Client: c,
78+
}, nil
79+
}
80+
81+
// New is for building the reconciler instance consumed by the Reconcile method.
82+
func New(mgr ctrl.Manager) (*Reconciler, error) {
83+
r := &Reconciler{
84+
ctx: context.Background(),
85+
log: ctrl.Log.WithName(ControllerName),
86+
}
87+
c, err := NewClient(mgr)
88+
if err != nil {
89+
return nil, err
90+
}
91+
r.CtrlClient = c
92+
return r, nil
93+
}
94+
95+
// BuildCustomClient creates a custom client with a custom cache of required objects.
96+
// The corresponding informers receive events for objects matching label criteria.
97+
func BuildCustomClient(mgr ctrl.Manager) (client.Client, error) {
98+
managedResourceLabelReq, _ := labels.NewRequirement(requestEnqueueLabelKey, selection.Equals, []string{requestEnqueueLabelValue})
99+
managedResourceLabelReqSelector := labels.NewSelector().Add(*managedResourceLabelReq)
100+
101+
customCacheOpts := cache.Options{
102+
HTTPClient: mgr.GetHTTPClient(),
103+
Scheme: mgr.GetScheme(),
104+
Mapper: mgr.GetRESTMapper(),
105+
ByObject: map[client.Object]cache.ByObject{
106+
&crdv1.CustomResourceDefinition{}: {
107+
Label: managedResourceLabelReqSelector,
108+
},
109+
&operatorv1alpha1.ExternalSecrets{}: {},
110+
},
111+
ReaderFailOnMissingInformer: true,
112+
}
113+
customCache, err := cache.New(mgr.GetConfig(), customCacheOpts)
114+
if err != nil {
115+
return nil, fmt.Errorf("failed to build custom cache: %w", err)
116+
}
117+
if _, err = customCache.GetInformer(context.Background(), &crdv1.CustomResourceDefinition{}); err != nil {
118+
return nil, err
119+
}
120+
if _, err = customCache.GetInformer(context.Background(), &operatorv1alpha1.ExternalSecrets{}); err != nil {
121+
return nil, err
122+
}
123+
124+
err = mgr.Add(customCache)
125+
if err != nil {
126+
return nil, err
127+
}
128+
129+
customClient, err := client.New(mgr.GetConfig(), client.Options{
130+
HTTPClient: mgr.GetHTTPClient(),
131+
Scheme: mgr.GetScheme(),
132+
Mapper: mgr.GetRESTMapper(),
133+
Cache: &client.CacheOptions{
134+
Reader: customCache,
135+
},
136+
})
137+
if err != nil {
138+
return nil, err
139+
}
140+
141+
return customClient, nil
142+
}
143+
144+
// SetupWithManager is for creating a controller instance with predicates and event filters.
145+
func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
146+
mapFunc := func(ctx context.Context, obj client.Object) []reconcile.Request {
147+
r.log.V(4).Info("received reconcile event", "object", fmt.Sprintf("%T", obj), "name", obj.GetName(), "namespace", obj.GetNamespace())
148+
149+
var objName string
150+
objLabels := obj.GetLabels()
151+
if objLabels != nil {
152+
if objLabels[requestEnqueueLabelKey] == requestEnqueueLabelValue {
153+
objName = obj.GetName()
154+
}
155+
}
156+
if obj.GetObjectKind().GroupVersionKind().GroupKind().String() ==
157+
(&operatorv1alpha1.ExternalSecrets{}).GetObjectKind().GroupVersionKind().GroupKind().String() {
158+
objName = reconcileObjectIdentifier
159+
}
160+
if objName != "" {
161+
return []reconcile.Request{
162+
{
163+
NamespacedName: types.NamespacedName{
164+
Name: objName,
165+
},
166+
},
167+
}
168+
}
169+
170+
r.log.V(4).Info("object not of interest, ignoring reconcile event", "object", fmt.Sprintf("%T", obj), "name", obj.GetName(), "namespace", obj.GetNamespace())
171+
return []reconcile.Request{}
172+
}
173+
174+
// predicate function to ignore events for objects not managed by controller.
175+
managedResources := predicate.NewPredicateFuncs(func(object client.Object) bool {
176+
return object.GetLabels() != nil && object.GetLabels()[requestEnqueueLabelKey] == requestEnqueueLabelValue
177+
})
178+
managedResourcePredicate := builder.WithPredicates(managedResources, predicate.AnnotationChangedPredicate{})
179+
180+
return ctrl.NewControllerManagedBy(mgr).
181+
Named(ControllerName).
182+
WatchesMetadata(&crdv1.CustomResourceDefinition{}, handler.EnqueueRequestsFromMapFunc(mapFunc), managedResourcePredicate).
183+
Watches(&operatorv1alpha1.ExternalSecrets{}, handler.EnqueueRequestsFromMapFunc(mapFunc), builder.WithPredicates(predicate.GenerationChangedPredicate{})).
184+
Complete(r)
185+
}
186+
187+
// Reconcile is the reconciliation loop to manage the current state of managed CRDS
188+
// to match the desired state.
189+
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
190+
r.log.V(1).Info("reconciling", "request", req)
191+
192+
// Fetch the externalsecrets.openshift.operator.io CR
193+
es := &operatorv1alpha1.ExternalSecrets{}
194+
key := types.NamespacedName{
195+
Name: common.ExternalSecretsObjectName,
196+
}
197+
if err := r.Get(ctx, key, es); err != nil {
198+
if errors.IsNotFound(err) {
199+
// NotFound errors, would mean the object hasn't been created yet and
200+
// not required to reconcile yet.
201+
r.log.V(1).Info("externalsecrets.openshift.operator.io object not found, skipping reconciliation", "key", key)
202+
return ctrl.Result{}, nil
203+
}
204+
return ctrl.Result{}, fmt.Errorf("failed to fetch externalsecrets.openshift.operator.io %q during reconciliation: %w", key, err)
205+
}
206+
207+
if common.IsInjectCertManagerAnnotationEnabled(es) {
208+
return r.processReconcileRequest(es, req.NamespacedName)
209+
}
210+
211+
return ctrl.Result{}, nil
212+
}
213+
214+
// processReconcileRequest is the reconciliation handler to manage the resources.
215+
func (r *Reconciler) processReconcileRequest(es *operatorv1alpha1.ExternalSecrets, req types.NamespacedName) (ctrl.Result, error) {
216+
var oErr error = nil
217+
if req.Name == reconcileObjectIdentifier {
218+
if err := r.updateAnnotationsInAllCRDs(); err != nil {
219+
oErr = fmt.Errorf("failed while updating annotations in all CRDs: %w", err)
220+
}
221+
} else {
222+
crd := &crdv1.CustomResourceDefinition{}
223+
if err := r.Get(r.ctx, req, crd); err != nil {
224+
// NotFound error will be ignored since CRDs are managed by OLM, and OLM will
225+
// reconcile it.
226+
if errors.IsNotFound(err) {
227+
r.log.V(1).Info("crd managed by OLM is not found, skipping reconciliation", "crd", req)
228+
return ctrl.Result{}, nil
229+
}
230+
oErr = fmt.Errorf("failed to fetch customresourcedefinitions.apiextensions.k8s.io %q during reconciliation: %w", req, err)
231+
}
232+
if err := r.updateAnnotations(crd); err != nil {
233+
oErr = fmt.Errorf("failed to update annotations in %q: %w", req, err)
234+
}
235+
}
236+
237+
if err := r.updateCondition(es, oErr); err != nil {
238+
return ctrl.Result{}, utilerrors.NewAggregate([]error{err, oErr})
239+
}
240+
241+
return ctrl.Result{}, oErr
242+
}
243+
244+
// updateAnnotations is for updating the annotations on the managed CRDs.
245+
func (r *Reconciler) updateAnnotations(crd *crdv1.CustomResourceDefinition) error {
246+
annotations := crd.GetAnnotations()
247+
if val, ok := annotations[common.CertManagerInjectCAFromAnnotation]; !ok || val != common.CertManagerInjectCAFromAnnotationValue {
248+
patch := client.RawPatch(types.MergePatchType,
249+
[]byte(fmt.Sprintf("{\"metadata\":{\"annotations\":{\"%s\":\"%s\"}}}",
250+
common.CertManagerInjectCAFromAnnotation, common.CertManagerInjectCAFromAnnotationValue)),
251+
)
252+
if err := r.Patch(r.ctx, crd, patch); err != nil {
253+
return err
254+
}
255+
}
256+
return nil
257+
}
258+
259+
func (r *Reconciler) updateAnnotationsInAllCRDs() error {
260+
managedCRDList := &crdv1.CustomResourceDefinitionList{}
261+
crdLabelFilter := map[string]string{
262+
requestEnqueueLabelKey: requestEnqueueLabelValue,
263+
}
264+
if err := r.List(r.ctx, managedCRDList, client.MatchingLabels(crdLabelFilter)); err != nil {
265+
return fmt.Errorf("failed to list managed CRD resources: %w", err)
266+
}
267+
if len(managedCRDList.Items) <= 0 {
268+
r.log.Info("list query to fetch managed CRD resources returned empty")
269+
return nil
270+
}
271+
272+
for _, crd := range managedCRDList.Items {
273+
if err := r.updateAnnotations(&crd); err != nil {
274+
return fmt.Errorf("failed to update annotations in %q: %w", crd.GetName(), err)
275+
}
276+
}
277+
278+
return nil
279+
}
280+
281+
func (r *Reconciler) updateCondition(es *operatorv1alpha1.ExternalSecrets, err error) error {
282+
cond := metav1.Condition{
283+
Type: operatorv1alpha1.UpdateAnnotation,
284+
ObservedGeneration: es.GetGeneration(),
285+
}
286+
287+
if err != nil {
288+
cond.Status = metav1.ConditionFalse
289+
cond.Reason = operatorv1alpha1.ReasonFailed
290+
cond.Message = fmt.Sprintf("failed to add annotations: %v", err.Error())
291+
} else {
292+
cond.Status = metav1.ConditionTrue
293+
cond.Reason = operatorv1alpha1.ReasonCompleted
294+
cond.Message = "successfully updated annotations"
295+
}
296+
297+
if apimeta.SetStatusCondition(&es.Status.Conditions, cond) {
298+
return r.updateStatus(r.ctx, es)
299+
}
300+
301+
return nil
302+
}
303+
304+
// updateStatus is for updating the status subresource of externalsecrets.openshift.operator.io.
305+
func (r *Reconciler) updateStatus(ctx context.Context, changed *operatorv1alpha1.ExternalSecrets) error {
306+
namespacedName := types.NamespacedName{Name: changed.Name, Namespace: changed.Namespace}
307+
if err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
308+
r.log.V(4).Info("updating externalsecrets.openshift.operator.io status", "request", namespacedName)
309+
current := &operatorv1alpha1.ExternalSecrets{}
310+
if err := r.Get(ctx, namespacedName, current); err != nil {
311+
return fmt.Errorf("failed to fetch externalsecrets.openshift.operator.io %q for status update: %w", namespacedName, err)
312+
}
313+
changed.Status.DeepCopyInto(&current.Status)
314+
315+
if err := r.StatusUpdate(ctx, current); err != nil {
316+
return fmt.Errorf("failed to update externalsecrets.openshift.operator.io %q status: %w", namespacedName, err)
317+
}
318+
319+
return nil
320+
}); err != nil {
321+
return err
322+
}
323+
324+
return nil
325+
}

0 commit comments

Comments
 (0)