fix(storagenodeset): give each StorageNodeSet its own ServiceAccount - #451
Draft
boddumanohar wants to merge 2 commits into
Draft
fix(storagenodeset): give each StorageNodeSet its own ServiceAccount#451boddumanohar wants to merge 2 commits into
boddumanohar wants to merge 2 commits into
Conversation
boddumanohar
marked this pull request as draft
August 21, 2026 13:55
boddumanohar
force-pushed
the
fix/storage-node-sa-token-expiration
branch
from
August 24, 2026 15:49
bd59125 to
aff7aa3
Compare
The storage-node ServiceAccount, ClusterRole, and ClusterRoleBinding were shared by every StorageNodeSet in a namespace, but reconcileRBAC gave the ServiceAccount a controller ownerReference to whichever StorageNodeSet reconciled it last. Deleting that one StorageNodeSet — including as an ordinary part of cluster expansion, adding a StorageNodeSet and then removing it — let the garbage collector cascade-delete the ServiceAccount out from under every other StorageNodeSet's already-running pods. Their mounted tokens are bound to the deleted ServiceAccount's UID, which the API server rejects (401) until kubelet's next token refresh (default ~48min). Scope the ServiceAccount and ClusterRoleBinding one-to-one per StorageNodeSet instead of sharing them, so each carries a correct ownerReference and deleting one StorageNodeSet can never affect another's. The ClusterRole stays shared and unowned, since its Rules are the same for every StorageNodeSet regardless of who reconciles it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This fix ships in the next release, not the one currently being cut, so existing StorageNodeSets already running under the shared-ServiceAccount design (already deployed with the current release) must not have their ServiceAccount name change — that would change the DaemonSet pod template and force an unwanted rolling restart of already-running storage nodes on upgrade. reconcileRBAC now resolves, per StorageNodeSet, whether to keep the legacy shared names or use the new per-StorageNodeSet ones: if a legacy ServiceAccount already exists and its ownerReference already names this StorageNodeSet, it keeps the legacy names untouched; otherwise it gets its own per-StorageNodeSet ServiceAccount and ClusterRoleBinding. Since the legacy design's ownerReference already names exactly one StorageNodeSet at any moment, this deterministically grandfathers exactly the one that already owned it, with zero disruption for the common single-StorageNodeSet- per-namespace case, while any other pre-existing StorageNodeSet in a multi-StorageNodeSet namespace (the scenario the original bug required) is split off onto its own correctly-owned ServiceAccount. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
boddumanohar
force-pushed
the
fix/storage-node-sa-token-expiration
branch
from
August 25, 2026 11:11
aff7aa3 to
aae9066
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
simplyblock-storage-node-saServiceAccount is ever deleted and recreated, every already-running pod's mounted token stays bound to the old, now-nonexistent ServiceAccount UID. The API server correctly rejects those with 401 Unauthorized, and nothing self-heals until kubelet's next token refresh (default ~48min) — in practice a long, manual-intervention outage (had to force-delete pods to get fresh tokens).reconcileRBACgives the SA acontroller: trueownerReference to a singleStorageNodeSet. The SA is shared by everyStorageNodeSetin the namespace, not owned 1:1 by any one of them, so whichever one reconciled last became the sole owner GC cares about. Deleting thatStorageNodeSet— including as an ordinary part of cluster expansion, adding one and then removing it — letkube-controller-manager's garbage collector cascade-delete the SA out from under every otherStorageNodeSet's already-running pods.StorageNodeSetits own ServiceAccount and ClusterRoleBinding instead of sharing one across the namespace, so the ownerReference is correct by construction.This targets the next release, not the one currently being cut (
release/26.3.0), so the fix has to be upgrade-safe: existing clusters already running the shared-ServiceAccount design must not have their already-deployed StorageNodeSet's ServiceAccount renamed, since that changes the DaemonSet pod template and forces an unwanted rolling restart of already-running storage nodes. See "Upgrade compatibility" below for how that's handled.Root cause (confirmed, reproduced)
reconcileRBACbuilds a fresh, ownerlessServiceAccountstruct on every reconcile and unconditionally overwrites itsownerReferencesto point at whicheverStorageNodeSetis currently reconciling. With more than oneStorageNodeSetsharing a namespace, ownership of the single shared SA just flip-flops to whichever reconciled most recently — there is no coordination between them.StorageNodeSet(e.g. for cluster expansion) — it reconciles almost immediately and becomes the SA's current owner — then delete it. Once its finalizer is removed and the object is actually gone, the garbage collector sees the SA's soleownerReferencesentry pointing at a UID that no longer exists and deletes the SA. It has no way to know any otherStorageNodeSetstill depends on it, since only the deleted one was ever recorded as owner.ownerReferencesto the new UID in the very next command was already too late.ownerReferencesmatch by UID, not name, so GC doesn't care that an object with the same name exists again.webappapi/tasks-runnercalling the storage-node's Flask API) is TLS-authenticated and stays reachable — this SA/token is unrelated to it. What breaks is the storage-node pod's own outbound calls to the Kubernetes API server (simplyblock_web/api/internal/storage_node/kubernetes.pyinsbcli, using the pod's own SA token): creating/deleting the SPDK device-prep Jobs, creating/deleting the spdk-proxy pod, and polling Node cordon/MachineConfigPool status. Once the SA token is invalid those calls return 401, so every request that tries to actually start, stop, or health-check a node's SPDK backend fails at that point — effectively freezing node lifecycle operations across the whole cluster, since every storage-node pod shares this one SA.Fix
simplyblock-storage-node-sa-<StorageNodeSet name>andsimplyblock-storage-node-binding-<namespace>-<StorageNodeSet name>— one ServiceAccount and one ClusterRoleBinding perStorageNodeSet, each owned 1:1 by theStorageNodeSetthat created it, so the ownerReference is correct by construction: deleting oneStorageNodeSetcan only ever garbage-collect its own SA, never another's.simplyblock-storage-node-role(the ClusterRole) stays shared and cluster-scoped with no owner, since its Rules are static regardless of who reconciles it.The ClusterRoleBinding deliberately carries no ownerReference either, even though it's now 1:1 per
StorageNodeSet: it's cluster-scoped, and Kubernetes documents that a cluster-scoped dependent naming a namespaced kind as owner is unresolvable by the garbage collector — it would just be dead weight, not real cleanup.Upgrade compatibility
Since this ships in the next release rather than the one currently being cut, upgrading clusters will already have the pre-fix shared
simplyblock-storage-node-sain place, most commonly owned by exactly oneStorageNodeSet(the common case: oneStorageNodeSetper namespace). Renaming it unconditionally would change every existingStorageNodeSet's DaemonSet pod template, forcing a rolling restart of every already-running storage node on upgrade — for a fix about avoiding disruption, that's the wrong trade.reconcileRBACnow resolves, perStorageNodeSet, whether to keep the legacy shared names or move to its own:simplyblock-storage-node-saalready exists and its ownerReference already names thisStorageNodeSet, it keeps the legacy names — its DaemonSet's pod template, and thus its already-running pods, are untouched by the upgrade.StorageNodeSet) it gets its own per-StorageNodeSetServiceAccount and ClusterRoleBinding.Since the legacy design's ownerReference always names exactly one
StorageNodeSetat any moment, this deterministically grandfathers exactly the one that already owned it — zero restarts for the common single-StorageNodeSet-per-namespace case. Any other pre-existingStorageNodeSetsharing a namespace (the multi-StorageNodeSetscenario the original bug required) gets split off onto its own correctly-owned ServiceAccount, which does mean one rolling restart to actually fix it — an acceptable, minimal cost for the rare case that was exposed to the bug in the first place. Any brand-newStorageNodeSetcreated after this ships (fresh namespace, no legacy SA) gets the clean per-instance naming immediately, no legacy baggage.Test plan
go build ./...passesmake testpasses (full suite, including envtest-backed controller tests)golangci-lint run ./internal/utils/... ./internal/controller/... --new-from-rev=origin/main— 0 new issuesStorageNodeSet, reconciled alongside a secondStorageNodeSetin the same namespace — confirms the owner keeps the legacy names (DaemonSet ServiceAccountName unchanged) and the other gets its ownStorageNodeSetServiceAccount/ClusterRoleBinding naming and ownershipStorageNodeSets in the same namespace, delete one, confirm the other's ServiceAccount and running pods are unaffected) against a real cluster in an earlier iteration of this design (per-StorageNodeSetnaming for all, no grandfathering) — the upgrade-compatibility layer added here doesn't change that underlying mechanismStorageNodeSet's pods are not restarted and a secondStorageNodeSetin the same namespace gets split onto its own SA) — not yet verified live🤖 Generated with Claude Code