Files
Aleksei Sviridkin 1d813c9445 fix(backups): refuse concurrent restores and skip the CNPG purge only for this restore's Cluster (#4754)
## What this PR does

Fixes #3322. An in-place Postgres (CNPG) restore decided whether to skip
deleting the live `Cluster` by comparing its `creationTimestamp` with
the RestoreJob's `StartedAt`. The timestamp says the Cluster is new, not
which restore rendered it, so two cases let a restore skip its purge and
report `Succeeded` on data from another restore:

1. Two RestoreJobs into the same application at once. The second sees
the Cluster the first produced, created after its own `StartedAt`, and
skips its purge.
2. The live Cluster is deleted while the restore waits for the WAL
archive. The still-active HelmRelease re-renders it from the previous
restore's values, again created after `StartedAt`.

### Refuse a restore into an application another restore holds

The RestoreJob controller now refuses, for every driver, a RestoreJob
that has not started yet when another unfinished RestoreJob in the
namespace writes into the same application and goes first. The refused
job fails with reason `ConcurrentRestore`, and its message names the job
that goes first. It fails instead of waiting, so the application is
never restored a second time later, after the first restore, by a job
its author may no longer expect to run.

A started restore goes before one that has not started. Between two that
have not, the older goes first, by creation time and then by name;
creation times have one-second precision, so two jobs created in the
same second are ordered by name. A running restore is never failed by a
newer one. The target application is resolved by `restoreJobTarget`, the
same helper the BackupJob hold uses, so a Velero restore into another
namespace does not collide with an in-place one.

The gate lists RestoreJobs through the manager's API reader, not the
informer cache. A job reconciled before the cache holds its peer's
`StartedAt` would otherwise see that peer as not started and, on a
same-second name tie, proceed alongside it. The controller runs a single
worker, so a peer's `StartedAt` written by an earlier reconcile is
already on the API server when the next job reads it; `SetupWithManager`
now sets `MaxConcurrentReconciles: 1` explicitly, since the guarantee
depends on it.

### Skip the CNPG purge only for this restore's Cluster

The restore patch already sets `bootstrap.newServerName` to
`restoredServerName(cluster, RestoreJob UID)`, a value unique to each
RestoreJob. The postgres chart now annotates the `Cluster` with it as
`postgres.cozystack.io/restored-server-name`. The annotation is rendered
even when the barman plugin is not; the plugin's `serverName` also
carries the value, but the plugin renders only with chart-level S3
coordinates. The driver skips the purge only when the annotation names
this RestoreJob. A Cluster produced by a concurrent restore, or
re-rendered from an earlier restore's values, carries another value and
is purged.

The mark lives on the rendered Cluster rather than in RestoreJob status.
That keeps it valid in the case the skip exists for: the purge
succeeded, but the `TargetPurged` status write failed. A UID recorded in
status was rejected in the code for exactly that reason. A Cluster
rendered by a chart that predates the annotation has none and keeps the
creation-time check, so a restore in flight across an upgrade behaves as
before.

### Tests

- `TestRestoreAhead` covers order, a started job going first, the name
tie-break, finished and terminating jobs, other applications, retargeted
restores and a Velero restore into another namespace.
- `TestReconcileRestore_ConcurrentRestoreIsRefused` checks that the
newer job fails with `ConcurrentRestore` and the running one is left
alone.
- `TestReconcileRestore_ConcurrentRestoreReadsPastTheCache` gives the
cache a stale, not-started peer and the API reader the started one, and
requires the job to fail with `ConcurrentRestore`.
- `TestCNPGClusterFreshlyRecovered` adds both cases from the issue: a
Cluster marked by another restore and created after `StartedAt` must be
purged. The timestamp-only cases are kept for the unmarked fallback.
- `TestReconcileCNPGRestore_RepeatInPlacePurgesStaleRecoveryCluster`
gains two reconcile-level cases. One purges a Cluster that another
restore marked, even though it was created after `StartedAt`. The other
runs a full purge, takes the mark from the `bootstrap.newServerName` the
restore patch wrote, drops `TargetPurged` as a lost status write would,
and requires a Cluster carrying that mark to survive, although it
predates `StartedAt`. That pins the purge decision to the value the
chart actually renders.
- `packages/apps/postgres/tests/restore_mark_test.yaml` checks that the
annotation is rendered with and without the barman plugin, and is absent
on a Cluster that was never restored.

Each new test was checked against the code it guards: disabling the gate
call, listing through the cache, flipping the started-job precedence,
ignoring the annotation, or deriving the expected mark from the app name
instead of the cluster name each turns one red. `go test
./internal/backupcontroller/...` and `helm unittest
packages/apps/postgres` (152 tests) pass. The restore flow was not run
against a live cluster.

`docs/operations/backup-classes.md` documents the one-restore-at-a-time
rule and the annotation.

### Screenshots

N/A — no UI changes.

### Downstream repositories

Walked the trigger map in `docs/agents/contributing.md` against the
diff: Go changes in `internal/backupcontroller` (no CRD or API type
change), one annotation added to the postgres chart's Cluster (no
`values.yaml` or `values.schema.json` change), and a docs paragraph. No
package is added or renamed, no platform values, ApplicationDefinition,
RestoreJob CRD, metric, label or namespace changes, so no trigger is
reached.

- [x] No downstream repository is affected by this change
- [ ] [cozystack/website](https://github.com/cozystack/website) -
follow-up:
- [ ]
[cozystack/terraform-provider-cozystack](https://github.com/cozystack/terraform-provider-cozystack)
- follow-up:
- [ ]
[cozystack/ansible-cozystack](https://github.com/cozystack/ansible-cozystack)
- follow-up:
- [ ] [cozystack/ccp](https://github.com/cozystack/ccp) - follow-up:
- [ ] [cozystack/talm](https://github.com/cozystack/talm) - follow-up:
- [ ] [cozystack/cozyhr](https://github.com/cozystack/cozyhr) -
follow-up:
- [ ] [cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy) -
follow-up:
- [ ]
[cozystack/cozystack-telemetry-server](https://github.com/cozystack/cozystack-telemetry-server)
- follow-up:
- [ ]
[cozystack/external-apps-example](https://github.com/cozystack/external-apps-example)
- follow-up:
- [ ] [cozystack/examples](https://github.com/cozystack/examples) -
follow-up:
- [ ] [cozystack/community](https://github.com/cozystack/community) -
follow-up:

### Release note

```release-note
fix(backups): a RestoreJob now fails with reason ConcurrentRestore when another unfinished RestoreJob is already restoring the same application, instead of running alongside it; create it again once the first restore has finished. An in-place Postgres restore now skips deleting the live Cluster only when that Cluster was rendered for this RestoreJob, identified by the new postgres.cozystack.io/restored-server-name annotation, so it no longer reports Succeeded on data from a concurrent or earlier restore.
```


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Restores targeting the same application are now handled one at a time.
A competing restore is refused rather than queued, with started restores
taking priority.
* PostgreSQL now calculates a default `shared_buffers` value when the
memory limit is below 512 MiB, while respecting explicitly configured
values.
* **Bug Fixes**
* Recovery clusters are more reliably distinguished from manual
recoveries and clusters created by other restores, reducing the risk of
purging the wrong cluster.
* **Documentation**
* Clarified restore ordering and how recovery-cluster cleanup is
determined.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-10-11 03:23:12 +03:00

480 lines
26 KiB
YAML

{{- $tlsEnabled := (include "postgres.tls.enabled" .) | eq "true" -}}
{{- $tlsRequired := (include "postgres.tls.required" .) | eq "true" -}}
{{- $autoscaling := (include "postgres.autoscaling.active" .) | eq "true" -}}
---
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: {{ .Release.Name }}
{{- with .Values.bootstrap.newServerName }}
{{- /* Rendered whether or not the barman plugin is: the CNPG restore driver
reads it to tell the Cluster its own restore produced from one rendered
for an earlier restore. */}}
annotations:
postgres.cozystack.io/restored-server-name: {{ . | quote }}
{{- end }}
spec:
{{- if $autoscaling }}
{{- /* Under active autoscaling KEDA owns the runtime count via the /scale
subresource. The chart SEEDS spec.instances at a constant = max(replicas,
effectiveMin) (postgres.autoscaling.activeSeed) rather than omitting it, for
three reasons: (1) with synchronous quorum an omitted field defaults to 1 and
CNPG's webhook rejects "maxSyncReplicas must be lower than the number of
instances"; (2) omitting it on an EXISTING cluster's enable/upgrade makes
three-way merge delete the field, CNPG re-defaults to 1, and every replica +
its PVC is decommissioned before KEDA re-clones (hours with no read replicas);
(3) seeding effectiveMin ALONE differs from the else-branch (instances:
replicas), so a dryRun/transition flip would rebase a staged live count down to
the bare floor. Seeding max(replicas, effectiveMin) equals the else-branch value
whenever replicas is staged at/above the floor, so those flips render the same
value and the live count does not dip; it still never undercuts the quorum floor.
Because the seed is constant, each reconcile renders the value it rendered last
time, so the client-side patch is empty and never reverts KEDA's higher live
value. This holds ONLY under client-side apply, where helm-controller patches
this CRD from the previous-vs-new rendered manifest and does not consult live
state (it leaves Helm's threeWayMergeForUnstructured at the default): the
platform's helm-controller (v1.5.0) defaults to SERVER-SIDE
apply, which force-owns every rendered field and would revert spec.instances from
KEDA's live /scale value on every reconcile. (Editing replicas / minReplicas /
quorum.maxSyncReplicas moves the constant, so that one reconcile DOES patch
spec.instances to the new seed — a documented floor-change caveat, see values.yaml.)
The postgres ApplicationDefinition
therefore forces client-side apply via the annotation
release.cozystack.io/helm-server-side-apply: "false"
(packages/system/postgres-rd/cozyrds/postgres.yaml), threaded onto the tenant
HelmRelease's Install/Upgrade.ServerSideApply in
pkg/registry/apps/application/rest.go (convertApplicationToHelmRelease) and
guarded by TestConvertApplicationToHelmRelease_ServerSideApplyOverride.
(Go-template comment, stripped at render.) */}}
instances: {{ include "postgres.autoscaling.activeSeed" . }}
{{- else }}
{{- /* Static-count path (autoscaling off / dryRun / transition), which renders
instances: <replicas>. CNPG's webhook rejects the Cluster if maxSyncReplicas
>= instances; guard the DISABLE case in particular, where an autoscaled +
sync-quorum cluster runs with instances seeded from effectiveMin while
replicas may sit at its default, so a naive disable would render an invalid
count and wedge the HelmRelease. Fail closed with an actionable message.
(Stripped at render.) */}}
{{- if le (int .Values.replicas) (int .Values.quorum.maxSyncReplicas) }}
{{- fail (printf "postgres: replicas (%d) must be greater than quorum.maxSyncReplicas (%d) - CNPG rejects a Cluster whose maxSyncReplicas is not lower than its instance count. Raise replicas above maxSyncReplicas. (If you are disabling autoscaling on a synchronous-quorum cluster, first set replicas to at least the live instance count: kubectl scale cluster.postgresql.cnpg.io/%s --replicas=<live count>, with the HelmRelease suspended.)" (int .Values.replicas) (int .Values.quorum.maxSyncReplicas) .Release.Name) }}
{{- end }}
instances: {{ .Values.replicas }}
{{- end }}
{{- if and $tlsEnabled .Values.external }}
# Operator-managed TLS. CNPG auto-generates its own self-signed CA and signs
# server + client + replication certs from it. The operator's default SAN list
# already covers the three built-in services (rw/r/ro) in four DNS forms each
# (bare, .ns, .ns.svc, .ns.svc.<cluster-domain>); only the external hostnames
# need to be appended via serverAltDNSNames.
#
# serverAltDNSNames is mutually exclusive with serverTLSSecret per the CNPG
# admission webhook, which is why the chart no longer renders a cert-manager
# chain — the operator owns the chain end-to-end.
#
# Trust anchor for clients: the key-free "<release>.tenant-ca" Secret the CA
# extraction controller publishes, as declared by the sentinel in
# templates/tenant-projection.yaml. Tenants read that Secret through the
# core.cozystack.io tenantsecrets API. It is neither <release>-credentials,
# which is chart-rendered and holds user passwords only, nor CNPG's
# <release>-ca, which holds the CA private key next to ca.crt.
#
# The second name is the per-engine one, meant for SNI routing through a
# TLS-passthrough listener on the tenant Gateway (*.postgres.<apex>). Both
# are issued so that a later move between the two hostname schemes reissues
# no certificate and changes no client's trust anchor; clients still change
# the host they dial.
{{- $apex := include "cozy-lib.ns-host-required" . }}
certificates:
serverAltDNSNames:
- {{ printf "%s.%s" .Release.Name $apex }}
- {{ printf "%s.postgres.%s" .Release.Name $apex }}
{{- end }}
{{/*
s3Credentials points at the operator-supplied Secret when
backup.s3CredentialsSecret.name is set; otherwise it falls back to the
chart-managed <release>-s3-creds Secret materialised from s3AccessKey/
s3SecretKey. The override path keeps credentials out of the Postgres
CR .spec — the CNPG backup driver uses it on restore.
*/}}
{{- $credCfg := default (dict) .Values.backup.s3CredentialsSecret }}
{{- $credSecretName := $credCfg.name | default (printf "%s-s3-creds" .Release.Name) }}
{{/*
Platform-managed default flow (useSystemBucket=true):
- The chart does NOT render spec.plugins or the barman-cloud
ObjectStore. The backup-controller's CNPG driver SSA-applies a
barmancloud.cnpg.io ObjectStore (carrying destinationPath/
endpointURL/credentials) and SSA-patches the live Cluster's
spec.plugins to reference it at first BackupJob time, using field
manager "cozystack-cnpg-backup-driver" with ForceOwnership (see
internal/backupcontroller/cnpgstrategy_controller.go
applyClusterPluginBackup). The postgres HelmRelease is applied
CLIENT-SIDE (release.cozystack.io/helm-server-side-apply: "false",
for the autoscaler seed invariant), and Helm's client-side patch
only diffs fields the chart RENDERS (previous-vs-new manifest);
since the chart never renders spec.plugins under useSystemBucket, a
subsequent Flux re-render leaves the driver's SSA+ForceOwnership
patch intact — no tug-of-war on the live Cluster (the two apply on
disjoint field sets).
Coordinates come from a single backupStorage values block in
the backupstrategy-controller chart. This eliminates the
endpoint-duplication failure mode (an operator switching the
platform endpoint would otherwise have to update both the
backupstrategy-controller and this chart).
- Consequence: WAL archiving via the plugin is not active until the
first BackupJob has run. WAL accumulates on the local PVC in the
meantime — fine for small / fresh clusters, but admins of large
Postgres releases switching mid-flight should run an ad-hoc
BackupJob immediately after enabling the flag.
- The chart-emitted <release>-s3-creds Secret is also skipped, so a
default install no longer leaks placeholder credentials into the
tenant namespace.
*/}}
{{- $destinationPath := .Values.backup.destinationPath }}
{{- $endpointURL := .Values.backup.endpointURL }}
{{/*
accessKeyIDKey/secretAccessKeyKey only apply when an external Secret was
supplied via backup.s3CredentialsSecret.name. The chart-managed
<release>-s3-creds Secret materialised by backup-secret.yaml hard-codes
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY, so applying overrides in
that mode would point CNPG at non-existent keys and break credential
reads. Default both names unconditionally and let the override take
effect only in external-Secret mode.
*/}}
{{- $credAccessKey := "AWS_ACCESS_KEY_ID" }}
{{- $credSecretKey := "AWS_SECRET_ACCESS_KEY" }}
{{- if $credCfg.name }}
{{- $credAccessKey = $credCfg.accessKeyIDKey | default "AWS_ACCESS_KEY_ID" }}
{{- $credSecretKey = $credCfg.secretAccessKeyKey | default "AWS_SECRET_ACCESS_KEY" }}
{{- end }}
{{/*
endpointCA references a Secret carrying the CA bundle the barman-cloud
plugin should trust. Required when the S3 endpoint serves a self-signed
certificate (the cozystack default seaweedfs-s3 ingress qualifies). The
same reference flows into both the backup ObjectStore's
spec.configuration.endpointCA and the recovery ObjectStore's
spec.configuration.endpointCA (referenced from externalClusters[].plugin)
so backup *and* recovery can verify TLS - missing it on the recovery
side made bootstrap.recovery panic in CNPG's instance-manager.
*/}}
{{- $caCfg := default (dict) .Values.backup.endpointCA }}
{{- $caKey := $caCfg.key | default "ca.crt" }}
{{/*
The barman-cloud plugin is chart-wired only when the legacy path has
real coordinates:
- backup.enabled=true AND
- useSystemBucket=false (platform flow lets the CNPG driver SSA-
patch spec.plugins + the ObjectStore instead) AND
- destinationPath is non-empty AND either an inline access/secret
pair is supplied OR an external Secret reference (s3Credentials-
Secret.name) is set.
Anything short of that means the user has enabled backup but not yet
supplied S3 coordinates — wiring the plugin against a half-empty
ObjectStore would just feed CNPG a broken archive_command. Tenants
opting into `useSystemBucket: true` keep spec.plugins unset until the
backup-controller's CNPG driver SSA-patches it on the first BackupJob.
The S3/barman settings live in the barmancloud.cnpg.io/ObjectStore CR
rendered at the bottom of this file (named after the release); the
Cluster only references it by name. Native spec.backup.barmanObjectStore
is deprecated in CNPG 1.27 and removed in 1.29. The bare (system-flavor)
image tags this chart pins still ship the barman-cloud-* binaries the
native path needs (unlike the `standard` variant keycloak-db pins), but
the system flavor is deprecated upstream, so this chart moves to the
plugin as well.
*/}}
{{- $hasInlineCreds := and (ne (default "" .Values.backup.s3AccessKey) "") (ne (default "" .Values.backup.s3SecretKey) "") }}
{{- $hasLegacyCreds := or $hasInlineCreds (ne (default "" $credCfg.name) "") }}
{{- $renderBarman := and .Values.backup.enabled (not .Values.backup.useSystemBucket) (ne $destinationPath "") $hasLegacyCreds }}
{{- if $renderBarman }}
plugins:
- name: barman-cloud.cloudnative-pg.io
isWALArchiver: true
parameters:
barmanObjectName: {{ .Release.Name }}
# Pin the WAL-archive S3 prefix explicitly (the plugin would default it
# to the Cluster name anyway); the driver preserves it on a live cluster.
# On restore bootstrap.newServerName overrides it with a fresh prefix, so
# the re-bootstrapped cluster does not archive onto the recovery source.
serverName: {{ .Values.bootstrap.newServerName | default .Release.Name }}
{{- end }}
{{/*
bootstrap.enabled + backup.useSystemBucket is unsupported in this
chart: bootstrap.recovery requires an explicit externalClusters entry
with destinationPath / endpointURL / s3Credentials, but useSystemBucket
deliberately leaves the platform coordinates out of values.yaml (they
live in the backupstrategy-controller chart). Rendering the bootstrap
block here with empty coordinates would produce a broken cnpg.io
Cluster CR and a confusing CNPG bootstrap failure.
The supported way to restore against the platform bucket is a
backups.cozystack.io/RestoreJob targeting a fresh Postgres app — the
backup-controller's CNPG driver SSA-patches the Cluster's
spec.bootstrap before CNPG sees it. See the BackupClass and Restore
flow in docs/operations/backup-classes.md.
Fail fast at chart-render time so tenants see the misconfiguration
immediately rather than during a Postgres bootstrap pod crash loop.
*/}}
{{- if and .Values.bootstrap.enabled .Values.backup.useSystemBucket }}
{{- fail "postgres: bootstrap.enabled=true is incompatible with backup.useSystemBucket=true. Use a backups.cozystack.io/RestoreJob against the cozy-default BackupClass to restore from the system bucket; see docs/operations/backup-classes.md." }}
{{- end }}
{{/*
The externalClusters bootstrap block below references $credSecretName
(default <release>-s3-creds) for recovery. That Secret is only
materialised by backup-secret.yaml when inline creds are present, so a
legacy-flow bootstrap with no usable credentials would render a cnpg.io
Cluster whose recovery s3Credentials points at a Secret that never
exists — CNPG then crash-loops with an opaque "secret not found".
Fail closed at render time (mirroring the useSystemBucket guard above)
so the misconfiguration is obvious immediately. useSystemBucket-mode
restore is the RestoreJob flow, already rejected by the guard above.
*/}}
{{- if and .Values.bootstrap.enabled (not .Values.backup.useSystemBucket) (not $credCfg.name) (not $hasInlineCreds) }}
{{- fail "postgres: bootstrap.enabled=true requires S3 credentials: set backup.s3AccessKey+backup.s3SecretKey, backup.s3CredentialsSecret.name, or backup.useSystemBucket=true (with a backups.cozystack.io/RestoreJob)." }}
{{- end }}
{{- if .Values.bootstrap.enabled }}
bootstrap:
recovery:
source: {{ .Values.bootstrap.oldName }}
{{- if or .Values.bootstrap.recoveryTime .Values.bootstrap.backupID }}
recoveryTarget:
{{- with .Values.bootstrap.backupID }}
backupID: {{ . | quote }}
{{- end }}
{{- with .Values.bootstrap.recoveryTime }}
targetTime: {{ . }}
{{- end }}
{{- end }}
externalClusters:
- name: {{ .Values.bootstrap.oldName }}
plugin:
name: barman-cloud.cloudnative-pg.io
parameters:
# References the recovery ObjectStore rendered at the bottom of this
# file, which carries the source cluster's S3 coordinates. serverName
# (the source's S3 path prefix) rides on the plugin parameter, not the
# ObjectStore (the plugin forbids serverName there). It must always be
# set: the native path defaulted an absent serverName to the external
# cluster's NAME (= oldName), but the plugin defaults it to the NEW
# cluster's name — which would read the wrong S3 prefix on restore.
barmanObjectName: {{ .Release.Name }}-recovery
serverName: {{ .Values.bootstrap.serverName | default .Values.bootstrap.oldName }}
{{- end }}
resources: {{- include "cozy-lib.resources.defaultingSanitize" (list .Values.resourcesPreset .Values.resources $) | nindent 4 }}
{{- include "postgres.flavorGuard" $ }}
imageName: {{ include "postgres.imageName" $ }}
enableSuperuserAccess: true
{{- if .Values._cluster.scheduling }}
{{- $rawConstraints := get .Values._cluster.scheduling "globalAppTopologySpreadConstraints" }}
{{- if $rawConstraints }}
{{- $rawConstraints | fromYaml | toYaml | nindent 2 }}
labelSelector:
matchLabels:
cnpg.io/cluster: {{ .Release.Name }}
{{- end }}
{{- end }}
postgresql:
{{- if $tlsRequired }}
{{- /*
CloudNativePG offers TLS (ssl=on) but its built-in last rule, host all all
all, also accepts plaintext, so a client choosing sslmode=disable gets an
unencrypted session. User rules go after the operator's fixed rules
(local peer, streaming_replica cert) and before that default rule, and the
first match wins, so these two lines make remote sessions TLS-only.
https://cloudnative-pg.io/documentation/current/postgresql_conf/#the-pg_hba-section
*/}}
{{- /*
Same password method as CloudNativePG's own default rule: md5 before
PostgreSQL 14 (where password_encryption defaults to md5, and md5 also
accepts SCRAM verifiers), scram-sha-256 from 14 on.
pkg/management/postgres/configuration.go in cloudnative-pg.
*/}}
{{- $pgMajor := trimPrefix "v" .Values.version | atoi }}
{{- $authMethod := ternary "md5" "scram-sha-256" (lt $pgMajor 14) }}
{{- /*
With ssl turned off, hostssl never matches and the reject rule would refuse
every remote session; stop with an actionable error instead.
*/}}
{{- $sslParam := "" }}
{{- if and .Values.postgresql .Values.postgresql.parameters }}
{{- $sslParam = toString (dig "ssl" "" .Values.postgresql.parameters) | lower }}
{{- end }}
{{- if has $sslParam (list "off" "false" "0" "no") }}
{{- fail "postgresql.parameters.ssl is off while tls.required is in effect (external: true or tls.required: true): enable ssl or set tls.required: false" }}
{{- end }}
pg_hba:
- hostssl all all all {{ $authMethod }}
- host all all all reject
{{- end }}
{{- /*
Preloading is exposed as a closed allowlist rather than through the blocked
shared_preload_libraries parameter. The parameter is free text and accepts a
path, so it can point at a writable location; a bare name from this list can
only resolve inside $libdir, which lives on the read-only image filesystem.
The names below all ship with the CloudNativePG PostgreSQL image. The enum in
values.schema.json is the primary guard; this check backs it up for renders
that bypass schema validation. helm-unittest cannot reach it, since the schema
rejects first, so hack/check-postgres-preload-allowlist.bats pins it instead.
*/ -}}
{{- with .Values.postgresql.sharedPreloadLibraries }}
{{- $allowedPreload := list "pg_stat_statements" "auto_explain" "pgaudit" "pg_prewarm" }}
shared_preload_libraries:
{{- range . }}
{{- if not (has . $allowedPreload) }}
{{- fail (printf "SECURITY: shared preload library %q is not allowed. Allowed: %s. Loading a library from an arbitrary path enables code execution inside the instance." . (join ", " $allowedPreload)) }}
{{- end }}
- {{ quote . }}
{{- end }}
{{- end }}
parameters:
{{- $defaults := dict "max_wal_senders" "30" }}
{{- /*
PostgreSQL sizes shared_buffers at 128MB whatever the container gets.
The pool is shared memory, which the kernel cannot reclaim without
swap, so every page that fills stays charged against the memory limit.
A full pool is the whole limit on t1.nano and half of it on t1.micro,
the default preset. A small or idle database never fills it, but a
dump, index scans over a large table or a replica catching up after a
failover can, and then the OOM killer takes the instance. Below 512Mi
the default is therefore a quarter of the memory limit with a 16MB
floor, capped at the memory request: CloudNativePG rejects a Cluster
whose shared_buffers exceeds that request. From 512Mi up a quarter
is at least 128MB, so nothing is
rendered and PostgreSQL keeps its own default as before. An explicit
shared_buffers in postgresql.parameters still wins. The limit is read
through the same cozy-lib helper that fills spec.resources, so presets
and resources overrides resolve exactly as they do there.
*/}}
{{- $resources := include "cozy-lib.resources.defaultingSanitize" (list .Values.resourcesPreset .Values.resources $) | fromYaml }}
{{- with $resources.limits.memory }}
{{- /* 4194304 is 4Mi, so bytes divided by it is a quarter of the limit in MiB. PostgreSQL reads MB as MiB. */}}
{{- $quarterMi := div (include "cozy-lib.resources.toFloat" . | float64 | int64) 4194304 }}
{{- if lt $quarterMi 128 }}
{{- $requestMi := div (include "cozy-lib.resources.toFloat" $resources.requests.memory | float64 | int64) 1048576 }}
{{- $_ := set $defaults "shared_buffers" (printf "%dMB" (min $requestMi (max 16 $quarterMi))) }}
{{- end }}
{{- end }}
{{- $params := merge (dict) .Values.postgresql.parameters $defaults }}
{{- /* Security: Block parameters that enable arbitrary code execution (shell commands or .so loading) */ -}}
{{- $dangerousParams := list "archive_command" "restore_command" "ssl_passphrase_command" "archive_cleanup_command" "recovery_end_command" "dynamic_library_path" "local_preload_libraries" "session_preload_libraries" "shared_preload_libraries" }}
{{- range $key, $value := $params }}
{{- $normalizedKey := lower $key }}
{{- if has $normalizedKey $dangerousParams }}
{{- fail (printf "SECURITY: Parameter '%s' is blocked. This parameter can enable arbitrary code execution and must not be set by tenants." $key) }}
{{- end }}
{{ $key }}: {{ $value | toString | quote }}
{{- end }}
minSyncReplicas: {{ .Values.quorum.minSyncReplicas }}
maxSyncReplicas: {{ .Values.quorum.maxSyncReplicas }}
monitoring:
enablePodMonitor: true
storage:
size: {{ required ".Values.size is required" .Values.size }}
{{- with .Values.storageClass }}
storageClass: {{ . }}
{{- end }}
inheritedMetadata:
labels:
policy.cozystack.io/allow-to-apiserver: "true"
app.kubernetes.io/name: postgres.apps.cozystack.io
app.kubernetes.io/instance: {{ $.Release.Name }}
---
apiVersion: cozystack.io/v1alpha1
kind: WorkloadMonitor
metadata:
name: {{ $.Release.Name }}
labels:
workloads.cozystack.io/resource-preset: {{ .Values.resourcesPreset | quote }}
spec:
{{- /* Under active autoscaling the desired-replica count is set to the quorum FLOOR
(effectiveMin), NOT the seeded spec.instances (activeSeed = max(replicas,
effectiveMin)) and not KEDA's live autoscaled target: the seed is only the
starting count, KEDA owns the runtime count via /scale and legitimately idles the
cluster DOWN to effectiveMin under low load. NOTE the health decision does not
actually read this field today — WorkloadMonitorReconciler gates Status.Operational
solely on availableReplicas < minReplicas (below), so with minReplicas: 1 an idle
autoscaled cluster never false-alarms regardless of what replicas says. replicas is
the declared desired target (see the field doc: "desired number of replicas");
pinning it to effectiveMin keeps that target honest — the floor KEDA never goes
below, so should a future reconciler compare desired vs observed it still holds
actual >= desired, rather than false-flagging every idle-to-floor as the live
count (or the >floor seed) would. */}}
replicas: {{ if $autoscaling }}{{ include "postgres.autoscaling.effectiveMin" . }}{{ else }}{{ .Values.replicas }}{{ end }}
minReplicas: 1
kind: postgres
type: postgres
selector:
app.kubernetes.io/name: postgres.apps.cozystack.io
app.kubernetes.io/instance: {{ $.Release.Name }}
version: {{ $.Chart.Version }}
{{- if $renderBarman }}
---
# Backup ObjectStore for the barman-cloud plugin. Carries the S3/barman
# settings the deprecated spec.backup.barmanObjectStore used to hold; the
# Cluster references it by name via spec.plugins above. serverName is
# intentionally omitted (the plugin forbids it here and defaults it to the
# cluster name, preserving the s3://.../<cluster>/ layout).
apiVersion: barmancloud.cnpg.io/v1
kind: ObjectStore
metadata:
name: {{ .Release.Name }}
spec:
{{- if .Values.backup.retentionPolicy }}
retentionPolicy: {{ .Values.backup.retentionPolicy | quote }}
{{- end }}
# Size the barman-cloud sidecar explicitly instead of inheriting the tenant
# LimitRange default of 128Mi, and pin its S3 request checksum (rationale in
# cozy-lib.barman.sidecarConfiguration).
{{- include "cozy-lib.barman.sidecarConfiguration" . | nindent 2 }}
configuration:
destinationPath: {{ $destinationPath }}
endpointURL: {{ $endpointURL }}
{{- if $caCfg.name }}
endpointCA:
name: {{ $caCfg.name }}
key: {{ $caKey }}
{{- end }}
s3Credentials:
accessKeyId:
name: {{ $credSecretName }}
key: {{ $credAccessKey }}
secretAccessKey:
name: {{ $credSecretName }}
key: {{ $credSecretKey }}
{{- end }}
{{- if .Values.bootstrap.enabled }}
---
# Recovery ObjectStore for bootstrap. Points at the source cluster's S3
# coordinates; referenced from externalClusters[].plugin above. serverName
# rides on that plugin parameter, not here.
apiVersion: barmancloud.cnpg.io/v1
kind: ObjectStore
metadata:
name: {{ .Release.Name }}-recovery
spec:
# Same sidecar configuration as the backup ObjectStore above.
{{- include "cozy-lib.barman.sidecarConfiguration" . | nindent 2 }}
configuration:
destinationPath: {{ $destinationPath }}
endpointURL: {{ $endpointURL }}
{{- if $caCfg.name }}
endpointCA:
name: {{ $caCfg.name }}
key: {{ $caKey }}
{{- end }}
s3Credentials:
accessKeyId:
name: {{ $credSecretName }}
key: {{ $credAccessKey }}
secretAccessKey:
name: {{ $credSecretName }}
key: {{ $credSecretKey }}
{{- end }}