OCPBUGS-94252

Resilient NodePool Informer — Solution Design

Defense-in-depth fix: make the HyperShift operator survive malformed NodePool objects in etcd

Priority: Critical Component: HyperShift Status: Assigned

1. Problem Statement

A single NodePool CR with an invalid *metav1.Duration value (e.g., nodeDrainTimeout: "1" — missing the unit suffix) causes the HyperShift operator's cluster-wide v1beta1.NodePool informer to fail on every LIST/WATCH cycle.

Because the informer uses typed deserialization (NodePoolList decoded all-or-nothing), one unparseable object prevents the shared cache from ever syncing — blocking NodePool reconciliation for every hosted cluster on the management cluster.

Production Impact

This caused a 4+ day complete outage in BrazilSouth (ARO-HCP). New clusters received a control plane but no worker nodes. Existing NodePool operations (scale, delete, modify) were all blocked. No self-healing occurred — manual break-glass intervention was required.

Error Chain

  1. Bad NodePool with nodeDrainTimeout: "1" (no unit suffix) exists in etcd
  2. metav1.Duration.UnmarshalJSON calls time.ParseDuration("1") → error
  3. v1beta1.NodePool typed LIST fails entirely (all-or-nothing deserialization)
  4. Informer shared cache never syncs (cluster-wide, not per-namespace)
  5. No NodePool reconciled for any cluster on the management cluster
  6. New clusters: CP up, no workers. Existing clusters: cannot delete/scale/modify NodePools

Affected Duration Fields

The NodePool spec has exactly two fields of type *metav1.Duration, both defined in api/hypershift/v1beta1/nodepool_types.go:

FieldJSON PathTypeLine
NodeDrainTimeoutspec.nodeDrainTimeout*metav1.Duration190
NodeVolumeDetachTimeoutspec.nodeVolumeDetachTimeout*metav1.Duration200

2. Relationship with Companion Bug (OCPBUGS-94251)

Two-layer defense

OCPBUGS-94251 (PR #9117) adds CRD-level CEL validation to prevent new malformed duration values from being written to etcd. That is the primary fix.

OCPBUGS-94252 (this bug) is the defense-in-depth layer: making the operator resilient to pre-existing bad data that was written before the validation fix is deployed. Without this, upgrading the operator on a management cluster that already has a malformed NodePool will still crash the informer.

ScenarioCEL Validation (94251)Resilient Informer (94252)
New malformed NodePool write attemptBlocked at admissionN/A (never reaches etcd)
Pre-existing bad NodePool in etcdCannot help (already in etcd)Skips bad object, reconciles rest
Operator upgrade with bad data presentInformer still crashesInformer syncs successfully
Direct etcd write bypassing API serverAdmission not invokedHandled by fallback

3. Codebase Exploration Findings

3.1 Operator Manager Configuration

The manager is created in hypershift-operator/main.go:createManager (line 431) with ctrl.NewManager. The cache.Options struct is left at its zero value — no DefaultWatchErrorHandler, no DefaultTransform, no NewInformer, and no namespace scoping. This means:

3.2 NodePool Controller Setup

In nodepool_controller.go:136-177, the controller uses .For(&hyperv1.NodePool{}), which creates a typed, cluster-wide informer. Under the hood, controller-runtime calls mgr.GetCache().GetInformer(ctx, &hyperv1.NodePool{}), which creates a SharedIndexInformer with a typed ListerWatcher.

3.3 Available Extension Points (controller-runtime v0.22.5)

HookWhat it doesCan catch per-object deser errors?
cache.Options.NewInformerReplace the SharedIndexInformer factoryYes — can wrap the ListerWatcher
DefaultWatchErrorHandlerCalled when entire ListAndWatch dropsNo — connection-level only
DefaultTransformRuns on each object after deserializationNo — post-decode, too late
ByObjectPer-type namespace/selector/transformNo — cannot switch to unstructured

3.4 Why the Dynamic Client Avoids the Problem

The dynamic.Interface client uses unstructured JSON deserialization (map[string]interface{}), which treats all fields as generic JSON values. A duration string like "1" is stored as a plain Go string — no time.ParseDuration is called. This means the dynamic LIST cannot fail for valid JSON stored in etcd, regardless of whether the duration values are semantically valid.

Subtlety: FromUnstructured also fails

runtime.DefaultUnstructuredConverter.FromUnstructured() eventually calls metav1.Duration.UnmarshalJSON for duration fields, which triggers the same time.ParseDuration error. Therefore, we must sanitize the unstructured object (remove bad duration fields) before attempting typed conversion. Since the fields are *metav1.Duration (pointer type), removing them results in nil — the valid zero value meaning "no timeout limit."

4. Approaches Considered

ApproachModifies user data?Handles runtime?Controller changes?Complexity
Resilient ListerWatcher (chosen)NoYesNoneMedium
Pre-flight sanitization (fix on startup)Yes (patches spec)Startup onlyNoneLow
Unstructured informer + typed conversion in reconcilerNoYesSignificantHigh
Per-namespace informer scopingNoPartialSomeMedium

5. Chosen Solution: Resilient ListerWatcher via cache.Options.NewInformer

Inject a custom informer factory using the cache.Options.NewInformer hook. For the NodePool GVK only, the factory wraps the standard ListerWatcher with a resilient version that falls back to the dynamic client on deserialization errors.

5.1 How It Works

ListWithContext(ctx, opts) called by the Reflector | v [1] Try standard typed LIST | +--- Success? ---> Return typed NodePoolList (fast path, zero overhead) | +--- Error? | +--- StatusError (404, 500, timeout)? ---> Propagate as-is +--- Context cancelled/expired? ---> Propagate as-is | +--- Deserialization error (e.g., "time: missing unit in duration") | v [2] Fall back to dynamic client LIST | v [3] Per-object conversion loop: | +--- Try FromUnstructured(item) -> *NodePool | | | +--- Success? ---> Add to result list | +--- Failed? ---> Sanitize duration fields, retry | | | +--- Success? ---> Add to result (log warning) | +--- Failed? ---> Skip object (log error) | v Return *NodePoolList with valid objects + original ResourceVersion

5.2 Key Design Decisions

Fast path has zero overhead

When all NodePool objects are valid (the normal case), the wrapper calls the standard typed LIST, which succeeds, and the result is returned directly. No dynamic client, no unstructured conversion, no additional allocations. The fallback path is only triggered when the typed LIST actually fails.

User data is never modified

Unlike a pre-flight sanitization approach, this solution never patches the bad NodePool in etcd. The sanitization happens only in the in-memory conversion path — the original object in etcd remains untouched. The bad NodePool is simply absent from the informer cache, so the controller doesn't try to reconcile it. Operators see clear log messages identifying the bad object and can fix it manually.

Transparent to the controller

The NodePool controller code requires zero changes. It still uses .For(&hyperv1.NodePool{}) and receives typed objects. The resilience layer is entirely in the informer/cache infrastructure.

5.3 WATCH Error Handling

WATCH errors are not intercepted. Here's why this is safe:

  1. If a bad NodePool exists but is never modified, the WATCH won't see it (it was skipped during LIST). Everything works normally.
  2. If the bad NodePool IS modified, the WATCH event fails deserialization, closing the stream.
  3. The Reflector detects the closed stream and restarts with a new LIST.
  4. The resilient LIST handles the re-sync, skipping the bad object again.
  5. A new WATCH opens from the latest ResourceVersion. If the bad object isn't modified again, the cycle doesn't repeat.

The worst case is if the bad object is continuously modified, causing repeated watch restarts. But with CEL validation preventing new bad writes, this scenario is extremely unlikely.

5.4 Sanitization Logic

When a per-object conversion fails, the sanitizer removes known *metav1.Duration fields that have invalid values:

var durationFields = []string{"nodeDrainTimeout", "nodeVolumeDetachTimeout"}

func sanitizeDurationFields(item *unstructured.Unstructured) *unstructured.Unstructured {
    sanitized := item.DeepCopy()

    spec, found, err := unstructured.NestedMap(sanitized.Object, "spec")
    if err != nil || !found {
        return sanitized
    }

    modified := false
    for _, field := range durationFields {
        val, exists := spec[field]
        if !exists {
            continue
        }
        strVal, ok := val.(string)
        if !ok {
            continue
        }
        if _, parseErr := time.ParseDuration(strVal); parseErr != nil {
            // Remove the invalid field; *metav1.Duration becomes nil (valid zero value)
            delete(spec, field)
            modified = true
        }
    }

    if modified {
        unstructured.SetNestedMap(sanitized.Object, spec, "spec")
    }
    return sanitized
}

5.5 Error Classification

It's critical to distinguish deserialization errors (which should trigger fallback) from API/network errors (which should be propagated):

Error TypeDetectionAction
API errors (404, 500, timeout, forbidden)errors.As(err, &apierrors.StatusError{})Propagate immediately
Context cancellation / deadlinectx.Err() != nilPropagate immediately
Deserialization errorsNone of the above matchFall back to dynamic LIST

The design is conservative: if uncertain, it falls back to dynamic LIST. A false-positive fallback is harmless — the dynamic LIST will also fail for real infrastructure errors, and that failure is propagated.

6. Implementation Details

6.1 Files to Create / Modify

6.2 New File: resilient_lister.go

Exported Function

func NewResilientInformerFactory(
    restConfig *rest.Config,
    log logr.Logger,
) func(toolscache.ListerWatcher, runtime.Object, time.Duration, toolscache.Indexers) toolscache.SharedIndexInformer

Returns a NewInformer callback. Internally, it creates a dynamic.Interface once from restConfig. The callback type-switches on obj: if *hyperv1.NodePool, wraps the ListerWatcher; otherwise delegates to toolscache.NewSharedIndexInformer unchanged.

Internal Struct

type resilientNodePoolListerWatcher struct {
    delegate      toolscache.ListerWatcher
    dynamicClient dynamic.NamespaceableResourceInterface
    log           logr.Logger
}

// Implements both ListerWatcher and ListerWatcherWithContext

Key Methods

6.3 Modified File: main.go

In createManager (line 431), add a Cache field to ctrl.Options:

mgr, err := ctrl.NewManager(restConfig, ctrl.Options{
    // ... existing options ...
    Cache: cache.Options{
        NewInformer: nodepool.NewResilientInformerFactory(
            restConfig,
            ctrl.Log.WithName("resilient-nodepool-cache"),
        ),
    },
})

6.4 Observability

Prometheus metrics registered with the controller-runtime metrics registry:

MetricTypeDescription
hypershift_nodepool_informer_fallback_totalCounterTimes the fallback from typed to dynamic LIST was triggered
hypershift_nodepool_informer_sanitized_totalCounter (field, ns, name)Duration fields sanitized during fallback conversion
hypershift_nodepool_informer_skipped_totalCounterNodePool objects completely skipped (unconvertible)

Additionally, error-level log messages are emitted for every sanitized field and skipped object, including the namespace, name, field path, and original bad value.

7. Test Strategy

Following TESTING.md conventions: table-driven tests, "When <condition>, it should <expected behavior>" naming, tests placed alongside the code in resilient_lister_test.go.

Test Functions

FunctionCases
TestNewResilientInformerFactory
  • When obj is not a NodePool, it should return a standard informer
  • When obj is a NodePool, it should wrap the ListerWatcher
TestResilientNodePoolListerWatcher_List
  • When typed LIST succeeds, it should return the result directly
  • When typed LIST fails with StatusError, it should propagate the error
  • When typed LIST fails with deserialization error, it should fall back to dynamic LIST
  • When both typed and dynamic LIST fail, it should return the dynamic error
TestSanitizeDurationFields
  • When NodePool has valid duration fields, it should not modify them
  • When nodeDrainTimeout has no unit suffix, it should remove the field
  • When both duration fields are invalid, it should remove both
  • When NodePool has no duration fields set, it should return unchanged
TestConvertUnstructuredNodePool
  • When unstructured data is valid, it should convert without sanitization
  • When duration field is invalid, it should sanitize and convert successfully
  • When data is unconvertible even after sanitization, it should return error
TestDynamicFallbackList
  • When all NodePools are valid, it should return all objects
  • When one NodePool has invalid duration among valid ones, it should return valid ones and sanitize the bad one
  • When dynamic LIST returns empty list, it should return empty NodePoolList

Verification Commands

# Run resilient lister tests
go test ./hypershift-operator/controllers/nodepool/ -run TestResilient -v -count=1
go test ./hypershift-operator/controllers/nodepool/ -run TestSanitize -v -count=1
go test ./hypershift-operator/controllers/nodepool/ -run TestConvert -v -count=1
go test ./hypershift-operator/controllers/nodepool/ -run TestDynamic -v -count=1

# Full nodepool controller tests
go test ./hypershift-operator/controllers/nodepool/... -count=1

# Vet and build
go vet ./hypershift-operator/...
go build ./hypershift-operator/...

8. Behavioral Summary

Before This Fix

  1. One bad NodePool → entire informer fails → all NodePool reconciliation blocked cluster-wide → complete outage

After This Fix

  1. Typed LIST fails → resilient wrapper detects deserialization error
  2. Falls back to dynamic LIST → retrieves all NodePools as unstructured JSON
  3. Converts each object individually → sanitizes bad duration fields → skips unconvertible objects
  4. Informer cache syncs with all valid NodePools → controllers reconcile normally
  5. Bad NodePool is absent from cache → logged at error level with details → operators can fix manually
Net effect

One bad NodePool = one bad NodePool. Not a cluster-wide outage. All other NodePools continue to be reconciled normally. The bad NodePool is clearly identified in logs and metrics for operators to investigate and fix.

9. Adversarial Review and Revised Recommendation

Critical Issues Identified

An adversarial review of the proposed solution (Section 5) identified five critical architectural flaws that introduce new failure modes potentially worse than the original problem. Both the design document and the analysis below are included for transparency and learning.

9.1 Core Issues with the Continuous Fallback Approach

Issue 1: Cache Inclusion Paradox (Silent Data Corruption vs Stuck Finalizers)

The design contains a fundamental contradiction about what happens to the malformed NodePool:

Both implementations lead to catastrophic outcomes:

Issue 2: WATCH Stream Thrashing and Self-Inflicted DoS

The design explicitly does not intercept WATCH errors. The stated justification (Section 5.3) assumes the bad object is not continuously modified. But in reality:

Issue 3: Memory Exhaustion and OOMKills at Scale

In a large management cluster (hundreds or thousands of NodePools), if a single object has a bad duration, the operator falls back to a dynamic LIST of all NodePools:

Issue 4: Flawed Error Classification (Catch-All Fallback)

Section 5.5 defines error classification: if the error is not a StatusError or ctx.Err(), it falls back to dynamic LIST. This is dangerous:

Issue 5: Pagination and Chunking Data Loss

The client-go Reflector uses pagination (e.g., Limit: 500, Continue tokens):

9.2 Why the Continuous Fallback Approach Is Fundamentally Flawed

The original proposal tried to be clever with a continuous fallback mechanism. But the real insight is:

The threat is bounded to startup time

The malformed data is already in etcd — it will not get worse because OCPBUGS-94251 prevents new bad values from being written. The fix should therefore be bounded (one-time operation at startup), not continuous (an operational mechanism that runs forever).

9.3 Final Recommendation: Startup Detection with Degraded-Mode Skip

After adversarial review and further analysis, startup detection with degraded-mode skip is the optimal approach. It allows the operator to remain functional while preventing bad data from poisoning the informer, and provides continuous visibility for the operator to fix the problem.

Why Degraded-Mode Skip Is Better Than Alternatives

vs. Refuse to start: Degraded mode keeps the cluster operational on valid NodePools while customer fixes bad data. No downtime.

vs. Auto-fix: We cannot know the operator's intent for malformed `nodeDrainTimeout: "1"` (could be `1s`, `1m`, `1h`, or unknown). Auto-fixing to any default (e.g., `nil` = infinite timeout) creates silent failure modes. Better to skip and ask operator to fix.

vs. Continuous fallback (Section 5): Degraded mode only affects the startup LIST. Bad objects are skipped once and stay out of cache. No WATCH thrashing, no memory exhaustion, no false-positive fallback triggers. Zero ongoing overhead.

How It Works

  1. On operator startup, use the dynamic client to LIST all NodePools
  2. Check each NodePool for invalid Duration fields
  3. If bad data found:
    • Skip the bad NodePool(s) from the cache entirely (not added to informer)
    • Log ERROR message (loudly and repeatedly) with: namespace, name, field, bad value, exact kubectl fix command
    • Continue startup — operator proceeds in degraded mode on valid NodePools
  4. Operator continues to reconcile all valid NodePools normally
  5. Customer sees persistent ERROR logs → manually fixes the bad NodePool
  6. Next LIST or WATCH cycle picks up the fixed object → added to cache automatically
  7. No restart needed; degraded mode ends automatically

Why This Is Better

DimensionContinuous Fallback (Section 5)Startup Sanitization (Revised)
Cache corruptionRisk of injecting sanitized objects with changed semanticsNo risk — user data fixed at source
WATCH thrashingContinuous mechanism repeatedly triggered by updatesOne-time operation at startup, stays fixed
Memory exhaustionRisk of OOMKill under continuous fallback + thrashingOne-time startup overhead, minimal ongoing impact
Pagination corruptionComplex fallback with token handlingNot applicable — standard LIST pathway only
False-positive fallbackNetwork errors trigger expensive LISTs continuouslyNot applicable — no fallback mechanism
Operational visibilityMetrics + logs show ongoing corrective actionWARN logs at startup show exactly what was fixed
PredictabilityOperational behavior depends on continuous error conditionsDeterministic one-time operation at startup

Detailed Implementation: Custom Informer Factory

The approach is similar to Section 5, but only for startup — no continuous fallback mechanism. Create a custom NewInformer factory that wraps the NodePool ListerWatcher:

// In hypershift-operator/controllers/nodepool/degraded_lister.go
func NewDegradedModeInformerFactory(
    ctx context.Context,
    restConfig *rest.Config,
    log logr.Logger,
) (func(lw toolscache.ListerWatcher, obj runtime.Object, resync time.Duration, indexers toolscache.Indexers) toolscache.SharedIndexInformer,
    error) {

    // Scan for bad NodePools once at startup
    dynClient, err := dynamic.NewForConfig(restConfig)
    if err != nil {
        return nil, fmt.Errorf("create dynamic client: %w", err)
    }

    badNodePoolKeys := findAllBadNodePools(ctx, dynClient, log)

    // Return a factory that wraps LIST to skip bad objects
    return func(lw toolscache.ListerWatcher, obj runtime.Object, resync time.Duration, indexers toolscache.Indexers) toolscache.SharedIndexInformer {
        // Type-switch: only wrap NodePool ListerWatchers
        if _, ok := obj.(*hyperv1.NodePool); !ok {
            return toolscache.NewSharedIndexInformer(lw, obj, resync, indexers)
        }

        // Wrap the ListerWatcher to skip bad NodePools
        wrappedLW := °radedNodePoolListerWatcher{
            delegate:       lw,
            badNodePoolKeys: badNodePoolKeys,
            log:            log,
        }
        return toolscache.NewSharedIndexInformer(wrappedLW, obj, resync, indexers)
    }, nil
}

type degradedNodePoolListerWatcher struct {
    delegate        toolscache.ListerWatcher
    badNodePoolKeys map[string]struct{} // "namespace/name" → struct{}
    log             logr.Logger
}

// ListWithContext: call delegate LIST, then filter out bad NodePools
func (d *degradedNodePoolListerWatcher) ListWithContext(ctx context.Context, opts metav1.ListOptions) (runtime.Object, error) {
    list, err := d.delegate.ListWithContext(ctx, opts)
    if err != nil {
        return list, err
    }

    // Filter out bad NodePools from the list
    npList, ok := list.(*hyperv1.NodePoolList)
    if !ok {
        return list, nil
    }

    filtered := npList.DeepCopy()
    filtered.Items = make([]hyperv1.NodePool, 0, len(npList.Items))
    for _, item := range npList.Items {
        key := fmt.Sprintf("%s/%s", item.Namespace, item.Name)
        if _, isBad := d.badNodePoolKeys[key]; !isBad {
            filtered.Items = append(filtered.Items, item)
        }
    }

    return filtered, nil
}

// WatchWithContext: delegate as-is
func (d *degradedNodePoolListerWatcher) WatchWithContext(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) {
    return d.delegate.WatchWithContext(ctx, opts)
}

// Helper: scan for bad NodePools once at startup
func findAllBadNodePools(ctx context.Context, dynClient dynamic.Interface, log logr.Logger) map[string]struct{} {
    badKeys := make(map[string]struct{})

    npGVR := schema.GroupVersionResource{
        Group:    "hypershift.openshift.io",
        Version:  "v1beta1",
        Resource: "nodepools",
    }

    list, err := dynClient.Resource(npGVR).List(ctx, metav1.ListOptions{})
    if err != nil {
        log.Error(err, "Failed to scan NodePools at startup")
        return badKeys
    }

    for _, item := range list.Items {
        badFields := findBadDurationFields(&item)
        if len(badFields) > 0 {
            key := fmt.Sprintf("%s/%s", item.GetNamespace(), item.GetName())
            badKeys[key] = struct{}{}

            // Log ERROR loudly with fix instructions
            log.Error(nil, "NodePool has invalid duration field(s); it will not be reconciled until fixed",
                "namespace", item.GetNamespace(),
                "name", item.GetName(),
                "badFields", badFields,
                "fixCommand", fmt.Sprintf(
                    "kubectl patch nodepool %s -n %s -p '{\"spec\":{...}}'",
                    item.GetName(), item.GetNamespace()),
            )
        }
    }

    return badKeys
}

Integration in main.go

// In hypershift-operator/main.go:createManager
factory, err := nodepool.NewDegradedModeInformerFactory(ctx, restConfig, log)
if err != nil {
    log.Error(err, "Failed to initialize degraded mode informer factory")
    os.Exit(1)
}

mgr, err := ctrl.NewManager(restConfig, ctrl.Options{
    // ... existing options ...
    Cache: cache.Options{
        NewInformer: factory,
    },
})

Why This Avoids All Five Failure Modes from Section 5

Failure ModeSection 5 Continuous FallbackDegraded-Mode Skip
Cache inclusion paradoxInjects sanitized objects (nil = infinite timeout hang)Skips bad objects entirely; not in cache
WATCH thrashing DoSBad object updates break WATCH repeatedly, exponential backoffBad object not in cache, not modified, WATCH works normally
Memory exhaustionDynamic LIST + deep-copy on every fallback triggerScan happens once at startup; zero ongoing overhead
False-positive fallbackNetwork errors trigger expensive fallback continuouslyNo fallback mechanism; LIST filters cached result
Pagination corruptionComplex token handling on fallbackStandard LIST pathway only; no token issues

Auditability

Trade-offs and Design Rationale

9.4 Summary

Decision: Degraded-Mode Skip at Startup

The original Section 5 design is architecturally flawed and introduces multiple new failure modes. The final approach is to detect bad data at startup and skip bad NodePools from the informer cache, allowing the operator to remain operational while customer fixes the problem:

  • Startup scan: Dynamic client scans all NodePools once for invalid Duration fields
  • Skip bad objects: Bad NodePools excluded from cache (one-time filter at startup)
  • ERROR logs: Loud, persistent logs identify bad NodePool(s) with exact field, value, kubectl fix command
  • Operator continues: Valid NodePools reconcile normally; cluster stays operational
  • Customer fixes: Manually patches the bad NodePool
  • Auto-recovery: Next LIST/WATCH picks up fixed object; no restart needed

Key advantages:

Next step: Implement degraded-mode factory in hypershift-operator/controllers/nodepool/degraded_lister.go with custom ListerWatcher that filters bad NodePools.