En ce moment, j’apprends énormement sur Kubernetes et en Golang, progresser dans ces domaines est un de mes objectifs pour cette année. J’ai donc décidé de reprendre le format de mon article sur NATS pour vous présenter un autre sujet que j’ai eu l’occasion de dig récemment : les opérateurs Kubernetes.

Mais on ne va pas simplement énoncer des vérités générales sur ce qu’est un opérateur, on va plutôt en créer un ensemble et voir les différentes étapes du développement. Cela nous permettra d’itérer sur un projet concret et de voir les différentes options qui s’offrent à nous.

Je me dois quand même de faire un petit disclaimer : je ne suis ni expert Golang, ni expert Kubernetes. Je sais d’avance qu’il y aura des énormités dans ce que je vais écrire. Aussi, n’hésitez pas à me faire part de vos retours pour que je puisse m’améliorer mais surtout : n’appliquez pas à la lettre ce que je vais écrire ici, je ne garantis pas que ce soit la meilleure manière de faire.

Petit disclaimer terminé, je vais vous présenter le projet sur lequel on va travailler.

Mon projet

Mon idée n’a rien de révolutionnaire mais elle a le mérite de s’expliquer simplement et de permettre de voir différents aspects de la création d’un opérateur. Venons-en au fait : je souhaite créer un opérateur Kubernetes qui permet de générer un secret aléatoire en respectant une taille précise spécifiée. Une idée plutôt simple, possible avec certains programmes existants (notamment en couplant ça avec Vault), mais je vais essayer de le faire le plus simplement possible (KISS) et sans me reposer sur des logicels tiers.

On s’y attaque ?

Qu’est-ce qu’un opérateur Kubernetes ?

Avant de commencer quoi que ce soit, nous avons déjà besoin de savoir ce qu’est un opérateur.

Je vais m’appuyer sur la définition de RedHat:

Un opérateur Kubernetes est un contrôleur spécifique à une application qui permet d’étendre les fonctions de l’API Kubernetes afin de créer, configurer et gérer des instances d’applications complexes au nom d’un utilisateur Kubernetes.

Un opérateur est alors une application présente dans Kubernetes (ou ayant un accès à l’API-Server) pour réaliser des opérations sur des ressources (qui peuvent être native, ou custom). Un opérateur est très souvent stateless, mais ça n’est pas non plus une règle absolue.

On peut retrouver de nombreux opérateurs disponibles sur OperatorHub, qui regroupe énormément de programmes utilisables directement dans votre cluster Kubernetes. Dès que vous avez un besoin : “There is an operator for that”.

Objectifs

Histoire de ne pas trop s’éparpiller, voici les objectifs que je me suis fixé pour ce projet :

  • Pouvoir définir un secret à générer à partir d’une template :
    • Pouvoir choisir la taille du secret.
    • Activer ou non la présence de caractères spéciaux.
  • Déclencher la génération du secret :
    • Par une annotation sur un objet.
    • Par un CRD RandomSecret.
  • Assurer une haute disponibilité (en permettant d’avoir plusieurs pods de l’opérateur en même temps).

Jusque-là, on n’a rien de bien compliqué. Voyons ça comme un contrat de service pour nos utilisateurs.

Very MVP

Si on devait faire ça salement, on pourrait se contenter d’un script bash. Commençons par celui qui pourrait générer un secret “from-scratch” (comme si on passait par une Custom Ressource) :

#!/bin/bash
NAMESPACE=$1
SECRET_NAME=$2
SECRET_KEY=$3
RANDOM_SECRET=$(openssl rand -base64 32)
kubectl create secret generic $SECRET_NAME --from-literal=${SECRET_KEY}=${RANDOM_SECRET} -n $NAMESPACE

L’autre cas d’usage serait de partir sur un secret déjà existant et de le mettre à jour. On pourrait imaginer un script comme ceci :

#!/bin/bash

NAMESPACE=$1
SECRET_NAME=$2
SECRET_KEY=$3 # Will be created if not exists

RANDOM_SECRET=$(openssl rand -base64 32)

kubectl get secret $SECRET_NAME -n $NAMESPACE > /dev/null 2>&1
if [ $? -eq 0 ]; then
    kubectl get secret $SECRET_NAME -o json | jq '.data["'${SECRET_KEY}'"]="'${RANDOM_SECRET}'"' | kubectl apply -f -
else
    kubectl create secret generic $SECRET_NAME --from-literal=${SECRET_KEY}=${RANDOM_SECRET} -n $NAMESPACE
fi

On peut techniquement faire notre operateur en Bash (en rajoutant un watch pour surveiller les opérations, rajouter la vérification des labels…), mais en plus d’être peu fiable, on apprendrait pas grand chose.

Le choix des armes

Notre principal outil sera le SDK de Kubernetes, il nous permettra de gérer les ressources Kubernetes sans devoir construire nous-mêmes nos requêtes vers l’API-Server. Nous pouvons trouver ce SDK dans plusieurs langages (Go, Python, Java, etc.), mais pour être cohérent avec mes autres projets (et parce que c’est celui qu’est le plus proche de Kubernetes), je vais choisir le Go.

Pour le reste, on va essayer de faire au plus simple et éviter l’usage d’un framework comme KubeBuilder.

Langage, check ! Objectifs, check !

Let’s go, on ouvre notre IDE !

Initialisation du projet

Comme pour tout projet en Go, on va initialiser notre projet avec go mod init github.com/qjoly/randomsecret. Chose faite, marquons notre premier commit après un touch main.go.

On va commencer par écrire un petit script nous permettant de lister les différents secrets à gérer dans tous les namespaces.

Dans la fonction secrets.IsSecretManaged(), on vérifie si le secret est géré par notre opérateur à l’aide des annotations.

// main.go
package main

func main() {
	cfg, err := rest.InClusterConfig()
	if err != nil {
		var kubeconfigPath string

		if os.Getenv("KUBECONFIG") != "" {
			kubeconfigPath = os.Getenv("KUBECONFIG")
		} else {
			homeDir, err := os.UserHomeDir()
			if err != nil {
				log.Fatalf("Error getting user home directory: %v", err)
			}
			kubeconfigPath = filepath.Join(homeDir, ".kube", "config")
		}

		klog.Info("Using kubeconfig: ", kubeconfigPath)
		flag.Parse()
		cfg, err = clientcmd.BuildConfigFromFlags("", kubeconfigPath)
		if err != nil {
			klog.Fatalf("Error building kubeconfig: %v", err)
		}
	}

	clientset, err := kubernetes.NewForConfig(cfg)
	if err != nil {
		klog.Info(fmt.Sprintf("Error creating clientset: %v", err))
	}

	kubeSecrets, err := clientset.CoreV1().Secrets("").List(context.Background(), metav1.ListOptions{})
	if err != nil {
		klog.Info(fmt.Sprintf("Error listing secrets: %v", err))
	}

	klog.Info(fmt.Sprintf("Found %d secrets\n", len(kubeSecrets.Items)))
	for _, secret := range kubeSecrets.Items {
		if len(secret.Annotations) > 0 {

			if secrets.IsSecretManaged(secret) {
				klog.Info(fmt.Sprintf("Secret %s is managed\n", secret.Name))
				// Implémenter le traitement du secret
			}
		}
	}
}
package types

const (
	OperatorPrefixAnnotation    = "secret.a-cup-of.coffee/"
	OperatorEnabledAnnotation   = OperatorPrefixAnnotation + "enable"
	OperatorSecretKeyAnnotation = OperatorPrefixAnnotation + "key"
	OperatorDefaultSecretKey    = "randomSecret"
)
package secrets

// getRandomSecretKey returns the key used to store the random secret
// Check if the annotations map contains the key annotation
// If it does, return the key
func getRandomSecretKey(secret v1.Secret) string {
	key, ok := secret.Annotations[types.OperatorSecretKeyAnnotation]
	if !ok {
		klog.Info("Secret does not have a key annotation, using default key")
		return types.OperatorDefaultSecretKey
	}
	klog.Info("Secret has a key annotation, using key: ", key)
	return key
}

func IsSecretManaged(secret v1.Secret) bool {
	value, ok := secret.Annotations[types.OperatorEnabledAnnotation]
	if !ok {
		return false
	}

	if value != "true" {
		return false
	}

	return ok
}

func isAlreadyHandled(secret v1.Secret) bool {
	_, ok := secret.Data[getRandomSecretKey(secret)]
	if !ok {
		return false
	}
	return true
}

func HandleSecrets(secret v1.Secret) {
	klog.Info(fmt.Sprintf("Handling secret: %s with key %s", secret.Name, getRandomSecretKey(secret)))
	klog.Info(fmt.Sprintf("Already Configured: %v", isAlreadyHandled(secret)))
}

À partir de cette base, faisons la liste de ce que nous pouvons faire :

  • Lister les secrets dans un namespace donné (ou tous les namespaces) ;
  • Vérifier si un secret est géré par notre opérateur ;
  • Vérifier si un secret a déjà été traité ;
  • Récupérer la configuration d’un secret (via des annotations).

Il nous reste maintenant le plus gros du travail : patch le secret pour ajouter notre chaine de caractères aléatoire.

Patch un secret

Nous allons utiliser le client Kubernetes pour mettre à jour le secret, en faisant l’équivalent d’un kubectl patch en Go. Voici le code nous le permettant :

func generateRandomSecret(annotations map[string]string) string {
	klog.Info("Generating random secret")
	pattern := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

	// Check if the special char annotation is present and different from false
	// Then add special characters to the pattern otherwise use the default pattern
	if annotations[types.OperatorSpecialCharAnnotation] != "" && annotations[types.OperatorSpecialCharAnnotation] != "false" {
		pattern += "!@#$%^&*()_+"
	}
	
	length := 32
	// Check if the length annotation is present
	// If it is, parse the value and use it as the length of the secret
	if annotations[types.OperatorLengthAnnotation] != "" {
		fmt.Sscanf(annotations[types.OperatorLengthAnnotation], "%d", &length)
	}

	rand.Seed(uint64(time.Now().UnixNano()))

	secret := make([]byte, length)
	for i := range secret {
		secret[i] = pattern[rand.Intn(len(pattern))]
	}

	return string(secret)
}


func patchSecret(clientset *kubernetes.Clientset, secret v1.Secret, key string, value string) {
	if secret.Data == nil {
		secret.Data = make(map[string][]byte)
	}
	secret.Data[key] = []byte(value)
	_, err := clientset.CoreV1().Secrets(secret.Namespace).Update(context.Background(), &secret, metav1.UpdateOptions{})
	if err != nil {
		klog.Info(fmt.Sprintf("Error patching secret %s: %v", secret.Name, err))
	}
	klog.Info(fmt.Sprintf("Secret %s patched with key %s", secret.Name, key))
}

Ainsi, dès qu’un secret est créé ou modifié, nous allons patcher le secret pour y ajouter notre chaine de caractères aléatoire.

Je pense que c’est la méthode la plus simple à mettre en place. Toutefois, elle n’est peut-être pas la plus optimale, notamment parce qu’elle nous oblige à écouter les événements de création et de modification de secrets (en plus de devoir gérer les cas où un événement est reçu quand l’opérateur n’est pas prêt). De plus, ce n’est pas instantané (il y a un délai entre la création du secret et le patch) et nous devons gérer les permissions nécessaires pour que notre programme ait accès aux secrets des namespaces.

Première étape de notre opérateur : check ! Il patche désormais les secrets en fonction des annotations. Testons-le !

It works !

Mais là, nous sommes dans le cas d’un lancement ponctuel de notre opérateur (autant dire que c’est inutile). Il nous faut maintenant le déployer dans un cluster Kubernetes et le faire tourner en permanence. Comme expliqué, l’idée est d’écouter les évenements de création et de modification de secrets pour les patcher dès qu’ils sont créés.

Rester disponible

Nous allons introduire une boucle de réconciliation dans notre opérateur. Chaque déclenchement de la boucle va lister les secrets et les traiter. Chaque boucle déclenchera quant à elle le traitement de tous les secrets (c’est aussi une manière de gérer les secrets qui ont été créés avant le démarrage de l’opérateur).

Remarque

Pour rappel, une boucle de réconciliation est un évenement qui se déclenche pour vérifier si l’état des ressources est conforme à l’état désiré. C’est un pattern majoritairement utilisé dans Kubernetes.

Boucle de reconciliation

Créons alors une boucle de réconciliation, déclenchée aux événements suivants :

  • Au démarrage de l’opérateur (pour traiter les secrets déjà existants) ;
  • Dès qu’un secret est créé ou modifié et possèdant les annotations nécessaires.
package main
// ...
func main() {
	k := kube.NewClient()
	clientset := k.Clientset

	secretWatch := cache.NewListWatchFromClient(
		clientset.CoreV1().RESTClient(),
		"secrets",
		metav1.NamespaceAll,
		fields.Everything(),
	)

	secretHandler := cache.ResourceEventHandlerFuncs{
		AddFunc: func(obj interface{}) {
			secret, ok := obj.(*v1.Secret)
			if !ok {
				log.Println("Failed to cast to Secret")
				return
			}
			if secrets.IsSecretManaged(*secret) {
				fmt.Println("Added, Found secret", secret.Name)
			}
		},
		UpdateFunc: func(_, newObj interface{}) {
			// We only care about the new object
			secret, ok := newObj.(*v1.Secret)
			if !ok {
				log.Println("Failed to cast to Secret")
				return
			}

			if secrets.IsSecretManaged(*secret) {
				fmt.Println("Updated, Found secret", secret.Name)
			}
		},
	}
	secretInformer := cache.NewSharedInformer(secretWatch, &v1.Secret{}, 0)
	secretInformer.AddEventHandler(secretHandler)
	endSignal := make(chan struct{})
	go secretInformer.Run(endSignal)

	// Garde le processus en vie
	select {}
	// ... 
}

Décortiquons un peu ce code :

  • On crée un secretWatch qui va écouter les événements autour des secrets dans tous les namespaces ;
  • On crée le secretHandler qui va être déclenché à chaque événement venant de secretWatch mais en ajoutant un filtre pour ne traiter que les événements de création et de modification de secrets ;
  • secretInformer va faire la liaison entre secretWatch et secretHandler.

Avant de passer à la suite (c.a.d. lancer la réconciliation), on va quand même tester ça :

go run main.go
I0118 08:59:51.666753   14232 kube.go:57] Using kubeconfig: /Users/qjoly/.kube/config
Added, Found secret non-empty-secret
Added, Found secret test-empty-secret

Dès que je lance mon operateur, il détecte les secrets déjà existants dans la AddFunc. Ce n’est pas vraiment ce que l’on souhaite (cela veut dire que la boucle de réconciliation va se lancer autant de fois qu’il y a de secrets déjà existants).

On va devoir adapter ça pour n’accepter l’évenement de création que si la date de création du secret est postérieure à la date de démarrage de l’opérateur. Voici ce que ça donne :

package main
// ...
func main() {
	k := kube.NewClient() // On récupère notre client Kubernetes depuis notre package kube
	clientset := k.Clientset

+	startTime := time.Now()

	secretWatch := cache.NewListWatchFromClient(
		clientset.CoreV1().RESTClient(),
		"secrets",
		metav1.NamespaceAll,
		fields.Everything(),
	)

	secretHandler := cache.ResourceEventHandlerFuncs{
		AddFunc: func(obj interface{}) {
			secret, ok := obj.(*v1.Secret)
			if !ok {
				log.Println("Failed to cast to Secret")
				return
			}

+			if secret.CreationTimestamp.Time.Before(startTime) {
+				return
+			}

			if secrets.IsSecretManaged(*secret) {
				fmt.Println("Added, Found secret", secret.Name)
			}
		},
		UpdateFunc: func(_, newObj interface{}) {
			// We only care about the new object
			secret, ok := newObj.(*v1.Secret)
			if !ok {
				log.Println("Failed to cast to Secret")
				return
			}

			if secrets.IsSecretManaged(*secret) {
				fmt.Println("Updated, Found secret", secret.Name)
			}
		},
	}

	secretInformer := cache.NewSharedInformer(secretWatch, &v1.Secret{}, 0)
	secretInformer.AddEventHandler(secretHandler)
	endSignal := make(chan struct{})
	go secretInformer.Run(endSignal)
}

On devrait maintenant avoir le comportement attendu. Testons ça !

go run main.go
I0118 09:11:00.216301   15497 kube.go:57] Using kubeconfig: /Users/qjoly/.kube/config
# Et seulement si on crée un secret après le démarrage de l'opérateur
Added, Found secret non-empty-secret

Parfait, on est maintenant capable d’agir à partir des événements de création et de modification de secrets. Mais il nous reste encore un point à traiter : la réconciliation.

Réconciliation

On va enfin mettre en place notre boucle de réconciliation !

Voici techniquement les étapes qu’elle va suivre :

  • Lister les secrets ;
  • Traiter les secrets qui ne l’ont pas déjà été.

Pour rappel, on ne veut pas traiter que le secret ayant déclenché la boucle, mais tous les secrets. Ainsi, directement dans le package main, on va rajouter la fonction reconcile :

func reconcile(clientset *kubernetes.Clientset) error {

	kubeSecrets, err := clientset.CoreV1().Secrets("").List(context.Background(), metav1.ListOptions{})
	if err != nil {
		klog.Info(fmt.Sprintf("Error listing secrets: %v", err))
		return err
	}

	klog.Info(fmt.Sprintf("Found %d secrets\n", len(kubeSecrets.Items)))
	for _, secret := range kubeSecrets.Items {
		if secrets.IsSecretManaged(secret) {
			secrets.HandleSecrets(clientset, secret)
		}
	}
	return nil
}

Ta-da ! Nous avons notre boucle de réconciliation. Notre opérateur est maintenant capable de patcher les secrets dès qu’ils sont créés ou modifiés pour peu qu’ils possèdent les annotations nécessaires.

Mais le travail est loin d’être terminé, il nous reste encore du pain sur la planche.

Gestion des signaux

Dès lors qu’on ship une application sur Kubernetes, il est important de gérer au mieux les douze cloud-factors regroupant les bonnes pratiques à suivre pour une application en cloud. Parmi ces règles, on retrouve à la onzième place : “Maximisez la robustesse avec des démarrages rapides et des arrêts gracieux”, et cela s’applique bien évidemment à notre opérateur.

Concrètement, ça va être assez simple : on va écouter les signaux envoyés par Kubernetes pour arrêter proprement notre application en prenant soin de terminer les traitements en cours (pour ne pas s’arrêter en plein milieu d’une réconciliation).

Actuellement, avec notre pauvre select{} et la variable endSignal (qui n’est jamais mise à jour), notre opérateur ne fait rien d’autre que de rester en vie et ne gère pas les signaux. Un arrêt brutal de l’opérateur est la seule issue.

Pour corriger ça, nous pouvons créer un canal qui va écouter les signaux d’arrêt et qui va envoyer un signal à notre boucle de réconciliation pour qu’elle s’arrête proprement.

Ainsi, juste après avoir déclaré nos informers, ajoutons le code suivant :

	// ...
	secretInformer.AddEventHandler(secretHandler)
	endSignal := make(chan struct{})
	go secretInformer.Run(endSignal)
	defer close(endSignal)

	// Gestions des signaux
	sigChan := make(chan os.Signal, 1)
	signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)

	<-sigChan
	endSignal <- struct{}{}
	fmt.Println("Received shutdown signal, exiting...")
}

Bye-bye select{}, on est désormais en attente de sigChan qui attend lui-même un signal d’arrêt donné par signal.Notify().

Il ne nous reste plus qu’à implémenter l’attente de la fin du job en cours après reception du signal d’arrêt.

+	working := false

	secretHandler := cache.ResourceEventHandlerFuncs{
		AddFunc: func(obj interface{}) {
			secret, ok := obj.(*v1.Secret)
			if !ok {
				log.Println("Failed to cast to Secret")
				return
			}

			if secret.CreationTimestamp.Time.Before(startTime) {
				return
			}

			if secrets.IsSecretManaged(*secret) {
+ 				working = true
				klog.Info(fmt.Sprintf("Added, Found secret %s", secret.Name))
				err = reconcile(clientset)
				if err != nil {
					klog.Info(fmt.Sprintf("Error reconciling secrets: %v", err))
				}
+				working = false
			}
		},
		UpdateFunc: func(_, newObj interface{}) {
			secret, ok := newObj.(*v1.Secret)
			if !ok {
				log.Println("Failed to cast to Secret")
				return
			}

			if secrets.IsSecretManaged(*secret) {
+				working = true
				klog.Info(fmt.Sprintf("Updated, Found secret %s", secret.Name))
				err = reconcile(clientset)
				if err != nil {
					klog.Info(fmt.Sprintf("Error reconciling secrets: %v", err))
				}
+				working = false
			}
		},
	}

// ...

	<-sigChan
	endSignal <- struct{}{}
	klog.Info("Received shutdown signal, exiting...")

+	for range [10]int{} {
+		if !working {
+			klog.Warning("Job ended, terminating...")
+			break
+		}

+		time.Sleep(2 * time.Second)
+		klog.Warning("Waiting for job to end...")
+	}
+    klog.Info("End of the program")

Grâce à ce petit bout de code, on gère maintenant les arrêts de notre opérateur, et on ne craint plus un rollout qui prendrait un temps fou si on ne gérait pas les signaux 😌 (sans ça, à chaque demande d’arrêt, le Kubelet enverrait un SIGTERM qui échouerait, et un SIGKILL serait envoyé 30 secondes plus tard).

Mais d’ailleurs, si on fait un rollout, par défaut Kubernetes va déployer une nouvelle version de notre opérateur avant de stopper l’ancienne (sauf si on utilise le strategy.Type:Recreate). Cela veut dire qu’il va y avoir un moment où deux versions vont tourner en même temps… c’est catastrophique si elles commencent à se marcher sur les pieds.

Traitons cette partie ensemble pour éviter tout problème (et même mieux: gérer la haute disponibilité de notre opérateur).

Haute disponibilité (leader election)

Actuellement, si je déploie deux versions de mon opérateur, les deux vont réagir aux mêmes évenements. Dès la création d’un secret, les deux instances vont vouloir le traiter alors qu’une seule devrait le faire. Il y a même de fortes chances que les deux opérateurs patchent le secret en même temps, ce qui va créer un conflit.

Bref, on ne veut pas qu’il y ait deux pods qui traitent les secrets en même temps.

Durant l’écriture de mon article sur NATS, j’étais tombé sur ce projet GitHub qui utilise le SDK NATS comme leader election pour créer un cluster Raft. C’est exactement ce qu’on pourrait faire, mais j’ai trouvé une nouvelle méthode qui semble un peu plus simple à mettre en place : le leader election de Kubernetes.

Ça fait maintenant un moment que je garde cet article dans mes favoris dans l’espoir de vous le présenter un jour. That’s my moment !

L’idée incroyable de cet article est d’utiliser les baux (leases) de Kubernetes pour élire un leader. Cela nous permet de choisir un pod spécifique pour traiter les secrets et de ne pas avoir à gérer nous-mêmes la logique de leader election.

Le pod leader va régulièrement renouveler son bail pour garder son rôle tandis que les autres pods vont essayer de lui prendre. Si le leader ne renouvelle pas son bail à temps, un autre pod prendra sa place.

Je vais alors vous montrer le fichier kube.go qui contient à la fois la définition de notre client Kubernetes, et la logique de leader election. Je vais également modifier le package main pour intégrer le fait d’attendre d’être leader avant de démarrer la réconciliation.

J’ai décidé de simplifier nos interactions avec le client Kubernetes en dédiant un package à cela. C’est plus facile à maintenir.

package kube
type (
	KubeClient struct {
		Clientset *kubernetes.Clientset
		Cfg       *rest.Config
		el        *leaderelection.LeaderElector
		namespace string
	}
)
const (
	lockName = "random-secret"
)
var (
	identity = fmt.Sprintf("%d", os.Getpid())
	// identity = identity = os.Getenv("HOSTNAME") // Lorsque nous serons dans un pod Kubernetes
)
func NewClient() *KubeClient {
	client := &KubeClient{
		Cfg: getConfig(),
	}
	var err error
	client.Clientset, err = kubernetes.NewForConfig(client.Cfg)
	if err != nil {
		klog.Info(fmt.Sprintf("Error creating clientset: %v", err))
	}
	client.namespace = os.Getenv("NAMESPACE")
	if client.namespace == "" {
		client.namespace = "default"
	}
	return client
}

func getConfig() *rest.Config {
	cfg, err := rest.InClusterConfig()
	if err != nil {
		var kubeconfigPath string
		if os.Getenv("KUBECONFIG") != "" {
			kubeconfigPath = os.Getenv("KUBECONFIG")
		} else {
			homeDir, err := os.UserHomeDir()
			if err != nil {
				log.Fatalf("Error getting user home directory: %v", err)
			}
			kubeconfigPath = filepath.Join(homeDir, ".kube", "config")
		}
		klog.Info("Using kubeconfig: ", kubeconfigPath)
		flag.Parse()
		cfg, err = clientcmd.BuildConfigFromFlags("", kubeconfigPath)
		if err != nil {
			klog.Fatalf("Error building kubeconfig: %v", err)
		}
	}
	return cfg
}

func (k *KubeClient) LeaderElection() {
	cfg, err := ctrl.GetConfig()
	if err != nil {
		panic(err.Error())
	}
	l, err := rl.NewFromKubeconfig(
		rl.LeasesResourceLock,
		k.namespace,
		lockName,
		rl.ResourceLockConfig{
			Identity: identity,
		},
		cfg,
		time.Second*10,
	)
	if err != nil {
		panic(err)
	}
	el, err := leaderelection.NewLeaderElector(leaderelection.LeaderElectionConfig{
		Lock:          l,
		LeaseDuration: time.Second * 15,
		RenewDeadline: time.Second * 10,
		RetryPeriod:   time.Second * 2,
		Name:          lockName,
		Callbacks: leaderelection.LeaderCallbacks{
			OnStartedLeading: func(ctx context.Context) { klog.Info("I am the leader!") },
			OnStoppedLeading: func() { klog.Info("I am not the leader anymore!") },
			OnNewLeader:      func(identity string) { klog.Info("the leader is %s\n", identity) },
		},
	})
	if err != nil {
		panic(err)
	}
	go el.Run(context.Background())
	k.el = el
	for !el.IsLeader() {
		klog.Info("Waiting to become leader")
		time.Sleep(2 * time.Second)
	}
}

func (k *KubeClient) CheckLeader() bool {
	return k.el.IsLeader()
}

func (k *KubeClient) WaitForLeader() {
	for !k.el.IsLeader() {
		klog.Info("Waiting to become leader")
		time.Sleep(2 * time.Second)
	}
}

Détaillons un peu ce code :

  • NewClient : Crée un client Kubernetes qu’on pourra appeler pour récupérer le clientset ;
  • LeaderElection: Interroge l’API-Server pour créer ou obtenir un lease et déterminer si le pod est leader ;
  • CheckLeader: Vérifie si le pod est leader ;
  • WaitForLeader: Attend que le pod soit leader.

Dans ce système là, nous demandons des baux de 15 secondes, renouvelés toutes les 10 secondes.

func main() {
	k := kube.NewClient()

+	k.LeaderElection()
	// La suite du code n'est exécutée que si le pod est leader

	clientset := k.Clientset
	err := reconcile(clientset)
	if err != nil {
		klog.Info(fmt.Sprintf("Error reconciling secrets: %v", err))
	}

	secretHandler := cache.ResourceEventHandlerFuncs{
		AddFunc: func(obj interface{}) {
+			if !k.CheckLeader() {
+				k.WaitForLeader()
+			}
			secret, ok := obj.(*v1.Secret)
			if !ok {
				log.Println("Failed to cast to Secret")
				return
			}

			if secret.CreationTimestamp.Time.Before(startTime) {
				return
			}

			if secrets.IsSecretManaged(*secret) {

				working = true

				klog.Info(fmt.Sprintf("Added, Found secret %s", secret.Name))
				err = reconcile(clientset)
				if err != nil {
					klog.Info(fmt.Sprintf("Error reconciling secrets: %v", err))
				}
				working = false
			}
		},
		UpdateFunc: func(_, newObj interface{}) {
+			if !k.CheckLeader() {
+				k.WaitForLeader()
+			}
			secret, ok := newObj.(*v1.Secret)
			if !ok {
				log.Println("Failed to cast to Secret")
				return
			}
			if secrets.IsSecretManaged(*secret) {
				working = true
				klog.Info(fmt.Sprintf("Updated, Found secret %s", secret.Name))
				err = reconcile(clientset)
				if err != nil {
					klog.Info(fmt.Sprintf("Error reconciling secrets: %v", err))
				}
				working = false
			}
		},
	}

	// ...

Le système vu ci-dessus n’est pas entièrement infaillible. L’article dans lequel j’ai découvert cette pratique explique que nous pouvons tomber dans un cas complexe où le leader traite un secret, le pod est stoppé temporairement, un autre pod devient leader et traite le même secret tandis que le premier pod recommence à le traiter également. C’est un cas de figure assez complexe, mais qui peut arriver. Afin de l’éviter, j’ai ajouté une vérification avant le traitement d’un secret pour s’assurer que le pod est bien leader (mais ça n’est quand même pas infaillible).

J’ai trouvé cette astuce incroyablement pratique et simple à mettre en place (grâce au fait que nous déléguons la logique de leader election à Kubernetes). Grâce à ça, on peut théoriquement scale notre opérateur sans craindre de conflit.

On valide tout ça avec un petit test :

Dans cette démo, j’utilise mon laptop de développement pour simuler plusieurs pods, on utilise ainsi le PID pour identifier le pod leader. Dans un cluster Kubernetes, on doit plutôt utiliser l’hostname du pod puisque le PID est toujours le même pour tous les pods.

Voila 🎉, on gère maintenant la haute disponibilité sur notre opérateur !

Créer nos propres ressources (CRD)

Pour le moment, notre opérateur se contente “uniquement” de patcher les secrets. Mais on pourrait aller plus loin et créer nos propres ressources pour déclencher la génération de secrets. Cela nous permettrait de gérer les secrets de manière plus fine et de ne pas avoir à les patcher.

Pour cela, nous devons nous plonger dans les Custom Ressources Definition (CRD) de Kubernetes. Commençons par expliquer ce qu’est une CRD :

Une Custom Ressource Definition (CRD) est une ressource Kubernetes que nous allons pouvoir définir nous-mêmes. C’est un schéma nous permettant de créer nos propres ressources qui n’existent pas nativement dans Kubernetes.

Imaginons que nous voulons créer une ressource RandomSecret pour générer des secrets aléatoires. Nous allons devoir définir le schéma de cette ressource puis créer un controller qui va gérer cette ressource.

Écrivons l’objet RandomSecret attendu :

apiVersion: secret.a-cup-of.coffee/v1
kind: RandomSecret
metadata:
  name: random-secret
spec:
  key: randomSecret
  length: 32
  secretName: random-secret
  specialChar: true
  static: 
	key: value1
	key2: value2
	key3: value3

Voici le schéma de notre ressource RandomSecret :

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: randomsecrets.secret.a-cup-of.coffee
spec:
  group: secret.a-cup-of.coffee
  names:
    kind: RandomSecret
    plural: randomsecrets
    singular: randomsecret
    shortNames:
      - rasec
      - ransec
      - ras
      - ran
  scope: Namespaced
  versions:
    - name: v1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            apiVersion:
              type: string
              enum:
                - secret.a-cup-of.coffee/v1
            kind:
              type: string
              enum:
                - RandomSecret
            metadata:
              type: object
            spec:
              type: object
              properties:
                secretName:
                  type: string
                  description: The name of the secret to create.
                key:
                  type: string
                  description: The key under which the secret value will be stored.
                length:
                  type: integer
                  description: Length of the generated secret.
                  minimum: 1
                specialChar:
                  type: boolean
                  description: Whether special characters should be included in the secret.
                static:
                  type: object
                  additionalProperties:
                    type: string
                  description: A map of static key-value pairs to include in the secret.
              required:
                - secretName
                - key
                - length

À partir de la Custom Resource Definition ci-dessus, on définit l’API secret.a-cup-of.coffee/v1 de notre ressource Kind: RandomSecret. Les champs obligatoires sont key, length et secretName, tandis que specialChar et static sont optionnels.

Créons notre premier RandomSecret :

kubectl apply -f - <<EOF
apiVersion: secret.a-cup-of.coffee/v1
kind: RandomSecret
metadata:
  name: random-secret
  namespace: default
spec:
  key: randomSecret
  secretName: random-secret
  length: 32
EOF

Vérifier que notre ressource a bien été créée :

kubectl get ransec       
NAME            AGE
random-secret   16s

C’est bien, mais il manque quelque chose… J’aimerais voir quel est le secret généré directement depuis kubectl sans devoir ouvrir le yaml de la ressource. Je vais donc modifier le CRD pour ajouter le champ additionalPrinterColumns :

spec:
	version:
	  - name: v1
		additionalPrinterColumns:
			- name: Secret
			type: string
			description: The name of the secret to create.
			jsonPath: .spec.secretName
			- name: Age
			type: date
			description: The time at which the secret was created.
			jsonPath: .metadata.creationTimestamp

Voici le résultat :

kubectl get ransec
NAME            SECRET          AGE
random-secret   random-secret   71m

C’est vachement mieux ! Mais on ne fait que créer une ressource qui ne fait rien pour le moment, il nous reste quand même à créer un controller (notre opérateur) qui va gérer cette ressource et créer les secrets correspondants.

Controller pour les CRDs

Pour gérer nos nouvelles ressources, nous allons devoir, tout comme pour les secrets, écouter les événements de création et de modification. Pour cela, nous allons devoir définir un dynamicClient, cela ne change pas réellement de ce que nous avons déjà fait à la seule différence qu’on ne surveille pas une ressource custom comme une ressource native. Ce client va nous permettre de communiquer avec l’API-Server à partir d’un schéma représentant notre ressource (là où la librairie clientset n’utilise que des ressources natives).

	randomSecretGVR = schema.GroupVersionResource{
		Group:    "secret.a-cup-of.coffee",
		Version:  "v1",
		Resource: "randomsecrets",
	}

	randomSecretWatch := &cache.ListWatch{
		ListFunc: func(options metav1.ListOptions) (runtime.Object, error) {
			return dynamicClient.Resource(randomSecretGVR).Namespace(metav1.NamespaceAll).List(context.Background(), options)
		},
		WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) {
			return dynamicClient.Resource(randomSecretGVR).Namespace(metav1.NamespaceAll).Watch(context.Background(), options)
		},
	}

	randomSecretHandler := cache.ResourceEventHandlerFuncs{
		AddFunc: func(obj interface{}) {
			if !k.CheckLeader() {
				k.WaitForLeader()
			}
			unstructuredObj, ok := obj.(runtime.Object)
			if !ok {
				log.Println("Failed to cast to Unstructured object")
				return
			}
			randomSecret, err := randomsecrets.ToRandomSecret(unstructuredObj)
			if err != nil {
				log.Printf("Failed to convert object to RandomSecret: %v\n", err)
				return
			}
			if randomSecret.CreationTimestamp.Before(startTime) {
				return
			}
			klog.Infof("Added, Found RandomSecret %s", randomSecret.Name)
			working = true
			err = randomsecrets.ReconcileRandomSecrets(k, randomSecretGVR)
			if err != nil {
				klog.Infof("Error reconciling RandomSecrets: %v", err)
			}
			working = false
		},
		UpdateFunc: func(_, newObj interface{}) {
			if !k.CheckLeader() {
				k.WaitForLeader()
			}
			unstructuredObj, ok := newObj.(runtime.Object)
			if !ok {
				log.Println("Failed to cast to Unstructured object")
				return
			}
			randomSecret, err := randomsecrets.ToRandomSecret(unstructuredObj)
			if err != nil {
				log.Printf("Failed to convert object to RandomSecret: %v\n", err)
				return
			}
			klog.Infof("Updated, Found RandomSecret %s", randomSecret.Name)
			working = true
			err = randomsecrets.ReconcileRandomSecrets(k, randomSecretGVR)
			if err != nil {
				klog.Infof("Error reconciling RandomSecrets: %v", err)
			}
			working = false
		},
	}

	secretInformer := cache.NewSharedInformer(secretWatch, &v1.Secret{}, 0)
	secretInformer.AddEventHandler(secretHandler)

	randomSecretInformer := cache.NewSharedInformer(randomSecretWatch, &unstructured.Unstructured{}, 0)
	randomSecretInformer.AddEventHandler(randomSecretHandler)
type (
	KubeClient struct {
		Clientset     *kubernetes.Clientset
		Cfg           *rest.Config
		el            *leaderelection.LeaderElector
		namespace     string
+		DynamicClient dynamic.Interface
	}
)

func NewClient() *KubeClient {
	client := &KubeClient{
		Cfg: getConfig(),
	}

	var err error

	client.Clientset, err = kubernetes.NewForConfig(client.Cfg)
	if err != nil {
		klog.Fatal(fmt.Sprintf("Error creating clientset: %v", err))
	}

	client.namespace = os.Getenv("NAMESPACE")
	if client.namespace == "" {
		client.namespace = "default"
	}

+	client.DynamicClient, err = dynamic.NewForConfig(client.Cfg)
+	if err != nil {
+		klog.Fatal("Failed to create dynamic client: %v", err)
+	}

	return client
}

Il ne reste plus qu’à intégrer la logique de génération de secret dans notre controller (et oui, pour l’instant la fonction randomsecrets.ToRandomSecret n’existe pas). On va alors créer un package randomsecrets qui va contenir les fonctions nécessaires à la convertion de notre ressource en objet Secret et à la génération du secret.


Maintenant, nous devons apprendre à réagir lorsqu’un package RandomSecret est créé ou modifié. Nous commençons par créer une méthode pour convertir une ressource Kubernetes en struct RandomSecret :

package randomsecrets

func ToRandomSecret(unstructuredObj runtime.Object) (types.RandomSecret, error) {

	// On aurait pu utiliser un yaml.Unmarshal pour parser l'objet unstructuredObj
	// mais j'ai choisi de le faire à la main pour plus de clarté
	var randomSecret types.RandomSecret

	objMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(unstructuredObj)
	if err != nil {
		return randomSecret, err
	}
	creationTimestampStr := objMap["metadata"].(map[string]interface{})["creationTimestamp"].(string)
	creationTimestamp, err := time.Parse(time.RFC3339, creationTimestampStr)
	if err != nil {
		return randomSecret, err
	}

	name := objMap["metadata"].(map[string]interface{})["name"].(string)
	specMap := objMap["spec"].(map[string]interface{})
	length := specMap["length"].(int64)

	var specialChar bool
	if specMap["specialChar"] == nil {
		specialChar = true
	} else {
		specialChar = specMap["specialChar"].(bool)
	}
	key := specMap["key"].(string)
	secretName := specMap["secretName"].(string)

	var static map[string]string
	if specMap["static"] != nil {
		static = specMap["static"].(map[string]string)
	}

	NameSpace := objMap["metadata"].(map[string]interface{})["namespace"].(string)

	randomSecret = types.RandomSecret{
		Name:              name,
		Length:            length,
		SpecialChar:       specialChar,
		Key:               key,
		SecretName:        secretName,
		Static:            static,
		CreationTimestamp: creationTimestamp,
		NameSpace:         NameSpace,
	}

	return randomSecret, nil
}

func ReconcileRandomSecrets(k *kube.KubeClient, randomSecretGVR schema.GroupVersionResource) error {

	dynamicClient := k.DynamicClient
	randomSecretsList, err := dynamicClient.Resource(randomSecretGVR).Namespace("").List(context.Background(), metav1.ListOptions{})
	if err != nil {
		klog.Infof("Error listing RandomSecrets: %v", err)
		return err
	}
	klog.Infof("Found %d RandomSecrets\n", len(randomSecretsList.Items))
	for _, item := range randomSecretsList.Items {
		randomSecret, err := ToRandomSecret(&item)
		if err != nil {
			klog.Infof("Failed to parse RandomSecret: %v", err)
			continue
		}

		isSecretAlreadyCreated, err := k.IsSecretCreated(randomSecret.Name, randomSecret.NameSpace)
		if err != nil {
			klog.Infof("Failed to check if secret %s is already created: %v", randomSecret.Name, err)
			continue
		}
		if !isSecretAlreadyCreated {
			err = k.CreateSecret(randomSecret)
			if err != nil {
				klog.Infof("Failed to create secret %s: %v", randomSecret.Name, err)
			}
		} else {
			klog.Infof("Secret %s already exists", randomSecret.Name)
		}

	}
	return nil
}
package kube
// ...
func (k *KubeClient) CreateSecret(randomSecret types.RandomSecret) error {

	spec := make(map[string]string)
	spec[randomSecret.Key] = secrets.GenerateRandomSecret(int(randomSecret.Length), randomSecret.SpecialChar)

	for k, v := range randomSecret.Static {
		spec[k] = v
	}

	secret := &v1.Secret{
		ObjectMeta: metav1.ObjectMeta{
			Name:      randomSecret.Name,
			Namespace: randomSecret.NameSpace,
		},
		StringData: spec,
	}

	clientset := k.Clientset

	_, err := clientset.CoreV1().Secrets(randomSecret.NameSpace).Create(context.Background(), secret, metav1.CreateOptions{})
	if err != nil {
		return fmt.Errorf("failed to create secret: %v", err)
	}

	fmt.Printf("Secret %s created in namespace %s\n", randomSecret.Name, randomSecret.NameSpace)
	return nil
}

func (k *KubeClient) IsSecretCreated(secretName string, namespace string) (bool, error) {

	clientset := k.Clientset
	_, err := clientset.CoreV1().Secrets(namespace).Get(context.TODO(), secretName, metav1.GetOptions{})
	if err != nil {
		if kerror.IsNotFound(err) {
			return false, nil
		}
		return false, err
	}
	return true, nil
}

Reste une dernière chose à faire, j’aimerais bien que notre controller puisse afficher un Ready pour indiquer que la ressource a bien été traitée.

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: randomsecrets.secret.a-cup-of.coffee
spec:
  group: secret.a-cup-of.coffee
  names:
    kind: RandomSecret
    plural: randomsecrets
    singular: randomsecret
    shortNames:
      - rasec
      - ransec
      - rsec
      - ras
      - ran
  scope: Namespaced
  versions:
    - name: v1
      served: true
      storage: true
      additionalPrinterColumns:
        - name: Secret
          type: string
          description: The name of the secret to create.
          jsonPath: .spec.secretName
        - name: Age
          type: date
          description: The time at which the secret was created.
          jsonPath: .metadata.creationTimestamp
+        - name: State
+          type: string
+          description: The current state of the secret.
+          jsonPath: .status.state
      schema:
        openAPIV3Schema:
          type: object
          properties:
            apiVersion:
              type: string
              enum:
                - secret.a-cup-of.coffee/v1
            kind:
              type: string
              enum:
                - RandomSecret
            metadata:
              type: object
+            status:
+              type: object
+              properties:
+                state:
+                  type: string
+                  description: The current state of the secret.
            spec:
              type: object
              properties:
                secretName:
                  type: string
                  description: The name of the secret to create.
                key:
                  type: string
                  description: The key under which the secret value will be stored.
                length:
                  type: integer
                  description: Length of the generated secret.
                  minimum: 1
                specialChar:
                  type: boolean
                  description: Whether special characters should be included in the secret.
                static:
                  type: object
                  additionalProperties:
                    type: string
                  description: A map of static key-value pairs to include in the secret.
              required:
                - secretName
                - key
                - length
func ReconcileRandomSecrets(k *kube.KubeClient, randomSecretGVR schema.GroupVersionResource) error {
	dynamicClient := k.DynamicClient
	randomSecretsList, err := dynamicClient.Resource(randomSecretGVR).Namespace("").List(context.Background(), metav1.ListOptions{})
	if err != nil {
		klog.Infof("Error listing RandomSecrets: %v", err)
		return err
	}
	klog.Infof("Found %d RandomSecrets\n", len(randomSecretsList.Items))
	for _, item := range randomSecretsList.Items {
		randomSecret, err := ToRandomSecret(&item)
		if err != nil {
			klog.Infof("Failed to parse RandomSecret: %v", err)
			continue
		}

		isSecretAlreadyCreated, err := k.IsSecretCreated(randomSecret.Name, randomSecret.NameSpace)
		if err != nil {
			klog.Infof("Failed to check if secret %s is already created: %v", randomSecret.Name, err)
			continue
		}
		if !isSecretAlreadyCreated {
			err = k.CreateSecret(randomSecret)
			if err != nil {
				klog.Infof("Failed to create secret %s: %v", randomSecret.Name, err)
				updateStatusReady(randomSecret, k, false)
			}

			updateStatusReady(randomSecret, k, true)
		} else {
			updateStatusReady(randomSecret, k, true)
			klog.Infof("Secret %s already exists", randomSecret.Name)
		}

	}
	return nil
}


func updateStatusReady(randomSecret types.RandomSecret, k *kube.KubeClient, ready bool) {
	dynamicClient := k.DynamicClient
	randomSecretGVR := types.RandomSecretGVR

	randomSecretObj, err := dynamicClient.Resource(randomSecretGVR).Namespace(randomSecret.NameSpace).Get(context.Background(), randomSecret.Name, metav1.GetOptions{})
	if err != nil {
		klog.Infof("Failed to get RandomSecret %s: %v", randomSecret.Name, err)
		return
	}

	readyString := "NotReady"
	if ready {
		readyString = "Ready"
	}
	randomSecretObj.Object["status"] = map[string]interface{}{
		"state": readyString,
	}
	_, err = dynamicClient.Resource(randomSecretGVR).Namespace(randomSecret.NameSpace).Update(context.Background(), randomSecretObj, metav1.UpdateOptions{})
	if err != nil {
		klog.Infof("Failed to update status of RandomSecret %s: %v", randomSecret.Name, err)
	}
}

Nous pouvons désormais voir l’état de notre ressource directement depuis kubectl :

kubectl get randomsecrets
NAME            SECRET          AGE   STATE
random-secret   random-secret   95s   Ready

MutatingWebhook

Si nous ne voulons pas utiliser notre nouvelle ressource “RandomSecrets” pour générer des secrets, nous pouvons toujours créer notre secret en appliquant nos annotations afin d’informer notre opérateur de la modification à effectuer. Je vous avais parlé d’une potentielle alternative évitant d’avoir à patcher les secrets a posteriori. Sortons alors l’artillerie lourde et utilisons un MutatingWebhook afin de modifier le secret à la volée directement lors de sa création. C’est techniquement plus compliqué à mettre en place, mais on diminue les actions nécessaires à l’application de la modification.

C’est quoi un MutatingWebhook ?

Commençons par le commencement : qu’est-ce qu’un MutatingWebhook ?. Lorsqu’on envoie une requête de création ou de modification de ressource à l’API-Server, notre requête passe par de nombreux composants comme le MutatingAdmissionController qui va contacter les MutatingWebhook pour modifier la requête avant qu’elle ne passe dans les mains des autres controllers.

alt text

Ainsi, grâce à un MutatingWebhook, nous allons pouvoir modifier la requête de création d’un secret afin d’ajouter notre chaine de caractères aléatoire. Cela nous permettra de ne pas avoir à écouter les événements de création des secrets et de ne pas avoir à les patcher nous-mêmes.

Avant de nous concentrer sur le code, voyons comment configurer notre MutatingWebhook :

apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
  name: random-secret-mutating-webhook
webhooks:
  - name: random-secret.a-cup-of.coffee
    clientConfig:
      service:
        name: random-secret-operator
        namespace: default
        path: /mutate
	  	caBundle: "onverracaplustard"
    rules:
      - operations: ["CREATE", "UPDATE"]
        apiGroups: [""]
        apiVersions: ["v1"]
        resources: ["secrets"]
    admissionReviewVersions: ["v1"]
    sideEffects: None
    timeoutSeconds: 10

Pour résumer :

  • On crée un MutatingWebhookConfiguration qui va pointer vers le service random-secret dans le namespace default ;
  • On appelle le MutatingWebhook pour les opérations de création et de modification des secrets ;
  • On demande à ce que Kubernetes utilise un CA pour valider le certificat du serveur web. (on règlera ça plus tard).

Côté code, nous allons devoir exposer un serveur web (et créer le service correspondant dans le cluster).

Ce qui est assez marrant, c’est qu’il n’est plus nécessaire de faire des requêtes à l’API-Server, c’est plutôt elle qui va nous en envoyer.

Notre MutatingWebhook

Commençons par répondre à la question du “sous quel format va-t’on recevoir les requêtes de l’API-Server ?” en présentant le format de la requête d’admission :

kind: AdmissionReview
apiVersion: admission.k8s.io/v1
request:
  uid: '12345'
  kind:
    group: ''
    version: v1
    kind: Secret
  resource:
    group: ''
    version: v1
    resource: secrets
  name: test-secret
  namespace: default
  operation: CREATE
  object:
    apiVersion: v1
    kind: Secret
    metadata:
      name: test-secret
      namespace: default
      annotations:
        secret.a-cup-of.coffee/enable: 'true'
    data:
      key: dmFsdWU=

Dans cet objet, nous retrouvons à la fois le contexte de la requête (le nom de la ressource, le namespace, l’opération, etc.) et l’objet à muter (.request.object). C’est ce dernier que nous allons devoir modifier, s’il possède nos annotations, pour ajouter notre chaine de caractères aléatoire.

Qu’est-ce qu’on répond à l’API-Server ? Le même objet, mais en ajoutant le champ “response” qui définit si la requête est autorisée et les patchs à appliquer sous le format JSONPatch [{"op": "add", "path": "/data/password", "value": "cGFzc3dvcmQx"}]

kind: AdmissionReview
apiVersion: admission.k8s.io/v1
request:
  uid: '12345'
  kind:
    group: ''
    version: v1
    kind: Secret
  resource:
    group: ''
    version: v1
    resource: secrets
  name: test-secret
  namespace: default
  operation: CREATE
  userInfo: {}
  object:
    apiVersion: v1
    kind: Secret
    metadata:
      name: test-secret
      namespace: default
      annotations:
        secret.a-cup-of.coffee/enable: 'true'
      labels:
        existing-label: value
    data:
      key: dmFsdWU=
  oldObject: 
  options: 
+response:
+  uid: '12345'
+  allowed: true
+  patch: W3sib3AiOiAiYWRkIiwgInBhdGgiOiAiL2RhdGEvcGFzc3dvcmQiLCAidmFsdWUiOiAiY0dGemMzZHZjbVF4In1d

Nous allons implémenter un nouveau package mutatingwebhook contenant notre serveur web et les fonctions pour traiter les requêtes de l’API-Server. Voici le code de notre serveur web :

package mutating

// ...

func Run() {
	http.HandleFunc("/mutate", func(w http.ResponseWriter, r *http.Request) {
		var admissionReview admissionv1.AdmissionReview
		if err := json.NewDecoder(r.Body).Decode(&admissionReview); err != nil {
			http.Error(w, fmt.Sprintf("Error decoding request: %v", err), http.StatusBadRequest)
			return
		}

		klog.Infof("Received request: %s in namespace %s", admissionReview.Request.Name, admissionReview.Request.Namespace)

		decoder := admission.NewDecoder(runtime.NewScheme())

		patchType := admissionv1.PatchTypeJSONPatch
		admissionResponse := admissionv1.AdmissionResponse{
			UID:       admissionReview.Request.UID,
			Allowed:   true,
			PatchType: &patchType,
		}

		secret := &corev1.Secret{}
		if err := decoder.DecodeRaw(admissionReview.Request.Object, secret); err != nil {
			http.Error(w, fmt.Sprintf("Error decoding secret: %v", err), http.StatusBadRequest)
			return
		}

		if !secrets.IsSecretManaged(*secret) {
			klog.Infof("Secret %s is not managed, returning", secret.Name)
			patchBytes, err := json.Marshal(secret)
			if err != nil {
				http.Error(w, fmt.Sprintf("Error marshalling patch: %v", err), http.StatusInternalServerError)
				return
			}

			admissionResponse.Patch = patchBytes
			admissionReview.Response = &admissionResponse
			if err := json.NewEncoder(w).Encode(admissionReview); err != nil {
				http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
				return
			}
			return
		}

		admissionResponse.Patch = secrets.GeneratePatch(*secret)

		admissionResponse.Result = &metav1.Status{
			Status: metav1.StatusSuccess,
		}

		admissionReview.Response = &admissionResponse

		klog.Infof("Patching secret %s in namespace %s", secret.Name, secret.Namespace)
		klog.Infof("Response: %s", string(admissionReview.Response.Patch))

		if err := json.NewEncoder(w).Encode(admissionReview); err != nil {
			http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
			return
		}
	})

	klog.Info("Starting webhook server on :8080")
	if err := http.ListenAndServe(":8080", nil); err != nil {
		klog.Errorf("Failed to start server: %v\n", err)
	}
}
package main 
// ...
func main() {
	k := kube.NewClient()

	k.LeaderElection()
	clientset := k.Clientset
	dynamicClient := k.DynamicClient

	go mutating.Run()

Pour tester que notre serveur web répond bien comme demandé, nous pouvons faire un curl pour envoyer une requête à notre MutatingWebhook :

curl -k \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "AdmissionReview",
    "apiVersion": "admission.k8s.io/v1",
    "request": {
      "uid": "12345",
      "kind": {
        "group": "",
        "version": "v1",
        "kind": "Secret"
      },
      "resource": {
        "group": "",
        "version": "v1",
        "resource": "secrets"
      },
      "name": "test-secret",
      "namespace": "default",
      "operation": "CREATE",
      "object": {
        "apiVersion": "v1",
        "kind": "Secret",
        "metadata": {
          "name": "test-secret",
          "namespace": "default",
          "annotations": {
            "secret.a-cup-of.coffee/enable": "true"
          },
          "labels": {
            "existing-label": "value"
          }
        },
        "data": {
          "key": "dmFsdWU="
        }
      }
    }
  }' \
  http://localhost:8080/mutate | jq

Et pour tester l’objet qu’on renvoie :

curl -k \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "AdmissionReview",
    "apiVersion": "admission.k8s.io/v1",
    "request": {
      "uid": "12345",
      "kind": {
        "group": "",
        "version": "v1",
        "kind": "Secret"
      },
      "resource": {
        "group": "",
        "version": "v1",
        "resource": "secrets"
      },
      "name": "test-secret",
      "namespace": "default",
      "operation": "CREATE",
      "object": {
        "apiVersion": "v1",
        "kind": "Secret",
        "metadata": {
          "name": "test-secret",
          "namespace": "default",
          "annotations": {
            "secret.a-cup-of.coffee/enable": "true"
          },
          "labels": {
            "existing-label": "value"
          }
        },
        "data": {
          "key": "dmFsdWU="
        }
      }
    }
  }' \
  http://localhost:8080/mutate | jq .response.patch | base64 -d

C’est bon, on a réussi à créer notre MutatingWebhook, il ne reste plus qu’à tester ça dans Kubernetes, qu’est-ce qui pourrait mal se passer ? :-)


… en appliquant un secret, on se rend compte qu’il n’est pas modifié comme on le voudrait. Dans les logs de notre API-Server, on remarque qu’il y a un petit soucis :

W0123 18:54:04.701866       1 dispatcher.go:225] Failed calling webhook, failing closed random-secret.a-cup-of.coffee: failed calling webhook "random-secret.a-cup-of.coffee": failed to call webhook: Post "https://r
andom-secret-operator.default.svc:443/mutate?timeout=10s": no service port 443 found for service "random-secret-operator"

En y regardant de plus près, on se rend compte que nous devons absolument utiliser un certificat pour que l’API-Server puisse valider notre serveur web. C’est une étape obligatoire pour des raisons de sécurité auxquelles nous devons nous plier (même si on précise le port 8080, l’API-Server ne va pas se connecter si le certificat n’est pas valide).

Et là, c’est le drame…

Je vais vous remontrer la ressource MutatingWebhookConfiguration :

apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
  name: random-secret-mutating-webhook
webhooks:
  - name: random-secret.a-cup-of.coffee
    clientConfig:
      service:
        name: random-secret-operator
        namespace: default
        path: /mutate
	  	caBundle: "onverracaplustard"
    rules:
      - operations: ["CREATE", "UPDATE"]
        apiGroups: [""]
        apiVersions: ["v1"]
        resources: ["secrets"]
    admissionReviewVersions: ["v1"]
    sideEffects: None
    timeoutSeconds: 10

En effet, il nous reste encore à créer le caBundle pour notre MutatingWebhook. Croyez moi, ça ne va pas être une partie de plaisir. Ce champ détermine la clé publique que l’API-Server va devoir utiliser pour valider le certificat de notre serveur web.

Comment peut-on se débrouiller pour générer ce caBundle ? Une solution serait de demander à l’utilisateur de générer des certificats auto-signés avec openssl (ou via mkcert), puis de configurer l’opérateur ainsi que le MutatingWebhook pour utiliser les clés. C’est une solution qui fonctionne et assez simple à mettre en place, surtout si on passe par du Helm pour déployer notre opérateur.

On pourrait alors fournir un fichier values comme celui-ci:

mutatingWebhook:
  caBundle: "-----BEGIN CERTIFICATE-----\njesuisunsupercaBundle..."
  privateKey: "-----BEGIN PRIVATE-----\ncertificatsuperprivé...."
  publicKey: "-----BEGIN PUBLIC-----\ncettesolutionmevapas...."

D’ailleurs,c’est ce que fait le prometheus-operator. On retrouve cette solution un peu partout étant donné qu’elle est la plus simple à mettre en place.

Je vais essayer de résumer mon feeling à propos de cette solution : je sais bien que 99% des personnes qui seront tentés d’utiliser mon opérateur ne vont pas générer de nouveaux certificats et vont utiliser les mêmes certificats que ceux par défaut. De plus, les trois utilisateurs qui voudront générer des certificats uniques vont se retrouver à devoir utiliser openssl ou un autre outil pour générer des certificats.

On va donc essayer de se casser un peu la tête pour proposer une méthode qui ne nécessite aucune action venant de l’utilisateur tout en permettant d’avoir un certificat unique dans le secret.

En réalité, il n’y a pas de solution miracle : quelqu’un doit bien générer ce certificat (certains utilisent également cert-manager), mais la seule option pour que ce soit transparent pour l’utilisateur est de déléguer ça à un pod. En partant de ce principe, établissons un plan d’action.

Astuce

Comme dit ci-dessus cert-manager est capable de gérer pour nous la gestion des certificats, ce qui simplifie grandement cette étape. C’est une solution que je recommande totalement. Mais comme l’objectif est d’apprendre, je vais volontairement faire un peu plus compliqué.

Nous allons créer un job qui se chargera de générer les certificats, de les stocker dans un secret et de créer le MutatingWebhook en utilisant la clé publique du certificat dans le champ caBundle. Le secret sera utilisé par notre pod, qui devra logiquement attendre que le job ait fini de générer les certificats pour se lancer.

On peut résumer notre plan à ceci :

Schéma de notre job

De plus, nous devons également créer le serviceaccount, le Role et le RoleBinding pour que notre pod puisse créer le MutatingWebhook.

apiVersion: v1
kind: ServiceAccount
metadata:
  name: random-secret-job-sa
  namespace: default
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: mutating-webhook-clusterrole
rules:
  - apiGroups: ["admissionregistration.k8s.io"]
    resources: ["mutatingwebhookconfigurations"]
    verbs: ["create"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: mutating-webhook-clusterrolebinding
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: mutating-webhook-clusterrole
subjects:
  - kind: ServiceAccount
    name: random-secret-job-sa
    namespace: default
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: secrets-role
  namespace: default
rules:
  - apiGroups: [""]
    resources: ["secrets"]
    verbs: ["create", "get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: secrets-rolebinding
  namespace: default
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: secrets-role
subjects:
  - kind: ServiceAccount
    name: random-secret-job-sa
    namespace: default
---
apiVersion: batch/v1
kind: Job
metadata:
  name: random-secret-job-job
  namespace: default
spec:
  template:
    metadata:
      name: random-secret-job
    spec:
      serviceAccountName: random-secret-job-sa
      containers:
        - name: webhook-creator
          image: nixery.dev/shell/kubectl/openssl
          command:
            - "/bin/sh"
            - "-c"
            - |
              openssl genrsa -out ca.key 4096
              openssl req -new -x509 -days 365 -key ca.key -subj "/C=FR/ST=01/L=Lyon/O=Coffee Inc." -out ca.crt
              SERVICE=random-secret-operator
              NAMESPACE=default
              RELEASE_NAME=random-secret-operator
              openssl req -newkey rsa:4096 -nodes -keyout tls.key -subj "/C=FR/ST=01/L=Lyon/O=Coffee Inc./CN=$SERVICE.$NAMESPACE.svc.cluster.local" -out tls.csr
              openssl x509 -req -extfile <(printf "subjectAltName=DNS:$SERVICE.$NAMESPACE.svc.cluster.local,DNS:$SERVICE.$NAMESPACE.svc.cluster,DNS:$SERVICE.$NAMESPACE.svc,DNS:$SERVICE.$NAMESPACE.svc,DNS:$SERVICE.$NAMESPACE,DNS:$SERVICE") -days 3650 -in tls.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out tls.crt
              kubectl create secret tls $RELEASE_NAME --cert=tls.crt --key=tls.key -n $NAMESPACE
              cat <<EOF | kubectl create -f -
              apiVersion: admissionregistration.k8s.io/v1
              kind: MutatingWebhookConfiguration
              metadata:
                name: random-secret-mutating-webhook
              webhooks:
                - name: random-secret.a-cup-of.coffee
                  clientConfig:
                    service:
                      name: random-secret-operator
                      namespace: $NAMESPACE
                      path: "/mutate"
                    caBundle: $(cat ca.crt | base64 | tr -d '\n')
                  rules:
                    - operations: ["CREATE", "UPDATE"]
                      apiGroups: [""]
                      apiVersions: ["v1"]
                      resources: ["secrets"]
                  admissionReviewVersions: ["v1"]
                  sideEffects: None
              EOF              
      restartPolicy: OnFailure

Ne vous inquiétez pas des répétitions des namespaces et des noms de services. Nous simplifierons cela plus tard à l’aide de Helm.

Pour l’image, j’utilise nixery.dev/shell/kubectl/openssl. Nixery est un service en ligne pour générer à la volée des images Docker contenant des outils présents dans les dépots Nix. Dans notre cas, nous n’avons besoin que de kubectl et openssl pour générer nos certificats et créer nos ressources.

Si j’applique ce job dans mon cluster, j’ai un nouveau secret random-secret-operator de type kubernetes.io/tls qui contient les clés privées et publiques de mon certificat.

$ kubectl get secret -o wide
NAME                             TYPE                                  DATA   AGE
random-secret-operator           kubernetes.io/tls                     2      3m2s

Il ne reste plus qu’à l’utiliser dans notre opérateur. Nous modifions donc le code pour qu’il expose son serveur web en HTTPS et qu’il utilise le certificat stocké dans le secret qui sera mappé dans /cert.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: random-secret-operator
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: random-secret-operator
  template:
    metadata:
      labels:
        app: random-secret-operator
    spec:
      serviceAccountName: random-secret-operator
      containers:
        - name: random-secret-operator
          image: ghcr.io/qjoly/randomsecret:dev
          imagePullPolicy: Always
          ports:
+            - containerPort: 443
+              name: https
          resources:
            limits:
              memory: "512Mi"
              cpu: "500m"
            requests:
              memory: "250Mi"
              cpu: "250m"
+          volumeMounts:
+            - name: tls-certificates
+              mountPath: /certs
+              readOnly: true
+      volumes:
+        - name: tls-certificates
+          secret:
+            secretName: random-secret-operator
+            items:
+              - key: tls.crt
+                path: tls.crt
+              - key: tls.key
+                path: tls.key

On peut tester si notre certificat est bien utilisé via le client openssl :

$ kubectl port-forward svc/random-secret-operator 8443:443 &
$ openssl s_client -connect localhost:8443 2>/dev/null | grep "subject="
subject=/C=FR/ST=01/L=Lyon/O=Coffee Inc./CN=random-secret-operator.default.svc.cluster.local

Parfait ! On a bien notre certificat qui est utilisé pour exposer notre serveur en HTTPS.

Nous pouvons maintenant demander à l’API-Server de créer des secrets afin de voir si notre MutatingWebhook fonctionne correctement.

kubectl apply --dry-run=server -o yaml -f - <<EOF
apiVersion: v1
kind: Secret
metadata:
  name: test-secret
  annotations:
	secret.a-cup-of.coffee/enable: "true"
data:
  key: dmFsdWU=
EOF

Dernière étape : Chart helm

Terminons ce projet en rendant notre opérateur facilement déployable grâce à une chart Helm. Celui-ci se chargera de :

  • Déployer le job pour générer les certificats et créer le MutatingWebhook ;
  • Installer les CRDs ;
  • Déployer l’opérateur.

Je ne vais pas vous détailler la création d’un chart Helm, en revanche, il y a un petit aspect que je voudrais aborder : la gestion d’une ressource externe (le mutatingwebhook) dans un chart Helm.

Effectivement, le mutating-webhook est une ressource externe à notre chart, mais si on désinstalle l’opérateur : on se retrouve avec un webhook qui ne redirige vers un service qui n’existe plus (à cause de cela, l’API-Server renverra une erreur à chaque création de secret). Pour supprimer cette ressource, il existe de nombreuses solutions, j’ai essayé de pré-créer le webhook/secret et utiliser notre job pour le mettre à jour (en changeant notre kubectl create, par un kubectl patch), mais je n’étais pas réellement satisfait de cette solution. J’ai donc migré vers une autre méthode : de la même manière qu’on crée le job pour ajouter notre webhook, on peut également utiliser un job pour le supprimer.

  annotations:
    helm.sh/hook: pre-delete
spec:
  template:
    metadata:
      name: {{ include "random-operator.fullname" . }}-cleanup-job
    spec:
      serviceAccountName: {{ include "random-operator.fullname" . }}-cleanup-job
      containers:
        - name: webhook-creator
          image: nixery.dev/shell/kubectl/openssl
          command:
            - "/bin/sh"
            - "-c"
            - |
              set -e
              kubectl delete secret -n {{ .Release.Namespace }} {{ .Values.secretName }}
              kubectl delete mutatingwebhookconfiguration {{ include "random-operator.fullname" . }}-webhook              
      restartPolicy: OnFailure

Ainsi, lorsqu’on désinstalle notre opérateur, le job de suppression va supprimer le webhook et le secret associé. À savoir également que nous utilisons les hooks Helm pour décider de quand nos deux jobs vont se lancer :

  • Le premier job (qui génère les certificats et crée le webhook) est lancé lors de l’installation de l’opérateur helm.sh/hook: pre-install ;
  • Le second (qui supprime le webhook et le secret) est lancé lors de la désinstallation de l’opérateur helm.sh/hook: pre-delete.

Conclusion

Si vous souhaitez voir le code source en entier, vous pouvez le retrouver sur mon dépôt GitHub, n’hésitez pas à me faire des retours si vous avez des questions ou des suggestions.

Comme vous avez pu le voir, c’est techniquement pas si compliqué de créer un opérateur Kubernetes (en dehors de la maitrise des concepts Kubernetes, on a pas dû implémenter de logique très complexe). C’est un bon moyen d’apprendre comment fonctionne Kubernetes (et encore, nous aurions pu aller bien plus loin en ajoutant la gestion des OwnerReferences, ou des Finalizer pour gérer nos RandomSecrets). Comme dit en début d’article, le but premier de ce projet est avant tout un prétexte pour apprendre et le code actuel n’est pas forcément à utiliser dans un contexte professionnel.

Avoir appris à bricoler mon propre opérateur en Go me conforte dans l’idée que Kubernetes est très flexible et quoiqu’en disent les anti-Kubernetes, on peut souvent bricoler quelque chose pour arriver à nos fins.

Bon kawa ! ☕️