diff --git a/CHANGELOG.md b/CHANGELOG.md index 143a840b..aeab22c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [MAJOR.MINOR.PATCH] - YYYY-MM-DD +- Add kind: `UpgradePipelineStep` + ## v0.38.0 - 2026-05-18 - Add `MySQL` and `PostgreSQL` field `migrationSecretSource`, type `object`: Reference to a Secret containing migration diff --git a/api/v1alpha1/upgradepipelinestep_types.go b/api/v1alpha1/upgradepipelinestep_types.go new file mode 100644 index 00000000..95be2482 --- /dev/null +++ b/api/v1alpha1/upgradepipelinestep_types.go @@ -0,0 +1,121 @@ +// Copyright (c) 2026 Aiven, Helsinki, Finland. https://aiven.io/ + +package v1alpha1 + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +// UpgradePipelineStepSpec defines the desired state of UpgradePipelineStep. +type UpgradePipelineStepSpec struct { + AuthSecretRefField `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 upgrade pipeline step. + 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" + // SourceProjectName is the project name of the service that must be upgraded first. + SourceProjectName string `json:"sourceProjectName"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=63 + // +kubebuilder:validation:Pattern="^[a-z][-a-z0-9]+$" + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + // SourceServiceName is the service name that must be upgraded first. + SourceServiceName string `json:"sourceServiceName"` + + // +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" + // DestinationProjectName is the project name of the service that waits for the source service. + DestinationProjectName string `json:"destinationProjectName"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=63 + // +kubebuilder:validation:Pattern="^[a-z][-a-z0-9]+$" + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" + // DestinationServiceName is the service name that waits for the source service. + DestinationServiceName string `json:"destinationServiceName"` + + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:default=7 + // AutoValidationDelayDays is the number of days before Aiven can automatically validate the source service. + AutoValidationDelayDays int `json:"autoValidationDelayDays,omitempty"` +} + +// UpgradePipelineStepLastValidationStatus describes the last validation observed for an upgrade pipeline step. +type UpgradePipelineStepLastValidationStatus struct { + // Comment is the validation comment. + Comment string `json:"comment,omitempty"` + + // ValidatedAt is the time the validation was created. + ValidatedAt *metav1.Time `json:"validatedAt,omitempty"` + + // ValidatedByUser is the user who created the validation. It is empty for auto-validation. + ValidatedByUser string `json:"validatedByUser,omitempty"` +} + +// UpgradePipelineStepStatus defines the observed state of UpgradePipelineStep. +type UpgradePipelineStepStatus struct { + // Conditions represent the latest available observations of an UpgradePipelineStep state. + Conditions []metav1.Condition `json:"conditions"` + + // ID is the Aiven upgrade pipeline step ID. + ID string `json:"id,omitempty"` + + // LastValidation contains the last validation information returned by the Aiven API. + LastValidation *UpgradePipelineStepLastValidationStatus `json:"lastValidation,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// UpgradePipelineStep is the Schema for the upgradepipelinesteps API. +// +kubebuilder:printcolumn:name="Organization",type="string",JSONPath=".spec.organizationId" +// +kubebuilder:printcolumn:name="Source Project",type="string",JSONPath=".spec.sourceProjectName" +// +kubebuilder:printcolumn:name="Source Service",type="string",JSONPath=".spec.sourceServiceName" +// +kubebuilder:printcolumn:name="Destination Project",type="string",JSONPath=".spec.destinationProjectName" +// +kubebuilder:printcolumn:name="Destination Service",type="string",JSONPath=".spec.destinationServiceName" +// +kubebuilder:printcolumn:name="Step ID",type="string",JSONPath=".status.id" +type UpgradePipelineStep struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec UpgradePipelineStepSpec `json:"spec,omitempty"` + Status UpgradePipelineStepStatus `json:"status,omitempty"` +} + +var _ AivenManagedObject = &UpgradePipelineStep{} + +func (*UpgradePipelineStep) NoSecret() bool { + return true +} + +func (in *UpgradePipelineStep) AuthSecretRef() *AuthSecretReference { + return in.Spec.AuthSecretRef +} + +func (in *UpgradePipelineStep) Conditions() *[]metav1.Condition { + return &in.Status.Conditions +} + +func (in *UpgradePipelineStep) GetObjectMeta() *metav1.ObjectMeta { + return &in.ObjectMeta +} + +// +kubebuilder:object:root=true + +// UpgradePipelineStepList contains a list of UpgradePipelineStep. +type UpgradePipelineStepList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []UpgradePipelineStep `json:"items"` +} + +func init() { + SchemeBuilder.Register(&UpgradePipelineStep{}, &UpgradePipelineStepList{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 68e16dea..7e0dfef8 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -3367,6 +3367,127 @@ func (in *ServiceUserStatus) DeepCopy() *ServiceUserStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UpgradePipelineStep) DeepCopyInto(out *UpgradePipelineStep) { + *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 UpgradePipelineStep. +func (in *UpgradePipelineStep) DeepCopy() *UpgradePipelineStep { + if in == nil { + return nil + } + out := new(UpgradePipelineStep) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *UpgradePipelineStep) 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 *UpgradePipelineStepLastValidationStatus) DeepCopyInto(out *UpgradePipelineStepLastValidationStatus) { + *out = *in + if in.ValidatedAt != nil { + in, out := &in.ValidatedAt, &out.ValidatedAt + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UpgradePipelineStepLastValidationStatus. +func (in *UpgradePipelineStepLastValidationStatus) DeepCopy() *UpgradePipelineStepLastValidationStatus { + if in == nil { + return nil + } + out := new(UpgradePipelineStepLastValidationStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UpgradePipelineStepList) DeepCopyInto(out *UpgradePipelineStepList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]UpgradePipelineStep, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UpgradePipelineStepList. +func (in *UpgradePipelineStepList) DeepCopy() *UpgradePipelineStepList { + if in == nil { + return nil + } + out := new(UpgradePipelineStepList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *UpgradePipelineStepList) 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 *UpgradePipelineStepSpec) DeepCopyInto(out *UpgradePipelineStepSpec) { + *out = *in + in.AuthSecretRefField.DeepCopyInto(&out.AuthSecretRefField) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UpgradePipelineStepSpec. +func (in *UpgradePipelineStepSpec) DeepCopy() *UpgradePipelineStepSpec { + if in == nil { + return nil + } + out := new(UpgradePipelineStepSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UpgradePipelineStepStatus) DeepCopyInto(out *UpgradePipelineStepStatus) { + *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]) + } + } + if in.LastValidation != nil { + in, out := &in.LastValidation, &out.LastValidation + *out = new(UpgradePipelineStepLastValidationStatus) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UpgradePipelineStepStatus. +func (in *UpgradePipelineStepStatus) DeepCopy() *UpgradePipelineStepStatus { + if in == nil { + return nil + } + out := new(UpgradePipelineStepStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Valkey) DeepCopyInto(out *Valkey) { *out = *in diff --git a/charts/aiven-operator-crds/templates/aiven.io_upgradepipelinesteps.yaml b/charts/aiven-operator-crds/templates/aiven.io_upgradepipelinesteps.yaml new file mode 100644 index 00000000..4da84126 --- /dev/null +++ b/charts/aiven-operator-crds/templates/aiven.io_upgradepipelinesteps.yaml @@ -0,0 +1,234 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: upgradepipelinesteps.aiven.io +spec: + group: aiven.io + names: + kind: UpgradePipelineStep + listKind: UpgradePipelineStepList + plural: upgradepipelinesteps + singular: upgradepipelinestep + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.organizationId + name: Organization + type: string + - jsonPath: .spec.sourceProjectName + name: Source Project + type: string + - jsonPath: .spec.sourceServiceName + name: Source Service + type: string + - jsonPath: .spec.destinationProjectName + name: Destination Project + type: string + - jsonPath: .spec.destinationServiceName + name: Destination Service + type: string + - jsonPath: .status.id + name: Step ID + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: + UpgradePipelineStep is the Schema for the upgradepipelinesteps + API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: UpgradePipelineStepSpec defines the desired state of UpgradePipelineStep. + 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 + autoValidationDelayDays: + default: 7 + description: + AutoValidationDelayDays is the number of days before + Aiven can automatically validate the source service. + minimum: 0 + type: integer + destinationProjectName: + description: + DestinationProjectName is the project name of the service + that waits for the source service. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z0-9_-]+$ + type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + destinationServiceName: + description: + DestinationServiceName is the service name that waits + for the source service. + maxLength: 63 + minLength: 1 + pattern: ^[a-z][-a-z0-9]+$ + type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + organizationId: + description: + OrganizationID is the Aiven organization ID that owns + the upgrade pipeline step. + minLength: 1 + type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + sourceProjectName: + description: + SourceProjectName is the project name of the service + that must be upgraded first. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z0-9_-]+$ + type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + sourceServiceName: + description: + SourceServiceName is the service name that must be upgraded + first. + maxLength: 63 + minLength: 1 + pattern: ^[a-z][-a-z0-9]+$ + type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + required: + - destinationProjectName + - destinationServiceName + - organizationId + - sourceProjectName + - sourceServiceName + type: object + status: + description: UpgradePipelineStepStatus defines the observed state of UpgradePipelineStep. + properties: + conditions: + description: + Conditions represent the latest available observations + of an UpgradePipelineStep 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 + id: + description: ID is the Aiven upgrade pipeline step ID. + type: string + lastValidation: + description: + LastValidation contains the last validation information + returned by the Aiven API. + properties: + comment: + description: Comment is the validation comment. + type: string + validatedAt: + description: ValidatedAt is the time the validation was created. + format: date-time + type: string + validatedByUser: + description: + ValidatedByUser is the user who created the validation. + It is empty for auto-validation. + type: string + type: object + 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 e96da118..b487bfe3 100644 --- a/charts/aiven-operator/templates/cluster_role.yaml +++ b/charts/aiven-operator/templates/cluster_role.yaml @@ -55,6 +55,7 @@ rules: - serviceintegrationendpoints - serviceintegrations - serviceusers + - upgradepipelinesteps - valkeys verbs: - create @@ -92,6 +93,7 @@ rules: - serviceintegrationendpoints/finalizers - serviceintegrations/finalizers - serviceusers/finalizers + - upgradepipelinesteps/finalizers - valkeys/finalizers verbs: - create @@ -126,6 +128,7 @@ rules: - serviceintegrationendpoints/status - serviceintegrations/status - serviceusers/status + - upgradepipelinesteps/status - valkeys/status verbs: - get diff --git a/config/crd/bases/aiven.io_upgradepipelinesteps.yaml b/config/crd/bases/aiven.io_upgradepipelinesteps.yaml new file mode 100644 index 00000000..4da84126 --- /dev/null +++ b/config/crd/bases/aiven.io_upgradepipelinesteps.yaml @@ -0,0 +1,234 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: upgradepipelinesteps.aiven.io +spec: + group: aiven.io + names: + kind: UpgradePipelineStep + listKind: UpgradePipelineStepList + plural: upgradepipelinesteps + singular: upgradepipelinestep + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.organizationId + name: Organization + type: string + - jsonPath: .spec.sourceProjectName + name: Source Project + type: string + - jsonPath: .spec.sourceServiceName + name: Source Service + type: string + - jsonPath: .spec.destinationProjectName + name: Destination Project + type: string + - jsonPath: .spec.destinationServiceName + name: Destination Service + type: string + - jsonPath: .status.id + name: Step ID + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: + UpgradePipelineStep is the Schema for the upgradepipelinesteps + API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: UpgradePipelineStepSpec defines the desired state of UpgradePipelineStep. + 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 + autoValidationDelayDays: + default: 7 + description: + AutoValidationDelayDays is the number of days before + Aiven can automatically validate the source service. + minimum: 0 + type: integer + destinationProjectName: + description: + DestinationProjectName is the project name of the service + that waits for the source service. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z0-9_-]+$ + type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + destinationServiceName: + description: + DestinationServiceName is the service name that waits + for the source service. + maxLength: 63 + minLength: 1 + pattern: ^[a-z][-a-z0-9]+$ + type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + organizationId: + description: + OrganizationID is the Aiven organization ID that owns + the upgrade pipeline step. + minLength: 1 + type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + sourceProjectName: + description: + SourceProjectName is the project name of the service + that must be upgraded first. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z0-9_-]+$ + type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + sourceServiceName: + description: + SourceServiceName is the service name that must be upgraded + first. + maxLength: 63 + minLength: 1 + pattern: ^[a-z][-a-z0-9]+$ + type: string + x-kubernetes-validations: + - message: Value is immutable + rule: self == oldSelf + required: + - destinationProjectName + - destinationServiceName + - organizationId + - sourceProjectName + - sourceServiceName + type: object + status: + description: UpgradePipelineStepStatus defines the observed state of UpgradePipelineStep. + properties: + conditions: + description: + Conditions represent the latest available observations + of an UpgradePipelineStep 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 + id: + description: ID is the Aiven upgrade pipeline step ID. + type: string + lastValidation: + description: + LastValidation contains the last validation information + returned by the Aiven API. + properties: + comment: + description: Comment is the validation comment. + type: string + validatedAt: + description: ValidatedAt is the time the validation was created. + format: date-time + type: string + validatedByUser: + description: + ValidatedByUser is the user who created the validation. + It is empty for auto-validation. + type: string + type: object + 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 1cd8b5f8..2a718cc9 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -52,6 +52,7 @@ rules: - serviceintegrationendpoints - serviceintegrations - serviceusers + - upgradepipelinesteps - valkeys verbs: - create @@ -89,6 +90,7 @@ rules: - serviceintegrationendpoints/finalizers - serviceintegrations/finalizers - serviceusers/finalizers + - upgradepipelinesteps/finalizers - valkeys/finalizers verbs: - create @@ -123,6 +125,7 @@ rules: - serviceintegrationendpoints/status - serviceintegrations/status - serviceusers/status + - upgradepipelinesteps/status - valkeys/status verbs: - get diff --git a/config/samples/_v1alpha1_upgradepipelinestep.yaml b/config/samples/_v1alpha1_upgradepipelinestep.yaml new file mode 100644 index 00000000..a619f2b1 --- /dev/null +++ b/config/samples/_v1alpha1_upgradepipelinestep.yaml @@ -0,0 +1,11 @@ +apiVersion: aiven.io/v1alpha1 +kind: UpgradePipelineStep +metadata: + name: upgradepipelinestep-sample +spec: + organizationId: org123 + sourceProjectName: sandbox + sourceServiceName: billing-pg-sandbox + destinationProjectName: prod + destinationServiceName: billing-pg-prod + autoValidationDelayDays: 7 diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 312afb6b..abe2d428 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -25,4 +25,5 @@ resources: - _v1alpha1_flink.yaml - _v1alpha1_valkey.yaml - _v1alpha1_kafkanativeacl.yaml + - _v1alpha1_upgradepipelinestep.yaml #+kubebuilder:scaffold:manifestskustomizesamples diff --git a/controllers/setup.go b/controllers/setup.go index b40a9acf..2c6cb3e9 100644 --- a/controllers/setup.go +++ b/controllers/setup.go @@ -79,6 +79,7 @@ func SetupControllersWithConfig(mgr ctrl.Manager, cfg SetupConfig) error { "ServiceIntegration": newServiceIntegrationReconciler, "ServiceIntegrationEndpoint": newServiceIntegrationEndpointReconciler, "ServiceUser": newServiceUserReconciler, + "UpgradePipelineStep": newUpgradePipelineStepReconciler, "Valkey": newValkeyReconciler, } diff --git a/controllers/upgradepipelinestep_controller.go b/controllers/upgradepipelinestep_controller.go new file mode 100644 index 00000000..0c8b1ad6 --- /dev/null +++ b/controllers/upgradepipelinestep_controller.go @@ -0,0 +1,181 @@ +// Copyright (c) 2026 Aiven, Helsinki, Finland. https://aiven.io/ + +package controllers + +import ( + "context" + "fmt" + + avngen "github.com/aiven/go-client-codegen" + "github.com/aiven/go-client-codegen/handler/upgradepipeline" + "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 newUpgradePipelineStepReconciler(c Controller) reconcilerType { + return newManagedReconciler( + c, + func(c Controller, avnGen avngen.Client) AivenController[*v1alpha1.UpgradePipelineStep] { + return &UpgradePipelineStepController{ + Client: c.Client, + avnGen: avnGen, + } + }, + nil, + ) +} + +//+kubebuilder:rbac:groups=aiven.io,resources=upgradepipelinesteps,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=aiven.io,resources=upgradepipelinesteps/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=aiven.io,resources=upgradepipelinesteps/finalizers,verbs=get;create;update + +// UpgradePipelineStepController reconciles an UpgradePipelineStep object. +type UpgradePipelineStepController struct { + client.Client + avnGen avngen.Client +} + +func (r *UpgradePipelineStepController) Observe(ctx context.Context, cr *v1alpha1.UpgradePipelineStep) (Observation, error) { + if cr.Status.ID == "" { + step, err := r.lookupStep(ctx, cr) + if err != nil { + return Observation{}, err + } + if step == nil { + return Observation{ResourceExists: false}, nil + } + + r.applyStatus(cr, *step) + + return Observation{ + ResourceExists: true, + ResourceUpToDate: !r.hasDrift(cr, *step), + }, nil + } + + step, err := r.avnGen.UpgradePipelineStepGet( + ctx, + cr.Spec.OrganizationID, + cr.Status.ID, + ) + if err != nil { + if isNotFound(err) { + cr.Status.ID = "" + cr.Status.LastValidation = nil + return Observation{ResourceExists: false}, nil + } + + return Observation{}, fmt.Errorf("getting upgrade pipeline step: %w", err) + } + + r.applyStatus(cr, upgradepipeline.StepOut(*step)) + + return Observation{ + ResourceExists: true, + ResourceUpToDate: !r.hasDrift(cr, upgradepipeline.StepOut(*step)), + }, nil +} + +func (r *UpgradePipelineStepController) Create(ctx context.Context, cr *v1alpha1.UpgradePipelineStep) (CreateResult, error) { + step, err := r.avnGen.UpgradePipelineStepCreate( + ctx, + cr.Spec.OrganizationID, + &upgradepipeline.UpgradePipelineStepCreateIn{ + AutoValidationDelayDays: &cr.Spec.AutoValidationDelayDays, + DestinationProjectName: cr.Spec.DestinationProjectName, + DestinationServiceName: cr.Spec.DestinationServiceName, + SourceProjectName: cr.Spec.SourceProjectName, + SourceServiceName: cr.Spec.SourceServiceName, + }, + ) + if err != nil { + return CreateResult{}, fmt.Errorf("creating upgrade pipeline step: %w", err) + } + + r.applyStatus(cr, upgradepipeline.StepOut(*step)) + + return CreateResult{}, nil +} + +func (r *UpgradePipelineStepController) Update(ctx context.Context, cr *v1alpha1.UpgradePipelineStep) (UpdateResult, error) { + step, err := r.avnGen.UpgradePipelineStepUpdate( + ctx, + cr.Spec.OrganizationID, + cr.Status.ID, + &upgradepipeline.UpgradePipelineStepUpdateIn{ + AutoValidationDelayDays: &cr.Spec.AutoValidationDelayDays, + }, + ) + if err != nil { + return UpdateResult{}, fmt.Errorf("updating upgrade pipeline step: %w", err) + } + + r.applyStatus(cr, upgradepipeline.StepOut(*step)) + + return UpdateResult{}, nil +} + +func (r *UpgradePipelineStepController) Delete(ctx context.Context, cr *v1alpha1.UpgradePipelineStep) error { + if cr.Status.ID == "" { + return nil + } + + if err := r.avnGen.UpgradePipelineStepDelete(ctx, cr.Spec.OrganizationID, cr.Status.ID); err != nil && !isNotFound(err) { + return fmt.Errorf("deleting upgrade pipeline step: %w", err) + } + + return nil +} + +func (r *UpgradePipelineStepController) lookupStep(ctx context.Context, cr *v1alpha1.UpgradePipelineStep) (*upgradepipeline.StepOut, error) { + out, err := r.avnGen.UpgradePipelineStepList(ctx, cr.Spec.OrganizationID) + if err != nil { + return nil, fmt.Errorf("listing upgrade pipeline steps: %w", err) + } + + var existing *upgradepipeline.StepOut + for i := range out.Steps { + step := &out.Steps[i] + if step.SourceProjectName != cr.Spec.SourceProjectName || + step.SourceServiceName != cr.Spec.SourceServiceName || + step.DestinationProjectName != cr.Spec.DestinationProjectName || + step.DestinationServiceName != cr.Spec.DestinationServiceName { + continue + } + if existing != nil { + return nil, fmt.Errorf("found multiple upgrade pipeline steps matching %s/%s -> %s/%s", + cr.Spec.SourceProjectName, + cr.Spec.SourceServiceName, + cr.Spec.DestinationProjectName, + cr.Spec.DestinationServiceName, + ) + } + + existing = step + } + + return existing, nil +} + +func (*UpgradePipelineStepController) applyStatus(cr *v1alpha1.UpgradePipelineStep, step upgradepipeline.StepOut) { + cr.Status.ID = step.StepId + lastValidation := &v1alpha1.UpgradePipelineStepLastValidationStatus{ + Comment: step.LastValidation.Comment, + ValidatedAt: NilIfZero(metav1.NewTime(step.LastValidation.ValidatedAt)), + ValidatedByUser: step.LastValidation.ValidatedByUser, + } + if lastValidation.Comment == "" && lastValidation.ValidatedAt == nil && lastValidation.ValidatedByUser == "" { + cr.Status.LastValidation = nil + } else { + cr.Status.LastValidation = lastValidation + } + meta.SetStatusCondition(&cr.Status.Conditions, getRunningCondition(metav1.ConditionTrue, "CheckRunning", "Instance is running on Aiven side")) + metav1.SetMetaDataAnnotation(&cr.ObjectMeta, instanceIsRunningAnnotation, "true") +} + +func (*UpgradePipelineStepController) hasDrift(cr *v1alpha1.UpgradePipelineStep, step upgradepipeline.StepOut) bool { + return cr.Spec.AutoValidationDelayDays != step.AutoValidationDelayDays +} diff --git a/controllers/upgradepipelinestep_controller_test.go b/controllers/upgradepipelinestep_controller_test.go new file mode 100644 index 00000000..beef3998 --- /dev/null +++ b/controllers/upgradepipelinestep_controller_test.go @@ -0,0 +1,333 @@ +package controllers + +import ( + "testing" + "time" + + avngen "github.com/aiven/go-client-codegen" + "github.com/aiven/go-client-codegen/handler/upgradepipeline" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + 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" +) + +const yamlUpgradePipelineStep = ` +apiVersion: aiven.io/v1alpha1 +kind: UpgradePipelineStep +metadata: + name: test-step + namespace: default +spec: + organizationId: org123 + sourceProjectName: sandbox + sourceServiceName: billing-pg-sandbox + destinationProjectName: prod + destinationServiceName: billing-pg-prod + autoValidationDelayDays: 7 +` + +func TestUpgradePipelineStepReconciler(t *testing.T) { + t.Parallel() + + runScenario := func(t *testing.T, step *v1alpha1.UpgradePipelineStep, avn avngen.Client, additionalObjects ...client.Object) (*Reconciler[*v1alpha1.UpgradePipelineStep], ctrlruntime.Result, error) { + t.Helper() + + scheme := runtime.NewScheme() + require.NoError(t, clientgoscheme.AddToScheme(scheme)) + require.NoError(t, v1alpha1.AddToScheme(scheme)) + + objects := append([]client.Object{step}, additionalObjects...) + + r := newUpgradePipelineStepReconciler(Controller{ + Client: fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&v1alpha1.UpgradePipelineStep{}). + WithObjects(objects...). + Build(), + Scheme: scheme, + Recorder: record.NewFakeRecorder(10), + DefaultToken: "test-token", + PollInterval: testPollInterval, + }).(*Reconciler[*v1alpha1.UpgradePipelineStep]) + r.newAivenGeneratedClient = func(_, _, _ string) (avngen.Client, error) { + return avn, nil + } + + res, err := r.Reconcile(t.Context(), ctrlruntime.Request{ + NamespacedName: types.NamespacedName{ + Name: step.Name, + Namespace: step.Namespace, + }, + }) + return r, res, err + } + + t.Run("Creates upgrade pipeline step on Aiven", func(t *testing.T) { + step := newObjectFromYAML[v1alpha1.UpgradePipelineStep](t, yamlUpgradePipelineStep) + step.Generation = 1 + + avn := avngen.NewMockClient(t) + avn.EXPECT(). + UpgradePipelineStepList(mock.Anything, step.Spec.OrganizationID). + Return(&upgradepipeline.UpgradePipelineStepListOut{}, nil).Once() + avn.EXPECT(). + UpgradePipelineStepCreate(mock.Anything, step.Spec.OrganizationID, mock.MatchedBy(func(in *upgradepipeline.UpgradePipelineStepCreateIn) bool { + return *in.AutoValidationDelayDays == step.Spec.AutoValidationDelayDays && + in.SourceProjectName == step.Spec.SourceProjectName && + in.SourceServiceName == step.Spec.SourceServiceName && + in.DestinationProjectName == step.Spec.DestinationProjectName && + in.DestinationServiceName == step.Spec.DestinationServiceName + })). + Return(&upgradepipeline.UpgradePipelineStepCreateOut{ + AutoValidationDelayDays: step.Spec.AutoValidationDelayDays, + DestinationProjectName: step.Spec.DestinationProjectName, + DestinationServiceName: step.Spec.DestinationServiceName, + SourceProjectName: step.Spec.SourceProjectName, + SourceServiceName: step.Spec.SourceServiceName, + StepId: "step-create", + }, nil).Once() + + r, res, err := runScenario(t, step, avn) + require.NoError(t, err) + require.Equal(t, ctrlruntime.Result{RequeueAfter: testPollInterval}, res) + + got := &v1alpha1.UpgradePipelineStep{} + require.NoError(t, r.Get(t.Context(), types.NamespacedName{Name: step.Name, Namespace: step.Namespace}, got)) + require.Contains(t, got.Finalizers, instanceDeletionFinalizer) + require.Equal(t, "step-create", got.Status.ID) + require.Nil(t, got.Status.LastValidation) + require.Equal(t, "1", got.Annotations[processedGenerationAnnotation]) + require.Equal(t, "true", got.Annotations[instanceIsRunningAnnotation]) + }) + + t.Run("Observes existing upgrade pipeline step by status ID", func(t *testing.T) { + step := newObjectFromYAML[v1alpha1.UpgradePipelineStep](t, yamlUpgradePipelineStep) + step.Generation = 1 + step.Status.ID = "step-observe" + validatedAt := time.Date(2026, 5, 19, 10, 30, 0, 0, time.UTC) + + avn := avngen.NewMockClient(t) + avn.EXPECT(). + UpgradePipelineStepGet(mock.Anything, step.Spec.OrganizationID, step.Status.ID). + Return(&upgradepipeline.UpgradePipelineStepGetOut{ + AutoValidationDelayDays: step.Spec.AutoValidationDelayDays, + DestinationProjectName: step.Spec.DestinationProjectName, + DestinationServiceName: step.Spec.DestinationServiceName, + LastValidation: upgradepipeline.LastValidationOut{ + Comment: "validated", + ValidatedAt: validatedAt, + ValidatedByUser: "alice@example.com", + }, + SourceProjectName: step.Spec.SourceProjectName, + SourceServiceName: step.Spec.SourceServiceName, + StepId: "step-observe", + }, nil).Once() + + r, res, err := runScenario(t, step, avn) + require.NoError(t, err) + require.Equal(t, ctrlruntime.Result{RequeueAfter: testPollInterval}, res) + + got := &v1alpha1.UpgradePipelineStep{} + require.NoError(t, r.Get(t.Context(), types.NamespacedName{Name: step.Name, Namespace: step.Namespace}, got)) + require.Equal(t, "step-observe", got.Status.ID) + require.NotNil(t, got.Status.LastValidation) + require.Equal(t, "validated", got.Status.LastValidation.Comment) + require.Equal(t, "alice@example.com", got.Status.LastValidation.ValidatedByUser) + require.NotNil(t, got.Status.LastValidation.ValidatedAt) + require.True(t, got.Status.LastValidation.ValidatedAt.Time.Equal(validatedAt)) + }) + + t.Run("Adopts existing upgrade pipeline step without creating", func(t *testing.T) { + step := newObjectFromYAML[v1alpha1.UpgradePipelineStep](t, yamlUpgradePipelineStep) + step.Generation = 1 + + avn := avngen.NewMockClient(t) + avn.EXPECT(). + UpgradePipelineStepList(mock.Anything, step.Spec.OrganizationID). + Return(&upgradepipeline.UpgradePipelineStepListOut{ + Steps: []upgradepipeline.StepOut{ + { + AutoValidationDelayDays: step.Spec.AutoValidationDelayDays, + DestinationProjectName: step.Spec.DestinationProjectName, + DestinationServiceName: step.Spec.DestinationServiceName, + SourceProjectName: step.Spec.SourceProjectName, + SourceServiceName: step.Spec.SourceServiceName, + StepId: "step-adopt", + }, + }, + }, nil).Once() + + r, res, err := runScenario(t, step, avn) + require.NoError(t, err) + require.Equal(t, ctrlruntime.Result{RequeueAfter: testPollInterval}, res) + + got := &v1alpha1.UpgradePipelineStep{} + require.NoError(t, r.Get(t.Context(), types.NamespacedName{Name: step.Name, Namespace: step.Namespace}, got)) + require.Equal(t, "step-adopt", got.Status.ID) + require.Equal(t, "1", got.Annotations[processedGenerationAnnotation]) + }) + + t.Run("Adopts existing upgrade pipeline step and updates delay", func(t *testing.T) { + step := newObjectFromYAML[v1alpha1.UpgradePipelineStep](t, yamlUpgradePipelineStep) + step.Generation = 1 + step.Spec.AutoValidationDelayDays = 3 + + avn := avngen.NewMockClient(t) + avn.EXPECT(). + UpgradePipelineStepList(mock.Anything, step.Spec.OrganizationID). + Return(&upgradepipeline.UpgradePipelineStepListOut{ + Steps: []upgradepipeline.StepOut{ + { + AutoValidationDelayDays: 7, + DestinationProjectName: step.Spec.DestinationProjectName, + DestinationServiceName: step.Spec.DestinationServiceName, + SourceProjectName: step.Spec.SourceProjectName, + SourceServiceName: step.Spec.SourceServiceName, + StepId: "step-adopt-update", + }, + }, + }, nil).Once() + avn.EXPECT(). + UpgradePipelineStepUpdate(mock.Anything, step.Spec.OrganizationID, "step-adopt-update", mock.MatchedBy(func(in *upgradepipeline.UpgradePipelineStepUpdateIn) bool { + return *in.AutoValidationDelayDays == step.Spec.AutoValidationDelayDays + })). + Return(&upgradepipeline.UpgradePipelineStepUpdateOut{ + AutoValidationDelayDays: step.Spec.AutoValidationDelayDays, + DestinationProjectName: step.Spec.DestinationProjectName, + DestinationServiceName: step.Spec.DestinationServiceName, + SourceProjectName: step.Spec.SourceProjectName, + SourceServiceName: step.Spec.SourceServiceName, + StepId: "step-adopt-update", + }, nil).Once() + + r, res, err := runScenario(t, step, avn) + require.NoError(t, err) + require.Equal(t, ctrlruntime.Result{RequeueAfter: testPollInterval}, res) + + got := &v1alpha1.UpgradePipelineStep{} + require.NoError(t, r.Get(t.Context(), types.NamespacedName{Name: step.Name, Namespace: step.Namespace}, got)) + require.Equal(t, "step-adopt-update", got.Status.ID) + }) + + t.Run("Updates auto validation delay", func(t *testing.T) { + step := newObjectFromYAML[v1alpha1.UpgradePipelineStep](t, yamlUpgradePipelineStep) + step.Generation = 2 + step.Status.ID = "step-update" + step.Spec.AutoValidationDelayDays = 3 + + avn := avngen.NewMockClient(t) + avn.EXPECT(). + UpgradePipelineStepGet(mock.Anything, step.Spec.OrganizationID, step.Status.ID). + Return(&upgradepipeline.UpgradePipelineStepGetOut{ + AutoValidationDelayDays: 7, + DestinationProjectName: step.Spec.DestinationProjectName, + DestinationServiceName: step.Spec.DestinationServiceName, + SourceProjectName: step.Spec.SourceProjectName, + SourceServiceName: step.Spec.SourceServiceName, + StepId: "step-update", + }, nil).Once() + avn.EXPECT(). + UpgradePipelineStepUpdate(mock.Anything, step.Spec.OrganizationID, step.Status.ID, mock.MatchedBy(func(in *upgradepipeline.UpgradePipelineStepUpdateIn) bool { + return *in.AutoValidationDelayDays == step.Spec.AutoValidationDelayDays + })). + Return(&upgradepipeline.UpgradePipelineStepUpdateOut{ + AutoValidationDelayDays: step.Spec.AutoValidationDelayDays, + DestinationProjectName: step.Spec.DestinationProjectName, + DestinationServiceName: step.Spec.DestinationServiceName, + SourceProjectName: step.Spec.SourceProjectName, + SourceServiceName: step.Spec.SourceServiceName, + StepId: "step-update", + }, nil).Once() + + r, res, err := runScenario(t, step, avn) + require.NoError(t, err) + require.Equal(t, ctrlruntime.Result{RequeueAfter: testPollInterval}, res) + + got := &v1alpha1.UpgradePipelineStep{} + require.NoError(t, r.Get(t.Context(), types.NamespacedName{Name: step.Name, Namespace: step.Namespace}, got)) + require.Equal(t, "2", got.Annotations[processedGenerationAnnotation]) + }) + + t.Run("Recreates upgrade pipeline step when status ID is missing remotely", func(t *testing.T) { + step := newObjectFromYAML[v1alpha1.UpgradePipelineStep](t, yamlUpgradePipelineStep) + step.Generation = 2 + step.Status.ID = "step-missing" + + avn := avngen.NewMockClient(t) + avn.EXPECT(). + UpgradePipelineStepGet(mock.Anything, step.Spec.OrganizationID, step.Status.ID). + Return(nil, newAivenError(404, "not found")).Once() + avn.EXPECT(). + UpgradePipelineStepCreate(mock.Anything, step.Spec.OrganizationID, mock.Anything). + Return(&upgradepipeline.UpgradePipelineStepCreateOut{ + AutoValidationDelayDays: step.Spec.AutoValidationDelayDays, + DestinationProjectName: step.Spec.DestinationProjectName, + DestinationServiceName: step.Spec.DestinationServiceName, + SourceProjectName: step.Spec.SourceProjectName, + SourceServiceName: step.Spec.SourceServiceName, + StepId: "step-recreated", + }, nil).Once() + + r, res, err := runScenario(t, step, avn) + require.NoError(t, err) + require.Equal(t, ctrlruntime.Result{RequeueAfter: testPollInterval}, res) + + got := &v1alpha1.UpgradePipelineStep{} + require.NoError(t, r.Get(t.Context(), types.NamespacedName{Name: step.Name, Namespace: step.Namespace}, got)) + require.Equal(t, "step-recreated", got.Status.ID) + }) + + t.Run("Deletes upgrade pipeline step and removes finalizer", func(t *testing.T) { + step := newObjectFromYAML[v1alpha1.UpgradePipelineStep](t, yamlUpgradePipelineStep) + step.Generation = 1 + step.Finalizers = []string{instanceDeletionFinalizer} + step.Status.ID = "step-delete" + now := metav1.Now() + step.DeletionTimestamp = &now + + avn := avngen.NewMockClient(t) + avn.EXPECT(). + UpgradePipelineStepDelete(mock.Anything, step.Spec.OrganizationID, step.Status.ID). + Return(nil).Once() + + r, res, err := runScenario(t, step, avn) + require.NoError(t, err) + require.Equal(t, ctrlruntime.Result{}, res) + + got := &v1alpha1.UpgradePipelineStep{} + err = r.Get(t.Context(), types.NamespacedName{Name: step.Name, Namespace: step.Namespace}, got) + require.True(t, apierrors.IsNotFound(err)) + }) + + t.Run("Removes finalizer when remote upgrade pipeline step is already missing", func(t *testing.T) { + step := newObjectFromYAML[v1alpha1.UpgradePipelineStep](t, yamlUpgradePipelineStep) + step.Generation = 1 + step.Finalizers = []string{instanceDeletionFinalizer} + step.Status.ID = "step-delete-missing" + now := metav1.Now() + step.DeletionTimestamp = &now + + avn := avngen.NewMockClient(t) + avn.EXPECT(). + UpgradePipelineStepDelete(mock.Anything, step.Spec.OrganizationID, step.Status.ID). + Return(newAivenError(404, "not found")).Once() + + r, res, err := runScenario(t, step, avn) + require.NoError(t, err) + require.Equal(t, ctrlruntime.Result{}, res) + + got := &v1alpha1.UpgradePipelineStep{} + err = r.Get(t.Context(), types.NamespacedName{Name: step.Name, Namespace: step.Namespace}, got) + require.True(t, apierrors.IsNotFound(err)) + }) +} diff --git a/docs/docs/resources/examples/upgradepipelinestep.yaml b/docs/docs/resources/examples/upgradepipelinestep.yaml new file mode 100644 index 00000000..ac83164d --- /dev/null +++ b/docs/docs/resources/examples/upgradepipelinestep.yaml @@ -0,0 +1,15 @@ +apiVersion: aiven.io/v1alpha1 +kind: UpgradePipelineStep +metadata: + name: upgrade-pipeline-step +spec: + authSecretRef: + name: aiven-token + key: token + + organizationId: org123 + sourceProjectName: sandbox + sourceServiceName: billing-pg-sandbox + destinationProjectName: prod + destinationServiceName: billing-pg-prod + autoValidationDelayDays: 7 diff --git a/docs/docs/resources/upgradepipelinestep.md b/docs/docs/resources/upgradepipelinestep.md new file mode 100644 index 00000000..b4af2da0 --- /dev/null +++ b/docs/docs/resources/upgradepipelinestep.md @@ -0,0 +1,89 @@ +--- +title: "UpgradePipelineStep" +--- + +## 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. + +## Usage example + +```yaml linenums="1" +apiVersion: aiven.io/v1alpha1 +kind: UpgradePipelineStep +metadata: + name: upgrade-pipeline-step +spec: + authSecretRef: + name: aiven-token + key: token + + organizationId: org123 + sourceProjectName: sandbox + sourceServiceName: billing-pg-sandbox + destinationProjectName: prod + destinationServiceName: billing-pg-prod + autoValidationDelayDays: 7 +``` + +Apply the resource with: + +```shell +kubectl apply -f example.yaml +``` + +Verify the newly created `UpgradePipelineStep`: + +```shell +kubectl get upgradepipelinesteps upgrade-pipeline-step +``` + +The output is similar to the following: +```shell +Name Organization Source Project Source Service Destination Project Destination Service Step ID +upgrade-pipeline-step org123 sandbox billing-pg-sandbox prod billing-pg-prod +``` + +--- + +## UpgradePipelineStep {: #UpgradePipelineStep } + +UpgradePipelineStep is the Schema for the upgradepipelinesteps API. + +**Required** + +- [`apiVersion`](#apiVersion-property){: name='apiVersion-property'} (string). Value `aiven.io/v1alpha1`. +- [`kind`](#kind-property){: name='kind-property'} (string). Value `UpgradePipelineStep`. +- [`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). UpgradePipelineStepSpec defines the desired state of UpgradePipelineStep. See below for [nested schema](#spec). + +## spec {: #spec } + +_Appears on [`UpgradePipelineStep`](#UpgradePipelineStep)._ + +UpgradePipelineStepSpec defines the desired state of UpgradePipelineStep. + +**Required** + +- [`destinationProjectName`](#spec.destinationProjectName-property){: name='spec.destinationProjectName-property'} (string, Immutable, Pattern: `^[a-zA-Z0-9_-]+$`, MinLength: 1, MaxLength: 63). DestinationProjectName is the project name of the service that waits for the source service. +- [`destinationServiceName`](#spec.destinationServiceName-property){: name='spec.destinationServiceName-property'} (string, Immutable, Pattern: `^[a-z][-a-z0-9]+$`, MinLength: 1, MaxLength: 63). DestinationServiceName is the service name that waits for the source service. +- [`organizationId`](#spec.organizationId-property){: name='spec.organizationId-property'} (string, Immutable, MinLength: 1). OrganizationID is the Aiven organization ID that owns the upgrade pipeline step. +- [`sourceProjectName`](#spec.sourceProjectName-property){: name='spec.sourceProjectName-property'} (string, Immutable, Pattern: `^[a-zA-Z0-9_-]+$`, MinLength: 1, MaxLength: 63). SourceProjectName is the project name of the service that must be upgraded first. +- [`sourceServiceName`](#spec.sourceServiceName-property){: name='spec.sourceServiceName-property'} (string, Immutable, Pattern: `^[a-z][-a-z0-9]+$`, MinLength: 1, MaxLength: 63). SourceServiceName is the service name that must be upgraded first. + +**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). +- [`autoValidationDelayDays`](#spec.autoValidationDelayDays-property){: name='spec.autoValidationDelayDays-property'} (integer, Minimum: 0, Default value: `7`). AutoValidationDelayDays is the number of days before Aiven can automatically validate the source service. + +## 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). diff --git a/docs/permissions.yaml b/docs/permissions.yaml index d9ad1b33..b9c577ca 100644 --- a/docs/permissions.yaml +++ b/docs/permissions.yaml @@ -193,6 +193,14 @@ ServiceUser: ServiceUserGet, ProjectKmsGetCA, ] +UpgradePipelineStep: + [ + UpgradePipelineStepCreate, + UpgradePipelineStepGet, + UpgradePipelineStepList, + UpgradePipelineStepUpdate, + UpgradePipelineStepDelete, + ] Valkey: [ ServiceGet, diff --git a/generators/docs/permissions.go b/generators/docs/permissions.go index 7dcfd7c6..e7f316c4 100644 --- a/generators/docs/permissions.go +++ b/generators/docs/permissions.go @@ -33,6 +33,7 @@ func readPermissionsFile(permissionsFile string) (map[string]kindOperations, err res := make(map[string]kindOperations) for kind, ids := range operationIDs { slices.Sort(ids) + res[kind] = kindOperations{} for _, id := range ids { v := permissionsMap[id] diff --git a/go.mod b/go.mod index 2ec3b7eb..ed60f2f5 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.26 require ( github.com/ClickHouse/clickhouse-go/v2 v2.46.0 github.com/aiven/go-api-schemas v1.190.0 - github.com/aiven/go-client-codegen v0.182.0 + github.com/aiven/go-client-codegen v0.184.0 github.com/avast/retry-go v3.0.0+incompatible github.com/dave/jennifer v1.7.1 github.com/docker/go-units v0.5.0 diff --git a/go.sum b/go.sum index 223694b9..6ea97f26 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/aiven/go-api-schemas v1.190.0 h1:Ym0uNhJSmea8yL5BWFf4FDJucij5fUG2eSN0 github.com/aiven/go-api-schemas v1.190.0/go.mod h1:WTWdlammndlBvINEUP2F8JDmOef6rEWkpNVhJS33GcQ= github.com/aiven/go-client-codegen v0.182.0 h1:M+oYaSPOx8FhIDxJy5b0Fh35XIwHcLgEaGR3ZS3C6gc= github.com/aiven/go-client-codegen v0.182.0/go.mod h1:wX+vwJ1nogBHPfGxwq5953zjfXHHiLMQCySw6IUlo6Q= +github.com/aiven/go-client-codegen v0.184.0 h1:0yRDGqRcYyCcbB2j4vltV8BpYvp04WJ883Qz2/yIdNM= +github.com/aiven/go-client-codegen v0.184.0/go.mod h1:wX+vwJ1nogBHPfGxwq5953zjfXHHiLMQCySw6IUlo6Q= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0= diff --git a/tests/suite_lib_test.go b/tests/suite_lib_test.go index 1b0f0b65..e7030a88 100644 --- a/tests/suite_lib_test.go +++ b/tests/suite_lib_test.go @@ -58,6 +58,16 @@ data: }, expected: []string{"my-new-app"}, }, + { + name: "should_update_numeric_value", + yamlContent: ` +data: + replicas: 7`, + replacements: map[string]string{ + "data.replicas": "3", + }, + expected: []string{"replicas: 3"}, + }, { name: "should_update_multiline_value", yamlContent: ` diff --git a/tests/upgradepipelinestep_test.go b/tests/upgradepipelinestep_test.go new file mode 100644 index 00000000..e82d0e07 --- /dev/null +++ b/tests/upgradepipelinestep_test.go @@ -0,0 +1,214 @@ +//go:build grafana + +package tests + +import ( + "context" + "strconv" + "testing" + "time" + + "github.com/aiven/go-client-codegen/handler/upgradepipeline" + "github.com/stretchr/testify/require" + + "github.com/aiven/aiven-operator/api/v1alpha1" +) + +func TestUpgradePipelineStep(t *testing.T) { + t.Parallel() + defer recoverPanic(t) + + // GIVEN + ctx, cancel := testCtx() + defer cancel() + + sourceName := randName("ups-src") + destinationName := randName("ups-dst") + stepName := randName("ups") + adoptedStepName := randName("ups-adopt") + + account, err := avnGen.AccountGet(ctx, cfg.AccountID) + require.NoError(t, err) + require.NotEmpty(t, account.OrganizationId) + organizationID := account.OrganizationId + + sourceYml, err := loadExampleYaml("grafana.yaml", map[string]string{ + "metadata.name": sourceName, + "spec.project": cfg.Project, + "spec.cloudName": cfg.PrimaryCloudName, + "spec.connInfoSecretTarget.name": sourceName, + }) + require.NoError(t, err) + destinationYml, err := loadExampleYaml("grafana.yaml", map[string]string{ + "metadata.name": destinationName, + "spec.project": cfg.Project, + "spec.cloudName": cfg.PrimaryCloudName, + "spec.connInfoSecretTarget.name": destinationName, + }) + require.NoError(t, err) + + s := NewSession(ctx, k8sClient) + defer s.Destroy(t) + + require.NoError(t, s.Apply(sourceYml+"---\n"+destinationYml)) + + source := new(v1alpha1.Grafana) + require.NoError(t, s.GetRunning(source, sourceName)) + + destination := new(v1alpha1.Grafana) + require.NoError(t, s.GetRunning(destination, destinationName)) + + // WHEN + stepYml, err := loadExampleYaml("upgradepipelinestep.yaml", map[string]string{ + "metadata.name": stepName, + "spec.organizationId": organizationID, + "spec.sourceProjectName": cfg.Project, + "spec.sourceServiceName": sourceName, + "spec.destinationProjectName": cfg.Project, + "spec.destinationServiceName": destinationName, + "spec.autoValidationDelayDays": "REMOVE", + }) + require.NoError(t, err) + require.NoError(t, s.Apply(stepYml)) + + step := new(v1alpha1.UpgradePipelineStep) + require.NoError(t, s.GetRunning(step, stepName)) + + // THEN + require.NotEmpty(t, step.Status.ID) + + stepAvn, err := avnGen.UpgradePipelineStepGet(ctx, organizationID, step.Status.ID) + require.NoError(t, err) + require.Equal(t, cfg.Project, stepAvn.SourceProjectName) + require.Equal(t, sourceName, stepAvn.SourceServiceName) + require.Equal(t, cfg.Project, stepAvn.DestinationProjectName) + require.Equal(t, destinationName, stepAvn.DestinationServiceName) + require.Equal(t, 7, stepAvn.AutoValidationDelayDays) + + updatedStepYml, err := loadExampleYaml("upgradepipelinestep.yaml", map[string]string{ + "metadata.name": stepName, + "spec.organizationId": organizationID, + "spec.sourceProjectName": cfg.Project, + "spec.sourceServiceName": sourceName, + "spec.destinationProjectName": cfg.Project, + "spec.destinationServiceName": destinationName, + "spec.autoValidationDelayDays": "3", + }) + require.NoError(t, err) + require.NoError(t, s.Apply(updatedStepYml)) + + updatedStep := new(v1alpha1.UpgradePipelineStep) + require.NoError(t, s.GetRunning(updatedStep, stepName)) + require.Eventually(t, func() bool { + out, err := avnGen.UpgradePipelineStepGet(ctx, organizationID, updatedStep.Status.ID) + if err != nil { + return false + } + + return out.AutoValidationDelayDays == 3 + }, 15*time.Second, 1*time.Second) + + require.NoError(t, s.Delete(updatedStep, func() error { + _, err := avnGen.UpgradePipelineStepGet(ctx, organizationID, updatedStep.Status.ID) + return err + })) + require.Eventually(t, func() bool { + out, err := avnGen.UpgradePipelineStepList(ctx, organizationID) + if err != nil { + return false + } + + count := 0 + for _, step := range out.Steps { + if step.SourceProjectName == cfg.Project && + step.SourceServiceName == sourceName && + step.DestinationProjectName == cfg.Project && + step.DestinationServiceName == destinationName { + count++ + } + } + + return count == 0 + }, 15*time.Second, 1*time.Second) + + adoptedInitialDelay := 5 + adoptedUpdatedDelay := 2 + adopted, err := avnGen.UpgradePipelineStepCreate(ctx, organizationID, &upgradepipeline.UpgradePipelineStepCreateIn{ + AutoValidationDelayDays: &adoptedInitialDelay, + DestinationProjectName: cfg.Project, + DestinationServiceName: destinationName, + SourceProjectName: cfg.Project, + SourceServiceName: sourceName, + }) + require.NoError(t, err) + require.NotEmpty(t, adopted.StepId) + adoptedStepID := adopted.StepId + defer func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), deleteTimeout) + defer cleanupCancel() + + _ = avnGen.UpgradePipelineStepDelete(cleanupCtx, organizationID, adoptedStepID) + }() + + adoptedStepYml, err := loadExampleYaml("upgradepipelinestep.yaml", map[string]string{ + "metadata.name": adoptedStepName, + "spec.organizationId": organizationID, + "spec.sourceProjectName": cfg.Project, + "spec.sourceServiceName": sourceName, + "spec.destinationProjectName": cfg.Project, + "spec.destinationServiceName": destinationName, + "spec.autoValidationDelayDays": strconv.Itoa(adoptedInitialDelay), + }) + require.NoError(t, err) + require.NoError(t, s.Apply(adoptedStepYml)) + + adoptedStep := new(v1alpha1.UpgradePipelineStep) + require.NoError(t, s.GetRunning(adoptedStep, adoptedStepName)) + require.Equal(t, adoptedStepID, adoptedStep.Status.ID) + require.Eventually(t, func() bool { + out, err := avnGen.UpgradePipelineStepList(ctx, organizationID) + if err != nil { + return false + } + + count := 0 + for _, step := range out.Steps { + if step.SourceProjectName == cfg.Project && + step.SourceServiceName == sourceName && + step.DestinationProjectName == cfg.Project && + step.DestinationServiceName == destinationName { + count++ + } + } + + return count == 1 + }, 15*time.Second, 1*time.Second) + + updatedAdoptedStepYml, err := loadExampleYaml("upgradepipelinestep.yaml", map[string]string{ + "metadata.name": adoptedStepName, + "spec.organizationId": organizationID, + "spec.sourceProjectName": cfg.Project, + "spec.sourceServiceName": sourceName, + "spec.destinationProjectName": cfg.Project, + "spec.destinationServiceName": destinationName, + "spec.autoValidationDelayDays": strconv.Itoa(adoptedUpdatedDelay), + }) + require.NoError(t, err) + require.NoError(t, s.Apply(updatedAdoptedStepYml)) + + updatedAdoptedStep := new(v1alpha1.UpgradePipelineStep) + require.NoError(t, s.GetRunning(updatedAdoptedStep, adoptedStepName)) + require.Eventually(t, func() bool { + out, err := avnGen.UpgradePipelineStepGet(ctx, organizationID, updatedAdoptedStep.Status.ID) + if err != nil { + return false + } + + return out.AutoValidationDelayDays == adoptedUpdatedDelay + }, 15*time.Second, 1*time.Second) + + require.NoError(t, s.Delete(updatedAdoptedStep, func() error { + _, err := avnGen.UpgradePipelineStepGet(ctx, organizationID, updatedAdoptedStep.Status.ID) + return err + })) +}