fix(installer): make operator readiness honest in the chart and the install gate - #3466
Conversation
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
🚧 Files skipped from review as they are similar to previous changes (3)
📝 WalkthroughWalkthroughThe operator Deployment now uses ChangesOperator rollout
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related PRs
Suggested reviewers: 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
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
f0981b2 to
4fe7123
Compare
4fe7123 to
cceed14
Compare
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
myasnikovdaniil
left a comment
There was a problem hiding this comment.
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=0and--health-probe-bind-address=0both reach controller-runtime's disabled path, and the webhook server on 9443 is never started: the only twoGetWebhookServer().Register(...)call sites live in theflux-shard-operatorandlineage-controller-webhookbinaries, not incozystack-operator, and controller-runtime adds that server as a runnable only insideGetWebhookServer(). So the surge pod neither fails to schedule nor hitsEADDRINUSE. - The install steps are safe under the concurrency
maxSurge: 1newly permits.crdinstall.Install,fluxinstall.applyObjectsandinstallPlatformPackageSourceall useclient.ApplywithFieldManager: "cozystack-operator"andForce: true.installPlatformSourceResourceis the oneGet→Create/Update-with-observed-resourceVersionread-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) andcozystack-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.
c43db3b7cintroduced this file already carryingmaxSurge: 0/maxUnavailable: 1, andpackages/core/installer/templates/cozystack.yamlcarried the same pair alongsidehostNetwork: trueand the8123sidecar 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
packages/system/opensearch-operatorstill has the exact condition this PR removes here. At the defaultmanager.replicaCount: 1its controller-manager Deployment rendersmaxSurge: 0/maxUnavailable: 1, so the minimum available replica count is0andAvailablereportsTruewith no running pod. It is out of this PR's scope and itsmaxSurge: 0is 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-Availablegain 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.- 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 statusonly exits early onProgressingwith reasonProgressDeadlineExceeded, and this Deployment leavesprogressDeadlineSecondsat 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. - The downstream behaviour change is real and unretried.
roles/cozystack/tasks/main.ymlwaits with--for=condition=Available --timeout={{ cozystack_operator_wait_timeout }}s(default300) and, unlike the CRD wait immediately below it, carries noretries:. The judgement that 300s covers a cold pull is reasonable; that repo's CI is where it would show up on the nextcozy-installerbump. - The suite pins
spec.strategy.rollingUpdate.*but notspec.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 || { |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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>
cceed14 to
338a925
Compare
|
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. |
There was a problem hiding this comment.
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
📒 Files selected for processing (4)
hack/e2e-install-cozystack.batshack/helm-unit-tests.shpackages/core/installer/templates/cozystack-operator.yamlpackages/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
| - 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: |
There was a problem hiding this comment.
🎯 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:
- 1: https://github.com/helm-unittest/helm-unittest/blob/main/DOCUMENT.md
- 2: https://github.com/helm-unittest/helm-unittest/blob/v0.3.3/DOCUMENT.md
- 3: https://github.com/quintush/helm-unittest/blob/master/DOCUMENT.md
- 4: https://github.com/helm-unittest/helm-unittest
- 5: https://pkg.go.dev/github.com/helm-unittest/helm-unittest
🏁 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
fiRepository: 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:
- 1: Return an error if the same flag is provided twice with different values spf13/pflag#72
- 2: https://github.com/spf13/pflag/blob/master/flag.go
- 3: Flag default assignments should be lazily applied spf13/pflag#257
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>
338a925 to
115d9bb
Compare
|
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. |
|
The red never reached the suites. Run The failing step is That is the class tracked in #3570: with |
IvanHunters
left a comment
There was a problem hiding this comment.
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=trueare 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:
- 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.
- The bind-address assert only covers
:8080/:8081; a duplicated flag would slip through. Acknowledged framework limitation. - 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.
What this PR does
The operator Deployment ran
replicas: 1withmaxUnavailable: 1, so the Deployment controller took the minimum available replica count as1 - 1 = 0and theAvailablecondition reportedTruewith no running pod at all. Measured against an operator whose image tag does not exist: the condition wasTruewithreason: MinimumReplicasAvailablewhile the only pod sat inImagePullBackOff,unavailableReplicas: 1, and noavailableReplicaskey in the status.That broke two separate things, so this fixes both.
The E2E install gate asked
kubectl wait --for=condition=Availableand passed in 156 ms against that dead operator. The install then carried on and died about two minutes later on thepackages.cozystack.ioCRD wait further down the file, which sends you after CRD installation when the operator never started. It now useskubectl 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 statusis 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.
Availableis read by more than this test, and until the strategy changes an alert on that condition, a health dashboard, or a person readingkubectl get deployall show green while the operator is dead. The strategy moves tomaxSurge: 1/maxUnavailable: 0, which restores the ordinary meaning of the condition. Kubernetes refusesmaxSurge: 0andmaxUnavailable: 0together, 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: 1the Kubernetes default of25%/25%resolves to exactlymaxSurge: 1andmaxUnavailable: 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
cozystackDeployment, where a sidecar published host port 8123 andhostNetworkleft 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 originaldidn't have free ports for the requested pod portsfailure exactly. Nothing else in the pod holds a port either: metrics and health probes are bound to0, 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: 0capped total pods atreplicasa second manager process could not exist, so--leader-elect=truewas 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 beforemgr.Start(), and the lease is only taken insidemgr.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. Thek8s-await-electionwrapper 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-operatorchart 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 aftermaxSurge: 1the 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, anhttpGetreadiness probe is off the table for this Deployment for good and only anexecprobe could ever work. SoAvailablestill 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.
--waitis dropped from the helm step in the same change. Helm decides a Deployment is ready with the samereplicas - maxUnavailablearithmetic, 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 alivenessProbeagainst a disabled listener crashloops the pod on every cluster, fresh install included. Only thehttpGetfield is forbidden rather than the probes themselves, so anexecprobe 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
containerPorton 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 withhttpGeteach 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. Anexecprobe 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 whyopensearch-operatorreaches 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. Thehack/triggers namehack/e2e-prepare-cluster.bats,hack/package.mk,hack/update-crd.shand moves or renames underhack/, and this PR only edits the body ofhack/e2e-install-cozystack.bats. Theansible-cozystacktrigger is renaming an object the role waits on, anddeployment/cozystack-operatorkeeps 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. Itscozystack_operator_wait_timeoutdefaults 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
Summary by CodeRabbit
Bug Fixes
Tests