Implement new setup key resource (#178)

This change implements a new resource called SetupKey that manages the
lifecycle of setup keys and stores them in secrets.

A major change here is that we are also switching to using SSA for
resource management.

Part of #172

Signed-off-by: Philip Laine <philip.laine@gmail.com>
This commit is contained in:
Philip Laine
2026-04-13 12:20:35 +02:00
committed by GitHub
parent 3efacfd4c8
commit 26479a19c0
20 changed files with 1206 additions and 4 deletions
+186
View File
@@ -0,0 +1,186 @@
package controller
import (
"context"
"errors"
"time"
netbird "github.com/netbirdio/netbird/shared/management/client/rest"
"github.com/netbirdio/netbird/shared/management/http/api"
corev1 "k8s.io/api/core/v1"
kerrors "k8s.io/apimachinery/pkg/api/errors"
corev1ac "k8s.io/client-go/applyconfigurations/core/v1"
"k8s.io/utils/ptr"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
nbv1alpha1 "github.com/netbirdio/kubernetes-operator/api/v1alpha1"
"github.com/netbirdio/kubernetes-operator/internal/ssautil"
nbv1alpha1ac "github.com/netbirdio/kubernetes-operator/pkg/applyconfigurations/api/v1alpha1"
)
const (
SetupKeyFinalizer = "netbird.io/setupkey"
SetupKeySecretKey = "setup-key"
)
type SetupKeyReconciler struct {
client.Client
Netbird *netbird.Client
}
// +kubebuilder:rbac:groups=netbird.io,resources=setupkeys,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=netbird.io,resources=setupkeys/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=netbird.io,resources=setupkeys/finalizers,verbs=update
func (r *SetupKeyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
setupKey := nbv1alpha1.SetupKey{}
err := r.Get(ctx, req.NamespacedName, &setupKey)
if err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if !setupKey.DeletionTimestamp.IsZero() {
return r.reconcileDelete(ctx, setupKey)
}
// Set finalizer on the setup key.
setupKeyAC := nbv1alpha1ac.SetupKey(req.Name, req.Namespace).WithFinalizers(SetupKeyFinalizer)
err = r.Client.Apply(ctx, setupKeyAC)
if err != nil {
return ctrl.Result{}, err
}
// Check if setup key is up to date.
ok, err := func() (bool, error) {
if setupKey.Status.SetupKeyID == nil {
return false, nil
}
// Check setup key in Netbird.
resp, err := r.Netbird.SetupKeys.Get(ctx, *setupKey.Status.SetupKeyID)
if netbird.IsNotFound(err) {
return false, nil
}
if err != nil {
return false, err
}
switch resp.State {
case "valid":
case "overused":
return false, errors.New("setup key is overused")
default:
return false, nil
}
// Secret exists and has not been modified.
secret := &corev1.Secret{}
err = r.Client.Get(ctx, client.ObjectKey{Name: setupKey.SecretName(), Namespace: req.Namespace}, secret)
if kerrors.IsNotFound(err) {
return false, nil
}
if err != nil {
return false, err
}
if resp.Key[:5] != string(secret.Data[SetupKeySecretKey])[:5] {
return false, nil
}
// Auto groups have not been changed.
setupKeyReq := api.PutApiSetupKeysKeyIdJSONRequestBody{
AutoGroups: []string{},
}
_, err = r.Netbird.SetupKeys.Update(ctx, *setupKey.Status.SetupKeyID, setupKeyReq)
if err != nil {
return false, err
}
return true, nil
}()
if err != nil {
return ctrl.Result{}, err
}
if ok {
return ctrl.Result{RequeueAfter: 15 * time.Minute}, nil
}
oldSetupKeyID := setupKey.Status.SetupKeyID
// Setup key does not exist so we create one.
expiresIn := 0
if setupKey.Spec.Duration != nil {
expiresIn = int(setupKey.Spec.Duration.Seconds())
}
setupKeyReq := api.PostApiSetupKeysJSONRequestBody{
AllowExtraDnsLabels: ptr.To(false),
AutoGroups: []string{},
Ephemeral: ptr.To(setupKey.Spec.Ephemeral),
ExpiresIn: expiresIn,
Name: req.Name,
Type: "reusable",
UsageLimit: 0,
}
resp, err := r.Netbird.SetupKeys.Create(ctx, setupKeyReq)
if err != nil {
return ctrl.Result{}, err
}
// Update the status with the id.
setupKeyAC = nbv1alpha1ac.SetupKey(req.Name, req.Namespace).WithStatus(nbv1alpha1ac.SetupKeyStatus().WithSetupKeyID(resp.Id))
err = r.Client.Status().Apply(ctx, setupKeyAC)
if err != nil {
return ctrl.Result{}, err
}
// Create the secret containing the key.
owner, err := ssautil.OwnerReference(&setupKey, r.Scheme())
if err != nil {
return ctrl.Result{}, err
}
data := map[string]string{
SetupKeySecretKey: resp.Key,
}
secret := corev1ac.Secret(setupKey.SecretName(), req.Namespace).
WithStringData(data).
WithOwnerReferences(owner)
err = r.Client.Apply(ctx, secret)
if err != nil {
return ctrl.Result{}, err
}
// Delete the old status key if we are recreating.
if oldSetupKeyID != nil {
err = r.Netbird.SetupKeys.Delete(ctx, *oldSetupKeyID)
if err != nil && !netbird.IsNotFound(err) {
return ctrl.Result{}, err
}
}
return ctrl.Result{RequeueAfter: 15 * time.Minute}, nil
}
func (r *SetupKeyReconciler) reconcileDelete(ctx context.Context, setupKey nbv1alpha1.SetupKey) (ctrl.Result, error) {
if setupKey.Status.SetupKeyID == nil {
return ctrl.Result{}, nil
}
err := r.Netbird.SetupKeys.Delete(ctx, *setupKey.Status.SetupKeyID)
if err != nil && !netbird.IsNotFound(err) {
return ctrl.Result{}, err
}
setupKeyAC := nbv1alpha1ac.SetupKey(setupKey.Name, setupKey.Namespace).WithFinalizers()
err = r.Client.Apply(ctx, setupKeyAC)
if err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
func (r *SetupKeyReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&nbv1alpha1.SetupKey{}).
Owns(&corev1.Secret{}).
Complete(r)
}
@@ -0,0 +1,191 @@
package controller
import (
"context"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"net/http/httptest"
netbird "github.com/netbirdio/netbird/shared/management/client/rest"
"github.com/netbirdio/netbird/shared/management/http/api"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
kerrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
nbv1alpha1 "github.com/netbirdio/kubernetes-operator/api/v1alpha1"
)
var _ = Describe("SetupKey Controller", func() {
Context("When reconciling a resource", func() {
ctx := context.Background()
r := rand.New(rand.NewSource(GinkgoRandomSeed()))
setupKeyStore := map[string]*api.SetupKey{}
mux := &http.ServeMux{}
mux.HandleFunc("/api/setup-keys", func(rw http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodPost:
resp := api.SetupKeyClear{
Id: fmt.Sprintf("id-%d", r.Int63()),
Key: fmt.Sprintf("%d", r.Int63()),
State: "valid",
}
b, err := json.Marshal(resp)
Expect(err).NotTo(HaveOccurred())
_, err = rw.Write(b)
Expect(err).NotTo(HaveOccurred())
setupKey := api.SetupKey{
Id: resp.Id,
Key: resp.Key,
State: resp.State,
}
setupKeyStore[resp.Id] = &setupKey
default:
rw.WriteHeader(http.StatusNotFound)
}
})
mux.HandleFunc("/api/setup-keys/{id}", func(rw http.ResponseWriter, req *http.Request) {
id := req.PathValue("id")
setupKey, ok := setupKeyStore[id]
if !ok {
rw.WriteHeader(http.StatusNotFound)
return
}
switch req.Method {
case http.MethodDelete:
delete(setupKeyStore, id)
rw.WriteHeader(http.StatusOK)
case http.MethodGet:
b, err := json.Marshal(setupKey)
Expect(err).NotTo(HaveOccurred())
_, err = rw.Write(b)
Expect(err).NotTo(HaveOccurred())
case http.MethodPut:
b, err := io.ReadAll(req.Body)
Expect(err).NotTo(HaveOccurred())
putReq := api.SetupKeyRequest{}
err = json.Unmarshal(b, &putReq)
Expect(err).NotTo(HaveOccurred())
setupKey.AutoGroups = putReq.AutoGroups
b, err = json.Marshal(setupKey)
Expect(err).NotTo(HaveOccurred())
_, err = rw.Write(b)
Expect(err).NotTo(HaveOccurred())
default:
rw.WriteHeader(http.StatusNotFound)
}
})
server := httptest.NewServer(mux)
nbClient := netbird.New(server.URL, "ABC")
var controllerReconciler *SetupKeyReconciler
nn := client.ObjectKey{
Name: "test-resource",
Namespace: "default",
}
BeforeEach(func() {
controllerReconciler = &SetupKeyReconciler{
Client: k8sClient,
Netbird: nbClient,
}
setupKeyStore = map[string]*api.SetupKey{}
})
AfterEach(func() {
setupKey := &nbv1alpha1.SetupKey{}
err := k8sClient.Get(ctx, nn, setupKey)
if kerrors.IsNotFound(err) {
return
}
Expect(err).ToNot(HaveOccurred())
Expect(k8sClient.Delete(ctx, setupKey)).To(Succeed())
_, err = controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: nn})
Expect(err).ToNot(HaveOccurred())
})
It("creates a secret containing the setup key", func() {
setupKey := &nbv1alpha1.SetupKey{
ObjectMeta: metav1.ObjectMeta{
Name: nn.Name,
Namespace: nn.Namespace,
},
}
Expect(k8sClient.Create(ctx, setupKey)).To(Succeed())
_, err := controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: nn})
Expect(err).NotTo(HaveOccurred())
err = k8sClient.Get(ctx, nn, setupKey)
Expect(err).NotTo(HaveOccurred())
Expect(*setupKey.Status.SetupKeyID).NotTo(BeEmpty())
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: setupKey.SecretName(),
Namespace: "default",
},
}
err = k8sClient.Get(ctx, client.ObjectKeyFromObject(secret), secret)
Expect(err).NotTo(HaveOccurred())
Expect(string(secret.Data[SetupKeySecretKey])).To(Equal(setupKeyStore[*setupKey.Status.SetupKeyID].Key))
})
It("creates a new setup key when the secret is deleted", func() {
setupKey := &nbv1alpha1.SetupKey{
ObjectMeta: metav1.ObjectMeta{
Name: nn.Name,
Namespace: nn.Namespace,
},
}
Expect(k8sClient.Create(ctx, setupKey)).To(Succeed())
_, err := controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: nn})
Expect(err).NotTo(HaveOccurred())
firstSetupKey := nbv1alpha1.SetupKey{}
err = k8sClient.Get(ctx, nn, &firstSetupKey)
Expect(err).NotTo(HaveOccurred())
firstSecret := corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: firstSetupKey.SecretName(),
Namespace: nn.Namespace,
},
}
err = k8sClient.Get(ctx, client.ObjectKeyFromObject(&firstSecret), &firstSecret)
Expect(err).NotTo(HaveOccurred())
Expect(k8sClient.Delete(ctx, &firstSecret)).To(Succeed())
_, err = controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: nn})
Expect(err).NotTo(HaveOccurred())
secondSetupKey := nbv1alpha1.SetupKey{}
err = k8sClient.Get(ctx, nn, &secondSetupKey)
Expect(err).NotTo(HaveOccurred())
secondSecret := corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: secondSetupKey.SecretName(),
Namespace: nn.Namespace,
},
}
err = k8sClient.Get(ctx, client.ObjectKeyFromObject(&secondSecret), &secondSecret)
Expect(err).NotTo(HaveOccurred())
Expect(k8sClient.Delete(ctx, &secondSecret)).To(Succeed())
Expect(setupKeyStore).To(HaveLen(1))
Expect(*firstSetupKey.Status.SetupKeyID).ToNot(Equal(*secondSetupKey.Status.SetupKeyID))
Expect(firstSecret.Data[SetupKeySecretKey]).ToNot(BeEquivalentTo(secondSecret.Data[SetupKeySecretKey]))
})
})
})
+6 -1
View File
@@ -35,6 +35,7 @@ import (
foobarv1 "k8s.io/api/core/v1"
netbirdiov1 "github.com/netbirdio/kubernetes-operator/api/v1"
netbirdiov1alpha1 "github.com/netbirdio/kubernetes-operator/api/v1alpha1"
// +kubebuilder:scaffold:imports
)
@@ -67,6 +68,9 @@ var _ = BeforeSuite(func() {
err = foobarv1.AddToScheme(scheme.Scheme)
Expect(err).NotTo(HaveOccurred())
err = netbirdiov1alpha1.AddToScheme(scheme.Scheme)
Expect(err).NotTo(HaveOccurred())
// +kubebuilder:scaffold:scheme
By("bootstrapping test environment")
@@ -85,7 +89,8 @@ var _ = BeforeSuite(func() {
Expect(err).NotTo(HaveOccurred())
Expect(cfg).NotTo(BeNil())
k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme, FieldOwner: "netbird-operator"})
Expect(err).NotTo(HaveOccurred())
Expect(k8sClient).NotTo(BeNil())
})
+22
View File
@@ -0,0 +1,22 @@
package ssautil
import (
"k8s.io/apimachinery/pkg/runtime"
metav1ac "k8s.io/client-go/applyconfigurations/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
)
func OwnerReference(owner client.Object, scheme *runtime.Scheme) (*metav1ac.OwnerReferenceApplyConfiguration, error) {
gvk, err := apiutil.GVKForObject(owner, scheme)
if err != nil {
return nil, err
}
return metav1ac.OwnerReference().
WithAPIVersion(gvk.GroupVersion().String()).
WithKind(gvk.Kind).
WithName(owner.GetName()).
WithUID(owner.GetUID()).
WithController(true).
WithBlockOwnerDeletion(true), nil
}