From 667e977093ae802a1367140be6efd6843b477421 Mon Sep 17 00:00:00 2001 From: Sebastian Widmer Date: Tue, 5 Mar 2024 16:52:47 +0100 Subject: [PATCH] Sync user default organization from the control-api --- config/rbac/role.yaml | 2 + controllers/userattributesync_controller.go | 88 +++++++++++++++++++ .../userattributesync_controller_test.go | 80 +++++++++++++++++ controllers/utils_test.go | 2 + main.go | 11 +++ 5 files changed, 183 insertions(+) create mode 100644 controllers/userattributesync_controller.go create mode 100644 controllers/userattributesync_controller_test.go diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 6e2c79e..4031e40 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -105,4 +105,6 @@ rules: verbs: - get - list + - patch + - update - watch diff --git a/controllers/userattributesync_controller.go b/controllers/userattributesync_controller.go new file mode 100644 index 0000000..7c74cd4 --- /dev/null +++ b/controllers/userattributesync_controller.go @@ -0,0 +1,88 @@ +package controllers + +import ( + "context" + "encoding/json" + + controlv1 "github.com/appuio/control-api/apis/v1" + userv1 "github.com/openshift/api/user/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/appuio/appuio-cloud-agent/controllers/clustersource" +) + +// UserAttributeSyncReconciler reconciles a User object +type UserAttributeSyncReconciler struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + + ForeignClient client.Client +} + +const DefaultOrganizationAnnotation = "appuio.io/default-organization" + +//+kubebuilder:rbac:groups=user.openshift.io,resources=users,verbs=get;list;watch;update;patch + +// Reconcile syncs the User with the upstream User resource from the foreign (Control-API) cluster. +// Currently the following attributes are synced: +// - .spec.preferences.defaultOrganizationRef -> .metadata.annotations["appuio.io/default-organization"] +func (r *UserAttributeSyncReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + l := log.FromContext(ctx) + l.Info("Reconciling User") + + var upstream controlv1.User + if err := r.ForeignClient.Get(ctx, client.ObjectKey{Name: req.Name}, &upstream); err != nil { + l.Error(err, "unable to get upstream User") + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + var local userv1.User + if err := r.Get(ctx, client.ObjectKey{Name: req.Name}, &local); err != nil { + l.Error(err, "unable to get local User") + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + if local.Annotations != nil && local.Annotations[DefaultOrganizationAnnotation] == upstream.Spec.Preferences.DefaultOrganizationRef { + l.Info("User has correct default organization annotation") + return ctrl.Result{}, nil + } + + patch := map[string]any{ + "metadata": map[string]any{ + "annotations": map[string]string{ + DefaultOrganizationAnnotation: upstream.Spec.Preferences.DefaultOrganizationRef, + }, + }, + } + encPatch, err := json.Marshal(patch) + if err != nil { + l.Error(err, "unable to marshal patch") + return ctrl.Result{}, err + } + + if err := r.Client.Patch(ctx, &local, client.RawPatch(types.StrategicMergePatchType, encPatch)); err != nil { + l.Error(err, "unable to patch User") + return ctrl.Result{}, err + } + + // Record event so we don't trigger another reconcile loop but still know when the last sync happened. + r.Recorder.Eventf(&local, "Normal", "Reconciled", "Reconciled User") + l.Info("User reconciled") + + return ctrl.Result{}, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *UserAttributeSyncReconciler) SetupWithManagerAndForeignCluster(mgr ctrl.Manager, foreign clustersource.ClusterSource) error { + return ctrl.NewControllerManagedBy(mgr). + For(&userv1.User{}). + WatchesRawSource(foreign.SourceFor(&controlv1.User{}), &handler.EnqueueRequestForObject{}). + Complete(r) +} diff --git a/controllers/userattributesync_controller_test.go b/controllers/userattributesync_controller_test.go new file mode 100644 index 0000000..e8f66f6 --- /dev/null +++ b/controllers/userattributesync_controller_test.go @@ -0,0 +1,80 @@ +package controllers + +import ( + "context" + "testing" + + controlv1 "github.com/appuio/control-api/apis/v1" + userv1 "github.com/openshift/api/user/v1" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" +) + +func Test_UserAttributeSyncReconciler_Reconcile(t *testing.T) { + upstream := controlv1.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "johndoe", + }, + Spec: controlv1.UserSpec{ + Preferences: controlv1.UserPreferences{ + DefaultOrganizationRef: "thedoening", + }, + }, + } + onlyUpstream := controlv1.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "johnupstream", + }, + Spec: controlv1.UserSpec{ + Preferences: controlv1.UserPreferences{ + DefaultOrganizationRef: "onlyupstream", + }, + }, + } + local := userv1.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "johndoe", + }, + } + onlyLocal := userv1.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "onlylocal", + }, + } + + client, scheme, recorder := prepareClient(t, &local, &onlyLocal) + foreignClient, _, _ := prepareClient(t, &upstream, &onlyUpstream) + + subject := UserAttributeSyncReconciler{ + Client: client, + Scheme: scheme, + Recorder: recorder, + ForeignClient: foreignClient, + } + + t.Run("normal", func(t *testing.T) { + _, err := subject.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Name: upstream.Name}}) + require.NoError(t, err) + var synced userv1.User + require.NoError(t, client.Get(context.Background(), types.NamespacedName{Name: upstream.Name}, &synced)) + require.Equal(t, "thedoening", synced.Annotations[DefaultOrganizationAnnotation]) + require.Equal(t, "Normal Reconciled Reconciled User", <-recorder.Events) + + require.Len(t, recorder.Events, 0) + _, err = subject.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Name: upstream.Name}}) + require.NoError(t, err) + require.Len(t, recorder.Events, 0) + }) + + t.Run("only local", func(t *testing.T) { + _, err := subject.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Name: onlyLocal.Name}}) + require.NoError(t, err) + }) + + t.Run("only upstream", func(t *testing.T) { + _, err := subject.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Name: onlyUpstream.Name}}) + require.NoError(t, err) + }) +} diff --git a/controllers/utils_test.go b/controllers/utils_test.go index de4aa8e..bc57ec9 100644 --- a/controllers/utils_test.go +++ b/controllers/utils_test.go @@ -5,6 +5,7 @@ import ( "testing" controlv1 "github.com/appuio/control-api/apis/v1" + userv1 "github.com/openshift/api/user/v1" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -41,6 +42,7 @@ func prepareClient(t *testing.T, initObjs ...client.Object) (client.WithWatch, * require.NoError(t, clientgoscheme.AddToScheme(scheme)) require.NoError(t, cloudagentv1.AddToScheme(scheme)) require.NoError(t, controlv1.AddToScheme(scheme)) + require.NoError(t, userv1.AddToScheme(scheme)) client := fake.NewClientBuilder(). WithScheme(scheme). diff --git a/main.go b/main.go index f516cdb..9928013 100644 --- a/main.go +++ b/main.go @@ -146,6 +146,17 @@ func main() { registerRatioController(mgr, conf, conf.OrganizationLabel) registerOrganizationRBACController(mgr, conf.OrganizationLabel, conf.DefaultOrganizationClusterRoles) + if err := (&controllers.UserAttributeSyncReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("user-attribute-sync-controller"), + + ForeignClient: controlAPICluster.GetClient(), + }).SetupWithManagerAndForeignCluster(mgr, controlAPICluster); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "UserAttributeSync") + os.Exit(1) + } + if err := (&controllers.ZoneUsageProfileSyncReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(),