Skip to content

Commit b929466

Browse files
committed
Sync OrganizationMembers/Teams from control-api to OCP groups
1 parent fa9fe92 commit b929466

File tree

5 files changed

+329
-1
lines changed

5 files changed

+329
-1
lines changed

config/foreign_rbac/role.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,19 @@ rules:
1010
resources:
1111
- usageprofiles
1212
- users
13+
- teams
14+
- organizationmembers
1315
verbs:
1416
- get
1517
- list
1618
- watch
19+
- apiGroups:
20+
- appuio.io
21+
resources:
22+
- teams
23+
- teams/finalizers
24+
- organizationmembers
25+
- organizationmembers/finalizers
26+
verbs:
27+
- update
28+
- patch

config/rbac/role.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,18 @@ rules:
7979
- get
8080
- patch
8181
- update
82+
- apiGroups:
83+
- group.openshift.io
84+
resources:
85+
- users
86+
verbs:
87+
- create
88+
- delete
89+
- get
90+
- list
91+
- patch
92+
- update
93+
- watch
8294
- apiGroups:
8395
- rbac.authorization.k8s.io
8496
resources:
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package controllers
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"slices"
7+
"strings"
8+
9+
controlv1 "github.com/appuio/control-api/apis/v1"
10+
userv1 "github.com/openshift/api/user/v1"
11+
apierrors "k8s.io/apimachinery/pkg/api/errors"
12+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
13+
"k8s.io/apimachinery/pkg/runtime"
14+
"k8s.io/apimachinery/pkg/types"
15+
"k8s.io/client-go/tools/record"
16+
ctrl "sigs.k8s.io/controller-runtime"
17+
"sigs.k8s.io/controller-runtime/pkg/client"
18+
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
19+
"sigs.k8s.io/controller-runtime/pkg/handler"
20+
"sigs.k8s.io/controller-runtime/pkg/log"
21+
"sigs.k8s.io/controller-runtime/pkg/reconcile"
22+
23+
"github.com/appuio/appuio-cloud-agent/controllers/clustersource"
24+
)
25+
26+
// GroupSyncReconciler reconciles a Group object
27+
type GroupSyncReconciler struct {
28+
client.Client
29+
Scheme *runtime.Scheme
30+
Recorder record.EventRecorder
31+
32+
ForeignClient client.Client
33+
34+
ControlAPIFinalizerZoneName string
35+
}
36+
37+
// OrganizationMembersManifestName is the static name of the OrganizationMembers manifest
38+
// in the control-api cluster.
39+
const OrganizationMembersManifestName = "members"
40+
41+
const UpstreamFinalizerPrefix = "agent.appuio.io/group-zone-"
42+
43+
//+kubebuilder:rbac:groups=group.openshift.io,resources=users,verbs=get;list;watch;update;patch;create;delete
44+
45+
// Reconcile syncs the Group with the upstream OrganizationMembers or Team resource from the foreign (Control-API) cluster.
46+
func (r *GroupSyncReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
47+
l := log.FromContext(ctx)
48+
l.Info("Reconciling Group")
49+
50+
var members []controlv1.UserRef
51+
var upstream client.Object
52+
53+
isTeam := strings.ContainsRune(req.Name, '+')
54+
if isTeam {
55+
nsn := strings.SplitN(req.Name, "+", 2)
56+
ns, name := nsn[0], nsn[1]
57+
var u controlv1.Team
58+
if err := r.ForeignClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: name}, &u); err != nil {
59+
if apierrors.IsNotFound(err) {
60+
l.Info("Upstream team not found")
61+
return ctrl.Result{}, nil
62+
}
63+
l.Error(err, "unable to get upstream Team")
64+
return ctrl.Result{}, err
65+
}
66+
upstream = &u
67+
members = u.Status.ResolvedUserRefs
68+
} else {
69+
var u controlv1.OrganizationMembers
70+
if err := r.ForeignClient.Get(ctx, client.ObjectKey{Namespace: req.Name, Name: OrganizationMembersManifestName}, &u); err != nil {
71+
if apierrors.IsNotFound(err) {
72+
l.Info("Upstream organization members not found")
73+
return ctrl.Result{}, nil
74+
}
75+
l.Error(err, "unable to get upstream OrganizationMembers")
76+
return ctrl.Result{}, err
77+
}
78+
upstream = &u
79+
members = u.Status.ResolvedUserRefs
80+
}
81+
82+
group := &userv1.Group{ObjectMeta: metav1.ObjectMeta{Name: req.Name}}
83+
84+
if upstream.GetDeletionTimestamp() != nil {
85+
l.Info("Upstream Group is being deleted")
86+
87+
err := r.Delete(ctx, group)
88+
if err != nil && !apierrors.IsNotFound(err) {
89+
l.Error(err, "unable to delete Group")
90+
return ctrl.Result{}, err
91+
}
92+
93+
if controllerutil.RemoveFinalizer(upstream, UpstreamFinalizerPrefix+r.ControlAPIFinalizerZoneName) {
94+
if err := r.ForeignClient.Update(ctx, upstream); err != nil {
95+
l.Error(err, "unable to remove finalizer from upstream")
96+
return ctrl.Result{}, err
97+
}
98+
}
99+
100+
return ctrl.Result{}, nil
101+
}
102+
103+
op, err := controllerutil.CreateOrUpdate(ctx, r.Client, group, func() error {
104+
group.Users = make([]string, len(members))
105+
for i, member := range members {
106+
group.Users[i] = member.Name
107+
}
108+
slices.Sort(group.Users)
109+
return nil
110+
})
111+
if err != nil {
112+
l.Error(err, "unable to create or update (%q) Group", op)
113+
return ctrl.Result{}, err
114+
}
115+
116+
if controllerutil.AddFinalizer(upstream, UpstreamFinalizerPrefix+r.ControlAPIFinalizerZoneName) {
117+
if err := r.ForeignClient.Update(ctx, upstream); err != nil {
118+
l.Error(err, "unable to add finalizer to upstream")
119+
return ctrl.Result{}, err
120+
}
121+
}
122+
123+
return ctrl.Result{}, nil
124+
}
125+
126+
// SetupWithManager sets up the controller with the Manager.
127+
func (r *GroupSyncReconciler) SetupWithManagerAndForeignCluster(mgr ctrl.Manager, foreign clustersource.ClusterSource) error {
128+
return ctrl.NewControllerManagedBy(mgr).
129+
For(&userv1.Group{}).
130+
WatchesRawSource(foreign.SourceFor(&controlv1.Team{}), handler.EnqueueRequestsFromMapFunc(teamMapper)).
131+
WatchesRawSource(foreign.SourceFor(&controlv1.OrganizationMembers{}), handler.EnqueueRequestsFromMapFunc(organizationMembersMapper)).
132+
Complete(r)
133+
}
134+
135+
// teamMapper maps the combination of namespace and name of the manifest as the group name to reconcile.
136+
// The namespace is the organization for the teams.
137+
func teamMapper(ctx context.Context, o client.Object) []reconcile.Request {
138+
team, ok := o.(*controlv1.Team)
139+
if !ok {
140+
log.FromContext(ctx).Error(nil, "expected a Team object got a %T", o)
141+
return []reconcile.Request{}
142+
}
143+
144+
return []reconcile.Request{
145+
{NamespacedName: types.NamespacedName{Name: fmt.Sprintf("%s+%s", team.Namespace, team.Name)}},
146+
}
147+
}
148+
149+
// organizationMembersMapper maps the namespace of the manifest as the group name to reconcile.
150+
// The name is static and the organization is in the namespace field.
151+
func organizationMembersMapper(ctx context.Context, o client.Object) []reconcile.Request {
152+
member, ok := o.(*controlv1.OrganizationMembers)
153+
if !ok {
154+
log.FromContext(ctx).Error(nil, "expected a OrganizationMembers object got a %T", o)
155+
return []reconcile.Request{}
156+
}
157+
158+
return []reconcile.Request{
159+
{NamespacedName: types.NamespacedName{Name: member.Namespace}},
160+
}
161+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package controllers
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
controlv1 "github.com/appuio/control-api/apis/v1"
8+
userv1 "github.com/openshift/api/user/v1"
9+
"github.com/stretchr/testify/require"
10+
apierrors "k8s.io/apimachinery/pkg/api/errors"
11+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
12+
"k8s.io/apimachinery/pkg/types"
13+
"sigs.k8s.io/controller-runtime/pkg/client"
14+
)
15+
16+
func Test_GroupSyncReconciler_Reconcile(t *testing.T) {
17+
upstreamTeam := controlv1.Team{
18+
ObjectMeta: metav1.ObjectMeta{
19+
Name: "developers",
20+
Namespace: "thedoening",
21+
},
22+
Spec: controlv1.TeamSpec{
23+
UserRefs: buildUserRefs("johndoe"),
24+
},
25+
Status: controlv1.TeamStatus{
26+
ResolvedUserRefs: buildUserRefs("johndoe"),
27+
},
28+
}
29+
upstreamOM := controlv1.OrganizationMembers{
30+
ObjectMeta: metav1.ObjectMeta{
31+
Name: OrganizationMembersManifestName,
32+
Namespace: "thedoening",
33+
},
34+
Spec: controlv1.OrganizationMembersSpec{
35+
UserRefs: buildUserRefs("johndoe"),
36+
},
37+
Status: controlv1.OrganizationMembersStatus{
38+
ResolvedUserRefs: buildUserRefs("johndoe"),
39+
},
40+
}
41+
42+
client, scheme, recorder := prepareClient(t)
43+
foreignClient, _, _ := prepareClient(t, &upstreamTeam, &upstreamOM)
44+
45+
subject := GroupSyncReconciler{
46+
Client: client,
47+
Scheme: scheme,
48+
Recorder: recorder,
49+
ForeignClient: foreignClient,
50+
51+
ControlAPIFinalizerZoneName: "lupfig",
52+
}
53+
54+
t.Run("Team", func(t *testing.T) {
55+
// Create
56+
_, err := subject.Reconcile(context.Background(), teamMapper(context.Background(), &upstreamTeam)[0])
57+
require.NoError(t, err)
58+
var group userv1.Group
59+
require.NoError(t, client.Get(context.Background(), types.NamespacedName{Name: "thedoening+developers"}, &group), "should have created a group from the team")
60+
require.Equal(t, userv1.OptionalNames{"johndoe"}, group.Users, "should have set the group users")
61+
// Finalizer
62+
require.NoError(t, foreignClient.Get(context.Background(), namespacedName(&upstreamTeam), &upstreamTeam))
63+
require.Contains(t, upstreamTeam.Finalizers, "agent.appuio.io/group-zone-lupfig", "should have added a finalizer upstream")
64+
65+
// Update
66+
upstreamTeam.Spec.UserRefs = buildUserRefs("johndoe", "janedoe")
67+
upstreamTeam.Status.ResolvedUserRefs = buildUserRefs("johndoe", "janedoe")
68+
require.NoError(t, foreignClient.Update(context.Background(), &upstreamTeam))
69+
_, err = subject.Reconcile(context.Background(), teamMapper(context.Background(), &upstreamTeam)[0])
70+
require.NoError(t, err)
71+
require.NoError(t, client.Get(context.Background(), types.NamespacedName{Name: "thedoening+developers"}, &group))
72+
require.Equal(t, userv1.OptionalNames{"janedoe", "johndoe"}, group.Users, "should have updated the group from the team")
73+
74+
// Delete upstream team
75+
require.NoError(t, foreignClient.Delete(context.Background(), &upstreamTeam))
76+
require.NoError(t, foreignClient.Get(context.Background(), namespacedName(&upstreamTeam), &upstreamTeam), "should not have deleted the upstream team since it has a finalizer")
77+
_, err = subject.Reconcile(context.Background(), teamMapper(context.Background(), &upstreamTeam)[0])
78+
require.NoError(t, err)
79+
require.True(t, apierrors.IsNotFound(foreignClient.Get(context.Background(), namespacedName(&upstreamTeam), &upstreamTeam)), "should have deleted the upstream team after removing the finalizer")
80+
})
81+
82+
t.Run("OrganizationMembers", func(t *testing.T) {
83+
// Create
84+
_, err := subject.Reconcile(context.Background(), organizationMembersMapper(context.Background(), &upstreamOM)[0])
85+
require.NoError(t, err)
86+
var group userv1.Group
87+
require.NoError(t, client.Get(context.Background(), types.NamespacedName{Name: "thedoening"}, &group), "should have created a group from the organization members")
88+
require.Equal(t, userv1.OptionalNames{"johndoe"}, group.Users, "should have set the group users")
89+
// Finalizer
90+
require.NoError(t, foreignClient.Get(context.Background(), namespacedName(&upstreamOM), &upstreamOM))
91+
require.Contains(t, upstreamOM.Finalizers, "agent.appuio.io/group-zone-lupfig", "should have added a finalizer upstream")
92+
93+
// Update
94+
upstreamOM.Spec.UserRefs = buildUserRefs("johndoe", "janedoe")
95+
upstreamOM.Status.ResolvedUserRefs = buildUserRefs("johndoe", "janedoe")
96+
require.NoError(t, foreignClient.Update(context.Background(), &upstreamOM))
97+
_, err = subject.Reconcile(context.Background(), organizationMembersMapper(context.Background(), &upstreamOM)[0])
98+
require.NoError(t, err)
99+
require.NoError(t, client.Get(context.Background(), types.NamespacedName{Name: "thedoening"}, &group))
100+
require.Equal(t, userv1.OptionalNames{"janedoe", "johndoe"}, group.Users, "should have updated the group from the organization members")
101+
102+
// Delete upstream organization members
103+
require.NoError(t, foreignClient.Delete(context.Background(), &upstreamOM))
104+
require.NoError(t, foreignClient.Get(context.Background(), namespacedName(&upstreamOM), &upstreamOM), "should not have deleted the upstream OrganizationMembers since it has a finalizer")
105+
_, err = subject.Reconcile(context.Background(), organizationMembersMapper(context.Background(), &upstreamOM)[0])
106+
require.NoError(t, err)
107+
require.True(t, apierrors.IsNotFound(foreignClient.Get(context.Background(), namespacedName(&upstreamOM), &upstreamOM)), "should have deleted the upstream OrganizationMembers after removing the finalizer")
108+
})
109+
}
110+
111+
func buildUserRefs(names ...string) []controlv1.UserRef {
112+
var refs []controlv1.UserRef
113+
for _, name := range names {
114+
refs = append(refs, controlv1.UserRef{Name: name})
115+
}
116+
return refs
117+
}
118+
119+
func namespacedName(o client.Object) types.NamespacedName {
120+
return types.NamespacedName{Name: o.GetName(), Namespace: o.GetNamespace()}
121+
}

main.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,15 +70,19 @@ func main() {
7070
var controlAPIURL string
7171
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.")
7272

73+
var upstreamZoneIdentifier string
74+
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.")
75+
7376
var selectedUsageProfile string
7477
flag.StringVar(&selectedUsageProfile, "usage-profile", "", "UsageProfile to use. Applies all profiles if empty. Dynamic selection is not supported yet.")
7578

7679
var qps, burst int
7780
flag.IntVar(&qps, "qps", 20, "QPS to use for the controller-runtime client")
7881
flag.IntVar(&burst, "burst", 100, "Burst to use for the controller-runtime client")
7982

80-
var disableUserAttributeSync, disableUsageProfiles bool
83+
var disableUserAttributeSync, disableGroupSync, disableUsageProfiles bool
8184
flag.BoolVar(&disableUserAttributeSync, "disable-user-attribute-sync", false, "Disable the UserAttributeSync controller")
85+
flag.BoolVar(&disableGroupSync, "disable-group-sync", false, "Disable the GroupSync controller")
8286
flag.BoolVar(&disableUsageProfiles, "disable-usage-profiles", false, "Disable the UsageProfile controllers")
8387

8488
opts := zap.Options{}
@@ -162,6 +166,24 @@ func main() {
162166
os.Exit(1)
163167
}
164168
}
169+
if !disableGroupSync {
170+
if upstreamZoneIdentifier == "" {
171+
setupLog.Error(err, "upstream-zone-identifier must be set if GroupSync controller is enabled")
172+
os.Exit(1)
173+
}
174+
if err := (&controllers.GroupSyncReconciler{
175+
Client: mgr.GetClient(),
176+
Scheme: mgr.GetScheme(),
177+
Recorder: mgr.GetEventRecorderFor("group-sync-controller"),
178+
179+
ForeignClient: controlAPICluster.GetClient(),
180+
181+
ControlAPIFinalizerZoneName: upstreamZoneIdentifier,
182+
}).SetupWithManagerAndForeignCluster(mgr, controlAPICluster); err != nil {
183+
setupLog.Error(err, "unable to create controller", "controller", "GroupSync")
184+
os.Exit(1)
185+
}
186+
}
165187

166188
if !disableUsageProfiles {
167189
if err := (&controllers.ZoneUsageProfileSyncReconciler{

0 commit comments

Comments
 (0)