Skip to content
This repository was archived by the owner on Aug 12, 2024. It is now read-only.

Commit af4a480

Browse files
committed
Add ConfigMapSyncer controller
The ConfigMapSyncer syncs secret data to configmaps based on injection annotations present in configmaps in watched namespaces. We include a rukpak-ca configmap with these annotations present so that cluster administrators can share rukpak-ca trust without exposing the CA key that's present in the rukpak-ca secret. Signed-off-by: Joe Lanford <joe.lanford@gmail.com>
1 parent c9aec01 commit af4a480

File tree

5 files changed

+292
-5
lines changed

5 files changed

+292
-5
lines changed

cmd/core/main.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,13 @@ import (
3636
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
3737
ctrl "sigs.k8s.io/controller-runtime"
3838
"sigs.k8s.io/controller-runtime/pkg/cache"
39+
"sigs.k8s.io/controller-runtime/pkg/client"
3940
crfinalizer "sigs.k8s.io/controller-runtime/pkg/finalizer"
4041
"sigs.k8s.io/controller-runtime/pkg/healthz"
4142
"sigs.k8s.io/controller-runtime/pkg/log/zap"
4243

4344
rukpakv1alpha1 "github.com/operator-framework/rukpak/api/v1alpha1"
45+
"github.com/operator-framework/rukpak/internal/configmapsyncer"
4446
"github.com/operator-framework/rukpak/internal/finalizer"
4547
plaincontrollers "github.com/operator-framework/rukpak/internal/provisioner/plain/controllers"
4648
registrycontrollers "github.com/operator-framework/rukpak/internal/provisioner/registry/controllers"
@@ -121,6 +123,12 @@ func main() {
121123
HealthProbeBindAddress: probeAddr,
122124
LeaderElection: enableLeaderElection,
123125
LeaderElectionID: "core.rukpak.io",
126+
// TODO: use cache.MultiNamespacedCacheWithOptionsBuilder for core controller cache
127+
// When https://github.com/kubernetes-sigs/controller-runtime/pull/1962
128+
// merges, use cache.MultiNamespacedCacheWithOptionsBuilder so that
129+
// "rukpak-system" can use cache.New and watch all objects in its own
130+
// namespace. Once we switch to that cache, we can avoid manually creating
131+
// informers for the configmapsyncer.
124132
NewCache: cache.BuilderWithOptions(cache.Options{
125133
SelectorsByObject: cache.SelectorsByObject{
126134
&rukpakv1alpha1.BundleDeployment{}: {},
@@ -238,6 +246,18 @@ func main() {
238246
setupLog.Error(err, "unable to create controller", "controller", rukpakv1alpha1.BundleDeploymentKind)
239247
os.Exit(1)
240248
}
249+
250+
directClient, err := client.New(cfg, client.Options{
251+
Scheme: mgr.GetScheme(),
252+
Mapper: mgr.GetRESTMapper(),
253+
})
254+
if err = (&configmapsyncer.Reconciler{
255+
Client: directClient,
256+
Namespace: systemNamespace,
257+
}).SetupWithManager(mgr); err != nil {
258+
setupLog.Error(err, "unable to create controller", "controller", "ConfigMap")
259+
os.Exit(1)
260+
}
241261
//+kubebuilder:scaffold:builder
242262

243263
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
package configmapsyncer
2+
3+
import (
4+
"context"
5+
"time"
6+
7+
corev1 "k8s.io/api/core/v1"
8+
"k8s.io/apimachinery/pkg/api/equality"
9+
"k8s.io/apimachinery/pkg/fields"
10+
"k8s.io/apimachinery/pkg/runtime/serializer"
11+
"k8s.io/apimachinery/pkg/types"
12+
"k8s.io/client-go/tools/cache"
13+
ctrl "sigs.k8s.io/controller-runtime"
14+
"sigs.k8s.io/controller-runtime/pkg/client"
15+
"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
16+
"sigs.k8s.io/controller-runtime/pkg/controller"
17+
"sigs.k8s.io/controller-runtime/pkg/handler"
18+
"sigs.k8s.io/controller-runtime/pkg/log"
19+
"sigs.k8s.io/controller-runtime/pkg/manager"
20+
"sigs.k8s.io/controller-runtime/pkg/predicate"
21+
"sigs.k8s.io/controller-runtime/pkg/reconcile"
22+
"sigs.k8s.io/controller-runtime/pkg/source"
23+
)
24+
25+
const (
26+
configMapInjectFromSecretName = "core.rukpak.io/inject-from-secret-name"
27+
configMapInjectFromSecretKey = "core.rukpak.io/inject-from-secret-key"
28+
configMapInjectToDataKey = "core.rukpak.io/inject-to-data-key"
29+
configMapInjectToBinaryDataKey = "core.rukpak.io/inject-to-binarydata-key"
30+
)
31+
32+
// Reconciler syncs secret fields to configmaps.
33+
//
34+
// Namespace defines the namespace in which the reconciler is active.
35+
// If Namespace is unset, all watched configmaps are candidates for
36+
// injection.
37+
type Reconciler struct {
38+
client.Client
39+
Namespace string
40+
}
41+
42+
// Reconcile syncs a secret field to a configmap field based on the presence
43+
// of annotations "core.rukpak.io/inject-from-secret-name" and
44+
// "core.rukpak.io/inject-from-secret-key" (configmaps without BOTH of these
45+
// annotations are ignored). When these annotations are present, this
46+
// reconciler manages all content in the data and binaryData fields.
47+
//
48+
// If the annotation "core.rukpak.io/inject-to-data-key" is present, Reconcile
49+
// creates a data key containing the secret value. Otherwise, the configmap
50+
// data will be empty.
51+
//
52+
// If the annotation "core.rukpak.io/inject-to-binarydata-key" is present,
53+
// Reconcile creates a binaryData key containing the secret value. Otherwise,
54+
// the configmap binaryData will be empty.
55+
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
56+
l := log.FromContext(ctx)
57+
l.V(1).Info("starting reconciliation")
58+
defer l.V(1).Info("ending reconciliation")
59+
60+
// Get configmap from cache and lookup its secret name annotation
61+
cm := &corev1.ConfigMap{}
62+
if err := r.Get(ctx, req.NamespacedName, cm); err != nil {
63+
return ctrl.Result{}, client.IgnoreNotFound(err)
64+
}
65+
66+
secretName, ok := cm.Annotations[configMapInjectFromSecretName]
67+
if !ok {
68+
return ctrl.Result{}, nil
69+
}
70+
71+
// Get referenced secret name (in the same namespace as the configmap)
72+
secret := &corev1.Secret{}
73+
l.V(1).Info("inject from secret", "secretName", secretName)
74+
if err := r.Get(ctx, types.NamespacedName{Namespace: req.Namespace, Name: secretName}, secret); err != nil {
75+
return ctrl.Result{}, client.IgnoreNotFound(err)
76+
}
77+
78+
// Get the referenced secret's key and value
79+
secretKey, ok := cm.Annotations[configMapInjectFromSecretKey]
80+
if !ok {
81+
return ctrl.Result{}, nil
82+
}
83+
secretValue := secret.Data[secretKey]
84+
85+
// If the configmap asks for injection into a binaryData key
86+
// generate the expected binary data.
87+
cmBinaryDataKey := cm.Annotations[configMapInjectToBinaryDataKey]
88+
expectedBinaryData := map[string][]byte(nil)
89+
if cmBinaryDataKey != "" {
90+
expectedBinaryData = map[string][]byte{cmBinaryDataKey: secretValue}
91+
}
92+
93+
// If the configmap asks for injection into a data key
94+
// generate the expected data.
95+
cmDataKey := cm.Annotations[configMapInjectToDataKey]
96+
expectedData := map[string]string(nil)
97+
if cmDataKey != "" {
98+
expectedData = map[string]string{cmDataKey: string(secretValue)}
99+
}
100+
101+
// If binaryData and data already have the expected contents,
102+
// there's no need to do anything, so return early.
103+
if equality.Semantic.DeepEqual(cm.BinaryData, expectedBinaryData) && equality.Semantic.DeepEqual(cm.Data, expectedData) {
104+
return ctrl.Result{}, nil
105+
}
106+
107+
// Set the expected binaryData and data fields on the configmap
108+
// and then update it.
109+
cm.BinaryData = expectedBinaryData
110+
cm.Data = expectedData
111+
return ctrl.Result{}, r.Update(ctx, cm)
112+
}
113+
114+
// SetupWithManager sets up the controller with the Manager.
115+
func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
116+
predicates := []predicate.Predicate{predicate.NewPredicateFuncs(cmHasInjectionAnnotations())}
117+
if r.Namespace != "" {
118+
predicates = append(predicates, predicate.NewPredicateFuncs(cmNamespaceFilter(r.Namespace)))
119+
}
120+
121+
ctrlr, err := controller.New("configmapsyncer", mgr, controller.Options{
122+
Reconciler: r,
123+
})
124+
if err != nil {
125+
return err
126+
}
127+
128+
configMapInformer, err := newInformer(mgr, &corev1.ConfigMap{}, r.Namespace)
129+
if err != nil {
130+
return err
131+
}
132+
if err := mgr.Add(informerRunnable{configMapInformer}); err != nil {
133+
return err
134+
}
135+
136+
secretInformer, err := newInformer(mgr, &corev1.Secret{}, r.Namespace)
137+
if err != nil {
138+
return err
139+
}
140+
if err := mgr.Add(informerRunnable{secretInformer}); err != nil {
141+
return err
142+
}
143+
144+
if err := ctrlr.Watch(&source.Informer{Informer: configMapInformer}, &handler.EnqueueRequestForObject{}, predicates...); err != nil {
145+
return err
146+
}
147+
148+
if err := ctrlr.Watch(&source.Informer{Informer: secretInformer}, handler.EnqueueRequestsFromMapFunc(func(object client.Object) []reconcile.Request {
149+
cmList := &corev1.ConfigMapList{}
150+
if err := mgr.GetClient().List(context.TODO(), cmList, client.InNamespace(object.GetNamespace())); err != nil {
151+
return nil
152+
}
153+
reqs := []reconcile.Request{}
154+
for _, cm := range cmList.Items {
155+
if cm.Annotations[configMapInjectFromSecretName] == object.GetName() {
156+
reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&cm)})
157+
}
158+
}
159+
return reqs
160+
})); err != nil {
161+
return err
162+
}
163+
164+
return nil
165+
}
166+
167+
var _ manager.LeaderElectionRunnable = &informerRunnable{}
168+
169+
type informerRunnable struct {
170+
informer cache.SharedIndexInformer
171+
}
172+
173+
func (i informerRunnable) NeedLeaderElection() bool {
174+
return true
175+
}
176+
177+
func (i informerRunnable) Start(ctx context.Context) error {
178+
i.informer.Run(ctx.Done())
179+
return nil
180+
}
181+
182+
func cmHasInjectionAnnotations() func(object client.Object) bool {
183+
return func(object client.Object) bool {
184+
cm := object.(*corev1.ConfigMap)
185+
if _, ok := cm.Annotations[configMapInjectFromSecretName]; !ok {
186+
return false
187+
}
188+
if _, ok := cm.Annotations[configMapInjectFromSecretKey]; !ok {
189+
return false
190+
}
191+
return true
192+
}
193+
}
194+
195+
func cmNamespaceFilter(namespace string) func(object client.Object) bool {
196+
return func(object client.Object) bool {
197+
return object.GetNamespace() == namespace
198+
}
199+
}
200+
201+
func newInformer(mgr manager.Manager, obj client.Object, namespace string) (cache.SharedIndexInformer, error) {
202+
gvk, err := apiutil.GVKForObject(obj, mgr.GetScheme())
203+
if err != nil {
204+
return nil, err
205+
}
206+
207+
restClient, err := apiutil.RESTClientForGVK(gvk, false, mgr.GetConfig(), serializer.NewCodecFactory(mgr.GetScheme()))
208+
if err != nil {
209+
return nil, err
210+
}
211+
212+
rm, err := mgr.GetRESTMapper().RESTMapping(gvk.GroupKind(), gvk.Version)
213+
if err != nil {
214+
return nil, err
215+
}
216+
217+
lw := cache.NewListWatchFromClient(restClient, rm.Resource.Resource, namespace, fields.Everything())
218+
return cache.NewSharedIndexInformer(lw, &corev1.ConfigMap{}, time.Hour*10, cache.Indexers{
219+
cache.NamespaceIndex: cache.MetaNamespaceIndexFunc,
220+
}), nil
221+
}

internal/rukpakctl/ca.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@ import (
1313

1414
// GetClusterCA returns an x509.CertPool by reading the contents of a Kubernetes Secret. It uses the provided
1515
// client to get the requested secret and then loads the contents of the secret's "ca.crt" key into the cert pool.
16-
func GetClusterCA(ctx context.Context, cl client.Reader, secretKey types.NamespacedName) (*x509.CertPool, error) {
17-
caSecret := &corev1.Secret{}
18-
if err := cl.Get(ctx, secretKey, caSecret); err != nil {
16+
func GetClusterCA(ctx context.Context, cl client.Reader, configmapKey types.NamespacedName) (*x509.CertPool, error) {
17+
caConfigMap := &corev1.ConfigMap{}
18+
if err := cl.Get(ctx, configmapKey, caConfigMap); err != nil {
1919
return nil, fmt.Errorf("get rukpak certificate authority: %v", err)
2020
}
2121
certPool := x509.NewCertPool()
22-
if !certPool.AppendCertsFromPEM(caSecret.Data["ca.crt"]) {
22+
if !certPool.AppendCertsFromPEM([]byte(caConfigMap.Data["ca-bundle.crt"])) {
2323
return nil, errors.New("failed to load certificate authority into cert pool: malformed PEM?")
2424
}
2525
return certPool, nil

manifests/core/resources/rukpak_issuer.yaml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,14 @@ metadata:
3434
namespace: rukpak-system
3535
spec:
3636
ca:
37-
secretName: rukpak-ca
37+
secretName: rukpak-ca
38+
---
39+
apiVersion: v1
40+
kind: ConfigMap
41+
metadata:
42+
annotations:
43+
core.rukpak.io/inject-from-secret-name: rukpak-ca
44+
core.rukpak.io/inject-from-secret-key: tls.crt
45+
core.rukpak.io/inject-to-data-key: ca-bundle.crt
46+
name: rukpak-ca
47+
namespace: rukpak-system

test/e2e/configmap_syncer_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package e2e
2+
3+
import (
4+
"context"
5+
6+
. "github.com/onsi/ginkgo/v2"
7+
. "github.com/onsi/gomega"
8+
corev1 "k8s.io/api/core/v1"
9+
"k8s.io/apimachinery/pkg/types"
10+
)
11+
12+
var _ = Describe("ConfigMapSyncer", func() {
13+
ctx := context.Background()
14+
It("should populate rukpak-ca configmap", func() {
15+
By("fetching rukpak-ca secret")
16+
secret := &corev1.Secret{}
17+
Eventually(func() (map[string][]byte, error) {
18+
err := c.Get(ctx, types.NamespacedName{Namespace: defaultSystemNamespace, Name: "rukpak-ca"}, secret)
19+
return secret.Data, err
20+
}).Should(And(
21+
HaveKey("ca.crt"),
22+
HaveKey("tls.crt"),
23+
HaveKey("tls.key"),
24+
))
25+
26+
By("fetching rukpak-ca configmap")
27+
cm := &corev1.ConfigMap{}
28+
Eventually(func() (map[string]string, error) {
29+
err := c.Get(ctx, types.NamespacedName{Namespace: defaultSystemNamespace, Name: "rukpak-ca"}, cm)
30+
return cm.Data, err
31+
}).Should(HaveKey("ca-bundle.crt"))
32+
33+
By("comparing expected injected value")
34+
Expect(string(secret.Data["tls.crt"])).To(Equal(cm.Data["ca-bundle.crt"]))
35+
})
36+
})

0 commit comments

Comments
 (0)