Skip to content

Commit 19057ed

Browse files
committed
Add finalizer to clean up OAuthClients on notebook deletion
1 parent eacf63c commit 19057ed

File tree

6 files changed

+291
-2
lines changed

6 files changed

+291
-2
lines changed
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
apiVersion: apiextensions.k8s.io/v1
2+
kind: CustomResourceDefinition
3+
metadata:
4+
annotations:
5+
crd/fake: "true"
6+
name: oauthclients.oauth.openshift.io
7+
spec:
8+
group: oauth.openshift.io
9+
names:
10+
kind: OAuthClient
11+
listKind: OAuthClientList
12+
plural: oauthclients
13+
singular: oauthclient
14+
scope: Cluster
15+
versions:
16+
- name: v1
17+
schema:
18+
openAPIV3Schema:
19+
description: OAuthClient represents an OAuth client
20+
type: object
21+
properties:
22+
apiVersion:
23+
description: 'APIVersion defines the versioned schema of this representation
24+
of an object.'
25+
type: string
26+
kind:
27+
description: 'Kind is a string value representing the REST resource this
28+
object represents.'
29+
type: string
30+
metadata:
31+
type: object
32+
grantMethod:
33+
description: 'Method used to authorize client users (auto, prompt, or deny)'
34+
type: string
35+
secret:
36+
description: 'Client secret'
37+
type: string
38+
redirectURIs:
39+
description: 'List of valid redirect URIs for the client'
40+
type: array
41+
items:
42+
type: string
43+
respondWithChallenges:
44+
description: 'If true, respond to 401 errors with a WWW-Authenticate challenge'
45+
type: boolean
46+
accessTokenMaxAgeSeconds:
47+
description: 'Maximum token lifetime in seconds'
48+
type: integer
49+
format: int32
50+
accessTokenInactivityTimeoutSeconds:
51+
description: 'Timeout for inactive tokens in seconds'
52+
type: integer
53+
format: int32
54+
additionalSecrets:
55+
description: 'Additional client secrets'
56+
type: array
57+
items:
58+
type: string
59+
scopeRestrictions:
60+
description: 'Restrictions on scopes the client can request'
61+
type: array
62+
items:
63+
type: object
64+
served: true
65+
storage: true

components/odh-notebook-controller/config/rbac/role.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ rules:
5858
- get
5959
- list
6060
- patch
61+
- update
6162
- watch
6263
- apiGroups:
6364
- kubeflow.org
@@ -88,6 +89,7 @@ rules:
8889
- oauthclients
8990
verbs:
9091
- create
92+
- delete
9193
- get
9294
- list
9395
- patch

components/odh-notebook-controller/controllers/notebook_controller.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,14 @@ type OpenshiftNotebookReconciler struct {
6767

6868
// ClusterRole permissions
6969

70-
// +kubebuilder:rbac:groups=kubeflow.org,resources=notebooks,verbs=get;list;watch;patch
70+
// +kubebuilder:rbac:groups=kubeflow.org,resources=notebooks,verbs=get;list;watch;update;patch
7171
// +kubebuilder:rbac:groups=kubeflow.org,resources=notebooks/status,verbs=get
7272
// +kubebuilder:rbac:groups=kubeflow.org,resources=notebooks/finalizers,verbs=update
7373
// +kubebuilder:rbac:groups=route.openshift.io,resources=routes,verbs=get;list;watch;create;update;patch
7474
// +kubebuilder:rbac:groups="",resources=services;serviceaccounts;secrets;configmaps,verbs=get;list;watch;create;update;patch
7575
// +kubebuilder:rbac:groups=config.openshift.io,resources=proxies,verbs=get;list;watch
7676
// +kubebuilder:rbac:groups=networking.k8s.io,resources=networkpolicies,verbs=get;list;watch;create;update;patch
77-
// +kubebuilder:rbac:groups=oauth.openshift.io,resources=oauthclients,verbs=get;list;watch;create;update;patch
77+
// +kubebuilder:rbac:groups=oauth.openshift.io,resources=oauthclients,verbs=get;list;watch;create;update;patch;delete
7878
// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles,verbs=get;list;watch;create;update;patch
7979
// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=rolebindings,verbs=get;list;watch;create;update;patch;delete
8080
// +kubebuilder:rbac:groups="image.openshift.io",resources=imagestreams,verbs=list;get;watch

components/odh-notebook-controller/controllers/notebook_controller_test.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929
. "github.com/onsi/gomega"
3030

3131
dspav1 "github.com/opendatahub-io/data-science-pipelines-operator/api/v1"
32+
oauthv1 "github.com/openshift/api/oauth/v1"
3233
routev1 "github.com/openshift/api/route/v1"
3334
corev1 "k8s.io/api/core/v1"
3435
netv1 "k8s.io/api/networking/v1"
@@ -1014,6 +1015,154 @@ var _ = Describe("The Openshift Notebook controller", func() {
10141015
})
10151016
})
10161017

1018+
When("Creating a Notebook with OAuth and then deleting it", func() {
1019+
const (
1020+
Name = "test-notebook-oauth-delete"
1021+
Namespace = "default"
1022+
Finalizer = "notebooks.kubeflow.org/oauthclient"
1023+
)
1024+
1025+
var (
1026+
notebook *nbv1.Notebook
1027+
oauthClient *oauthv1.OAuthClient
1028+
oauthClientName string
1029+
ctx context.Context
1030+
)
1031+
1032+
// Setup for all tests in this context
1033+
BeforeEach(func() {
1034+
ctx = context.Background()
1035+
notebook = createNotebook(Name, Namespace)
1036+
oauthClientName = fmt.Sprintf("%s-%s-%s", Name, Namespace, "oauth-client")
1037+
1038+
oauthClient = &oauthv1.OAuthClient{
1039+
ObjectMeta: metav1.ObjectMeta{
1040+
Name: oauthClientName,
1041+
},
1042+
RedirectURIs: []string{"https://www.kubeflow.org/"},
1043+
GrantMethod: oauthv1.GrantHandlerAuto,
1044+
}
1045+
})
1046+
1047+
// Clean up resources after each test
1048+
AfterEach(func() {
1049+
nbToCleanup := &nbv1.Notebook{}
1050+
err := cli.Get(ctx, types.NamespacedName{Name: Name, Namespace: Namespace}, nbToCleanup)
1051+
if err == nil {
1052+
// Remove finalizers to allow deletion
1053+
nbToCleanup.Finalizers = nil
1054+
_ = cli.Update(ctx, nbToCleanup)
1055+
_ = cli.Delete(ctx, nbToCleanup)
1056+
}
1057+
1058+
oauthClientToCleanup := &oauthv1.OAuthClient{}
1059+
err = cli.Get(ctx, types.NamespacedName{Name: oauthClientName}, oauthClientToCleanup)
1060+
if err == nil {
1061+
_ = cli.Delete(ctx, oauthClientToCleanup)
1062+
}
1063+
})
1064+
1065+
It("Should add a finalizer when a Notebook has been created", func() {
1066+
By("Creating a new Notebook")
1067+
Expect(cli.Create(ctx, notebook)).Should(Succeed())
1068+
1069+
By("Creating an OAuthClient")
1070+
err := cli.Create(ctx, oauthClient)
1071+
if err != nil {
1072+
GinkgoT().Logf("OAuthClient creation result: %v", err)
1073+
}
1074+
1075+
By("Adding the finalizer to the Notebook")
1076+
key := types.NamespacedName{Name: Name, Namespace: Namespace}
1077+
Expect(cli.Get(ctx, key, notebook)).Should(Succeed())
1078+
1079+
notebook.Finalizers = append(notebook.Finalizers, Finalizer)
1080+
Expect(cli.Update(ctx, notebook)).Should(Succeed())
1081+
1082+
By("Verifying the finalizer is added to the Notebook")
1083+
Expect(cli.Get(ctx, key, notebook)).Should(Succeed())
1084+
Expect(notebook.Finalizers).To(ContainElement(Finalizer))
1085+
})
1086+
1087+
It("Should prevent deletion when a finalizer is present", func() {
1088+
By("Creating a Notebook with finalizer")
1089+
notebook.Finalizers = []string{Finalizer}
1090+
Expect(cli.Create(ctx, notebook)).Should(Succeed())
1091+
1092+
By("Creating an OAuthClient")
1093+
Expect(cli.Create(ctx, oauthClient)).Should(Succeed())
1094+
1095+
By("Requesting deletion of the Notebook")
1096+
Expect(cli.Delete(ctx, notebook)).Should(Succeed())
1097+
1098+
By("Verifying Notebook has deletion timestamp but still exists")
1099+
key := types.NamespacedName{Name: Name, Namespace: Namespace}
1100+
Expect(cli.Get(ctx, key, notebook)).Should(Succeed())
1101+
Expect(notebook.DeletionTimestamp).NotTo(BeNil(), "Notebook should have deletion timestamp")
1102+
Expect(notebook.Finalizers).To(ContainElement(Finalizer), "Finalizer should still be present")
1103+
})
1104+
1105+
It("Should delete the OAuthClient when the Notebook is deleted", func() {
1106+
By("Creating a Notebook with finalizer")
1107+
notebook.Finalizers = []string{Finalizer}
1108+
Expect(cli.Create(ctx, notebook)).Should(Succeed())
1109+
1110+
By("Creating an OAuthClient")
1111+
Expect(cli.Create(ctx, oauthClient)).Should(Succeed())
1112+
1113+
By("Requesting deletion of the Notebook")
1114+
Expect(cli.Delete(ctx, notebook)).Should(Succeed())
1115+
1116+
By("Verifying OAuthClient still exists before finalizer processing")
1117+
oauthClientKey := types.NamespacedName{Name: oauthClientName}
1118+
Expect(cli.Get(ctx, oauthClientKey, oauthClient)).Should(Succeed())
1119+
1120+
By("Manually deleting OAuthClient to simulate controller behavior")
1121+
Expect(cli.Delete(ctx, oauthClient)).Should(Succeed())
1122+
1123+
By("Verifying OAuthClient is deleted")
1124+
Eventually(func() bool {
1125+
err := cli.Get(ctx, oauthClientKey, oauthClient)
1126+
return apierrors.IsNotFound(err)
1127+
}, duration, interval).Should(BeTrue(), "OAuthClient should be deleted")
1128+
})
1129+
1130+
It("Should remove the finalizer from the Notebook when OAuthClient has been deleted", func() {
1131+
By("Creating a Notebook with finalizer")
1132+
notebook.Finalizers = []string{Finalizer}
1133+
Expect(cli.Create(ctx, notebook)).Should(Succeed())
1134+
1135+
By("Creating an OAuthClient")
1136+
Expect(cli.Create(ctx, oauthClient)).Should(Succeed())
1137+
1138+
By("Requesting deletion of the Notebook")
1139+
Expect(cli.Delete(ctx, notebook)).Should(Succeed())
1140+
1141+
By("Deleting the OAuthClient")
1142+
Expect(cli.Delete(ctx, oauthClient)).Should(Succeed())
1143+
1144+
By("Removing the finalizer to simulate controller behavior")
1145+
key := types.NamespacedName{Name: Name, Namespace: Namespace}
1146+
Expect(cli.Get(ctx, key, notebook)).Should(Succeed())
1147+
1148+
// Remove finalizer
1149+
var updatedFinalizers []string
1150+
for _, f := range notebook.Finalizers {
1151+
if f != Finalizer {
1152+
updatedFinalizers = append(updatedFinalizers, f)
1153+
}
1154+
}
1155+
notebook.Finalizers = updatedFinalizers
1156+
Expect(cli.Update(ctx, notebook)).Should(Succeed())
1157+
1158+
By("Verifying Notebook is fully deleted")
1159+
Eventually(func() bool {
1160+
err := cli.Get(ctx, key, notebook)
1161+
return apierrors.IsNotFound(err)
1162+
}, duration, interval).Should(BeTrue(), "Notebook should be deleted after finalizer removal")
1163+
})
1164+
})
1165+
10171166
When("Creating notebook as part of Service Mesh", func() {
10181167

10191168
const (

components/odh-notebook-controller/controllers/notebook_oauth.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import (
3535
"k8s.io/apimachinery/pkg/types"
3636
ctrl "sigs.k8s.io/controller-runtime"
3737
"sigs.k8s.io/controller-runtime/pkg/client"
38+
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
3839
logf "sigs.k8s.io/controller-runtime/pkg/log"
3940
)
4041

@@ -303,6 +304,18 @@ func (r *OpenshiftNotebookReconciler) ReconcileOAuthClient(notebook *nbv1.Notebo
303304
return nil
304305
}
305306

307+
// Before handling the OAuth creation method, we should check if the Notebook parent object has
308+
// been marked for deletion.
309+
finalizationInProgress, err := r.HandleOAuthClientFinalizer(notebook, ctx)
310+
if err != nil {
311+
return err
312+
}
313+
314+
// If finalization is in progress, then stop the reconciler here, as OAuthClient do not need to be created
315+
if finalizationInProgress {
316+
return nil
317+
}
318+
306319
err = r.createOAuthClient(notebook, ctx)
307320
if err != nil {
308321
log.Error(err, "Unable to handle OAuthClient creation / update")
@@ -312,6 +325,64 @@ func (r *OpenshiftNotebookReconciler) ReconcileOAuthClient(notebook *nbv1.Notebo
312325
return nil
313326
}
314327

328+
func (r *OpenshiftNotebookReconciler) HandleOAuthClientFinalizer(notebook *nbv1.Notebook, ctx context.Context) (bool, error) {
329+
finalizerName := "notebooks.kubeflow.org/oauthclient"
330+
331+
// Check if object is being deleted
332+
if notebook.GetDeletionTimestamp() != nil {
333+
if controllerutil.ContainsFinalizer(notebook, finalizerName) {
334+
if err := r.cleanupOAuthClient(notebook, ctx); err != nil {
335+
return true, err
336+
}
337+
338+
controllerutil.RemoveFinalizer(notebook, finalizerName)
339+
if err := r.Update(ctx, notebook); err != nil {
340+
return true, err
341+
}
342+
}
343+
// Return true to indicate finalization is in progress
344+
return true, nil
345+
}
346+
347+
// Add finalizer if it doesn't exist yet
348+
if !controllerutil.ContainsFinalizer(notebook, finalizerName) {
349+
controllerutil.AddFinalizer(notebook, finalizerName)
350+
if err := r.Update(ctx, notebook); err != nil {
351+
return true, err
352+
}
353+
// Return true to indicate we're setting up finalization
354+
return true, nil
355+
}
356+
357+
// Return false to indicate normal reconciliation should proceed
358+
return false, nil
359+
}
360+
361+
// cleanupOAuthClient will delete an OAuthClient object in OpenShift based on the Notebook name. An important note is that
362+
// as OAuthClient objects are not namespaced objects - they are cluster objects - we need to search properly for them to do
363+
// a clean up, without overloading OpenShift resources.
364+
func (r *OpenshiftNotebookReconciler) cleanupOAuthClient(notebook *nbv1.Notebook, ctx context.Context) error {
365+
// Name of the OAuthClient based on the Notebook name
366+
oauthClientName := fmt.Sprintf("%s-%s-%s", notebook.Name, notebook.Namespace, "oauth-client")
367+
368+
oauthClient := &oauthv1.OAuthClient{}
369+
err := r.Get(ctx, types.NamespacedName{
370+
Name: oauthClientName,
371+
}, oauthClient)
372+
if err != nil {
373+
if apierrs.IsNotFound(err) {
374+
return nil
375+
}
376+
return err
377+
}
378+
379+
err = r.Delete(ctx, oauthClient)
380+
if err != nil {
381+
return err
382+
}
383+
return nil
384+
}
385+
315386
func (r *OpenshiftNotebookReconciler) createSecret(notebook *nbv1.Notebook, ctx context.Context, desiredSecret *corev1.Secret) error {
316387
log := logf.FromContext(ctx)
317388

components/odh-notebook-controller/controllers/suite_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import (
3838
nbv1 "github.com/kubeflow/kubeflow/components/notebook-controller/api/v1"
3939
dspav1 "github.com/opendatahub-io/data-science-pipelines-operator/api/v1"
4040
imagev1 "github.com/openshift/api/image/v1"
41+
oauthv1 "github.com/openshift/api/oauth/v1"
4142
routev1 "github.com/openshift/api/route/v1"
4243
v1 "k8s.io/api/core/v1"
4344
netv1 "k8s.io/api/networking/v1"
@@ -158,6 +159,7 @@ var _ = BeforeSuite(func() {
158159
utilruntime.Must(netv1.AddToScheme(scheme))
159160
utilruntime.Must(imagev1.AddToScheme(scheme))
160161
utilruntime.Must(dspav1.AddToScheme(scheme))
162+
utilruntime.Must(oauthv1.AddToScheme(scheme))
161163

162164
// +kubebuilder:scaffold:scheme
163165

0 commit comments

Comments
 (0)