Skip to content

Fix: OCPBUGS-93148: installer skip NotReady nodes during static pod revision rollout - #2337

Open
dpateriya wants to merge 1 commit into
openshift:masterfrom
dpateriya:fix/installer-skip-notready-nodes
Open

Fix: OCPBUGS-93148: installer skip NotReady nodes during static pod revision rollout#2337
dpateriya wants to merge 1 commit into
openshift:masterfrom
dpateriya:fix/installer-skip-notready-nodes

Conversation

@dpateriya

@dpateriya dpateriya commented Jun 26, 2026

Copy link
Copy Markdown

Summary

  • Deprioritizes master nodes that have been Kubernetes-NotReady for more than 10 minutes during static pod revision rollout
  • Times out installer pods stuck on NotReady nodes after 15 minutes, marking them as failed so rollout proceeds on healthy masters
  • Wires a node informer into the installer controller factory so node readiness changes trigger resync

Problem

When a master node enters a prolonged NotReady state (e.g., hardware failure), the InstallerController's nodeToStartRevisionWith function selects it first (because its static pod isn't reporting Ready). This blocks the entire revision rollout ring, preventing healthy masters from receiving updated configurations.

This is critical during certificate rotation: new certificates are written to etcd but operators cannot roll out new revisions to disk. Static pods on healthy masters continue using old (expired) certificate files, causing cascading authentication failures (401 Unauthorized for all users).

Root Cause

The InstallerController had no awareness of the Kubernetes NodeReady condition. It only checked static pod readiness (via mirror pod status), which is unreportable when a node is down. Combined with tolerations: [{operator: Exists}] on installer pods, installations were attempted on unreachable nodes indefinitely.

Fix Design

Layer 1: Deprioritization in nodeToStartRevisionWith
All selection loops (TargetRevision, LastFailedRevision, not-ready static pod, wrong-revision, oldest-revision) now skip nodes where isNodeNotReadyForTooLong returns true.

Layer 2: Timeout in newNodeStateForInstallInProgress
If an installer pod has been in a non-terminal phase on a NotReady node for more than 15 minutes, it is deleted and the installation is marked as failed. The normal back-off retry mechanism then applies.

Backward Compatibility:

  • WithNodeLister is opt-in; when nodeLister is nil, all checks return false (legacy behavior preserved)
  • Existing tests pass with nil for the new parameter

Test Plan

  • Unit tests for isNodeNotReadyForTooLong (Ready, below threshold, above threshold, Unknown status, no condition, nil lister)
  • Unit tests for nodeToStartRevisionWith with NotReady skip (single NotReady, multiple NotReady, all NotReady fallback)
  • All existing installer_controller_test.go tests pass unchanged
  • Full go build ./... passes
  • Manual validation: deploy on a 3-master cluster, cordon+drain one master, verify revision rollout proceeds on remaining 2 masters

References

Made with Cursor

Summary by CodeRabbit

  • New Features
    • Enhanced installer rollout ordering to skip nodes that have been unready longer than a configurable threshold.
  • Bug Fixes
    • Added automatic handling for installer pods stuck on long-unready nodes: the pod is deleted and the installation is marked as failed.
    • Proactively clears stale targets for long-unready nodes and records detailed failure information to avoid blocking healthy rollout.
  • Tests
    • Added unit tests covering node-readiness thresholds, skip behavior, and stuck-node timeout handling.

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: openshift/coderabbit/.coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 1434ca97-1e0a-427c-8df5-e0bd1e455e0b

📥 Commits

Reviewing files that changed from the base of the PR and between 51b392e and 9d4c867.

📒 Files selected for processing (4)
  • pkg/operator/staticpod/controller/installer/installer_controller.go
  • pkg/operator/staticpod/controller/installer/installer_controller_test.go
  • pkg/operator/staticpod/controller/installer/installer_notready_test.go
  • pkg/operator/staticpod/controllers.go
✅ Files skipped from review due to trivial changes (1)
  • pkg/operator/staticpod/controller/installer/installer_controller_test.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • pkg/operator/staticpod/controllers.go
  • pkg/operator/staticpod/controller/installer/installer_notready_test.go
  • pkg/operator/staticpod/controller/installer/installer_controller.go

Walkthrough

The installer controller now uses NodeLister-backed readiness-age checks to skip long-NotReady nodes during rollout ordering and to delete installer pods stuck on those nodes past a timeout. Wiring and tests were updated to cover the new behavior.

Changes

Installer NodeReady-aware rollout

Layer / File(s) Summary
Controller state and wiring
pkg/operator/staticpod/controller/installer/installer_controller.go, pkg/operator/staticpod/controllers.go
Adds NodeLister state, readiness timing constants, WithNodeLister, and conditional builder wiring from cluster informers.
Rollout selection skips long-NotReady nodes
pkg/operator/staticpod/controller/installer/installer_controller.go, pkg/operator/staticpod/controller/installer/installer_controller_test.go, pkg/operator/staticpod/controller/installer/installer_notready_test.go
nodeToStartRevisionWith accepts a readiness predicate, skips long-NotReady nodes across its selection paths, updates the fallback reason, and the new selection behavior is covered by tests.
Stuck pod cleanup and failure recording
pkg/operator/staticpod/controller/installer/installer_controller.go
manageInstallationPods clears stale target revisions for long-NotReady nodes, and newNodeStateForInstallInProgress deletes installer pods that exceed the timeout and records failed node state.
Readiness-duration tests and lister stub
pkg/operator/staticpod/controller/installer/installer_notready_test.go
Adds tests for readiness-age checks, nil-lister behavior, and a fake node lister implementation.

Estimated code review effort: 4 (Complex) | ~60 minutes


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (2 errors, 1 warning)

Check name Status Explanation Resolution
Stable And Deterministic Test Names ❌ Error New subtest titles embed node names like 'master-2' and 'master-0', which the check explicitly forbids. Rename those subtests to generic, static descriptions without node-specific literals (e.g. 'NotReady nodes are deprioritized').
No-Sensitive-Data-In-Logs ❌ Error New klog/eventRecorder messages log node and pod names (e.g. “Node %s...”, “Clearing stale targetRevision... on NotReady node %s”), which can expose internal hostnames. Remove raw node/pod identifiers from logs/events or redact them (hash/truncate) before emitting; keep only non-sensitive revision/state details.
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (12 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: skipping NotReady nodes during static pod installer rollout.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Test Structure And Quality ✅ Passed The new tests are isolated, table-driven unit tests with fake listers and no cluster waits; they follow existing repo patterns.
Microshift Test Compatibility ✅ Passed No new Ginkgo e2e tests were added; the new tests are plain testing.T unit tests and use no MicroShift-unsupported APIs/features.
Single Node Openshift (Sno) Test Compatibility ✅ Passed PASS: The added tests are plain testing.T unit tests; no It/Describe/Context/When Ginkgo tests were added in the changed files, so no SNO-specific multi-node assumption applies.
Topology-Aware Scheduling Compatibility ✅ Passed No new topology-sensitive scheduling constraints were added; changes only add NodeReady-aware rollout ordering and node informer resync wiring.
Ote Binary Stdout Contract ✅ Passed No process-level main/init/TestMain/RunSpecs code was added, and the new klog/printf use is confined to controller methods, not stdout-emitting entrypoints.
Ipv6 And Disconnected Network Test Compatibility ✅ Passed Only Go unit tests were added (testing.T); no Ginkgo/e2e tests, hardcoded IPv4, or external-host/network calls were found.
No-Weak-Crypto ✅ Passed No weak-crypto APIs or secret comparisons appear in the changed files; the patch only adds NodeReady rollout logic and timeout handling.
Container-Privileges ✅ Passed PR only adds NodeReady-aware rollout logic/tests; no changed file introduces privileged, hostPID/Network/IPC, SYS_ADMIN, or allowPrivilegeEscalation settings.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@openshift-ci
openshift-ci Bot requested review from dgrisonnet and p0lyn0mial June 26, 2026 19:44
@openshift-ci

openshift-ci Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by: dpateriya
Once this PR has been reviewed and has the lgtm label, please assign dgrisonnet for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/operator/staticpod/controller/installer/installer_controller.go`:
- Around line 966-968: The stuck installer pod cleanup path in the installer
controller is swallowing real Delete failures, which lets the code continue as
if the pod was removed. Update the cleanup logic around
c.podsGetter.Pods(...).Delete in the installer controller so that non-NotFound
errors are returned to the caller instead of only logging a warning, and ensure
the code that advances LastFailed* or otherwise mutates node state only runs
after deletion succeeds. Keep the node state unchanged when deletion fails, so
the retry logic in the installer controller can retry cleanup before proceeding.

In `@pkg/operator/staticpod/controller/installer/installer_notready_test.go`:
- Around line 87-89: The fallback check in installer_notready_test.go is too
weak because it only asserts that reason is non-empty instead of verifying the
expected substring. Update the test branch in the relevant loop to use
strings.Contains against tt.expectedContains when validating reason, and add the
strings import so the assertion actually fails if the message regresses. Use the
existing tt.expectedContains and reason variables in the test helper.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: openshift/coderabbit/.coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 8689a70f-b28d-4eb9-9d89-1dd7b10164ea

📥 Commits

Reviewing files that changed from the base of the PR and between dd144d2 and 5c603f8.

📒 Files selected for processing (4)
  • pkg/operator/staticpod/controller/installer/installer_controller.go
  • pkg/operator/staticpod/controller/installer/installer_controller_test.go
  • pkg/operator/staticpod/controller/installer/installer_notready_test.go
  • pkg/operator/staticpod/controllers.go

Comment thread pkg/operator/staticpod/controller/installer/installer_controller.go
Comment thread pkg/operator/staticpod/controller/installer/installer_notready_test.go Outdated
@dpateriya dpateriya changed the title installer: skip NotReady nodes during static pod revision rollout Fix: OCPBUGS-93148: installer skip NotReady nodes during static pod revision rollout Jun 26, 2026
@openshift-ci-robot openshift-ci-robot added the jira/valid-reference Indicates that this PR references a valid Jira ticket of any type. label Jun 26, 2026
@openshift-ci-robot

Copy link
Copy Markdown

@dpateriya: This pull request references Jira Issue OCPBUGS-93148, which is invalid:

  • expected the bug to target the "5.0.0" version, but no target version was set

Comment /jira refresh to re-evaluate validity if changes to the Jira bug are made, or edit the title of this pull request to link to a different bug.

The bug has been updated to refer to the pull request using the external bug tracker.

Details

In response to this:

Summary

  • Deprioritizes master nodes that have been Kubernetes-NotReady for more than 10 minutes during static pod revision rollout
  • Times out installer pods stuck on NotReady nodes after 15 minutes, marking them as failed so rollout proceeds on healthy masters
  • Wires a node informer into the installer controller factory so node readiness changes trigger resync

Problem

When a master node enters a prolonged NotReady state (e.g., hardware failure), the InstallerController's nodeToStartRevisionWith function selects it first (because its static pod isn't reporting Ready). This blocks the entire revision rollout ring, preventing healthy masters from receiving updated configurations.

This is critical during certificate rotation: new certificates are written to etcd but operators cannot roll out new revisions to disk. Static pods on healthy masters continue using old (expired) certificate files, causing cascading authentication failures (401 Unauthorized for all users).

Root Cause

The InstallerController had no awareness of the Kubernetes NodeReady condition. It only checked static pod readiness (via mirror pod status), which is unreportable when a node is down. Combined with tolerations: [{operator: Exists}] on installer pods, installations were attempted on unreachable nodes indefinitely.

Fix Design

Layer 1: Deprioritization in nodeToStartRevisionWith
All selection loops (TargetRevision, LastFailedRevision, not-ready static pod, wrong-revision, oldest-revision) now skip nodes where isNodeNotReadyForTooLong returns true.

Layer 2: Timeout in newNodeStateForInstallInProgress
If an installer pod has been in a non-terminal phase on a NotReady node for more than 15 minutes, it is deleted and the installation is marked as failed. The normal back-off retry mechanism then applies.

Backward Compatibility:

  • WithNodeLister is opt-in; when nodeLister is nil, all checks return false (legacy behavior preserved)
  • Existing tests pass with nil for the new parameter

Test Plan

  • Unit tests for isNodeNotReadyForTooLong (Ready, below threshold, above threshold, Unknown status, no condition, nil lister)
  • Unit tests for nodeToStartRevisionWith with NotReady skip (single NotReady, multiple NotReady, all NotReady fallback)
  • All existing installer_controller_test.go tests pass unchanged
  • Full go build ./... passes
  • Manual validation: deploy on a 3-master cluster, cordon+drain one master, verify revision rollout proceeds on remaining 2 masters

References

Made with Cursor

Summary by CodeRabbit

  • New Features

  • Improved rollout handling to better account for nodes that stay unready for too long.

  • Added smarter installer pod selection so unhealthy nodes are avoided when possible.

  • Bug Fixes

  • Stuck installer pods on long-unready nodes are now cleaned up automatically.

  • Failed installations now record clearer failure status to help unblock progress.

  • Tests

  • Added coverage for node-readiness-based selection and timeout behavior.

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the openshift-eng/jira-lifecycle-plugin repository.

@openshift-ci-robot openshift-ci-robot added the jira/invalid-bug Indicates that a referenced Jira bug is invalid for the branch this PR is targeting. label Jun 26, 2026
@dpateriya
dpateriya force-pushed the fix/installer-skip-notready-nodes branch from 5c603f8 to 9dae900 Compare June 26, 2026 20:02
@openshift-ci-robot openshift-ci-robot added jira/valid-bug Indicates that a referenced Jira bug is valid for the branch this PR is targeting. and removed jira/invalid-bug Indicates that a referenced Jira bug is invalid for the branch this PR is targeting. labels Jun 26, 2026
@openshift-ci-robot

Copy link
Copy Markdown

@dpateriya: This pull request references Jira Issue OCPBUGS-93148, which is valid. The bug has been moved to the POST state.

3 validation(s) were run on this bug
  • bug is open, matching expected state (open)
  • bug target version (5.0.0) matches configured target version for branch (5.0.0)
  • bug is in the state New, which is one of the valid states (NEW, ASSIGNED, POST)
Details

In response to this:

Summary

  • Deprioritizes master nodes that have been Kubernetes-NotReady for more than 10 minutes during static pod revision rollout
  • Times out installer pods stuck on NotReady nodes after 15 minutes, marking them as failed so rollout proceeds on healthy masters
  • Wires a node informer into the installer controller factory so node readiness changes trigger resync

Problem

When a master node enters a prolonged NotReady state (e.g., hardware failure), the InstallerController's nodeToStartRevisionWith function selects it first (because its static pod isn't reporting Ready). This blocks the entire revision rollout ring, preventing healthy masters from receiving updated configurations.

This is critical during certificate rotation: new certificates are written to etcd but operators cannot roll out new revisions to disk. Static pods on healthy masters continue using old (expired) certificate files, causing cascading authentication failures (401 Unauthorized for all users).

Root Cause

The InstallerController had no awareness of the Kubernetes NodeReady condition. It only checked static pod readiness (via mirror pod status), which is unreportable when a node is down. Combined with tolerations: [{operator: Exists}] on installer pods, installations were attempted on unreachable nodes indefinitely.

Fix Design

Layer 1: Deprioritization in nodeToStartRevisionWith
All selection loops (TargetRevision, LastFailedRevision, not-ready static pod, wrong-revision, oldest-revision) now skip nodes where isNodeNotReadyForTooLong returns true.

Layer 2: Timeout in newNodeStateForInstallInProgress
If an installer pod has been in a non-terminal phase on a NotReady node for more than 15 minutes, it is deleted and the installation is marked as failed. The normal back-off retry mechanism then applies.

Backward Compatibility:

  • WithNodeLister is opt-in; when nodeLister is nil, all checks return false (legacy behavior preserved)
  • Existing tests pass with nil for the new parameter

Test Plan

  • Unit tests for isNodeNotReadyForTooLong (Ready, below threshold, above threshold, Unknown status, no condition, nil lister)
  • Unit tests for nodeToStartRevisionWith with NotReady skip (single NotReady, multiple NotReady, all NotReady fallback)
  • All existing installer_controller_test.go tests pass unchanged
  • Full go build ./... passes
  • Manual validation: deploy on a 3-master cluster, cordon+drain one master, verify revision rollout proceeds on remaining 2 masters

References

Made with Cursor

Summary by CodeRabbit

  • New Features

  • Improved rollout ordering to account for nodes that remain unready for too long.

  • Added node-readiness-aware installer pod selection and fallback behavior.

  • Bug Fixes

  • Installer pods stuck on long-unready nodes are now cleaned up automatically.

  • Failed installations now record clearer failure details to help rollout progress.

  • Tests

  • Added unit tests covering node readiness thresholds, selection skipping behavior, and stuck-node timeout handling.

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the openshift-eng/jira-lifecycle-plugin repository.

@tjungblu

tjungblu commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Aside from the technicalities involved in the static pod machinery that makes this approach infeasible, the cert rotation has a 9 month (!) buffer for you to fix your node:
https://github.com/openshift/cluster-etcd-operator/blob/main/pkg/tlshelpers/tlshelpers.go#L28-L34

are you telling me that there is a node that was not ready for 9 consecutive months?

@dpateriya

Copy link
Copy Markdown
Author

Hi @tjungblu, it is not about the etcd certificate.

It is about the aggregator-client, aggregator-client-signer, and csr-signer certificates, which have a shorter validity period compared to etcd certs.

When these certificates rotate, the new cert is written to etcd and a new revision is triggered. The installer controller then targets the NotReady node first because nodeToStartRevisionWith prioritizes nodes whose static pod is not reporting Ready — which includes nodes that are Kubernetes-NotReady (since kubelet is down, the mirror pod status is stale/absent).

The customer had a master node in NotReady state for 12 days due to hardware failure. They were aware of this and the fix (hardware replacement) was estimated to take another 2-3 weeks. The cluster was expected to continue operating with 2 healthy masters during this period.

Due to the aggregator-client and aggregator-client-signer certificate rotation triggering a new revision that got stuck on the NotReady node, the customer was unable to login to the OCP cluster via console and CLI (401 Unauthorized for all users).

@dpateriya

Copy link
Copy Markdown
Author

Also, this fix preserves the one-at-a-time rollout invariant; it only changes which node is picked first in the ring.

@dpateriya

dpateriya commented Jul 1, 2026

Copy link
Copy Markdown
Author

@tjungblu, can you please do the needful once you have time?

@p0lyn0mial

Copy link
Copy Markdown
Contributor

It is about the aggregator-client, aggregator-client-signer, and csr-signer certificates.

@dpateriya please check but I think these certificates are unrevisioned.

https://github.com/openshift/cluster-kube-apiserver-operator/blob/main/pkg/operator/starter.go#L675C1-L675C29
https://github.com/openshift/cluster-kube-apiserver-operator/blob/main/pkg/operator/starter.go#L661
https://github.com/openshift/cluster-kube-controller-manager-operator/blob/main/pkg/operator/starter.go#L350

Unrevisioned resources are delivered by a sidecar and don't go through the revision/installer mechanism. The static pods reload them dynamically from disk without restart.

If that the case then it could be that something else broke the cluster.

@dpateriya

Copy link
Copy Markdown
Author

@p0lyn0mial , I did confirm the same from the must-gather report.

While the leaf aggregator-client cert is indeed delivered by cert-syncer (unrevisioned), the aggregator-client-ca ConfigMap IS a revisioned resource (visible in CertConfigMapNamePrefixes). When the signer rotated, it updated this CA bundle, triggering revision 301.

  - currentRevision: 299
    lastFailedCount: 1
    lastFailedReason: InstallerFailed
    lastFailedRevision: 5
    lastFailedRevisionErrors:
    - |
      installer: elet-client",
        (string) (len=16) "node-kubeconfigs"
       },
       OptionalCertSecretNamePrefixes: ([]string) (len=11 cap=16) {
        (string) (len=17) "user-serving-cert",
        (string) (len=21) "user-serving-cert-000",
        (string) (len=21) "user-serving-cert-001",
        (string) (len=21) "user-serving-cert-002",
        (string) (len=21) "user-serving-cert-003",
        (string) (len=21) "user-serving-cert-004",
        (string) (len=21) "user-serving-cert-005",
        (string) (len=21) "user-serving-cert-006",
        (string) (len=21) "user-serving-cert-007",
        (string) (len=21) "user-serving-cert-008",
        (string) (len=21) "user-serving-cert-009"
       },
       CertConfigMapNamePrefixes: ([]string) (len=4 cap=4) {
        (string) (len=20) "aggregator-client-ca",
        (string) (len=9) "client-ca",
        (string) (len=29) "control-plane-node-kubeconfig",
        (string) (len=26) "check-endpoints-kubeconfig"
       },
       OptionalCertConfigMapNamePrefixes: ([]string) (len=1 cap=1) {
        (string) (len=17) "trusted-ca-bundle"
       },
       CertDir: (string) (len=57) "/etc/kubernetes/static-pod-resources/kube-apiserver-certs",
       ResourceDir: (string) (len=36) "/etc/kubernetes/static-pod-resources",
       PodManifestDir: (string) (len=25) "/etc/kubernetes/manifests",
       Timeout: (time.Duration) 2m0s,
       StaticPodManifestsLockFile: (string) "",
       PodMutationFns: ([]installerpod.PodMutationFunc) <nil>,
       KubeletVersion: (string) ""
      })
      I0416 07:49:27.372032       1 cmd.go:410] Getting controller reference for node master1.example.com
      I0416 07:49:27.379776       1 cmd.go:423] Waiting for installer revisions to settle for node master1.example.com
      I0416 07:49:27.381713       1 cmd.go:515] Waiting additional period after revisions have settled for node master1.example.com
      I0416 07:49:57.382465       1 cmd.go:521] Getting installer pods for node master1.example.com
      F0416 07:50:11.386170       1 cmd.go:106] Get "https://172.30.0.1:443/api/v1/namespaces/openshift-kube-apiserver/pods?labelSelector=app%3Dinstaller": net/http: request canceled (Client.Timeout exceeded while awaiting headers)
    lastFailedTime: "2025-04-16T07:53:56Z"
    lastFallbackCount: 0
    nodeName: master1.example.com
    targetRevision: 301

The InstallerController selected the NotReady node first for revision 301, got stuck, and never proceeded to the two healthy nodes. The healthy nodes remained on revision 299 with the old CA trust bundle, so they could not validate the newly rotated leaf certs delivered by cert-syncer — resulting in x509: certificate has expired errors and authentication failures.

Once the NotReady node was removed (unblocking the operator), the revision rolled out to the healthy nodes and login was immediately restored — confirming the revision contained the critical aggregator-client-ca update.

The fix ensures the InstallerController deprioritizes NotReady nodes so that healthy nodes receive the new revision first, preventing this class of outage.

@p0lyn0mial

Copy link
Copy Markdown
Contributor

the aggregator-client-ca ConfigMap IS a revisioned resource

@dpateriya please double check but it seems that aggregator-client-ca is not revisioned.

have a look at: https://github.com/openshift/cluster-kube-apiserver-operator/blob/main/pkg/operator/starter.go#L661

var CertConfigMaps = []installer.UnrevisionedResource{
	{Name: "aggregator-client-ca"},
	...
}

@dpateriya

Copy link
Copy Markdown
Author

@p0lyn0mial you are correct, aggregator-client-ca is unrevisioned resource. Thanks for the correction.

However, the must-gather evidence clearly shows that revision 301 was created (likely triggered by a revisioned resource such as kube-apiserver-cert-syncer-kubeconfig, sa-token-signing-certs, or bound-sa-token-signing-certs) and got stuck on the NotReady node:

nodeStatuses:
- nodeName: master1.example.com (NotReady since June 14)
  currentRevision: 299
  targetRevision: 301   # stuck here

- nodeName: master2.example.com (Ready)
  currentRevision: 299
  targetRevision: 0     # never attempted

- nodeName: master3.example.com (Ready)
  currentRevision: 299
  targetRevision: 0     # never attempted

The InstallerController selected the NotReady node first for revision 301. The two healthy nodes with targetRevision: 0 were never started. Once the NotReady node was removed and the revision completed on the healthy nodes, login was immediately restored.

Regardless of which specific revisioned resource triggered revision 301, the core issue is that any new revision gets stuck when the InstallerController targets a NotReady node first, blocking healthy nodes from progressing. This fix ensures healthy nodes are prioritized so the cluster remains functional even with a prolonged NotReady master.

I'd value your perspective on whether deprioritizing NotReady nodes in nodeToStartRevisionWith is the right approach here, or if there's an existing mechanism I may have missed that handles this case.

@tjungblu

tjungblu commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Once the NotReady node was removed and the revision completed on the healthy nodes, login was immediately restored.

are you sure, Claude? because in the support case this is not the case, the customer wanted to keep the not-ready node around at all cost.

The procedure applied was:

Static Pod Force-Rollout: We manually forced a rollout of the kube-apiserver, kube-scheduler, and kube-controller-manager static pods by temporarily moving their manifest files out of /etc/kubernetes/manifests and added back to same path.

Component Restarts: We restarted the running pods within the openshift-oauth-apiserver, openshift-authentication, and openshift-apiserver namespaces to force them to pick up new tokens/secrets, though initial oc login attempts continued to fail due to the pending cluster configuration updates.

But it is not clear which of those restarts were actually fixing the issue. Maybe just restarting oauth-apiserver would've fixed it. They have experienced login issues and they're using IDP. I think somebody from auth should take a look at this case first and analyse the must-gather before we start plumbing the static pod machinery here.

@dpateriya

Copy link
Copy Markdown
Author

@tjungblu, claude was not on the call with the customer. I was on the call with the customer.

When I said I removed the master node, it meant that the master role (node-role.kubernetes.io/master=) from the not ready master node was removed, and then the installercontroller targeted the rest of available 2 master nodes.

Manual restart of oauth-openshift pods, openshift-apiserver pods, openshift-oauth-apiserver pods and even static pods like KCM, KAS were restarted.

The static pods were restarted by moving their pod yaml from /etc/kubernetes/manifests to /home/core and then again moved back to the original location.

All these were the efforts made during the remote call. But this does not change the fact that the installer controller prioritizes the not ready master node first.

// nodeNotReadyThreshold is how long a node must be Kubernetes-NotReady before
// the installer controller deprioritizes it in rollout ordering. This prevents
// a prolonged hardware failure from blocking cert rotation on healthy masters.
nodeNotReadyThreshold = 10 * time.Minute

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.

a bare metal node can take about an hour to reboot, so the node will be not ready for 50 minutes. Will this cause etcd downtime when a static pod revision rolls out?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

AFAIK, etcd can suffer a loss/unavailability of one master node.

So even if a bare-metal node takes hours to reboot, this will have no downtime for etcd.

Also, the KAS revision rollout does not trigger etcd pod rollout.

Taking your feedback into consideration, we can increase the nodeNotReadyThreshold to 60 minutes to avoid unnecessary skipping during normal reboots for bare-metal nodes while still catching prolonged outages (the incident involved 12+ days NotReady).

Please confirm.

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.

You understand that the code is shared between all control plane operators, right?

So even if a bare-metal node takes hours to reboot, this will have no downtime for etcd.

of course it does, because your code doesn't stop the rollout.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agree, so a bare-metal node reboot + etcd revision rollout on the other master node will briefly trigger quorum loss and a cluster outage.

We can exclude etcd without adding any new methods or interfaces. The etcd-operator is the only consumer that sets WithRevisionControllerPrecondition (for quorum safety). KAS, KCM, and scheduler don't use it.

So the operators like etcd, which are quorum-sensitive, will not get the NotReady skip and all others like KAS, KCM, and scheduler will do.

@dpateriya dpateriya Jul 6, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

One condition change in controllers.go line 300:

// Before:
if clusterInformers != nil {

// After:
if clusterInformers != nil && b.revisionControllerPrecondition == nil {

I think this would help then.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Changes have been made.

You can verify the same.

@dpateriya
dpateriya force-pushed the fix/installer-skip-notready-nodes branch from 9dae900 to 51b392e Compare July 9, 2026 10:05
@openshift-ci-robot

Copy link
Copy Markdown

@dpateriya: This pull request references Jira Issue OCPBUGS-93148, which is valid.

3 validation(s) were run on this bug
  • bug is open, matching expected state (open)
  • bug target version (5.0.0) matches configured target version for branch (5.0.0)
  • bug is in the state POST, which is one of the valid states (NEW, ASSIGNED, POST)
Details

In response to this:

Summary

  • Deprioritizes master nodes that have been Kubernetes-NotReady for more than 10 minutes during static pod revision rollout
  • Times out installer pods stuck on NotReady nodes after 15 minutes, marking them as failed so rollout proceeds on healthy masters
  • Wires a node informer into the installer controller factory so node readiness changes trigger resync

Problem

When a master node enters a prolonged NotReady state (e.g., hardware failure), the InstallerController's nodeToStartRevisionWith function selects it first (because its static pod isn't reporting Ready). This blocks the entire revision rollout ring, preventing healthy masters from receiving updated configurations.

This is critical during certificate rotation: new certificates are written to etcd but operators cannot roll out new revisions to disk. Static pods on healthy masters continue using old (expired) certificate files, causing cascading authentication failures (401 Unauthorized for all users).

Root Cause

The InstallerController had no awareness of the Kubernetes NodeReady condition. It only checked static pod readiness (via mirror pod status), which is unreportable when a node is down. Combined with tolerations: [{operator: Exists}] on installer pods, installations were attempted on unreachable nodes indefinitely.

Fix Design

Layer 1: Deprioritization in nodeToStartRevisionWith
All selection loops (TargetRevision, LastFailedRevision, not-ready static pod, wrong-revision, oldest-revision) now skip nodes where isNodeNotReadyForTooLong returns true.

Layer 2: Timeout in newNodeStateForInstallInProgress
If an installer pod has been in a non-terminal phase on a NotReady node for more than 15 minutes, it is deleted and the installation is marked as failed. The normal back-off retry mechanism then applies.

Backward Compatibility:

  • WithNodeLister is opt-in; when nodeLister is nil, all checks return false (legacy behavior preserved)
  • Existing tests pass with nil for the new parameter

Test Plan

  • Unit tests for isNodeNotReadyForTooLong (Ready, below threshold, above threshold, Unknown status, no condition, nil lister)
  • Unit tests for nodeToStartRevisionWith with NotReady skip (single NotReady, multiple NotReady, all NotReady fallback)
  • All existing installer_controller_test.go tests pass unchanged
  • Full go build ./... passes
  • Manual validation: deploy on a 3-master cluster, cordon+drain one master, verify revision rollout proceeds on remaining 2 masters

References

Made with Cursor

Summary by CodeRabbit

  • New Features
  • Improved installer rollout ordering by accounting for nodes that have remained unready beyond a configurable threshold.
  • Added node-readiness-aware selection logic for choosing installer start points across multiple candidate phases.
  • Bug Fixes
  • Automatically cleans up installer pods running on nodes unready for too long and marks the node installation as failed.
  • Clears stale targets for long-unready nodes and records detailed failure information to unblock healthy rollout.
  • Tests
  • Added unit tests covering readiness thresholds, skipping behavior, and stuck-node timeout handling.

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the openshift-eng/jira-lifecycle-plugin repository.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
pkg/operator/staticpod/controller/installer/installer_controller.go (1)

983-998: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use a single time source in this block.

podAge is computed from c.clock.Now() (Line 983) while LastFailedTime is set from c.now() (Line 998). These are two independently injectable clocks (clock and now), so they can diverge under test and are needlessly inconsistent. Pick one time source here for the failure record.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/operator/staticpod/controller/installer/installer_controller.go` around
lines 983 - 998, The stuck-installer-pod handling block uses two different time
sources, which makes the failure record inconsistent and can diverge in tests.
In the installer_controller.go logic around the installer pod timeout handling,
use a single source for both podAge and the LastFailedTime assignment, ideally
the same clock already used by c.clock.Now(), and keep the rest of the failure
path in sync with that choice.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/operator/staticpod/controller/installer/installer_controller.go`:
- Around line 553-576: The stale targetRevision cleanup in
installer_controller.go should also delete the stuck installer pod before
returning. In the loop over operatorStatus.NodeStatuses inside the staticpod
installer controller logic, update the branch that clears a NotReady node’s
targetRevision so it first invokes the existing pod cleanup path used for
timeout-based cleanup, then returns the updated NodeStatus. This ensures the
installer pod is removed even when the early return short-circuits the later
cleanup logic.

---

Nitpick comments:
In `@pkg/operator/staticpod/controller/installer/installer_controller.go`:
- Around line 983-998: The stuck-installer-pod handling block uses two different
time sources, which makes the failure record inconsistent and can diverge in
tests. In the installer_controller.go logic around the installer pod timeout
handling, use a single source for both podAge and the LastFailedTime assignment,
ideally the same clock already used by c.clock.Now(), and keep the rest of the
failure path in sync with that choice.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: openshift/coderabbit/.coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 24579fe3-09de-4474-8053-ad71f5300475

📥 Commits

Reviewing files that changed from the base of the PR and between 9dae900 and 51b392e.

📒 Files selected for processing (4)
  • pkg/operator/staticpod/controller/installer/installer_controller.go
  • pkg/operator/staticpod/controller/installer/installer_controller_test.go
  • pkg/operator/staticpod/controller/installer/installer_notready_test.go
  • pkg/operator/staticpod/controllers.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • pkg/operator/staticpod/controllers.go
  • pkg/operator/staticpod/controller/installer/installer_controller_test.go
  • pkg/operator/staticpod/controller/installer/installer_notready_test.go

Comment thread pkg/operator/staticpod/controller/installer/installer_controller.go
A node that has been Kubernetes-NotReady for more than 10 minutes is now
deprioritized in the installer controller's rollout ring. Additionally,
installer pods stuck on such nodes for more than 15 minutes are
force-failed so the rollout proceeds on healthy masters.

The fix covers all node selection paths in nodeToStartRevisionWith:
- TargetRevision (in-progress) loop
- LastFailedRevision loop
- Not-ready static pod loop
- Wrong-revision loop
- Oldest-revision loop

A node informer is wired into the controller factory so that changes in
node readiness trigger controller resync.

This prevents a single hardware-failed master from blocking certificate
rotation (and other revision-driven updates) across the entire cluster,
which previously caused authentication outages.

Bug: https://redhat.atlassian.net/browse/OCPBUGS-93148
Co-authored-by: Cursor <cursoragent@cursor.com>
@dpateriya
dpateriya force-pushed the fix/installer-skip-notready-nodes branch from 51b392e to 9d4c867 Compare July 9, 2026 11:10
@openshift-ci

openshift-ci Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

@dpateriya: all tests passed!

Full PR test history. Your PR dashboard.

Details

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository. I understand the commands that are listed here.

@dpateriya

Copy link
Copy Markdown
Author

Hi Team, can someone please re-review this?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

jira/valid-bug Indicates that a referenced Jira bug is valid for the branch this PR is targeting. jira/valid-reference Indicates that this PR references a valid Jira ticket of any type.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants