From e09d85f570792fa184eb1666544c5ec42be6ec5c Mon Sep 17 00:00:00 2001 From: Myroslav Vivcharyk Date: Tue, 28 Jul 2026 19:31:58 +0200 Subject: [PATCH 1/3] feat(organizationproject): added OrganizationProject resource --- CHANGELOG.md | 2 + PROJECT | 8 + api/v1alpha1/groupversion_info.go | 1 + api/v1alpha1/organizationproject_types.go | 110 ++++ api/v1alpha1/zz_generated.deepcopy.go | 115 ++++ .../aiven.io_organizationprojects.yaml | 252 ++++++++ .../templates/cluster_role.yaml | 3 + .../bases/aiven.io_organizationprojects.yaml | 252 ++++++++ config/rbac/role.yaml | 3 + controllers/client.go | 4 +- controllers/common.go | 8 + controllers/organizationproject_controller.go | 211 ++++++ .../organizationproject_controller_test.go | 604 ++++++++++++++++++ controllers/setup.go | 1 + .../examples/organizationproject.yaml | 24 + docs/docs/resources/organizationproject.md | 171 +++++ docs/mkdocs.yml | 1 + docs/permissions.yaml | 9 + tests/organizationproject_test.go | 201 ++++++ 19 files changed, 1978 insertions(+), 2 deletions(-) create mode 100644 api/v1alpha1/organizationproject_types.go create mode 100644 charts/aiven-operator-crds/templates/aiven.io_organizationprojects.yaml create mode 100644 config/crd/bases/aiven.io_organizationprojects.yaml create mode 100644 controllers/organizationproject_controller.go create mode 100644 controllers/organizationproject_controller_test.go create mode 100644 docs/docs/resources/examples/organizationproject.yaml create mode 100644 docs/docs/resources/organizationproject.md create mode 100644 tests/organizationproject_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 4accd6eec..8492b7872 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [MAJOR.MINOR.PATCH] - YYYY-MM-DD +- Add kind: `OrganizationProject` to manage Aiven projects that belong to an organization or organizational unit. + ## v0.43.0 - 2026-07-24 - Add `ServiceUser` field `username`, type `string`: Username of the service user on Aiven. diff --git a/PROJECT b/PROJECT index c9b6ae2c2..6421de6a4 100644 --- a/PROJECT +++ b/PROJECT @@ -309,4 +309,12 @@ resources: kind: KafkaQuota path: github.com/aiven/aiven-operator/api/v1alpha1 version: v1alpha1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: aiven.io + kind: OrganizationProject + path: github.com/aiven/aiven-operator/api/v1alpha1 + version: v1alpha1 version: "3" diff --git a/api/v1alpha1/groupversion_info.go b/api/v1alpha1/groupversion_info.go index b718e1021..1bb1e4c9d 100644 --- a/api/v1alpha1/groupversion_info.go +++ b/api/v1alpha1/groupversion_info.go @@ -47,6 +47,7 @@ func addKnownTypes(scheme *runtime.Scheme) error { &MySQL{}, &MySQLList{}, &OpenSearch{}, &OpenSearchList{}, &OpenSearchACLConfig{}, &OpenSearchACLConfigList{}, + &OrganizationProject{}, &OrganizationProjectList{}, &PostgreSQL{}, &PostgreSQLList{}, &Project{}, &ProjectList{}, &ProjectVPC{}, &ProjectVPCList{}, diff --git a/api/v1alpha1/organizationproject_types.go b/api/v1alpha1/organizationproject_types.go new file mode 100644 index 000000000..3b625e215 --- /dev/null +++ b/api/v1alpha1/organizationproject_types.go @@ -0,0 +1,110 @@ +// Copyright (c) 2026 Aiven, Helsinki, Finland. https://aiven.io/ + +package v1alpha1 + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +// OrganizationProjectSpec defines the desired state of OrganizationProject. +type OrganizationProjectSpec struct { + AuthSecretRefField `json:",inline"` + SecretFields `json:",inline"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + // OrganizationID is the Aiven organization ID that owns the project. + // It is the addressing key for the project and cannot be changed (moving a project + // between organizations is not supported). + OrganizationID string `json:"organizationId"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=63 + // +kubebuilder:validation:Pattern="^[a-zA-Z0-9_-]+$" + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + // ProjectID is the name of the project. It is immutable once set. + ProjectID string `json:"projectId"` + + // +kubebuilder:validation:MinLength=1 + // BillingGroupID is the ID of the billing group the project is assigned to. + BillingGroupID string `json:"billingGroupId"` + + // +kubebuilder:validation:MinLength=1 + // ParentID is the ID of the organization or organizational unit the project belongs to. + // Moving a project between organizational units within the same organization is supported. + ParentID string `json:"parentId"` + + // +kubebuilder:validation:Minimum=10000 + // +kubebuilder:validation:Maximum=30000 + // BasePort is the valid port number range for the project, from 10000 to 30000. + // When omitted, the field is unmanaged: Aiven assigns the value and changes made + // outside Kubernetes are left as is. + BasePort *int `json:"basePort,omitempty"` + + // +kubebuilder:validation:MaxItems=10 + // TechnicalEmails are the technical contact emails of the project. + // This list is authoritative: when omitted, emails added outside + // Kubernetes are removed. + TechnicalEmails []string `json:"technicalEmails,omitempty"` + + // Tags are key-value pairs that allow you to categorize projects. + // This map is authoritative: when omitted, tags added outside + // Kubernetes are removed. + Tags map[string]string `json:"tags,omitempty"` +} + +// OrganizationProjectStatus defines the observed state of OrganizationProject. +type OrganizationProjectStatus struct { + // Conditions represent the latest available observations of an OrganizationProject state. + Conditions []metav1.Condition `json:"conditions"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// OrganizationProject is the Schema for the organizationprojects API. +// Warning "Adoption of existing projects": +// If `projectId` refers to a project that already exists in the organization, the operator adopts it: +// the remote state is overwritten to match the spec (billing group, parent, tags, technical emails, base port), +// and deleting the resource deletes the project in Aiven. +// +// Info "Exposes secret keys": `ORGANIZATIONPROJECT_CA_CERT` +// +kubebuilder:printcolumn:name="Organization",type="string",JSONPath=".spec.organizationId" +// +kubebuilder:printcolumn:name="Project",type="string",JSONPath=".spec.projectId" +// +kubebuilder:printcolumn:name="Parent",type="string",JSONPath=".spec.parentId" +type OrganizationProject struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec OrganizationProjectSpec `json:"spec,omitempty"` + Status OrganizationProjectStatus `json:"status,omitempty"` +} + +var _ AivenManagedObject = &OrganizationProject{} + +func (in *OrganizationProject) AuthSecretRef() *AuthSecretReference { + return in.Spec.AuthSecretRef +} + +func (in *OrganizationProject) Conditions() *[]metav1.Condition { + return &in.Status.Conditions +} + +func (in *OrganizationProject) GetObjectMeta() *metav1.ObjectMeta { + return &in.ObjectMeta +} + +func (in *OrganizationProject) NoSecret() bool { + return in.Spec.ConnInfoSecretTargetDisabled != nil && *in.Spec.ConnInfoSecretTargetDisabled +} + +func (in *OrganizationProject) GetConnInfoSecretTarget() ConnInfoSecretTarget { + return in.Spec.ConnInfoSecretTarget +} + +// +kubebuilder:object:root=true + +// OrganizationProjectList contains a list of OrganizationProject. +type OrganizationProjectList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []OrganizationProject `json:"items"` +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 553916f59..f2b8a3b31 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -2481,6 +2481,121 @@ func (in *OpenSearchSpec) DeepCopy() *OpenSearchSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OrganizationProject) DeepCopyInto(out *OrganizationProject) { + *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 OrganizationProject. +func (in *OrganizationProject) DeepCopy() *OrganizationProject { + if in == nil { + return nil + } + out := new(OrganizationProject) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *OrganizationProject) 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 *OrganizationProjectList) DeepCopyInto(out *OrganizationProjectList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]OrganizationProject, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OrganizationProjectList. +func (in *OrganizationProjectList) DeepCopy() *OrganizationProjectList { + if in == nil { + return nil + } + out := new(OrganizationProjectList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *OrganizationProjectList) 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 *OrganizationProjectSpec) DeepCopyInto(out *OrganizationProjectSpec) { + *out = *in + in.AuthSecretRefField.DeepCopyInto(&out.AuthSecretRefField) + in.SecretFields.DeepCopyInto(&out.SecretFields) + if in.BasePort != nil { + in, out := &in.BasePort, &out.BasePort + *out = new(int) + **out = **in + } + if in.TechnicalEmails != nil { + in, out := &in.TechnicalEmails, &out.TechnicalEmails + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Tags != nil { + in, out := &in.Tags, &out.Tags + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OrganizationProjectSpec. +func (in *OrganizationProjectSpec) DeepCopy() *OrganizationProjectSpec { + if in == nil { + return nil + } + out := new(OrganizationProjectSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OrganizationProjectStatus) DeepCopyInto(out *OrganizationProjectStatus) { + *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 OrganizationProjectStatus. +func (in *OrganizationProjectStatus) DeepCopy() *OrganizationProjectStatus { + if in == nil { + return nil + } + out := new(OrganizationProjectStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PostgreSQL) DeepCopyInto(out *PostgreSQL) { *out = *in diff --git a/charts/aiven-operator-crds/templates/aiven.io_organizationprojects.yaml b/charts/aiven-operator-crds/templates/aiven.io_organizationprojects.yaml new file mode 100644 index 000000000..ba7691c1d --- /dev/null +++ b/charts/aiven-operator-crds/templates/aiven.io_organizationprojects.yaml @@ -0,0 +1,252 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: organizationprojects.aiven.io +spec: + group: aiven.io + names: + kind: OrganizationProject + listKind: OrganizationProjectList + plural: organizationprojects + singular: organizationproject + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.organizationId + name: Organization + type: string + - jsonPath: .spec.projectId + name: Project + type: string + - jsonPath: .spec.parentId + name: Parent + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + OrganizationProject is the Schema for the organizationprojects API. + Warning "Adoption of existing projects": + If `projectId` refers to a project that already exists in the organization, the operator adopts it: + the remote state is overwritten to match the spec (billing group, parent, tags, technical emails, base port), + and deleting the resource deletes the project in Aiven. + + Info "Exposes secret keys": `ORGANIZATIONPROJECT_CA_CERT` + 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: OrganizationProjectSpec defines the desired state of OrganizationProject. + properties: + authSecretRef: + description: Authentication reference to Aiven token in a secret + properties: + key: + minLength: 1 + type: string + name: + minLength: 1 + type: string + required: + - key + - name + type: object + basePort: + description: |- + BasePort is the valid port number range for the project, from 10000 to 30000. + When omitted, the field is unmanaged: Aiven assigns the value and changes made + outside Kubernetes are left as is. + maximum: 30000 + minimum: 10000 + type: integer + billingGroupId: + description: + BillingGroupID is the ID of the billing group the project + is assigned to. + minLength: 1 + type: string + connInfoSecretTarget: + description: Secret configuration. + properties: + annotations: + additionalProperties: + type: string + description: Annotations added to the secret + type: object + x-kubernetes-preserve-unknown-fields: true + labels: + additionalProperties: + type: string + description: Labels added to the secret + type: object + x-kubernetes-preserve-unknown-fields: true + name: + description: + Name of the secret resource to be created. By default, + it is equal to the resource name + type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + prefix: + description: |- + Prefix for the secret's keys. + Added "as is" without any transformations. + By default, is equal to the kind name in uppercase + underscore, e.g. `KAFKA_`, `REDIS_`, etc. + type: string + required: + - name + type: object + connInfoSecretTargetDisabled: + description: + When true, the secret containing connection information + will not be created, defaults to false. This field cannot be changed + after resource creation. + type: boolean + x-kubernetes-validations: + - message: connInfoSecretTargetDisabled is immutable. + rule: self == oldSelf + organizationId: + description: |- + OrganizationID is the Aiven organization ID that owns the project. + It is the addressing key for the project and cannot be changed (moving a project + between organizations is not supported). + minLength: 1 + type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + parentId: + description: |- + ParentID is the ID of the organization or organizational unit the project belongs to. + Moving a project between organizational units within the same organization is supported. + minLength: 1 + type: string + projectId: + description: + ProjectID is the name of the project. It is immutable + once set. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z0-9_-]+$ + type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + tags: + additionalProperties: + type: string + description: |- + Tags are key-value pairs that allow you to categorize projects. + This map is authoritative: when omitted, tags added outside + Kubernetes are removed. + type: object + technicalEmails: + description: |- + TechnicalEmails are the technical contact emails of the project. + This list is authoritative: when omitted, emails added outside + Kubernetes are removed. + items: + type: string + maxItems: 10 + type: array + required: + - billingGroupId + - organizationId + - parentId + - projectId + type: object + x-kubernetes-validations: + - message: + connInfoSecretTargetDisabled can only be set during resource + creation. + rule: has(oldSelf.connInfoSecretTargetDisabled) == has(self.connInfoSecretTargetDisabled) + status: + description: OrganizationProjectStatus defines the observed state of OrganizationProject. + properties: + conditions: + description: + Conditions represent the latest available observations + of an OrganizationProject state. + items: + description: + Condition contains details for one aspect of the current + state of this API Resource. + 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. + 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 + required: + - conditions + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/charts/aiven-operator/templates/cluster_role.yaml b/charts/aiven-operator/templates/cluster_role.yaml index 428bd1915..aaa30fc29 100644 --- a/charts/aiven-operator/templates/cluster_role.yaml +++ b/charts/aiven-operator/templates/cluster_role.yaml @@ -50,6 +50,7 @@ rules: - mysqls - opensearchaclconfigs - opensearches + - organizationprojects - postgresqls - projects - projectvpcs @@ -90,6 +91,7 @@ rules: - mysqls/finalizers - opensearchaclconfigs/finalizers - opensearches/finalizers + - organizationprojects/finalizers - postgresqls/finalizers - projects/finalizers - projectvpcs/finalizers @@ -126,6 +128,7 @@ rules: - mysqls/status - opensearchaclconfigs/status - opensearches/status + - organizationprojects/status - postgresqls/status - projects/status - projectvpcs/status diff --git a/config/crd/bases/aiven.io_organizationprojects.yaml b/config/crd/bases/aiven.io_organizationprojects.yaml new file mode 100644 index 000000000..ba7691c1d --- /dev/null +++ b/config/crd/bases/aiven.io_organizationprojects.yaml @@ -0,0 +1,252 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: organizationprojects.aiven.io +spec: + group: aiven.io + names: + kind: OrganizationProject + listKind: OrganizationProjectList + plural: organizationprojects + singular: organizationproject + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.organizationId + name: Organization + type: string + - jsonPath: .spec.projectId + name: Project + type: string + - jsonPath: .spec.parentId + name: Parent + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + OrganizationProject is the Schema for the organizationprojects API. + Warning "Adoption of existing projects": + If `projectId` refers to a project that already exists in the organization, the operator adopts it: + the remote state is overwritten to match the spec (billing group, parent, tags, technical emails, base port), + and deleting the resource deletes the project in Aiven. + + Info "Exposes secret keys": `ORGANIZATIONPROJECT_CA_CERT` + 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: OrganizationProjectSpec defines the desired state of OrganizationProject. + properties: + authSecretRef: + description: Authentication reference to Aiven token in a secret + properties: + key: + minLength: 1 + type: string + name: + minLength: 1 + type: string + required: + - key + - name + type: object + basePort: + description: |- + BasePort is the valid port number range for the project, from 10000 to 30000. + When omitted, the field is unmanaged: Aiven assigns the value and changes made + outside Kubernetes are left as is. + maximum: 30000 + minimum: 10000 + type: integer + billingGroupId: + description: + BillingGroupID is the ID of the billing group the project + is assigned to. + minLength: 1 + type: string + connInfoSecretTarget: + description: Secret configuration. + properties: + annotations: + additionalProperties: + type: string + description: Annotations added to the secret + type: object + x-kubernetes-preserve-unknown-fields: true + labels: + additionalProperties: + type: string + description: Labels added to the secret + type: object + x-kubernetes-preserve-unknown-fields: true + name: + description: + Name of the secret resource to be created. By default, + it is equal to the resource name + type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + prefix: + description: |- + Prefix for the secret's keys. + Added "as is" without any transformations. + By default, is equal to the kind name in uppercase + underscore, e.g. `KAFKA_`, `REDIS_`, etc. + type: string + required: + - name + type: object + connInfoSecretTargetDisabled: + description: + When true, the secret containing connection information + will not be created, defaults to false. This field cannot be changed + after resource creation. + type: boolean + x-kubernetes-validations: + - message: connInfoSecretTargetDisabled is immutable. + rule: self == oldSelf + organizationId: + description: |- + OrganizationID is the Aiven organization ID that owns the project. + It is the addressing key for the project and cannot be changed (moving a project + between organizations is not supported). + minLength: 1 + type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + parentId: + description: |- + ParentID is the ID of the organization or organizational unit the project belongs to. + Moving a project between organizational units within the same organization is supported. + minLength: 1 + type: string + projectId: + description: + ProjectID is the name of the project. It is immutable + once set. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z0-9_-]+$ + type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + tags: + additionalProperties: + type: string + description: |- + Tags are key-value pairs that allow you to categorize projects. + This map is authoritative: when omitted, tags added outside + Kubernetes are removed. + type: object + technicalEmails: + description: |- + TechnicalEmails are the technical contact emails of the project. + This list is authoritative: when omitted, emails added outside + Kubernetes are removed. + items: + type: string + maxItems: 10 + type: array + required: + - billingGroupId + - organizationId + - parentId + - projectId + type: object + x-kubernetes-validations: + - message: + connInfoSecretTargetDisabled can only be set during resource + creation. + rule: has(oldSelf.connInfoSecretTargetDisabled) == has(self.connInfoSecretTargetDisabled) + status: + description: OrganizationProjectStatus defines the observed state of OrganizationProject. + properties: + conditions: + description: + Conditions represent the latest available observations + of an OrganizationProject state. + items: + description: + Condition contains details for one aspect of the current + state of this API Resource. + 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. + 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 + required: + - conditions + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index e3b2480dd..e7b157848 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -47,6 +47,7 @@ rules: - mysqls - opensearchaclconfigs - opensearches + - organizationprojects - postgresqls - projects - projectvpcs @@ -87,6 +88,7 @@ rules: - mysqls/finalizers - opensearchaclconfigs/finalizers - opensearches/finalizers + - organizationprojects/finalizers - postgresqls/finalizers - projects/finalizers - projectvpcs/finalizers @@ -123,6 +125,7 @@ rules: - mysqls/status - opensearchaclconfigs/status - opensearches/status + - organizationprojects/status - postgresqls/status - projects/status - projectvpcs/status diff --git a/controllers/client.go b/controllers/client.go index 6f6f5d0f2..71b3441ee 100644 --- a/controllers/client.go +++ b/controllers/client.go @@ -51,8 +51,8 @@ type Observation struct { // SecretDetails contains secret data for the resource (credentials, endpoints, CA certs, etc.). // Will be written to the connInfoSecretTarget if not nil and not empty. - // Keys should NOT include prefixes - the reconciler will apply the appropriate prefix. - // Example keys: "HOST", "PORT", "USERNAME", "PASSWORD", "CA_CERT" + // Keys are written as-is: controllers apply the secret prefix themselves, + // e.g. getSecretPrefix(obj) + "CA_CERT". SecretDetails SecretDetails } diff --git a/controllers/common.go b/controllers/common.go index b08419f7f..a50a849c4 100644 --- a/controllers/common.go +++ b/controllers/common.go @@ -277,6 +277,14 @@ func NilIfZero[T comparable](v T) *T { return &v } +// emptyIfNil returns the map, or a non-nil empty map if it is nil. +func emptyIfNil[K comparable, V any](m map[K]V) map[K]V { + if m == nil { + return map[K]V{} + } + return m +} + // userAgent is a helper function to create a User-Agent string used for the Go client. func userAgent(kubeVersion, operatorVersion string) string { // Remove the leading "v" from the version strings, if present. diff --git a/controllers/organizationproject_controller.go b/controllers/organizationproject_controller.go new file mode 100644 index 000000000..8e6135dd7 --- /dev/null +++ b/controllers/organizationproject_controller.go @@ -0,0 +1,211 @@ +// Copyright (c) 2026 Aiven, Helsinki, Finland. https://aiven.io/ + +package controllers + +import ( + "context" + "fmt" + "maps" + "strings" + + avngen "github.com/aiven/go-client-codegen" + "github.com/aiven/go-client-codegen/handler/organizationprojects" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/aiven/aiven-operator/api/v1alpha1" +) + +func newOrganizationProjectReconciler(c Controller) reconcilerType { + return newManagedReconciler( + c, + func(c Controller, avnGen avngen.Client) AivenController[*v1alpha1.OrganizationProject] { + return &OrganizationProjectController{ + Client: c.Client, + avnGen: avnGen, + } + }, + nil, + ) +} + +//+kubebuilder:rbac:groups=aiven.io,resources=organizationprojects,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=aiven.io,resources=organizationprojects/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=aiven.io,resources=organizationprojects/finalizers,verbs=get;create;update + +// OrganizationProjectController reconciles an OrganizationProject object. +type OrganizationProjectController struct { + client.Client + avnGen avngen.Client +} + +func (r *OrganizationProjectController) Observe(ctx context.Context, cr *v1alpha1.OrganizationProject) (Observation, error) { + got, err := r.avnGen.OrganizationProjectsGet(ctx, cr.Spec.OrganizationID, cr.Spec.ProjectID) + if err != nil { + if isNotFound(err) { + return Observation{ResourceExists: false}, nil + } + return Observation{}, fmt.Errorf("getting organization project: %w", err) + } + + inSync := hasLatestGeneration(cr) + if inSync { // The spec was applied, compare the remote state + inSync, err = r.orgProjectMatchesSpec(ctx, got, cr) + if err != nil { + return Observation{}, err + } + } + + if !inSync { + return Observation{ + ResourceExists: true, + ResourceUpToDate: false, + }, nil + } + + cert, err := r.avnGen.ProjectKmsGetCA(ctx, cr.Spec.ProjectID) + if err != nil { + return Observation{}, fmt.Errorf("getting project KMS CA: %w", err) + } + + markInstanceRunning(cr) + prefix := getSecretPrefix(cr) + + return Observation{ + ResourceExists: true, + ResourceUpToDate: true, + SecretDetails: SecretDetails{ + prefix + "CA_CERT": cert, + }, + }, nil +} + +func (r *OrganizationProjectController) Create(ctx context.Context, cr *v1alpha1.OrganizationProject) (CreateResult, error) { + delete(cr.GetAnnotations(), instanceIsRunningAnnotation) + + parentID, err := r.resolveParentID(ctx, cr.Spec.ParentID) + if err != nil { + return CreateResult{}, err + } + + techEmails := organizationProjectTechEmails(cr.Spec.TechnicalEmails) + in := &organizationprojects.OrganizationProjectsCreateIn{ + ProjectId: cr.Spec.ProjectID, + BillingGroupId: cr.Spec.BillingGroupID, + ParentId: NilIfZero(parentID), + BasePort: cr.Spec.BasePort, + Tags: emptyIfNil(cr.Spec.Tags), + TechEmails: &techEmails, + } + + if _, err := r.avnGen.OrganizationProjectsCreate(ctx, cr.Spec.OrganizationID, in); err != nil { + if isServerError(err) { + return CreateResult{}, fmt.Errorf("%w: creating organization project: %w", errPreconditionNotMet, err) + } + return CreateResult{}, fmt.Errorf("creating organization project: %w", err) + } + + const reason = "CreatedOrUpdated" + meta.SetStatusCondition(&cr.Status.Conditions, getInitializedCondition(reason, "Successfully created or updated the instance in Aiven")) + meta.SetStatusCondition(&cr.Status.Conditions, getRunningCondition(metav1.ConditionUnknown, reason, "Successfully created or updated the instance in Aiven, status remains unknown")) + + return CreateResult{}, nil +} + +func (r *OrganizationProjectController) Update(ctx context.Context, cr *v1alpha1.OrganizationProject) (UpdateResult, error) { + delete(cr.GetAnnotations(), instanceIsRunningAnnotation) + + parentID, err := r.resolveParentID(ctx, cr.Spec.ParentID) + if err != nil { + return UpdateResult{}, err + } + + techEmails := organizationProjectTechEmails(cr.Spec.TechnicalEmails) + tags := emptyIfNil(cr.Spec.Tags) + in := &organizationprojects.OrganizationProjectsUpdateIn{ + BillingGroupId: NilIfZero(cr.Spec.BillingGroupID), + ParentId: NilIfZero(parentID), + BasePort: cr.Spec.BasePort, + Tags: &tags, + TechEmails: &techEmails, + } + + if _, err := r.avnGen.OrganizationProjectsUpdate(ctx, cr.Spec.OrganizationID, cr.Spec.ProjectID, in); err != nil { + if isServerError(err) { + return UpdateResult{}, fmt.Errorf("%w: updating organization project: %w", errPreconditionNotMet, err) + } + return UpdateResult{}, fmt.Errorf("updating organization project: %w", err) + } + + const reason = "CreatedOrUpdated" + meta.SetStatusCondition(&cr.Status.Conditions, getInitializedCondition(reason, "Successfully created or updated the instance in Aiven")) + meta.SetStatusCondition(&cr.Status.Conditions, getRunningCondition(metav1.ConditionUnknown, reason, "Successfully created or updated the instance in Aiven, status remains unknown")) + + return UpdateResult{}, nil +} + +func (r *OrganizationProjectController) Delete(ctx context.Context, cr *v1alpha1.OrganizationProject) error { + err := r.avnGen.OrganizationProjectsDelete(ctx, cr.Spec.OrganizationID, cr.Spec.ProjectID) + if err != nil && !isNotFound(err) { + return fmt.Errorf("deleting organization project: %w", err) + } + + return nil +} + +// orgProjectMatchesSpec reports whether the remote project matches the spec. +func (r *OrganizationProjectController) orgProjectMatchesSpec(ctx context.Context, got *organizationprojects.OrganizationProjectsGetOut, cr *v1alpha1.OrganizationProject) (bool, error) { + if cr.Spec.BillingGroupID != fromAnyPointer(got.BillingGroupId) { + return false, nil + } + + // A nil basePort in the spec means the field is unmanaged. + if cr.Spec.BasePort != nil && *cr.Spec.BasePort != fromAnyPointer(got.BasePort) { + return false, nil + } + + if !maps.Equal(cr.Spec.Tags, got.Tags) { + return false, nil + } + + gotEmails := make([]string, len(got.TechEmails)) + for i, e := range got.TechEmails { + gotEmails[i] = e.Email + } + if !cmp.Equal(cr.Spec.TechnicalEmails, gotEmails, cmpopts.SortSlices(strings.Compare), cmpopts.EquateEmpty()) { + return false, nil + } + + parentID, err := r.resolveParentID(ctx, cr.Spec.ParentID) + if err != nil { + return false, err + } + + return parentID == got.ParentId, nil +} + +// resolveParentID converts an organization ID to its account ID form. +// Account IDs are passed through unchanged. +func (r *OrganizationProjectController) resolveParentID(ctx context.Context, parentID string) (string, error) { + if !strings.HasPrefix(parentID, "org") { + return parentID, nil + } + + org, err := r.avnGen.OrganizationGet(ctx, parentID) + if err != nil { + return "", fmt.Errorf("converting organization ID %q to account ID: %w", parentID, err) + } + return org.AccountId, nil +} + +// organizationProjectTechEmails always returns a non-nil slice of technical emails. +func organizationProjectTechEmails(emails []string) []organizationprojects.TechEmailIn { + techEmails := make([]organizationprojects.TechEmailIn, len(emails)) + for i, v := range emails { + techEmails[i] = organizationprojects.TechEmailIn{Email: v} + } + return techEmails +} diff --git a/controllers/organizationproject_controller_test.go b/controllers/organizationproject_controller_test.go new file mode 100644 index 000000000..1a2c24121 --- /dev/null +++ b/controllers/organizationproject_controller_test.go @@ -0,0 +1,604 @@ +package controllers + +import ( + "errors" + "testing" + + avngen "github.com/aiven/go-client-codegen" + "github.com/aiven/go-client-codegen/handler/organization" + "github.com/aiven/go-client-codegen/handler/organizationprojects" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/tools/record" + ctrlruntime "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/aiven/aiven-operator/api/v1alpha1" +) + +func TestOrganizationProjectReconciler(t *testing.T) { + t.Parallel() + + newOrganizationProject := func(t *testing.T) *v1alpha1.OrganizationProject { + t.Helper() + op := newObjectFromExampleYAML[v1alpha1.OrganizationProject](t, "organizationproject") + op.Namespace = "default" + return op + } + + const parentAccountID = "a123456789a" + + expectParentIDResolution := func(op *v1alpha1.OrganizationProject, avn *avngen.MockClient) { + avn.EXPECT(). + OrganizationGet(mock.Anything, op.Spec.ParentID). + Return(&organization.OrganizationGetOut{AccountId: parentAccountID}, nil).Once() + } + + remoteFromSpec := func(op *v1alpha1.OrganizationProject) *organizationprojects.OrganizationProjectsGetOut { + emails := make([]organizationprojects.TechEmailOut, 0, len(op.Spec.TechnicalEmails)) + for _, e := range op.Spec.TechnicalEmails { + emails = append(emails, organizationprojects.TechEmailOut{Email: e}) + } + return &organizationprojects.OrganizationProjectsGetOut{ + OrganizationId: op.Spec.OrganizationID, + ProjectId: op.Spec.ProjectID, + ParentId: parentAccountID, + BillingGroupId: &op.Spec.BillingGroupID, + BasePort: op.Spec.BasePort, + Tags: op.Spec.Tags, + TechEmails: emails, + } + } + + runScenarioErr := func(t *testing.T, op *v1alpha1.OrganizationProject, avn avngen.Client) (*Reconciler[*v1alpha1.OrganizationProject], ctrlruntime.Result, error) { + t.Helper() + + scheme := runtime.NewScheme() + require.NoError(t, clientgoscheme.AddToScheme(scheme)) + require.NoError(t, v1alpha1.AddToScheme(scheme)) + + r := newOrganizationProjectReconciler(Controller{ + Client: fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&v1alpha1.OrganizationProject{}). + WithObjects([]client.Object{op}...). + Build(), + Scheme: scheme, + Recorder: record.NewFakeRecorder(20), + DefaultToken: "test-token", + PollInterval: testPollInterval, + }).(*Reconciler[*v1alpha1.OrganizationProject]) + r.newAivenGeneratedClient = func(_, _, _ string) (avngen.Client, error) { + return avn, nil + } + + res, err := r.Reconcile(t.Context(), ctrlruntime.Request{ + NamespacedName: types.NamespacedName{Name: op.Name, Namespace: op.Namespace}, + }) + return r, res, err + } + + runScenario := func(t *testing.T, op *v1alpha1.OrganizationProject, avn avngen.Client) (*Reconciler[*v1alpha1.OrganizationProject], ctrlruntime.Result) { + t.Helper() + + r, res, err := runScenarioErr(t, op, avn) + require.NoError(t, err) + return r, res + } + + t.Run("Creates organization project on Aiven when it doesn't exist", func(t *testing.T) { + op := newOrganizationProject(t) + op.Generation = 1 + // Seed the running annotation so the assertion below genuinely verifies that + // Create clears it (delete(cr.GetAnnotations(), instanceIsRunningAnnotation)). + op.Annotations = map[string]string{ + instanceIsRunningAnnotation: "true", + } + + avn := avngen.NewMockClient(t) + avn.EXPECT(). + OrganizationProjectsGet(mock.Anything, op.Spec.OrganizationID, op.Spec.ProjectID). + Return(nil, newAivenError(404, "project not found")).Once() + expectParentIDResolution(op, avn) + avn.EXPECT(). + OrganizationProjectsCreate(mock.Anything, op.Spec.OrganizationID, mock.MatchedBy(func(in *organizationprojects.OrganizationProjectsCreateIn) bool { + return in.ProjectId == op.Spec.ProjectID && + in.BillingGroupId == op.Spec.BillingGroupID && + in.ParentId != nil && *in.ParentId == parentAccountID && + in.BasePort != nil && *in.BasePort == *op.Spec.BasePort && + assert.Equal(t, op.Spec.Tags, in.Tags) && + in.TechEmails != nil && + assert.Equal(t, organizationProjectTechEmails(op.Spec.TechnicalEmails), *in.TechEmails) + })). + Return(&organizationprojects.OrganizationProjectsCreateOut{ProjectId: op.Spec.ProjectID}, nil).Once() + + r, res := runScenario(t, op, avn) + require.Equal(t, ctrlruntime.Result{RequeueAfter: requeueTimeout}, res) + + got := &v1alpha1.OrganizationProject{} + require.NoError(t, r.Get(t.Context(), types.NamespacedName{Name: op.Name, Namespace: op.Namespace}, got)) + require.Contains(t, got.Finalizers, instanceDeletionFinalizer) + require.Equal(t, "1", got.Annotations[processedGenerationAnnotation]) + require.NotContains(t, got.Annotations, instanceIsRunningAnnotation) + condition := meta.FindStatusCondition(got.Status.Conditions, conditionTypeRunning) + require.NotNil(t, condition) + require.Equal(t, metav1.ConditionUnknown, condition.Status) + + secret := &corev1.Secret{} + err := r.Get(t.Context(), types.NamespacedName{Name: op.Spec.ConnInfoSecretTarget.Name, Namespace: op.Namespace}, secret) + require.True(t, apierrors.IsNotFound(err)) + }) + + t.Run("Requeues without hard error on transient server error during create", func(t *testing.T) { + op := newOrganizationProject(t) + op.Generation = 1 + + avn := avngen.NewMockClient(t) + avn.EXPECT(). + OrganizationProjectsGet(mock.Anything, op.Spec.OrganizationID, op.Spec.ProjectID). + Return(nil, newAivenError(404, "project not found")).Once() + expectParentIDResolution(op, avn) + avn.EXPECT(). + OrganizationProjectsCreate(mock.Anything, op.Spec.OrganizationID, mock.Anything). + Return(nil, newAivenError(500, "temporary create failure")).Once() + + r, res, err := runScenarioErr(t, op, avn) + require.NoError(t, err) + require.Equal(t, ctrlruntime.Result{RequeueAfter: requeueTimeout}, res) + + got := &v1alpha1.OrganizationProject{} + require.NoError(t, r.Get(t.Context(), types.NamespacedName{Name: op.Name, Namespace: op.Namespace}, got)) + require.Contains(t, got.Finalizers, instanceDeletionFinalizer) + require.NotEqual(t, "1", got.Annotations[processedGenerationAnnotation]) + require.Nil(t, meta.FindStatusCondition(got.Status.Conditions, ConditionTypeError)) + }) + + t.Run("Marks running and writes CA cert secret when project exists and is up to date", func(t *testing.T) { + op := newOrganizationProject(t) + op.Generation = 1 + op.Annotations = map[string]string{ + processedGenerationAnnotation: "1", + } + + avn := avngen.NewMockClient(t) + avn.EXPECT(). + OrganizationProjectsGet(mock.Anything, op.Spec.OrganizationID, op.Spec.ProjectID). + Return(remoteFromSpec(op), nil).Once() + expectParentIDResolution(op, avn) + avn.EXPECT(). + ProjectKmsGetCA(mock.Anything, op.Spec.ProjectID). + Return("ca-cert", nil).Once() + + r, res := runScenario(t, op, avn) + require.Equal(t, ctrlruntime.Result{RequeueAfter: testPollInterval}, res) + + got := &v1alpha1.OrganizationProject{} + require.NoError(t, r.Get(t.Context(), types.NamespacedName{Name: op.Name, Namespace: op.Namespace}, got)) + require.Contains(t, got.Finalizers, instanceDeletionFinalizer) + require.Equal(t, "true", got.Annotations[instanceIsRunningAnnotation]) + condition := meta.FindStatusCondition(got.Status.Conditions, conditionTypeRunning) + require.NotNil(t, condition) + require.Equal(t, metav1.ConditionTrue, condition.Status) + + secret := &corev1.Secret{} + require.NoError(t, r.Get(t.Context(), types.NamespacedName{Name: op.Spec.ConnInfoSecretTarget.Name, Namespace: op.Namespace}, secret)) + require.Equal(t, "ca-cert", string(secret.Data["ORGANIZATIONPROJECT_CA_CERT"])) + }) + + t.Run("Updates existing organization project when generation changed", func(t *testing.T) { + op := newOrganizationProject(t) + op.Generation = 2 + op.Annotations = map[string]string{ + processedGenerationAnnotation: "1", + instanceIsRunningAnnotation: "true", + } + + avn := avngen.NewMockClient(t) + avn.EXPECT(). + OrganizationProjectsGet(mock.Anything, op.Spec.OrganizationID, op.Spec.ProjectID). + Return(&organizationprojects.OrganizationProjectsGetOut{ + OrganizationId: op.Spec.OrganizationID, + ProjectId: op.Spec.ProjectID, + ParentId: op.Spec.ParentID, + }, nil).Once() + expectParentIDResolution(op, avn) + avn.EXPECT(). + OrganizationProjectsUpdate(mock.Anything, op.Spec.OrganizationID, op.Spec.ProjectID, mock.MatchedBy(func(in *organizationprojects.OrganizationProjectsUpdateIn) bool { + return in.BillingGroupId != nil && *in.BillingGroupId == op.Spec.BillingGroupID && + in.ParentId != nil && *in.ParentId == parentAccountID && + // projectId is immutable, so no rename must ever be sent. + in.ProjectName == nil && + in.BasePort != nil && *in.BasePort == *op.Spec.BasePort && + in.Tags != nil && assert.Equal(t, op.Spec.Tags, *in.Tags) && + in.TechEmails != nil && + assert.Equal(t, organizationProjectTechEmails(op.Spec.TechnicalEmails), *in.TechEmails) + })). + Return(&organizationprojects.OrganizationProjectsUpdateOut{ProjectId: op.Spec.ProjectID}, nil).Once() + + r, res := runScenario(t, op, avn) + require.Equal(t, ctrlruntime.Result{RequeueAfter: requeueTimeout}, res) + + got := &v1alpha1.OrganizationProject{} + require.NoError(t, r.Get(t.Context(), types.NamespacedName{Name: op.Name, Namespace: op.Namespace}, got)) + require.Equal(t, "2", got.Annotations[processedGenerationAnnotation]) + require.NotContains(t, got.Annotations, instanceIsRunningAnnotation) + condition := meta.FindStatusCondition(got.Status.Conditions, conditionTypeRunning) + require.NotNil(t, condition) + require.Equal(t, metav1.ConditionUnknown, condition.Status) + }) + + t.Run("Deletes organization project and removes finalizer on deletion", func(t *testing.T) { + op := newOrganizationProject(t) + op.Generation = 1 + op.Finalizers = []string{instanceDeletionFinalizer} + now := metav1.Now() + op.DeletionTimestamp = &now + + avn := avngen.NewMockClient(t) + avn.EXPECT(). + OrganizationProjectsDelete(mock.Anything, op.Spec.OrganizationID, op.Spec.ProjectID). + Return(nil).Once() + + r, res := runScenario(t, op, avn) + require.Equal(t, ctrlruntime.Result{}, res) + + got := &v1alpha1.OrganizationProject{} + err := r.Get(t.Context(), types.NamespacedName{Name: op.Name, Namespace: op.Namespace}, got) + require.True(t, apierrors.IsNotFound(err)) + }) + + t.Run("Ignores not found on deletion", func(t *testing.T) { + op := newOrganizationProject(t) + op.Generation = 1 + op.Finalizers = []string{instanceDeletionFinalizer} + now := metav1.Now() + op.DeletionTimestamp = &now + + avn := avngen.NewMockClient(t) + avn.EXPECT(). + OrganizationProjectsDelete(mock.Anything, op.Spec.OrganizationID, op.Spec.ProjectID). + Return(newAivenError(404, "not found")).Once() + + r, res := runScenario(t, op, avn) + require.Equal(t, ctrlruntime.Result{}, res) + + got := &v1alpha1.OrganizationProject{} + err := r.Get(t.Context(), types.NamespacedName{Name: op.Name, Namespace: op.Namespace}, got) + require.True(t, apierrors.IsNotFound(err)) + }) + + t.Run("Sets error condition on non-retryable create failure", func(t *testing.T) { + op := newOrganizationProject(t) + op.Generation = 1 + + avn := avngen.NewMockClient(t) + avn.EXPECT(). + OrganizationProjectsGet(mock.Anything, op.Spec.OrganizationID, op.Spec.ProjectID). + Return(nil, newAivenError(404, "project not found")).Once() + expectParentIDResolution(op, avn) + avn.EXPECT(). + OrganizationProjectsCreate(mock.Anything, op.Spec.OrganizationID, mock.Anything). + Return(nil, newAivenError(400, "bad request")).Once() + + r, res, err := runScenarioErr(t, op, avn) + require.Error(t, err) + require.Equal(t, ctrlruntime.Result{}, res) + + got := &v1alpha1.OrganizationProject{} + require.NoError(t, r.Get(t.Context(), types.NamespacedName{Name: op.Name, Namespace: op.Namespace}, got)) + require.NotEqual(t, "1", got.Annotations[processedGenerationAnnotation]) + require.NotNil(t, meta.FindStatusCondition(got.Status.Conditions, ConditionTypeError)) + }) + + t.Run("Sends empty technical emails list when none are configured", func(t *testing.T) { + op := newOrganizationProject(t) + op.Generation = 1 + op.Spec.TechnicalEmails = nil + + avn := avngen.NewMockClient(t) + avn.EXPECT(). + OrganizationProjectsGet(mock.Anything, op.Spec.OrganizationID, op.Spec.ProjectID). + Return(nil, newAivenError(404, "project not found")).Once() + expectParentIDResolution(op, avn) + avn.EXPECT(). + OrganizationProjectsCreate(mock.Anything, op.Spec.OrganizationID, mock.MatchedBy(func(in *organizationprojects.OrganizationProjectsCreateIn) bool { + return in.TechEmails != nil && len(*in.TechEmails) == 0 + })). + Return(&organizationprojects.OrganizationProjectsCreateOut{ProjectId: op.Spec.ProjectID}, nil).Once() + + _, res := runScenario(t, op, avn) + require.Equal(t, ctrlruntime.Result{RequeueAfter: requeueTimeout}, res) + }) + + t.Run("Sends empty tags map when none are configured", func(t *testing.T) { + op := newOrganizationProject(t) + op.Generation = 1 + op.Spec.Tags = nil + + avn := avngen.NewMockClient(t) + avn.EXPECT(). + OrganizationProjectsGet(mock.Anything, op.Spec.OrganizationID, op.Spec.ProjectID). + Return(nil, newAivenError(404, "project not found")).Once() + expectParentIDResolution(op, avn) + avn.EXPECT(). + OrganizationProjectsCreate(mock.Anything, op.Spec.OrganizationID, mock.MatchedBy(func(in *organizationprojects.OrganizationProjectsCreateIn) bool { + return in.Tags != nil && len(in.Tags) == 0 + })). + Return(&organizationprojects.OrganizationProjectsCreateOut{ProjectId: op.Spec.ProjectID}, nil).Once() + + _, res := runScenario(t, op, avn) + require.Equal(t, ctrlruntime.Result{RequeueAfter: requeueTimeout}, res) + }) + + t.Run("Reverts out-of-band tags with an empty map when spec has no tags", func(t *testing.T) { + op := newOrganizationProject(t) + op.Generation = 1 + op.Annotations = map[string]string{ + processedGenerationAnnotation: "1", + instanceIsRunningAnnotation: "true", + } + op.Spec.Tags = nil + + // Someone added tags directly in the Aiven console; the spec has none, so the drift must be reverted. + drifted := remoteFromSpec(op) + drifted.Tags = map[string]string{"env": "hijacked"} + + avn := avngen.NewMockClient(t) + avn.EXPECT(). + OrganizationProjectsGet(mock.Anything, op.Spec.OrganizationID, op.Spec.ProjectID). + Return(drifted, nil).Once() + expectParentIDResolution(op, avn) + avn.EXPECT(). + OrganizationProjectsUpdate(mock.Anything, op.Spec.OrganizationID, op.Spec.ProjectID, mock.MatchedBy(func(in *organizationprojects.OrganizationProjectsUpdateIn) bool { + // Clearing tags must send a non-nil. + return in.Tags != nil && *in.Tags != nil && len(*in.Tags) == 0 + })). + Return(&organizationprojects.OrganizationProjectsUpdateOut{ProjectId: op.Spec.ProjectID}, nil).Once() + + _, res := runScenario(t, op, avn) + require.Equal(t, ctrlruntime.Result{RequeueAfter: requeueTimeout}, res) + }) + + t.Run("Passes account-form parentId through without resolving it", func(t *testing.T) { + op := newOrganizationProject(t) + op.Generation = 1 + op.Spec.ParentID = parentAccountID + + // No OrganizationGet expectation: account/unit IDs must be sent as-is. + avn := avngen.NewMockClient(t) + avn.EXPECT(). + OrganizationProjectsGet(mock.Anything, op.Spec.OrganizationID, op.Spec.ProjectID). + Return(nil, newAivenError(404, "project not found")).Once() + avn.EXPECT(). + OrganizationProjectsCreate(mock.Anything, op.Spec.OrganizationID, mock.MatchedBy(func(in *organizationprojects.OrganizationProjectsCreateIn) bool { + return in.ParentId != nil && *in.ParentId == parentAccountID + })). + Return(&organizationprojects.OrganizationProjectsCreateOut{ProjectId: op.Spec.ProjectID}, nil).Once() + + _, res := runScenario(t, op, avn) + require.Equal(t, ctrlruntime.Result{RequeueAfter: requeueTimeout}, res) + }) + + t.Run("Requeues without hard error on transient server error during update", func(t *testing.T) { + op := newOrganizationProject(t) + op.Generation = 2 + op.Annotations = map[string]string{ + processedGenerationAnnotation: "1", + instanceIsRunningAnnotation: "true", + } + + avn := avngen.NewMockClient(t) + avn.EXPECT(). + OrganizationProjectsGet(mock.Anything, op.Spec.OrganizationID, op.Spec.ProjectID). + Return(&organizationprojects.OrganizationProjectsGetOut{ + OrganizationId: op.Spec.OrganizationID, + ProjectId: op.Spec.ProjectID, + ParentId: op.Spec.ParentID, + }, nil).Once() + expectParentIDResolution(op, avn) + avn.EXPECT(). + OrganizationProjectsUpdate(mock.Anything, op.Spec.OrganizationID, op.Spec.ProjectID, mock.Anything). + Return(nil, newAivenError(500, "temporary update failure")).Once() + + r, res, err := runScenarioErr(t, op, avn) + require.NoError(t, err) + require.Equal(t, ctrlruntime.Result{RequeueAfter: requeueTimeout}, res) + + got := &v1alpha1.OrganizationProject{} + require.NoError(t, r.Get(t.Context(), types.NamespacedName{Name: op.Name, Namespace: op.Namespace}, got)) + require.NotEqual(t, "2", got.Annotations[processedGenerationAnnotation]) + require.Nil(t, meta.FindStatusCondition(got.Status.Conditions, ConditionTypeError)) + }) + + t.Run("Stays up to date when Aiven echoes the account form of parentId", func(t *testing.T) { + op := newOrganizationProject(t) + op.Generation = 1 + op.Annotations = map[string]string{ + processedGenerationAnnotation: "1", + } + + avn := avngen.NewMockClient(t) + // remoteFromSpec returns parent_id in the account form (a...) while the spec + // uses the org... form: the normalization echo must NOT be treated as drift. + avn.EXPECT(). + OrganizationProjectsGet(mock.Anything, op.Spec.OrganizationID, op.Spec.ProjectID). + Return(remoteFromSpec(op), nil).Once() + expectParentIDResolution(op, avn) + avn.EXPECT(). + ProjectKmsGetCA(mock.Anything, op.Spec.ProjectID). + Return("ca-cert", nil).Once() + + r, res := runScenario(t, op, avn) + require.Equal(t, ctrlruntime.Result{RequeueAfter: testPollInterval}, res) + + got := &v1alpha1.OrganizationProject{} + require.NoError(t, r.Get(t.Context(), types.NamespacedName{Name: op.Name, Namespace: op.Namespace}, got)) + require.Equal(t, "true", got.Annotations[instanceIsRunningAnnotation]) + condition := meta.FindStatusCondition(got.Status.Conditions, conditionTypeRunning) + require.NotNil(t, condition) + require.Equal(t, metav1.ConditionTrue, condition.Status) + }) + + t.Run("Reverts out-of-band remote changes even when generation is processed", func(t *testing.T) { + op := newOrganizationProject(t) + op.Generation = 1 + op.Annotations = map[string]string{ + processedGenerationAnnotation: "1", + instanceIsRunningAnnotation: "true", + } + + // Someone changed tags directly in the Aiven console. + drifted := remoteFromSpec(op) + drifted.Tags = map[string]string{"env": "hijacked"} + + avn := avngen.NewMockClient(t) + avn.EXPECT(). + OrganizationProjectsGet(mock.Anything, op.Spec.OrganizationID, op.Spec.ProjectID). + Return(drifted, nil).Once() + // Only one resolution (in Update): the drift check short-circuits on the + // tags mismatch before reaching the parent_id comparison. + expectParentIDResolution(op, avn) + avn.EXPECT(). + OrganizationProjectsUpdate(mock.Anything, op.Spec.OrganizationID, op.Spec.ProjectID, mock.MatchedBy(func(in *organizationprojects.OrganizationProjectsUpdateIn) bool { + return in.Tags != nil && assert.Equal(t, op.Spec.Tags, *in.Tags) + })). + Return(&organizationprojects.OrganizationProjectsUpdateOut{ProjectId: op.Spec.ProjectID}, nil).Once() + + _, res := runScenario(t, op, avn) + require.Equal(t, ctrlruntime.Result{RequeueAfter: requeueTimeout}, res) + }) + + t.Run("Reverts an out-of-band parent move", func(t *testing.T) { + op := newOrganizationProject(t) + op.Generation = 1 + op.Annotations = map[string]string{ + processedGenerationAnnotation: "1", + instanceIsRunningAnnotation: "true", + } + + // The project was moved to another organizational unit outside Kubernetes. + drifted := remoteFromSpec(op) + drifted.ParentId = "a-other-unit-id" + + avn := avngen.NewMockClient(t) + avn.EXPECT(). + OrganizationProjectsGet(mock.Anything, op.Spec.OrganizationID, op.Spec.ProjectID). + Return(drifted, nil).Once() + // One resolution in the drift check (Observe) and one in Update. + expectParentIDResolution(op, avn) + expectParentIDResolution(op, avn) + avn.EXPECT(). + OrganizationProjectsUpdate(mock.Anything, op.Spec.OrganizationID, op.Spec.ProjectID, mock.MatchedBy(func(in *organizationprojects.OrganizationProjectsUpdateIn) bool { + return in.ParentId != nil && *in.ParentId == parentAccountID + })). + Return(&organizationprojects.OrganizationProjectsUpdateOut{ProjectId: op.Spec.ProjectID}, nil).Once() + + _, res := runScenario(t, op, avn) + require.Equal(t, ctrlruntime.Result{RequeueAfter: requeueTimeout}, res) + }) + + t.Run("Requeues on transient server error during Get", func(t *testing.T) { + op := newOrganizationProject(t) + op.Generation = 1 + + avn := avngen.NewMockClient(t) + avn.EXPECT(). + OrganizationProjectsGet(mock.Anything, op.Spec.OrganizationID, op.Spec.ProjectID). + Return(nil, newAivenError(500, "temporary get failure")).Once() + + r, res, err := runScenarioErr(t, op, avn) + require.NoError(t, err) + require.Equal(t, ctrlruntime.Result{RequeueAfter: requeueTimeout}, res) + + got := &v1alpha1.OrganizationProject{} + require.NoError(t, r.Get(t.Context(), types.NamespacedName{Name: op.Name, Namespace: op.Namespace}, got)) + require.NotEqual(t, "1", got.Annotations[processedGenerationAnnotation]) + }) + + t.Run("Does not mark running when CA fetch fails", func(t *testing.T) { + op := newOrganizationProject(t) + op.Generation = 1 + op.Annotations = map[string]string{ + processedGenerationAnnotation: "1", + } + + avn := avngen.NewMockClient(t) + avn.EXPECT(). + OrganizationProjectsGet(mock.Anything, op.Spec.OrganizationID, op.Spec.ProjectID). + Return(remoteFromSpec(op), nil).Once() + expectParentIDResolution(op, avn) + avn.EXPECT(). + ProjectKmsGetCA(mock.Anything, op.Spec.ProjectID). + Return("", errors.New("ca unavailable")).Once() + + r, res, err := runScenarioErr(t, op, avn) + require.ErrorContains(t, err, "getting project KMS CA: ca unavailable") + require.Equal(t, ctrlruntime.Result{}, res) + + got := &v1alpha1.OrganizationProject{} + require.NoError(t, r.Get(t.Context(), types.NamespacedName{Name: op.Name, Namespace: op.Namespace}, got)) + require.NotContains(t, got.Annotations, instanceIsRunningAnnotation) + require.NotNil(t, meta.FindStatusCondition(got.Status.Conditions, ConditionTypeError)) + + secret := &corev1.Secret{} + err = r.Get(t.Context(), types.NamespacedName{Name: op.Spec.ConnInfoSecretTarget.Name, Namespace: op.Namespace}, secret) + require.True(t, apierrors.IsNotFound(err)) + }) + + t.Run("Does not write CA cert secret when connection secret is disabled", func(t *testing.T) { + op := newOrganizationProject(t) + op.Generation = 1 + op.Annotations = map[string]string{ + processedGenerationAnnotation: "1", + } + disabled := true + op.Spec.ConnInfoSecretTargetDisabled = &disabled + + avn := avngen.NewMockClient(t) + avn.EXPECT(). + OrganizationProjectsGet(mock.Anything, op.Spec.OrganizationID, op.Spec.ProjectID). + Return(remoteFromSpec(op), nil).Once() + expectParentIDResolution(op, avn) + avn.EXPECT(). + ProjectKmsGetCA(mock.Anything, op.Spec.ProjectID). + Return("ca-cert", nil).Once() + + r, res := runScenario(t, op, avn) + require.Equal(t, ctrlruntime.Result{RequeueAfter: testPollInterval}, res) + + secret := &corev1.Secret{} + err := r.Get(t.Context(), types.NamespacedName{Name: op.Spec.ConnInfoSecretTarget.Name, Namespace: op.Namespace}, secret) + require.True(t, apierrors.IsNotFound(err)) + }) + + t.Run("Retains finalizer and requeues on transient server error during delete", func(t *testing.T) { + op := newOrganizationProject(t) + op.Generation = 1 + op.Finalizers = []string{instanceDeletionFinalizer} + now := metav1.Now() + op.DeletionTimestamp = &now + + avn := avngen.NewMockClient(t) + avn.EXPECT(). + OrganizationProjectsDelete(mock.Anything, op.Spec.OrganizationID, op.Spec.ProjectID). + Return(newAivenError(500, "temporary delete failure")).Once() + + r, res, err := runScenarioErr(t, op, avn) + require.NoError(t, err) + require.Equal(t, ctrlruntime.Result{RequeueAfter: requeueTimeout}, res) + + got := &v1alpha1.OrganizationProject{} + require.NoError(t, r.Get(t.Context(), types.NamespacedName{Name: op.Name, Namespace: op.Namespace}, got)) + require.Contains(t, got.Finalizers, instanceDeletionFinalizer) + require.NotNil(t, meta.FindStatusCondition(got.Status.Conditions, ConditionTypeError)) + }) +} diff --git a/controllers/setup.go b/controllers/setup.go index 182a15ca7..e6285814b 100644 --- a/controllers/setup.go +++ b/controllers/setup.go @@ -74,6 +74,7 @@ func SetupControllersWithConfig(mgr ctrl.Manager, cfg SetupConfig) error { "MySQL": newMySQLReconciler, "OpenSearch": newOpenSearchReconciler, "OpenSearchACLConfig": newOpenSearchACLConfigReconciler, + "OrganizationProject": newOrganizationProjectReconciler, "PostgreSQL": newPostgreSQLReconciler, "Project": newProjectReconciler, "ProjectVPC": newProjectVPCReconciler, diff --git a/docs/docs/resources/examples/organizationproject.yaml b/docs/docs/resources/examples/organizationproject.yaml new file mode 100644 index 000000000..353d2ccb7 --- /dev/null +++ b/docs/docs/resources/examples/organizationproject.yaml @@ -0,0 +1,24 @@ +apiVersion: aiven.io/v1alpha1 +kind: OrganizationProject +metadata: + name: my-organization-project +spec: + authSecretRef: + name: aiven-token + key: token + + connInfoSecretTarget: + name: my-organization-project-ca-cert + + organizationId: org123456789a + projectId: my-organization-project + billingGroupId: bg123456789a + parentId: org123456789a + + basePort: 12000 + + technicalEmails: + - tech@example.com + + tags: + env: prod diff --git a/docs/docs/resources/organizationproject.md b/docs/docs/resources/organizationproject.md new file mode 100644 index 000000000..0a80928ec --- /dev/null +++ b/docs/docs/resources/organizationproject.md @@ -0,0 +1,171 @@ +--- +title: "OrganizationProject" +--- + +## Prerequisites + +* A Kubernetes cluster with the operator installed using [helm](../installation/helm.md), [kubectl](../installation/kubectl.md) or [kind](../contributing/developer-guide.md) (for local development). +* A Kubernetes [Secret](../authentication.md) with an Aiven authentication token. + +### Required permissions + +To create and manage this resource, you must have the appropriate [roles or permissions](https://aiven.io/docs/platform/concepts/permissions). +See the [Aiven documentation](https://aiven.io/docs/platform/howto/manage-permissions) for details on managing permissions. + +This resource uses the following API operations, and for each operation, _any_ of the listed permissions is sufficient: + +| Operation | Permissions | +| ----------- | ----------- | +| [OrganizationProjectsDelete](https://api.aiven.io/doc/#operation/OrganizationProjectsDelete) | `organization:projects:write` | +| [OrganizationProjectsGet](https://api.aiven.io/doc/#operation/OrganizationProjectsGet) | `project:services:read` | +| [OrganizationProjectsUpdate](https://api.aiven.io/doc/#operation/OrganizationProjectsUpdate) | `organization:projects:write` | +| [ProjectKmsGetCA](https://api.aiven.io/doc/#operation/ProjectKmsGetCA) | `organization:projects:write` | + +## Usage example + +```yaml linenums="1" +apiVersion: aiven.io/v1alpha1 +kind: OrganizationProject +metadata: + name: my-organization-project +spec: + authSecretRef: + name: aiven-token + key: token + + connInfoSecretTarget: + name: my-organization-project-ca-cert + + organizationId: org123456789a + projectId: my-organization-project + billingGroupId: bg123456789a + parentId: org123456789a + + basePort: 12000 + + technicalEmails: + - tech@example.com + + tags: + env: prod +``` + +Apply the resource with: + +```shell +kubectl apply -f example.yaml +``` + +Verify the newly created `OrganizationProject`: + +```shell +kubectl get organizationprojects my-organization-project +``` + +The output is similar to the following: +```shell +Name Organization Project Parent +my-organization-project org123456789a my-organization-project org123456789a +``` + +To view the details of the `Secret`, use the following command: +```shell +kubectl describe secret my-organization-project-ca-cert +``` + +You can use the [jq](https://github.com/jqlang/jq) to quickly decode the `Secret`: + +```shell +kubectl get secret my-organization-project-ca-cert -o json | jq '.data | map_values(@base64d)' +``` + +The output is similar to the following: + +```{ .json .no-copy } +{ + "ORGANIZATIONPROJECT_CA_CERT": "", +} +``` + +--- + +## OrganizationProject {: #OrganizationProject } + +OrganizationProject is the Schema for the organizationprojects API. + +!!! Warning "Adoption of existing projects" + + If `projectId` refers to a project that already exists in the organization, the operator adopts it: + the remote state is overwritten to match the spec (billing group, parent, tags, technical emails, base port), + and deleting the resource deletes the project in Aiven. + +!!! Info "Exposes secret keys" + + `ORGANIZATIONPROJECT_CA_CERT`. + +**Required** + +- [`apiVersion`](#apiVersion-property){: name='apiVersion-property'} (string). Value `aiven.io/v1alpha1`. +- [`kind`](#kind-property){: name='kind-property'} (string). Value `OrganizationProject`. +- [`metadata`](#metadata-property){: name='metadata-property'} (object). Data that identifies the object, including a `name` string and optional `namespace`. +- [`spec`](#spec-property){: name='spec-property'} (object). OrganizationProjectSpec defines the desired state of OrganizationProject. See below for [nested schema](#spec). + +## spec {: #spec } + +_Appears on [`OrganizationProject`](#OrganizationProject)._ + +OrganizationProjectSpec defines the desired state of OrganizationProject. + +**Required** + +- [`billingGroupId`](#spec.billingGroupId-property){: name='spec.billingGroupId-property'} (string, MinLength: 1). BillingGroupID is the ID of the billing group the project is assigned to. +- [`organizationId`](#spec.organizationId-property){: name='spec.organizationId-property'} (string, Immutable, MinLength: 1). OrganizationID is the Aiven organization ID that owns the project. + It is the addressing key for the project and cannot be changed (moving a project + between organizations is not supported). +- [`parentId`](#spec.parentId-property){: name='spec.parentId-property'} (string, MinLength: 1). ParentID is the ID of the organization or organizational unit the project belongs to. + Moving a project between organizational units within the same organization is supported. +- [`projectId`](#spec.projectId-property){: name='spec.projectId-property'} (string, Immutable, Pattern: `^[a-zA-Z0-9_-]+$`, MinLength: 1, MaxLength: 63). ProjectID is the name of the project. It is immutable once set. + +**Optional** + +- [`authSecretRef`](#spec.authSecretRef-property){: name='spec.authSecretRef-property'} (object). Authentication reference to Aiven token in a secret. See below for [nested schema](#spec.authSecretRef). +- [`basePort`](#spec.basePort-property){: name='spec.basePort-property'} (integer, Minimum: 10000, Maximum: 30000). BasePort is the valid port number range for the project, from 10000 to 30000. + When omitted, the field is unmanaged: Aiven assigns the value and changes made + outside Kubernetes are left as is. +- [`connInfoSecretTarget`](#spec.connInfoSecretTarget-property){: name='spec.connInfoSecretTarget-property'} (object). Secret configuration. See below for [nested schema](#spec.connInfoSecretTarget). +- [`connInfoSecretTargetDisabled`](#spec.connInfoSecretTargetDisabled-property){: name='spec.connInfoSecretTargetDisabled-property'} (boolean, Immutable). When true, the secret containing connection information will not be created, defaults to false. This field cannot be changed after resource creation. +- [`tags`](#spec.tags-property){: name='spec.tags-property'} (object, AdditionalProperties: string). Tags are key-value pairs that allow you to categorize projects. + This map is authoritative: when omitted, tags added outside + Kubernetes are removed. +- [`technicalEmails`](#spec.technicalEmails-property){: name='spec.technicalEmails-property'} (array of strings, MaxItems: 10). TechnicalEmails are the technical contact emails of the project. + This list is authoritative: when omitted, emails added outside + Kubernetes are removed. + +## authSecretRef {: #spec.authSecretRef } + +_Appears on [`spec`](#spec)._ + +Authentication reference to Aiven token in a secret. + +**Required** + +- [`key`](#spec.authSecretRef.key-property){: name='spec.authSecretRef.key-property'} (string, MinLength: 1). +- [`name`](#spec.authSecretRef.name-property){: name='spec.authSecretRef.name-property'} (string, MinLength: 1). + +## connInfoSecretTarget {: #spec.connInfoSecretTarget } + +_Appears on [`spec`](#spec)._ + +Secret configuration. + +**Required** + +- [`name`](#spec.connInfoSecretTarget.name-property){: name='spec.connInfoSecretTarget.name-property'} (string, Immutable). Name of the secret resource to be created. By default, it is equal to the resource name. + +**Optional** + +- [`annotations`](#spec.connInfoSecretTarget.annotations-property){: name='spec.connInfoSecretTarget.annotations-property'} (object, AdditionalProperties: string). Annotations added to the secret. +- [`labels`](#spec.connInfoSecretTarget.labels-property){: name='spec.connInfoSecretTarget.labels-property'} (object, AdditionalProperties: string). Labels added to the secret. +- [`prefix`](#spec.connInfoSecretTarget.prefix-property){: name='spec.connInfoSecretTarget.prefix-property'} (string). Prefix for the secret's keys. + Added "as is" without any transformations. + By default, is equal to the kind name in uppercase + underscore, e.g. `KAFKA_`, `REDIS_`, etc. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index c0802789d..16c032a73 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -101,6 +101,7 @@ nav: - resources/kafkatopic.md - resources/mysql.md - resources/opensearch.md + - resources/organizationproject.md - resources/postgresql.md - resources/project.md - resources/projectvpc.md diff --git a/docs/permissions.yaml b/docs/permissions.yaml index 20881c6b0..f162f2dfa 100644 --- a/docs/permissions.yaml +++ b/docs/permissions.yaml @@ -156,6 +156,15 @@ OpenSearchACLConfig: ServiceOpenSearchAclSet, ServiceOpenSearchAclUpdate, ] +OrganizationProject: + [ + OrganizationGet, + OrganizationProjectsCreate, + OrganizationProjectsGet, + OrganizationProjectsUpdate, + OrganizationProjectsDelete, + ProjectKmsGetCA, + ] PostgreSQL: [ ServiceGet, diff --git a/tests/organizationproject_test.go b/tests/organizationproject_test.go new file mode 100644 index 000000000..1142b90d1 --- /dev/null +++ b/tests/organizationproject_test.go @@ -0,0 +1,201 @@ +//go:build misc + +package tests + +import ( + "testing" + "time" + + "github.com/aiven/go-client-codegen/handler/organizationprojects" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aiven/aiven-operator/api/v1alpha1" +) + +func TestOrganizationProject(t *testing.T) { + t.Parallel() + defer recoverPanic(t) + + // GIVEN + ctx, cancel := testCtx() + defer cancel() + + account, err := avnGen.AccountGet(ctx, cfg.AccountID) + require.NoError(t, err) + require.NotEmpty(t, account.OrganizationId) + organizationID := account.OrganizationId + + // Picks a real billing group that belongs to the test account. + billingGroups, err := avnGen.BillingGroupList(ctx) + require.NoError(t, err) + var billingGroupID string + for _, bg := range billingGroups { + if bg.AccountId == cfg.AccountID { + billingGroupID = bg.BillingGroupId + break + } + } + require.NotEmpty(t, billingGroupID, "no billing group found for account %q", cfg.AccountID) + + name := randName("org-project") + projectID := name + + yml, err := loadExampleYaml("organizationproject.yaml", map[string]string{ + "metadata.name": name, + "spec.connInfoSecretTarget.name": name, + "spec.organizationId": organizationID, + "spec.projectId": projectID, + "spec.billingGroupId": billingGroupID, + "spec.parentId": organizationID, + }) + require.NoError(t, err) + + s := NewSession(ctx, k8sClient) + + // Cleans test afterward + defer s.Destroy(t) + + // WHEN + require.NoError(t, s.Apply(yml)) + + orgProject := new(v1alpha1.OrganizationProject) + require.NoError(t, s.GetRunning(orgProject, name)) + + // THEN + assert.Equal(t, name, orgProject.GetName()) + assert.Equal(t, organizationID, orgProject.Spec.OrganizationID) + assert.Equal(t, projectID, orgProject.Spec.ProjectID) + assert.Equal(t, billingGroupID, orgProject.Spec.BillingGroupID) + assert.Equal(t, organizationID, orgProject.Spec.ParentID) + + // Validate the project exists in Aiven. + orgProjectAvn, err := avnGen.OrganizationProjectsGet(ctx, organizationID, projectID) + require.NoError(t, err) + assert.Equal(t, projectID, orgProjectAvn.ProjectId) + // The API normalizes parent_id: a project parented to the organization itself is + // echoed back with the organization's account ID. + assert.Contains(t, []string{organizationID, cfg.AccountID}, orgProjectAvn.ParentId) + assert.Equal(t, "prod", orgProjectAvn.Tags["env"]) + require.Len(t, orgProjectAvn.TechEmails, 1) + assert.Equal(t, "tech@example.com", orgProjectAvn.TechEmails[0].Email) + + // Validates the CA cert Secret is written with the resource-kind prefix. + secret, err := s.GetSecret(name) + require.NoError(t, err) + assert.NotEmpty(t, secret.Data["ORGANIZATIONPROJECT_CA_CERT"]) + + // WHEN + // Updates tags and technical emails. + updatedYml, err := loadExampleYaml("organizationproject.yaml", map[string]string{ + "metadata.name": name, + "spec.connInfoSecretTarget.name": name, + "spec.organizationId": organizationID, + "spec.projectId": projectID, + "spec.billingGroupId": billingGroupID, + "spec.parentId": organizationID, + "spec.tags.env": "staging", + "spec.technicalEmails[0]": "updated@example.com", + }) + require.NoError(t, err) + require.NoError(t, s.Apply(updatedYml)) + + updatedOrgProject := new(v1alpha1.OrganizationProject) + require.NoError(t, s.GetRunning(updatedOrgProject, name)) + + // THEN + // The update reconciles cleanly and the new values round-trip through the API. + require.Eventually(t, func() bool { + out, err := avnGen.OrganizationProjectsGet(ctx, organizationID, projectID) + if err != nil { + return false + } + if out.Tags["env"] != "staging" { + return false + } + if len(out.TechEmails) != 1 { + return false + } + return out.TechEmails[0].Email == "updated@example.com" + }, time.Minute, 5*time.Second) + + t.Run("reverts out-of-band tags when spec has no tags", func(t *testing.T) { + // GIVEN + // A project whose spec has no tags at all: the controller treats nil tags + // as "must be empty" in its drift check, so tags added directly in Aiven + // are expected to be reverted. + noTagsName := randName("org-project") + noTagsYml, err := loadExampleYaml("organizationproject.yaml", map[string]string{ + "metadata.name": noTagsName, + "spec.connInfoSecretTarget.name": noTagsName, + "spec.organizationId": organizationID, + "spec.projectId": noTagsName, + "spec.billingGroupId": billingGroupID, + "spec.parentId": organizationID, + "spec.tags": "REMOVE", + }) + require.NoError(t, err) + require.NoError(t, s.Apply(noTagsYml)) + + noTagsProject := new(v1alpha1.OrganizationProject) + require.NoError(t, s.GetRunning(noTagsProject, noTagsName)) + require.Empty(t, noTagsProject.Spec.Tags) + + // WHEN + // Adds a tag directly in Aiven, bypassing Kubernetes. + _, err = avnGen.OrganizationProjectsUpdate(ctx, organizationID, noTagsName, &organizationprojects.OrganizationProjectsUpdateIn{ + Tags: &map[string]string{"env": "hijacked"}, + }) + require.NoError(t, err) + + out, err := avnGen.OrganizationProjectsGet(ctx, organizationID, noTagsName) + require.NoError(t, err) + require.Equal(t, "hijacked", out.Tags["env"], "out-of-band tag update did not land") + + // THEN + // The controller re-observes on its poll interval (1m in the test suite), + // detects the drift and must clear the remote tags. A nil spec.Tags is sent + // as an empty map ("tags": {}), not null, so the API clears the field + // instead of ignoring it. + require.Eventually(t, func() bool { + out, err := avnGen.OrganizationProjectsGet(ctx, organizationID, noTagsName) + if err != nil { + return false + } + return len(out.Tags) == 0 + }, 3*time.Minute, 5*time.Second, "remote tags were not cleared; spec has no tags so the drift must be reverted") + }) + + t.Run("immutable fields are rejected by the API server", func(t *testing.T) { + // organizationId and projectId are guarded by CEL rules on the CRD, so the + // API server must reject any change before it ever reaches the controller. + projectIDUpdate, err := loadExampleYaml("organizationproject.yaml", map[string]string{ + "metadata.name": name, + "spec.connInfoSecretTarget.name": name, + "spec.organizationId": organizationID, + "spec.projectId": projectID + "-renamed", + "spec.billingGroupId": billingGroupID, + "spec.parentId": organizationID, + }) + require.NoError(t, err) + require.ErrorContains(t, s.Apply(projectIDUpdate), "Value is immutable") + + organizationIDUpdate, err := loadExampleYaml("organizationproject.yaml", map[string]string{ + "metadata.name": name, + "spec.connInfoSecretTarget.name": name, + "spec.organizationId": "org000000000a", + "spec.projectId": projectID, + "spec.billingGroupId": billingGroupID, + "spec.parentId": organizationID, + }) + require.NoError(t, err) + require.ErrorContains(t, s.Apply(organizationIDUpdate), "Value is immutable") + }) + + // WHEN/THEN + // Deletes the project and asserts it is gone from Aiven. + require.NoError(t, s.Delete(updatedOrgProject, func() error { + _, err := avnGen.OrganizationProjectsGet(ctx, organizationID, projectID) + return err + })) +} From d3a4d566a0c34dccd902d98b9a6f00df24c8a451 Mon Sep 17 00:00:00 2001 From: Myroslav Vivcharyk Date: Wed, 5 Aug 2026 11:56:05 +0200 Subject: [PATCH 2/3] feat(organizationproject): added OrganizationProject resource --- api/v1alpha1/organizationproject_types.go | 6 +- .../aiven.io_organizationprojects.yaml | 8 +- .../bases/aiven.io_organizationprojects.yaml | 8 +- config/crd/kustomization.yaml | 2 + controllers/organizationproject_controller.go | 42 ++++- .../organizationproject_controller_test.go | 143 +++++++++++++++++- docs/docs/resources/organizationproject.md | 4 +- tests/organizationproject_test.go | 26 ++++ 8 files changed, 219 insertions(+), 20 deletions(-) diff --git a/api/v1alpha1/organizationproject_types.go b/api/v1alpha1/organizationproject_types.go index 3b625e215..d92bf8205 100644 --- a/api/v1alpha1/organizationproject_types.go +++ b/api/v1alpha1/organizationproject_types.go @@ -40,9 +40,11 @@ type OrganizationProjectSpec struct { BasePort *int `json:"basePort,omitempty"` // +kubebuilder:validation:MaxItems=10 + // +kubebuilder:validation:items:MaxLength=254 + // +kubebuilder:validation:XValidation:rule="self.all(x, self.exists_one(y, y == x))",message="Emails must be unique" // TechnicalEmails are the technical contact emails of the project. - // This list is authoritative: when omitted, emails added outside - // Kubernetes are removed. + // This list is authoritative: when omitted, emails added outside Kubernetes are removed. + // Duplicates are rejected. TechnicalEmails []string `json:"technicalEmails,omitempty"` // Tags are key-value pairs that allow you to categorize projects. diff --git a/charts/aiven-operator-crds/templates/aiven.io_organizationprojects.yaml b/charts/aiven-operator-crds/templates/aiven.io_organizationprojects.yaml index ba7691c1d..3de467c58 100644 --- a/charts/aiven-operator-crds/templates/aiven.io_organizationprojects.yaml +++ b/charts/aiven-operator-crds/templates/aiven.io_organizationprojects.yaml @@ -162,12 +162,16 @@ spec: technicalEmails: description: |- TechnicalEmails are the technical contact emails of the project. - This list is authoritative: when omitted, emails added outside - Kubernetes are removed. + This list is authoritative: when omitted, emails added outside Kubernetes are removed. + Duplicates are rejected. items: + maxLength: 254 type: string maxItems: 10 type: array + x-kubernetes-validations: + - message: Emails must be unique + rule: self.all(x, self.exists_one(y, y == x)) required: - billingGroupId - organizationId diff --git a/config/crd/bases/aiven.io_organizationprojects.yaml b/config/crd/bases/aiven.io_organizationprojects.yaml index ba7691c1d..3de467c58 100644 --- a/config/crd/bases/aiven.io_organizationprojects.yaml +++ b/config/crd/bases/aiven.io_organizationprojects.yaml @@ -162,12 +162,16 @@ spec: technicalEmails: description: |- TechnicalEmails are the technical contact emails of the project. - This list is authoritative: when omitted, emails added outside - Kubernetes are removed. + This list is authoritative: when omitted, emails added outside Kubernetes are removed. + Duplicates are rejected. items: + maxLength: 254 type: string maxItems: 10 type: array + x-kubernetes-validations: + - message: Emails must be unique + rule: self.all(x, self.exists_one(y, y == x)) required: - billingGroupId - organizationId diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index e5498666d..e506d8007 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -14,6 +14,8 @@ resources: - bases/aiven.io_kafkaschemas.yaml - bases/aiven.io_kafkatopics.yaml - bases/aiven.io_opensearches.yaml + - bases/aiven.io_opensearchaclconfigs.yaml + - bases/aiven.io_organizationprojects.yaml - bases/aiven.io_postgresqls.yaml - bases/aiven.io_projects.yaml - bases/aiven.io_projectvpcs.yaml diff --git a/controllers/organizationproject_controller.go b/controllers/organizationproject_controller.go index 8e6135dd7..e307b584b 100644 --- a/controllers/organizationproject_controller.go +++ b/controllers/organizationproject_controller.go @@ -6,6 +6,7 @@ import ( "context" "fmt" "maps" + "slices" "strings" avngen "github.com/aiven/go-client-codegen" @@ -66,20 +67,26 @@ func (r *OrganizationProjectController) Observe(ctx context.Context, cr *v1alpha }, nil } - cert, err := r.avnGen.ProjectKmsGetCA(ctx, cr.Spec.ProjectID) - if err != nil { - return Observation{}, fmt.Errorf("getting project KMS CA: %w", err) + // The CA cert is only fetched to fill the connection secret, so skip the call + // entirely when the secret is disabled: the details would be dropped anyway. + var details SecretDetails + if !cr.NoSecret() { + cert, err := r.avnGen.ProjectKmsGetCA(ctx, cr.Spec.ProjectID) + if err != nil { + return Observation{}, fmt.Errorf("getting project KMS CA: %w", err) + } + + details = SecretDetails{ + getSecretPrefix(cr) + "CA_CERT": cert, + } } markInstanceRunning(cr) - prefix := getSecretPrefix(cr) return Observation{ ResourceExists: true, ResourceUpToDate: true, - SecretDetails: SecretDetails{ - prefix + "CA_CERT": cert, - }, + SecretDetails: details, }, nil } @@ -175,7 +182,7 @@ func (r *OrganizationProjectController) orgProjectMatchesSpec(ctx context.Contex for i, e := range got.TechEmails { gotEmails[i] = e.Email } - if !cmp.Equal(cr.Spec.TechnicalEmails, gotEmails, cmpopts.SortSlices(strings.Compare), cmpopts.EquateEmpty()) { + if !cmp.Equal(normalizeTechEmails(cr.Spec.TechnicalEmails), normalizeTechEmails(gotEmails), cmpopts.EquateEmpty()) { return false, nil } @@ -201,6 +208,25 @@ func (r *OrganizationProjectController) resolveParentID(ctx context.Context, par return org.AccountId, nil } +// normalizeTechEmails mirrors how Aiven stores tech emails: domain lowercased duplicates dropped, sorted. +func normalizeTechEmails(emails []string) []string { + out := make([]string, 0, len(emails)) + seen := make(map[string]struct{}, len(emails)) + for _, e := range emails { + at := strings.LastIndex(e, "@") + if at >= 0 { + e = e[:at+1] + strings.ToLower(e[at+1:]) + } + if _, ok := seen[e]; ok { + continue + } + seen[e] = struct{}{} + out = append(out, e) + } + slices.Sort(out) + return out +} + // organizationProjectTechEmails always returns a non-nil slice of technical emails. func organizationProjectTechEmails(emails []string) []organizationprojects.TechEmailIn { techEmails := make([]organizationprojects.TechEmailIn, len(emails)) diff --git a/controllers/organizationproject_controller_test.go b/controllers/organizationproject_controller_test.go index 1a2c24121..e69cb9b0f 100644 --- a/controllers/organizationproject_controller_test.go +++ b/controllers/organizationproject_controller_test.go @@ -448,6 +448,97 @@ func TestOrganizationProjectReconciler(t *testing.T) { require.Equal(t, metav1.ConditionTrue, condition.Status) }) + t.Run("Stays up to date when Aiven lowercases an email domain", func(t *testing.T) { + // Aiven lowercases the domain, so the echo must not read as drift. + op := newOrganizationProject(t) + op.Generation = 1 + op.Annotations = map[string]string{ + processedGenerationAnnotation: "1", + } + op.Spec.TechnicalEmails = []string{"Ops.Team@Example.COM"} + + remote := remoteFromSpec(op) + remote.TechEmails = []organizationprojects.TechEmailOut{{Email: "Ops.Team@example.com"}} + + avn := avngen.NewMockClient(t) + avn.EXPECT(). + OrganizationProjectsGet(mock.Anything, op.Spec.OrganizationID, op.Spec.ProjectID). + Return(remote, nil).Once() + expectParentIDResolution(op, avn) + avn.EXPECT(). + ProjectKmsGetCA(mock.Anything, op.Spec.ProjectID). + Return("ca-cert", nil).Once() + // No OrganizationProjectsUpdate expectation: the domain-case echo must not + // be read as drift. The mock fails the test if Update is called. + + r, res := runScenario(t, op, avn) + require.Equal(t, ctrlruntime.Result{RequeueAfter: testPollInterval}, res) + + got := &v1alpha1.OrganizationProject{} + require.NoError(t, r.Get(t.Context(), types.NamespacedName{Name: op.Name, Namespace: op.Namespace}, got)) + require.Equal(t, "true", got.Annotations[instanceIsRunningAnnotation]) + }) + + t.Run("Stays up to date when Aiven collapses domain-case duplicates", func(t *testing.T) { + // Byte-wise unique, so the CEL rule allows it, but Aiven stores one entry. + op := newOrganizationProject(t) + op.Generation = 1 + op.Annotations = map[string]string{ + processedGenerationAnnotation: "1", + } + op.Spec.TechnicalEmails = []string{"dup@Example.com", "dup@example.com"} + + remote := remoteFromSpec(op) + remote.TechEmails = []organizationprojects.TechEmailOut{{Email: "dup@example.com"}} + + avn := avngen.NewMockClient(t) + avn.EXPECT(). + OrganizationProjectsGet(mock.Anything, op.Spec.OrganizationID, op.Spec.ProjectID). + Return(remote, nil).Once() + expectParentIDResolution(op, avn) + avn.EXPECT(). + ProjectKmsGetCA(mock.Anything, op.Spec.ProjectID). + Return("ca-cert", nil).Once() + // No Update expectation: the collapse must not read as drift. + + r, res := runScenario(t, op, avn) + require.Equal(t, ctrlruntime.Result{RequeueAfter: testPollInterval}, res) + + got := &v1alpha1.OrganizationProject{} + require.NoError(t, r.Get(t.Context(), types.NamespacedName{Name: op.Name, Namespace: op.Namespace}, got)) + require.Equal(t, "true", got.Annotations[instanceIsRunningAnnotation]) + }) + + t.Run("Treats a local-part case difference as real drift", func(t *testing.T) { + // Aiven keeps these as two addresses, so folding the local part would + // wrongly report in-sync. Guards against EqualFold creeping in. + op := newOrganizationProject(t) + op.Generation = 1 + op.Annotations = map[string]string{ + processedGenerationAnnotation: "1", + } + op.Spec.TechnicalEmails = []string{"Ops@example.com"} + + remote := remoteFromSpec(op) + remote.TechEmails = []organizationprojects.TechEmailOut{{Email: "ops@example.com"}} + + avn := avngen.NewMockClient(t) + avn.EXPECT(). + OrganizationProjectsGet(mock.Anything, op.Spec.OrganizationID, op.Spec.ProjectID). + Return(remote, nil).Once() + // Only one resolution (in Update): the drift check short-circuits on the + // email mismatch before reaching the parent_id comparison. + expectParentIDResolution(op, avn) + avn.EXPECT(). + OrganizationProjectsUpdate(mock.Anything, op.Spec.OrganizationID, op.Spec.ProjectID, mock.MatchedBy(func(in *organizationprojects.OrganizationProjectsUpdateIn) bool { + return in.TechEmails != nil && assert.Equal(t, organizationProjectTechEmails(op.Spec.TechnicalEmails), *in.TechEmails) + })). + Return(&organizationprojects.OrganizationProjectsUpdateOut{ProjectId: op.Spec.ProjectID}, nil).Once() + + _, res := runScenario(t, op, avn) + require.Equal(t, ctrlruntime.Result{RequeueAfter: requeueTimeout}, res) + }) + t.Run("Reverts out-of-band remote changes even when generation is processed", func(t *testing.T) { op := newOrganizationProject(t) op.Generation = 1 @@ -554,7 +645,7 @@ func TestOrganizationProjectReconciler(t *testing.T) { require.True(t, apierrors.IsNotFound(err)) }) - t.Run("Does not write CA cert secret when connection secret is disabled", func(t *testing.T) { + t.Run("Skips the CA fetch and writes no secret when connection secret is disabled", func(t *testing.T) { op := newOrganizationProject(t) op.Generation = 1 op.Annotations = map[string]string{ @@ -568,9 +659,8 @@ func TestOrganizationProjectReconciler(t *testing.T) { OrganizationProjectsGet(mock.Anything, op.Spec.OrganizationID, op.Spec.ProjectID). Return(remoteFromSpec(op), nil).Once() expectParentIDResolution(op, avn) - avn.EXPECT(). - ProjectKmsGetCA(mock.Anything, op.Spec.ProjectID). - Return("ca-cert", nil).Once() + // No ProjectKmsGetCA expectation: the cert only feeds the connection secret, + // so the call must not be made at all. The mock fails on an unexpected call. r, res := runScenario(t, op, avn) require.Equal(t, ctrlruntime.Result{RequeueAfter: testPollInterval}, res) @@ -602,3 +692,48 @@ func TestOrganizationProjectReconciler(t *testing.T) { require.NotNil(t, meta.FindStatusCondition(got.Status.Conditions, ConditionTypeError)) }) } + +func TestNormalizeTechEmails(t *testing.T) { + t.Parallel() + + // Behaviors verified against the live API. + cases := []struct { + name string + in []string + want []string + }{{ + name: "lowercases the domain but not the local part", + in: []string{"MixedCase.Probe@Example.COM"}, + want: []string{"MixedCase.Probe@example.com"}, + }, { + name: "keeps local-part case differences as distinct addresses", + in: []string{"Case.Dup@example.com", "case.dup@example.com"}, + want: []string{"Case.Dup@example.com", "case.dup@example.com"}, + }, { + name: "collapses domain-case duplicates", + in: []string{"domain.dup@Example.com", "domain.dup@example.com"}, + want: []string{"domain.dup@example.com"}, + }, { + name: "drops exact duplicates", + in: []string{"dup@example.com", "dup@example.com"}, + want: []string{"dup@example.com"}, + }, { + name: "sorts so order is not drift", + in: []string{"b@example.com", "a@example.com"}, + want: []string{"a@example.com", "b@example.com"}, + }, { + name: "leaves a value without @ alone", + in: []string{"not-an-email"}, + want: []string{"not-an-email"}, + }, { + name: "handles nil", + in: nil, + want: []string{}, + }} + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, normalizeTechEmails(tc.in)) + }) + } +} diff --git a/docs/docs/resources/organizationproject.md b/docs/docs/resources/organizationproject.md index 0a80928ec..7ff09da3b 100644 --- a/docs/docs/resources/organizationproject.md +++ b/docs/docs/resources/organizationproject.md @@ -138,8 +138,8 @@ OrganizationProjectSpec defines the desired state of OrganizationProject. This map is authoritative: when omitted, tags added outside Kubernetes are removed. - [`technicalEmails`](#spec.technicalEmails-property){: name='spec.technicalEmails-property'} (array of strings, MaxItems: 10). TechnicalEmails are the technical contact emails of the project. - This list is authoritative: when omitted, emails added outside - Kubernetes are removed. + This list is authoritative: when omitted, emails added outside Kubernetes are removed. + Duplicates are rejected. ## authSecretRef {: #spec.authSecretRef } diff --git a/tests/organizationproject_test.go b/tests/organizationproject_test.go index 1142b90d1..61d232275 100644 --- a/tests/organizationproject_test.go +++ b/tests/organizationproject_test.go @@ -3,6 +3,7 @@ package tests import ( + "fmt" "testing" "time" @@ -192,6 +193,31 @@ func TestOrganizationProject(t *testing.T) { require.ErrorContains(t, s.Apply(organizationIDUpdate), "Value is immutable") }) + t.Run("duplicate technical emails are rejected by the API server", func(t *testing.T) { + // Aiven deduplicates technicalEmails, which the controller would then see as + // permanent drift and try to fix on every poll. A CEL rule on the CRD stops + // the manifest at admission, so the resource is never created. + duplicateEmails := fmt.Sprintf(` +apiVersion: aiven.io/v1alpha1 +kind: OrganizationProject +metadata: + name: %[1]s-duplicate-emails +spec: + authSecretRef: + name: aiven-token + key: token + organizationId: %[2]s + projectId: %[1]s-duplicate-emails + billingGroupId: %[3]s + parentId: %[2]s + technicalEmails: + - tech@example.com + - tech@example.com +`, name, organizationID, billingGroupID) + + require.ErrorContains(t, s.Apply(duplicateEmails), "Emails must be unique") + }) + // WHEN/THEN // Deletes the project and asserts it is gone from Aiven. require.NoError(t, s.Delete(updatedOrgProject, func() error { From ec03482da510f2644f6cac4a7a09e3711aa8a77d Mon Sep 17 00:00:00 2001 From: Myroslav Vivcharyk Date: Wed, 5 Aug 2026 16:15:56 +0200 Subject: [PATCH 3/3] feat(organizationproject): added OrganizationProject resource --- tests/organizationproject_test.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/organizationproject_test.go b/tests/organizationproject_test.go index 61d232275..3d0559846 100644 --- a/tests/organizationproject_test.go +++ b/tests/organizationproject_test.go @@ -167,6 +167,18 @@ func TestOrganizationProject(t *testing.T) { }, 3*time.Minute, 5*time.Second, "remote tags were not cleared; spec has no tags so the drift must be reverted") }) + t.Run("parent_id is echoed in the account form", func(t *testing.T) { + // orgProjectMatchesSpec resolves spec.parentId to the account form before comparing it with parent_id. + // If the API echoed the org form instead, the comparison would never match and Update would re-run on every poll. + out, err := avnGen.OrganizationProjectsGet(ctx, organizationID, projectID) + require.NoError(t, err) + org, err := avnGen.OrganizationGet(ctx, organizationID) + require.NoError(t, err) + + assert.Equal(t, org.AccountId, out.ParentId) + assert.NotEqual(t, organizationID, out.ParentId, "the org form must not be echoed back") + }) + t.Run("immutable fields are rejected by the API server", func(t *testing.T) { // organizationId and projectId are guarded by CEL rules on the CRD, so the // API server must reject any change before it ever reaches the controller.