Defense-in-depth fix: make the HyperShift operator survive malformed NodePool objects in etcd
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.
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.
nodeDrainTimeout: "1" (no unit suffix) exists in etcdmetav1.Duration.UnmarshalJSON calls time.ParseDuration("1") → errorv1beta1.NodePool typed LIST fails entirely (all-or-nothing deserialization)
The NodePool spec has exactly two fields of type *metav1.Duration,
both defined in api/hypershift/v1beta1/nodepool_types.go:
| Field | JSON Path | Type | Line |
|---|---|---|---|
NodeDrainTimeout | spec.nodeDrainTimeout | *metav1.Duration | 190 |
NodeVolumeDetachTimeout | spec.nodeVolumeDetachTimeout | *metav1.Duration | 200 |
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.
| Scenario | CEL Validation (94251) | Resilient Informer (94252) |
|---|---|---|
| New malformed NodePool write attempt | Blocked at admission | N/A (never reaches etcd) |
| Pre-existing bad NodePool in etcd | Cannot help (already in etcd) | Skips bad object, reconciles rest |
| Operator upgrade with bad data present | Informer still crashes | Informer syncs successfully |
| Direct etcd write bypassing API server | Admission not invoked | Handled by fallback |
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:
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.
| Hook | What it does | Can catch per-object deser errors? |
|---|---|---|
cache.Options.NewInformer | Replace the SharedIndexInformer factory | Yes — can wrap the ListerWatcher |
DefaultWatchErrorHandler | Called when entire ListAndWatch drops | No — connection-level only |
DefaultTransform | Runs on each object after deserialization | No — post-decode, too late |
ByObject | Per-type namespace/selector/transform | No — cannot switch to unstructured |
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.
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."
| Approach | Modifies user data? | Handles runtime? | Controller changes? | Complexity |
|---|---|---|---|---|
| Resilient ListerWatcher (chosen) | No | Yes | None | Medium |
| Pre-flight sanitization (fix on startup) | Yes (patches spec) | Startup only | None | Low |
| Unstructured informer + typed conversion in reconciler | No | Yes | Significant | High |
| Per-namespace informer scoping | No | Partial | Some | Medium |
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.
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.
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.
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.
WATCH errors are not intercepted. Here's why this is safe:
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.
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
}
It's critical to distinguish deserialization errors (which should trigger fallback) from API/network errors (which should be propagated):
| Error Type | Detection | Action |
|---|---|---|
| API errors (404, 500, timeout, forbidden) | errors.As(err, &apierrors.StatusError{}) | Propagate immediately |
| Context cancellation / deadline | ctx.Err() != nil | Propagate immediately |
| Deserialization errors | None of the above match | Fall 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.
resilient_lister.gofunc 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.
type resilientNodePoolListerWatcher struct {
delegate toolscache.ListerWatcher
dynamicClient dynamic.NamespaceableResourceInterface
log logr.Logger
}
// Implements both ListerWatcher and ListerWatcherWithContext
ListWithContext(ctx, opts) — fast-path typed LIST, deserialization-error fallback to dynamicWatchWithContext(ctx, opts) — pure delegation to wrapped ListerWatcherdynamicFallbackList(ctx, opts) — dynamic LIST + per-object conversionconvertUnstructuredNodePool(item) — try direct conversion, sanitize on failuresanitizeDurationFields(item) — remove invalid *metav1.Duration fieldsmain.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"),
),
},
})
Prometheus metrics registered with the controller-runtime metrics registry:
| Metric | Type | Description |
|---|---|---|
hypershift_nodepool_informer_fallback_total | Counter | Times the fallback from typed to dynamic LIST was triggered |
hypershift_nodepool_informer_sanitized_total | Counter (field, ns, name) | Duration fields sanitized during fallback conversion |
hypershift_nodepool_informer_skipped_total | Counter | NodePool 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.
Following TESTING.md conventions: table-driven tests,
"When <condition>, it should <expected behavior>" naming,
tests placed alongside the code in resilient_lister_test.go.
| Function | Cases |
|---|---|
TestNewResilientInformerFactory |
|
TestResilientNodePoolListerWatcher_List |
|
TestSanitizeDurationFields |
|
TestConvertUnstructuredNodePool |
|
TestDynamicFallbackList |
|
# 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/...
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.
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.
The design contains a fundamental contradiction about what happens to the malformed NodePool:
Both implementations lead to catastrophic outcomes:
nodeDrainTimeout: "1" results in nil,
which in Kubernetes means infinite drain timeout. User intent (1 minute) becomes permanent hangup of
worker replacements and cluster upgrades.DeletionTimestamp, but the controller never sees it in the cache, never cleans up cloud
resources, never removes the finalizer. The NodePool becomes permanently stuck in Terminating.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:
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:
map[string]interface{}DeepCopy() on each unstructured object
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:
The client-go Reflector uses pagination (e.g., Limit: 500, Continue tokens):
The original proposal tried to be clever with a continuous fallback mechanism. But the real insight is:
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).
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.
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.
| Dimension | Continuous Fallback (Section 5) | Startup Sanitization (Revised) |
|---|---|---|
| Cache corruption | Risk of injecting sanitized objects with changed semantics | No risk — user data fixed at source |
| WATCH thrashing | Continuous mechanism repeatedly triggered by updates | One-time operation at startup, stays fixed |
| Memory exhaustion | Risk of OOMKill under continuous fallback + thrashing | One-time startup overhead, minimal ongoing impact |
| Pagination corruption | Complex fallback with token handling | Not applicable — standard LIST pathway only |
| False-positive fallback | Network errors trigger expensive LISTs continuously | Not applicable — no fallback mechanism |
| Operational visibility | Metrics + logs show ongoing corrective action | WARN logs at startup show exactly what was fixed |
| Predictability | Operational behavior depends on continuous error conditions | Deterministic one-time operation at startup |
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
}
// 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,
},
})
| Failure Mode | Section 5 Continuous Fallback | Degraded-Mode Skip |
|---|---|---|
| Cache inclusion paradox | Injects sanitized objects (nil = infinite timeout hang) | Skips bad objects entirely; not in cache |
| WATCH thrashing DoS | Bad object updates break WATCH repeatedly, exponential backoff | Bad object not in cache, not modified, WATCH works normally |
| Memory exhaustion | Dynamic LIST + deep-copy on every fallback trigger | Scan happens once at startup; zero ongoing overhead |
| False-positive fallback | Network errors trigger expensive fallback continuously | No fallback mechanism; LIST filters cached result |
| Pagination corruption | Complex token handling on fallback | Standard LIST pathway only; no token issues |
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:
Key advantages:
Next step: Implement degraded-mode factory in hypershift-operator/controllers/nodepool/degraded_lister.go with custom ListerWatcher that filters bad NodePools.