generated from vshn/go-bootstrap
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Sync OrganizationMembers/Teams from control-api to OCP groups (#84)
- Loading branch information
Showing
5 changed files
with
337 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}}, | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters