Skip to content

Commit a3871bf

Browse files
stubbiclaude
andauthored
feat: Deployment workload profile, multi-replica preconditions, scale subresource (#81)
* feat(api): spec.workload field selecting the server workload kind Adds spec.workload (StatefulSet | Deployment | auto, default StatefulSet) to InstanceSpec. Deployment is intended for stateless multi-replica instances (external/managed database + object storage, persistence disabled); auto picks Deployment when persistence is disabled and the database mode is not embedded. Includes regenerated CRD and synced Helm chart CRD template. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(resources): extract shared server pod template Mechanically moves the pod template construction out of BuildStatefulSet into BuildServerPodTemplate (internal/resources/podtemplate.go) so a Deployment workload builder can share it. Renames StatefulSetReplicas to WorkloadReplicas since it will serve both workload kinds (no other callers, so no deprecated alias). The built StatefulSet is byte-identical: golden JSON dumps of eight instance variants (persistence on/off, multi-replica heartbeat, tailscale, platform-admin, k8s execution, object storage, suspended) match exactly before and after the extraction, and all existing builder tests pass unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(resources): Deployment workload builder Adds BuildDeployment, sharing BuildServerPodTemplate, the workload name (DeploymentName == StatefulSetName), labels, selector, and replica handling (WorkloadReplicas, incl. suspend scale-to-zero) with the StatefulSet builder so the Service keeps selecting the server pods by label across workload kinds. Rollouts surge (maxSurge=1, maxUnavailable=0) so capacity never drops during updates. Also adds UseDeploymentWorkload resolving spec.workload, including the auto rule (no persistence AND non-embedded database). Covered by builder tests plus a truth-table test for the workload resolution. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(controller): workload-kind reconcile with safe kind migration Reconcile the server workload as a Deployment when spec.workload selects it (explicitly or via auto) and delete the stale counterpart kind afterwards (Normal event WorkloadKindMigrated), so a spec.workload flip migrates cleanly. reconcileDeployment mirrors reconcileStatefulSet including HPA replica preservation and suspend scale-to-zero, and maintains a DeploymentReady condition (the counterpart kind's readiness condition is removed to avoid stale gating). PVC-safety override: spec.workload=Deployment with persistence enabled keeps the StatefulSet and reports the advisory WorkloadProfileValid=False (reason PersistenceRequiresStatefulSet, Warning event) instead of attaching the RWO data PVC to surging Deployment pods. The HPA scaleTargetRef follows the effective workload kind via the new resources.EffectiveWorkloadIsDeployment helper. Fix a round-trip bug that blocked stateless profiles entirely: PersistenceSpec.Enabled was a plain bool with omitempty, so enabled=false was dropped on marshal and re-defaulted to true by the API server on every controller update of the CR (e.g. adding the finalizer). It is now *bool (generated CRD schema is unchanged) read through resources.PersistenceEnabled. Adds apps/deployments RBAC (marker, role.yaml, Helm chart) and the Deployment to the controller's Owns watches. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(controller): multi-replica precondition and PDB warnings Before the workload step, maintain the advisory MultiReplicaPreconditions condition whenever effective replicas > 1: False (Warning event, once per transition) when the embedded database is selected (it cannot be shared between replicas) or objectStorage is unset (required for shared file state), True when both prerequisites are met. At replicas <= 1 the condition is removed entirely so single-replica instances carry no stale noise. Advisory conditions are excluded from the Ready aggregation. Also emit a Warning event PDBMayBlockDrains (event only, no condition) when podDisruptionBudget.minAvailable >= autoScaling.minReplicas, since disruptionsAllowed is then 0 at minimum scale and node drains can stall. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(api): scale subresource and managedResources.deployment tracking Adds the scale subresource to the Instance CRD (specpath=.spec.availability.replicas, statuspath=.status.replicas, selectorpath=.status.selector) so kubectl scale and KEDA/HPA-style tooling can drive replicas through /scale. updateStatus populates status.replicas and status.selector from the active server workload (Deployment or StatefulSet; names and selectors are identical by construction, so the selector string is stable across kind migrations). kubectl scale writes spec.availability.replicas, which the reconcilers already ignore while autoScaling is enabled (HPA replica preservation), so scaling via /scale is a documented no-op under HPA and there is no controller/autoscaler fight. Also adds status.managedResources.deployment, set in reconcileDeployment and cleared in the StatefulSet path (and vice versa), closing the Task 2 leftover where a kind migration left no record of the active workload. Covered by an envtest case that GETs /scale, scales 1 -> 3 through the subresource, and asserts the Deployment spec, status.replicas, and status.selector follow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: workload profiles, scale subresource, and PDB guidance in scaling section Rewrites the README Scaling section: workload profile table (StatefulSet default vs Deployment for stateless multi-replica, auto), multi-replica preconditions and the MultiReplicaPreconditions condition, the PVC-safety rule behind WorkloadProfileValid, kubectl scale / KEDA scaleTargetRef usage of the new scale subresource (and that it is a no-op under HPA), and PDB drain guidance (minAvailable strictly below autoScaling.minReplicas, unhealthyPodEvictionPolicy: AlwaysAllow example). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: regenerate API reference for workload, scale, and condition fields Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent c3e4468 commit a3871bf

19 files changed

Lines changed: 1233 additions & 160 deletions

README.md

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,23 @@ spec:
595595

596596
### Scaling
597597

598+
#### Workload profiles
599+
600+
`spec.workload` selects how the server runs:
601+
602+
```yaml
603+
spec:
604+
workload: auto # "StatefulSet" (default), "Deployment", or "auto"
605+
```
606+
607+
| Profile | Use for | Behavior |
608+
|---------|---------|----------|
609+
| `StatefulSet` (default) | Single replica, persistence, or embedded database | Stable pod identity with a per-instance PVC; rolling updates replace pods in place |
610+
| `Deployment` | Stateless multi-replica (external/managed database + `objectStorage`, persistence off) | Surge rollouts (`maxSurge: 1`, `maxUnavailable: 0`) so capacity never drops, no AZ-pinned per-ordinal PVCs, HPA-friendly scale-in |
611+
| `auto` | Let the operator decide | Deployment when persistence is disabled and the database is not embedded; StatefulSet otherwise |
612+
613+
> **PVC safety:** `workload: Deployment` requires `storage.persistence.enabled: false` -- the ReadWriteOnce data PVC cannot be shared by surging Deployment pods. If persistence is still enabled, the operator keeps the StatefulSet and reports the `WorkloadProfileValid: False` condition.
614+
598615
#### Manual replicas
599616

600617
```yaml
@@ -603,7 +620,22 @@ spec:
603620
replicas: 3
604621
```
605622

606-
When running multiple replicas, use `database.mode: external` with a production-grade PostgreSQL service and configure `objectStorage` for shared file access. The operator ensures only pod-0 runs the heartbeat scheduler.
623+
When running multiple replicas, use `database.mode: external` (or `managed`) with a production-grade PostgreSQL service and configure `objectStorage` for shared file access -- the operator surfaces a `MultiReplicaPreconditions: False` condition (plus a Warning event) at `replicas > 1` until both are in place. The operator ensures only one pod runs the heartbeat scheduler.
624+
625+
The Instance CRD exposes the scale subresource (`status.replicas` / `status.selector` track the active workload), so standard tooling works:
626+
627+
```bash
628+
kubectl scale instance/my-paperclip --replicas=3
629+
```
630+
631+
External autoscalers like KEDA can target the instance directly:
632+
633+
```yaml
634+
scaleTargetRef:
635+
apiVersion: paperclip.inc/v1alpha1
636+
kind: Instance
637+
name: my-paperclip
638+
```
607639

608640
#### Horizontal Pod Autoscaler
609641

@@ -618,7 +650,7 @@ spec:
618650
targetMemoryUtilizationPercentage: 70 # optional
619651
```
620652

621-
When auto-scaling is enabled, the HPA manages the replica count and the StatefulSet's `replicas` field is set to nil.
653+
When auto-scaling is enabled, the HPA owns the replica count: the operator preserves the workload's current `replicas` on every reconcile, and `spec.availability.replicas` (including writes via `kubectl scale`) is ignored.
622654

623655
#### Pod Disruption Budget
624656

@@ -631,6 +663,23 @@ spec:
631663
# or: maxUnavailable: 1
632664
```
633665

666+
Keep `minAvailable` strictly below `autoScaling.minReplicas`: when they are equal, the PDB allows zero disruptions at minimum scale and node drains stall (the operator emits a `PDBMayBlockDrains` warning event). If unhealthy pods blocking drains is a concern, manage your own PDB instead of the operator's and set the eviction policy:
667+
668+
```yaml
669+
apiVersion: policy/v1
670+
kind: PodDisruptionBudget
671+
metadata:
672+
name: my-paperclip-pdb
673+
spec:
674+
minAvailable: 1
675+
unhealthyPodEvictionPolicy: AlwaysAllow # evict crash-looping pods during drains
676+
selector:
677+
matchLabels:
678+
app.kubernetes.io/name: paperclip
679+
app.kubernetes.io/instance: my-paperclip
680+
app.kubernetes.io/component: server
681+
```
682+
634683
#### Topology Spread Constraints
635684

636685
Spread pods across zones or nodes for improved availability:

api/v1alpha1/paperclipinstance_types.go

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,20 @@ type InstanceSpec struct {
124124
// +optional
125125
Availability AvailabilitySpec `json:"availability,omitempty"`
126126

127+
// Workload selects the server workload kind.
128+
// - "StatefulSet" (default): per-instance PVC, stable identity. Required for
129+
// embedded database mode and for persistence-backed instances.
130+
// - "Deployment": stateless pods (ephemeral scratch); intended for
131+
// database.mode external/managed with objectStorage when replicas > 1.
132+
// Enables surge rollouts and avoids AZ-pinned per-ordinal PVCs under
133+
// autoscaling. Requires storage.persistence.enabled=false.
134+
// - "auto": Deployment when persistence is disabled AND database mode is
135+
// not embedded; StatefulSet otherwise.
136+
// +kubebuilder:validation:Enum=StatefulSet;Deployment;auto
137+
// +kubebuilder:default=StatefulSet
138+
// +optional
139+
Workload string `json:"workload,omitempty"`
140+
127141
// Probes configures liveness, readiness, and startup probes.
128142
// +optional
129143
Probes ProbesSpec `json:"probes,omitempty"`
@@ -445,10 +459,14 @@ type StorageSpec struct {
445459

446460
// PersistenceSpec configures PVC settings.
447461
type PersistenceSpec struct {
462+
// A pointer is required for round-tripping: with a plain bool plus
463+
// omitempty, enabled=false is dropped on marshal and the API server
464+
// re-defaults it to true on every controller update of the CR.
465+
448466
// Enabled controls whether a PVC is created. Defaults to true.
449467
// +kubebuilder:default=true
450468
// +optional
451-
Enabled bool `json:"enabled,omitempty"`
469+
Enabled *bool `json:"enabled,omitempty"`
452470

453471
// Size is the PVC storage size.
454472
// +kubebuilder:default="5Gi"
@@ -1065,8 +1083,10 @@ type LoggingSpec struct {
10651083

10661084
// AvailabilitySpec configures scaling and pod scheduling.
10671085
type AvailabilitySpec struct {
1068-
// Replicas is the desired number of Paperclip server pods.
1069-
// Ignored when autoScaling is enabled (the HPA manages replicas).
1086+
// Replicas is the desired number of Paperclip server pods. This field is
1087+
// also the target of the scale subresource, so `kubectl scale` writes it.
1088+
// Ignored when autoScaling is enabled (the HPA manages replicas), which
1089+
// makes `kubectl scale` a no-op while the HPA is active.
10701090
// +kubebuilder:default=1
10711091
// +kubebuilder:validation:Minimum=1
10721092
// +optional
@@ -1298,13 +1318,23 @@ type InstanceStatus struct {
12981318
// AutoUpdate tracks the state of automatic image update checks.
12991319
// +optional
13001320
AutoUpdate *AutoUpdateStatus `json:"autoUpdate,omitempty"`
1321+
1322+
// Replicas is the observed replica count of the active server workload (scale subresource).
1323+
// +optional
1324+
Replicas int32 `json:"replicas,omitempty"`
1325+
1326+
// Selector is the label selector for the scale subresource (string form).
1327+
// +optional
1328+
Selector string `json:"selector,omitempty"`
13011329
}
13021330

13031331
// ManagedResources tracks the names of managed Kubernetes resources.
13041332
type ManagedResources struct {
13051333
// +optional
13061334
StatefulSet string `json:"statefulSet,omitempty"`
13071335
// +optional
1336+
Deployment string `json:"deployment,omitempty"`
1337+
// +optional
13081338
Service string `json:"service,omitempty"`
13091339
// +optional
13101340
ConfigMap string `json:"configMap,omitempty"`
@@ -1375,6 +1405,7 @@ type AutoUpdateStatus struct {
13751405

13761406
// +kubebuilder:object:root=true
13771407
// +kubebuilder:subresource:status
1408+
// +kubebuilder:subresource:scale:specpath=.spec.availability.replicas,statuspath=.status.replicas,selectorpath=.status.selector
13781409
// +kubebuilder:resource:shortName=pci
13791410
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
13801411
// +kubebuilder:printcolumn:name="Endpoint",type=string,JSONPath=`.status.endpoint`

api/v1alpha1/zz_generated.deepcopy.go

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

charts/paperclip-operator/templates/crds/paperclip.inc_instances.yaml

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1589,8 +1589,10 @@ spec:
15891589
replicas:
15901590
default: 1
15911591
description: |-
1592-
Replicas is the desired number of Paperclip server pods.
1593-
Ignored when autoScaling is enabled (the HPA manages replicas).
1592+
Replicas is the desired number of Paperclip server pods. This field is
1593+
also the target of the scale subresource, so `kubectl scale` writes it.
1594+
Ignored when autoScaling is enabled (the HPA manages replicas), which
1595+
makes `kubectl scale` a no-op while the HPA is active.
15941596
format: int32
15951597
minimum: 1
15961598
type: integer
@@ -8269,6 +8271,23 @@ spec:
82698271
type: object
82708272
type: object
82718273
type: object
8274+
workload:
8275+
default: StatefulSet
8276+
description: |-
8277+
Workload selects the server workload kind.
8278+
- "StatefulSet" (default): per-instance PVC, stable identity. Required for
8279+
embedded database mode and for persistence-backed instances.
8280+
- "Deployment": stateless pods (ephemeral scratch); intended for
8281+
database.mode external/managed with objectStorage when replicas > 1.
8282+
Enables surge rollouts and avoids AZ-pinned per-ordinal PVCs under
8283+
autoscaling. Requires storage.persistence.enabled=false.
8284+
- "auto": Deployment when persistence is disabled AND database mode is
8285+
not embedded; StatefulSet otherwise.
8286+
enum:
8287+
- StatefulSet
8288+
- Deployment
8289+
- auto
8290+
type: string
82728291
type: object
82738292
status:
82748293
description: InstanceStatus defines the observed state of Instance.
@@ -8381,6 +8400,8 @@ spec:
83818400
type: string
83828401
databaseStatefulSet:
83838402
type: string
8403+
deployment:
8404+
type: string
83848405
grafanaDashboardInstance:
83858406
type: string
83868407
grafanaDashboardOperator:
@@ -8421,6 +8442,11 @@ spec:
84218442
- Updating
84228443
- Suspended
84238444
type: string
8445+
replicas:
8446+
description: Replicas is the observed replica count of the active
8447+
server workload (scale subresource).
8448+
format: int32
8449+
type: integer
84248450
restore:
84258451
description: Restore tracks the state of the latest restore operation.
84268452
properties:
@@ -8432,10 +8458,18 @@ spec:
84328458
description: Result is the result of the restore.
84338459
type: string
84348460
type: object
8461+
selector:
8462+
description: Selector is the label selector for the scale subresource
8463+
(string form).
8464+
type: string
84358465
type: object
84368466
type: object
84378467
served: true
84388468
storage: true
84398469
subresources:
8470+
scale:
8471+
labelSelectorPath: .status.selector
8472+
specReplicasPath: .spec.availability.replicas
8473+
statusReplicasPath: .status.replicas
84408474
status: {}
84418475
{{- end }}

charts/paperclip-operator/templates/rbac.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ rules:
7070
verbs: ["get", "create", "delete"]
7171
# Apps
7272
- apiGroups: ["apps"]
73-
resources: ["statefulsets"]
73+
resources: ["deployments", "statefulsets"]
7474
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
7575
# Networking
7676
- apiGroups: ["networking.k8s.io"]

config/crd/bases/paperclip.inc_instances.yaml

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1583,8 +1583,10 @@ spec:
15831583
replicas:
15841584
default: 1
15851585
description: |-
1586-
Replicas is the desired number of Paperclip server pods.
1587-
Ignored when autoScaling is enabled (the HPA manages replicas).
1586+
Replicas is the desired number of Paperclip server pods. This field is
1587+
also the target of the scale subresource, so `kubectl scale` writes it.
1588+
Ignored when autoScaling is enabled (the HPA manages replicas), which
1589+
makes `kubectl scale` a no-op while the HPA is active.
15881590
format: int32
15891591
minimum: 1
15901592
type: integer
@@ -8263,6 +8265,23 @@ spec:
82638265
type: object
82648266
type: object
82658267
type: object
8268+
workload:
8269+
default: StatefulSet
8270+
description: |-
8271+
Workload selects the server workload kind.
8272+
- "StatefulSet" (default): per-instance PVC, stable identity. Required for
8273+
embedded database mode and for persistence-backed instances.
8274+
- "Deployment": stateless pods (ephemeral scratch); intended for
8275+
database.mode external/managed with objectStorage when replicas > 1.
8276+
Enables surge rollouts and avoids AZ-pinned per-ordinal PVCs under
8277+
autoscaling. Requires storage.persistence.enabled=false.
8278+
- "auto": Deployment when persistence is disabled AND database mode is
8279+
not embedded; StatefulSet otherwise.
8280+
enum:
8281+
- StatefulSet
8282+
- Deployment
8283+
- auto
8284+
type: string
82668285
type: object
82678286
status:
82688287
description: InstanceStatus defines the observed state of Instance.
@@ -8375,6 +8394,8 @@ spec:
83758394
type: string
83768395
databaseStatefulSet:
83778396
type: string
8397+
deployment:
8398+
type: string
83788399
grafanaDashboardInstance:
83798400
type: string
83808401
grafanaDashboardOperator:
@@ -8415,6 +8436,11 @@ spec:
84158436
- Updating
84168437
- Suspended
84178438
type: string
8439+
replicas:
8440+
description: Replicas is the observed replica count of the active
8441+
server workload (scale subresource).
8442+
format: int32
8443+
type: integer
84188444
restore:
84198445
description: Restore tracks the state of the latest restore operation.
84208446
properties:
@@ -8426,9 +8452,17 @@ spec:
84268452
description: Result is the result of the restore.
84278453
type: string
84288454
type: object
8455+
selector:
8456+
description: Selector is the label selector for the scale subresource
8457+
(string form).
8458+
type: string
84298459
type: object
84308460
type: object
84318461
served: true
84328462
storage: true
84338463
subresources:
8464+
scale:
8465+
labelSelectorPath: .status.selector
8466+
specReplicasPath: .spec.availability.replicas
8467+
statusReplicasPath: .status.replicas
84348468
status: {}

config/rbac/role.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ rules:
8383
- apiGroups:
8484
- apps
8585
resources:
86+
- deployments
8687
- statefulsets
8788
verbs:
8889
- create

docs/api-reference.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ _Appears in:_
208208

209209
| Field | Description | Default | Validation |
210210
| --- | --- | --- | --- |
211-
| `replicas` _integer_ | Replicas is the desired number of Paperclip server pods.<br />Ignored when autoScaling is enabled (the HPA manages replicas). | 1 | Minimum: 1 <br />Optional: \{\} <br /> |
211+
| `replicas` _integer_ | Replicas is the desired number of Paperclip server pods. This field is<br />also the target of the scale subresource, so `kubectl scale` writes it.<br />Ignored when autoScaling is enabled (the HPA manages replicas), which<br />makes `kubectl scale` a no-op while the HPA is active. | 1 | Minimum: 1 <br />Optional: \{\} <br /> |
212212
| `podDisruptionBudget` _[PDBSpec](#pdbspec)_ | PodDisruptionBudget configures the PDB. | | Optional: \{\} <br /> |
213213
| `autoScaling` _[AutoScalingSpec](#autoscalingspec)_ | AutoScaling configures the HorizontalPodAutoscaler. | | Optional: \{\} <br /> |
214214
| `nodeSelector` _object (keys:string, values:string)_ | NodeSelector specifies node selection constraints. | | Optional: \{\} <br /> |
@@ -601,6 +601,7 @@ _Appears in:_
601601
| `networking` _[NetworkingSpec](#networkingspec)_ | Networking configures service, ingress, and WebSocket settings. | | Optional: \{\} <br /> |
602602
| `observability` _[ObservabilitySpec](#observabilityspec)_ | Observability configures metrics, logging, and monitoring. | | Optional: \{\} <br /> |
603603
| `availability` _[AvailabilitySpec](#availabilityspec)_ | Availability configures scaling, PDB, and pod scheduling. | | Optional: \{\} <br /> |
604+
| `workload` _string_ | Workload selects the server workload kind.<br /> - "StatefulSet" (default): per-instance PVC, stable identity. Required for<br /> embedded database mode and for persistence-backed instances.<br /> - "Deployment": stateless pods (ephemeral scratch); intended for<br /> database.mode external/managed with objectStorage when replicas > 1.<br /> Enables surge rollouts and avoids AZ-pinned per-ordinal PVCs under<br /> autoscaling. Requires storage.persistence.enabled=false.<br /> - "auto": Deployment when persistence is disabled AND database mode is<br /> not embedded; StatefulSet otherwise. | StatefulSet | Enum: [StatefulSet Deployment auto] <br />Optional: \{\} <br /> |
604605
| `probes` _[ProbesSpec](#probesspec)_ | Probes configures liveness, readiness, and startup probes. | | Optional: \{\} <br /> |
605606
| `backup` _[BackupSpec](#backupspec)_ | Backup configures periodic backup to S3-compatible storage. | | Optional: \{\} <br /> |
606607
| `restoreFrom` _string_ | RestoreFrom specifies a remote backup path to restore from on first boot. | | Optional: \{\} <br /> |

0 commit comments

Comments
 (0)