fix(sidecar): restore the seictl compatibility name in the image - #505
Conversation
#504 shipped this shim in its first commit and removed it in its third, before either half of its own stated removal condition was met. The condition was: remove only after the controller stops rendering Command AND every cell has rolled past that controller. The first half happened at 186d65a. The second has not happened anywhere — no cell has adopted this image at all. Without the shim the two working combinations are diagonal: controller renders ["seictl","serve"] + old seictl image -> works (today) controller renders ["seictl","serve"] + this image -> exec seictl: not found controller renders nothing + this image -> works (target) controller renders nothing + old seictl image -> bare seictl, exits 0 There is no ordering between those two that passes through a working state, so image and controller have to flip together in every cell at once, and the rollback wedges: every mode's plan runs config-patch (a polled sidecar task) before replace-pod, so reverting the controller submits to a sidecar that is already dead. The submit fails, replace-pod is never reached, and the broken pod is never replaced — recovery is a manual pod delete per node. With this name present there is never a dead sidecar to wedge on, and reverting the controller alone is sufficient. Restoring it makes THIS image valid under both controller versions, which decomposes the cutover into three independently reversible steps: adopt the image, then bump the controller, then delete the shim. Note the asymmetry — the image becomes version-agnostic, the controller does not. Old image plus new controller is still broken, so the safe rollback is "revert the controller, leave the image", never the reverse. A second COPY rather than a symlink: the final stage is distroless and has no shell to run `ln`. Costs one duplicated static binary in the layer. Verified argv[0] independence empirically rather than by reading: built the binary, copied it to `seictl`, and ran both names. `seictl serve` with SEI_HOME unset reaches serve's required-flag check; with a relative SEI_HOME it hits validateHome with the same message; output is identical under either name. The root command's Name is a hardcoded literal and nothing in sidecar/ or sidecarapi/ reads os.Args[0], so dispatch keys only on argv[1]. Dockerfile-only change; no Go behaviour is affected. The image build is not verified locally (no Docker daemon available) — CI's publish-sidecar job covers it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR SummaryMedium Risk Overview Pins both sidecar render sites (StatefulSet and bootstrap Job) to empty Command/Args so the shim is not removed until those tests exist. Documents the order, rollback, and removal conditions in Reviewed by Cursor Bugbot for commit 80a678f. Bugbot is set up for automated code reviews on this repo. Configure here. |
Review follow-ups on the shim, three parts. Dockerfile comment. Two claims in it were wrong and one was missing. - The symlink alternative is not impossible: the BUILD stage has a shell, so `RUN ln -s` there plus a COPY of the link is the ordinary way to get a symlink into a distroless image, and it would save the duplicated binary. Untested here (no daemon), so the comment now gives the real reason — a transitional cost preferred over being clever across stages — instead of asserting a constraint that does not exist. - The wedge trigger was wrong. Reverting the controller changes no image, so sidecarImageDrifted is false and no update plan is built at all; the pod just sits there under OnDelete. The wedge belongs to any LATER image-drift plan once a sidecar is already dead: that one does drift, and dies at config-patch before reaching replace-pod. Same conclusion, correct mechanism. - The required order was never named. It is image first, controller second, per cell, and the safe rollback is "revert the controller, leave the image". Removal condition now names checkable signals — no rendered pod spec naming seictl at either render site, and no cell's config still on ghcr — rather than a controller version, because OnDelete lets a pod outlive the bump. The block also moved above the ENTRYPOINT comment, which it had pushed 29 lines away from the ENTRYPOINT it documents. CLAUDE.md. It still told the reader this was a coordinated deploy where image, config and controller ship together per cell — the thing the shim exists to stop being true. Rewritten as the three ordered steps, with the rollback direction and the manual-delete recovery, because nobody reads a Dockerfile before planning a deploy. Tests. Nothing pinned the invariant the whole cutover rests on: neither render site asserted the sidecar container renders no Command and no Args. Re-adding either breaks every pod in every cell quietly — exits 0, restarts, seid blocked behind a ~5-day StartupProbe. Both assertions mutation-checked: adding Command back fails each one at the expected line, and removing it passes again. Verified: gofmt and go vet clean; both new tests pass and fail under mutation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Restores the compatibility shim that
531d7dc("feat(sidecar)!: let the image own its entrypoint; drop the seictl name") deleted — the fourth commit of #504, after8062799had added it. That commit dropped the shim and bothCommandrenderings together, before either half of the shim's own removal condition was met.Reviewed by the kubernetes and systems specialists; corrections from both are folded in.
Why
The original condition was: remove only after the controller stops rendering
Commandand every cell has rolled past that controller. The first half happened at186d65a. The second has not happened anywhere — all five overlays (harbor,dev,prod,prod-euw1,prod-use2) still pointimages.sidecaratghcr.io/sei-protocol/seictl, and no cell's controller is past186d65a.What breaks without it
A pod spec's
commandreplaces the image ENTRYPOINT (argsreplaces CMD); neither render site setsArgs, so argv is exactly what the controller renders.["seictl","serve"]seictl serve["seictl","serve"]exec seictl: not foundsei-sidecar serveseictl→ help, exit 0Controller-first does pass through a working state — no plan triggers on a
Command-only change, so live pods keep the old template. But it is latently broken: the nextapply-statefulsetfrom any other cause (a seid image bump, a bootstrap plan, a re-init) renders old-image + no-Command and lands the quiet exit-0 pod. That is the real argument for the shim.Row four is the quiet direction: exit 0 reads as
Completed/success to a human and to any alert keyed on error reasons, where row two givesStartError. So the safe rollback is "revert the controller, leave the image" — never the reverse.What it enables
Three steps per cell instead of one flip:
images.sidecarat the ECR image, and restart the controller at its current version —images.sidecaris read once at startup (internal/platform/load.go:30-33), so the commit alone is inert. The restart rolls the cell, on the old controller, through theseictlname.Commandleaves the StatefulSet at the next drift plan, atomically with whatever image that plan applies.Command.Price: two cell rolls instead of one. That is the right trade and it should not be a surprise.
The shim covers both render sites —
internal/noderesource/noderesource.goandinternal/task/bootstrap_resources.go. The bootstrap Job renders fresh each time rather than through a StatefulSet, so an old controller creating a bootstrap Job against the new image needsseictltoo.Mechanism
A second
COPY, not a symlink. A build-stageRUN ln -splus aCOPYof the link would work and would save the duplicate — but it makes the one thing unverifiable locally (the image build) carry a new BuildKit-behavior dependency, on a change whose whole purpose is to not fail. Cost is 83,037,797 bytes (linux/amd64), roughly doubling the image over a ~2 MB distroless base. Right trade for a few weeks.Verification
argv[0] independence, empirically: built the binary, copied it to
seictl, ran both names.seictl servewithSEI_HOMEunset reachesserve's required-flag check; with a relativeSEI_HOMEit hitsvalidateHomeidentically; output identical under either name.os.Argsappears once, atsidecar/main.go:29, passed whole to urfave/cli; rootNameis the literal"sei-sidecar"at:40.PATH resolution — the stronger argument, and the one my local run did not exercise. The old controller renders a relative
seictl, so this depends on PATH inside the container. Both images share the same base (gcr.io/distroless/static-debian12) and the same install path (/usr/bin/seictl), so resolution is byte-identical to what has run in prod for months.serve servecannot happen. Beyond the replace-not-append rule: every cell today runsCommand: ["seictl","serve"]against an image whose ENTRYPOINT is["/usr/bin/seictl"]. If Kubernetes appended, argv would be["/usr/bin/seictl","seictl","serve"]and every sidecar in production would be dead. Replacement is already proven in prod on this exact pair.Wire contract unchanged — old client ↔ new server verified compatible: seictl v0.0.68's
sidecar/serverdiffers only in the import path and four route literals hoisted to constants;wire.gobyte-identical; all 24 task-type constants match.Contract now pinned in code. Neither render site had any assertion that the sidecar renders no
Command/Args— the invariant the whole cutover rests on. Both added and mutation-checked: re-addingCommandfails each at the expected line, removing it passes again.CLAUDE.mdupdated. It still stated the coordinated-deploy doctrine this PR dissolves. That doc outranks a Dockerfile comment, and the shim was deleted last time precisely because its rationale lived only in the Dockerfile.The image build is not verified locally (no Docker daemon). The
dockerjob in.github/workflows/ci.yml:78-109buildssidecar/Dockerfileon every PR, so it is covered before merge —publish-sidecarruns only on push to main.Not addressed here
Two open findings this PR does not touch, both raised as gates before the first cell:
internal/planner/executor.go:157-166) and never escalates toFailed, so a wedged plan is invisible and blocks later planssharedDoerhas no client timeout (cmd/main.go:153) andMaxConcurrentReconcilesis unset, so one hung sidecar parks the single SeiNode worker for a whole cell🤖 Generated with Claude Code