Skip to content

Commit

Permalink
feat: Controller that copies ClusterClasses to namespaces
Browse files Browse the repository at this point in the history
  • Loading branch information
dlipovetsky committed Jun 11, 2024
1 parent 6f0f61e commit 7d065bf
Show file tree
Hide file tree
Showing 7 changed files with 677 additions and 0 deletions.
166 changes: 166 additions & 0 deletions pkg/controllers/namespacesync/controller.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// Copyright 2024 Nutanix. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

package namespacesync

import (
"context"
"fmt"

corev1 "k8s.io/api/core/v1"
clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller"
"sigs.k8s.io/controller-runtime/pkg/event"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/predicate"
)

type Reconciler struct {
Client client.Client

// UnstructuredCachingClient provides a client that forces caching of unstructured objects,
// optimizing reads of provider-specific resources.
UnstructuredCachingClient client.Client

// SourceClusterClassNamespace is the namespace from which ClusterClasses are copied.
SourceClusterClassNamespace string

// TargetNamespaceFilter determines whether ClusterClasses should be copied to a given namespace.
TargetNamespaceFilter func(ns *corev1.Namespace) bool
}

var NamespaceHasLabelKey = func(key string) func(ns *corev1.Namespace) bool {
return func(ns *corev1.Namespace) bool {
_, ok := ns.GetLabels()[key]
return ok
}
}

func (r *Reconciler) SetupWithManager(
ctx context.Context,
mgr ctrl.Manager,
options controller.Options,
) error {
if r.TargetNamespaceFilter == nil {
return fmt.Errorf("target Namespace filter is nil")
}

err := ctrl.NewControllerManagedBy(mgr).
For(&corev1.Namespace{},
builder.WithPredicates(
predicate.Funcs{
CreateFunc: func(e event.CreateEvent) bool {
// Called when an object is first seen by the cache, i.e. when a new object is created,
// or when the cache is populated on start.
ns, ok := e.Object.(*corev1.Namespace)
if !ok {
return false
}
return r.TargetNamespaceFilter(ns)
},
UpdateFunc: func(e event.UpdateEvent) bool {
// Called when an object is already in the cache, and it is either updated,
// or fetched as part of a re-list (aka re-sync).
return false
},
DeleteFunc: func(e event.DeleteEvent) bool {
// Ignore deletes.
return false
},
GenericFunc: func(e event.GenericEvent) bool {
// Ignore generic events, i.e. events that don't come from the API server.
return false
},
},
)).
Watches(&clusterv1.ClusterClass{},
handler.EnqueueRequestsFromMapFunc(r.clusterClassToNamespaces),
).
Named("syncclusterclass").
WithOptions(options).
Complete(r)
if err != nil {
return fmt.Errorf("failed to set up with controller manager: %w", err)
}

return nil
}

func (r *Reconciler) clusterClassToNamespaces(ctx context.Context, o client.Object) []ctrl.Request {
namespaceList := &corev1.NamespaceList{}
err := r.Client.List(ctx, namespaceList)
if err != nil {
// TODO Log the error, and record an Event.
return nil
}

rs := []ctrl.Request{}
for i := range namespaceList.Items {
ns := &namespaceList.Items[i]
if r.TargetNamespaceFilter(ns) {
rs = append(rs,
ctrl.Request{
NamespacedName: client.ObjectKeyFromObject(ns),
},
)
}
}
return rs
}

func (r *Reconciler) Reconcile(
ctx context.Context,
req ctrl.Request,
) (
_ ctrl.Result,
rerr error,
) {
namespace := req.Name

sccs, err := r.listSourceClusterClasses(ctx)
if err != nil {
// TODO Record an Event.
return ctrl.Result{}, fmt.Errorf("failed to list source ClusterClasses: %w", err)
}

// TODO Consider running in parallel.
for i := range sccs {
scc := &sccs[i]
err := copyClusterClassAndTemplates(
ctx,
r.Client,
r.UnstructuredCachingClient,
scc,
namespace,
)
if client.IgnoreAlreadyExists(err) != nil {
// TODO Record an Event.
return ctrl.Result{}, fmt.Errorf(
"failed to copy source ClusterClass %s or its referenced Templates to namespace %s: %w",
client.ObjectKeyFromObject(scc),
namespace,
err,
)
}
}

// TODO Record an Event.
return ctrl.Result{}, nil
}

func (r *Reconciler) listSourceClusterClasses(
ctx context.Context,
) (
[]clusterv1.ClusterClass,
error,
) {
ccl := &clusterv1.ClusterClassList{}
err := r.Client.List(ctx, ccl, client.InNamespace(r.SourceClusterClassNamespace))
if err != nil {
return nil, err
}
return ccl.Items, nil
}
195 changes: 195 additions & 0 deletions pkg/controllers/namespacesync/controller_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// Copyright 2024 Nutanix. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package namespacesync

import (
"context"
"fmt"
"testing"
"time"

. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
"k8s.io/apiserver/pkg/storage/names"
clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
"sigs.k8s.io/controller-runtime/pkg/client"

"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/internal/test/builder"
)

func TestReconcileNewNamespaces(t *testing.T) {
g := NewWithT(t)
timeout := 5 * time.Second

sourceClusterClassName, cleanup, err := createUniqueClusterClassAndTemplates(
sourceClusterClassNamespace,
)
g.Expect(err).ToNot(HaveOccurred())
defer func() {
g.Expect(cleanup()).To(Succeed())
}()

targetNamespaces, err := createTargetNamespaces(3)
g.Expect(err).ToNot(HaveOccurred())

for _, targetNamespace := range targetNamespaces {
g.Eventually(func() error {
return verifyClusterClassAndTemplates(
env.Client,
sourceClusterClassName,
targetNamespace.Name,
)
},
timeout,
).Should(Succeed())
}
}

func TestReconcileNewClusterClass(t *testing.T) {
g := NewWithT(t)
timeout := 5 * time.Second

targetNamespaces, err := createTargetNamespaces(3)
g.Expect(err).ToNot(HaveOccurred())

sourceClusterClassName, cleanup, err := createUniqueClusterClassAndTemplates(
sourceClusterClassNamespace,
)
g.Expect(err).ToNot(HaveOccurred())
defer func() {
g.Expect(cleanup()).To(Succeed())
}()

for _, targetNamespace := range targetNamespaces {
g.Eventually(func() error {
return verifyClusterClassAndTemplates(
env.Client,
sourceClusterClassName,
targetNamespace.Name,
)
},
timeout,
).Should(Succeed())
}
}

func verifyClusterClassAndTemplates(
cli client.Reader,
name,
namespace string,
) error {
cc := &clusterv1.ClusterClass{}
key := client.ObjectKey{
Name: name,
Namespace: namespace,
}
err := cli.Get(ctx, key, cc)
if err != nil {
return fmt.Errorf("failed to get ClusterClass %s: %w", key, err)
}

return walkReferences(ctx, cc, func(ctx context.Context, ref *corev1.ObjectReference) error {
_, err := getReference(ctx, cli, ref)
return err
})
}

func createUniqueClusterClassAndTemplates(namespace string) (
clusterClassName string,
cleanup func() error,
err error,
) {
return createClusterClassAndTemplates(
names.SimpleNameGenerator.GenerateName("test-"),
namespace,
)
}

func createClusterClassAndTemplates(
prefix,
namespace string,
) (
clusterClassName string,
cleanup func() error,
err error,
) {
// The below objects are created in order to feed the reconcile loop all the information it needs to create a
// full tree of ClusterClass objects (the objects should have owner references to the ClusterClass).

// Bootstrap templates for the workers.
bootstrapTemplate := builder.BootstrapTemplate(namespace, prefix).Build()

// InfraMachineTemplates for the workers and the control plane.
infraMachineTemplateControlPlane := builder.InfrastructureMachineTemplate(
namespace,
fmt.Sprintf("%s-control-plane", prefix),
).Build()
infraMachineTemplateWorker := builder.InfrastructureMachineTemplate(
namespace,
fmt.Sprintf("%s-worker", prefix),
).Build()

// Control plane template.
controlPlaneTemplate := builder.ControlPlaneTemplate(namespace, prefix).Build()

// InfraClusterTemplate.
infraClusterTemplate := builder.InfrastructureClusterTemplate(namespace, prefix).Build()

// MachineDeploymentClasses that will be part of the ClusterClass.
machineDeploymentClass := builder.MachineDeploymentClass(fmt.Sprintf("%s-worker", prefix)).
WithBootstrapTemplate(bootstrapTemplate).
WithInfrastructureTemplate(infraMachineTemplateWorker).
Build()

// ClusterClass.
clusterClass := builder.ClusterClass(namespace, prefix).
WithInfrastructureClusterTemplate(infraClusterTemplate).
WithControlPlaneTemplate(controlPlaneTemplate).
WithControlPlaneInfrastructureMachineTemplate(infraMachineTemplateControlPlane).
WithWorkerMachineDeploymentClasses(*machineDeploymentClass).
Build()

// Create the set of initObjects from the objects above to add to the API server when the test environment starts.

templates := []client.Object{
bootstrapTemplate,
infraMachineTemplateWorker,
infraMachineTemplateControlPlane,
controlPlaneTemplate,
infraClusterTemplate,
}

for _, obj := range templates {
if err := env.CreateAndWait(ctx, obj); err != nil {
return "", nil, err
}
}
if err := env.CreateAndWait(ctx, clusterClass); err != nil {
return "", nil, err
}

cleanup = func() error {
for _, obj := range templates {
if err := env.CleanupAndWait(ctx, obj); err != nil {
return err
}
}
return env.CleanupAndWait(ctx, clusterClass)
}

return clusterClass.Name, cleanup, nil
}

func createTargetNamespaces(number int) ([]*corev1.Namespace, error) {
targetNamespaces := []*corev1.Namespace{}
for i := 0; i < number; i++ {
targetNamespace, err := env.CreateNamespace(ctx, "target", map[string]string{
targetNamespaceLabelKey: "",
})
if err != nil {
return nil, err
}
targetNamespaces = append(targetNamespaces, targetNamespace)
}
return targetNamespaces, nil
}
Loading

0 comments on commit 7d065bf

Please sign in to comment.