Skip to content

fix(opensearch-operator): use RollingUpdate maxSurge=0 instead of Recreate - #3319

Merged
myasnikovdaniil merged 1 commit into
mainfrom
fix/opensearch-operator-upgrade-strategy
Jul 21, 2026
Merged

myasnikovdaniil merged 1 commit into
mainfrom
fix/opensearch-operator-upgrade-strategy

Conversation

@myasnikovdaniil

@myasnikovdaniil myasnikovdaniil commented Jul 16, 2026 •

Copy link
Copy Markdown
Contributor

What this PR does

Fixes a Helm-upgrade failure in the opensearch-operator chart that blocks the new release-upgrade E2E lane (added in #3276).

Upgrading from a chart that shipped no explicit strategy (v1.5.x) to a v1.6.0-rc build fails validation immediately, helm-controller retries it, and the HelmRelease never becomes Ready:

Helm upgrade failed for release cozy-opensearch-operator/opensearch-operator:
Deployment.apps "opensearch-operator-controller-manager" is invalid:
spec.strategy.rollingUpdate: Forbidden: may not be specified when strategy type is Recreate

Observed in the "Upgrade E2E Test" job of run 29480724982 — UpgradeFailed ... (x19 over 11m), with the HelmRelease never reaching Ready.

Root cause

#3040 (commit db797b1) set the single-replica operator Deployment to strategy.type: Recreate — a reasonable goal: with leader election gated off on one replica, a default RollingUpdate briefly runs two uncoordinated managers (maxSurge) during a rollout, and Recreate closes that window.

The problem surfaces only on upgrade from an older chart, under server-side apply:

  • A v1.5.x release shipped no strategy at all, so the apiserver defaulted spec.strategy.rollingUpdate to 25%/25%, and the helm-controller field manager never owned that block. Its managedFields entry covers f:replicas, f:selector and f:template, but not f:strategy.
  • helm-controller applies server-side by default (Install.ServerSideApply defaults to true, Upgrade.ServerSideApply to auto; internal/operator/package_reconciler.go sets neither), and SSA does not remove a field the applier never owned merely because the new intent omits it.
  • So the v1.6.0-rc manifest's type: Recreate merges onto a live object that still carries the defaulted rollingUpdate, and the apiserver rejects the result with FieldValueForbidden.

The client-side path is unaffected, which is why this surfaces through helm-controller's SSA but not a local helm upgrade: DeploymentSpec.Strategy carries patchStrategy:"retainKeys" (k8s.io/[email protected]/apps/v1/types.go:395), so a 3-way merge emits {"spec":{"strategy":{"$retainKeys":["type"],"type":"Recreate"}}}, which merges to a valid {"strategy":{"type":"Recreate"}} and does clear the block.

This is a genuine product upgrade bug, not a test-harness issue — #3276 only adds the upgrade lane that exposes it; it does not touch opensearch-operator.

An earlier revision of this PR attributed the failure to Helm's client-side 3-way merge. That was wrong, and the correction is Aleksei Sviridkin (@lexfrei)'s — see his review. The mechanism above is now reproduced end to end (below).

The fix

Keep type: RollingUpdate on the single-replica path and set rollingUpdate.maxSurge: 0 / maxUnavailable: 1 instead of switching to Recreate:

  • maxSurge: 0 caps total pods at replicas, so the deployment controller must scale the old manager down before it can scale the new one up — unlike the default 25% surge, which starts a second manager while the first is still fully live.
  • This is not Recreate's exclusivity, and the PR no longer claims it is. rolloutRecreate gates scale-up on oldPodsRunning(), which counts non-terminal Status.Phase, so Recreate waits for the old pod to be gone. rolloutRolling has no such gate: NewRSNewReplicas derives currentPodCount from GetReplicaCountForReplicaSets, which sums rs.Spec.Replicas (desired, not live). The ordering that is guaranteed is over ReplicaSet desired counts, not pod lifecycles, so a draining old manager can still overlap the new pod within terminationGracePeriodSeconds (10s here).
  • Because strategy.type never becomes Recreate, the forbidden merge cannot occur on any apply path. It also recovers anyone already on an rc build with Recreate, since Recreate -> RollingUpdate is permitted.
  • This matches the idiom already vendored in this repo: packages/system/kubeovn/charts/kube-ovn/templates/central-deploy.yaml:11-15 uses RollingUpdate with maxSurge: 0 / maxUnavailable: 1.

Multi-replica installs (manager.replicaCount > 1) are unchanged: they keep the default RollingUpdate with leader election enabled.

The change is carried in patches/leaderElection.diff so it survives a chart re-vendor via make update; the patch applies to pristine upstream 2.8.0 with no fuzz and reproduces the committed strategy block exactly.

Alternatives considered

Option Why not
Keep Recreate, but make the chart own rollingUpdate first (ship an explicit block, then switch type in a later release) This is the only option that preserves real exclusivity, but it needs two releases and only works once every install has reconciled the intermediate one. Disproportionate for a single-replica operator whose residual overlap is a draining manager.
Pre-upgrade hook / Job that patches or deletes the Deployment strategy before the upgrade Heavy: needs a kubectl image, a ServiceAccount and RBAC to patch/delete Deployments, and hook ordering; runs on every upgrade even when unneeded; adds standing attack surface — disproportionate for a scalar field transition.
helm.sh/resource-policy / force-replace resource-policy: keep only governs deletion on uninstall (irrelevant). Helm has no per-resource "force recreate" annotation; --force / HelmRelease spec.upgrade.force is global and disruptive and not controllable from within the chart.
Plain revert to default RollingUpdate (25%/25%) Fixes the rejection but starts a surge pod while the old manager is still fully live — the window #3040 set out to close. maxSurge: 0 narrows it to a draining old pod without leaving RollingUpdate.

Verification

The mechanism and the fix were reproduced on kind v1.33.1 (a bare apiserver is enough — the failure is defaulting + SSA + validation):

Path Result
SSA install of the v1.5.x manifest (no strategy) apiserver defaults rollingUpdate: 25%/25%; managedFields shows helm-controller does not own f:strategy
SSA upgrade to type: Recreate fails with the exact production error above
Client-side apply of the same pair succeeds; rollingUpdate is cleared, live strategy becomes {"type":"Recreate"}
SSA upgrade to maxSurge: 0 (this PR) succeeds
SSA of maxSurge: 0 onto a live Recreate Deployment (rc recovery) succeeds
Rollout under maxSurge: 0 vs Recreate maxSurge: 0: two pods coexisted within 1s (old Terminating, new ContainerCreating). Recreate: one pod, waited the full drain. This is the evidence for the "not exclusivity" wording above.

Note on regression coverage

tests/leader_election_test.yaml was never executed: hack/helm-unit-tests.sh runs a package's suite only when the package Makefile defines a test: target, and this one did not — so the coverage the previous revision of this PR claimed did not exist. This PR adds the target (matching packages/system/etcd-operator/Makefile), which revives the suite; the repo-wide runner now picks the package up and a revert to Recreate fails it. Thanks to Aleksei Sviridkin (@lexfrei) for catching that.

The apiserver-side merge that produces the invalid object still needs a live cluster, so the behavioural regression test remains the release-upgrade E2E lane in #3276 (which this fix unblocks).

Follow-ups filed

Downstream repositories

  • No downstream repository is affected by this change

Release note

fix(opensearch-operator): fix a Helm upgrade failure on single-replica installs. Upgrading a release that predates an explicit Deployment strategy failed under server-side apply, because switching strategy.type to Recreate does not clear the apiserver-defaulted spec.strategy.rollingUpdate block that the chart never owned, and the merged object is rejected. The operator now uses RollingUpdate with maxSurge=0/maxUnavailable=1, which keeps the transition legal on every apply path and recovers releases already stuck on Recreate.

Summary by CodeRabbit

  • Bug Fixes
    • Updated OpenSearch Operator controller rollout behavior for single-replica installs to use a safer rolling update configuration.
    • Leader election is now applied only when multiple replicas are configured, reducing the chance of conflicting controller activity.
  • Tests
    • Expanded Helm unittest assertions for the controller deployment rollout strategy and leader-election behavior for single- and multi-replica scenarios.
  • Chores
    • Added a make test target to run Helm unit tests.

@github-actions github-actions Bot added area/uncategorized PR auto-labeler could not map title scope to a known area/*; please review kind/bug Categorizes issue or PR as related to a bug labels Jul 16, 2026
@coderabbitai

coderabbitai Bot commented Jul 16, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a55521c6-ef42-4895-b6a3-13ca112dccfd

📥 Commits

Reviewing files that changed from the base of the PR and between 518e227 and 955f7cc.

📒 Files selected for processing (4)
  • packages/system/opensearch-operator/Makefile
  • packages/system/opensearch-operator/charts/opensearch-operator/templates/opensearch-operator-controller-manager-deployment.yaml
  • packages/system/opensearch-operator/patches/leaderElection.diff
  • packages/system/opensearch-operator/tests/leader_election_test.yaml
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/system/opensearch-operator/charts/opensearch-operator/templates/opensearch-operator-controller-manager-deployment.yaml
  • packages/system/opensearch-operator/patches/leaderElection.diff

📝 Walkthrough

Walkthrough

The OpenSearch operator now uses manager.replicaCount for replica-aware leader election and rollout settings. Single-replica deployments use constrained rolling updates, with Helm tests and a Makefile test target updated accordingly.

Changes

Replica-aware OpenSearch operator rollout

Layer / File(s) Summary
Replica-aware deployment, leader election, and rollout strategy
packages/system/opensearch-operator/charts/.../opensearch-operator-controller-manager-deployment.yaml, packages/system/opensearch-operator/patches/leaderElection.diff
The Deployment uses manager.replicaCount, omits --leader-elect for one replica or fewer, and applies constrained RollingUpdate settings in that case.
Helm validation and test command
packages/system/opensearch-operator/tests/leader_election_test.yaml, packages/system/opensearch-operator/Makefile
The test verifies the single-replica rollout configuration, and the Makefile adds a Helm unit-test target.

Estimated code review effort: 2 (Simple) | ~10 minutes

Sequence Diagram(s)

sequenceDiagram
  participant HelmValues
  participant ControllerDeployment
  participant ControllerManager
  HelmValues->>ControllerDeployment: Set manager.replicaCount
  ControllerDeployment->>ControllerManager: Omit --leader-elect when count <= 1
  ControllerDeployment->>ControllerDeployment: Set RollingUpdate constraints when count <= 1
Loading

Possibly related PRs

  • cozystack/cozystack#3040: Modifies the same OpenSearch operator leader-election and rollout behavior based on manager.replicaCount.

Suggested labels: area/kubernetes, area/testing

Suggested reviewers: kvaps

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: replacing Recreate with RollingUpdate maxSurge=0 for opensearch-operator.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/opensearch-operator-upgrade-strategy

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.

@github-actions github-actions Bot added the size/M This PR changes 30-99 lines, ignoring generated files label Jul 16, 2026
@myasnikovdaniil
myasnikovdaniil marked this pull request as ready for review July 16, 2026 10:44
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses a Helm upgrade failure in the opensearch-operator chart caused by a conflict between the 'Recreate' deployment strategy and leftover 'rollingUpdate' configurations from previous chart versions. By switching to a 'RollingUpdate' strategy with specific surge and unavailability settings, the operator maintains the desired single-replica rollout behavior while ensuring compatibility with existing Kubernetes deployments.

Highlights

  • Deployment Strategy Update: Changed the opensearch-operator deployment strategy from 'Recreate' to 'RollingUpdate' with 'maxSurge: 0' and 'maxUnavailable: 1' to resolve Helm upgrade failures.
  • Upgrade Compatibility: Ensured that the transition from older chart versions (which defaulted to RollingUpdate) to the new version does not trigger an invalid Kubernetes API configuration where 'Recreate' and 'rollingUpdate' fields conflict.
  • Test Suite Adjustment: Updated the helm-unittest suite in 'leader_election_test.yaml' to reflect the new deployment strategy requirements.
New Features

🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Ignored Files
  • Ignored by pattern: **/*.diff (1)
    • packages/system/opensearch-operator/patches/leaderElection.diff
  • Ignored by pattern: **/charts/** (1)
    • packages/system/opensearch-operator/charts/opensearch-operator/templates/opensearch-operator-controller-manager-deployment.yaml
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment Gemini (@gemini-code-assist) Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on Gemini (@gemini-code-assist) comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@dosubot dosubot Bot added the area/platform Issues or PRs related to platform infrastructure (bundle, flux, talos, installer) label Jul 16, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request updates the leader election test suite for the opensearch-operator package. Specifically, it changes the expected deployment strategy type from Recreate to RollingUpdate and adds assertions to verify that maxSurge is set to 0 and maxUnavailable is set to 1. This configuration ensures that the old manager is torn down before the new one starts during a single-replica rollout, preventing uncoordinated managers from overlapping while avoiding invalid transition errors during upgrades. There are no review comments, and I have no feedback to provide.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

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.

NOT LGTM — the fix itself is directionally right, but the root cause it documents is empirically false, and the regression coverage it claims does not execute.

Business context: a v1.5.x -> v1.6.0-rc upgrade of opensearch-operator fails repeatedly until the HelmRelease times out, because the Deployment ends up with strategy.type: Recreate alongside a leftover rollingUpdate block, which the apiserver rejects.

The direction is right and I want to say so up front: maxSurge: 0 is a sound choice, and re-enabling leader election is correctly off the table (#3040 gated it off because lease-renewal blips crashloop a single-replica manager). The fix works under both the client-side and server-side apply paths. The blockers below are about the reasoning that ships with it, not the strategy value.

Blockers

B1: The documented root cause is disproven, and the wrong mechanism is baked into four places

The PR states that Helm's 3-way strategic merge "never owned rollingUpdate (it was server-defaulted), so Helm emits no delete directive for it — the patch only flips type to Recreate". That is not what happens: DeploymentSpec.Strategy carries patchStrategy:"retainKeys", the exact Kubernetes mechanism added for this transition.

Evidence: k8s.io/[email protected]/apps/v1/types.go:395 declares Strategy DeploymentStrategy \json:"strategy,omitempty" patchStrategy:"retainKeys"`. Running strategicpatch.CreateThreeWayMergePatchwith this repo's own apimachinery, usingoriginal= the v1.5.x manifest (no strategy),modified = the #3040 manifest (type: Recreate), current= the live object (server-defaulted25%/25%`):

patch : {"spec":{"strategy":{"$retainKeys":["type"],"type":"Recreate"}}}
merged: {"spec":{"strategy":{"type":"Recreate"}}}     <- rollingUpdate IS cleared, object is valid

So the client-side path cannot produce the observed error. Since the error is real, the path that produced it is server-side apply, which has no $retainKeys and leaves the server-defaulted, unowned rollingUpdate in place. That path is the default here: helm-controller v1.5.0 documents install.serverSideApply as "Defaults to true" and upgrade.serverSideApply as "Defaults to auto ... based on the release's previous usage", and this repo sets neither (internal/operator/package_reconciler.go populates only Timeout, Strategy, CRDs). It also explains why the failure shows up through a HelmRelease rather than a local helm upgrade.

Impact: this is not a wording nit — it changes the scope of the bug, and it is recorded permanently in the commit message plus three comments (template, patch, test). The PR's own theory is self-refuting: if a client-side merge could not clear rollingUpdate, then packages/system/ouroboros would be failing today for the same reason (see follow-up 1) — it is not. Under the server-side apply mechanism, ouroboros is latently broken. Please re-derive the mechanism and correct the commit message and the comments; the class of bug is "adding Recreate over a server-defaulted rollingUpdate under SSA", not "Helm 3-way merge".

B2: tests/leader_election_test.yaml never runs in CI, so the claimed regression coverage is a no-op

The PR states "a revert to Recreate fails the unit suite". It does not — this suite has never executed.

Evidence: hack/helm-unit-tests.sh runs a package's tests only when the package's own Makefile defines a test: target (guarded by make -C "$dir" -n test >/dev/null 2>&1), and hack/package.mk provides no such target. packages/system/opensearch-operator/Makefile has no test: target — make -C packages/system/opensearch-operator -n test returns "No rule to make target 'test'". 64 other packages define it; packages/system/etcd-operator/Makefile is the idiom:

test:
	helm unittest .

Impact: the only chart-layer guard this PR relies on is dead. Recreate can be reintroduced with fully green CI. This is a pre-existing gap inherited from #3040, but the PR touches this exact file and makes a load-bearing claim about it, so it belongs here — and the two-line fix revives the suite for free. This is a recurring shape in this repo (packages/system/linstor had the same dead suite until #2987 accidentally revived it).

B3: maxSurge: 0 does not provide Recreate's exclusivity, and the comment says it does

The comment claims maxSurge: 0 "tears the old manager down before the new one starts" and the PR body calls it "the exact guarantee #3040 wanted". The guarantee is weaker than that.

Evidence: rolloutRecreate gates scale-up on oldPodsRunning(newRS, oldRSs, podMap), which counts pods by non-terminal Status.Phase — a Terminating pod is still Running, so Recreate waits for the old pod to actually be gone. rolloutRolling has no such gate; NewRSNewReplicas computes currentPodCount from GetReplicaCountForReplicaSets, which sums rs.Spec.Replicas (desired), not live pods. So with replicas: 1, maxSurge: 0, maxUnavailable: 1 the old RS is scaled to 0, and on the next sync currentPodCount is already 0 and the new pod is created while the old pod is still terminating — bounded here by terminationGracePeriodSeconds: 10, during which the old manager drains in-flight reconciles with no lease coordination.

Impact: small in practice, and not a reason to change the approach — but it is the claim the whole design rests on, so it should state what is actually guaranteed. What maxSurge: 0 buys is that the old manager is sent SIGTERM before the new pod is created, rather than the pre-#3040 default where a surge pod started while the old manager was still fully live. That is a real improvement over RollingUpdate 25%, just not equivalence with Recreate.

Fix: reword to something like "maxSurge: 0 means the old manager is torn down before the new pod is created, rather than overlapping with it for a full startup as the default 25% surge would; a terminating old manager can still briefly overlap the new one within terminationGracePeriodSeconds."

Non-blocking follow-ups

  1. packages/system/ouroboros/charts/ouroboros/templates/controller-deployment.yaml:16-23 has the identical latent transition: it gates strategy: {type: Recreate} on controller.mode == "external-dns" and ships no strategy otherwise, while controller.mode is a documented, user-settable value defaulting to coredns. Toggling coredns -> external-dns on a live release adds Recreate over a server-defaulted rollingUpdate — the same shape this PR fixes. Out of scope here; worth its own issue.
  2. make update does not currently reproduce the committed vendored template. The patch's own hunks apply cleanly to pristine upstream 2.8.0, but the committed template also carries hand-edits not captured in patches/ (manager.extraEnv nindent 10 vs upstream 8, manager.imagePullSecrets nindent 8 vs 6, and a securityContext spacing difference), so a re-vendor would silently revert them. Pre-existing on main and not this PR's doing — but it makes the body's "the regenerated patch reproduces the committed template exactly" inaccurate.
  3. The body cites cert-manager as an in-repo precedent for maxSurge: 0 / maxUnavailable: 1. Its chart actually ships strategy: {}; maxSurge: 0 appears only in a commented-out example in values.yaml. The kubeovn precedent (central-deploy.yaml:13-15) is real — that one carries the point on its own.

# an upgrade; with leader election gated off they would both reconcile without lease
# coordination. Recreate closes that window at zero availability cost on one replica.
# Leader election is gated off on a single replica, so a rollout must never run two
# managers at once. maxSurge: 0 tears the old manager down before the new one starts

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.

maxSurge: 0 does not give this guarantee. rolloutRecreate gates scale-up on oldPodsRunning(), which counts pods by non-terminal Status.Phase — so Recreate waits for the old pod to actually be gone. rolloutRolling has no such gate, and NewRSNewReplicas derives currentPodCount from GetReplicaCountForReplicaSets, which sums rs.Spec.Replicas (desired), not live pods. Once the old RS is scaled to 0, the next sync creates the new pod while the old one is still terminating (up to terminationGracePeriodSeconds: 10 here), draining in-flight reconciles without lease coordination.

The strategy value is still the right call — please just state the real guarantee: the old manager is torn down before the new pod is created (unlike the default 25% surge, which starts a second pod while the old manager is fully live), with a brief terminating overlap still possible.

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.

Correct, and I A/B'd it on kind rather than argue: with maxSurge: 0, two pods coexisted within 1s of a rollout (old Terminating, new ContainerCreating); with Recreate, it held at one pod for the entire drain. Your reading of oldPodsRunning vs NewRSNewReplicas is exactly what the behaviour shows.

One refinement in your direction, though: I think "sent SIGTERM before the new pod is created" still overstates it, so I did not use that phrasing. The deployment controller only orders the ReplicaSet desired counts — it scales the old RS to 0 before it can scale the new one up (rolloutRolling tries scale-up first, and maxSurge: 0 structurally blocks it). Pod deletion, kubelet SIGTERM delivery and new-pod creation are all downstream and asynchronous, so the real-time ordering is incidental rather than guaranteed.

The comment now claims only the guaranteed part:

# maxSurge: 0 caps total pods at replicas, so the old manager is
# scaled down before the new one is scaled up (maxUnavailable: 1 permits the gap),
# unlike the default 25% surge which starts a second manager while the first is fully
# live. This is not Recreate's exclusivity: the old manager may still be draining
# in-flight reconciles (up to terminationGracePeriodSeconds) as the new one starts.

# managers at once. maxSurge: 0 tears the old manager down before the new one starts
# (maxUnavailable: 1 permits the brief gap) — Recreate-like exclusivity while keeping
# type RollingUpdate, so upgrades from a chart that shipped the default RollingUpdate
# never hit "spec.strategy.rollingUpdate: Forbidden ... when strategy type is Recreate".

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.

This attributes the failure to Helm's client-side 3-way merge, which is disproven. DeploymentSpec.Strategy carries patchStrategy:"retainKeys" (k8s.io/[email protected]/apps/v1/types.go:395), and CreateThreeWayMergePatch over (v1.5.x manifest, #3040 manifest, live 25%/25% object) emits {"spec":{"strategy":{"$retainKeys":["type"],"type":"Recreate"}}}, which merges to a valid {"strategy":{"type":"Recreate"}} — rollingUpdate is cleared.

The path that actually produces the error is server-side apply (no $retainKeys, and the server-defaulted rollingUpdate is unowned), which helm-controller v1.5.0 uses by default here. Worth correcting in the commit message too, since it is what a future reader will find.

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.

You're right, and this was my error. I checked it both ways before rewriting rather than take it on faith, and both agree with you.

CreateThreeWayMergePatch with this repo's apimachinery emits exactly what you quoted, and on kind a client-side kubectl apply of the v1.5.x manifest followed by the Recreate manifest succeeds, leaving {"type":"Recreate"} — rollingUpdate is cleared, so the client-side path cannot produce the error.

The SSA path reproduces it verbatim. After kubectl apply --server-side --field-manager=helm-controller of the strategy-less manifest, the apiserver defaults rollingUpdate to 25%/25% and managedFields shows the applier owning f:replicas, f:selector and f:template but not f:strategy; the same-manager apply of type: Recreate then fails with the exact production error. So the mechanism is the one you named, confirmed rather than inferred.

The commit message and all three comments now describe the SSA field-ownership mechanism, and the PR body carries a note crediting the correction. Your self-refutation argument was what made it airtight — ouroboros would have to be broken today under my theory, and it isn't. It is latently broken under the real one: filed as #3324.

- equal:
path: spec.strategy.type
value: Recreate
value: RollingUpdate

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.

This assertion never runs. hack/helm-unit-tests.sh executes a package's suite only when the package Makefile defines a test: target, and packages/system/opensearch-operator/Makefile does not (hack/package.mk does not supply one either) — so a revert to Recreate would not fail the unit suite, contrary to the PR body.

Two lines in the package Makefile revive it, matching the idiom in packages/system/etcd-operator/Makefile:

test:
	helm unittest .

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.

Confirmed and fixed. make -C packages/system/opensearch-operator -n test returned No rule to make target 'test', exactly as you said — the suite had never executed, so the coverage claim in the body was empty.

Added the target using your snippet (matching etcd-operator). The repo-wide runner now picks the package up, and I mutation-checked it rather than assume: reverting the template to Recreate fails the suite with unknown path spec.strategy.rollingUpdate.maxUnavailable. So the guard is real now.

I left .PHONY off to match the dominant idiom here (66 packages define a bare test:, 16 declare .PHONY). Unrelated, but noticed while checking: hack/package.mk:2 writes .PHONY=help show diff ... — an assignment, not the .PHONY: special target, so it is a no-op today.

@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

Thanks — this was a genuinely good review. I verified all three blockers and all three follow-ups independently instead of taking them on faith, and every one of them held. Details are in the inline replies; summary of what changed:

B1 — my root cause was wrong. Confirmed both ways: the client-side 3-way merge emits $retainKeys and clears rollingUpdate (so it cannot produce the error), while the SSA path reproduces it verbatim, with managedFields showing the applier never owned f:strategy. Commit message and all three comments re-derived around SSA; the body carries a correction note.

B2 — the suite had never run. Added the test: target; mutation-checked that a revert to Recreate now fails it.

B3 — you were right that maxSurge: 0 is not exclusivity, and an A/B on kind shows it plainly (2 pods within 1s vs 1 pod for the full drain). I went slightly further than your suggested wording — see the inline reply — because "sent SIGTERM before the new pod is created" also overstates what the controller guarantees.

Follow-ups:

  1. ouroboros — filed as ouroboros: switching controller.mode to external-dns on a live release fails under server-side apply #3324. Worth flagging: it likely needs a different fix from this PR, since external-dns mode genuinely depends on the exclusivity that maxSurge: 0 does not provide. The issue suggests owning the field in every mode so a Recreate transition stays legal, and is explicit that the toggle itself was not reproduced.
  2. make update drift — filed as opensearch-operator: make update does not reproduce the vendored template #3325. Investigating it inverted the framing slightly: the extraEnv hand-edit isn't just drift, it makes helm template fail outright when manager.extraEnv is set (nindent 10 under an env: list at 8), so a re-vendor would actually fix it. The other two are cosmetic. The body no longer claims the regeneration is exact.
  3. cert-manager — you're right, it ships strategy: {} behind a {{- with }}, so it renders no strategy at all. Dropped from the body; kubeovn carries the point alone, as you said.

The strategy value itself is unchanged, since your review agreed it was the right call. What changed is the reasoning shipped with it — which is the part that would have misled the next reader. The PR body now has a Verification table with the full kind reproduction (SSA fails / client-side succeeds / fix succeeds / rc-recovery succeeds / rollout A/B).

…reate

#3040 set the single-replica operator Deployment to strategy.type:
Recreate to avoid two uncoordinated managers overlapping during a
rollout while leader election is gated off. That breaks Helm upgrades of
existing releases: the upgrade fails validation immediately,
helm-controller retries it (x19 over 11m in Actions run 29480724982),
and the HelmRelease never becomes Ready:

  Deployment.apps "opensearch-operator-controller-manager" is invalid:
  spec.strategy.rollingUpdate: Forbidden: may not be specified when
  strategy type is Recreate

The trigger is server-side apply. A v1.5.x release shipped no strategy
at all, so the apiserver defaulted spec.strategy.rollingUpdate to
25%/25%, and the helm-controller field manager never owned that block.
helm-controller applies server-side by default (Install.ServerSideApply
defaults to true, Upgrade.ServerSideApply to "auto"; the operator's
package_reconciler.go sets neither), and SSA does not remove a field the
applier never owned merely because the new intent omits it -- so sending
type: Recreate leaves the defaulted rollingUpdate in place and the
apiserver rejects the merged object.

The client-side path is unaffected: DeploymentSpec.Strategy carries
patchStrategy:"retainKeys", so a 3-way merge emits
{"$retainKeys":["type"],"type":"Recreate"} and does clear rollingUpdate.
That is why this surfaces through helm-controller's server-side apply
but not a local 'helm upgrade'.

Keep type: RollingUpdate and set rollingUpdate.maxSurge: 0 /
maxUnavailable: 1 on the single-replica path instead. maxSurge: 0 caps
total pods at replicas, so the old manager is scaled down before the new
one is scaled up, rather than starting a surge pod while the old manager
is still fully live as the default 25% would. It is not Recreate's
exclusivity -- the deployment controller orders the ReplicaSet desired
counts, not the pod lifecycles, so a draining old manager can still
overlap the new pod -- but it keeps the type transition legal on every
apply path, and recovers releases already on an rc build with Recreate.
This matches the existing kube-ovn idiom (central-deploy.yaml).

Also add a 'test' target to the package Makefile:
hack/helm-unit-tests.sh runs a package's suite only when its Makefile
defines one, so tests/leader_election_test.yaml had never executed.

Multi-replica installs keep the default RollingUpdate (leader election
on), unchanged. The change is carried in patches/leaderElection.diff so
it survives 'make update'.

Verified on kind v1.33.1: SSA of the v1.5.x manifest followed by SSA of
the Recreate manifest reproduces the error, while the same pair applied
client-side succeeds; SSA of the maxSurge:0 manifest succeeds both from
v1.5.x and from a live Recreate Deployment.

Assisted-By: Claude <[email protected]>
Signed-off-by: Myasnikov Daniil <[email protected]>
@myasnikovdaniil
myasnikovdaniil force-pushed the fix/opensearch-operator-upgrade-strategy branch from 518e227 to 955f7cc Compare July 20, 2026 06:34

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict

LGTM with non-blocking notes

The change replaces strategy.type: Recreate with RollingUpdate / maxSurge:0 / maxUnavailable:1 on the single-replica path; the direction is correct, the render corners are valid, the regression test is now wired in and non-vacuous, and the only residual items are verification that cannot be executed in a hermetic static review.

Caveats

  • SSA upgrade merge is the crux of the fix and is not statically verifiable. The bug (spec.strategy.rollingUpdate: Forbidden when type: Recreate merges onto an apiserver-defaulted, helm-controller-unowned rollingUpdate block under server-side apply) and the two recovery paths (upgrade from no-strategy v1.5.x, and recovery from an rc build already on Recreate) require a live apiserver. This review is hermetic (no cluster). helm template renders a valid object on every corner and the SSA field-ownership mechanism described in the PR body is internally sound, but the actual Recreate → RollingUpdate and no-strategy → RollingUpdate maxSurge:0 SSA merges are relied upon from the author's kind v1.33.1 reproduction, not independently executed here. Recommend the release-upgrade E2E lane (#3276) as the authoritative gate.
  • Residual overlap window is a documented, accepted trade-off. maxSurge:0 does not give Recreate's exclusivity: a terminating old manager can overlap the new pod for up to terminationGracePeriodSeconds (10s here) with no lease coordination, since leader election is gated off on one replica. This is now correctly stated in both the template comment and the PR body. Impact is a ~10s double-reconcile risk on a single-replica operator, far less severe than the permanently-failing upgrade it replaces. Non-blocking.
  • charts-direct-edit invariant is mitigated. The edit under packages/system/opensearch-operator/charts/.../opensearch-operator-controller-manager-deployment.yaml is captured in patches/leaderElection.diff and the Makefile update target re-applies it. Template and patch were edited symmetrically in this diff, so the change survives make update.

Verification performed

  • helm unittest . green baseline (2 tests). Non-vacuity confirmed by mutation: reverting the template to type: Recreate turns the suite RED (spec.strategy.rollingUpdate.maxSurge / maxUnavailable unknown path). The render-layer guard genuinely protects against a naive revert.
  • Config corners rendered: default and replicaCount=1 → RollingUpdate maxSurge:0/maxUnavailable:1; replicaCount=2/3 → strategy block entirely absent (apiserver default) with --leader-elect present. Both branches valid.
  • Precedent packages/system/kubeovn/charts/kube-ovn/templates/central-deploy.yaml:10-14 confirmed to use the identical RollingUpdate maxSurge:0/maxUnavailable:1 idiom.
  • Round-trip check: #3040 set Recreate; this PR reverts it. The reversal premise is re-verified, not blindly flipped — the overlap window #3040 cared about is narrowed via maxSurge:0 rather than fully reverting to the 25% default, and Recreate is left because of a hard SSA constraint. Justified.

Recommended follow-ups

  • Land the fix behind the release-upgrade E2E lane (#3276) which reproduces the SSA merge on a real apiserver — that is the authoritative behavioural regression test, since helm-unittest cannot exercise SSA.
  • Author already filed #3324 (ouroboros has the same latent Recreate-on-mode-toggle shape) and #3325 (make update does not fully reproduce this package's vendored template). Both are correctly scoped out of this PR.

@myasnikovdaniil
myasnikovdaniil dismissed Aleksei Sviridkin (lexfrei)’s stale review July 21, 2026 12:16

lexfrei can't update his review and I addressed his findings

@myasnikovdaniil
myasnikovdaniil merged commit 741a5ac into main Jul 21, 2026
17 checks passed
@myasnikovdaniil
myasnikovdaniil deleted the fix/opensearch-operator-upgrade-strategy branch July 21, 2026 12:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/platform Issues or PRs related to platform infrastructure (bundle, flux, talos, installer) area/uncategorized PR auto-labeler could not map title scope to a known area/*; please review kind/bug Categorizes issue or PR as related to a bug size/M This PR changes 30-99 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants