Skip to content

Commit de2005d

Browse files
stubbiclaude
andauthored
feat: lease-aware scheduler gating, leader visibility, failover e2e (#82)
* feat(operator): lease-aware scheduler gating modes (ordinal|lease|auto) 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> * feat(operator): scheduler leader visibility — role labels, status, deletion-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> * test(e2e): image-gated scheduler lease failover scenario 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> * docs: scheduler gating modes, version-skew matrix, and failover guide 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> * fix(operator): present the service FQDN as Host when polling pod health 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> * fix(e2e): authenticated mode for the lease-failover scenario (local_trusted 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> * fix(rbac): grant jobs/status to the execution ClusterRole 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> * fix(rbac): operator role needs jobs/status to grant it (escalation prevention) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(chart): sync jobs/status into the chart RBAC Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(api): objectStorage.forcePathStyle, networkPolicy.extraEgress, netpol 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> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent a3871bf commit de2005d

21 files changed

Lines changed: 1573 additions & 28 deletions

README.md

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -530,13 +530,14 @@ spec:
530530

531531
### Heartbeat Scheduler
532532

533-
Paperclip runs a heartbeat scheduler for periodic agent tasks. In multi-replica deployments, only pod-0 (ordinal 0) runs the scheduler to prevent duplicate execution:
533+
Paperclip runs a heartbeat scheduler for periodic agent tasks. In multi-replica deployments only one replica may run it; by default the operator pins it to pod-0 (ordinal 0), and `schedulerGating` selects lease-based failover instead -- see [Scheduler gating and failover](#scheduler-gating-and-failover):
534534

535535
```yaml
536536
spec:
537537
heartbeat:
538-
enabled: true # default: true
539-
intervalMS: 60000 # default: 60000 (1 minute)
538+
enabled: true # default: true
539+
intervalMS: 60000 # default: 60000 (1 minute)
540+
schedulerGating: ordinal # default; "lease" enables automatic failover
540541
```
541542

542543
### Persistent Storage
@@ -637,6 +638,46 @@ scaleTargetRef:
637638
name: my-paperclip
638639
```
639640

641+
#### Scheduler gating and failover
642+
643+
The heartbeat scheduler must run on exactly one replica. `spec.heartbeat.schedulerGating` selects how that is enforced at `replicas > 1`:
644+
645+
```yaml
646+
spec:
647+
heartbeat:
648+
schedulerGating: lease # "ordinal" (default), "lease", or "auto"
649+
```
650+
651+
| Mode | How it works | Failover |
652+
|------|--------------|----------|
653+
| `ordinal` (default) | The operator wraps the container entrypoint so only pod-0 of the StatefulSet sets `HEARTBEAT_SCHEDULER_ENABLED=true`. StatefulSet only -- Deployment pods have no stable ordinals, so the wrapper is skipped and the operator reports `SchedulerGatingValid: False` | None: while pod-0 is down, no scheduler runs |
654+
| `lease` | The operator sets no scheduler env at all and delegates to the app's lease-based leader election (requires an app version with scheduler leases, paperclipai/paperclip#7995) | Automatic: a surviving replica takes over the lease |
655+
| `auto` | Currently resolves to `ordinal`; will flip to `lease` once the minimum supported app version ships lease leadership | Follows the resolved mode |
656+
657+
**Version skew.** What actually runs for each combination of operator gating mode and app image:
658+
659+
| Operator gating | App without leases | App with leases (>= the #7995 release) |
660+
|-----------------|--------------------|----------------------------------------|
661+
| `ordinal` (default) | pod-0 pinned, no failover | pod-0 pinned (the wrapper wins: only pod-0 is a lease candidate) |
662+
| `lease` | ALL replicas run the scheduler -- unsafe, do not use | automatic failover |
663+
664+
**Migrating from ordinal to lease.** Order matters: setting `lease` against an app image without lease support removes the only gate and every replica runs the scheduler.
665+
666+
1. Upgrade the app image to a version that includes lease-based scheduler leadership (paperclipai/paperclip#7995).
667+
2. Set `spec.heartbeat.schedulerGating: lease`.
668+
3. Optionally remove any manual `HEARTBEAT_SCHEDULER_ENABLED` pinning you carry in `spec.env`.
669+
670+
**Leader observability.** While lease gating is active at `replicas > 1`, the operator polls each server pod's unauthenticated `/api/health` and surfaces the lease holder:
671+
672+
- `status.schedulerLeader` records the leader pod name:
673+
674+
```bash
675+
kubectl get instance my-paperclip -o jsonpath='{.status.schedulerLeader}'
676+
```
677+
678+
- server pods are labeled `paperclip.inc/role=scheduler` (lease holder) or `paperclip.inc/role=web`
679+
- on Deployment workloads the leader pod also carries the `controller.kubernetes.io/pod-deletion-cost` annotation, so ReplicaSet scale-in prefers removing web replicas and avoids needless failovers
680+
640681
#### Horizontal Pod Autoscaler
641682

642683
```yaml

api/v1alpha1/paperclipinstance_types.go

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package v1alpha1
1818

1919
import (
2020
corev1 "k8s.io/api/core/v1"
21+
networkingv1 "k8s.io/api/networking/v1"
2122
"k8s.io/apimachinery/pkg/api/resource"
2223
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2324
)
@@ -502,6 +503,14 @@ type ObjectStorageSpec struct {
502503
// CredentialsSecretRef references a Secret containing AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
503504
// +optional
504505
CredentialsSecretRef *corev1.LocalObjectReference `json:"credentialsSecretRef,omitempty"`
506+
507+
// ForcePathStyle forces path-style S3 addressing (bucket in the URL path
508+
// rather than the hostname). Defaults to true when provider is "minio"
509+
// (virtual-hosted addressing needs wildcard DNS that in-cluster MinIO
510+
// deployments don't have), false otherwise. Maps to
511+
// PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE.
512+
// +optional
513+
ForcePathStyle *bool `json:"forcePathStyle,omitempty"`
505514
}
506515

507516
// HeartbeatSpec configures the agent heartbeat scheduler.
@@ -515,6 +524,21 @@ type HeartbeatSpec struct {
515524
// +kubebuilder:default=60000
516525
// +optional
517526
IntervalMS int32 `json:"intervalMS,omitempty"`
527+
528+
// SchedulerGating selects how the heartbeat scheduler is pinned to a single
529+
// replica when replicas > 1.
530+
// - "ordinal" (default): a shell wrapper enables the scheduler only on the
531+
// StatefulSet's ordinal-0 pod. Works with every app version, but has no
532+
// failover and requires the StatefulSet workload.
533+
// - "lease": no env manipulation - every replica participates in the app's
534+
// lease-based leader election with automatic failover. Requires an app
535+
// version with scheduler leases.
536+
// - "auto": currently behaves like "ordinal"; will default to "lease" once
537+
// the minimum supported app version includes lease leadership.
538+
// +kubebuilder:validation:Enum=ordinal;lease;auto
539+
// +kubebuilder:default=ordinal
540+
// +optional
541+
SchedulerGating string `json:"schedulerGating,omitempty"`
518542
}
519543

520544
// AdaptersSpec configures agent runtime adapters.
@@ -861,9 +885,12 @@ type SecuritySpec struct {
861885
// NetworkPolicySpec configures network isolation.
862886
type NetworkPolicySpec struct {
863887
// Enabled controls whether a NetworkPolicy is created. Defaults to true.
888+
// Pointer so an explicit `false` survives marshaling (a plain bool with
889+
// omitempty is dropped and re-defaulted to true by the API server on
890+
// every controller update — same bug class as PersistenceSpec.Enabled).
864891
// +kubebuilder:default=true
865892
// +optional
866-
Enabled bool `json:"enabled,omitempty"`
893+
Enabled *bool `json:"enabled,omitempty"`
867894

868895
// AllowIngressCIDRs specifies additional CIDR blocks allowed to reach the Paperclip service.
869896
// +optional
@@ -876,6 +903,14 @@ type NetworkPolicySpec struct {
876903
// +listType=set
877904
// +kubebuilder:validation:items:Pattern=`^([0-9]{1,3}\.){3}[0-9]{1,3}/[0-9]{1,2}$`
878905
AllowEgressCIDRs []string `json:"allowEgressCIDRs,omitempty"`
906+
907+
// ExtraEgress appends additional egress rules verbatim to the
908+
// operator-managed NetworkPolicy — e.g. to reach an in-cluster
909+
// object-storage endpoint (spec.objectStorage) or other services the
910+
// default rules don't cover. Required when networkPolicy is enabled and
911+
// objectStorage points at an in-cluster endpoint.
912+
// +optional
913+
ExtraEgress []networkingv1.NetworkPolicyEgressRule `json:"extraEgress,omitempty"`
879914
}
880915

881916
// RBACSpec configures ServiceAccount and RBAC.
@@ -1264,6 +1299,14 @@ type BackupS3Spec struct {
12641299
// CredentialsSecretRef references a Secret containing AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
12651300
// +optional
12661301
CredentialsSecretRef *corev1.LocalObjectReference `json:"credentialsSecretRef,omitempty"`
1302+
1303+
// ForcePathStyle forces path-style S3 addressing (bucket in the URL path
1304+
// rather than the hostname). Defaults to true when provider is "minio"
1305+
// (virtual-hosted addressing needs wildcard DNS that in-cluster MinIO
1306+
// deployments don't have), false otherwise. Maps to
1307+
// PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE.
1308+
// +optional
1309+
ForcePathStyle *bool `json:"forcePathStyle,omitempty"`
12671310
}
12681311

12691312
// --- Status types ---
@@ -1326,6 +1369,10 @@ type InstanceStatus struct {
13261369
// Selector is the label selector for the scale subresource (string form).
13271370
// +optional
13281371
Selector string `json:"selector,omitempty"`
1372+
1373+
// SchedulerLeader is the pod currently holding the scheduler lease (lease gating only).
1374+
// +optional
1375+
SchedulerLeader string `json:"schedulerLeader,omitempty"`
13291376
}
13301377

13311378
// ManagedResources tracks the names of managed Kubernetes resources.

api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 23 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)