Repository: crossplane-contrib/provider-gitlab
Pull request #104 (2023-08-23 14:30:38)

feat(group): Add support for group variables
<!--
Thank you for helping to improve Crossplane!

Please read through https://git.io/fj2m9 if this is your first time opening a
Crossplane pull request. Find us in https://slack.crossplane.io/messages/dev if
you need any help contributing.
-->

### Description of your changes

<!--
Briefly describe what this pull request does. Be sure to direct your reviewers'
attention to anything that needs special consideration.

-->
Issue #91 : created groupVariable controller

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

unit testing, make reviewable test PASS

[contribution process]: https://git.io/fj2m9


MisterMX: Can you separate the changes from this and #103? It is hard to see what changes belong in which PR.

Patch:
diff --git a/apis/groups/v1alpha1/referencers.go b/apis/groups/v1alpha1/referencers.go
index 6d4ca74..f2179b2 100644
--- a/apis/groups/v1alpha1/referencers.go
+++ b/apis/groups/v1alpha1/referencers.go
@@ -47,6 +47,29 @@ func toPtrValue(v string) *int {
 	return &r
 }
 
+// ResolveReferences of this Variable
+func (mg *Variable) ResolveReferences(ctx context.Context, c client.Reader) error {
+	r := reference.NewAPIResolver(c, mg)
+
+	// resolve spec.forProvider.projectIdRef
+	rsp, err := r.Resolve(ctx, reference.ResolutionRequest{
+		CurrentValue: fromPtrValue(mg.Spec.ForProvider.GroupID),
+		Reference:    mg.Spec.ForProvider.GroupIDRef,
+		Selector:     mg.Spec.ForProvider.GroupIDSelector,
+		To:           reference.To{Managed: &Group{}, List: &GroupList{}},
+		Extract:      reference.ExternalName(),
+	})
+
+	if err != nil {
+		return errors.Wrap(err, "spec.forProvider.groupId")
+	}
+
+	mg.Spec.ForProvider.GroupID = toPtrValue(rsp.ResolvedValue)
+	mg.Spec.ForProvider.GroupIDRef = rsp.ResolvedReference
+
+	return nil
+}
+
 // ResolveReferences of this Member
 func (mg *Member) ResolveReferences(ctx context.Context, c client.Reader) error {
 	r := reference.NewAPIResolver(c, mg)
diff --git a/apis/groups/v1alpha1/register.go b/apis/groups/v1alpha1/register.go
index 6bc42bd..c785ce1 100644
--- a/apis/groups/v1alpha1/register.go
+++ b/apis/groups/v1alpha1/register.go
@@ -61,8 +61,17 @@ var (
 	DeployTokenGroupVersionKind = SchemeGroupVersion.WithKind(DeployTokenKind)
 )
 
+// Variable type metadata
+var (
+	VariableKind             = reflect.TypeOf(Variable{}).Name()
+	VariableGroupKind        = schema.GroupKind{Group: KubernetesGroup, Kind: VariableKind}.String()
+	VariableKindAPIVersion   = VariableKind + "." + SchemeGroupVersion.String()
+	VariableGroupVersionKind = SchemeGroupVersion.WithKind(VariableKind)
+)
+
 func init() {
 	SchemeBuilder.Register(&Group{}, &GroupList{})
 	SchemeBuilder.Register(&Member{}, &MemberList{})
 	SchemeBuilder.Register(&DeployToken{}, &DeployTokenList{})
+	SchemeBuilder.Register(&Variable{}, &VariableList{})
 }
diff --git a/apis/groups/v1alpha1/variable_types.go b/apis/groups/v1alpha1/variable_types.go
new file mode 100644
index 0000000..ee401bd
--- /dev/null
+++ b/apis/groups/v1alpha1/variable_types.go
@@ -0,0 +1,127 @@
+/*
+Copyright 2021 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v1alpha1
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
+)
+
+// VariableType indicates the type of the GitLab CI variable.
+type VariableType string
+
+// List of variable type values.
+//
+// GitLab API docs: https://docs.gitlab.com/ee/api/group_level_variables.html
+const (
+	VariableTypeEnvVar VariableType = "env_var"
+	VariableTypeFile   VariableType = "file"
+)
+
+// VariableParameters define the desired state of a Gitlab CI Variable
+// https://docs.gitlab.com/ee/api/group_level_variables.html
+type VariableParameters struct {
+	// GroupID is the ID of the group to create the variable on.
+	// +optional
+	// +immutable
+	GroupID *int `json:"groupId,omitempty"`
+
+	// GroupIDRef is a reference to a group to retrieve its groupId.
+	// +optional
+	// +immutable
+	GroupIDRef *xpv1.Reference `json:"groupIdRef,omitempty"`
+
+	// GroupIDSelector selects reference to a group to retrieve its groupId.
+	// +optional
+	GroupIDSelector *xpv1.Selector `json:"groupIdSelector,omitempty"`
+
+	// Key of a variable.
+	// +kubebuilder:validation:Pattern:=^[a-zA-Z0-9\_]+$
+	// +kubebuilder:validation:MaxLength:=255
+	// +immutable
+	Key string `json:"key"`
+
+	// Value of a variable. Mutually exclusive with ValueSecretRef.
+	// +optional
+	Value *string `json:"value,omitempty"`
+
+	// ValueSecretRef is used to obtain the value from a secret. This will set Masked and Raw to true if they
+	// have not been set implicitly. Mutually exclusive with Value.
+	// +optional
+	// +nullable
+	ValueSecretRef *xpv1.SecretKeySelector `json:"valueSecretRef,omitempty"`
+
+	// Masked enables or disables variable masking.
+	// +optional
+	Masked *bool `json:"masked,omitempty"`
+
+	// Protected enables or disables variable protection.
+	// +optional
+	Protected *bool `json:"protected,omitempty"`
+
+	// Raw disables variable expansion of the variable.
+	// +optional
+	Raw *bool `json:"raw,omitempty"`
+
+	// VariableType is the type of a variable.
+	// +kubebuilder:validation:Enum:=env_var;file
+	// +optional
+	VariableType *VariableType `json:"variableType,omitempty"`
+
+	// EnvironmentScope indicates the environment scope of a variable.
+	// +optional
+	EnvironmentScope *string `json:"environmentScope,omitempty"`
+}
+
+// A VariableSpec defines the desired state of a Gitlab Group CI
+// Variable.
+type VariableSpec struct {
+	xpv1.ResourceSpec `json:",inline"`
+	ForProvider       VariableParameters `json:"forProvider"`
+}
+
+// A VariableStatus represents the observed state of a Gitlab Group CI
+// Variable.
+type VariableStatus struct {
+	xpv1.ResourceStatus `json:",inline"`
+}
+
+// +kubebuilder:object:root=true
+
+// A Variable is a managed resource that represents a Gitlab CI variable.
+// +kubebuilder:printcolumn:name="READY",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status"
+// +kubebuilder:printcolumn:name="SYNCED",type="string",JSONPath=".status.conditions[?(@.type=='Synced')].status"
+// +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp"
+// +kubebuilder:subresource:status
+// +kubebuilder:resource:scope=Cluster,categories={crossplane,managed,gitlab}
+type Variable struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec   VariableSpec   `json:"spec"`
+	Status VariableStatus `json:"status,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+
+// VariableList contains a list of Variable items.
+type VariableList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []Variable `json:"items"`
+}
diff --git a/apis/groups/v1alpha1/zz_generated.deepcopy.go b/apis/groups/v1alpha1/zz_generated.deepcopy.go
index 534dc31..6d7ee20 100644
--- a/apis/groups/v1alpha1/zz_generated.deepcopy.go
+++ b/apis/groups/v1alpha1/zz_generated.deepcopy.go
@@ -748,3 +748,160 @@ func (in *StorageStatistics) DeepCopy() *StorageStatistics {
 	in.DeepCopyInto(out)
 	return out
 }
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *Variable) DeepCopyInto(out *Variable) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	in.Spec.DeepCopyInto(&out.Spec)
+	in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Variable.
+func (in *Variable) DeepCopy() *Variable {
+	if in == nil {
+		return nil
+	}
+	out := new(Variable)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *Variable) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *VariableList) DeepCopyInto(out *VariableList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]Variable, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VariableList.
+func (in *VariableList) DeepCopy() *VariableList {
+	if in == nil {
+		return nil
+	}
+	out := new(VariableList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *VariableList) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *VariableParameters) DeepCopyInto(out *VariableParameters) {
+	*out = *in
+	if in.GroupID != nil {
+		in, out := &in.GroupID, &out.GroupID
+		*out = new(int)
+		**out = **in
+	}
+	if in.GroupIDRef != nil {
+		in, out := &in.GroupIDRef, &out.GroupIDRef
+		*out = new(v1.Reference)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.GroupIDSelector != nil {
+		in, out := &in.GroupIDSelector, &out.GroupIDSelector
+		*out = new(v1.Selector)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.Value != nil {
+		in, out := &in.Value, &out.Value
+		*out = new(string)
+		**out = **in
+	}
+	if in.ValueSecretRef != nil {
+		in, out := &in.ValueSecretRef, &out.ValueSecretRef
+		*out = new(v1.SecretKeySelector)
+		**out = **in
+	}
+	if in.Masked != nil {
+		in, out := &in.Masked, &out.Masked
+		*out = new(bool)
+		**out = **in
+	}
+	if in.Protected != nil {
+		in, out := &in.Protected, &out.Protected
+		*out = new(bool)
+		**out = **in
+	}
+	if in.Raw != nil {
+		in, out := &in.Raw, &out.Raw
+		*out = new(bool)
+		**out = **in
+	}
+	if in.VariableType != nil {
+		in, out := &in.VariableType, &out.VariableType
+		*out = new(VariableType)
+		**out = **in
+	}
+	if in.EnvironmentScope != nil {
+		in, out := &in.EnvironmentScope, &out.EnvironmentScope
+		*out = new(string)
+		**out = **in
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VariableParameters.
+func (in *VariableParameters) DeepCopy() *VariableParameters {
+	if in == nil {
+		return nil
+	}
+	out := new(VariableParameters)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *VariableSpec) DeepCopyInto(out *VariableSpec) {
+	*out = *in
+	in.ResourceSpec.DeepCopyInto(&out.ResourceSpec)
+	in.ForProvider.DeepCopyInto(&out.ForProvider)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VariableSpec.
+func (in *VariableSpec) DeepCopy() *VariableSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(VariableSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *VariableStatus) DeepCopyInto(out *VariableStatus) {
+	*out = *in
+	in.ResourceStatus.DeepCopyInto(&out.ResourceStatus)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VariableStatus.
+func (in *VariableStatus) DeepCopy() *VariableStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(VariableStatus)
+	in.DeepCopyInto(out)
+	return out
+}
diff --git a/apis/groups/v1alpha1/zz_generated.managed.go b/apis/groups/v1alpha1/zz_generated.managed.go
index 4091319..5f4726b 100644
--- a/apis/groups/v1alpha1/zz_generated.managed.go
+++ b/apis/groups/v1alpha1/zz_generated.managed.go
@@ -217,3 +217,69 @@ func (mg *Member) SetPublishConnectionDetailsTo(r *xpv1.PublishConnectionDetails
 func (mg *Member) SetWriteConnectionSecretToReference(r *xpv1.SecretReference) {
 	mg.Spec.WriteConnectionSecretToReference = r
 }
+
+// GetCondition of this Variable.
+func (mg *Variable) GetCondition(ct xpv1.ConditionType) xpv1.Condition {
+	return mg.Status.GetCondition(ct)
+}
+
+// GetDeletionPolicy of this Variable.
+func (mg *Variable) GetDeletionPolicy() xpv1.DeletionPolicy {
+	return mg.Spec.DeletionPolicy
+}
+
+// GetProviderConfigReference of this Variable.
+func (mg *Variable) GetProviderConfigReference() *xpv1.Reference {
+	return mg.Spec.ProviderConfigReference
+}
+
+/*
+GetProviderReference of this Variable.
+Deprecated: Use GetProviderConfigReference.
+*/
+func (mg *Variable) GetProviderReference() *xpv1.Reference {
+	return mg.Spec.ProviderReference
+}
+
+// GetPublishConnectionDetailsTo of this Variable.
+func (mg *Variable) GetPublishConnectionDetailsTo() *xpv1.PublishConnectionDetailsTo {
+	return mg.Spec.PublishConnectionDetailsTo
+}
+
+// GetWriteConnectionSecretToReference of this Variable.
+func (mg *Variable) GetWriteConnectionSecretToReference() *xpv1.SecretReference {
+	return mg.Spec.WriteConnectionSecretToReference
+}
+
+// SetConditions of this Variable.
+func (mg *Variable) SetConditions(c ...xpv1.Condition) {
+	mg.Status.SetConditions(c...)
+}
+
+// SetDeletionPolicy of this Variable.
+func (mg *Variable) SetDeletionPolicy(r xpv1.DeletionPolicy) {
+	mg.Spec.DeletionPolicy = r
+}
+
+// SetProviderConfigReference of this Variable.
+func (mg *Variable) SetProviderConfigReference(r *xpv1.Reference) {
+	mg.Spec.ProviderConfigReference = r
+}
+
+/*
+SetProviderReference of this Variable.
+Deprecated: Use SetProviderConfigReference.
+*/
+func (mg *Variable) SetProviderReference(r *xpv1.Reference) {
+	mg.Spec.ProviderReference = r
+}
+
+// SetPublishConnectionDetailsTo of this Variable.
+func (mg *Variable) SetPublishConnectionDetailsTo(r *xpv1.PublishConnectionDetailsTo) {
+	mg.Spec.PublishConnectionDetailsTo = r
+}
+
+// SetWriteConnectionSecretToReference of this Variable.
+func (mg *Variable) SetWriteConnectionSecretToReference(r *xpv1.SecretReference) {
+	mg.Spec.WriteConnectionSecretToReference = r
+}
diff --git a/apis/groups/v1alpha1/zz_generated.managedlist.go b/apis/groups/v1alpha1/zz_generated.managedlist.go
index ced895f..5d6da2d 100644
--- a/apis/groups/v1alpha1/zz_generated.managedlist.go
+++ b/apis/groups/v1alpha1/zz_generated.managedlist.go
@@ -46,3 +46,12 @@ func (l *MemberList) GetItems() []resource.Managed {
 	}
 	return items
 }
+
+// GetItems of this VariableList.
+func (l *VariableList) GetItems() []resource.Managed {
+	items := make([]resource.Managed, len(l.Items))
+	for i := range l.Items {
+		items[i] = &l.Items[i]
+	}
+	return items
+}
diff --git a/apis/projects/v1alpha1/register.go b/apis/projects/v1alpha1/register.go
index ff4335c..4a8cbc1 100644
--- a/apis/projects/v1alpha1/register.go
+++ b/apis/projects/v1alpha1/register.go
@@ -69,12 +69,12 @@ var (
 	DeployTokenGroupVersionKind = SchemeGroupVersion.WithKind(DeployTokenKind)
 )
 
-// Deploy Token type metadata
+// Access Token type metadata
 var (
-	AccessTokenKind             = reflect.TypeOf(DeployToken{}).Name()
-	AccessTokenGroupKind        = schema.GroupKind{Group: Group, Kind: DeployTokenKind}.String()
-	AccessTokenKindAPIVersion   = DeployTokenKind + "." + SchemeGroupVersion.String()
-	AccessTokenGroupVersionKind = SchemeGroupVersion.WithKind(DeployTokenKind)
+	AccessTokenKind             = reflect.TypeOf(AccessToken{}).Name()
+	AccessTokenGroupKind        = schema.GroupKind{Group: Group, Kind: AccessTokenKind}.String()
+	AccessTokenKindAPIVersion   = AccessTokenKind + "." + SchemeGroupVersion.String()
+	AccessTokenGroupVersionKind = SchemeGroupVersion.WithKind(AccessTokenKind)
 )
 
 // Variable type metadata
@@ -108,5 +108,6 @@ func init() {
 	SchemeBuilder.Register(&DeployToken{}, &DeployTokenList{})
 	SchemeBuilder.Register(&Variable{}, &VariableList{})
 	SchemeBuilder.Register(&DeployKey{}, &DeployKeyList{})
+	SchemeBuilder.Register(&AccessToken{}, &AccessTokenList{})
 	SchemeBuilder.Register(&PipelineSchedule{}, &PipelineScheduleList{})
 }
diff --git a/examples/groups/groupvariable.yaml b/examples/groups/groupvariable.yaml
new file mode 100644
index 0000000..2b09a02
--- /dev/null
+++ b/examples/groups/groupvariable.yaml
@@ -0,0 +1,13 @@
+apiVersion: groups.gitlab.crossplane.io/v1alpha1
+kind: Variable
+metadata:
+  name: deploy-arn
+spec:
+  forProvider:
+    groupIdRef:
+      name: example-group
+    variableType: file
+    key: AWS_ROLE_ARN
+    value: arn:aws:iam::999999999:role/my-deploy-role
+  providerConfigRef:
+    name: gitlab-provider
diff --git a/package/crds/groups.gitlab.crossplane.io_variables.yaml b/package/crds/groups.gitlab.crossplane.io_variables.yaml
new file mode 100644
index 0000000..a7b9c5a
--- /dev/null
+++ b/package/crds/groups.gitlab.crossplane.io_variables.yaml
@@ -0,0 +1,411 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.11.1
+  creationTimestamp: null
+  name: variables.groups.gitlab.crossplane.io
+spec:
+  group: groups.gitlab.crossplane.io
+  names:
+    categories:
+    - crossplane
+    - managed
+    - gitlab
+    kind: Variable
+    listKind: VariableList
+    plural: variables
+    singular: variable
+  scope: Cluster
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .status.conditions[?(@.type=='Ready')].status
+      name: READY
+      type: string
+    - jsonPath: .status.conditions[?(@.type=='Synced')].status
+      name: SYNCED
+      type: string
+    - jsonPath: .metadata.creationTimestamp
+      name: AGE
+      type: date
+    name: v1alpha1
+    schema:
+      openAPIV3Schema:
+        description: A Variable is a managed resource that represents a Gitlab CI
+          variable.
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: A VariableSpec defines the desired state of a Gitlab Group
+              CI Variable.
+            properties:
+              deletionPolicy:
+                default: Delete
+                description: DeletionPolicy specifies what will happen to the underlying
+                  external when this managed resource is deleted - either "Delete"
+                  or "Orphan" the external resource.
+                enum:
+                - Orphan
+                - Delete
+                type: string
+              forProvider:
+                description: VariableParameters define the desired state of a Gitlab
+                  CI Variable https://docs.gitlab.com/ee/api/group_level_variables.html
+                properties:
+                  environmentScope:
+                    description: EnvironmentScope indicates the environment scope
+                      of a variable.
+                    type: string
+                  groupId:
+                    description: GroupID is the ID of the group to create the variable
+                      on.
+                    type: integer
+                  groupIdRef:
+                    description: GroupIDRef is a reference to a group to retrieve
+                      its groupId.
+                    properties:
+                      name:
+                        description: Name of the referenced object.
+                        type: string
+                      policy:
+                        description: Policies for referencing.
+                        properties:
+                          resolution:
+                            default: Required
+                            description: Resolution specifies whether resolution of
+                              this reference is required. The default is 'Required',
+                              which means the reconcile will fail if the reference
+                              cannot be resolved. 'Optional' means this reference
+                              will be a no-op if it cannot be resolved.
+                            enum:
+                            - Required
+                            - Optional
+                            type: string
+                          resolve:
+                            description: Resolve specifies when this reference should
+                              be resolved. The default is 'IfNotPresent', which will
+                              attempt to resolve the reference only when the corresponding
+                              field is not present. Use 'Always' to resolve the reference
+                              on every reconcile.
+                            enum:
+                            - Always
+                            - IfNotPresent
+                            type: string
+                        type: object
+                    required:
+                    - name
+                    type: object
+                  groupIdSelector:
+                    description: GroupIDSelector selects reference to a group to retrieve
+                      its groupId.
+                    properties:
+                      matchControllerRef:
+                        description: MatchControllerRef ensures an object with the
+                          same controller reference as the selecting object is selected.
+                        type: boolean
+                      matchLabels:
+                        additionalProperties:
+                          type: string
+                        description: MatchLabels ensures an object with matching labels
+                          is selected.
+                        type: object
+                      policy:
+                        description: Policies for selection.
+                        properties:
+                          resolution:
+                            default: Required
+                            description: Resolution specifies whether resolution of
+                              this reference is required. The default is 'Required',
+                              which means the reconcile will fail if the reference
+                              cannot be resolved. 'Optional' means this reference
+                              will be a no-op if it cannot be resolved.
+                            enum:
+                            - Required
+                            - Optional
+                            type: string
+                          resolve:
+                            description: Resolve specifies when this reference should
+                              be resolved. The default is 'IfNotPresent', which will
+                              attempt to resolve the reference only when the corresponding
+                              field is not present. Use 'Always' to resolve the reference
+                              on every reconcile.
+                            enum:
+                            - Always
+                            - IfNotPresent
+                            type: string
+                        type: object
+                    type: object
+                  key:
+                    description: Key of a variable.
+                    maxLength: 255
+                    pattern: ^[a-zA-Z0-9\_]+$
+                    type: string
+                  masked:
+                    description: Masked enables or disables variable masking.
+                    type: boolean
+                  protected:
+                    description: Protected enables or disables variable protection.
+                    type: boolean
+                  raw:
+                    description: Raw disables variable expansion of the variable.
+                    type: boolean
+                  value:
+                    description: Value of a variable. Mutually exclusive with ValueSecretRef.
+                    type: string
+                  valueSecretRef:
+                    description: ValueSecretRef is used to obtain the value from a
+                      secret. This will set Masked and Raw to true if they have not
+                      been set implicitly. Mutually exclusive with Value.
+                    nullable: true
+                    properties:
+                      key:
+                        description: The key to select.
+                        type: string
+                      name:
+                        description: Name of the secret.
+                        type: string
+                      namespace:
+                        description: Namespace of the secret.
+                        type: string
+                    required:
+                    - key
+                    - name
+                    - namespace
+                    type: object
+                  variableType:
+                    description: VariableType is the type of a variable.
+                    enum:
+                    - env_var
+                    - file
+                    type: string
+                required:
+                - key
+                type: object
+              providerConfigRef:
+                default:
+                  name: default
+                description: ProviderConfigReference specifies how the provider that
+                  will be used to create, observe, update, and delete this managed
+                  resource should be configured.
+                properties:
+                  name:
+                    description: Name of the referenced object.
+                    type: string
+                  policy:
+                    description: Policies for referencing.
+                    properties:
+                      resolution:
+                        default: Required
+                        description: Resolution specifies whether resolution of this
+                          reference is required. The default is 'Required', which
+                          means the reconcile will fail if the reference cannot be
+                          resolved. 'Optional' means this reference will be a no-op
+                          if it cannot be resolved.
+                        enum:
+                        - Required
+                        - Optional
+                        type: string
+                      resolve:
+                        description: Resolve specifies when this reference should
+                          be resolved. The default is 'IfNotPresent', which will attempt
+                          to resolve the reference only when the corresponding field
+                          is not present. Use 'Always' to resolve the reference on
+                          every reconcile.
+                        enum:
+                        - Always
+                        - IfNotPresent
+                        type: string
+                    type: object
+                required:
+                - name
+                type: object
+              providerRef:
+                description: 'ProviderReference specifies the provider that will be
+                  used to create, observe, update, and delete this managed resource.
+                  Deprecated: Please use ProviderConfigReference, i.e. `providerConfigRef`'
+                properties:
+                  name:
+                    description: Name of the referenced object.
+                    type: string
+                  policy:
+                    description: Policies for referencing.
+                    properties:
+                      resolution:
+                        default: Required
+                        description: Resolution specifies whether resolution of this
+                          reference is required. The default is 'Required', which
+                          means the reconcile will fail if the reference cannot be
+                          resolved. 'Optional' means this reference will be a no-op
+                          if it cannot be resolved.
+                        enum:
+                        - Required
+                        - Optional
+                        type: string
+                      resolve:
+                        description: Resolve specifies when this reference should
+                          be resolved. The default is 'IfNotPresent', which will attempt
+                          to resolve the reference only when the corresponding field
+                          is not present. Use 'Always' to resolve the reference on
+                          every reconcile.
+                        enum:
+                        - Always
+                        - IfNotPresent
+                        type: string
+                    type: object
+                required:
+                - name
+                type: object
+              publishConnectionDetailsTo:
+                description: PublishConnectionDetailsTo specifies the connection secret
+                  config which contains a name, metadata and a reference to secret
+                  store config to which any connection details for this managed resource
+                  should be written. Connection details frequently include the endpoint,
+                  username, and password required to connect to the managed resource.
+                properties:
+                  configRef:
+                    default:
+                      name: default
+                    description: SecretStoreConfigRef specifies which secret store
+                      config should be used for this ConnectionSecret.
+                    properties:
+                      name:
+                        description: Name of the referenced object.
+                        type: string
+                      policy:
+                        description: Policies for referencing.
+                        properties:
+                          resolution:
+                            default: Required
+                            description: Resolution specifies whether resolution of
+                              this reference is required. The default is 'Required',
+                              which means the reconcile will fail if the reference
+                              cannot be resolved. 'Optional' means this reference
+                              will be a no-op if it cannot be resolved.
+                            enum:
+                            - Required
+                            - Optional
+                            type: string
+                          resolve:
+                            description: Resolve specifies when this reference should
+                              be resolved. The default is 'IfNotPresent', which will
+                              attempt to resolve the reference only when the corresponding
+                              field is not present. Use 'Always' to resolve the reference
+                              on every reconcile.
+                            enum:
+                            - Always
+                            - IfNotPresent
+                            type: string
+                        type: object
+                    required:
+                    - name
+                    type: object
+                  metadata:
+                    description: Metadata is the metadata for connection secret.
+                    properties:
+                      annotations:
+                        additionalProperties:
+                          type: string
+                        description: Annotations are the annotations to be added to
+                          connection secret. - For Kubernetes secrets, this will be
+                          used as "metadata.annotations". - It is up to Secret Store
+                          implementation for others store types.
+                        type: object
+                      labels:
+                        additionalProperties:
+                          type: string
+                        description: Labels are the labels/tags to be added to connection
+                          secret. - For Kubernetes secrets, this will be used as "metadata.labels".
+                          - It is up to Secret Store implementation for others store
+                          types.
+                        type: object
+                      type:
+                        description: Type is the SecretType for the connection secret.
+                          - Only valid for Kubernetes Secret Stores.
+                        type: string
+                    type: object
+                  name:
+                    description: Name is the name of the connection secret.
+                    type: string
+                required:
+                - name
+                type: object
+              writeConnectionSecretToRef:
+                description: WriteConnectionSecretToReference specifies the namespace
+                  and name of a Secret to which any connection details for this managed
+                  resource should be written. Connection details frequently include
+                  the endpoint, username, and password required to connect to the
+                  managed resource. This field is planned to be replaced in a future
+                  release in favor of PublishConnectionDetailsTo. Currently, both
+                  could be set independently and connection details would be published
+                  to both without affecting each other.
+                properties:
+                  name:
+                    description: Name of the secret.
+                    type: string
+                  namespace:
+                    description: Namespace of the secret.
+                    type: string
+                required:
+                - name
+                - namespace
+                type: object
+            required:
+            - forProvider
+            type: object
+          status:
+            description: A VariableStatus represents the observed state of a Gitlab
+              Group CI Variable.
+            properties:
+              conditions:
+                description: Conditions of the resource.
+                items:
+                  description: A Condition that may apply to a resource.
+                  properties:
+                    lastTransitionTime:
+                      description: LastTransitionTime is the last time this condition
+                        transitioned from one status to another.
+                      format: date-time
+                      type: string
+                    message:
+                      description: A Message containing details about this condition's
+                        last transition from one status to another, if any.
+                      type: string
+                    reason:
+                      description: A Reason for this condition's last transition from
+                        one status to another.
+                      type: string
+                    status:
+                      description: Status of this condition; is it currently True,
+                        False, or Unknown?
+                      type: string
+                    type:
+                      description: Type of this condition. At most one of each condition
+                        type may apply to a resource at any point in time.
+                      type: string
+                  required:
+                  - lastTransitionTime
+                  - reason
+                  - status
+                  - type
+                  type: object
+                type: array
+            type: object
+        required:
+        - spec
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
diff --git a/pkg/clients/groups/fake/fake.go b/pkg/clients/groups/fake/fake.go
index 87a4bbf..21ee7c5 100644
--- a/pkg/clients/groups/fake/fake.go
+++ b/pkg/clients/groups/fake/fake.go
@@ -43,6 +43,14 @@ type MockClient struct {
 	MockGetGroupDeployToken    func(gid interface{}, deployToken int, options ...gitlab.RequestOptionFunc) (*gitlab.DeployToken, *gitlab.Response, error)
 	MockCreateGroupDeployToken func(gid interface{}, opt *gitlab.CreateGroupDeployTokenOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DeployToken, *gitlab.Response, error)
 	MockDeleteGroupDeployToken func(gid interface{}, deployToken int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
+
+	MockListGroupVariables  func(gid interface{}, opt *gitlab.ListGroupVariablesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.GroupVariable, *gitlab.Response, error)
+	MockGetGroupVariable    func(gid interface{}, key string, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error)
+	MockCreateGroupVariable func(gid interface{}, opt *gitlab.CreateGroupVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error)
+	MockUpdateGroupVariable func(gid interface{}, key string, opt *gitlab.UpdateGroupVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error)
+	MockRemoveGroupVariable func(gid interface{}, key string, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
+
+	MockListUsers func(opt *gitlab.ListUsersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.User, *gitlab.Response, error)
 }
 
 // GetGroup calls the underlying MockGetGroup method.
@@ -109,3 +117,33 @@ func (c *MockClient) CreateGroupDeployToken(gid interface{}, opt *gitlab.CreateG
 func (c *MockClient) DeleteGroupDeployToken(gid interface{}, deployToken int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
 	return c.MockDeleteGroupDeployToken(gid, deployToken)
 }
+
+// ListVariables calls the underlying MockListGroupVariables method.
+func (c *MockClient) ListVariables(gid interface{}, opt *gitlab.ListGroupVariablesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.GroupVariable, *gitlab.Response, error) {
+	return c.MockListGroupVariables(gid, opt)
+}
+
+// GetVariable calls the underlying MockGetGrouptVariable method.
+func (c *MockClient) GetVariable(gid interface{}, key string, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error) {
+	return c.MockGetGroupVariable(gid, key)
+}
+
+// CreateVariable calls the underlying MockCreateGroupVariable method.
+func (c *MockClient) CreateVariable(gid interface{}, opt *gitlab.CreateGroupVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error) {
+	return c.MockCreateGroupVariable(gid, opt)
+}
+
+// UpdateVariable calls the underlying MockUpdateGroupVariable method.
+func (c *MockClient) UpdateVariable(gid interface{}, key string, opt *gitlab.UpdateGroupVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error) {
+	return c.MockUpdateGroupVariable(gid, key, opt)
+}
+
+// RemoveVariable calls the underlying MockRemoveGroupVariable method.
+func (c *MockClient) RemoveVariable(gid interface{}, key string, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
+	return c.MockRemoveGroupVariable(gid, key)
+}
+
+// ListUsers calls the underlying MockListUsers method.
+func (c *MockClient) ListUsers(opt *gitlab.ListUsersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.User, *gitlab.Response, error) {
+	return c.MockListUsers(opt)
+}
diff --git a/pkg/clients/groups/variable.go b/pkg/clients/groups/variable.go
new file mode 100644
index 0000000..3491c15
--- /dev/null
+++ b/pkg/clients/groups/variable.go
@@ -0,0 +1,150 @@
+/*
+Copyright 2021 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package groups
+
+import (
+	"strings"
+
+	xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+	"github.com/xanzy/go-gitlab"
+
+	"github.com/crossplane-contrib/provider-gitlab/apis/groups/v1alpha1"
+	"github.com/crossplane-contrib/provider-gitlab/pkg/clients"
+)
+
+const (
+	errVariableNotFound = "404 Variable Not Found"
+)
+
+// VariableClient defines Gitlab Variable service operations
+type VariableClient interface {
+	ListVariables(gid interface{}, opt *gitlab.ListGroupVariablesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.GroupVariable, *gitlab.Response, error)
+	GetVariable(gid interface{}, key string, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error)
+	CreateVariable(gid interface{}, opt *gitlab.CreateGroupVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error)
+	UpdateVariable(gid interface{}, key string, opt *gitlab.UpdateGroupVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error)
+	RemoveVariable(gid interface{}, key string, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
+}
+
+// NewVariableClient returns a new Gitlab Group service
+func NewVariableClient(cfg clients.Config) VariableClient {
+	git := clients.NewClient(cfg)
+	return git.GroupVariables
+}
+
+// IsErrorVariableNotFound helper function to test for errGroupNotFound error.
+func IsErrorVariableNotFound(err error) bool {
+	if err == nil {
+		return false
+	}
+	return strings.Contains(err.Error(), errVariableNotFound)
+}
+
+// LateInitializeVariable fills the empty fields in the groupVariable spec with the
+// values seen in gitlab.Variable.
+func LateInitializeVariable(in *v1alpha1.VariableParameters, variable *gitlab.GroupVariable) { // nolint:gocyclo
+	if variable == nil {
+		return
+	}
+
+	if in.VariableType == nil {
+		in.VariableType = (*v1alpha1.VariableType)(&variable.VariableType)
+	}
+
+	if in.Protected == nil {
+		in.Protected = &variable.Protected
+	}
+
+	if in.Masked == nil {
+		in.Masked = &variable.Masked
+	}
+
+	if in.EnvironmentScope == nil {
+		in.EnvironmentScope = &variable.EnvironmentScope
+	}
+
+	if in.Raw == nil {
+		in.Raw = &variable.Raw
+	}
+}
+
+// VariableToParameters coonverts a GitLab API representation of a
+// Group Variable back into our local VariableParameters format
+func VariableToParameters(in gitlab.GroupVariable) v1alpha1.VariableParameters {
+	return v1alpha1.VariableParameters{
+		Key:              in.Key,
+		Value:            &in.Value,
+		VariableType:     (*v1alpha1.VariableType)(&in.VariableType),
+		Protected:        &in.Protected,
+		Masked:           &in.Masked,
+		EnvironmentScope: &in.EnvironmentScope,
+		Raw:              &in.Raw,
+	}
+}
+
+// GenerateCreateVariableOptions generates group creation options
+func GenerateCreateVariableOptions(p *v1alpha1.VariableParameters) *gitlab.CreateGroupVariableOptions {
+	variable := &gitlab.CreateGroupVariableOptions{
+		Key:              &p.Key,
+		Value:            p.Value,
+		VariableType:     (*gitlab.VariableTypeValue)(p.VariableType),
+		Protected:        p.Protected,
+		Masked:           p.Masked,
+		EnvironmentScope: p.EnvironmentScope,
+		Raw:              p.Raw,
+	}
+	return variable
+}
+
+// GenerateUpdateVariableOptions generates group update options
+func GenerateUpdateVariableOptions(p *v1alpha1.VariableParameters) *gitlab.UpdateGroupVariableOptions {
+	variable := &gitlab.UpdateGroupVariableOptions{
+		Value:            p.Value,
+		VariableType:     (*gitlab.VariableTypeValue)(p.VariableType),
+		Protected:        p.Protected,
+		Masked:           p.Masked,
+		EnvironmentScope: p.EnvironmentScope,
+		Raw:              p.Raw,
+	}
+	return variable
+}
+
+// GenerateVariableFilter generates a variable filter that matches the variable parameters' environment scope.
+func GenerateVariableFilter(p *v1alpha1.VariableParameters) *gitlab.VariableFilter {
+	if p.EnvironmentScope == nil {
+		return nil
+	}
+
+	return &gitlab.VariableFilter{
+		EnvironmentScope: *p.EnvironmentScope,
+	}
+}
+
+// IsVariableUpToDate checks whether there is a change in any of the modifiable fields.
+func IsVariableUpToDate(p *v1alpha1.VariableParameters, g *gitlab.GroupVariable) bool {
+	if p == nil {
+		return true
+	}
+
+	return cmp.Equal(*p,
+		VariableToParameters(*g),
+		cmpopts.EquateEmpty(),
+		cmpopts.IgnoreTypes(&xpv1.Reference{}, &xpv1.Selector{}, []xpv1.Reference{}, &xpv1.SecretKeySelector{}),
+		cmpopts.IgnoreFields(v1alpha1.VariableParameters{}, "GroupID"),
+	)
+}
diff --git a/pkg/controller/gitlab.go b/pkg/controller/gitlab.go
index 4cc77b0..e7ba945 100644
--- a/pkg/controller/gitlab.go
+++ b/pkg/controller/gitlab.go
@@ -23,15 +23,17 @@ import (
 
 	"github.com/crossplane-contrib/provider-gitlab/pkg/controller/config"
 	"github.com/crossplane-contrib/provider-gitlab/pkg/controller/groups"
-	groupDeployToken "github.com/crossplane-contrib/provider-gitlab/pkg/controller/groups/deploytokens"
-	groupmembers "github.com/crossplane-contrib/provider-gitlab/pkg/controller/groups/members"
+	groupsDeployToken "github.com/crossplane-contrib/provider-gitlab/pkg/controller/groups/deploytokens"
+	groupsMembers "github.com/crossplane-contrib/provider-gitlab/pkg/controller/groups/members"
+	groupsVariables "github.com/crossplane-contrib/provider-gitlab/pkg/controller/groups/variables"
 	"github.com/crossplane-contrib/provider-gitlab/pkg/controller/projects"
-	"github.com/crossplane-contrib/provider-gitlab/pkg/controller/projects/deploykeys"
-	projectDeployToken "github.com/crossplane-contrib/provider-gitlab/pkg/controller/projects/deploytokens"
-	projecthooks "github.com/crossplane-contrib/provider-gitlab/pkg/controller/projects/hooks"
-	projectmembers "github.com/crossplane-contrib/provider-gitlab/pkg/controller/projects/members"
-	"github.com/crossplane-contrib/provider-gitlab/pkg/controller/projects/pipelineschedules"
-	"github.com/crossplane-contrib/provider-gitlab/pkg/controller/projects/variables"
+	projectsAccessToken "github.com/crossplane-contrib/provider-gitlab/pkg/controller/projects/accesstokens"
+	projectsDeployKeys "github.com/crossplane-contrib/provider-gitlab/pkg/controller/projects/deploykeys"
+	projectsDeployToken "github.com/crossplane-contrib/provider-gitlab/pkg/controller/projects/deploytokens"
+	projectsHooks "github.com/crossplane-contrib/provider-gitlab/pkg/controller/projects/hooks"
+	projectsMembers "github.com/crossplane-contrib/provider-gitlab/pkg/controller/projects/members"
+	projectsPipelineschedules "github.com/crossplane-contrib/provider-gitlab/pkg/controller/projects/pipelineschedules"
+	projectsVariables "github.com/crossplane-contrib/provider-gitlab/pkg/controller/projects/variables"
 )
 
 // Setup creates all Gitlab API controllers with the supplied logger and adds
@@ -40,15 +42,17 @@ func Setup(mgr ctrl.Manager, l logging.Logger) error {
 	for _, setup := range []func(ctrl.Manager, logging.Logger) error{
 		config.Setup,
 		groups.SetupGroup,
-		groupmembers.SetupMember,
-		groupDeployToken.SetupDeployToken,
+		groupsMembers.SetupMember,
+		groupsDeployToken.SetupDeployToken,
+		groupsVariables.SetupVariable,
 		projects.SetupProject,
-		projecthooks.SetupHook,
-		projectmembers.SetupMember,
-		projectDeployToken.SetupDeployToken,
-		variables.SetupVariable,
-		deploykeys.SetupDeployKey,
-		pipelineschedules.SetupPipelineSchedule,
+		projectsHooks.SetupHook,
+		projectsMembers.SetupMember,
+		projectsDeployToken.SetupDeployToken,
+		projectsAccessToken.SetupAccessToken,
+		projectsVariables.SetupVariable,
+		projectsDeployKeys.SetupDeployKey,
+		projectsPipelineschedules.SetupPipelineSchedule,
 	} {
 		if err := setup(mgr, l); err != nil {
 			return err
diff --git a/pkg/controller/groups/variables/controller.go b/pkg/controller/groups/variables/controller.go
new file mode 100644
index 0000000..6b74544
--- /dev/null
+++ b/pkg/controller/groups/variables/controller.go
@@ -0,0 +1,231 @@
+/*
+Copyright 2021 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package variables
+
+import (
+	"context"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/xanzy/go-gitlab"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/types"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
+	"github.com/crossplane/crossplane-runtime/pkg/errors"
+	"github.com/crossplane/crossplane-runtime/pkg/event"
+	"github.com/crossplane/crossplane-runtime/pkg/logging"
+	"github.com/crossplane/crossplane-runtime/pkg/reconciler/managed"
+	"github.com/crossplane/crossplane-runtime/pkg/resource"
+
+	"github.com/crossplane-contrib/provider-gitlab/apis/groups/v1alpha1"
+	"github.com/crossplane-contrib/provider-gitlab/pkg/clients"
+	"github.com/crossplane-contrib/provider-gitlab/pkg/clients/groups"
+)
+
+const (
+	errNotVariable       = "managed resource is not a Gitlab variable custom resource"
+	errGetFailed         = "cannot get Gitlab variable"
+	errCreateFailed      = "cannot create Gitlab variable"
+	errUpdateFailed      = "cannot update Gitlab variable"
+	errDeleteFailed      = "cannot delete Gitlab variable"
+	errGetSecretFailed   = "cannot get secret for Gitlab variable value"
+	errSecretKeyNotFound = "cannot find key in secret for Gitlab variable value"
+	errGroupIDMissing    = "GroupID is missing"
+)
+
+// SetupVariable adds a controller that reconciles Variables.
+func SetupVariable(mgr ctrl.Manager, l logging.Logger) error {
+	name := managed.ControllerName(v1alpha1.VariableKind)
+
+	return ctrl.NewControllerManagedBy(mgr).
+		Named(name).
+		For(&v1alpha1.Variable{}).
+		Complete(managed.NewReconciler(mgr,
+			resource.ManagedKind(v1alpha1.VariableGroupVersionKind),
+			managed.WithExternalConnecter(&connector{kube: mgr.GetClient(), newGitlabClientFn: groups.NewVariableClient}),
+			managed.WithInitializers(managed.NewDefaultProviderConfig(mgr.GetClient())),
+			managed.WithLogger(l.WithValues("controller", name)),
+			managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name)))))
+}
+
+type connector struct {
+	kube              client.Client
+	newGitlabClientFn func(cfg clients.Config) groups.VariableClient
+}
+
+func (c *connector) Connect(ctx context.Context, mg resource.Managed) (managed.ExternalClient, error) {
+	cr, ok := mg.(*v1alpha1.Variable)
+	if !ok {
+		return nil, errors.New(errNotVariable)
+	}
+	cfg, err := clients.GetConfig(ctx, c.kube, cr)
+	if err != nil {
+		return nil, err
+	}
+	return &external{kube: c.kube, client: c.newGitlabClientFn(*cfg)}, nil
+}
+
+type external struct {
+	kube   client.Client
+	client groups.VariableClient
+}
+
+func (e *external) Observe(ctx context.Context, mg resource.Managed) (managed.ExternalObservation, error) {
+	cr, ok := mg.(*v1alpha1.Variable)
+	if !ok {
+		return managed.ExternalObservation{}, errors.New(errNotVariable)
+	}
+	if cr.Spec.ForProvider.GroupID == nil {
+		return managed.ExternalObservation{}, errors.New(errGroupIDMissing)
+	}
+
+	variable, res, err := e.client.GetVariable(
+		*cr.Spec.ForProvider.GroupID,
+		cr.Spec.ForProvider.Key,
+		gitlab.WithContext(ctx))
+
+	if err != nil {
+		if clients.IsResponseNotFound(res) {
+			return managed.ExternalObservation{}, nil
+		}
+		return managed.ExternalObservation{}, errors.Wrap(err, errGetFailed)
+	}
+
+	if cr.Spec.ForProvider.ValueSecretRef != nil {
+		if err = e.updateVariableFromSecret(ctx, cr.Spec.ForProvider.ValueSecretRef, &cr.Spec.ForProvider); err != nil {
+			return managed.ExternalObservation{}, errors.Wrap(err, errUpdateFailed)
+		}
+	}
+
+	current := cr.Spec.ForProvider.DeepCopy()
+	groups.LateInitializeVariable(&cr.Spec.ForProvider, variable)
+
+	cr.Status.SetConditions(xpv1.Available())
+
+	return managed.ExternalObservation{
+		ResourceExists:          true,
+		ResourceUpToDate:        groups.IsVariableUpToDate(&cr.Spec.ForProvider, variable),
+		ResourceLateInitialized: !cmp.Equal(current, &cr.Spec.ForProvider),
+	}, nil
+}
+
+func (e *external) Create(ctx context.Context, mg resource.Managed) (managed.ExternalCreation, error) {
+	cr, ok := mg.(*v1alpha1.Variable)
+	if !ok {
+		return managed.ExternalCreation{}, errors.New(errNotVariable)
+	}
+
+	if cr.Spec.ForProvider.ValueSecretRef != nil {
+		if err := e.updateVariableFromSecret(ctx, cr.Spec.ForProvider.ValueSecretRef, &cr.Spec.ForProvider); err != nil {
+			return managed.ExternalCreation{}, errors.Wrap(err, errCreateFailed)
+		}
+	}
+	if cr.Spec.ForProvider.GroupID == nil {
+		return managed.ExternalCreation{}, errors.New(errGroupIDMissing)
+	}
+
+	cr.Status.SetConditions(xpv1.Creating())
+	_, _, err := e.client.CreateVariable(
+		*cr.Spec.ForProvider.GroupID,
+		groups.GenerateCreateVariableOptions(&cr.Spec.ForProvider),
+		gitlab.WithContext(ctx))
+
+	if err != nil {
+		return managed.ExternalCreation{}, errors.Wrap(err, errCreateFailed)
+	}
+	return managed.ExternalCreation{}, nil
+}
+
+func (e *external) Update(ctx context.Context, mg resource.Managed) (managed.ExternalUpdate, error) {
+	cr, ok := mg.(*v1alpha1.Variable)
+	if !ok {
+		return managed.ExternalUpdate{}, errors.New(errNotVariable)
+	}
+
+	if cr.Spec.ForProvider.ValueSecretRef != nil {
+		if err := e.updateVariableFromSecret(ctx, cr.Spec.ForProvider.ValueSecretRef, &cr.Spec.ForProvider); err != nil {
+			return managed.ExternalUpdate{}, errors.Wrap(err, errUpdateFailed)
+		}
+	}
+	if cr.Spec.ForProvider.GroupID == nil {
+		return managed.ExternalUpdate{}, errors.New(errGroupIDMissing)
+	}
+
+	_, _, err := e.client.UpdateVariable(
+		*cr.Spec.ForProvider.GroupID,
+		cr.Spec.ForProvider.Key,
+		groups.GenerateUpdateVariableOptions(&cr.Spec.ForProvider),
+		gitlab.WithContext(ctx),
+	)
+	return managed.ExternalUpdate{}, errors.Wrap(err, errUpdateFailed)
+}
+
+func (e *external) Delete(ctx context.Context, mg resource.Managed) error {
+	cr, ok := mg.(*v1alpha1.Variable)
+	if !ok {
+		return errors.New(errNotVariable)
+	}
+
+	if cr.Spec.ForProvider.GroupID == nil {
+		return errors.New(errGroupIDMissing)
+	}
+
+	cr.Status.SetConditions(xpv1.Deleting())
+	_, err := e.client.RemoveVariable(
+		*cr.Spec.ForProvider.GroupID,
+		cr.Spec.ForProvider.Key,
+		gitlab.WithContext(ctx),
+	)
+	return errors.Wrap(err, errDeleteFailed)
+}
+
+func (e *external) updateVariableFromSecret(ctx context.Context, selector *xpv1.SecretKeySelector, params *v1alpha1.VariableParameters) error {
+	// Fetch the Kubernetes secret.
+	secret := &corev1.Secret{}
+	nn := types.NamespacedName{
+		Namespace: selector.Namespace,
+		Name:      selector.Name,
+	}
+
+	err := e.kube.Get(ctx, nn, secret)
+	if err != nil {
+		return errors.Wrap(err, errGetSecretFailed)
+	}
+
+	// Obtain the data from the secret.
+	raw, ok := secret.Data[selector.Key]
+	if raw == nil || !ok {
+		return errors.New(errSecretKeyNotFound)
+	}
+
+	// Mask variable if it hasn't already been explicitly configured.
+	if params.Masked == nil {
+		params.Masked = gitlab.Bool(true)
+	}
+
+	// Make variable raw if it hasn't already been explicitly configured.
+	if params.Raw == nil {
+		params.Raw = gitlab.Bool(true)
+	}
+
+	value := string(raw)
+	params.Value = &value
+
+	return nil
+}


Test patch:
diff --git a/pkg/controller/groups/variables/controller_test.go b/pkg/controller/groups/variables/controller_test.go
new file mode 100644
index 0000000..9d29729
--- /dev/null
+++ b/pkg/controller/groups/variables/controller_test.go
@@ -0,0 +1,781 @@
+/*
+Copyright 2021 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package variables
+
+import (
+	"context"
+	"net/http"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/xanzy/go-gitlab"
+	corev1 "k8s.io/api/core/v1"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
+	"github.com/crossplane/crossplane-runtime/pkg/errors"
+	"github.com/crossplane/crossplane-runtime/pkg/reconciler/managed"
+	"github.com/crossplane/crossplane-runtime/pkg/test"
+
+	"github.com/crossplane-contrib/provider-gitlab/apis/groups/v1alpha1"
+	"github.com/crossplane-contrib/provider-gitlab/pkg/clients/groups"
+	"github.com/crossplane-contrib/provider-gitlab/pkg/clients/groups/fake"
+)
+
+var (
+	errBoom          = errors.New("boom")
+	groupID          = 5678
+	variableKey      = "VARIABLE_KEY"
+	variableValue    = "1234"
+	variableType     = v1alpha1.VariableTypeEnvVar
+	variableEnvScope = "*"
+	f                = false
+)
+
+var (
+	pv = gitlab.GroupVariable{
+		Value:            variableValue,
+		Key:              variableKey,
+		EnvironmentScope: variableEnvScope,
+		VariableType:     gitlab.VariableTypeValue(variableType),
+		Protected:        f,
+		Masked:           f,
+		Raw:              f,
+	}
+)
+
+type args struct {
+	variable groups.VariableClient
+	kube     client.Client
+	cr       *v1alpha1.Variable
+}
+
+type variableModifier func(*v1alpha1.Variable)
+
+func withConditions(c ...xpv1.Condition) variableModifier {
+	return func(r *v1alpha1.Variable) { r.Status.ConditionedStatus.Conditions = c }
+}
+
+func withDefaultValues() variableModifier {
+	return func(pv *v1alpha1.Variable) {
+		pv.Spec.ForProvider = v1alpha1.VariableParameters{
+			GroupID:          &groupID,
+			Key:              variableKey,
+			Value:            &variableValue,
+			Protected:        &f,
+			Masked:           &f,
+			Raw:              &f,
+			VariableType:     &variableType,
+			EnvironmentScope: &variableEnvScope,
+		}
+	}
+}
+
+func withGroupID(pid int) variableModifier {
+	return func(r *v1alpha1.Variable) {
+		r.Spec.ForProvider.GroupID = &pid
+	}
+}
+
+func withValue(value string) variableModifier {
+	return func(r *v1alpha1.Variable) {
+		r.Spec.ForProvider.Value = &value
+	}
+}
+
+func withValueSecretRef(selector *xpv1.SecretKeySelector) variableModifier {
+	return func(r *v1alpha1.Variable) {
+		r.Spec.ForProvider.ValueSecretRef = selector
+	}
+}
+
+func withKey(key string) variableModifier {
+	return func(r *v1alpha1.Variable) {
+		r.Spec.ForProvider.Key = key
+	}
+}
+
+func withMasked(masked bool) variableModifier {
+	return func(r *v1alpha1.Variable) {
+		r.Spec.ForProvider.Masked = &masked
+	}
+}
+
+func withRaw(raw bool) variableModifier {
+	return func(r *v1alpha1.Variable) {
+		r.Spec.ForProvider.Raw = &raw
+	}
+}
+
+func withVariableType(variableType v1alpha1.VariableType) variableModifier {
+	return func(r *v1alpha1.Variable) {
+		r.Spec.ForProvider.VariableType = &variableType
+	}
+}
+
+func withEnvironmentScope(scope string) variableModifier {
+	return func(r *v1alpha1.Variable) {
+		r.Spec.ForProvider.EnvironmentScope = &scope
+	}
+}
+
+func variable(m ...variableModifier) *v1alpha1.Variable {
+	cr := &v1alpha1.Variable{}
+	for _, f := range m {
+		f(cr)
+	}
+	return cr
+}
+
+func TestObserve(t *testing.T) {
+	type want struct {
+		cr     *v1alpha1.Variable
+		result managed.ExternalObservation
+		err    error
+	}
+
+	cases := map[string]struct {
+		args
+		want
+	}{
+		"SuccessfulAvailable": {
+			args: args{
+				variable: &fake.MockClient{
+					MockGetGroupVariable: func(gid interface{}, key string, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error) {
+						return &pv, &gitlab.Response{}, nil
+					},
+				},
+				cr: variable(withDefaultValues()),
+			},
+			want: want{
+				cr: variable(
+					withDefaultValues(),
+					withConditions(xpv1.Available()),
+				),
+				result: managed.ExternalObservation{
+					ResourceExists:   true,
+					ResourceUpToDate: true,
+				},
+			},
+		},
+		"NotUpToDate": {
+			args: args{
+				variable: &fake.MockClient{
+					MockGetGroupVariable: func(gid interface{}, key string, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error) {
+						rv := pv
+						rv.Value = "not-up-to-date"
+						return &rv, &gitlab.Response{}, nil
+					},
+				},
+				cr: variable(
+					withDefaultValues(),
+					withValue("blah"),
+				),
+			},
+			want: want{
+				cr: variable(
+					withDefaultValues(),
+					withValue("blah"),
+					withConditions(xpv1.Available()),
+				),
+				result: managed.ExternalObservation{
+					ResourceExists:   true,
+					ResourceUpToDate: false,
+				},
+			},
+		},
+		"LateInitSuccess": {
+			args: args{
+				variable: &fake.MockClient{
+					MockGetGroupVariable: func(gid interface{}, key string, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error) {
+						rv := pv
+						rv.Masked = true
+						rv.VariableType = gitlab.FileVariableType
+						return &rv, &gitlab.Response{}, nil
+					},
+				},
+				cr: variable(
+					withGroupID(groupID),
+					withKey(variableKey),
+					withValue(variableValue),
+					withVariableType(v1alpha1.VariableTypeEnvVar),
+					withRaw(false),
+				),
+			},
+			want: want{
+				cr: variable(
+					withDefaultValues(),
+					withKey(variableKey),
+					// We expect the masked value to be late-inited to true
+					withMasked(true),
+					// We expect the variable type value to be unchanged,
+					// as it was already set in the existing CR.
+					withVariableType(v1alpha1.VariableTypeEnvVar),
+					withConditions(xpv1.Available()),
+				),
+				result: managed.ExternalObservation{
+					ResourceExists: true,
+					// Resource is not up to date as local and remote
+					// variableType setting do not match.
+					ResourceUpToDate:        false,
+					ResourceLateInitialized: true,
+				},
+			},
+		},
+		"GetError": {
+			args: args{
+				variable: &fake.MockClient{
+					MockGetGroupVariable: func(gid interface{}, key string, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error) {
+						return &gitlab.GroupVariable{}, &gitlab.Response{Response: &http.Response{StatusCode: 400}}, errBoom
+					},
+				},
+				cr: variable(
+					withDefaultValues(),
+					withValue("blah"),
+				),
+			},
+			want: want{
+				cr: variable(
+					withDefaultValues(),
+					withValue("blah"),
+				),
+				result: managed.ExternalObservation{},
+				err:    errors.Wrap(errBoom, errGetFailed),
+			},
+		},
+		"ErrGet404": {
+			args: args{
+				variable: &fake.MockClient{
+					MockGetGroupVariable: func(gid interface{}, key string, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error) {
+						return &gitlab.GroupVariable{}, &gitlab.Response{Response: &http.Response{StatusCode: 404}}, errBoom
+					},
+				},
+				cr: variable(
+					withDefaultValues(),
+					withValue("blah"),
+				),
+			},
+			want: want{
+				cr: variable(
+					withDefaultValues(),
+					withValue("blah"),
+				),
+				result: managed.ExternalObservation{},
+				err:    nil,
+			},
+		},
+		"ValueSecretRef": {
+			args: args{
+				kube: &test.MockClient{
+					MockGet: func(_ context.Context, key client.ObjectKey, obj client.Object) error {
+						secret, ok := obj.(*corev1.Secret)
+						if !ok {
+							return errors.Wrapf(errBoom, "unexpected object type %T, expected %T", obj, secret)
+						}
+
+						secret.Data = map[string][]byte{
+							"blah": []byte(variableValue),
+						}
+
+						return nil
+					},
+				},
+				variable: &fake.MockClient{
+					MockGetGroupVariable: func(gid interface{}, key string, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error) {
+						return &gitlab.GroupVariable{}, &gitlab.Response{}, nil
+					},
+				},
+				cr: variable(
+					// We don't want to use the default values used in the other tests since we want value to be empty and
+					// Raw to be nil.
+					withGroupID(groupID),
+					withKey(variableKey),
+					withValueSecretRef(&xpv1.SecretKeySelector{
+						SecretReference: xpv1.SecretReference{},
+						Key:             "blah",
+					}),
+					withEnvironmentScope("*"),
+					withVariableType(v1alpha1.VariableTypeEnvVar),
+				),
+			},
+			want: want{
+				cr: variable(
+					withDefaultValues(),
+					withValueSecretRef(&xpv1.SecretKeySelector{
+						SecretReference: xpv1.SecretReference{},
+						Key:             "blah",
+					}),
+					withMasked(true),
+					withRaw(true),
+					withConditions(xpv1.Available()),
+					withVariableType(v1alpha1.VariableTypeEnvVar),
+				),
+				result: managed.ExternalObservation{
+					ResourceExists:          true,
+					ResourceLateInitialized: true,
+				},
+			},
+		},
+		"ValueSecretRefWrongKey": {
+			args: args{
+				kube: &test.MockClient{
+					MockGet: func(_ context.Context, key client.ObjectKey, obj client.Object) error {
+						secret, ok := obj.(*corev1.Secret)
+						if !ok {
+							return errors.Wrapf(errBoom, "unexpected object type %T, expected %T", obj, secret)
+						}
+
+						secret.Data = map[string][]byte{
+							"blah": []byte(variableValue),
+						}
+
+						return nil
+					},
+				},
+				variable: &fake.MockClient{
+					MockGetGroupVariable: func(gid interface{}, key string, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error) {
+						return &gitlab.GroupVariable{}, &gitlab.Response{Response: &http.Response{StatusCode: 400}}, errors.New(errSecretKeyNotFound)
+					},
+				},
+				cr: variable(
+					withGroupID(groupID),
+					withKey(variableKey),
+					withValueSecretRef(&xpv1.SecretKeySelector{
+						SecretReference: xpv1.SecretReference{},
+						Key:             "bad",
+					}),
+				),
+			},
+			want: want{
+				cr: variable(
+					withGroupID(groupID),
+					withKey(variableKey),
+					withValueSecretRef(&xpv1.SecretKeySelector{
+						SecretReference: xpv1.SecretReference{},
+						Key:             "bad",
+					}),
+				),
+				err: errors.Wrap(errors.New(errSecretKeyNotFound), errGetFailed),
+			},
+		},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			e := &external{kube: tc.kube, client: tc.variable}
+			o, err := e.Observe(context.Background(), tc.args.cr)
+
+			if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+			if diff := cmp.Diff(tc.want.cr, tc.args.cr, test.EquateConditions()); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+			if diff := cmp.Diff(tc.want.result, o); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+		})
+	}
+}
+
+func TestCreate(t *testing.T) {
+	type want struct {
+		cr     *v1alpha1.Variable
+		result managed.ExternalCreation
+		err    error
+	}
+
+	cases := map[string]struct {
+		args
+		want
+	}{
+		"SuccessfulCreation": {
+			args: args{
+				kube: &test.MockClient{
+					MockUpdate: test.NewMockUpdateFn(nil),
+				},
+				variable: &fake.MockClient{
+					MockCreateGroupVariable: func(gid interface{}, opt *gitlab.CreateGroupVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error) {
+						return &gitlab.GroupVariable{Key: variableKey}, &gitlab.Response{}, nil
+					},
+				},
+				cr: variable(
+					withDefaultValues(),
+				),
+			},
+			want: want{
+				cr: variable(
+					withDefaultValues(),
+					withConditions(xpv1.Creating()),
+				),
+				result: managed.ExternalCreation{},
+			},
+		},
+		"FailedCreation": {
+			args: args{
+				variable: &fake.MockClient{
+					MockCreateGroupVariable: func(gid interface{}, opt *gitlab.CreateGroupVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error) {
+						return &gitlab.GroupVariable{}, &gitlab.Response{}, errBoom
+					},
+				},
+				cr: variable(
+					withDefaultValues(),
+				),
+			},
+			want: want{
+				cr: variable(
+					withDefaultValues(),
+					withConditions(xpv1.Creating()),
+				),
+				err: errors.Wrap(errBoom, errCreateFailed),
+			},
+		},
+		"ValueSecretRef": {
+			args: args{
+				kube: &test.MockClient{
+					MockGet: func(_ context.Context, key client.ObjectKey, obj client.Object) error {
+						secret := obj.(*corev1.Secret)
+						secret.Data = map[string][]byte{
+							"blah": []byte(variableValue),
+						}
+
+						return nil
+					},
+				},
+				variable: &fake.MockClient{
+					MockCreateGroupVariable: func(gid interface{}, opt *gitlab.CreateGroupVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error) {
+						return &gitlab.GroupVariable{}, &gitlab.Response{}, nil
+					},
+				},
+				cr: variable(
+					withGroupID(groupID),
+					withKey(variableKey),
+					withValueSecretRef(&xpv1.SecretKeySelector{
+						SecretReference: xpv1.SecretReference{},
+						Key:             "blah",
+					}),
+				),
+			},
+			want: want{
+				cr: variable(
+					withGroupID(groupID),
+					withKey(variableKey),
+					withConditions(xpv1.Creating()),
+					withValueSecretRef(&xpv1.SecretKeySelector{
+						SecretReference: xpv1.SecretReference{},
+						Key:             "blah",
+					}),
+					withValue(variableValue),
+					withMasked(true),
+					withRaw(true),
+				),
+			},
+		},
+		"ValueSecretRefWrongKey": {
+			args: args{
+				kube: &test.MockClient{
+					MockGet: func(_ context.Context, key client.ObjectKey, obj client.Object) error {
+						secret := obj.(*corev1.Secret)
+						secret.Data = map[string][]byte{
+							"blah": []byte(variableValue),
+						}
+
+						return nil
+					},
+				},
+				variable: &fake.MockClient{
+					MockCreateGroupVariable: func(gid interface{}, opt *gitlab.CreateGroupVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error) {
+						return &gitlab.GroupVariable{}, &gitlab.Response{}, errors.New(errSecretKeyNotFound)
+					},
+				},
+				cr: variable(
+					withGroupID(groupID),
+					withKey(variableKey),
+					withValueSecretRef(&xpv1.SecretKeySelector{
+						SecretReference: xpv1.SecretReference{},
+						Key:             "bad",
+					}),
+				),
+			},
+			want: want{
+				cr: variable(
+					withGroupID(groupID),
+					withKey(variableKey),
+					withValueSecretRef(&xpv1.SecretKeySelector{
+						SecretReference: xpv1.SecretReference{},
+						Key:             "bad",
+					})),
+				err: errors.Wrap(errors.New(errSecretKeyNotFound), errCreateFailed),
+			},
+		},
+	}
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			e := &external{kube: tc.kube, client: tc.variable}
+			o, err := e.Create(context.Background(), tc.args.cr)
+
+			if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+			if diff := cmp.Diff(tc.want.cr, tc.args.cr, test.EquateConditions()); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+			if diff := cmp.Diff(tc.want.result, o); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+		})
+	}
+}
+
+func TestUpdate(t *testing.T) {
+	type want struct {
+		cr     *v1alpha1.Variable
+		result managed.ExternalUpdate
+		err    error
+	}
+
+	cases := map[string]struct {
+		args
+		want
+	}{
+		"SuccessfulEditGroup": {
+			args: args{
+				variable: &fake.MockClient{
+					MockUpdateGroupVariable: func(gid interface{}, key string, opt *gitlab.UpdateGroupVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error) {
+						return &gitlab.GroupVariable{}, &gitlab.Response{}, nil
+					},
+				},
+				cr: variable(
+					withKey(variableKey),
+					withGroupID(groupID),
+				),
+			},
+			want: want{
+				cr: variable(
+					withKey(variableKey),
+					withGroupID(groupID),
+				),
+			},
+		},
+		"FailedEdit": {
+			args: args{
+				variable: &fake.MockClient{
+					MockUpdateGroupVariable: func(gid interface{}, key string, opt *gitlab.UpdateGroupVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error) {
+						return &gitlab.GroupVariable{}, &gitlab.Response{}, errBoom
+					},
+				},
+				cr: variable(
+					withKey(variableKey),
+					withGroupID(groupID),
+				),
+			},
+			want: want{
+				cr: variable(
+					withKey(variableKey),
+					withGroupID(groupID),
+				),
+				err: errors.Wrap(errBoom, errUpdateFailed),
+			},
+		},
+		"ValueSecretRef": {
+			args: args{
+				kube: &test.MockClient{
+					MockGet: func(_ context.Context, key client.ObjectKey, obj client.Object) error {
+						secret, ok := obj.(*corev1.Secret)
+						if !ok {
+							return errors.Wrapf(errBoom, "unexpected object type %T, expected %T", obj, secret)
+						}
+
+						secret.Data = map[string][]byte{
+							"blah": []byte(variableValue),
+						}
+
+						return nil
+					},
+				},
+				variable: &fake.MockClient{
+					MockUpdateGroupVariable: func(gid interface{}, key string, opt *gitlab.UpdateGroupVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error) {
+						return &gitlab.GroupVariable{}, &gitlab.Response{}, nil
+					},
+				},
+				cr: variable(
+					withGroupID(groupID),
+					withKey(variableKey),
+					withValueSecretRef(&xpv1.SecretKeySelector{
+						SecretReference: xpv1.SecretReference{},
+						Key:             "blah",
+					}),
+				),
+			},
+			want: want{
+				cr: variable(
+					withGroupID(groupID),
+					withKey(variableKey),
+					withValueSecretRef(&xpv1.SecretKeySelector{
+						SecretReference: xpv1.SecretReference{},
+						Key:             "blah",
+					}),
+					withValue(variableValue),
+					withMasked(true),
+					withRaw(true),
+				),
+			},
+		},
+		"ValueSecretRefWrongKey": {
+			args: args{
+				kube: &test.MockClient{
+					MockGet: func(_ context.Context, key client.ObjectKey, obj client.Object) error {
+						secret, ok := obj.(*corev1.Secret)
+						if !ok {
+							return errors.Wrapf(errBoom, "unexpected object type %T, expected %T", obj, secret)
+						}
+
+						secret.Data = map[string][]byte{
+							"blah": []byte(variableValue),
+						}
+
+						return nil
+					},
+				},
+				variable: &fake.MockClient{
+					MockUpdateGroupVariable: func(gid interface{}, key string, opt *gitlab.UpdateGroupVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.GroupVariable, *gitlab.Response, error) {
+						return &gitlab.GroupVariable{}, &gitlab.Response{}, errors.New(errSecretKeyNotFound)
+					},
+				},
+				cr: variable(
+					withGroupID(groupID),
+					withKey(variableKey),
+					withValueSecretRef(&xpv1.SecretKeySelector{
+						SecretReference: xpv1.SecretReference{},
+						Key:             "bad",
+					}),
+				),
+			},
+			want: want{
+				cr: variable(
+					withGroupID(groupID),
+					withKey(variableKey),
+					withValueSecretRef(&xpv1.SecretKeySelector{
+						SecretReference: xpv1.SecretReference{},
+						Key:             "bad",
+					})),
+				err: errors.Wrap(errors.New(errSecretKeyNotFound), errUpdateFailed),
+			},
+		},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			e := &external{kube: tc.kube, client: tc.variable}
+			o, err := e.Update(context.Background(), tc.args.cr)
+
+			if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+			if diff := cmp.Diff(tc.want.cr, tc.args.cr, test.EquateConditions()); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+			if diff := cmp.Diff(tc.want.result, o); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+		})
+	}
+}
+func TestDelete(t *testing.T) {
+	type want struct {
+		cr  *v1alpha1.Variable
+		err error
+	}
+
+	cases := map[string]struct {
+		args
+		want
+	}{
+		"SuccessfulDeletion": {
+			args: args{
+				variable: &fake.MockClient{
+					MockRemoveGroupVariable: func(gid interface{}, key string, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
+						return &gitlab.Response{}, nil
+					},
+				},
+				cr: variable(
+					withGroupID(groupID),
+					withConditions(xpv1.Available()),
+				),
+			},
+			want: want{
+				cr: variable(
+					withGroupID(groupID),
+					withConditions(xpv1.Deleting()),
+				),
+			},
+		},
+		"FailedDeletion": {
+			args: args{
+				variable: &fake.MockClient{
+					MockRemoveGroupVariable: func(gid interface{}, key string, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
+						return &gitlab.Response{}, errBoom
+					},
+				},
+				cr: variable(
+					withGroupID(groupID),
+					withConditions(xpv1.Available()),
+				),
+			},
+			want: want{
+				cr: variable(
+					withGroupID(groupID),
+					withConditions(xpv1.Deleting()),
+				),
+				err: errors.Wrap(errBoom, errDeleteFailed),
+			},
+		},
+		"InvalidVariableID": {
+			args: args{
+				variable: &fake.MockClient{
+					MockRemoveGroupVariable: func(gid interface{}, key string, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
+						return &gitlab.Response{}, nil
+					},
+				},
+				cr: variable(
+					withGroupID(groupID),
+					withConditions(xpv1.Available()),
+				),
+			},
+			want: want{
+				cr: variable(
+					withGroupID(groupID),
+					withConditions(xpv1.Deleting()),
+				),
+			},
+		},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			e := &external{client: tc.variable}
+			err := e.Delete(context.Background(), tc.args.cr)
+
+			if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+			if diff := cmp.Diff(tc.want.cr, tc.args.cr, test.EquateConditions()); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+		})
+	}
+}