diff --git a/config/foreign_rbac/role.yaml b/config/foreign_rbac/role.yaml index 1cb10a1..f29d860 100644 --- a/config/foreign_rbac/role.yaml +++ b/config/foreign_rbac/role.yaml @@ -10,7 +10,19 @@ rules: resources: - usageprofiles - users + - teams + - organizationmembers verbs: - get - list - watch +- apiGroups: + - appuio.io + resources: + - teams + - teams/finalizers + - organizationmembers + - organizationmembers/finalizers + verbs: + - update + - patch diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 4031e40..5406ca0 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -79,6 +79,18 @@ rules: - get - patch - update +- apiGroups: + - group.openshift.io + resources: + - users + verbs: + - create + - delete + - get + - list + - patch + - update + - watch - apiGroups: - rbac.authorization.k8s.io resources: diff --git a/controllers/groupsync_controller.go b/controllers/groupsync_controller.go new file mode 100644 index 0000000..56c18d7 --- /dev/null +++ b/controllers/groupsync_controller.go @@ -0,0 +1,161 @@ +package controllers + +import ( + "context" + "fmt" + "slices" + "strings" + + controlv1 "github.com/appuio/control-api/apis/v1" + userv1 "github.com/openshift/api/user/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "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/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/appuio/appuio-cloud-agent/controllers/clustersource" +) + +// GroupSyncReconciler reconciles a Group object +type GroupSyncReconciler struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + + ForeignClient client.Client + + ControlAPIFinalizerZoneName string +} + +// OrganizationMembersManifestName is the static name of the OrganizationMembers manifest +// in the control-api cluster. +const OrganizationMembersManifestName = "members" + +const UpstreamFinalizerPrefix = "agent.appuio.io/group-zone-" + +//+kubebuilder:rbac:groups=group.openshift.io,resources=users,verbs=get;list;watch;update;patch;create;delete + +// Reconcile syncs the Group with the upstream OrganizationMembers or Team resource from the foreign (Control-API) cluster. +func (r *GroupSyncReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + l := log.FromContext(ctx) + l.Info("Reconciling Group") + + var members []controlv1.UserRef + var upstream client.Object + + isTeam := strings.ContainsRune(req.Name, '+') + if isTeam { + nsn := strings.SplitN(req.Name, "+", 2) + ns, name := nsn[0], nsn[1] + var u controlv1.Team + if err := r.ForeignClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: name}, &u); err != nil { + if apierrors.IsNotFound(err) { + l.Info("Upstream team not found") + return ctrl.Result{}, nil + } + l.Error(err, "unable to get upstream Team") + return ctrl.Result{}, err + } + upstream = &u + members = u.Status.ResolvedUserRefs + } else { + var u controlv1.OrganizationMembers + if err := r.ForeignClient.Get(ctx, client.ObjectKey{Namespace: req.Name, Name: OrganizationMembersManifestName}, &u); err != nil { + if apierrors.IsNotFound(err) { + l.Info("Upstream organization members not found") + return ctrl.Result{}, nil + } + l.Error(err, "unable to get upstream OrganizationMembers") + return ctrl.Result{}, err + } + upstream = &u + members = u.Status.ResolvedUserRefs + } + + group := &userv1.Group{ObjectMeta: metav1.ObjectMeta{Name: req.Name}} + + if upstream.GetDeletionTimestamp() != nil { + l.Info("Upstream Group is being deleted") + + err := r.Delete(ctx, group) + if err != nil && !apierrors.IsNotFound(err) { + l.Error(err, "unable to delete Group") + return ctrl.Result{}, err + } + + if controllerutil.RemoveFinalizer(upstream, UpstreamFinalizerPrefix+r.ControlAPIFinalizerZoneName) { + if err := r.ForeignClient.Update(ctx, upstream); err != nil { + l.Error(err, "unable to remove finalizer from upstream") + return ctrl.Result{}, err + } + } + + return ctrl.Result{}, nil + } + + op, err := controllerutil.CreateOrUpdate(ctx, r.Client, group, func() error { + group.Users = make([]string, len(members)) + for i, member := range members { + group.Users[i] = member.Name + } + slices.Sort(group.Users) + return nil + }) + if err != nil { + l.Error(err, "unable to create or update (%q) Group", op) + return ctrl.Result{}, err + } + + if controllerutil.AddFinalizer(upstream, UpstreamFinalizerPrefix+r.ControlAPIFinalizerZoneName) { + if err := r.ForeignClient.Update(ctx, upstream); err != nil { + l.Error(err, "unable to add finalizer to upstream") + return ctrl.Result{}, err + } + } + + return ctrl.Result{}, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *GroupSyncReconciler) SetupWithManagerAndForeignCluster(mgr ctrl.Manager, foreign clustersource.ClusterSource) error { + return ctrl.NewControllerManagedBy(mgr). + For(&userv1.Group{}). + WatchesRawSource(foreign.SourceFor(&controlv1.Team{}), handler.EnqueueRequestsFromMapFunc(teamMapper)). + WatchesRawSource(foreign.SourceFor(&controlv1.OrganizationMembers{}), handler.EnqueueRequestsFromMapFunc(organizationMembersMapper)). + Complete(r) +} + +// teamMapper maps the combination of namespace and name of the manifest as the group name to reconcile. +// The namespace is the organization for the teams. +func teamMapper(ctx context.Context, o client.Object) []reconcile.Request { + team, ok := o.(*controlv1.Team) + if !ok { + log.FromContext(ctx).Error(nil, "expected a Team object got a %T", o) + return []reconcile.Request{} + } + + return []reconcile.Request{ + {NamespacedName: types.NamespacedName{Name: fmt.Sprintf("%s+%s", team.Namespace, team.Name)}}, + } +} + +// organizationMembersMapper maps the namespace of the manifest as the group name to reconcile. +// The name is static and the organization is in the namespace field. +func organizationMembersMapper(ctx context.Context, o client.Object) []reconcile.Request { + member, ok := o.(*controlv1.OrganizationMembers) + if !ok { + log.FromContext(ctx).Error(nil, "expected a OrganizationMembers object got a %T", o) + return []reconcile.Request{} + } + + return []reconcile.Request{ + {NamespacedName: types.NamespacedName{Name: member.Namespace}}, + } +} diff --git a/controllers/groupsync_controller_test.go b/controllers/groupsync_controller_test.go new file mode 100644 index 0000000..416f7a6 --- /dev/null +++ b/controllers/groupsync_controller_test.go @@ -0,0 +1,121 @@ +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" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func Test_GroupSyncReconciler_Reconcile(t *testing.T) { + upstreamTeam := controlv1.Team{ + ObjectMeta: metav1.ObjectMeta{ + Name: "developers", + Namespace: "thedoening", + }, + Spec: controlv1.TeamSpec{ + UserRefs: buildUserRefs("johndoe"), + }, + Status: controlv1.TeamStatus{ + ResolvedUserRefs: buildUserRefs("johndoe"), + }, + } + upstreamOM := controlv1.OrganizationMembers{ + ObjectMeta: metav1.ObjectMeta{ + Name: OrganizationMembersManifestName, + Namespace: "thedoening", + }, + Spec: controlv1.OrganizationMembersSpec{ + UserRefs: buildUserRefs("johndoe"), + }, + Status: controlv1.OrganizationMembersStatus{ + ResolvedUserRefs: buildUserRefs("johndoe"), + }, + } + + client, scheme, recorder := prepareClient(t) + foreignClient, _, _ := prepareClient(t, &upstreamTeam, &upstreamOM) + + subject := GroupSyncReconciler{ + Client: client, + Scheme: scheme, + Recorder: recorder, + ForeignClient: foreignClient, + + ControlAPIFinalizerZoneName: "lupfig", + } + + t.Run("Team", func(t *testing.T) { + // Create + _, err := subject.Reconcile(context.Background(), teamMapper(context.Background(), &upstreamTeam)[0]) + require.NoError(t, err) + var group userv1.Group + require.NoError(t, client.Get(context.Background(), types.NamespacedName{Name: "thedoening+developers"}, &group), "should have created a group from the team") + require.Equal(t, userv1.OptionalNames{"johndoe"}, group.Users, "should have set the group users") + // Finalizer + require.NoError(t, foreignClient.Get(context.Background(), namespacedName(&upstreamTeam), &upstreamTeam)) + require.Contains(t, upstreamTeam.Finalizers, "agent.appuio.io/group-zone-lupfig", "should have added a finalizer upstream") + + // Update + upstreamTeam.Spec.UserRefs = buildUserRefs("johndoe", "janedoe") + upstreamTeam.Status.ResolvedUserRefs = buildUserRefs("johndoe", "janedoe") + require.NoError(t, foreignClient.Update(context.Background(), &upstreamTeam)) + _, err = subject.Reconcile(context.Background(), teamMapper(context.Background(), &upstreamTeam)[0]) + require.NoError(t, err) + require.NoError(t, client.Get(context.Background(), types.NamespacedName{Name: "thedoening+developers"}, &group)) + require.Equal(t, userv1.OptionalNames{"janedoe", "johndoe"}, group.Users, "should have updated the group from the team") + + // Delete upstream team + require.NoError(t, foreignClient.Delete(context.Background(), &upstreamTeam)) + require.NoError(t, foreignClient.Get(context.Background(), namespacedName(&upstreamTeam), &upstreamTeam), "should not have deleted the upstream team since it has a finalizer") + _, err = subject.Reconcile(context.Background(), teamMapper(context.Background(), &upstreamTeam)[0]) + require.NoError(t, err) + require.True(t, apierrors.IsNotFound(foreignClient.Get(context.Background(), namespacedName(&upstreamTeam), &upstreamTeam)), "should have deleted the upstream team after removing the finalizer") + }) + + t.Run("OrganizationMembers", func(t *testing.T) { + // Create + _, err := subject.Reconcile(context.Background(), organizationMembersMapper(context.Background(), &upstreamOM)[0]) + require.NoError(t, err) + var group userv1.Group + require.NoError(t, client.Get(context.Background(), types.NamespacedName{Name: "thedoening"}, &group), "should have created a group from the organization members") + require.Equal(t, userv1.OptionalNames{"johndoe"}, group.Users, "should have set the group users") + // Finalizer + require.NoError(t, foreignClient.Get(context.Background(), namespacedName(&upstreamOM), &upstreamOM)) + require.Contains(t, upstreamOM.Finalizers, "agent.appuio.io/group-zone-lupfig", "should have added a finalizer upstream") + + // Update + upstreamOM.Spec.UserRefs = buildUserRefs("johndoe", "janedoe") + upstreamOM.Status.ResolvedUserRefs = buildUserRefs("johndoe", "janedoe") + require.NoError(t, foreignClient.Update(context.Background(), &upstreamOM)) + _, err = subject.Reconcile(context.Background(), organizationMembersMapper(context.Background(), &upstreamOM)[0]) + require.NoError(t, err) + require.NoError(t, client.Get(context.Background(), types.NamespacedName{Name: "thedoening"}, &group)) + require.Equal(t, userv1.OptionalNames{"janedoe", "johndoe"}, group.Users, "should have updated the group from the organization members") + + // Delete upstream organization members + require.NoError(t, foreignClient.Delete(context.Background(), &upstreamOM)) + require.NoError(t, foreignClient.Get(context.Background(), namespacedName(&upstreamOM), &upstreamOM), "should not have deleted the upstream OrganizationMembers since it has a finalizer") + _, err = subject.Reconcile(context.Background(), organizationMembersMapper(context.Background(), &upstreamOM)[0]) + require.NoError(t, err) + require.True(t, apierrors.IsNotFound(foreignClient.Get(context.Background(), namespacedName(&upstreamOM), &upstreamOM)), "should have deleted the upstream OrganizationMembers after removing the finalizer") + }) +} + +func buildUserRefs(names ...string) []controlv1.UserRef { + var refs []controlv1.UserRef + for _, name := range names { + refs = append(refs, controlv1.UserRef{Name: name}) + } + return refs +} + +func namespacedName(o client.Object) types.NamespacedName { + return types.NamespacedName{Name: o.GetName(), Namespace: o.GetNamespace()} +} diff --git a/main.go b/main.go index 267bf49..6cad286 100644 --- a/main.go +++ b/main.go @@ -70,6 +70,9 @@ func main() { var controlAPIURL string flag.StringVar(&controlAPIURL, "control-api-url", "", "URL of the control API. If set agent does not use `-kubeconfig-control-api`. Expects a bearer token in `CONTROL_API_BEARER_TOKEN` env var.") + var upstreamZoneIdentifier string + flag.StringVar(&upstreamZoneIdentifier, "upstream-zone-identifier", "", "Identifies the agent in the control API. Currently used for Team/OrganizationMembers finalizer. Must be set if the GroupSync controller is enabled.") + var selectedUsageProfile string flag.StringVar(&selectedUsageProfile, "usage-profile", "", "UsageProfile to use. Applies all profiles if empty. Dynamic selection is not supported yet.") @@ -77,8 +80,9 @@ func main() { flag.IntVar(&qps, "qps", 20, "QPS to use for the controller-runtime client") flag.IntVar(&burst, "burst", 100, "Burst to use for the controller-runtime client") - var disableUserAttributeSync, disableUsageProfiles bool + var disableUserAttributeSync, disableGroupSync, disableUsageProfiles bool flag.BoolVar(&disableUserAttributeSync, "disable-user-attribute-sync", false, "Disable the UserAttributeSync controller") + flag.BoolVar(&disableGroupSync, "disable-group-sync", false, "Disable the GroupSync controller") flag.BoolVar(&disableUsageProfiles, "disable-usage-profiles", false, "Disable the UsageProfile controllers") opts := zap.Options{} @@ -162,6 +166,24 @@ func main() { os.Exit(1) } } + if !disableGroupSync { + if upstreamZoneIdentifier == "" { + setupLog.Error(err, "upstream-zone-identifier must be set if GroupSync controller is enabled") + os.Exit(1) + } + if err := (&controllers.GroupSyncReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("group-sync-controller"), + + ForeignClient: controlAPICluster.GetClient(), + + ControlAPIFinalizerZoneName: upstreamZoneIdentifier, + }).SetupWithManagerAndForeignCluster(mgr, controlAPICluster); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "GroupSync") + os.Exit(1) + } + } if !disableUsageProfiles { if err := (&controllers.ZoneUsageProfileSyncReconciler{