stsresize

package
v1.0.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 29, 2026 License: Apache-2.0 Imports: 21 Imported by: 0

Documentation

Overview

Package stsresize converges a StatefulSet whose declared volume size grew.

The problem

A StatefulSet's volumeClaimTemplates are immutable. Raising the size an env declares for a StatefulSet's volume therefore makes every apply of the new render fail:

The StatefulSet "clickhouse" is invalid: spec: Forbidden: updates to
statefulset spec for fields other than 'replicas', 'ordinals', 'template',
'updateStrategy', 'persistentVolumeClaimRetentionPolicy' and
'minReadySeconds' are forbidden

(newer API servers say `spec.volumeClaimTemplates: Invalid value: ...: field is immutable`). The template is not even what decides the size of a running volume: it is read once, when the StatefulSet controller creates a claim, and never again. Growing a live volume means growing its PersistentVolumeClaim, which IS mutable, grow-only, when the claim's StorageClass allows expansion.

Without a converging step a declared size can never change after the first deploy: either the template is left at the old value and live and declared disagree for ever, or the apply wedges. Under a reconciler the wedge is total — Flux dry-runs every object of a Kustomization before applying any of them, so one rejected StatefulSet stops the whole unit from converging.

What this package does

The production procedure, made a function:

  1. Decide whether the ONLY difference the API server refuses is a growth of a volumeClaimTemplate's storage request. Anything else — a shrink, a changed storage class, access mode, selector or serviceName, an added or removed template — is refused with a reason, and nothing is written.
  2. Grow every existing claim of each grown template that is smaller than the declared size, after checking its StorageClass has allowVolumeExpansion. Claims already at or above the size are left alone (an operator may have expanded one ahead of the declaration; that is the declaration catching up, not drift). Claims of ordinals the StatefulSet has scaled away from are grown too, so a later scale-up does not bring back a small volume.
  3. Delete the StatefulSet object with propagationPolicy=Orphan and wait for it to be gone. Its pods, claims and ControllerRevisions keep running and lose only their owner reference.

The caller then re-applies the declared StatefulSet. The new object adopts the orphaned pods (they match its selector) and its revisions; when the pod template is unchanged the pods are not restarted, and when it changed the normal rolling update follows.

Every step is idempotent, so a converge interrupted at any point is finished by running it again: claims already grown are skipped, and a StatefulSet already orphan-deleted is simply created by the re-apply.

Who calls it

Two callers run the procedure, by two routes that share one decision:

  • forge's direct apply runs Converge when an apply fails on a StatefulSet's immutable spec (internal/cluster). It holds a cluster client, so it uses the Cluster interface.
  • A reconciled bundle has no such caller: Flux dry-runs every object of a Kustomization before applying any, so one rejected StatefulSet stops the whole tree, and nothing of forge's runs before it. The render therefore carries the procedure itself: Build makes a pre-rollout Job (with the namespace-scoped identity it runs as) whose container runs Script, the same steps in shell and jq. The bundle orders a pre tree ahead of the rollout tree, so the Job runs first and every write stays inside an identity the bundle declares.

The decision is pure (Decide); the cluster access is the Cluster interface. script_test.go runs Script and Decide over one table so the two cannot drift.

Index

Constants

View Source
const (
	// JobBaseName is the Job's name before its spec hash, and the name of its
	// ServiceAccount, Role and RoleBinding, in the default delivery unit.
	JobBaseName = "sts-volume-resize"
	// PodLabel is the label key every pod of the Job carries, valued with the
	// Job's base name.
	PodLabel = "forge.dev/job-name"
	// ContainerName is the Job's one container.
	ContainerName = "resize"
)

Names and labels of what Build emits. They are exported because a project that runs a default-deny network policy must let the Job's pod reach DNS and the API server, and selects that pod by PodLabel.

View Source
const DefaultImage = "docker.io/alpine/k8s:1.34.12@sha256:d50424976a0a888d2531f13035dce45c004ec2b6f877f233005ba35c0d61be5d"

DefaultImage is the image the resize Job runs: kubectl and jq, pinned by digest so the Job runs the same bytes on every cluster and a registry retagging cannot change what writes to a production volume. Bundle.statefulset_resize.image replaces it, for a mirror or a newer kubectl.

Variables

View Source
var (
	// ErrShrink is a declared volume size below the live template's. A
	// PersistentVolumeClaim cannot shrink, so the only honest outcome is to
	// refuse: lowering the template alone would describe volumes that do not
	// exist.
	ErrShrink = errors.New("stsresize: a volume cannot shrink")
	// ErrNotExpandable is a claim that must grow but whose StorageClass does
	// not set allowVolumeExpansion (or which has no class at all).
	ErrNotExpandable = errors.New("stsresize: the claim's storage class does not allow volume expansion")
	// ErrTemplateChanged is a volumeClaimTemplate change other than a larger
	// storage request: a template added, removed or renamed, or a changed
	// storage class, access mode, selector, volume mode, label or annotation.
	// Those describe different volumes, not bigger ones, and recreating the
	// StatefulSet would leave every existing claim contradicting the template.
	ErrTemplateChanged = errors.New("stsresize: a volumeClaimTemplate changed in a way other than a larger storage request")
	// ErrOtherImmutable is a change to another immutable StatefulSet field
	// (selector, serviceName, podManagementPolicy) alongside the volume
	// growth, as judged by the API server's own dry run. Orphan-recreating the
	// object then would create a StatefulSet that does not adopt the running
	// pods, so it is refused.
	ErrOtherImmutable = errors.New("stsresize: the StatefulSet changes an immutable field other than its volume sizes")
)

Refusals. Each wraps a detail naming the template or claim; match with errors.Is. Nothing has been written to the cluster when one is returned.

View Source
var Script string

Script is the converge procedure as a POSIX shell script that needs only kubectl and jq, for a Job that runs INSIDE the cluster before the workloads roll out. Converge is the same procedure for a caller holding a cluster client (forge's direct apply); the Script is for an applier that holds none, a Flux-reconciled bundle, whose only way to act before a rollout is an object in an earlier tree.

It reads RESIZE_NAMESPACE and RESIZE_DECLARED (a JSON array of Declared) and exits:

0  converged, or nothing to do
2  REFUSED: a shrink, or a change other than a larger storage request.
   Permanent; the Job declares exit 2 a non-retriable failure
1  anything else (transient; the Job retries)

Its decision is Decide's, restated in jq so the Job needs no forge binary; script_test.go runs both over one table of cases so they cannot drift. Two differences are deliberate. The script has no server-side dry run of the whole StatefulSet (it holds only the declared immutable fields, not the manifest), so it checks serviceName, podManagementPolicy and the selector itself. And it holds no cluster-scoped permission, so it cannot read StorageClasses: instead it dry-run-patches every claim before it writes any, and the API server's own expansion admission refuses a claim whose class does not allow it.

Functions

func DeclaredJSON added in v1.0.0

func DeclaredJSON(sets ...*appsv1.StatefulSet) (string, error)

DeclaredJSON is the Script's RESIZE_DECLARED value for a set of StatefulSets: a JSON array in the order given.

func Name added in v1.0.0

func Name(unit string) string

Name is the base name for a delivery unit's objects.

Types

type ClaimResize

type ClaimResize struct {
	Name         string
	Template     string
	StorageClass string
	From, To     resource.Quantity
}

ClaimResize is one PersistentVolumeClaim to grow to To.

type Cluster

type Cluster interface {
	// StatefulSet returns the live object, or nil and no error when absent.
	StatefulSet(ctx context.Context, namespace, name string) (*appsv1.StatefulSet, error)
	// PersistentVolumeClaims lists the claims in namespace.
	PersistentVolumeClaims(ctx context.Context, namespace string) ([]corev1.PersistentVolumeClaim, error)
	// StorageClasses lists the cluster's StorageClasses.
	StorageClasses(ctx context.Context) ([]storagev1.StorageClass, error)
	// DryRunApply server-side applies sts with dryRun=All, under the same
	// field manager the real apply uses, and returns the server's verdict.
	DryRunApply(ctx context.Context, sts *appsv1.StatefulSet) error
	// ResizeClaim sets a claim's spec.resources.requests.storage.
	ResizeClaim(ctx context.Context, namespace, name string, size resource.Quantity) error
	// OrphanStatefulSet deletes the StatefulSet with propagationPolicy=Orphan
	// and returns once it is gone. Absent is success.
	OrphanStatefulSet(ctx context.Context, namespace, name string) error
}

Cluster is the cluster access Converge needs. Declared here, by the one consumer, so each applier supplies its own client (forge's direct apply shells out to kubectl; a reconciler's converger holds a controller-runtime client).

type Declared added in v1.0.0

type Declared struct {
	Name                string                `json:"name"`
	ServiceName         string                `json:"serviceName,omitempty"`
	PodManagementPolicy string                `json:"podManagementPolicy,omitempty"`
	Selector            *metav1.LabelSelector `json:"selector,omitempty"`
	// VolumeClaimTemplates is in declaration order, which the live object
	// keeps.
	VolumeClaimTemplates []DeclaredTemplate `json:"volumeClaimTemplates"`
}

Declared is what the Script is told about one StatefulSet: the fields whose change it judges, and nothing else. It is the Job's input, so it is small and carries no pod template.

func DeclaredOf added in v1.0.0

func DeclaredOf(sts *appsv1.StatefulSet) Declared

DeclaredOf extracts what the Script judges from a StatefulSet.

type DeclaredTemplate added in v1.0.0

type DeclaredTemplate struct {
	Metadata DeclaredTemplateMeta             `json:"metadata"`
	Spec     corev1.PersistentVolumeClaimSpec `json:"spec"`
}

DeclaredTemplate is one volumeClaimTemplate, without the status and the null creationTimestamp a typed claim would carry.

type DeclaredTemplateMeta added in v1.0.0

type DeclaredTemplateMeta struct {
	Name        string            `json:"name"`
	Labels      map[string]string `json:"labels,omitempty"`
	Annotations map[string]string `json:"annotations,omitempty"`
}

DeclaredTemplateMeta is the part of a template's metadata the API server compares.

type JobSpec added in v1.0.0

type JobSpec struct {
	// Namespace is the StatefulSets' namespace. Required.
	Namespace string
	// Unit names the delivery unit, "" or "base" for the default one. It only
	// names the objects: a Job in a unit's pre tree gates that unit's rollout,
	// so each unit has its own, and two must not share a name.
	Unit string
	// Image is the Job's image; empty is [DefaultImage].
	Image string
	// StatefulSets are the DECLARED objects, all in Namespace, each with at
	// least one volumeClaimTemplate.
	StatefulSets []*appsv1.StatefulSet
	// Labels are stamped on the metadata of every object (never a pod): the
	// routing and ownership labels of the StatefulSets the Job serves, so the
	// objects deploy to the same cluster, in the same env and delivery unit.
	Labels map[string]string
	// ImagePullSecrets name existing pull Secrets in Namespace, put on the
	// Job's ServiceAccount like every forge-generated one.
	ImagePullSecrets []string
}

JobSpec is one resize Job's input: the StatefulSets of one namespace (and, for a project that splits its delivery units, of one unit) whose volumes it converges.

type Objects added in v1.0.0

type Objects struct {
	ServiceAccount *corev1.ServiceAccount
	Role           *rbacv1.Role
	RoleBinding    *rbacv1.RoleBinding
	// NetworkPolicy lets the Job's pod, and only it, reach DNS and the API
	// server in a namespace that runs default-deny. Policies are additive, so
	// it changes nothing where there is none.
	NetworkPolicy *networkingv1.NetworkPolicy
	Job           *batchv1.Job
}

Objects is what Build emits for one JobSpec.

func Build added in v1.0.0

func Build(spec JobSpec) (Objects, error)

Build assembles the pre-rollout Job that converges spec's StatefulSets, and the identity it runs as.

THE JOB'S NAME CARRIES A HASH OF ITS POD SPEC, like every forge one-shot: a Job's spec is immutable, so a changed spec must be a new object, and an unchanged one is a true no-op under both a re-apply and a Flux reconcile. The spec embeds the declared volume fields, so the Job runs again exactly when a declared volume changes, and not when only a pod template does.

THE IDENTITY IS NAMESPACE-SCOPED AND NARROW: get and delete on the named StatefulSets only, and get, list and patch on claims. It holds nothing cluster-scoped, which is why the script learns whether a claim may grow from the API server's own refusal of a dry-run patch rather than by reading StorageClasses.

func (Objects) Manifests added in v1.0.0

func (o Objects) Manifests() ([]map[string]any, error)

Manifests encodes the objects as the plain documents a render stream carries, in apply order, without the null creationTimestamp and empty status a typed object serializes.

type Plan

type Plan struct {
	Namespace string
	Name      string
	// Grown lists each volumeClaimTemplate whose storage request grows.
	Grown []TemplateGrowth
	// Resize lists each existing claim that must be grown, in name order.
	Resize []ClaimResize
	// Recreate reports that the StatefulSet object must be orphan-deleted and
	// re-applied, because its templates differ from the declared ones.
	Recreate bool
}

Plan is what converging one StatefulSet takes. The zero Plan (Recreate false) means there is nothing to converge: the live templates already declare the declared sizes.

func Converge

func Converge(ctx context.Context, c Cluster, desired *appsv1.StatefulSet) (Plan, error)

Converge makes the live cluster ready to accept desired: it plans with Decide, proves with a server-side dry run that the volume templates are the only immutable difference, grows the claims, and orphan-deletes the StatefulSet. The caller applies desired afterwards.

desired must carry its namespace. The returned Plan says what was done; when its Recreate is false nothing was written. On a refusal nothing was written either. An error after the claims were grown leaves them grown, which is correct on its own, and a re-run finishes the rest.

func Decide

func Decide(desired, live *appsv1.StatefulSet, claims []corev1.PersistentVolumeClaim, classes []storagev1.StorageClass) (Plan, error)

Decide computes the plan for converging live to desired, given the live claims in the StatefulSet's namespace and the cluster's StorageClasses. It is pure.

It judges the volumeClaimTemplates only. Whether another immutable field also changed is the API server's call, which Converge asks it with a dry run; a caller using Decide alone must make that check itself before acting on a Recreate plan.

A nil live StatefulSet yields the zero Plan: creating one has no immutable field to collide with.

func (Plan) String

func (p Plan) String() string

String is a one-line account of the plan, for an applier's log.

type TemplateGrowth

type TemplateGrowth struct {
	Template string
	From, To resource.Quantity
}

TemplateGrowth is one volumeClaimTemplate's storage request, live and declared.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL