Skip to content

Commit

Permalink
Sync OrganizationMembers/Teams from control-api to OCP groups (#84)
Browse files Browse the repository at this point in the history
  • Loading branch information
bastjan authored Mar 12, 2024
1 parent fa9fe92 commit d54025c
Show file tree
Hide file tree
Showing 5 changed files with 337 additions and 1 deletion.
12 changes: 12 additions & 0 deletions config/foreign_rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 12 additions & 0 deletions config/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
169 changes: 169 additions & 0 deletions controllers/groupsync_controller.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
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")

finalizerName := UpstreamFinalizerPrefix + r.ControlAPIFinalizerZoneName

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
}

l.Info("Group deleted")

if controllerutil.RemoveFinalizer(upstream, finalizerName) {
if err := r.ForeignClient.Update(ctx, upstream); err != nil {
l.Error(err, "unable to remove finalizer from upstream")
return ctrl.Result{}, err
}
}

l.Info("Finalizer removed from upstream", "finalizer", finalizerName)

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
}
l.Info("Group reconciled", "operation", op)

if controllerutil.AddFinalizer(upstream, finalizerName) {
if err := r.ForeignClient.Update(ctx, upstream); err != nil {
l.Error(err, "unable to add finalizer to upstream")
return ctrl.Result{}, err
}
l.Info("Finalizer added to upstream", "finalizer", finalizerName)
}

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}},
}
}
121 changes: 121 additions & 0 deletions controllers/groupsync_controller_test.go
Original file line number Diff line number Diff line change
@@ -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()}
}
24 changes: 23 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,19 @@ 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.")

var qps, burst int
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{}
Expand Down Expand Up @@ -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{
Expand Down

0 comments on commit d54025c

Please sign in to comment.