From d46d53109f9599ec469fd35246b7e5b71d8662c9 Mon Sep 17 00:00:00 2001 From: Haim Kortovich Date: Wed, 15 May 2024 15:09:55 -0500 Subject: [PATCH] Add APIApp crd [ZITADOPER-1] --- ops/chart/crds/apiapp-crd.yaml | 176 ++++++++++++ ops/chart/templates/manager-rbac.yaml | 26 ++ src/PROJECT | 9 + src/api/v1alpha1/apiapp_types.go | 126 +++++++++ src/api/v1alpha1/zz_generated.deepcopy.go | 97 +++++++ src/cmd/main.go | 4 + .../bases/zitadel.topmanage.com_apiapps.yaml | 177 ++++++++++++ src/config/crd/kustomization.yaml | 3 + .../crd/patches/cainjection_in_apiapps.yaml | 7 + .../crd/patches/webhook_in_apiapps.yaml | 16 ++ src/config/rbac/apiapp_editor_role.yaml | 31 +++ src/config/rbac/apiapp_viewer_role.yaml | 27 ++ src/config/rbac/role.yaml | 26 ++ src/config/samples/kustomization.yaml | 1 + .../samples/zitadel_v1alpha1_apiapp.yaml | 12 + src/go.mod | 2 +- src/internal/controller/apiapp_controller.go | 261 ++++++++++++++++++ .../controller/apiapp_controller_finalizer.go | 91 ++++++ .../controller/machineuser_controller.go | 34 +-- 19 files changed, 1092 insertions(+), 34 deletions(-) create mode 100644 ops/chart/crds/apiapp-crd.yaml create mode 100644 src/api/v1alpha1/apiapp_types.go create mode 100644 src/config/crd/bases/zitadel.topmanage.com_apiapps.yaml create mode 100644 src/config/crd/patches/cainjection_in_apiapps.yaml create mode 100644 src/config/crd/patches/webhook_in_apiapps.yaml create mode 100644 src/config/rbac/apiapp_editor_role.yaml create mode 100644 src/config/rbac/apiapp_viewer_role.yaml create mode 100644 src/config/samples/zitadel_v1alpha1_apiapp.yaml create mode 100644 src/internal/controller/apiapp_controller.go create mode 100644 src/internal/controller/apiapp_controller_finalizer.go diff --git a/ops/chart/crds/apiapp-crd.yaml b/ops/chart/crds/apiapp-crd.yaml new file mode 100644 index 0000000..0a4deb6 --- /dev/null +++ b/ops/chart/crds/apiapp-crd.yaml @@ -0,0 +1,176 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.11.1 + creationTimestamp: null + name: apiapps.zitadel.topmanage.com +spec: + group: zitadel.topmanage.com + names: + kind: APIApp + listKind: APIAppList + plural: apiapps + singular: apiapp + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: APIApp is the Schema for the apiapps API + 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: APIAppSpec defines the desired state of APIApp + properties: + authMethodType: + enum: + - API_AUTH_METHOD_TYPE_BASIC + - API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT + type: string + projectRef: + description: 'INSERT ADDITIONAL SPEC FIELDS - desired state of cluster + Important: Run "make" to regenerate code after modifying this file' + properties: + apiVersion: + description: API version of the referent. + type: string + fieldPath: + description: 'If referring to a piece of an object instead of + an entire object, this string should contain a valid JSON/Go + field access statement, such as desiredState.manifest.containers[2]. + For example, if the object reference is to a container within + a pod, this would take on a value like: "spec.containers{name}" + (where "name" refers to the name of the container that triggered + the event) or if no container name is specified "spec.containers[2]" + (container with index 2 in this pod). This syntax is chosen + only to have some well-defined way of referencing a part of + an object. TODO: this design is not final and this field is + subject to change in the future.' + type: string + kind: + description: 'Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + namespace: + description: 'Namespace of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/' + type: string + resourceVersion: + description: 'Specific resourceVersion to which this reference + is made, if any. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency' + type: string + uid: + description: 'UID of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids' + type: string + type: object + x-kubernetes-map-type: atomic + required: + - authMethodType + - projectRef + type: object + status: + description: APIAppStatus defines the observed state of APIApp + properties: + appId: + default: "" + type: string + clientId: + default: "" + type: string + conditions: + description: 'INSERT ADDITIONAL STATUS FIELD - define observed state + of cluster Important: Run "make" to regenerate code after modifying + this file' + items: + description: "Condition contains details for one aspect of the current + state of this API Resource. --- This struct is intended for direct + use as an array at the field path .status.conditions. For example, + \n type FooStatus struct{ // Represents the observations of a + foo's current state. // Known .status.conditions.type are: \"Available\", + \"Progressing\", and \"Degraded\" // +patchMergeKey=type // +patchStrategy=merge + // +listType=map // +listMapKey=type Conditions []metav1.Condition + `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" + protobuf:\"bytes,1,rep,name=conditions\"` \n // other fields }" + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition + transitioned from one status to another. This should be when + the underlying condition changed. If that is not known, then + using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating + details about the transition. This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation + that the condition was set based upon. For instance, if .metadata.generation + is currently 12, but the .status.conditions[x].observedGeneration + is 9, the condition is out of date with respect to the current + state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating + the reason for the condition's last transition. Producers + of specific condition types may define expected values and + meanings for this field, and whether the values are considered + a guaranteed API. The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + --- Many .condition.type values are consistent across resources + like Available, but because arbitrary conditions can be useful + (see .node.status.conditions), the ability to deconflict is + important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + keyId: + default: "" + type: string + required: + - appId + - clientId + - keyId + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/ops/chart/templates/manager-rbac.yaml b/ops/chart/templates/manager-rbac.yaml index 389a533..6e7066a 100644 --- a/ops/chart/templates/manager-rbac.yaml +++ b/ops/chart/templates/manager-rbac.yaml @@ -170,6 +170,32 @@ rules: - list - patch - watch +- apiGroups: + - zitadel.topmanage.com + resources: + - apiapps + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - zitadel.topmanage.com + resources: + - apiapps/finalizers + verbs: + - update +- apiGroups: + - zitadel.topmanage.com + resources: + - apiapps/status + verbs: + - get + - patch + - update - apiGroups: - zitadel.topmanage.com resources: diff --git a/src/PROJECT b/src/PROJECT index 7b28a22..0edf841 100644 --- a/src/PROJECT +++ b/src/PROJECT @@ -56,4 +56,13 @@ resources: kind: MachineUser path: bitbucket.org/topmanage-software-engineering/zitadel-k8s-operator/src/api/v1alpha1 version: v1alpha1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: topmanage.com + group: zitadel + kind: APIApp + path: bitbucket.org/topmanage-software-engineering/zitadel-k8s-operator/src/api/v1alpha1 + version: v1alpha1 version: "3" diff --git a/src/api/v1alpha1/apiapp_types.go b/src/api/v1alpha1/apiapp_types.go new file mode 100644 index 0000000..1364e22 --- /dev/null +++ b/src/api/v1alpha1/apiapp_types.go @@ -0,0 +1,126 @@ +/* +Copyright 2024. + +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 ( + "context" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! +// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. + +// APIAppSpec defines the desired state of APIApp +type APIAppSpec struct { + // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster + // Important: Run "make" to regenerate code after modifying this file + ProjectRef ProjectRef `json:"projectRef"` + // +kubebuilder:validation:Enum=API_AUTH_METHOD_TYPE_BASIC;API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT + AuthMethodType string `json:"authMethodType"` +} + +// APIAppStatus defines the observed state of APIApp +type APIAppStatus struct { + // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster + // Important: Run "make" to regenerate code after modifying this file + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=status,xDescriptors={"urn:alm:descriptor:io.kubernetes.conditions"} + Conditions []metav1.Condition `json:"conditions,omitempty"` + // +kubebuilder:default="" + AppId string `json:"appId"` + // +kubebuilder:default="" + KeyId string `json:"keyId"` + // +kubebuilder:default="" + ClientId string `json:"clientId"` +} + +func (d *APIAppStatus) SetCondition(condition metav1.Condition) { + if d.Conditions == nil { + d.Conditions = make([]metav1.Condition, 0) + } + meta.SetStatusCondition(&d.Conditions, condition) +} + +//+kubebuilder:object:root=true +//+kubebuilder:subresource:status + +// APIApp is the Schema for the apiapps API +type APIApp struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec APIAppSpec `json:"spec,omitempty"` + Status APIAppStatus `json:"status,omitempty"` +} + +func (d *APIApp) IsBeingDeleted() bool { + return !d.DeletionTimestamp.IsZero() +} + +func (d *APIApp) IsReady() bool { + return meta.IsStatusConditionTrue(d.Status.Conditions, ConditionTypeReady) +} + +func (d *APIApp) ZitadelClusterRef(ctx context.Context, refresolver *RefResolver) (*ZitadelClusterRef, error) { + project, err := refresolver.ProjectRef(ctx, &d.Spec.ProjectRef, d.Namespace) + if err != nil { + return nil, err + } + org, err := refresolver.OrganizationRef(ctx, &project.Spec.OrganizationRef, d.Namespace) + if err != nil { + return nil, err + } + ref, err := org.ZitadelClusterRef(ctx, refresolver) + if err != nil { + return nil, err + } + return ref, nil +} +func (d *APIApp) Organization(ctx context.Context, refresolver *RefResolver) (*Organization, error) { + project, err := refresolver.ProjectRef(ctx, &d.Spec.ProjectRef, d.Namespace) + if err != nil { + return nil, err + } + org, err := refresolver.OrganizationRef(ctx, &project.Spec.OrganizationRef, d.Namespace) + if err != nil { + return nil, err + } + return org, nil +} + +func (d *APIApp) Project(ctx context.Context, refresolver *RefResolver) (*Project, error) { + project, err := refresolver.ProjectRef(ctx, &d.Spec.ProjectRef, d.Namespace) + if err != nil { + return nil, err + } + return project, nil +} + +//+kubebuilder:object:root=true + +// APIAppList contains a list of APIApp +type APIAppList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []APIApp `json:"items"` +} + +func init() { + SchemeBuilder.Register(&APIApp{}, &APIAppList{}) +} diff --git a/src/api/v1alpha1/zz_generated.deepcopy.go b/src/api/v1alpha1/zz_generated.deepcopy.go index 1c3af72..86f3a0f 100644 --- a/src/api/v1alpha1/zz_generated.deepcopy.go +++ b/src/api/v1alpha1/zz_generated.deepcopy.go @@ -26,6 +26,103 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *APIApp) DeepCopyInto(out *APIApp) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIApp. +func (in *APIApp) DeepCopy() *APIApp { + if in == nil { + return nil + } + out := new(APIApp) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *APIApp) 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 *APIAppList) DeepCopyInto(out *APIAppList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]APIApp, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIAppList. +func (in *APIAppList) DeepCopy() *APIAppList { + if in == nil { + return nil + } + out := new(APIAppList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *APIAppList) 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 *APIAppSpec) DeepCopyInto(out *APIAppSpec) { + *out = *in + out.ProjectRef = in.ProjectRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIAppSpec. +func (in *APIAppSpec) DeepCopy() *APIAppSpec { + if in == nil { + return nil + } + out := new(APIAppSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *APIAppStatus) DeepCopyInto(out *APIAppStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIAppStatus. +func (in *APIAppStatus) DeepCopy() *APIAppStatus { + if in == nil { + return nil + } + out := new(APIAppStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *CrdbClusterRef) DeepCopyInto(out *CrdbClusterRef) { *out = *in diff --git a/src/cmd/main.go b/src/cmd/main.go index 77f220e..5151801 100644 --- a/src/cmd/main.go +++ b/src/cmd/main.go @@ -133,6 +133,10 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "MachineUser") os.Exit(1) } + if err = controller.NewAPIAppReconciler(client, refResolver, builder, conditionReady, requeueZitadel).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "APIApp") + os.Exit(1) + } //+kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/src/config/crd/bases/zitadel.topmanage.com_apiapps.yaml b/src/config/crd/bases/zitadel.topmanage.com_apiapps.yaml new file mode 100644 index 0000000..b5bf07b --- /dev/null +++ b/src/config/crd/bases/zitadel.topmanage.com_apiapps.yaml @@ -0,0 +1,177 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.11.1 + creationTimestamp: null + name: apiapps.zitadel.topmanage.com +spec: + group: zitadel.topmanage.com + names: + kind: APIApp + listKind: APIAppList + plural: apiapps + singular: apiapp + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: APIApp is the Schema for the apiapps API + 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: APIAppSpec defines the desired state of APIApp + properties: + authMethodType: + enum: + - API_AUTH_METHOD_TYPE_BASIC + - API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT + type: string + projectRef: + description: 'INSERT ADDITIONAL SPEC FIELDS - desired state of cluster + Important: Run "make" to regenerate code after modifying this file' + properties: + apiVersion: + description: API version of the referent. + type: string + fieldPath: + description: 'If referring to a piece of an object instead of + an entire object, this string should contain a valid JSON/Go + field access statement, such as desiredState.manifest.containers[2]. + For example, if the object reference is to a container within + a pod, this would take on a value like: "spec.containers{name}" + (where "name" refers to the name of the container that triggered + the event) or if no container name is specified "spec.containers[2]" + (container with index 2 in this pod). This syntax is chosen + only to have some well-defined way of referencing a part of + an object. TODO: this design is not final and this field is + subject to change in the future.' + type: string + kind: + description: 'Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + namespace: + description: 'Namespace of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/' + type: string + resourceVersion: + description: 'Specific resourceVersion to which this reference + is made, if any. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency' + type: string + uid: + description: 'UID of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids' + type: string + type: object + x-kubernetes-map-type: atomic + required: + - authMethodType + - projectRef + type: object + status: + description: APIAppStatus defines the observed state of APIApp + properties: + appId: + default: "" + type: string + clientId: + default: "" + type: string + conditions: + description: 'INSERT ADDITIONAL STATUS FIELD - define observed state + of cluster Important: Run "make" to regenerate code after modifying + this file' + items: + description: "Condition contains details for one aspect of the current + state of this API Resource. --- This struct is intended for direct + use as an array at the field path .status.conditions. For example, + \n type FooStatus struct{ // Represents the observations of a + foo's current state. // Known .status.conditions.type are: \"Available\", + \"Progressing\", and \"Degraded\" // +patchMergeKey=type // +patchStrategy=merge + // +listType=map // +listMapKey=type Conditions []metav1.Condition + `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" + protobuf:\"bytes,1,rep,name=conditions\"` \n // other fields }" + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition + transitioned from one status to another. This should be when + the underlying condition changed. If that is not known, then + using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating + details about the transition. This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation + that the condition was set based upon. For instance, if .metadata.generation + is currently 12, but the .status.conditions[x].observedGeneration + is 9, the condition is out of date with respect to the current + state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating + the reason for the condition's last transition. Producers + of specific condition types may define expected values and + meanings for this field, and whether the values are considered + a guaranteed API. The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + --- Many .condition.type values are consistent across resources + like Available, but because arbitrary conditions can be useful + (see .node.status.conditions), the ability to deconflict is + important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + keyId: + default: "" + type: string + required: + - appId + - clientId + - keyId + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/src/config/crd/kustomization.yaml b/src/config/crd/kustomization.yaml index 4d426aa..08028de 100644 --- a/src/config/crd/kustomization.yaml +++ b/src/config/crd/kustomization.yaml @@ -7,6 +7,7 @@ resources: - bases/zitadel.topmanage.com_projects.yaml - bases/zitadel.topmanage.com_oidcapps.yaml - bases/zitadel.topmanage.com_machineusers.yaml +- bases/zitadel.topmanage.com_apiapps.yaml #+kubebuilder:scaffold:crdkustomizeresource patchesStrategicMerge: @@ -17,6 +18,7 @@ patchesStrategicMerge: #- patches/webhook_in_projects.yaml #- patches/webhook_in_oidcapps.yaml #- patches/webhook_in_machineusers.yaml +#- patches/webhook_in_apiapps.yaml #+kubebuilder:scaffold:crdkustomizewebhookpatch # [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix. @@ -26,6 +28,7 @@ patchesStrategicMerge: #- patches/cainjection_in_projects.yaml #- patches/cainjection_in_oidcapps.yaml #- patches/cainjection_in_machineusers.yaml +#- patches/cainjection_in_apiapps.yaml #+kubebuilder:scaffold:crdkustomizecainjectionpatch # the following config is for teaching kustomize how to do kustomization for CRDs. diff --git a/src/config/crd/patches/cainjection_in_apiapps.yaml b/src/config/crd/patches/cainjection_in_apiapps.yaml new file mode 100644 index 0000000..9332f3d --- /dev/null +++ b/src/config/crd/patches/cainjection_in_apiapps.yaml @@ -0,0 +1,7 @@ +# The following patch adds a directive for certmanager to inject CA into the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + cert-manager.io/inject-ca-from: CERTIFICATE_NAMESPACE/CERTIFICATE_NAME + name: apiapps.zitadel.topmanage.com diff --git a/src/config/crd/patches/webhook_in_apiapps.yaml b/src/config/crd/patches/webhook_in_apiapps.yaml new file mode 100644 index 0000000..04173a6 --- /dev/null +++ b/src/config/crd/patches/webhook_in_apiapps.yaml @@ -0,0 +1,16 @@ +# The following patch enables a conversion webhook for the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: apiapps.zitadel.topmanage.com +spec: + conversion: + strategy: Webhook + webhook: + clientConfig: + service: + namespace: system + name: webhook-service + path: /convert + conversionReviewVersions: + - v1 diff --git a/src/config/rbac/apiapp_editor_role.yaml b/src/config/rbac/apiapp_editor_role.yaml new file mode 100644 index 0000000..3bf43db --- /dev/null +++ b/src/config/rbac/apiapp_editor_role.yaml @@ -0,0 +1,31 @@ +# permissions for end users to edit apiapps. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: apiapp-editor-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: src + app.kubernetes.io/part-of: src + app.kubernetes.io/managed-by: kustomize + name: apiapp-editor-role +rules: +- apiGroups: + - zitadel.topmanage.com + resources: + - apiapps + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - zitadel.topmanage.com + resources: + - apiapps/status + verbs: + - get diff --git a/src/config/rbac/apiapp_viewer_role.yaml b/src/config/rbac/apiapp_viewer_role.yaml new file mode 100644 index 0000000..042b6f3 --- /dev/null +++ b/src/config/rbac/apiapp_viewer_role.yaml @@ -0,0 +1,27 @@ +# permissions for end users to view apiapps. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: apiapp-viewer-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: src + app.kubernetes.io/part-of: src + app.kubernetes.io/managed-by: kustomize + name: apiapp-viewer-role +rules: +- apiGroups: + - zitadel.topmanage.com + resources: + - apiapps + verbs: + - get + - list + - watch +- apiGroups: + - zitadel.topmanage.com + resources: + - apiapps/status + verbs: + - get diff --git a/src/config/rbac/role.yaml b/src/config/rbac/role.yaml index 02c7efc..8e3f4a9 100644 --- a/src/config/rbac/role.yaml +++ b/src/config/rbac/role.yaml @@ -170,6 +170,32 @@ rules: - list - patch - watch +- apiGroups: + - zitadel.topmanage.com + resources: + - apiapps + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - zitadel.topmanage.com + resources: + - apiapps/finalizers + verbs: + - update +- apiGroups: + - zitadel.topmanage.com + resources: + - apiapps/status + verbs: + - get + - patch + - update - apiGroups: - zitadel.topmanage.com resources: diff --git a/src/config/samples/kustomization.yaml b/src/config/samples/kustomization.yaml index a9ca3f7..fe16a01 100644 --- a/src/config/samples/kustomization.yaml +++ b/src/config/samples/kustomization.yaml @@ -5,4 +5,5 @@ resources: - zitadel_v1alpha1_project.yaml - zitadel_v1alpha1_oidcapp.yaml - zitadel_v1alpha1_machineuser.yaml +- zitadel_v1alpha1_apiapp.yaml #+kubebuilder:scaffold:manifestskustomizesamples diff --git a/src/config/samples/zitadel_v1alpha1_apiapp.yaml b/src/config/samples/zitadel_v1alpha1_apiapp.yaml new file mode 100644 index 0000000..3aea6ef --- /dev/null +++ b/src/config/samples/zitadel_v1alpha1_apiapp.yaml @@ -0,0 +1,12 @@ +apiVersion: zitadel.topmanage.com/v1alpha1 +kind: APIApp +metadata: + labels: + app.kubernetes.io/name: apiapp + app.kubernetes.io/instance: apiapp-sample + app.kubernetes.io/part-of: src + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/created-by: src + name: apiapp-sample +spec: + # TODO(user): Add fields here diff --git a/src/go.mod b/src/go.mod index e51cff5..ee64d49 100644 --- a/src/go.mod +++ b/src/go.mod @@ -13,6 +13,7 @@ require ( github.com/sethvargo/go-password v0.2.0 github.com/zitadel/oidc v1.13.5 github.com/zitadel/zitadel-go/v2 v2.1.10 + golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 golang.org/x/oauth2 v0.18.0 google.golang.org/grpc v1.62.1 google.golang.org/protobuf v1.33.0 @@ -82,7 +83,6 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.25.0 // indirect golang.org/x/crypto v0.21.0 // indirect - golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect golang.org/x/net v0.22.0 // indirect golang.org/x/sys v0.18.0 // indirect golang.org/x/term v0.18.0 // indirect diff --git a/src/internal/controller/apiapp_controller.go b/src/internal/controller/apiapp_controller.go new file mode 100644 index 0000000..0097ad3 --- /dev/null +++ b/src/internal/controller/apiapp_controller.go @@ -0,0 +1,261 @@ +/* +Copyright 2024. + +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 controller + +import ( + "context" + "fmt" + "strings" + "time" + + zitadelv1alpha1 "bitbucket.org/topmanage-software-engineering/zitadel-k8s-operator/src/api/v1alpha1" + "bitbucket.org/topmanage-software-engineering/zitadel-k8s-operator/src/pkg/builder" + condition "bitbucket.org/topmanage-software-engineering/zitadel-k8s-operator/src/pkg/condition" + "bitbucket.org/topmanage-software-engineering/zitadel-k8s-operator/src/pkg/controller/zitadel" + "github.com/zitadel/zitadel-go/v2/pkg/client/management" + "github.com/zitadel/zitadel-go/v2/pkg/client/middleware" + app "github.com/zitadel/zitadel-go/v2/pkg/client/zitadel/app" + "github.com/zitadel/zitadel-go/v2/pkg/client/zitadel/authn" + pb "github.com/zitadel/zitadel-go/v2/pkg/client/zitadel/management" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/workqueue" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + ctrlClient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" +) + +// APIAppReconciler reconciles a APIApp object +type APIAppReconciler struct { + client.Client + RefResolver *zitadelv1alpha1.RefResolver + ConditionReady *condition.Ready + RequeueInterval time.Duration + Builder *builder.Builder +} + +func NewAPIAppReconciler(client client.Client, refResolver *zitadelv1alpha1.RefResolver, builder *builder.Builder, conditionReady *condition.Ready, + requeueInterval time.Duration) *APIAppReconciler { + return &APIAppReconciler{ + Client: client, + RefResolver: refResolver, + ConditionReady: conditionReady, + RequeueInterval: requeueInterval, + Builder: builder, + } +} + +//+kubebuilder:rbac:groups=zitadel.topmanage.com,resources=apiapps,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=zitadel.topmanage.com,resources=apiapps/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=zitadel.topmanage.com,resources=apiapps/finalizers,verbs=update + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +func (r *APIAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + var APIApp zitadelv1alpha1.APIApp + if err := r.Get(ctx, req.NamespacedName, &APIApp); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + wr := newWrappedAPIAppReconciler(r.Client, r.RefResolver, r.Builder, &APIApp) + wf := newWrappedAPIAppFinalizer(r.Client, &APIApp, r.RefResolver) + tf := zitadel.NewZitadelFinalizer(r.Client, wf) + tr := zitadel.NewZitadelReconciler(r.Client, r.ConditionReady, wr, tf, r.RequeueInterval) + + result, err := tr.Reconcile(ctx, &APIApp) + if err != nil { + return result, fmt.Errorf("error reconciling in APIAppReconciler: %v", err) + } + return result, nil +} + +type wrappedAPIAppReconciler struct { + client.Client + refResolver *zitadelv1alpha1.RefResolver + APIApp *zitadelv1alpha1.APIApp + Builder *builder.Builder +} + +func newWrappedAPIAppReconciler(client client.Client, refResolver *zitadelv1alpha1.RefResolver, builder *builder.Builder, + APIApp *zitadelv1alpha1.APIApp) zitadel.WrappedReconciler { + return &wrappedAPIAppReconciler{ + Client: client, + refResolver: refResolver, + APIApp: APIApp, + Builder: builder, + } +} + +type apiAppReoncilePhase struct { + Name string + Reconcile func(context.Context, *management.Client) error +} + +func (wr *wrappedAPIAppReconciler) Reconcile(ctx context.Context, ztdClient *management.Client) error { + phases := []projectReconcilePhase{ + { + Name: "apiapp", + Reconcile: wr.reconcileApp, + }, + { + Name: "keys", + Reconcile: wr.reconcileKeys, + }, + } + for _, p := range phases { + err := p.Reconcile(ctx, ztdClient) + if err != nil { + return err + } + } + return nil +} + +func (wr *wrappedAPIAppReconciler) reconcileApp(ctx context.Context, ztdClient *management.Client) error { + org, err := wr.APIApp.Organization(ctx, wr.refResolver) + if err != nil { + return err + } + project, err := wr.APIApp.Project(ctx, wr.refResolver) + if err != nil { + return err + } + if wr.APIApp.Status.AppId != "" { + appResp, err := ztdClient.GetAppByID(middleware.SetOrgID(ctx, org.Status.OrgId), &pb.GetAppByIDRequest{ + ProjectId: project.Status.ProjectId, + AppId: string(wr.APIApp.Status.AppId), + }) + if err != nil { + return fmt.Errorf("Error getting APIApp: %v", err) + } + if appResp.App != nil { + _, err := ztdClient.UpdateAPIAppConfig(middleware.SetOrgID(ctx, org.Status.OrgId), &pb.UpdateAPIAppConfigRequest{ProjectId: project.Status.ProjectId, AppId: wr.APIApp.Status.AppId, + AuthMethodType: app.APIAuthMethodType(app.APIAuthMethodType_value[wr.APIApp.Spec.AuthMethodType]), + }) + if err != nil { + if !strings.Contains(err.Error(), "No changes") { + return fmt.Errorf("Error updating APIApp: %v", err) + } + } + return nil + } + } + + resp, err := ztdClient.AddAPIApp(middleware.SetOrgID(ctx, org.Status.OrgId), + &pb.AddAPIAppRequest{ + Name: wr.APIApp.Name, + ProjectId: project.Status.ProjectId, + AuthMethodType: app.APIAuthMethodType(app.APIAuthMethodType_value[wr.APIApp.Spec.AuthMethodType]), + }, + ) + if err != nil { + if strings.Contains(err.Error(), "AlreadyExists") { + return nil + } + return fmt.Errorf("error creating APIApp in Zitadel: %v", err) + } + key := types.NamespacedName{ + Name: wr.APIApp.Name + "-client-secret", + Namespace: wr.APIApp.Namespace, + } + + secretData := map[string][]byte{"client-secret": []byte(resp.ClientSecret)} + secret, err := wr.Builder.BuildSecret(builder.SecretOpts{Immutable: true, Zitadel: nil, Key: key, Data: secretData}, wr.APIApp) + if err != nil { + return fmt.Errorf("error building Secret: %v", err) + } + if err := wr.Create(ctx, secret); err != nil { + return fmt.Errorf("error creating Client-secret Secret: %v", err) + } + patch := ctrlClient.MergeFrom(wr.APIApp.DeepCopy()) + wr.APIApp.Status.AppId = resp.AppId + wr.APIApp.Status.ClientId = resp.ClientId + return wr.Client.Status().Patch(ctx, wr.APIApp, patch) +} + +func (wr *wrappedAPIAppReconciler) reconcileKeys(ctx context.Context, ztdClient *management.Client) error { + if wr.APIApp.Spec.AuthMethodType == "API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT" { + org, err := wr.APIApp.Organization(ctx, wr.refResolver) + if err != nil { + return err + } + project, err := wr.APIApp.Project(ctx, wr.refResolver) + if err != nil { + return err + } + if wr.APIApp.Status.KeyId != "" { + _, err = ztdClient.GetAppKey(middleware.SetOrgID(ctx, org.Status.OrgId), &pb.GetAppKeyRequest{ + ProjectId: project.Status.ProjectId, + AppId: wr.APIApp.Status.AppId, + KeyId: wr.APIApp.Status.KeyId, + }) + if err != nil { + if strings.Contains(err.Error(), "AlreadyExists") { + return nil + } + } + } + resp, err := ztdClient.AddAppKey(middleware.SetOrgID(ctx, org.Status.OrgId), &pb.AddAppKeyRequest{ + ProjectId: project.Status.ProjectId, + AppId: wr.APIApp.Status.AppId, + Type: authn.KeyType_KEY_TYPE_JSON, + ExpirationDate: nil, + }) + + if err != nil { + return fmt.Errorf("Error adding Key to app: %v", err) + } + + key := types.NamespacedName{ + Name: wr.APIApp.Name + "-privatekey-secret", + Namespace: wr.APIApp.Namespace, + } + + secretData := map[string][]byte{"key.json": resp.KeyDetails} + secret, err := wr.Builder.BuildSecret(builder.SecretOpts{Immutable: true, Zitadel: nil, Key: key, Data: secretData}, wr.APIApp) + if err != nil { + return fmt.Errorf("error building Secret: %v", err) + } + if err := wr.Create(ctx, secret); err != nil { + return fmt.Errorf("error creating Client-secret Secret: %v", err) + } + patch := ctrlClient.MergeFrom(wr.APIApp.DeepCopy()) + wr.APIApp.Status.KeyId = resp.Id + return wr.Client.Status().Patch(ctx, wr.APIApp, patch) + + } + return nil +} + +func (wr *wrappedAPIAppReconciler) PatchStatus(ctx context.Context, patcher condition.Patcher) error { + patch := client.MergeFrom(wr.APIApp.DeepCopy()) + patcher(&wr.APIApp.Status) + + if err := wr.Client.Status().Patch(ctx, wr.APIApp, patch); err != nil { + return fmt.Errorf("error patching APIApp status: %v", err) + } + return nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *APIAppReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&zitadelv1alpha1.APIApp{}). + Owns(&corev1.Secret{}). + WithOptions(controller.Options{RateLimiter: workqueue.NewItemExponentialFailureRateLimiter(time.Millisecond*500, time.Minute*3)}). + Complete(r) +} diff --git a/src/internal/controller/apiapp_controller_finalizer.go b/src/internal/controller/apiapp_controller_finalizer.go new file mode 100644 index 0000000..4ab174b --- /dev/null +++ b/src/internal/controller/apiapp_controller_finalizer.go @@ -0,0 +1,91 @@ +package controller + +import ( + "strings" + + zitadelv1alpha1 "bitbucket.org/topmanage-software-engineering/zitadel-k8s-operator/src/api/v1alpha1" + "bitbucket.org/topmanage-software-engineering/zitadel-k8s-operator/src/pkg/controller/zitadel" + + "context" + "fmt" + + "github.com/zitadel/zitadel-go/v2/pkg/client/management" + "github.com/zitadel/zitadel-go/v2/pkg/client/middleware" + pb "github.com/zitadel/zitadel-go/v2/pkg/client/zitadel/management" + "sigs.k8s.io/controller-runtime/pkg/client" + ctrlClient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +const ( + APIAppFinalizerName = "apiapp.zitadel.topmanage.com/apiapp" +) + +type wrappedAPIAppFinalizer struct { + client.Client + APIApp *zitadelv1alpha1.APIApp + refresolver *zitadelv1alpha1.RefResolver +} + +func newWrappedAPIAppFinalizer(client client.Client, APIApp *zitadelv1alpha1.APIApp, refresolver *zitadelv1alpha1.RefResolver) zitadel.WrappedFinalizer { + return &wrappedAPIAppFinalizer{ + Client: client, + APIApp: APIApp, + refresolver: refresolver, + } +} + +func (wf *wrappedAPIAppFinalizer) AddFinalizer(ctx context.Context) error { + if wf.ContainsFinalizer() { + return nil + } + return wf.patch(ctx, wf.APIApp, func(APIApp *zitadelv1alpha1.APIApp) { + controllerutil.AddFinalizer(APIApp, APIAppFinalizerName) + }) +} + +func (wf *wrappedAPIAppFinalizer) RemoveFinalizer(ctx context.Context) error { + if !wf.ContainsFinalizer() { + return nil + } + return wf.patch(ctx, wf.APIApp, func(APIApp *zitadelv1alpha1.APIApp) { + controllerutil.RemoveFinalizer(wf.APIApp, APIAppFinalizerName) + }) +} + +func (wr *wrappedAPIAppFinalizer) ContainsFinalizer() bool { + return controllerutil.ContainsFinalizer(wr.APIApp, APIAppFinalizerName) +} + +func (wf *wrappedAPIAppFinalizer) Reconcile(ctx context.Context, ztdClient *management.Client) error { + if wf.APIApp.Status.AppId == "" { + return nil + } + org, err := wf.APIApp.Organization(ctx, wf.refresolver) + if err != nil { + return err + } + project, err := wf.APIApp.Project(ctx, wf.refresolver) + if err != nil { + return err + } + _, err = ztdClient.RemoveApp(middleware.SetOrgID(ctx, org.Status.OrgId), &pb.RemoveAppRequest{ProjectId: project.Status.ProjectId, AppId: wf.APIApp.Status.AppId}) + if err != nil { + if strings.Contains(err.Error(), "doesn't exist") { + return nil + } + return err + } + return nil +} + +func (wr *wrappedAPIAppFinalizer) patch(ctx context.Context, APIApp *zitadelv1alpha1.APIApp, + patchFn func(*zitadelv1alpha1.APIApp)) error { + patch := ctrlClient.MergeFrom(APIApp.DeepCopy()) + patchFn(APIApp) + + if err := wr.Client.Patch(ctx, APIApp, patch); err != nil { + return fmt.Errorf("error patching APIApp finalizer: %v", err) + } + return nil +} diff --git a/src/internal/controller/machineuser_controller.go b/src/internal/controller/machineuser_controller.go index 8e5934b..1f598d7 100644 --- a/src/internal/controller/machineuser_controller.go +++ b/src/internal/controller/machineuser_controller.go @@ -84,39 +84,7 @@ func newWrappedMachineUserReconciler(client client.Client, refResolver *zitadelv } func (wr *wrappedMachineUserReconciler) Reconcile(ctx context.Context, ztdClient *management.Client) error { - // if wr.MachineUser.Status.AppId != "" { - // appResp, err := ztdClient.GetAppByID(middleware.SetOrgID(ctx, org.Status.OrgId), &pb.GetAppByIDRequest{ - // ProjectId: project.Status.ProjectId, - // AppId: string(wr.MachineUser.Status.AppId), - // }) - // if err != nil { - // return fmt.Errorf("Error getting MachineUser: %v", err) - // } - // if appResp.App != nil { - // _, err := ztdClient.UpdateMachineUserConfig(middleware.SetOrgID(ctx, org.Status.OrgId), &pb.UpdateMachineUserConfigRequest{ProjectId: project.Status.ProjectId, AppId: wr.MachineUser.Status.AppId, - // RedirectUris: wr.MachineUser.Spec.RedirectUris, - // ResponseTypes: responseTypes, - // GrantTypes: grantTypes, - // AppType: app.MachineUserType(app.MachineUserType_value[wr.MachineUser.Spec.AppType]), - // AuthMethodType: app.OIDCAuthMethodType(app.OIDCAuthMethodType_value[wr.MachineUser.Spec.AuthMethodType]), - // PostLogoutRedirectUris: wr.MachineUser.Spec.PostLogoutRedirectUris, - // DevMode: wr.MachineUser.Spec.DevMode, - // AccessTokenType: app.OIDCTokenType(app.OIDCTokenType_value[wr.MachineUser.Spec.AccessTokenType]), - // AccessTokenRoleAssertion: wr.MachineUser.Spec.AccessTokenRoleAssertion, - // IdTokenRoleAssertion: wr.MachineUser.Spec.IdTokenRoleAssertion, - // IdTokenUserinfoAssertion: wr.MachineUser.Spec.IdTokenUserinfoAssertion, - // ClockSkew: durationpb.New(wr.MachineUser.Spec.ClockSkew.Duration), - // AdditionalOrigins: wr.MachineUser.Spec.AdditionalOrigins, - // SkipNativeAppSuccessPage: wr.MachineUser.Spec.SkipNativeAppSuccessPage, - // }) - // if err != nil { - // if !strings.Contains(err.Error(), "No changes") { - // return fmt.Errorf("Error updating MachineUser: %v", err) - // } - // } - // return nil - // } - // } + // TODO: update machine user zitadel, err := wr.refResolver.ZitadelCluster(ctx, &wr.MachineUser.Spec.ZitadelClusterRef, wr.MachineUser.Namespace) if err != nil { return err