Skip to content

fix(installer): make operator readiness honest in the chart and the install gate - #3466

Merged
Aleksei Sviridkin (lexfrei) merged 2 commits into
mainfrom
fix/e2e-operator-rollout-gate
Aug 6, 2026
Merged

fix(installer): make operator readiness honest in the chart and the install gate#3466
Aleksei Sviridkin (lexfrei) merged 2 commits into
mainfrom
fix/e2e-operator-rollout-gate

Conversation

@lexfrei

@lexfrei Aleksei Sviridkin (lexfrei) commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

What this PR does

The operator Deployment ran replicas: 1 with maxUnavailable: 1, so the Deployment controller took the minimum available replica count as 1 - 1 = 0 and the Available condition reported True with no running pod at all. Measured against an operator whose image tag does not exist: the condition was True with reason: MinimumReplicasAvailable while the only pod sat in ImagePullBackOff, unavailableReplicas: 1, and no availableReplicas key in the status.

That broke two separate things, so this fixes both.

The E2E install gate asked kubectl wait --for=condition=Available and passed in 156 ms against that dead operator. The install then carried on and died about two minutes later on the packages.cozystack.io CRD wait further down the file, which sends you after CRD installation when the operator never started. It now uses kubectl rollout status, which reads updated and available replicas directly, and dumps the deployment, pods, operator logs and namespace events when it fails. This half is deliberately independent of the chart: rollout status is correct under any rollout strategy, so a later change to that strategy cannot quietly make the gate vacuous again.

The chart is the other half, and fixing the gate does not cover it. Available is read by more than this test, and until the strategy changes an alert on that condition, a health dashboard, or a person reading kubectl get deploy all show green while the operator is dead. The strategy moves to maxSurge: 1 / maxUnavailable: 0, which restores the ordinary meaning of the condition. Kubernetes refuses maxSurge: 0 and maxUnavailable: 0 together, so the surge has to move for the unavailability to reach zero.

That pair is worth reading twice, because it is not a configuration I picked. At replicas: 1 the Kubernetes default of 25% / 25% resolves to exactly maxSurge: 1 and maxUnavailable: 0, since the surge rounds up and the unavailability rounds down. What this PR does is stop overriding the default, not introduce a tuning of its own, and the risk of the change should be judged on that basis.

Moving the surge is safe here, and I checked rather than assumed, because the pair came from somewhere. It was carried over from the earlier cozystack Deployment, where a sidecar published host port 8123 and hostNetwork left the surge pod unschedulable on a single-node cluster. It got here in two steps, which is worth knowing if you go looking for the reason: by the commit before this file existed the sidecar was already gone and no port was declared, yet the pair stayed. It had outlived its own reason before it was ever copied, which is why there is no justification to find in this file's own history. On a single-node cluster the surge pod is scheduled next to the outgoing one and the rollout completes; adding a declared container port reproduces the original didn't have free ports for the requested pod ports failure exactly. Nothing else in the pod holds a port either: metrics and health probes are bound to 0, and the configured webhook server never starts because the operator registers no webhook and never asks the manager for the server.

The surge makes leader election load-bearing, though it covers less of the overlap than it first appears. While maxSurge: 0 capped total pods at replicas a second manager process could not exist, so --leader-elect=true was defence in depth. With a surge pod the lease is what keeps the incoming manager's reconcile loops off the cluster while the outgoing one is still leader, and because there is no readiness probe the incoming pod counts as available as soon as its container starts, so that overlap covers the whole image pull. It does not cover everything: the operator installs CRDs, installs Flux and creates the platform PackageSource before mgr.Start(), and the lease is only taken inside mgr.Start(), so those three privileged installs run outside leader election entirely. They are safe because both pods write the same objects through server-side apply under the same field manager, which is a different guarantee from the lease. The k8s-await-election wrapper this operator replaced did hold the whole process until it won leadership, so if that property sounds familiar it belongs to the predecessor rather than to this binary.

The sibling opensearch-operator chart turns leader election off at one replica for a real reason: a transient lease-renewal blip makes controller-runtime exit the process, which at one replica reads as a crashlooping Deployment. This operator installs first, when the apiserver is at its shakiest, so that scenario applies here too. I am accepting the risk knowingly, because after maxSurge: 1 the alternative is two managers reconciling the same cluster, which is worse. The flag is pinned in the unit test so it cannot be turned off without the test going red.

One consequence is permanent, so I am writing it down now instead of leaving it to be found in a year. Because any bound port breaks the surge pod under hostNetwork, an httpGet readiness probe is off the table for this Deployment for good and only an exec probe could ever work. So Available still answers "a container started", not "the operator is healthy": an operator that starts and then dies partway through installing CRDs is caught by the CRD waits below the gate, not by the gate itself.

The surge also opens one new window. The operator installs CRDs and Flux before the manager starts, which puts that work outside leader election, so the incoming pod runs those applies while the outgoing pod is still leader. Both apply the same objects server-side and the outgoing pod exits moments later.

--wait is dropped from the helm step in the same change. Helm decides a Deployment is ready with the same replicas - maxUnavailable arithmetic, so on the old strategy it was satisfied at zero ready pods and gated nothing, which is why the broken install got past it as well. Once the strategy is honest it would gate for real, but on its own 2m budget, and it would fail the install before the rollout gate ran and hide the diagnostics behind a bare Helm timeout. Hooks are waited for with or without the flag, and the release otherwise carries only the Deployment and its RBAC objects, so the rollout gate is the better single place to decide the operator is up.

A helm unittest suite pins the strategy and all four preconditions the surge makes load-bearing: no container or initContainer declares a port, both listener arguments stay disabled, leader election stays on, and no probe uses httpGet. That last one follows from the two disabled listeners leaving no port for a probe to reach, and it is the worst of the four to get wrong, because the others wedge an upgrade while a livenessProbe against a disabled listener crashloops the pod on every cluster, fresh install included. Only the httpGet field is forbidden rather than the probes themselves, so an exec probe stays available. All four run against all three chart variants, because the template branches on variant inside the container spec and a port added under one branch would render for that variant alone.

I checked the suite fails for the right reasons instead of trusting it. Reverting the strategy, declaring a containerPort on the operator container, declaring one under a single variant branch, declaring one on an initContainer, pointing a bind-address at a real address, turning leader election off, and adding a readiness, liveness or startup probe with httpGet each turns it red on its own assert. Inserting a container ahead of the operator trips the container-name pin before the argument asserts can start describing the wrong container. An exec probe correctly stays green.

The rationale is split by audience. What an operator applying the rendered manifest has to respect stays in plain comments and travels into _out/assets/cozystack-operator-*.yaml: why the strategy is what it is, that no port may be declared or bound, and that leader election has to stay on. Everything that is only about this repository moves into a {{/* */}} template comment that stops at the chart boundary. That covers the pointer to the test and which assert pins what, where the strategy was copied from, why the webhook server on 9443 is not a third port binder, and why opensearch-operator reaches the opposite conclusion on leader election. The other templates in this chart already keep maintainer notes that way.

Screenshots

Not applicable, no UI changes.

Downstream repositories

I left the boxes empty on purpose rather than to skip the question, because the honest answer is not one of them and someone else should make the call.

Walking the trigger map in docs/agents/contributing.md, nothing fires. The hack/ triggers name hack/e2e-prepare-cluster.bats, hack/package.mk, hack/update-crd.sh and moves or renames under hack/, and this PR only edits the body of hack/e2e-install-cozystack.bats. The ansible-cozystack trigger is renaming an object the role waits on, and deployment/cozystack-operator keeps its name.

The behaviour that role sees does change, though. It runs kubectl wait deployment/cozystack-operator --for=condition=Available, the same vacuous check this PR replaces here, so the chart change turns its wait from one that returns instantly into one that means something. Its cozystack_operator_wait_timeout defaults to 300 seconds, the same budget I gave the gate here, so a cold image pull fits and I do not think anything needs to change over there. No follow-up PR seemed warranted for a change that only makes an existing check work, but that is a judgement call rather than a trigger, which is why the boxes are empty.

Release note

fix(installer): the cozystack-operator Deployment no longer reports Available with zero running pods. It ran replicas: 1 with maxUnavailable: 1, which made the minimum available replica count 0, so the condition stayed True while the operator was dead and anything reading it, including the E2E install gate, saw green. The rollout strategy is now maxSurge: 1 / maxUnavailable: 0 and the install gate waits for the rollout instead of the condition.

Summary by CodeRabbit

  • Bug Fixes

    • Improved operator installation checks by monitoring deployment rollouts and providing deployment, pod, log, and event details when a rollout fails.
    • Updated operator upgrades to maintain availability during rollout, including in single-node environments.
    • Documented rollout requirements and behavior across supported installation configurations.
  • Tests

    • Added coverage for rollout settings and upgrade compatibility across supported installation variants.
    • Clarified Helm unit-test directory coverage documentation.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@coderabbitai

coderabbitai Bot commented Jul 27, 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 Plus

Run ID: 3c82dd03-2edd-4fd0-be65-c051afc57e05

📥 Commits

Reviewing files that changed from the base of the PR and between df157da and 115d9bb.

📒 Files selected for processing (4)
  • hack/e2e-install-cozystack.bats
  • hack/helm-unit-tests.sh
  • packages/core/installer/templates/cozystack-operator.yaml
  • packages/core/installer/tests/rollout_strategy_test.yaml
🚧 Files skipped from review as they are similar to previous changes (3)
  • hack/helm-unit-tests.sh
  • hack/e2e-install-cozystack.bats
  • packages/core/installer/tests/rollout_strategy_test.yaml

📝 Walkthrough

Walkthrough

The operator Deployment now uses maxSurge: 1 and maxUnavailable: 0. Helm tests cover rollout prerequisites across chart variants. The installer uses rollout status and emits diagnostics when readiness fails.

Changes

Operator rollout

Layer / File(s) Summary
Rollout strategy and Helm validation
packages/core/installer/templates/cozystack-operator.yaml, packages/core/installer/tests/rollout_strategy_test.yaml, hack/helm-unit-tests.sh
The operator Deployment uses surge-based rolling updates. Helm tests validate rollout settings and related networking, argument, leader-election, and probe configuration across variants. Comments document rollout constraints and test-directory coverage.
Installer rollout readiness diagnostics
hack/e2e-install-cozystack.bats
The installer no longer waits during the Helm upgrade. kubectl rollout status performs readiness gating and prints Deployment, pod, log, and event diagnostics when rollout fails.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested reviewers: androndo

Sequence Diagram(s)

sequenceDiagram
  participant InstallerTest
  participant Helm
  participant Kubernetes
  participant OperatorPods
  InstallerTest->>Helm: upgrade installer without --wait
  Helm->>Kubernetes: apply operator Deployment
  InstallerTest->>Kubernetes: check rollout status with 5-minute timeout
  Kubernetes->>OperatorPods: observe Deployment rollout
  Kubernetes-->>InstallerTest: return rollout result
  InstallerTest->>Kubernetes: collect YAML, pod details, logs, and events on failure
Loading
🚥 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 changes to operator readiness in the Helm chart and install gate.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/e2e-operator-rollout-gate

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 size/S This PR changes 10-29 lines, ignoring generated files area/testing Issues or PRs related to testing (e2e, bats, unit tests) kind/bug Categorizes issue or PR as related to a bug labels Jul 27, 2026
@lexfrei
Aleksei Sviridkin (lexfrei) force-pushed the fix/e2e-operator-rollout-gate branch from f0981b2 to 4fe7123 Compare July 27, 2026 22:41
@github-actions github-actions Bot added size/M This PR changes 30-99 lines, ignoring generated files and removed size/S This PR changes 10-29 lines, ignoring generated files labels Jul 27, 2026
@lexfrei Aleksei Sviridkin (lexfrei) changed the title fix(tests): wait for operator rollout instead of condition=Available fix(installer): make operator readiness honest in the chart and the install gate Jul 27, 2026
@lexfrei Aleksei Sviridkin (lexfrei) added the area/platform Issues or PRs related to platform infrastructure (bundle, flux, talos, installer) label Jul 27, 2026
@lexfrei
Aleksei Sviridkin (lexfrei) force-pushed the fix/e2e-operator-rollout-gate branch from 4fe7123 to cceed14 Compare July 28, 2026 11:07
@github-actions github-actions Bot added size/L This PR changes 100-499 lines, ignoring generated files and removed size/M This PR changes 30-99 lines, ignoring generated files labels Jul 28, 2026
@lexfrei
Aleksei Sviridkin (lexfrei) marked this pull request as ready for review July 28, 2026 11:11
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@myasnikovdaniil myasnikovdaniil 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.

LGTM — both halves are correct and independently verified: the chart change is safe because every precondition the surge relies on holds in this binary, and the suite that pins them is mutation-proof and provably gating in CI.

Business context: the operator Deployment ran replicas: 1 with maxUnavailable: 1, so the minimum available replica count was 0 and Available reported True with no running pod — making the E2E install gate vacuous and hiding a dead operator from every other consumer of that condition.

I verified the four claims the change actually rests on rather than taking them from the description.

  • Nothing in this pod binds a port. --metrics-bind-address=0 and --health-probe-bind-address=0 both reach controller-runtime's disabled path, and the webhook server on 9443 is never started: the only two GetWebhookServer().Register(...) call sites live in the flux-shard-operator and lineage-controller-webhook binaries, not in cozystack-operator, and controller-runtime adds that server as a runnable only inside GetWebhookServer(). So the surge pod neither fails to schedule nor hits EADDRINUSE.
  • The install steps are safe under the concurrency maxSurge: 1 newly permits. crdinstall.Install, fluxinstall.applyObjects and installPlatformPackageSource all use client.Apply with FieldManager: "cozystack-operator" and Force: true. installPlatformSourceResource is the one GetCreate/Update-with-observed-resourceVersion read-modify-write, and it fails loudly into a restart rather than leaving a partial object — exactly as the note in the template says.
  • The reconciler field managers are as documented: cozystack-packagesource-controller (internal/operator/packagesource_reconciler.go:398) and cozystack-package-controller (internal/operator/package_reconciler.go:901), so the install-versus-reconcile seam is separated by owner as claimed.
  • The strategy pair's provenance is as stated. c43db3b7c introduced this file already carrying maxSurge: 0 / maxUnavailable: 1, and packages/core/installer/templates/cozystack.yaml carried the same pair alongside hostNetwork: true and the 8123 sidecar before it. The pair did outlive its reason before being copied.

On the suite: it is not vacuous. Thirteen mutations against it — a port on the operator container, a port under a single variant branch only, a port on an initContainer, httpGet on each of the three probe kinds, the reverted strategy, --leader-elect=false, each bind-address pointed at a real address, a container inserted ahead of the operator, replicas: 2, hostNetwork: false — each turn it red on its own assert, and an exec probe correctly stays green. The containers[*] wildcard in the negated notExists asserts does discriminate, which is worth stating explicitly because a negated containsDocument in this repo does not. The suite also genuinely gates: packages/core is already in the hack/helm-unit-tests.sh loop, packages/core/installer has a test: target, and the passing checks job logs Running tests in packages/core/installer followed by PASS cozystack-operator rollout strategy.

Dropping --wait is right and loses nothing: Helm's deploymentReady uses the same replicas - maxUnavailable arithmetic, so it was satisfied at zero ready pods, hooks are waited for regardless, and this BATS step is the only cozy-installer install in the tree. The new gate is also a direct match for the idiom already in this file at the incloud-web-gatekeeper rollout.

Non-blocking follow-ups

  1. packages/system/opensearch-operator still has the exact condition this PR removes here. At the default manager.replicaCount: 1 its controller-manager Deployment renders maxSurge: 0 / maxUnavailable: 1, so the minimum available replica count is 0 and Available reports True with no running pod. It is out of this PR's scope and its maxSurge: 0 is deliberate — leader election is gated off at one replica precisely so a second manager cannot exist — so the fix is not the same one applied here, and the honest-Available gain has to be weighed against the lease-renewal-blip crashloop that chart is avoiding. Worth tracking rather than resolving inline, especially since the note added here reasons about that chart without observing that it inherits the bug.
  2. On a genuinely dead operator the gate now takes the full 5m to fail, where the vacuous check surfaced the failure at roughly 2m via the CRD wait below. kubectl rollout status only exits early on Progressing with reason ProgressDeadlineExceeded, and this Deployment leaves progressDeadlineSeconds at the default 600s, so that fast path cannot fire inside a 300s budget. Noting it as a tradeoff rather than a request — see the inline comment.
  3. The downstream behaviour change is real and unretried. roles/cozystack/tasks/main.yml waits with --for=condition=Available --timeout={{ cozystack_operator_wait_timeout }}s (default 300) and, unlike the CRD wait immediately below it, carries no retries:. The judgement that 300s covers a cold pull is reasonable; that repo's CI is where it would show up on the next cozy-installer bump.
  4. The suite pins spec.strategy.rollingUpdate.* but not spec.strategy.type — see the inline comment. This was the one mutation of thirteen that survived.

Checked and dismissed, recorded so the dismissals can be argued with: the server-side-apply Recreate trap does not apply, since type stays RollingUpdate and only values change inside a block that was always explicit, so f:strategy is owned under SSA and the client-side path is covered by DeploymentSpec.Strategy's retainKeys; the 9443 webhook server is not a third port binder; two pods running the privileged installs concurrently cannot leave a corrupt object; and no other cozystack-owned Deployment shares this pattern — the remaining maxUnavailable: 1 hits are PodDisruptionBudgets (http-cache, mongodb) or MachineDeployments (kubernetes, kubernetes-nodes), with opensearch-operator the one real instance, raised above.

One scope note on the evidence: E2E covers only a fresh install, which performs no rollout, so it does not exercise the surge path at all. That path rests on the four preconditions above rather than on CI.

# This is the only readiness gate for the operator, since the helm step above
# drops --wait, so the timeout must cover scheduling and a cold pull of an
# image that is not in the prepull set.
kubectl rollout status deployment/cozystack-operator -n cozy-system --timeout=5m || {

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.

rollout status only exits early on Progressing with reason ProgressDeadlineExceeded, and this Deployment leaves progressDeadlineSeconds at the default 600s — so inside a 300s budget that fast path can never fire, and a dead operator burns the full 5m before failing, where the vacuous check surfaced it at roughly 2m via the CRD wait below. Setting progressDeadlineSeconds under the gate's timeout would restore fail-fast and produce exceeded its progress deadline instead of a generic wait.

Raising it as a tradeoff rather than a request: the incloud-web-gatekeeper gate lower in this file makes the same choice, so changing only one would split the pattern.

path: kind
value: Deployment
- equal:
path: spec.strategy.rollingUpdate.maxSurge

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.

spec.strategy.type is not pinned. Changing it to Recreate while leaving this rollingUpdate block in place keeps all nine tests green — the only mutation of thirteen that survives this suite.

Impact is low, because the apiserver rejects that combination (spec.strategy.rollingUpdate: Forbidden), so it fails loudly at install rather than silently. equal spec.strategy.type: RollingUpdate would close it.

condition=Available answers whether the Deployment sits within the
slack its rollout strategy allows, not whether the operator came up.
That is the wrong question for an install gate, and it stays wrong
whatever the strategy is: any maxUnavailable equal to replicas drives
the minimum available replica count to 0 and the condition then reports
True with no running pod. Measured against an operator stuck in
ImagePullBackOff, the old check passed in 156 ms, and the install went
on to fail two minutes later on the CRD wait, which pointed the
investigation at the wrong component.

rollout status reads updated and available replicas directly and only
succeeds once the updated pod is available, which holds under any
rollout strategy. Dump the deployment, pods, operator logs and events
on failure so the cause is legible where it is detected.

Drop --wait from the helm step. Helm 3 decides a Deployment is ready
with the same replicas - maxUnavailable arithmetic, so on the current
strategy it is satisfied at zero ready pods and gates nothing; were the
strategy tightened it would instead fail the install on its own 2m
budget before this gate runs, hiding the diagnostics behind a bare Helm
timeout. That arithmetic is specific to Helm 3, since Helm 4 reworked
--wait into a wait strategy with a different default, so treat the
reason as version-bound. The conclusion is not: one gate that reports
what it saw is better than two that can disagree about whose budget
expired first, whichever major is installed. Hooks are waited for with
or without the flag. The 5m budget here covers scheduling and a cold
pull of an image that is not in the prepull set.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 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 `@packages/core/installer/tests/rollout_strategy_test.yaml`:
- Around line 86-107: Expand the rollout strategy assertions for the Deployment
container args so metrics and health probe bind-address flags accept only the
exact values --metrics-bind-address=0 and --health-probe-bind-address=0. Reject
every other value, including duplicate flag occurrences, using the installed
helm-unittest assertion API and verify its regex support before relying on it.
🪄 Autofix

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 82283170-3598-40c2-bbf7-9fb39d75802d

📥 Commits

Reviewing files that changed from the base of the PR and between ee26a50 and 338a925.

📒 Files selected for processing (4)
  • hack/e2e-install-cozystack.bats
  • hack/helm-unit-tests.sh
  • packages/core/installer/templates/cozystack-operator.yaml
  • packages/core/installer/tests/rollout_strategy_test.yaml
🚧 Files skipped from review as they are similar to previous changes (2)
  • hack/helm-unit-tests.sh
  • hack/e2e-install-cozystack.bats

Comment on lines +86 to +107
- contains:
path: spec.template.spec.containers[0].args
content: --metrics-bind-address=0
documentSelector:
path: kind
value: Deployment
- notContains:
path: spec.template.spec.containers[0].args
content: --metrics-bind-address=:8080
documentSelector:
path: kind
value: Deployment
- contains:
path: spec.template.spec.containers[0].args
content: --health-probe-bind-address=0
documentSelector:
path: kind
value: Deployment
- notContains:
path: spec.template.spec.containers[0].args
content: --health-probe-bind-address=:8081
documentSelector:

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== repo files =="
git ls-files | sed -n '1,120p'

echo "== target file outline/size =="
if [ -f packages/core/installer/tests/rollout_strategy_test.yaml ]; then
  wc -l packages/core/installer/tests/rollout_strategy_test.yaml
  sed -n '1,180p' packages/core/installer/tests/rollout_strategy_test.yaml
else
  echo "target file not found"
fi

echo "== locate rollout_strategy_test.yaml =="
fd -a 'rollout_strategy_test\.ya?ml|rollout.*strategy.*test' .

Repository: cozystack/cozystack

Length of output: 9831


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== package manifests mentioning chart/unittest =="
for f in $(git ls-files | rg '(^|/)(Chart\.yaml|Chart\.lock|values\.ya?ml|go\.(mod|sum)|helm-unittest|helm-unit|unittest)'); do
  echo "--- $f"
  sed -n '1,220p' "$f" | rg -n "helm|unittest|chart|go\.mod|helm-unittest|github.com/Quviq|gopkg.in/yaml|modules" || true
done

echo "== locate rollout_strategy assertions in templates/values =="
rg -n "metrics-bind-address|health-probe-bind-address|leader-elect|cozystackOperator\.variant|chartAppVersion|helm" packages/core/installer packages/core 2>/dev/null | sed -n '1,240p'

echo "== Helm unittest syntax examples in repo if any =="
fd -i '.*test.*ya?ml$' . | sed -n '1,80p' | xargs -r rg -n "regex|contains|notContains|asserts|helm-unittest|unstable|match" | sed -n '1,240p'

Repository: cozystack/cozystack

Length of output: 50376


🌐 Web query:

github.com/Quviq/helm-unittest regex contains assertion argument notContains

💡 Result:

In the helm-unittest framework, the contains and notContains assertions are used specifically for validating that an array at a given JSONPath contains (or does not contain) a specific piece of content [1][2][3]. These assertions do not inherently support regex matching for the content argument [1][2][3]. If you need to perform regex-based validation against values, you should use the dedicated regex assertions provided by the framework [1][2][3]: 1. matchRegex: Asserts that the value at a specified path matches a given regex pattern [1][2][3]. 2. notMatchRegex: Asserts that the value at a specified path does not match a given regex pattern [1][2][3]. For more complex scenarios, such as validating that an array does not contain any items matching a specific regex, the framework does not provide a direct notContainsRegex assertion [1][2][3]. Users typically handle such cases by checking the structure of the data or using snapshot testing, which allows for regex assertions within the snapshot validation process [4][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail
go_version="$(go version | cut -d' ' -f3 || true)"
echo "== Go version =="
echo "${go_version:-not available}"

if [ -n "${go_version:-}" ]; then
  tmp="$(mktemp -d)"
  export GOMODCACHE="$tmp/modcache"
  export GOPATH="$tmp/gopath"
  cat > "$tmp/args.go" <<'EOF'
package main

import (
	"bytes"
	"fmt"
	"strings"

	"github.com/spf13/pflag"
)

func flags(args []string) (map[string]string, bool) {
	var metrics, health string
	fs := pflag.NewFlagSet("probe", pflag.ContinueOnError)
	fs.SetOutput(bytes.NewBuffer(nil))
	fs.StringVar(&metrics, "metrics-bind-address", "x", "metrics bind address")
	fs.StringVar(&health, "health-probe-bind-address", "y", "health probe bind address")
	var n bool
	fs.BoolVar(&n, "leader-elect", true, "leader elect")
	err := fs.Parse(args)
	return map[string]string{"metrics": metrics, "health": health}, err == nil
}

func main() {
	vals := []string{
		"--metrics-bind-address=0 --health-probe-bind-address=:8081",
		"--health-probe-bind-address=:8081 --metrics-bind-address=0",
		"--metrics-bind-address=0 --metrics-bind-address=:9090",
		"--health-probe-bind-address=0 --health-probe-bind-address=:9091",
		"--metrics-bind-address=:9090 --health-probe-bind-address=:9091",
	}
	for _, s := range vals {
		args := strings.Fields(s)
		fs := pflag.NewFlagSet("probe", pflag.ContinueOnError)
		fs.SetOutput(bytes.NewBuffer(nil))
		var metrics, health string
		fs.StringVar(&metrics, "metrics-bind-address", "x", "metrics bind address")
		fs.StringVar(&health, "health-probe-bind-address", "y", "health probe bind address")
		var n bool
		fs.BoolVar(&n, "leader-elect", true, "leader elect")
		err := fs.Parse(args)
		fmt.Printf("%s -> metrics=%q health=%q leader=%v ok=%v err=%v\n", strings.Join(args, " "), metrics, health, n, fs.Parsed(), err)
	}
}
EOF
  go mod init probe >/dev/null
  go get github.com/spf13/pflag@v1.0.9 >/dev/null
  go run "$tmp/args.go"
  go mod tidy >/dev/null 2>&1 || true
fi

Repository: cozystack/cozystack

Length of output: 228


🏁 Script executed:

#!/bin/bash
set -euo pipefail
tmp="$(mktemp -d)"
export GOMODCACHE="$tmp/modcache"
export GOPATH="$tmp/gopath"
export GOFLAGS="-mod=mod"

cat > "$tmp/go.mod" <<'EOF'
module probe
go 1.26
EOF

cat > "$tmp/args.go" <<'EOF'
package main

import (
	"bytes"
	"fmt"
	"strings"

	"github.com/spf13/pflag"
)

func parse(args []string) {
	var metrics, health string
	fs := pflag.NewFlagSet("probe", pflag.ContinueOnError)
	fs.SetOutput(bytes.NewBuffer(nil))
	fs.StringVar(&metrics, "metrics-bind-address", "x", "metrics bind address")
	fs.StringVar(&health, "health-probe-bind-address", "y", "health probe bind address")
	var n bool
	fs.BoolVar(&n, "leader-elect", true, "leader elect")
	err := fs.Parse(args)
	fmt.Printf("%s -> metrics=%q health=%q leader=%v ok=%v err=%v\n", strings.Join(args, " "), metrics, health, n, fs.Parsed(), err)
}

func main() {
	vals := []string{
		"--metrics-bind-address=0 --health-probe-bind-address=:8081",
		"--health-probe-bind-address=:8081 --metrics-bind-address=0",
		"--metrics-bind-address=0 --metrics-bind-address=:9090",
		"--health-probe-bind-address=0 --health-probe-bind-address=:9091",
		"--metrics-bind-address=:9090 --health-probe-bind-address=:9091",
	}
	for _, s := range vals {
		parse(strings.Fields(s))
	}
}
EOF

go run "$tmp/args.go"

Repository: cozystack/cozystack

Length of output: 315


🌐 Web query:

github.com/spf13/pflag StringVar duplicate flags last value

💡 Result:

In the spf13/pflag library, when using standard flag types like StringVar (or its equivalents like String), providing the same flag multiple times on the command line typically results in the last provided value overwriting any previous values [1]. The pflag library behaves similarly to the standard library's flag package in this regard for basic, non-slice flag types [1]. When the parser encounters a flag, it calls the Set method of that flag's underlying value, which updates the bound variable with the new value [2][3]. If the same flag name appears multiple times, the subsequent calls to Set simply overwrite the variable's previous content with the new value. Key points to consider: - Default Behavior: For single-value types (like string, int, bool), pflag allows multiple occurrences of the same flag, with the final occurrence determining the final value [1]. - Slice/Array Types: If you need to capture multiple values for the same flag, you should use specialized types such as StringSlice, StringArray, or similar slice-based flag types provided by the library [1]. For these types, the library is designed to append values rather than overwrite them [1]. - Error Handling: Historically, there have been discussions and requests to optionally error out if a non-slice flag is provided more than once, but it is not the default behavior of the library [1]. If you are encountering unexpected behavior, ensure that you are using the correct flag type for your requirements (e.g., StringSlice instead of StringVar if you intend to accumulate values).

Citations:


Reject every non-disabled bind address.

These assertions only reject :8080 and :8081. Since duplicate pflag.StringVar values use the last occurrence, --metrics-bind-address=0 --metrics-bind-address=:9090 or --health-probe-bind-address=0 --health-probe-bind-address=:9091 still bind a listener on the host network. Reject every metrics and health bind-address value except the exact =0 value, and reject duplicate occurrences. Check the installed helm-unittest assertion API before using a regex assertion.

🤖 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 `@packages/core/installer/tests/rollout_strategy_test.yaml` around lines 86 -
107, Expand the rollout strategy assertions for the Deployment container args so
metrics and health probe bind-address flags accept only the exact values
--metrics-bind-address=0 and --health-probe-bind-address=0. Reject every other
value, including duplicate flag occurrences, using the installed helm-unittest
assertion API and verify its regex support before relying on it.

At replicas: 1 with maxUnavailable: 1 the Deployment controller takes
the minimum available replica count as 1 - 1 = 0, so the Available
condition reports True with no running pod at all. That reading is what
every consumer of the status sees: an alert on Available, a health
dashboard, or a person reading kubectl get deploy all show green while
the operator is dead.

The replacement is not a tuned pair, it is the platform default written
down. At replicas: 1 the default 25% / 25% resolves to maxSurge 1 and
maxUnavailable 0, because the surge rounds up and the unavailability
rounds down. So this change stops overriding the default rather than
introducing a configuration of its own, and Kubernetes rejects
maxUnavailable: 0 together with maxSurge: 0, so the surge could not
have gone the other way regardless.

The overridden pair was carried over from the earlier cozystack
Deployment, where a darkhttpd sidecar published a host port and
hostNetwork left a surge pod unschedulable on a single-node cluster. It
arrived here in two steps: by the commit before this file was created
the sidecar was already gone and no port was declared, yet the pair
stayed, so it had outlived its own reason before it was ever copied.
That is why looking for a justification in this file's history finds
none. With no port declared anywhere in the pod there is no hostNetwork
port conflict left, so the surge pod schedules beside the outgoing one;
declaring one reintroduces exactly the scheduling failure the pair was
added for. Not exercised on a live cluster in this rework.

Stay on RollingUpdate rather than move to Recreate, which would fix the
condition too, and by a shorter route: MaxUnavailable() reports 0 for
any strategy that is not RollingUpdate, so the minimum available
replica count becomes replicas - 0 = 1 and the condition is honest
without any surge pod at all.
The transition was not attempted, and the failure this project fixed in
ouroboros 0.8.1 does not transfer to it. There the rollingUpdate block
was the one the apiserver defaults onto a Deployment created without
one, so it belonged to no applier and outlived the switch to Recreate.
This Deployment has carried an explicit block since the commit that
created it, and nothing applies it server-side, so whether Recreate
would upgrade cleanly is an open question about the field-ownership
history of the live object rather than a known rejection. What keeps
RollingUpdate here is availability rather than apply semantics:
maxUnavailable: 0 leaves the outgoing operator running when the
replacement never starts, where Recreate deletes it first and leaves
none. Editing the numbers inside the block is
upgrade-safe from every state the field can already be in.

The surge introduces new windows, and leader election covers less of
them than it looks. The operator runs four privileged install steps
before mgr.Start(), and the lease is only acquired inside mgr.Start():
the CRD install, the Flux install, the platform source resource and the
platform PackageSource. All four therefore run in the incoming pod
outside leader election entirely. What the lease does cover is the
reconcile loops, the part that would otherwise conflict continuously
instead of once at startup.

That leaves three seams, and only the first two happen on a healthy
upgrade. The routine one is the incoming pod's install steps against
the outgoing pod's live reconcile loops, since the outgoing pod ran its
own installs at its startup and has been inside mgr.Start() ever since.
Those do not contend for the same fields: the installs write as field
manager cozystack-operator, while the reconcilers write as
cozystack-packagesource-controller and cozystack-package-controller and
go mostly to status subresources. Different owners, different objects,
so this seam is safe by separation rather than by any shared manager.

The second is version skew rather than field contention, and maxSurge:
1 widens it rather than opening it. Under maxSurge: 0 the surge budget
counts desired replicas rather than live pods, so the incoming pod is
created while the outgoing one may still be Terminating - the overlap
existed. What it did not have was a live counterparty: scale-down
sends SIGTERM before the new pod is created, and that cancels the
manager context, so the old loops were already unwinding. Here the
outgoing pod is not signalled until the incoming one is available, so
the overlap runs against a fully live reconciler for a termination
grace period instead of a vestigial one. The incoming pod reports
Ready the instant its
container starts, because there is no readiness probe, so its CRD
install runs while the outgoing binary's reconcile loops are still live
and now writing against a schema the incoming pod has already moved.
Old code against a new schema, which no field manager separates. It is
bounded rather than benign: the outgoing pod exits on SIGTERM within
seconds, a rejected write is transient and the next reconcile repairs
it, and no object is left corrupt. Widening the skew, by adding a probe
that delays Ready or by a migration the old code cannot round-trip, is
what would make this seam matter.

The third seam is install against install, and it needs the outgoing
pod to be restarting, since a crashlooping pod re-runs its four steps
on every restart, which is exactly the broken operator this gate exists
to catch. Three of the four steps use server-side apply under the same
field manager, so they never conflict and resolve last-writer-wins
whenever the two pods carry different manifests. That ordering matters
in one direction, and it is accepted here rather than guarded against:
the CRD install and the Flux install both apply with Force: true, so an
outgoing pod that writes last silently overwrites what the incoming pod
has just applied, with no conflict and no error to exit on. The
incoming pod is inside mgr.Start() by then and will not apply again
until it restarts, so that revert of the upgrade's install phase
persists rather than healing itself. Reaching it needs a restart
landing in the seconds between the incoming pod's installs and the
outgoing pod's SIGTERM. installPlatformSourceResource is the remaining
exception, a read-modify-write of a Get followed by Create or by Update
with the observed resourceVersion, so two pods running it at once can
collide on AlreadyExists or on a conflict; that failure is loud and
self-healing, since any error there exits the process and the pod
restarts, and the object is not left corrupt.

Pin the strategy and all four preconditions the surge makes
load-bearing in a unit test: no container or initContainer declares a
port, both listener arguments stay disabled, leader election stays on,
and no probe uses httpGet. They fail differently and none is visible to
the others, since a declared port stops the scheduler, a bound one only
fails once the incoming process starts, and a second manager without a
lease fails nothing at all until it writes. The httpGet precondition
follows from the two disabled listeners leaving no port to target, and
it is the worst of the four to get wrong: the other three wedge an
upgrade, while a livenessProbe against a disabled listener crashloops
the pod on every cluster, including a fresh install where no surge
happens. Only the httpGet field is forbidden rather than the probes
themselves, because an exec probe stays legitimate. All four are
checked against all three variants, because the template branches on
variant inside the container spec and a port added under one branch
renders for that variant alone. The strategy type is pinned alongside
its two values, since maxSurge and maxUnavailable say nothing on their
own once the type is no longer RollingUpdate.

The listener preconditions are pinned less tightly than the other two,
and the test says so rather than implying coverage it does not have. A
membership assert has no notion of position while Go's flag package
keeps the last occurrence, so a duplicate after the disabled one wins
and the assert still passes, while a duplicate before it loses and the
assert passes just the same - the test cannot tell those two apart.
Moving the disabled flag last fixes the process without making the
assert catch anything. Rejecting the two addresses this
binary defaults to closes the shape a values-gated block to enable
metrics would take, and nothing available here closes an arbitrary
address, since helm-unittest cannot express that a flag appears at most
once.

Note in hack/helm-unit-tests.sh that the loop covers packages/core,
which its comment omitted. The suite added here lives there, so a later
edit reconciling the loop with the stale comment would have silently
stopped running it.

Split the rationale by audience rather than keeping it all in shipped
comments. What an operator applying the rendered manifest has to respect
stays in plain comments and travels into the install artifacts: that the
pair is the default, why maxUnavailable must stay 0, that no port may be
declared or bound, that no probe may use httpGet, and that leader
election has to stay on. Most of what stays in the template comment is
what the chart's own suite pins, so it cannot drift out of step with the
chart without a test going red: the four preconditions and which assert
covers each, and why the variant loop is needed. Three things there are
deliberate exceptions, kept because anyone editing the strategy has to
know them and no assert can carry them - that the webhook server
configured on 9443 is not a port binder, since controller-runtime adds
it to the runnables only inside GetWebhookServer() and this binary never
calls it; that two overlaps between the pods sit outside the lease; and
why RollingUpdate is kept rather than Recreate. Those name what exists.
The seam analysis above, which is how each one works, deliberately stays
here instead. It describes another file's internals, nothing pins it,
and a commit message is fixed in time, so it cannot quietly fall out of
date the way a comment can.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@lexfrei

Copy link
Copy Markdown
Contributor Author

The red never reached the suites. Run 31021522273 on 115d9bbaa fails at Install Cozystack into sandbox, and Select E2E tests, Run OpenAPI tests and Run E2E tests are all skipped after it.

The failing step is Configure Tenant and wait for applications (exit 1). Under it grafana restarts three times against Error: ✗ dial tcp 10.96.4.25:5432: connect: no route to host, dialing its Postgres service on 5432. monitoring then reports InstallFailed with timeout waiting for: [HelmRelease/tenant-root...], and tenant-root reports UpgradeFailed.

That is the class tracked in #3570: with kubeProxyReplacement, a ClusterIP with no ready backend answers EHOSTUNREACH instead of hanging, and the client comes up at the same time as its database. The gate this PR changes governs when the installer considers the operator rolled out; the failure above happens in a tenant application dialing its own database, on a path the gate does not sit on.

@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.

LGTM.

Verified both halves against source, not just the description:

  • Operator binary defaults (--metrics-bind-address=:8080, --health-probe-bind-address=:8081, --leader-elect=false) confirmed in cmd/cozystack-operator/main.go, so the chart's explicit =0/leader-elect=true are load-bearing. Webhook server on 9443 is configured but never registered, so nothing binds it. Install phase runs before mgr.Start(), i.e. outside leader election.
  • maxSurge:1/maxUnavailable:0 is safe under hostNetwork: no port is bound and there is no anti-affinity, so the surge pod co-locates on a single-node cluster.
  • Test suite is non-vacuous: 3/3 pass on talos/generic/hosted; 5 independent mutations each turn it red, restore green.
  • The bats gate is now fail-closed (previously fail-open against a dead operator).

Non-blocking notes:

  1. progressDeadlineSeconds is unset (default 600s > the 300s gate timeout), so on ImagePullBackOff the gate burns the full 5m before failing instead of fast-failing. Consider setting it below the gate timeout to restore a fast, explicit failure.
  2. The bind-address assert only covers :8080/:8081; a duplicated flag would slip through. Acknowledged framework limitation.
  3. Architectural note (not fixable in the chart): maxSurge:1 makes concurrent execution of the privileged install phase (CRD/Flux install, PackageSource) reachable on every upgrade, since that phase runs outside leader election. Rare (needs a broken outgoing pod at restart), generally idempotent via server-side apply. Worth a Go-side follow-up to gate the install phase behind the lease or prove it upgrade-safe.

@lexfrei
Aleksei Sviridkin (lexfrei) merged commit 879d0f6 into main Aug 6, 2026
15 of 16 checks passed
@lexfrei
Aleksei Sviridkin (lexfrei) deleted the fix/e2e-operator-rollout-gate branch August 6, 2026 12:25
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/testing Issues or PRs related to testing (e2e, bats, unit tests) kind/bug Categorizes issue or PR as related to a bug size/L This PR changes 100-499 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants