Repository: crossplane-contrib/provider-gitlab
Pull request #213 (2025-07-03 15:12:00)

feat(group): Add option to treat pending deletion as deleted
### Description of your changes

<!--
Briefly describe what this pull request does. Be sure to direct your reviewers'
attention to anything that needs special consideration.

We love pull requests that resolve an open Crossplane issue. If yours does, you
can uncomment the below line to indicate which issue your PR fixes, for example
"Fixes #500":

-->

Adds a new property to specify if the controller should treat groups in pending deletion state as deleted.

Relates to https://github.com/crossplane-contrib/provider-gitlab/pull/212 and #207.

I have:

- [x] Read and followed Crossplane's [contribution process].
- [x] Run `make reviewable test` to ensure this PR is ready for review.

### How has this code been tested

Manually

[contribution process]: https://git.io/fj2m9



Patch:
diff --git a/apis/groups/v1alpha1/group_types.go b/apis/groups/v1alpha1/group_types.go
index b5c6265..beea4bd 100644
--- a/apis/groups/v1alpha1/group_types.go
+++ b/apis/groups/v1alpha1/group_types.go
@@ -164,6 +164,11 @@ type GroupParameters struct {
 	// +optional
 	PermanentlyRemove *bool `json:"permanentlyRemove,omitempty"`
 
+	// RemoveFinalizerOnPendingDeletion specifies wether the finalizer of this
+	// object should be removed in case the Kubernetes object and
+	// the external Gitlab group are marked for pending deletion.
+	RemoveFinalizerOnPendingDeletion *bool `json:"removeFinalizerOnPendingDeletion,omitempty"`
+
 	// Full path of group to delete permanently. Only required if PermanentlyRemove is set to true.
 	// GitLab Premium and Ultimate only.
 	// +optional
diff --git a/apis/groups/v1alpha1/zz_generated.deepcopy.go b/apis/groups/v1alpha1/zz_generated.deepcopy.go
index 7da1d18..8658eef 100644
--- a/apis/groups/v1alpha1/zz_generated.deepcopy.go
+++ b/apis/groups/v1alpha1/zz_generated.deepcopy.go
@@ -603,6 +603,11 @@ func (in *GroupParameters) DeepCopyInto(out *GroupParameters) {
 		*out = new(bool)
 		**out = **in
 	}
+	if in.RemoveFinalizerOnPendingDeletion != nil {
+		in, out := &in.RemoveFinalizerOnPendingDeletion, &out.RemoveFinalizerOnPendingDeletion
+		*out = new(bool)
+		**out = **in
+	}
 	if in.FullPathToRemove != nil {
 		in, out := &in.FullPathToRemove, &out.FullPathToRemove
 		*out = new(string)
diff --git a/apis/projects/v1alpha1/project_types.go b/apis/projects/v1alpha1/project_types.go
index def35aa..bee9afd 100644
--- a/apis/projects/v1alpha1/project_types.go
+++ b/apis/projects/v1alpha1/project_types.go
@@ -441,6 +441,11 @@ type ProjectParameters struct {
 	// just marked for deletion and removed permanently after seven days. Defaults to false.
 	// +optional
 	PermanentlyRemove *bool `json:"permanentlyRemove,omitempty"`
+
+	// RemoveFinalizerOnPendingDeletion specifies wether the finalizer of this
+	// object should be removed in case the Kubernetes object and
+	// the external Gitlab project are marked for pending deletion.
+	RemoveFinalizerOnPendingDeletion *bool `json:"removeFinalizerOnPendingDeletion,omitempty"`
 }
 
 type PushRules struct {
diff --git a/apis/projects/v1alpha1/zz_generated.deepcopy.go b/apis/projects/v1alpha1/zz_generated.deepcopy.go
index de8ecff..87ba7c7 100644
--- a/apis/projects/v1alpha1/zz_generated.deepcopy.go
+++ b/apis/projects/v1alpha1/zz_generated.deepcopy.go
@@ -1744,6 +1744,11 @@ func (in *ProjectParameters) DeepCopyInto(out *ProjectParameters) {
 		*out = new(bool)
 		**out = **in
 	}
+	if in.RemoveFinalizerOnPendingDeletion != nil {
+		in, out := &in.RemoveFinalizerOnPendingDeletion, &out.RemoveFinalizerOnPendingDeletion
+		*out = new(bool)
+		**out = **in
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectParameters.
diff --git a/package/crds/groups.gitlab.crossplane.io_groups.yaml b/package/crds/groups.gitlab.crossplane.io_groups.yaml
index dc7f1b4..38afa52 100644
--- a/package/crds/groups.gitlab.crossplane.io_groups.yaml
+++ b/package/crds/groups.gitlab.crossplane.io_groups.yaml
@@ -208,6 +208,12 @@ spec:
                       developers can create projects in the group.
                       Can be noone (No one), maintainer (Maintainers), or developer (Developers + Maintainers).
                     type: string
+                  removeFinalizerOnPendingDeletion:
+                    description: |-
+                      RemoveFinalizerOnPendingDeletion specifies wether the finalizer of this
+                      object should be removed in case the Kubernetes object and
+                      the external Gitlab group are marked for pending deletion.
+                    type: boolean
                   requestAccessEnabled:
                     description: Allow users to request member access.
                     type: boolean
diff --git a/package/crds/projects.gitlab.crossplane.io_projects.yaml b/package/crds/projects.gitlab.crossplane.io_projects.yaml
index ab23dd2..20f2bfc 100644
--- a/package/crds/projects.gitlab.crossplane.io_projects.yaml
+++ b/package/crds/projects.gitlab.crossplane.io_projects.yaml
@@ -391,6 +391,12 @@ spec:
                         description: Reject commit when it’s not signed.
                         type: boolean
                     type: object
+                  removeFinalizerOnPendingDeletion:
+                    description: |-
+                      RemoveFinalizerOnPendingDeletion specifies wether the finalizer of this
+                      object should be removed in case the Kubernetes object and
+                      the external Gitlab project are marked for pending deletion.
+                    type: boolean
                   removeSourceBranchAfterMerge:
                     description: Enable Delete source branch option by default for
                       all new merge requests.
diff --git a/pkg/controller/groups/groups/group.go b/pkg/controller/groups/groups/group.go
index 26bc0e5..fc6b52e 100644
--- a/pkg/controller/groups/groups/group.go
+++ b/pkg/controller/groups/groups/group.go
@@ -35,6 +35,7 @@ import (
 	"github.com/pkg/errors"
 	gitlab "gitlab.com/gitlab-org/api/client-go"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/utils/ptr"
 	ctrl "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
@@ -118,7 +119,7 @@ type external struct {
 	client groups.Client
 }
 
-func (e *external) Observe(ctx context.Context, mg resource.Managed) (managed.ExternalObservation, error) {
+func (e *external) Observe(ctx context.Context, mg resource.Managed) (managed.ExternalObservation, error) { //nolint:gocyclo
 	cr, ok := mg.(*v1alpha1.Group)
 	if !ok {
 		return managed.ExternalObservation{}, errors.New(errNotGroup)
@@ -145,6 +146,24 @@ func (e *external) Observe(ctx context.Context, mg resource.Managed) (managed.Ex
 		return managed.ExternalObservation{}, errors.Wrap(err, errGetFailed)
 	}
 
+	// Check if the group is in a pending deletion state and either remove the
+	// finalizer if specified or keep tracking it.
+	//
+	// Mark the resource as unavailable if the group is in a deletion state but
+	// managed resource is not.
+	if grp.MarkedForDeletionOn != nil {
+		if meta.WasDeleted(cr) {
+			if ptr.Deref(cr.Spec.ForProvider.RemoveFinalizerOnPendingDeletion, false) {
+				return managed.ExternalObservation{}, nil
+			}
+			cr.SetConditions(xpv1.Deleting().WithMessage("Group is in pending deletion state"))
+		} else {
+			cr.SetConditions(xpv1.Unavailable().WithMessage("Group is in pending deletion state but this managed resource is not"))
+		}
+	} else {
+		cr.Status.SetConditions(xpv1.Available())
+	}
+
 	current := cr.Spec.ForProvider.DeepCopy()
 
 	err = lateInitialize(&cr.Spec.ForProvider, grp)
@@ -154,7 +173,6 @@ func (e *external) Observe(ctx context.Context, mg resource.Managed) (managed.Ex
 	isResourceLateInitialized := !cmp.Equal(current, &cr.Spec.ForProvider)
 
 	cr.Status.AtProvider = groups.GenerateObservation(grp)
-	cr.Status.SetConditions(xpv1.Available())
 	isUpToDate, err := isGroupUpToDate(&cr.Spec.ForProvider, grp)
 	if err != nil {
 		return managed.ExternalObservation{}, errors.Wrap(err, errGetFailed)
diff --git a/pkg/controller/projects/projects/project.go b/pkg/controller/projects/projects/project.go
index f5044c7..162685f 100644
--- a/pkg/controller/projects/projects/project.go
+++ b/pkg/controller/projects/projects/project.go
@@ -34,6 +34,7 @@ import (
 	"github.com/google/go-cmp/cmp/cmpopts"
 	"github.com/pkg/errors"
 	gitlab "gitlab.com/gitlab-org/api/client-go"
+	"k8s.io/utils/ptr"
 	ctrl "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
@@ -122,7 +123,7 @@ type external struct {
 	}
 }
 
-func (e *external) Observe(ctx context.Context, mg resource.Managed) (managed.ExternalObservation, error) {
+func (e *external) Observe(ctx context.Context, mg resource.Managed) (managed.ExternalObservation, error) { //nolint:gocyclo
 	cr, ok := mg.(*v1alpha1.Project)
 	if !ok {
 		return managed.ExternalObservation{}, errors.New(errNotProject)
@@ -146,6 +147,24 @@ func (e *external) Observe(ctx context.Context, mg resource.Managed) (managed.Ex
 		return managed.ExternalObservation{}, errors.Wrap(err, errGetFailed)
 	}
 
+	// Check if the project is in a pending deletion state and either remove the
+	// finalizer if specified or keep tracking it.
+	//
+	// Mark the resource as unavailable if the project is in a deletion state but
+	// managed resource is not.
+	if prj.MarkedForDeletionOn != nil {
+		if meta.WasDeleted(cr) {
+			if ptr.Deref(cr.Spec.ForProvider.RemoveFinalizerOnPendingDeletion, false) {
+				return managed.ExternalObservation{}, nil
+			}
+			cr.SetConditions(xpv1.Deleting().WithMessage("Project is in pending deletion state"))
+		} else {
+			cr.SetConditions(xpv1.Unavailable().WithMessage("Project is in pending deletion state but this managed resource is not"))
+		}
+	} else {
+		cr.Status.SetConditions(xpv1.Available())
+	}
+
 	current := cr.Spec.ForProvider.DeepCopy()
 	if err := e.lateInitialize(ctx, cr, prj); err != nil {
 		return managed.ExternalObservation{}, errors.Wrap(err, errLateInitialize)
@@ -157,8 +176,6 @@ func (e *external) Observe(ctx context.Context, mg resource.Managed) (managed.Ex
 	}
 
 	cr.Status.AtProvider = projects.GenerateObservation(prj)
-	cr.Status.SetConditions(xpv1.Available())
-
 	return managed.ExternalObservation{
 		ResourceExists:          true,
 		ResourceUpToDate:        isProjectUpToDate(&cr.Spec.ForProvider, prj) && e.cache.isPushRulesUpToDate,


Test patch:
diff --git a/pkg/controller/groups/groups/group_test.go b/pkg/controller/groups/groups/group_test.go
index c20f967..542034b 100644
--- a/pkg/controller/groups/groups/group_test.go
+++ b/pkg/controller/groups/groups/group_test.go
@@ -32,6 +32,7 @@ import (
 	"github.com/pkg/errors"
 	gitlab "gitlab.com/gitlab-org/api/client-go"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/utils/ptr"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
 	"github.com/crossplane-contrib/provider-gitlab/apis/groups/v1alpha1"
@@ -55,6 +56,8 @@ var (
 	visibility         = "private"
 	v1alpha1Visibility = v1alpha1.VisibilityValue(visibility)
 
+	timeNow = time.Now()
+
 	projectCreationLevel         = "developer"
 	v1alpha1ProjectCreationLevel = v1alpha1.ProjectCreationLevelValue(projectCreationLevel)
 
@@ -443,6 +446,44 @@ func TestObserve(t *testing.T) {
 				},
 			},
 		},
+		"DeleteOnPendingDeletion": {
+			args: args{
+				kube: &test.MockClient{
+					MockUpdate: test.NewMockUpdateFn(nil),
+				},
+				group: &fake.MockClient{
+					MockGetGroup: func(pid interface{}, options ...gitlab.RequestOptionFunc) (*gitlab.Group, *gitlab.Response, error) {
+						return &gitlab.Group{
+							Name:                name,
+							MarkedForDeletionOn: &gitlab.ISOTime{},
+						}, &gitlab.Response{}, nil
+					},
+				},
+				cr: group(
+					withClientDefaultValues(),
+					func(p *v1alpha1.Group) {
+						p.DeletionTimestamp = &metav1.Time{Time: timeNow}
+						p.Spec.ForProvider.RemoveFinalizerOnPendingDeletion = ptr.To(true)
+					},
+					withExternalName(extName),
+				),
+			},
+			want: want{
+				cr: group(
+					withClientDefaultValues(),
+					withExternalName(extName),
+					func(p *v1alpha1.Group) {
+						p.DeletionTimestamp = &metav1.Time{Time: timeNow}
+						p.Spec.ForProvider.RemoveFinalizerOnPendingDeletion = ptr.To(true)
+					},
+				),
+				result: managed.ExternalObservation{
+					ResourceExists:          false,
+					ResourceUpToDate:        false,
+					ResourceLateInitialized: false,
+				},
+			},
+		},
 	}
 
 	isGroupUpToDateCases := map[string]interface{}{
diff --git a/pkg/controller/projects/projects/project_test.go b/pkg/controller/projects/projects/project_test.go
index 240bc43..e2ea98d 100644
--- a/pkg/controller/projects/projects/project_test.go
+++ b/pkg/controller/projects/projects/project_test.go
@@ -22,6 +22,7 @@ import (
 	"reflect"
 	"strconv"
 	"testing"
+	"time"
 
 	xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
 	"github.com/crossplane/crossplane-runtime/pkg/meta"
@@ -31,6 +32,7 @@ import (
 	"github.com/google/go-cmp/cmp"
 	"github.com/pkg/errors"
 	gitlab "gitlab.com/gitlab-org/api/client-go"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/utils/ptr"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
@@ -46,6 +48,7 @@ var (
 	projectID         = 1234
 	extName           = strconv.Itoa(projectID)
 	extNameAnnotation = map[string]string{meta.AnnotationKeyExternalName: extName}
+	timeNow           = time.Now()
 )
 
 type args struct {
@@ -432,6 +435,76 @@ func TestObserve(t *testing.T) {
 				},
 			},
 		},
+		"DeleteOnPendingDeletion": {
+			args: args{
+				kube: &test.MockClient{
+					MockUpdate: test.NewMockUpdateFn(nil),
+				},
+				project: &fake.MockClient{
+					MockGetProject: func(pid interface{}, opt *gitlab.GetProjectOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Project, *gitlab.Response, error) {
+						return &gitlab.Project{
+							MarkedForDeletionOn: &gitlab.ISOTime{},
+						}, &gitlab.Response{}, nil
+					},
+					MockGetProjectPushRules: func(pid interface{}, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectPushRules, *gitlab.Response, error) {
+						return &gitlab.ProjectPushRules{}, nil, nil
+					},
+				},
+				cr: project(
+					withClientDefaultValues(),
+					func(p *v1alpha1.Project) {
+						p.DeletionTimestamp = &v1.Time{Time: timeNow}
+						p.Spec.ForProvider.RemoveFinalizerOnPendingDeletion = ptr.To(true)
+					},
+					withExternalName(extName),
+					withProjectPushRules(&v1alpha1.PushRules{
+						AuthorEmailRegex:           ptr.To(""),
+						BranchNameRegex:            ptr.To(""),
+						CommitCommitterCheck:       ptr.To(false),
+						CommitCommitterNameCheck:   ptr.To(false),
+						CommitMessageNegativeRegex: ptr.To(""),
+						CommitMessageRegex:         ptr.To(""),
+						DenyDeleteTag:              ptr.To(false),
+						FileNameRegex:              ptr.To(""),
+						MaxFileSize:                ptr.To(0),
+						MemberCheck:                ptr.To(false),
+						PreventSecrets:             ptr.To(false),
+						RejectUnsignedCommits:      ptr.To(false),
+						RejectNonDCOCommits:        ptr.To(false),
+					}),
+				),
+			},
+			want: want{
+				cr: project(
+					withClientDefaultValues(),
+					withExternalName(extName),
+					withProjectPushRules(&v1alpha1.PushRules{
+						AuthorEmailRegex:           ptr.To(""),
+						BranchNameRegex:            ptr.To(""),
+						CommitCommitterCheck:       ptr.To(false),
+						CommitCommitterNameCheck:   ptr.To(false),
+						CommitMessageNegativeRegex: ptr.To(""),
+						CommitMessageRegex:         ptr.To(""),
+						DenyDeleteTag:              ptr.To(false),
+						FileNameRegex:              ptr.To(""),
+						MaxFileSize:                ptr.To(0),
+						MemberCheck:                ptr.To(false),
+						PreventSecrets:             ptr.To(false),
+						RejectUnsignedCommits:      ptr.To(false),
+						RejectNonDCOCommits:        ptr.To(false),
+					}),
+					func(p *v1alpha1.Project) {
+						p.DeletionTimestamp = &v1.Time{Time: timeNow}
+						p.Spec.ForProvider.RemoveFinalizerOnPendingDeletion = ptr.To(true)
+					},
+				),
+				result: managed.ExternalObservation{
+					ResourceExists:          false,
+					ResourceUpToDate:        false,
+					ResourceLateInitialized: false,
+				},
+			},
+		},
 	}
 
 	isProjectUpToDateCases := map[string]interface{}{