Skip to content
This repository was archived by the owner on Sep 9, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions api/v1alpha1/tenant_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,9 @@ const (

// Reason constants for Tenant conditions
const (
TenantReasonFound = "Found"
TenantReasonNotFound = "NotFound"
TenantReasonSharedDefault = "SharedDefault"
TenantReasonMultipleFound = "MultipleFound"
TenantReasonMultipleDefaultsFound = "MultipleDefaultsFound"
TenantReasonFound = "Found"
TenantReasonNotFound = "NotFound"
TenantReasonMultipleFound = "MultipleFound"
)

// ResolvedStorageClass captures a single resolved StorageClass for a specific
Expand Down
223 changes: 131 additions & 92 deletions internal/controller/tenant_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ package controller
import (
"context"
"fmt"
"regexp"
"sort"
"strings"

ovnv1 "github.com/ovn-org/ovn-kubernetes/go-controller/pkg/crd/userdefinednetwork/v1"
Expand Down Expand Up @@ -144,58 +146,62 @@ func (r *TenantReconciler) handleUpdate(ctx context.Context, req reconcile.Reque
v1alpha1.TenantReasonFound,
fmt.Sprintf("Namespace %q found on target cluster", instance.GetName()))

// Prerequisite 2: valid StorageClass (tenant-specific or shared Default)
scResult, err := r.getTenantStorageClass(ctx, targetClient, instance.GetName())
// Prerequisite 2: resolve all storage tiers
result, err := r.getTenantStorageClasses(ctx, targetClient, instance.GetName())
if err != nil {
return ctrl.Result{}, err
}

if scResult.name == "" {
for _, msg := range result.duplicateMessages {
r.Recorder.Eventf(instance, nil, corev1.EventTypeWarning, eventReasonDuplicateStorageClass, eventActionDetectDuplicate, "%s", msg)
}

if len(result.resolved) == 0 {
reason := v1alpha1.TenantReasonNotFound
if len(result.duplicateMessages) > 0 {
reason = v1alpha1.TenantReasonMultipleFound
}
condMsg := result.conditionMessage()
instance.SetStatusCondition(v1alpha1.TenantConditionStorageClassReady,
metav1.ConditionFalse,
scResult.reason,
scResult.message)
if scResult.reason == v1alpha1.TenantReasonMultipleFound || scResult.reason == v1alpha1.TenantReasonMultipleDefaultsFound {
r.Recorder.Eventf(instance, nil, corev1.EventTypeWarning, eventReasonDuplicateStorageClass, eventActionDetectDuplicate, "%s", scResult.message)
}
reason,
condMsg)
r.Recorder.Eventf(instance, nil, corev1.EventTypeWarning, eventReasonStorageClassNotReady, "StorageClassResolution", "%s", condMsg)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: When should a Tenant be considered not ready due to StorageClass issues?

Currently StorageClassNotReady is emitted only when zero tiers resolve. If some tiers resolve and others have duplicates, the Tenant is Ready and the only signal is a DuplicateStorageClass event.

This raises a question: should a Tenant be Ready when a tier that was explicitly configured for it doesn't resolve? For example, if an admin creates a tenant-specific "fast" StorageClass, then accidentally introduces a duplicate, the Tenant stays Ready. The misconfiguration is only visible in events, not in the phase.

Before deciding on the event model (StorageClassNotReady vs TenantNotReady vs TenantTierNotReady), it's worth clarifying the readiness semantics:

Option 1: At least one tier resolves = Ready (current, per EP #32)

  • StorageClassNotReady only fires when all tiers fail
  • Pro: lenient, unrelated tier misconfigurations don't block the tenant
  • Con: hides misconfigurations in tiers explicitly set up for the tenant

Option 2: All discovered tiers must resolve = Ready

  • Any tier failure blocks readiness
  • Pro: catches all misconfigurations
  • Con: a duplicate in a shared Default tier the tenant doesn't need would block it

Option 3: Spec-driven required tiers

  • Add requiredTiers to TenantSpec, controller checks exactly those
  • Pro: explicit contract, cleanest model
  • Con: CRD change, broader scope

This PR implements Option 1 per the EP. Raising it here since the answer shapes what events we emit and when. Happy to discuss offline.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think option 1 is the right starting point for v1, a misconfiguration in one tier shouldn't block the entire tenant when other tiers are fine.

The DuplicateStorageClass event already signals per-tier misconfigurations. If we find that operators need stronger guarantees, option 3 would be the cleanest future evolution, it makes the contract explicit without overloading the resolution logic.

@akshaynadkarni akshaynadkarni Apr 30, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking about the event hierarchy a bit more. Even with Option 1, we could separate cause from effect in the event naming:

Tenant-level (all tiers failed, tenant is Progressing):

  • TenantNotReady: emitted when no tier resolves at all

Tier-level (specific tier failed, tenant may still be Ready):

  • TenantStorageTierNotReady: emitted per failing tier, with reason in the message:
    • DuplicateStorageClass: multiple SCs matched for the tier
    • StorageClassMissing: no SC found for the tier (no tenant-specific, no Default)

This separates what happened (cause) from the impact (effect), and also future-proofs for new failure reasons without changing the event structure. Today DuplicateStorageClass serves as both the cause and the event name, which works but conflates the two.

One thing to consider: for TenantStorageTierNotReady to be meaningful, we'd need to know which tiers are expected for a tenant. Otherwise we'd fire events for tiers that aren't relevant to the tenant (e.g., a shared Default "archive" tier that the tenant doesn't use). This might circle back to tracking enabled tiers per tenant, either in the spec or derived from discovered SCs. This can be tackled in post v1.0, if we identify the events are noisy or just as part of event hardening.

WDYT?

return ctrl.Result{}, nil
}

instance.SetStatusCondition(v1alpha1.TenantConditionStorageClassReady,
metav1.ConditionTrue,
scResult.reason,
scResult.message)
v1alpha1.TenantReasonFound,
result.conditionMessage())

instance.Status.Namespace = namespace.GetName()
instance.Status.StorageClass = scResult.name
instance.Status.StorageClasses = []v1alpha1.ResolvedStorageClass{
{Name: scResult.name, Tier: scResult.tier},
}
instance.Status.StorageClass = result.resolved[0].Name
instance.Status.StorageClasses = result.resolved
instance.Status.Phase = v1alpha1.TenantPhaseReady

return ctrl.Result{}, nil
}

// storageClassResult holds the outcome of a StorageClass lookup, including
// the resolved name (empty if none found) and the reason/message for the
// condition that should be set on the Tenant.
type storageClassResult struct {
name string
tier string
reason string
message string
// tierResolutionResult holds the outcome of resolving all storage tiers for a tenant.
type tierResolutionResult struct {
resolved []v1alpha1.ResolvedStorageClass
resolvedMessages []string
errorMessages []string
duplicateMessages []string
}

// storageTierFromLabel reads the osac.openshift.io/storage-tier label from a
// StorageClass, normalized to lowercase. Returns "default" if the label is
// absent or empty.
func storageTierFromLabel(sc *storagev1.StorageClass) string {
if tier := sc.GetLabels()[osacStorageTierLabel]; tier != "" {
return strings.ToLower(tier)
}
return "default"
func (r *tierResolutionResult) conditionMessage() string {
parts := make([]string, 0, len(r.resolvedMessages)+len(r.errorMessages))
parts = append(parts, r.resolvedMessages...)
parts = append(parts, r.errorMessages...)
return strings.Join(parts, "; ")
}

// tierLabelPattern matches values that conform to the ResolvedStorageClass.Tier
// CRD validation: lowercase alphanumeric with dashes, dots, underscores, 1-63 chars.
var tierLabelPattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$`)

// joinStorageClassNames returns StorageClass metadata names as a comma-separated string for
// messages and the same values as a slice for structured logging.
func joinStorageClassNames(items []storagev1.StorageClass) (joined string, names []string) {
Expand All @@ -206,79 +212,112 @@ func joinStorageClassNames(items []storagev1.StorageClass) (joined string, names
return strings.Join(names, ", "), names
}

// getTenantStorageClass looks up the StorageClass for a tenant using a two-step
// fallback: tenant-specific SC first, then shared Default SC. Returns a
// storageClassResult with the resolved name and condition metadata. A non-nil
// error is only returned for API call failures.
func (r *TenantReconciler) getTenantStorageClass(ctx context.Context, targetClient client.Client, tenantName string) (storageClassResult, error) {
// groupByTier groups StorageClasses by their osac.openshift.io/storage-tier label value.
// StorageClasses missing the label or with values that don't match the CRD tier
// pattern (after lowercase normalization) are ignored.
func groupByTier(scList []storagev1.StorageClass) map[string][]storagev1.StorageClass {
groups := make(map[string][]storagev1.StorageClass)
for _, sc := range scList {
raw, exists := sc.GetLabels()[osacStorageTierLabel]
if !exists || raw == "" {
continue
}
tier := strings.ToLower(raw)
if !tierLabelPattern.MatchString(tier) {
continue
}
groups[tier] = append(groups[tier], sc)
}
return groups
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

// getTenantStorageClasses resolves all storage tiers for a tenant. For each
// distinct storage-tier value found across tenant-specific and shared Default
// StorageClasses, it applies a two-step fallback per tier: tenant-specific first,
// then shared Default. StorageClasses missing the storage-tier label are ignored.
func (r *TenantReconciler) getTenantStorageClasses(ctx context.Context, targetClient client.Client, tenantName string) (tierResolutionResult, error) {
log := ctrllog.FromContext(ctx)

// Step 1 — Tenant-specific StorageClasses (label osac.openshift.io/tenant=<tenantName>).
// Exactly one → use it. More than one → error (do not consider Default SC). Zero → Step 2.
tenantSCList := &storagev1.StorageClassList{}
if err := targetClient.List(ctx, tenantSCList, client.MatchingLabels{osacTenantAnnotation: tenantName}); err != nil {
return storageClassResult{}, err
return tierResolutionResult{}, err
}

switch len(tenantSCList.Items) {
case 1:
sc := &tenantSCList.Items[0]
return storageClassResult{
name: sc.GetName(),
tier: storageTierFromLabel(sc),
reason: v1alpha1.TenantReasonFound,
message: fmt.Sprintf("StorageClass %q found for tenant %q", sc.GetName(), tenantName),
}, nil
case 0:
// No tenant SCs — evaluate shared Default SCs (Step 2) below.
default:
joined, names := joinStorageClassNames(tenantSCList.Items)
msg := fmt.Sprintf("Multiple StorageClasses found for tenant %q: [%s]. Exactly one is required; remove the extras to resolve.",
tenantName, joined)
log.Info(msg, "tenant", tenantName, "storageClasses", names)
return storageClassResult{
reason: v1alpha1.TenantReasonMultipleFound,
message: msg,
}, nil
}

// Step 2 — Shared Default StorageClasses (label osac.openshift.io/tenant=Default).
// Only reached when Step 1 found zero tenant-specific SCs.
defaultSCList := &storagev1.StorageClassList{}
if err := targetClient.List(ctx, defaultSCList, client.MatchingLabels{osacTenantAnnotation: defaultStorageClassSentinel}); err != nil {
return storageClassResult{}, err
return tierResolutionResult{}, err
}

switch len(defaultSCList.Items) {
case 1:
sc := &defaultSCList.Items[0]
msg := fmt.Sprintf("No tenant-specific StorageClass found for tenant %q. "+
"Using shared Default StorageClass %q. Storage is not isolated for this tenant.",
tenantName, sc.GetName())
log.Info(msg)
return storageClassResult{
name: sc.GetName(),
tier: storageTierFromLabel(sc),
reason: v1alpha1.TenantReasonSharedDefault,
message: msg,
}, nil
case 0:
msg := fmt.Sprintf("No StorageClass found for tenant %q (no tenant SC, no Default SC)", tenantName)
log.Info(msg)
return storageClassResult{
reason: v1alpha1.TenantReasonNotFound,
message: msg,
}, nil
default:
joined, names := joinStorageClassNames(defaultSCList.Items)
msg := fmt.Sprintf("Multiple shared Default StorageClasses found: [%s]. Exactly one is required; remove the extras to resolve. Tenant %q has no dedicated StorageClass and is affected.",
joined, tenantName)
log.Info(msg, "tenant", tenantName, "storageClasses", names)
return storageClassResult{
reason: v1alpha1.TenantReasonMultipleDefaultsFound,
message: msg,
}, nil
tenantByTier := groupByTier(tenantSCList.Items)
defaultByTier := groupByTier(defaultSCList.Items)

allTiers := make(map[string]struct{})
for t := range tenantByTier {
allTiers[t] = struct{}{}
}
for t := range defaultByTier {
allTiers[t] = struct{}{}
}

sortedTiers := make([]string, 0, len(allTiers))
for t := range allTiers {
sortedTiers = append(sortedTiers, t)
}
sort.Strings(sortedTiers)

var result tierResolutionResult

for _, tier := range sortedTiers {
tenantSCs := tenantByTier[tier]
defaultSCs := defaultByTier[tier]

switch len(tenantSCs) {
case 1:
scName := tenantSCs[0].GetName()
result.resolved = append(result.resolved, v1alpha1.ResolvedStorageClass{
Name: scName,
Tier: tier,
})
msg := fmt.Sprintf("tier %q: StorageClass %q (tenant-specific)", tier, scName)
result.resolvedMessages = append(result.resolvedMessages, msg)
continue
case 0:
// Fall through to Default resolution below.
default:
joined, names := joinStorageClassNames(tenantSCs)
msg := fmt.Sprintf("tier %q: multiple tenant StorageClasses [%s]", tier, joined)
log.Info(msg, "tenant", tenantName, "tier", tier, "storageClasses", names)
result.errorMessages = append(result.errorMessages, msg)
result.duplicateMessages = append(result.duplicateMessages, msg)
continue
}

switch len(defaultSCs) {
case 1:
scName := defaultSCs[0].GetName()
result.resolved = append(result.resolved, v1alpha1.ResolvedStorageClass{
Name: scName,
Tier: tier,
})
msg := fmt.Sprintf("tier %q: StorageClass %q (shared Default)", tier, scName)
result.resolvedMessages = append(result.resolvedMessages, msg)
case 0:
// Tier not available — not an error at the Tenant level.
default:
joined, names := joinStorageClassNames(defaultSCs)
msg := fmt.Sprintf("tier %q: multiple shared Default StorageClasses [%s]", tier, joined)
log.Info(msg, "tenant", tenantName, "tier", tier, "storageClasses", names)
result.errorMessages = append(result.errorMessages, msg)
result.duplicateMessages = append(result.duplicateMessages, msg)
}
}

if len(result.resolved) == 0 && len(result.errorMessages) == 0 {
result.errorMessages = append(result.errorMessages,
fmt.Sprintf("no StorageClasses with %s label found for tenant %q", osacStorageTierLabel, tenantName))
}

return result, nil
}

// mapStorageClassToTenant maps a StorageClass event to Tenant reconcile requests.
Expand Down
Loading
Loading