diff --git a/controller.go b/controller.go index cd1bb04c..8b665a92 100644 --- a/controller.go +++ b/controller.go @@ -313,6 +313,14 @@ func setupManager( return nil, err } } + dor := &controllers.DefaultOrganizationReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("organization-members-controller"), + } + if err = dor.SetupWithManager(mgr); err != nil { + return nil, err + } obenc := &controllers.OrgBillingEntityNameCacheController{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), diff --git a/controllers/default_organization_controller.go b/controllers/default_organization_controller.go new file mode 100644 index 00000000..a879d182 --- /dev/null +++ b/controllers/default_organization_controller.go @@ -0,0 +1,101 @@ +package controllers + +import ( + "context" + + "go.uber.org/multierr" + apierrors "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/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/log" + + controlv1 "github.com/appuio/control-api/apis/v1" +) + +// DefaultOrganizationReconciler reconciles User resources to ensure they have a DefaultOrganization set if applicable. +type DefaultOrganizationReconciler struct { + client.Client + Recorder record.EventRecorder + Scheme *runtime.Scheme +} + +//+kubebuilder:rbac:groups=appuio.io,resources=organizationmembers,verbs=get;list;watch +//+kubebuilder:rbac:groups=appuio.io,resources=users,verbs=get;list;watch;update;patch +//+kubebuilder:rbac:groups=appuio.io,resources=users/status,verbs=get + +// Reconcile reacts on changes of memberships and sets members' default organization if appropriate +func (r *DefaultOrganizationReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := log.FromContext(ctx) + log.V(1).WithValues("request", req).Info("Reconciling") + + memb := controlv1.OrganizationMembers{} + if err := r.Get(ctx, req.NamespacedName, &memb); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + if !memb.ObjectMeta.DeletionTimestamp.IsZero() { + return ctrl.Result{}, nil + } + + allMemberships := controlv1.OrganizationMembersList{} + if err := r.List(ctx, &allMemberships); err != nil { + return ctrl.Result{}, err + } + + var errGroup error + for _, user := range memb.Status.ResolvedUserRefs { + myOrgs := make([]string, 0) + + for _, membership := range allMemberships.Items { + for _, membershipUser := range membership.Status.ResolvedUserRefs { + if user.Name == membershipUser.Name { + myOrgs = append(myOrgs, membership.Namespace) + break + } + } + } + if len(myOrgs) == 1 { + err := setUserDefaultOrganization(ctx, r.Client, user.Name, myOrgs[0]) + errGroup = multierr.Append(errGroup, err) + } + } + + return ctrl.Result{}, errGroup +} + +func setUserDefaultOrganization(ctx context.Context, c client.Client, userName string, orgName string) error { + user := controlv1.User{} + if err := c.Get(ctx, types.NamespacedName{Name: userName}, &user); err != nil { + if !apierrors.IsNotFound(err) { + return err + } + return c.Create(ctx, &controlv1.User{ + ObjectMeta: v1.ObjectMeta{ + Name: userName, + }, + Spec: controlv1.UserSpec{ + Preferences: controlv1.UserPreferences{ + DefaultOrganizationRef: orgName, + }, + }, + }) + } + + if user.Spec.Preferences.DefaultOrganizationRef != "" { + return nil + } + + user.Spec.Preferences.DefaultOrganizationRef = orgName + return c.Update(ctx, &user) +} + +// SetupWithManager sets up the controller with the Manager. +func (r *DefaultOrganizationReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&controlv1.OrganizationMembers{}). + Complete(r) +} diff --git a/controllers/default_organization_controller_test.go b/controllers/default_organization_controller_test.go new file mode 100644 index 00000000..e483a3bb --- /dev/null +++ b/controllers/default_organization_controller_test.go @@ -0,0 +1,225 @@ +package controllers_test + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + controlv1 "github.com/appuio/control-api/apis/v1" + . "github.com/appuio/control-api/controllers" +) + +var testMemberships1 = controlv1.OrganizationMembers{ + ObjectMeta: metav1.ObjectMeta{ + Name: "members", + Namespace: "foo-gmbh", + }, + Spec: controlv1.OrganizationMembersSpec{ + UserRefs: []controlv1.UserRef{ + {Name: "u1"}, + {Name: "u2"}, + {Name: "u3"}, + }, + }, + Status: controlv1.OrganizationMembersStatus{ + ResolvedUserRefs: []controlv1.UserRef{ + {Name: "u1"}, + {Name: "u2"}, + {Name: "u3"}, + }, + }, +} + +var testMemberships2 = controlv1.OrganizationMembers{ + ObjectMeta: metav1.ObjectMeta{ + Name: "members", + Namespace: "bar-gmbh", + }, + Spec: controlv1.OrganizationMembersSpec{ + UserRefs: []controlv1.UserRef{ + {Name: "u1"}, + }, + }, + Status: controlv1.OrganizationMembersStatus{ + ResolvedUserRefs: []controlv1.UserRef{ + {Name: "u1"}, + }, + }, +} + +var u1 = controlv1.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "u1", + }, +} +var u2 = controlv1.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "u2", + }, +} +var u3 = controlv1.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "u3", + }, +} + +func Test_DefaultOrganizationReconciler_Reconcile_Success(t *testing.T) { + ctx := context.Background() + c := prepareTest(t, &testMemberships1, &testMemberships2, &u1, &u2, &u3) + fakeRecorder := record.NewFakeRecorder(3) + + _, err := (&DefaultOrganizationReconciler{ + Client: c, + Scheme: c.Scheme(), + Recorder: fakeRecorder, + }).Reconcile(ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: testMemberships1.Name, + Namespace: testMemberships1.Namespace, + }, + }) + require.NoError(t, err) + + user := controlv1.User{} + require.NoError(t, c.Get(context.TODO(), types.NamespacedName{Name: u1.ObjectMeta.Name}, &user)) + assert.Empty(t, user.Spec.Preferences.DefaultOrganizationRef) + + require.NoError(t, c.Get(context.TODO(), types.NamespacedName{Name: u2.ObjectMeta.Name}, &user)) + assert.Equal(t, testMemberships1.ObjectMeta.Namespace, user.Spec.Preferences.DefaultOrganizationRef) + + require.NoError(t, c.Get(context.TODO(), types.NamespacedName{Name: u3.ObjectMeta.Name}, &user)) + assert.Equal(t, testMemberships1.ObjectMeta.Namespace, user.Spec.Preferences.DefaultOrganizationRef) + +} + +func Test_DefaultOrganizationReconciler_Reconcile_NoMembership_Success(t *testing.T) { + ctx := context.Background() + c := prepareTest(t, &testMemberships2, &u1, &u2, &u3) + fakeRecorder := record.NewFakeRecorder(3) + + _, err := (&DefaultOrganizationReconciler{ + Client: c, + Scheme: c.Scheme(), + Recorder: fakeRecorder, + }).Reconcile(ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: testMemberships2.Name, + Namespace: testMemberships2.Namespace, + }, + }) + require.NoError(t, err) + + user := controlv1.User{} + require.NoError(t, c.Get(context.TODO(), types.NamespacedName{Name: u1.ObjectMeta.Name}, &user)) + assert.Equal(t, testMemberships2.ObjectMeta.Namespace, user.Spec.Preferences.DefaultOrganizationRef) + + require.NoError(t, c.Get(context.TODO(), types.NamespacedName{Name: u2.ObjectMeta.Name}, &user)) + assert.Empty(t, user.Spec.Preferences.DefaultOrganizationRef) + + require.NoError(t, c.Get(context.TODO(), types.NamespacedName{Name: u3.ObjectMeta.Name}, &user)) + assert.Empty(t, user.Spec.Preferences.DefaultOrganizationRef) + +} + +func Test_DefaultOrganizationReconciler_Reconcile_UserNotExist_Success(t *testing.T) { + ctx := context.Background() + c := prepareTest(t, &testMemberships1, &u1) + fakeRecorder := record.NewFakeRecorder(3) + + _, err := (&DefaultOrganizationReconciler{ + Client: c, + Scheme: c.Scheme(), + Recorder: fakeRecorder, + }).Reconcile(ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: testMemberships1.Name, + Namespace: testMemberships1.Namespace, + }, + }) + require.NoError(t, err) + + user := controlv1.User{} + require.NoError(t, c.Get(context.TODO(), types.NamespacedName{Name: u1.ObjectMeta.Name}, &user)) + assert.Equal(t, testMemberships1.ObjectMeta.Namespace, user.Spec.Preferences.DefaultOrganizationRef) + + require.NoError(t, c.Get(context.TODO(), types.NamespacedName{Name: u2.ObjectMeta.Name}, &user)) + assert.Equal(t, testMemberships1.ObjectMeta.Namespace, user.Spec.Preferences.DefaultOrganizationRef) + + require.NoError(t, c.Get(context.TODO(), types.NamespacedName{Name: u3.ObjectMeta.Name}, &user)) + assert.Equal(t, testMemberships1.ObjectMeta.Namespace, user.Spec.Preferences.DefaultOrganizationRef) + +} + +func Test_DefaultOrganizationReconciler_Reconcile_Error(t *testing.T) { + failU4 := controlv1.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "fail-u4", + }, + } + failMemberships := controlv1.OrganizationMembers{ + ObjectMeta: metav1.ObjectMeta{ + Name: "members", + Namespace: "foo-gmbh", + }, + Spec: controlv1.OrganizationMembersSpec{ + UserRefs: []controlv1.UserRef{ + {Name: "u1"}, + {Name: "u2"}, + {Name: "u3"}, + {Name: "fail-u4"}, + }, + }, + Status: controlv1.OrganizationMembersStatus{ + ResolvedUserRefs: []controlv1.UserRef{ + {Name: "u1"}, + {Name: "u2"}, + {Name: "u3"}, + {Name: "fail-u4"}, + }, + }, + } + ctx := context.Background() + c := failingClient{prepareTest(t, &failMemberships, &failU4, &u1, &u2, &u3)} + fakeRecorder := record.NewFakeRecorder(3) + + _, err := (&DefaultOrganizationReconciler{ + Client: c, + Scheme: c.Scheme(), + Recorder: fakeRecorder, + }).Reconcile(ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: failMemberships.Name, + Namespace: failMemberships.Namespace, + }, + }) + require.Error(t, err) + + user := controlv1.User{} + require.NoError(t, c.Get(context.TODO(), types.NamespacedName{Name: u1.ObjectMeta.Name}, &user)) + assert.Equal(t, failMemberships.ObjectMeta.Namespace, user.Spec.Preferences.DefaultOrganizationRef) + + require.NoError(t, c.Get(context.TODO(), types.NamespacedName{Name: u2.ObjectMeta.Name}, &user)) + assert.Equal(t, failMemberships.ObjectMeta.Namespace, user.Spec.Preferences.DefaultOrganizationRef) + + require.NoError(t, c.Get(context.TODO(), types.NamespacedName{Name: u3.ObjectMeta.Name}, &user)) + assert.Equal(t, failMemberships.ObjectMeta.Namespace, user.Spec.Preferences.DefaultOrganizationRef) + +} + +func (c failingClient) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error { + if strings.HasPrefix(obj.GetName(), "fail-") { + return apierrors.NewInternalError(errors.New("ups")) + } + return c.WithWatch.Update(ctx, obj, opts...) +}