From 6867ffe1aa21454072dca45242132b3e8a4b2cd7 Mon Sep 17 00:00:00 2001 From: Zoltan Szabo Date: Thu, 23 Apr 2026 13:49:38 +0200 Subject: [PATCH 1/2] MGMT-23976: Add multi-tier storage types to Tenant CRD Add ResolvedStorageClass struct with name and tier fields to support multiple storage tiers per tenant. Replace the singular status.storageClass string with a status.storageClasses list. Add osac.openshift.io/storage-tier label constant for StorageClass tier identification. Update printer column to show resolved tier names. The controller is minimally adapted to write the existing single-tier result as a one-element list. Full multi-tier resolution logic will follow once the enhancement proposal (EP #32) is finalized. Signed-off-by: Zoltan Szabo Generated-By: Claude Code (Anthropic) --- api/v1alpha1/tenant_types.go | 25 ++++++++++++-- api/v1alpha1/zz_generated.deepcopy.go | 20 +++++++++++ .../crd/bases/osac.openshift.io_tenants.yaml | 34 +++++++++++++++---- internal/controller/tenant_controller.go | 6 ++-- internal/controller/tenant_controller_test.go | 9 +++-- internal/controller/tenant_names.go | 3 ++ 6 files changed, 84 insertions(+), 13 deletions(-) diff --git a/api/v1alpha1/tenant_types.go b/api/v1alpha1/tenant_types.go index 94cb01f4..6be103cc 100644 --- a/api/v1alpha1/tenant_types.go +++ b/api/v1alpha1/tenant_types.go @@ -56,6 +56,23 @@ const ( TenantReasonMultipleDefaultsFound = "MultipleDefaultsFound" ) +// ResolvedStorageClass captures a single resolved StorageClass for a specific +// storage tier. The Tenant controller populates one entry per tier. +type ResolvedStorageClass struct { + // Name is the name of the resolved Kubernetes StorageClass. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // Tier is the storage tier this StorageClass provides, + // taken from the osac.openshift.io/storage-tier label. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=63 + // +kubebuilder:validation:Pattern=`^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$` + Tier string `json:"tier"` +} + // TenantStatus defines the observed state of Tenant. type TenantStatus struct { // Phase is the phase of the tenant @@ -64,8 +81,10 @@ type TenantStatus struct { // Namespace is the namespace allocated to the tenant on the target cluster Namespace string `json:"namespace,omitempty"` - // StorageClass is the StorageClass allocated to the tenant on the target cluster - StorageClass string `json:"storageClass,omitempty"` + // StorageClasses lists all resolved StorageClass mappings for the tenant, + // one per storage tier. + // +kubebuilder:validation:Optional + StorageClasses []ResolvedStorageClass `json:"storageClasses,omitempty"` // Conditions holds an array of metav1.Condition that describe the state of the Tenant // +kubebuilder:validation:Optional @@ -75,7 +94,7 @@ type TenantStatus struct { // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +kubebuilder:printcolumn:name="Tenant Namespace",type=string,JSONPath=`.status.namespace` -// +kubebuilder:printcolumn:name="Storage Class",type=string,JSONPath=`.status.storageClass` +// +kubebuilder:printcolumn:name="Storage Tiers",type=string,JSONPath=`.status.storageClasses[*].tier` // +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` // Tenant is the Schema for the tenants API. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 833daf35..fd4ebc4f 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -564,6 +564,21 @@ func (in *PublicIPStatus) DeepCopy() *PublicIPStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResolvedStorageClass) DeepCopyInto(out *ResolvedStorageClass) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResolvedStorageClass. +func (in *ResolvedStorageClass) DeepCopy() *ResolvedStorageClass { + if in == nil { + return nil + } + out := new(ResolvedStorageClass) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SecurityGroup) DeepCopyInto(out *SecurityGroup) { *out = *in @@ -901,6 +916,11 @@ func (in *TenantSpec) DeepCopy() *TenantSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TenantStatus) DeepCopyInto(out *TenantStatus) { *out = *in + if in.StorageClasses != nil { + in, out := &in.StorageClasses, &out.StorageClasses + *out = make([]ResolvedStorageClass, len(*in)) + copy(*out, *in) + } if in.Conditions != nil { in, out := &in.Conditions, &out.Conditions *out = make([]v1.Condition, len(*in)) diff --git a/config/crd/bases/osac.openshift.io_tenants.yaml b/config/crd/bases/osac.openshift.io_tenants.yaml index 9417e8df..5f592b86 100644 --- a/config/crd/bases/osac.openshift.io_tenants.yaml +++ b/config/crd/bases/osac.openshift.io_tenants.yaml @@ -18,8 +18,8 @@ spec: - jsonPath: .status.namespace name: Tenant Namespace type: string - - jsonPath: .status.storageClass - name: Storage Class + - jsonPath: .status.storageClasses[*].tier + name: Storage Tiers type: string - jsonPath: .status.phase name: Phase @@ -117,10 +117,32 @@ spec: phase: description: Phase is the phase of the tenant type: string - storageClass: - description: StorageClass is the StorageClass allocated to the tenant - on the target cluster - type: string + storageClasses: + description: |- + StorageClasses lists all resolved StorageClass mappings for the tenant, + one per storage tier. + items: + description: |- + ResolvedStorageClass captures a single resolved StorageClass for a specific + storage tier. The Tenant controller populates one entry per tier. + properties: + name: + description: Name is the name of the resolved Kubernetes StorageClass. + minLength: 1 + type: string + tier: + description: |- + Tier is the storage tier this StorageClass provides, + taken from the osac.openshift.io/storage-tier label. + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$ + type: string + required: + - name + - tier + type: object + type: array type: object type: object served: true diff --git a/internal/controller/tenant_controller.go b/internal/controller/tenant_controller.go index 44b8273c..ba17dab4 100644 --- a/internal/controller/tenant_controller.go +++ b/internal/controller/tenant_controller.go @@ -116,7 +116,7 @@ func (r *TenantReconciler) handleUpdate(ctx context.Context, req reconcile.Reque // Ready. Any early return below leaves the status in a clean Progressing state. instance.Status.Phase = v1alpha1.TenantPhaseProgressing instance.Status.Namespace = "" - instance.Status.StorageClass = "" + instance.Status.StorageClasses = nil // Get target cluster client where namespace, StorageClass, and UDN are reconciled targetClient, err := r.getTargetClient(ctx) @@ -166,7 +166,9 @@ func (r *TenantReconciler) handleUpdate(ctx context.Context, req reconcile.Reque scResult.message) instance.Status.Namespace = namespace.GetName() - instance.Status.StorageClass = scResult.name + instance.Status.StorageClasses = []v1alpha1.ResolvedStorageClass{ + {Name: scResult.name, Tier: "default"}, + } instance.Status.Phase = v1alpha1.TenantPhaseReady return ctrl.Result{}, nil diff --git a/internal/controller/tenant_controller_test.go b/internal/controller/tenant_controller_test.go index 5e667fa5..ea8096b4 100644 --- a/internal/controller/tenant_controller_test.go +++ b/internal/controller/tenant_controller_test.go @@ -95,7 +95,7 @@ var _ = Describe("Tenant Controller", func() { // found) while still correctly setting status conditions. reconcileAndAssertStatus := func( expectedPhase v1alpha1.TenantPhaseType, - expectedSC string, + expectedSCName string, expectedNSStatus metav1.ConditionStatus, expectedNSReason string, expectedSCStatus metav1.ConditionStatus, @@ -105,7 +105,12 @@ var _ = Describe("Tenant Controller", func() { _ = doReconcile() g.Expect(k8sClient.Get(ctx, typeNamespacedName, tenant)).To(Succeed()) g.Expect(tenant.Status.Phase).To(Equal(expectedPhase)) - g.Expect(tenant.Status.StorageClass).To(Equal(expectedSC)) + if expectedSCName == "" { + g.Expect(tenant.Status.StorageClasses).To(BeNil()) + } else { + g.Expect(tenant.Status.StorageClasses).To(HaveLen(1)) + g.Expect(tenant.Status.StorageClasses[0].Name).To(Equal(expectedSCName)) + } nsCond := tenant.GetStatusCondition(v1alpha1.TenantConditionNamespaceReady) g.Expect(nsCond).NotTo(BeNil()) diff --git a/internal/controller/tenant_names.go b/internal/controller/tenant_names.go index 19eba4d5..df5da8f1 100644 --- a/internal/controller/tenant_names.go +++ b/internal/controller/tenant_names.go @@ -46,4 +46,7 @@ var ( // osacTenantAnnotation is the annotation used to reference the tenant name osacTenantAnnotation string = fmt.Sprintf("%s/tenant", osacPrefix) + + // osacStorageTierLabel is the label key that identifies the storage tier of a StorageClass + osacStorageTierLabel string = fmt.Sprintf("%s/storage-tier", osacPrefix) ) From 29db7537983feacc5b186b6052f687e8777d96d5 Mon Sep 17 00:00:00 2001 From: Zoltan Szabo Date: Thu, 23 Apr 2026 14:39:00 +0200 Subject: [PATCH 2/2] MGMT-23976: Add list-map annotation and use storage-tier label in predicate Add listType=map and listMapKey=tier to StorageClasses field so the CRD schema enforces one entry per tier and enables server-side apply merge semantics. Use osacStorageTierLabel in the StorageClass watch predicate so StorageClasses without the storage-tier label are filtered out before triggering reconciliation. Signed-off-by: Zoltan Szabo Generated-By: Claude Code (Anthropic) --- api/v1alpha1/tenant_types.go | 2 ++ config/crd/bases/osac.openshift.io_tenants.yaml | 3 +++ internal/controller/tenant_controller.go | 8 +++++--- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/api/v1alpha1/tenant_types.go b/api/v1alpha1/tenant_types.go index 6be103cc..affcf6f4 100644 --- a/api/v1alpha1/tenant_types.go +++ b/api/v1alpha1/tenant_types.go @@ -84,6 +84,8 @@ type TenantStatus struct { // StorageClasses lists all resolved StorageClass mappings for the tenant, // one per storage tier. // +kubebuilder:validation:Optional + // +listType=map + // +listMapKey=tier StorageClasses []ResolvedStorageClass `json:"storageClasses,omitempty"` // Conditions holds an array of metav1.Condition that describe the state of the Tenant diff --git a/config/crd/bases/osac.openshift.io_tenants.yaml b/config/crd/bases/osac.openshift.io_tenants.yaml index 5f592b86..af801d3b 100644 --- a/config/crd/bases/osac.openshift.io_tenants.yaml +++ b/config/crd/bases/osac.openshift.io_tenants.yaml @@ -143,6 +143,9 @@ spec: - tier type: object type: array + x-kubernetes-list-map-keys: + - tier + x-kubernetes-list-type: map type: object type: object served: true diff --git a/internal/controller/tenant_controller.go b/internal/controller/tenant_controller.go index ba17dab4..62dbd888 100644 --- a/internal/controller/tenant_controller.go +++ b/internal/controller/tenant_controller.go @@ -381,11 +381,13 @@ func (r *TenantReconciler) SetupWithManager(mgr mcmanager.Manager) error { } // storageClassTenantPredicate returns a predicate that passes only StorageClasses -// carrying the osac.openshift.io/tenant label (any value). +// carrying both the osac.openshift.io/tenant and osac.openshift.io/storage-tier labels. func storageClassTenantPredicate() predicate.Predicate { return predicate.NewPredicateFuncs(func(obj client.Object) bool { - _, exists := obj.GetLabels()[osacTenantAnnotation] - return exists + labels := obj.GetLabels() + _, hasTenant := labels[osacTenantAnnotation] + _, hasTier := labels[osacStorageTierLabel] + return hasTenant && hasTier }) }