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

Commit

Permalink
Add ConfigMapSyncer controller
Browse files Browse the repository at this point in the history
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>
  • Loading branch information
joelanford committed Sep 15, 2022
1 parent c9aec01 commit af4a480
Show file tree
Hide file tree
Showing 5 changed files with 292 additions and 5 deletions.
20 changes: 20 additions & 0 deletions cmd/core/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,13 @@ import (
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/cache"
"sigs.k8s.io/controller-runtime/pkg/client"
crfinalizer "sigs.k8s.io/controller-runtime/pkg/finalizer"
"sigs.k8s.io/controller-runtime/pkg/healthz"
"sigs.k8s.io/controller-runtime/pkg/log/zap"

rukpakv1alpha1 "github.com/operator-framework/rukpak/api/v1alpha1"
"github.com/operator-framework/rukpak/internal/configmapsyncer"
"github.com/operator-framework/rukpak/internal/finalizer"
plaincontrollers "github.com/operator-framework/rukpak/internal/provisioner/plain/controllers"
registrycontrollers "github.com/operator-framework/rukpak/internal/provisioner/registry/controllers"
Expand Down Expand Up @@ -121,6 +123,12 @@ func main() {
HealthProbeBindAddress: probeAddr,
LeaderElection: enableLeaderElection,
LeaderElectionID: "core.rukpak.io",
// TODO: use cache.MultiNamespacedCacheWithOptionsBuilder for core controller cache
// When https://github.com/kubernetes-sigs/controller-runtime/pull/1962
// merges, use cache.MultiNamespacedCacheWithOptionsBuilder so that
// "rukpak-system" can use cache.New and watch all objects in its own
// namespace. Once we switch to that cache, we can avoid manually creating
// informers for the configmapsyncer.
NewCache: cache.BuilderWithOptions(cache.Options{
SelectorsByObject: cache.SelectorsByObject{
&rukpakv1alpha1.BundleDeployment{}: {},
Expand Down Expand Up @@ -238,6 +246,18 @@ func main() {
setupLog.Error(err, "unable to create controller", "controller", rukpakv1alpha1.BundleDeploymentKind)
os.Exit(1)
}

directClient, err := client.New(cfg, client.Options{
Scheme: mgr.GetScheme(),
Mapper: mgr.GetRESTMapper(),
})
if err = (&configmapsyncer.Reconciler{
Client: directClient,
Namespace: systemNamespace,
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "ConfigMap")
os.Exit(1)
}
//+kubebuilder:scaffold:builder

if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
Expand Down
221 changes: 221 additions & 0 deletions internal/configmapsyncer/configmapsyncer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
package configmapsyncer

import (
"context"
"time"

corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/equality"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/cache"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
"sigs.k8s.io/controller-runtime/pkg/controller"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/manager"
"sigs.k8s.io/controller-runtime/pkg/predicate"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
"sigs.k8s.io/controller-runtime/pkg/source"
)

const (
configMapInjectFromSecretName = "core.rukpak.io/inject-from-secret-name"
configMapInjectFromSecretKey = "core.rukpak.io/inject-from-secret-key"
configMapInjectToDataKey = "core.rukpak.io/inject-to-data-key"
configMapInjectToBinaryDataKey = "core.rukpak.io/inject-to-binarydata-key"
)

// Reconciler syncs secret fields to configmaps.
//
// Namespace defines the namespace in which the reconciler is active.
// If Namespace is unset, all watched configmaps are candidates for
// injection.
type Reconciler struct {
client.Client
Namespace string
}

// Reconcile syncs a secret field to a configmap field based on the presence
// of annotations "core.rukpak.io/inject-from-secret-name" and
// "core.rukpak.io/inject-from-secret-key" (configmaps without BOTH of these
// annotations are ignored). When these annotations are present, this
// reconciler manages all content in the data and binaryData fields.
//
// If the annotation "core.rukpak.io/inject-to-data-key" is present, Reconcile
// creates a data key containing the secret value. Otherwise, the configmap
// data will be empty.
//
// If the annotation "core.rukpak.io/inject-to-binarydata-key" is present,
// Reconcile creates a binaryData key containing the secret value. Otherwise,
// the configmap binaryData will be empty.
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
l := log.FromContext(ctx)
l.V(1).Info("starting reconciliation")
defer l.V(1).Info("ending reconciliation")

// Get configmap from cache and lookup its secret name annotation
cm := &corev1.ConfigMap{}
if err := r.Get(ctx, req.NamespacedName, cm); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}

secretName, ok := cm.Annotations[configMapInjectFromSecretName]
if !ok {
return ctrl.Result{}, nil
}

// Get referenced secret name (in the same namespace as the configmap)
secret := &corev1.Secret{}
l.V(1).Info("inject from secret", "secretName", secretName)
if err := r.Get(ctx, types.NamespacedName{Namespace: req.Namespace, Name: secretName}, secret); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}

// Get the referenced secret's key and value
secretKey, ok := cm.Annotations[configMapInjectFromSecretKey]
if !ok {
return ctrl.Result{}, nil
}
secretValue := secret.Data[secretKey]

// If the configmap asks for injection into a binaryData key
// generate the expected binary data.
cmBinaryDataKey := cm.Annotations[configMapInjectToBinaryDataKey]
expectedBinaryData := map[string][]byte(nil)
if cmBinaryDataKey != "" {
expectedBinaryData = map[string][]byte{cmBinaryDataKey: secretValue}
}

// If the configmap asks for injection into a data key
// generate the expected data.
cmDataKey := cm.Annotations[configMapInjectToDataKey]
expectedData := map[string]string(nil)
if cmDataKey != "" {
expectedData = map[string]string{cmDataKey: string(secretValue)}
}

// If binaryData and data already have the expected contents,
// there's no need to do anything, so return early.
if equality.Semantic.DeepEqual(cm.BinaryData, expectedBinaryData) && equality.Semantic.DeepEqual(cm.Data, expectedData) {
return ctrl.Result{}, nil
}

// Set the expected binaryData and data fields on the configmap
// and then update it.
cm.BinaryData = expectedBinaryData
cm.Data = expectedData
return ctrl.Result{}, r.Update(ctx, cm)
}

// SetupWithManager sets up the controller with the Manager.
func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
predicates := []predicate.Predicate{predicate.NewPredicateFuncs(cmHasInjectionAnnotations())}
if r.Namespace != "" {
predicates = append(predicates, predicate.NewPredicateFuncs(cmNamespaceFilter(r.Namespace)))
}

ctrlr, err := controller.New("configmapsyncer", mgr, controller.Options{
Reconciler: r,
})
if err != nil {
return err
}

configMapInformer, err := newInformer(mgr, &corev1.ConfigMap{}, r.Namespace)
if err != nil {
return err
}
if err := mgr.Add(informerRunnable{configMapInformer}); err != nil {
return err
}

secretInformer, err := newInformer(mgr, &corev1.Secret{}, r.Namespace)
if err != nil {
return err
}
if err := mgr.Add(informerRunnable{secretInformer}); err != nil {
return err
}

if err := ctrlr.Watch(&source.Informer{Informer: configMapInformer}, &handler.EnqueueRequestForObject{}, predicates...); err != nil {
return err
}

if err := ctrlr.Watch(&source.Informer{Informer: secretInformer}, handler.EnqueueRequestsFromMapFunc(func(object client.Object) []reconcile.Request {
cmList := &corev1.ConfigMapList{}
if err := mgr.GetClient().List(context.TODO(), cmList, client.InNamespace(object.GetNamespace())); err != nil {
return nil
}
reqs := []reconcile.Request{}
for _, cm := range cmList.Items {
if cm.Annotations[configMapInjectFromSecretName] == object.GetName() {
reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&cm)})
}
}
return reqs
})); err != nil {
return err
}

return nil
}

var _ manager.LeaderElectionRunnable = &informerRunnable{}

type informerRunnable struct {
informer cache.SharedIndexInformer
}

func (i informerRunnable) NeedLeaderElection() bool {
return true
}

func (i informerRunnable) Start(ctx context.Context) error {
i.informer.Run(ctx.Done())
return nil
}

func cmHasInjectionAnnotations() func(object client.Object) bool {
return func(object client.Object) bool {
cm := object.(*corev1.ConfigMap)
if _, ok := cm.Annotations[configMapInjectFromSecretName]; !ok {
return false
}
if _, ok := cm.Annotations[configMapInjectFromSecretKey]; !ok {
return false
}
return true
}
}

func cmNamespaceFilter(namespace string) func(object client.Object) bool {
return func(object client.Object) bool {
return object.GetNamespace() == namespace
}
}

func newInformer(mgr manager.Manager, obj client.Object, namespace string) (cache.SharedIndexInformer, error) {
gvk, err := apiutil.GVKForObject(obj, mgr.GetScheme())
if err != nil {
return nil, err
}

restClient, err := apiutil.RESTClientForGVK(gvk, false, mgr.GetConfig(), serializer.NewCodecFactory(mgr.GetScheme()))
if err != nil {
return nil, err
}

rm, err := mgr.GetRESTMapper().RESTMapping(gvk.GroupKind(), gvk.Version)
if err != nil {
return nil, err
}

lw := cache.NewListWatchFromClient(restClient, rm.Resource.Resource, namespace, fields.Everything())
return cache.NewSharedIndexInformer(lw, &corev1.ConfigMap{}, time.Hour*10, cache.Indexers{
cache.NamespaceIndex: cache.MetaNamespaceIndexFunc,
}), nil
}
8 changes: 4 additions & 4 deletions internal/rukpakctl/ca.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ import (

// GetClusterCA returns an x509.CertPool by reading the contents of a Kubernetes Secret. It uses the provided
// client to get the requested secret and then loads the contents of the secret's "ca.crt" key into the cert pool.
func GetClusterCA(ctx context.Context, cl client.Reader, secretKey types.NamespacedName) (*x509.CertPool, error) {
caSecret := &corev1.Secret{}
if err := cl.Get(ctx, secretKey, caSecret); err != nil {
func GetClusterCA(ctx context.Context, cl client.Reader, configmapKey types.NamespacedName) (*x509.CertPool, error) {
caConfigMap := &corev1.ConfigMap{}
if err := cl.Get(ctx, configmapKey, caConfigMap); err != nil {
return nil, fmt.Errorf("get rukpak certificate authority: %v", err)
}
certPool := x509.NewCertPool()
if !certPool.AppendCertsFromPEM(caSecret.Data["ca.crt"]) {
if !certPool.AppendCertsFromPEM([]byte(caConfigMap.Data["ca-bundle.crt"])) {
return nil, errors.New("failed to load certificate authority into cert pool: malformed PEM?")
}
return certPool, nil
Expand Down
12 changes: 11 additions & 1 deletion manifests/core/resources/rukpak_issuer.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,14 @@ metadata:
namespace: rukpak-system
spec:
ca:
secretName: rukpak-ca
secretName: rukpak-ca
---
apiVersion: v1
kind: ConfigMap
metadata:
annotations:
core.rukpak.io/inject-from-secret-name: rukpak-ca
core.rukpak.io/inject-from-secret-key: tls.crt
core.rukpak.io/inject-to-data-key: ca-bundle.crt
name: rukpak-ca
namespace: rukpak-system
36 changes: 36 additions & 0 deletions test/e2e/configmap_syncer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package e2e

import (
"context"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
)

var _ = Describe("ConfigMapSyncer", func() {
ctx := context.Background()
It("should populate rukpak-ca configmap", func() {
By("fetching rukpak-ca secret")
secret := &corev1.Secret{}
Eventually(func() (map[string][]byte, error) {
err := c.Get(ctx, types.NamespacedName{Namespace: defaultSystemNamespace, Name: "rukpak-ca"}, secret)
return secret.Data, err
}).Should(And(
HaveKey("ca.crt"),
HaveKey("tls.crt"),
HaveKey("tls.key"),
))

By("fetching rukpak-ca configmap")
cm := &corev1.ConfigMap{}
Eventually(func() (map[string]string, error) {
err := c.Get(ctx, types.NamespacedName{Namespace: defaultSystemNamespace, Name: "rukpak-ca"}, cm)
return cm.Data, err
}).Should(HaveKey("ca-bundle.crt"))

By("comparing expected injected value")
Expect(string(secret.Data["tls.crt"])).To(Equal(cm.Data["ca-bundle.crt"]))
})
})

0 comments on commit af4a480

Please sign in to comment.