feat: lease-aware scheduler gating, leader visibility, failover e2e - #82
Merged
Conversation
Adds spec.heartbeat.schedulerGating (enum ordinal|lease|auto, default ordinal) controlling how the heartbeat scheduler is pinned to a single replica at replicas > 1: - ordinal (default): ordinal-0 shell wrapper, StatefulSet only, no failover. The HEARTBEAT_SCHEDULER_ENABLED wrapper is now additionally guarded by the resolved mode and skipped for the Deployment workload. - lease: no env manipulation; delegates to the app's lease-based leader election (paperclipai/paperclip#7995). - auto: currently resolves to ordinal; will flip to lease once the minimum supported app version includes lease leadership. SchedulerGatingMode in internal/resources resolves auto/empty to ordinal. New advisory SchedulerGatingValid condition (excluded from the Ready aggregate) reports False/OrdinalGatingRequiresStatefulSet plus a Warning event for Deployment+ordinal at replicas > 1, True when valid, and is removed at replicas <= 1. Tests: wrapper presence matrix across mode/workload/replicas, SchedulerGatingMode unit tests, condition lifecycle envtest, and the advisory exclusion in allSubConditionsReady. Regenerated CRDs, chart CRDs, and API docs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…letion-cost When schedulerGating resolves to lease at replicas > 1, the operator polls each running server pod's unauthenticated /api/health for the scheduler block, labels the lease holder paperclip.inc/role=scheduler (others web), records status.schedulerLeader, and tags the leader with the controller.kubernetes.io/pod-deletion-cost annotation on Deployment workloads so ReplicaSet scale-down prefers web replicas. Per-pod poll errors skip the pod; when every poll fails the previous leader value is kept to avoid thrashing. Ordinal gating or replicas <= 1 strips all markers and clears the status field. Active instances requeue every 30s. The probe is injectable (healthProbe field) so envtest covers leader labeling, status, deletion-cost, no-thrash, and the clear path without a pod network. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a "Scheduler lease failover" e2e context: a 2-replica Deployment workload Instance (database.mode external against an in-cluster postgres:16 Deployment, persistence off, schedulerGating lease) must elect exactly one paperclip.inc/role=scheduler pod with a matching status.schedulerLeader, and deleting that pod must hand the lease to a different pod (label + status follow). The scenario needs an app image that ships lease-based scheduler leadership (paperclipai/paperclip#7995, unreleased), so it is gated behind PAPERCLIP_E2E_LEASE_IMAGE and SKIPPED by default; the image ref is split into spec.image repository/tag-or-digest. objectStorage is intentionally omitted: MultiReplicaPreconditions reports an advisory False, but the workload reconciles regardless and the failover assertions do not touch file state, keeping the scenario free of a MinIO dependency. Terminating pods are excluded from the leader-label query so the drained old leader never trips the exactly-one assertion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a "Scheduler gating and failover" subsection to the README Scaling section: the three schedulerGating modes (ordinal default, lease, auto) with their failover behavior, the operator-vs-app version-skew matrix (including the unsafe lease + lease-less-app combination), the ordinal-to-lease migration recipe (upgrade the app first), and leader observability (status.schedulerLeader, paperclip.inc/role pod labels, pod-deletion-cost on Deployments). The Heartbeat Scheduler section now points at it instead of stating pod-0 pinning unconditionally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The app enforces a Host-header allowlist (service DNS names + loopback); polling a pod IP with the IP as Host gets 403 in authenticated mode — found live on a real cluster; envtest's injected probe couldn't catch it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rusted cannot bind in-cluster)
The app hard-rejects local_trusted on non-loopback binds ("local_trusted
requires server.bind=loopback"), so in-cluster local_trusted Instances
crash-loop. Switch the lease-failover Instance (and the env-rendering
e2e-feat Instance, which also runs the real app image) to
deployment.mode=authenticated with a BETTER_AUTH_SECRET Secret.
auth.adminUser is deliberately omitted: the bootstrap Job proved flaky
in live verification and the failover assertions need no authenticated
principal - the operator reads scheduler leadership from the
unauthenticated redacted health endpoint.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The kubernetes sandbox plugin polls Job completion via the status subresource; without it every sandboxed run fails with a 403 after Job creation. Found during live multi-replica verification. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…evention) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…etpol Enabled pointer fix Three live-verification findings: (1) in-cluster MinIO requires path-style S3 addressing — new forcePathStyle field, defaulting true for provider=minio; (2) the operator-managed NetworkPolicy had no way to allow egress to an in-cluster object-storage endpoint — new extraEgress passthrough; (3) networkPolicy.enabled was a plain bool with omitempty + default=true, so an explicit false was dropped on marshal and re-defaulted by the API server — same bug class as PersistenceSpec.Enabled, now *bool. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
stubbi
force-pushed
the
feat/lease-scheduler-gating
branch
from
June 13, 2026 17:33
63d769f to
cc64648
Compare
stubbi
marked this pull request as ready for review
June 13, 2026 17:46
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.
Note
Stacked on #81 (
feat/multi-replica-workload). This branch contains all of #81's commits; please review only the last 4 commits here (dd3cef6,a708e63,d615714,9b3a860). The diff collapses to just those once #81 merges.Motivation
The app is gaining lease-based scheduler leader election (paperclipai/paperclip#7995): every replica is a lease candidate, one wins, and the lease fails over automatically when the holder dies. The operator's existing gating (a pod-0 ordinal shell wrapper setting
HEARTBEAT_SCHEDULER_ENABLED) has two gaps that this PR closes:What changed (per commit)
dd3cef6feat(operator): lease-aware scheduler gating modes (ordinal|lease|auto)spec.heartbeat.schedulerGatingenum:ordinal(default, unchanged behavior),lease(no env manipulation; delegates to the app's lease election),auto(resolves toordinaltoday; flips toleaseonce the minimum supported app version ships lease leadership).SchedulerGatingValidcondition (excluded from theReadyaggregate) + Warning event for the Deployment+ordinal+replicas>1 combination; removed at replicas <= 1.a708e63feat(operator): scheduler leader visibilityWhile lease gating is active at replicas > 1, the operator polls each running server pod's unauthenticated
/api/healthfor the scheduler block and surfaces the lease holder:paperclip.inc/role=scheduleron the leader,webon the rest;status.schedulerLeaderrecords the leader pod name (per-pod poll failures skip the pod; when every poll fails the previous value is kept to avoid thrashing);controller.kubernetes.io/pod-deletion-coston the leader for Deployment workloads, so ReplicaSet scale-in prefers web replicas and avoids needless failovers;The health probe is injectable, so envtest covers labeling, status, deletion-cost, no-thrash, and the clear path without a pod network.
d615714test(e2e): image-gated scheduler lease failover scenario2-replica Deployment-workload Instance against an in-cluster
postgres:16(database.mode: external),schedulerGating: lease: asserts 2 ready pods, exactly one scheduler-labeled pod matchingstatus.schedulerLeader, then deletes the leader and asserts a different pod is labeled and recorded. Skipped by default -- it needs an app image containing lease leadership (unreleased), and runs only when an operator provides it:objectStorageis intentionally omitted in the scenario:MultiReplicaPreconditionsreports an advisoryFalse, the workload reconciles regardless, and the failover assertions touch no file state (keeps the suite free of a MinIO dependency).9b3a860docs: scheduler gating, version skew, migration, observabilityREADME "Scheduler gating and failover" subsection under Scaling.
Version skew
ordinal(default)leaseMigration recipe (order matters): upgrade the app image first, then set
schedulerGating: lease, then optionally drop any manualHEARTBEAT_SCHEDULER_ENABLEDpinning fromspec.env.Verification
make testgreen (unit + envtest, controller coverage includes the new gating condition and leader-visibility paths)make lint0 issuesgo vet ./test/... && go build ./test/...green;go test ./test/e2e/ -ginkgo.dry-runbuilds the spec tree (5 specs registered, including the new one)Skips inBeforeAllwhenPAPERCLIP_E2E_LEASE_IMAGEis unset, so a normalmake test-e2erun is unaffected; the full kind suite was not run hereDependencies
Mark ready for review after paperclip releases lease leadership and #81 merges.
🤖 Generated with Claude Code