From f003851338f8e5824425fdc6234539b7f475c698 Mon Sep 17 00:00:00 2001 From: Mattia Eleuteri Date: Mon, 9 Nov 2026 11:18:27 +0100 Subject: [PATCH] fix(postgres): refuse system databases and never drop them postgres, template0 and template1 were accepted in databases and handled like an application database. The init job made _admin their owner, installed the auto-grant event trigger in them and, once the entry left values, ran DROP DATABASE on them. That DROP can only fail (template databases cannot be dropped, and the job is connected to postgres), so every later upgrade failed in the init job and the release stayed in UpgradeFailed even after the entry was put back. template1 also passed the takeover on to every database created from it afterwards, where the trigger, naming a role about to be dropped, makes CREATE SCHEMA fail. A release a previous version let take one of them over has to be repaired without dropping it, and without touching what this chart did not create: a role or owner that merely carries an expected name may belong to a tenant or an administrator, and a role comment can be set by any CREATEROLE holder, so only the cozystack:managed mark is trusted. What the marked roles own goes to cozystack:orphaned rather than to the superuser, since a tenant's SECURITY DEFINER function would otherwise run with superuser rights. A failed lookup of the databases cloned from template1 has to stop the run before template1 stops being selected, or those clones would never be repaired. Assisted-by: LLM Signed-off-by: Mattia Eleuteri --- api/apps/v1alpha1/postgresql/types.go | 2 +- hack/e2e-chainsaw/postgres/chainsaw-test.yaml | 145 ++++++++++ .../e2e-chainsaw/postgres/postgres-sysdb.yaml | 18 ++ hack/postgres-release-system-databases.bats | 261 ++++++++++++++++++ packages/apps/postgres/README.md | 14 +- .../apps/postgres/templates/init-script.yaml | 66 ++++- .../postgres/tests/system_databases_test.yaml | 135 +++++++++ packages/apps/postgres/values.schema.json | 2 +- packages/apps/postgres/values.yaml | 2 +- .../system/postgres-rd/cozyrds/postgres.yaml | 2 +- 10 files changed, 635 insertions(+), 12 deletions(-) create mode 100644 hack/e2e-chainsaw/postgres/postgres-sysdb.yaml create mode 100644 hack/postgres-release-system-databases.bats create mode 100644 packages/apps/postgres/tests/system_databases_test.yaml diff --git a/api/apps/v1alpha1/postgresql/types.go b/api/apps/v1alpha1/postgresql/types.go index f0415f398..4c0d80ab8 100644 --- a/api/apps/v1alpha1/postgresql/types.go +++ b/api/apps/v1alpha1/postgresql/types.go @@ -58,7 +58,7 @@ type ConfigSpec struct { // Users configuration map. Passwords are always auto-generated and stored in the `-credentials` Secret; they cannot be set from values — read a user's password from that Secret. A `password` left over in values from before the field was removed is ignored by the render and draws an admission warning, but it is not inert on an upgraded release: the chart preserves whatever password is already in the Secret, which on the first upgrade is the value that was set before removal, so that value stays the live credential until it is rotated (dedicated rotation is tracked in cozystack/community#72). The live password lives only in the Secret. // +kubebuilder:default:={} Users map[string]User `json:"users,omitempty"` - // Databases configuration map. + // Databases configuration map. The PostgreSQL system databases `postgres`, `template0` and `template1` are refused. // +kubebuilder:default:={} Databases map[string]Database `json:"databases,omitempty"` // Backup configuration. diff --git a/hack/e2e-chainsaw/postgres/chainsaw-test.yaml b/hack/e2e-chainsaw/postgres/chainsaw-test.yaml index 170c0ce36..bc0bc1c98 100644 --- a/hack/e2e-chainsaw/postgres/chainsaw-test.yaml +++ b/hack/e2e-chainsaw/postgres/chainsaw-test.yaml @@ -347,3 +347,148 @@ spec: set -eu cd ../../.. NAMESPACE="$NAMESPACE" examples/backups/postgres/cleanup.sh +--- +# A system database that a version before the refusal took over is handed back +# by the next init-job run, and only what this chart marked changes hands. +# +# The helm-unittest and bats suites check the rendered SQL against a fake psql; +# only a real PostgreSQL can say that the predicates select what they claim. The +# state an older version left is rebuilt by hand as the superuser, since the +# chart now refuses to produce it: +# * postgres is commented as managed and owned by a postgres_admin role that +# carries the cozystack:managed mark, as the chart left it: it must come back +# to the superuser, and the role must be dropped; +# * template1 is commented as managed, but its template1_admin carries only the +# role comment, which a CREATEROLE holder can forge, and not the mark: the +# comment must be cleared and everything that role owns or was granted must +# stay as it is, in template1 and in a database cloned from it. +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: postgres-3-system-databases +spec: + catch: + - events: {} + - describe: + apiVersion: helm.toolkit.fluxcd.io/v2 + kind: HelmRelease + name: postgres-sysdb + - podLogs: + selector: job-name=postgres-sysdb-init-job + tail: 200 + steps: + - name: create-postgres + try: + - apply: + file: postgres-sysdb.yaml + # 11m: above the 600s install timeout cozystack-api gives this release; + # see "Sizing an HR-Ready budget" in docs/agents/e2e-testing.md. + - assert: + timeout: 11m + resource: + apiVersion: helm.toolkit.fluxcd.io/v2 + kind: HelmRelease + metadata: + name: postgres-sysdb + status: + (conditions[?type == 'Ready']): + - status: "True" + - assert: + timeout: 2m + resource: + apiVersion: batch/v1 + kind: Job + metadata: + name: postgres-sysdb-init-job + status: + (conditions[?type == 'Complete']): + - status: "True" + + - name: rebuild-taken-over-system-databases + try: + - script: + timeout: 3m + content: | + set -eu + pod=$(kubectl -n "$NAMESPACE" get pod \ + -l cnpg.io/cluster=postgres-sysdb,cnpg.io/instanceRole=primary -o name | head -n1) + [ -n "$pod" ] || { echo "no primary pod for postgres-sysdb" >&2; exit 1; } + sql() { kubectl -n "$NAMESPACE" exec -i "$pod" -c postgres -- psql -v ON_ERROR_STOP=1 -U postgres -d "$1"; } + sql postgres <<'SQL' + CREATE ROLE postgres_admin NOLOGIN; + COMMENT ON ROLE postgres_admin IS 'role managed by helm'; + GRANT "cozystack:managed" TO postgres_admin WITH INHERIT FALSE, SET FALSE; + COMMENT ON DATABASE postgres IS 'database managed by helm'; + ALTER DATABASE postgres OWNER TO postgres_admin; + CREATE ROLE template1_admin NOLOGIN; + COMMENT ON ROLE template1_admin IS 'role managed by helm'; + CREATE ROLE template1_readonly NOLOGIN; + COMMENT ON DATABASE template1 IS 'database managed by helm'; + ALTER DATABASE template1 OWNER TO template1_admin; + SQL + sql template1 <<'SQL' + ALTER SCHEMA public OWNER TO template1_admin; + CREATE TABLE public.kept (id int); + ALTER TABLE public.kept OWNER TO template1_admin; + GRANT SELECT ON public.kept TO template1_readonly; + SQL + sql postgres <<'SQL' + CREATE DATABASE sysdb_clone TEMPLATE template1; + SQL + # The next run must be a new Job: remove the completed one so the + # assert below cannot match it. + kubectl -n "$NAMESPACE" delete job postgres-sysdb-init-job --wait=true + # Declaring a user is enough for an upgrade, and runs the init-job. + kubectl -n "$NAMESPACE" patch postgres.apps.cozystack.io sysdb --type merge \ + -p '{"spec":{"users":{"sysdbuser":{},"sysdbprobe":{}}}}' + check: + ($error == null): true + - assert: + timeout: 11m + resource: + apiVersion: batch/v1 + kind: Job + metadata: + name: postgres-sysdb-init-job + status: + (conditions[?type == 'Complete']): + - status: "True" + + - name: verify-release + try: + - script: + timeout: 2m + content: | + set -eu + pod=$(kubectl -n "$NAMESPACE" get pod \ + -l cnpg.io/cluster=postgres-sysdb,cnpg.io/instanceRole=primary -o name | head -n1) + q() { kubectl -n "$NAMESPACE" exec "$pod" -c postgres -- psql -v ON_ERROR_STOP=1 -U postgres -tA -d "$1" -c "$2"; } + fail=0 + expect() { + got=$(q "$1" "$3") + if [ "$got" != "$4" ]; then + echo "FAIL: $2: got '$got', want '$4'" >&2 + fail=1 + fi + } + comment="SELECT coalesce(shobj_description(oid, 'pg_database'), '') FROM pg_database WHERE datname = " + owner="SELECT pg_get_userbyid(datdba) FROM pg_database WHERE datname = " + expect postgres "postgres comment cleared" "$comment'postgres'" '' + expect postgres "postgres back to the superuser" "$owner'postgres'" postgres + expect postgres "marked postgres_admin dropped" "SELECT count(*) FROM pg_roles WHERE rolname = 'postgres_admin'" 0 + expect postgres "template1 comment cleared" "$comment'template1'" '' + expect postgres "unmarked template1_admin still exists" "SELECT count(*) FROM pg_roles WHERE rolname = 'template1_admin'" 1 + expect postgres "template1 still owned by the unmarked role" "$owner'template1'" template1_admin + for db in template1 sysdb_clone; do + expect "$db" "$db public still owned by the unmarked role" \ + "SELECT pg_get_userbyid(nspowner) FROM pg_namespace WHERE nspname = 'public'" template1_admin + expect "$db" "$db kept table still owned by the unmarked role" \ + "SELECT tableowner FROM pg_tables WHERE schemaname = 'public' AND tablename = 'kept'" template1_admin + expect "$db" "$db grant to the unmarked readonly role kept" \ + "SELECT has_table_privilege('template1_readonly', 'public.kept', 'SELECT')" t + done + exit "$fail" + check: + ($error == null): true +# The Postgres CR was applied by chainsaw; deleting it removes the whole +# cluster, the databases and roles built above included. diff --git a/hack/e2e-chainsaw/postgres/postgres-sysdb.yaml b/hack/e2e-chainsaw/postgres/postgres-sysdb.yaml new file mode 100644 index 000000000..975dc6a51 --- /dev/null +++ b/hack/e2e-chainsaw/postgres/postgres-sysdb.yaml @@ -0,0 +1,18 @@ +apiVersion: apps.cozystack.io/v1alpha1 +kind: Postgres +metadata: + name: sysdb +spec: + external: false + size: 1Gi + replicas: 1 + storageClass: "" + quorum: + minSyncReplicas: 0 + maxSyncReplicas: 0 + users: + sysdbuser: {} + databases: + appdb: {} + resources: {} + resourcesPreset: "nano" diff --git a/hack/postgres-release-system-databases.bats b/hack/postgres-release-system-databases.bats new file mode 100644 index 000000000..688f20df5 --- /dev/null +++ b/hack/postgres-release-system-databases.bats @@ -0,0 +1,261 @@ +#!/usr/bin/env bats +# Behavioural tests for the "release system databases" pass of the Postgres +# init script: +# +# packages/apps/postgres/templates/init-script.yaml +# +# The pass is cut out of the rendered script and RUN against a fake psql that +# records which database each session connects to. The helm-unittest suite in +# packages/apps/postgres/tests/system_databases_test.yaml matches the SQL text, +# and stayed green when the template0 guard was inverted: the rendered pass then +# connected to template0, which accepts no connection, and skipped postgres and +# template1, which are the two it exists to hand back. +# +# What is pinned here: +# * no session ever connects to template0; +# * postgres and template1 are each connected to and released; +# * every taken-over system database, template0 included, has its comment +# cleared; +# * releasing template1 visits every other connectable database, for the +# trigger it passed on to them, and a failed lookup of those databases +# stops the pass before the comment that selects template1 is cleared; +# * the session that releases a system database hands its public schema +# and the database itself back only from a marked _admin or +# _readonly owner (or cozystack:orphaned), before the reassignment, +# an inherited public only from a marked template1_admin, and touches +# only the roles that carry the +# "cozystack:managed" mark, not a role that merely has one of the names. +# +# Run from the repo root with make bats-unit-tests. Direct status checks +# retain compatibility with cozytest, which has no Bats run/setup helpers. +# Inline cleanup preserves Bats' TAP handler on failed assertions. + +load test_helper + +PG_CHART=packages/apps/postgres + +# render_pass +# Extract the release pass, from its banner to the next one, out of the rendered +# init script. The non-empty check must be an early `return 1`: hack/cozytest.sh +# rewrites every line matching ^}$ into `return 0` plus `}`, helpers included, +# so a check left in last-command position has its status discarded. +render_pass() { + helm template pg-test "$PG_CHART" --namespace tenant-test --set-json '_cluster={"scheduling":{}}' 2>/dev/null \ + | yq eval 'select(.metadata.name == "pg-test-init-script") | .stringData["init.sh"]' - \ + | awk '/^echo "== release system databases"/ { f = 1 } /^echo "== delete databases"/ { f = 0 } f' > "$1" + grep -q 'SYSTEM_DBS=' "$1" || return 1 +} + +# write_fake_psql +# Emit a fake psql. A `-c` query answers from $SYSTEM_DBS for the system +# database lookup and from $OTHER_DBS for the connectable-database lookup, +# which exits with $CLONE_LOOKUP_FAILS instead when that is set. A +# session fed on stdin logs "connect " ("connect -" without --dbname) +# followed by its first line and every -v db= value, to $PLOG, and its whole +# input under a "-- session db=" header to $PLOG.sql. +# No line below is a bare column-0 `}`, so cozytest.sh's parser leaves it alone. +write_fake_psql() { + cat > "$1/psql" <<'PEOF' +#!/bin/sh +dbname=- query= vdb= +while [ $# -gt 0 ]; do + case "$1" in + --dbname) dbname=$2; shift ;; + -c) query=$2; shift ;; + -v) case "$2" in db=*) vdb=${2#db=} ;; esac; shift ;; + esac + shift +done +if [ -n "$query" ]; then + case "$query" in + *"d.datname IN ('postgres', 'template0', 'template1')"*) printf '%s\n' $SYSTEM_DBS ;; + *datallowconn*) + if [ -n "${CLONE_LOOKUP_FAILS:-}" ]; then + echo "fake psql: connectable-database lookup failed" >&2 + exit "$CLONE_LOOKUP_FAILS" + fi + printf '%s\n' $OTHER_DBS ;; + *) echo "fake psql: unexpected query: $query" >&2; exit 64 ;; + esac + exit 0 +fi +sql=$(cat) +echo "connect $dbname db=$vdb $(printf '%s\n' "$sql" | head -n 1)" >> "$PLOG" +printf -- '-- session %s db=%s\n%s\n' "$dbname" "$vdb" "$sql" >> "$PLOG.sql" +PEOF + chmod +x "$1/psql" +} + +# run_pass +# Run the extracted pass with the fake psql first on PATH; status in $rc. +run_pass() { + export PLOG="$1/calls" + : > "$PLOG" + rc=0 + PATH="$1:$PATH" sh -e "$1/pass.sh" > "$1/out" 2> "$1/err" || rc=$? +} + +@test "a render that produces no release pass fails instead of passing as a no-op" { + tmp=$(mktemp -d) + if PG_CHART=packages/apps/no-such-chart render_pass "$tmp/pass.sh"; then + echo "FAIL: render_pass reported success for a chart that does not exist" + false + fi + rm -rf "$tmp" +} + +@test "release pass connects to postgres and template1, never to template0" { + tmp=$(mktemp -d) + write_fake_psql "$tmp" + render_pass "$tmp/pass.sh" + export SYSTEM_DBS="postgres template0 template1" OTHER_DBS="postgres app" + run_pass "$tmp" + + if [ "$rc" -ne 0 ]; then + echo "FAIL: the pass exited $rc" + cat "$tmp/out" "$tmp/err" + false + fi + if grep -q '^connect template0 ' "$tmp/calls"; then + echo "FAIL: the pass connected to template0, which accepts no connection" + cat "$tmp/calls" + false + fi + for db in postgres template1; do + if ! grep -q "^connect $db db=$db DROP EVENT TRIGGER IF EXISTS trigger_auto_grant;" "$tmp/calls"; then + echo "FAIL: $db was not connected to and released" + cat "$tmp/calls" + false + fi + done + for db in postgres template0 template1; do + if ! grep -q "^connect - db=$db COMMENT ON DATABASE :\"db\" IS NULL;" "$tmp/calls"; then + echo "FAIL: the comment on $db was not cleared" + cat "$tmp/calls" + false + fi + done + rm -rf "$tmp" +} + +@test "release session hands public back and touches only marked roles" { + tmp=$(mktemp -d) + write_fake_psql "$tmp" + render_pass "$tmp/pass.sh" + export SYSTEM_DBS="postgres" OTHER_DBS="postgres" + run_pass "$tmp" + + if [ "$rc" -ne 0 ]; then + echo "FAIL: the pass exited $rc" + cat "$tmp/out" "$tmp/err" + false + fi + awk '/^-- session / { f = ($0 == "-- session postgres db=postgres") } f' "$tmp/calls.sql" > "$tmp/release.sql" + # Each statement must end on its whole predicate: a clause appended after the + # mark (an OR, say) would select an unmarked role again. + reassign_where="WHERE EXISTS (SELECT FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid JOIN pg_roles u ON u.oid = m.member WHERE g.rolname = 'cozystack:managed' AND u.rolname = r)\\gexec" + owner_marked="(o.rolname = 'cozystack:orphaned' OR o.rolname IN (:'db' || '_admin', :'db' || '_readonly') AND EXISTS (SELECT FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid WHERE g.rolname = 'cozystack:managed' AND m.member = o.oid))\\gexec" + public_where="WHERE n.nspname = 'public' AND $owner_marked" + database_where="WHERE d.datname = :'db' AND $owner_marked" + public_line=$(grep -n 'ALTER SCHEMA public OWNER TO' "$tmp/release.sql" | head -1) + database_line=$(grep -n 'ALTER DATABASE' "$tmp/release.sql" | head -1) + reassign_line=$(grep -n 'REASSIGN OWNED BY' "$tmp/release.sql" | head -1) + if [ "${public_line##*"$public_where"}" != "" ] || [ -z "$public_line" ]; then + echo "FAIL: the release session does not hand public back only from a marked _admin or _readonly owner" + cat "$tmp/release.sql" + false + fi + if [ "${database_line##*"$database_where"}" != "" ] || [ -z "$database_line" ]; then + echo "FAIL: the release session does not take the database back only from a marked owner" + cat "$tmp/release.sql" + false + fi + if [ "${reassign_line##*"$reassign_where"}" != "" ] || [ -z "$reassign_line" ]; then + echo "FAIL: the release session does not reassign only roles carrying the cozystack:managed mark" + cat "$tmp/release.sql" + false + fi + # public and the database must be handed back before REASSIGN OWNED would + # move them to cozystack:orphaned. + if [ "${public_line%%:*}" -ge "${reassign_line%%:*}" ] || [ "${database_line%%:*}" -ge "${reassign_line%%:*}" ]; then + echo "FAIL: public or the database is handed back after the reassignment has already moved it" + cat "$tmp/release.sql" + false + fi + rm -rf "$tmp" +} + +@test "release pass of template1 visits every other connectable database" { + tmp=$(mktemp -d) + write_fake_psql "$tmp" + render_pass "$tmp/pass.sh" + export SYSTEM_DBS="template1" OTHER_DBS="postgres app" + run_pass "$tmp" + + if [ "$rc" -ne 0 ]; then + echo "FAIL: the pass exited $rc" + cat "$tmp/out" "$tmp/err" + false + fi + for db in postgres app; do + if ! grep -q "^connect $db db= SELECT 'DROP EVENT TRIGGER IF EXISTS trigger_auto_grant'" "$tmp/calls"; then + echo "FAIL: $db was not checked for the trigger inherited from template1" + cat "$tmp/calls" + false + fi + done + # An inherited public changes hands only from a template1_admin the chart + # marked; an owner that merely has the name keeps it. + inherited_where="WHERE n.nspname = 'public' AND o.rolname = 'template1_admin' AND EXISTS (SELECT FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid WHERE g.rolname = 'cozystack:managed' AND m.member = o.oid)\\gexec" + inherited_line=$(awk '/^-- session / { f = ($0 == "-- session app db=") } f' "$tmp/calls.sql" | grep 'ALTER SCHEMA public OWNER TO' | head -1) + if [ -z "$inherited_line" ] || [ "${inherited_line##*"$inherited_where"}" != "" ]; then + echo "FAIL: an inherited public is handed back without checking that a marked template1_admin owns it" + cat "$tmp/calls.sql" + false + fi + rm -rf "$tmp" +} + +@test "release pass of template1 stops before clearing its comment when the clone lookup fails" { + # Clearing the comment is what stops later runs from selecting template1, so + # a lookup failure that let the pass go on would skip the clone repair for + # good. + tmp=$(mktemp -d) + write_fake_psql "$tmp" + render_pass "$tmp/pass.sh" + export SYSTEM_DBS="template1" OTHER_DBS="postgres app" CLONE_LOOKUP_FAILS=73 + run_pass "$tmp" + unset CLONE_LOOKUP_FAILS + + if [ "$rc" -ne 73 ]; then + echo "FAIL: the pass exited $rc instead of stopping on the failed lookup (73)" + cat "$tmp/out" "$tmp/err" "$tmp/calls" + false + fi + if grep -q 'COMMENT ON DATABASE' "$tmp/calls"; then + echo "FAIL: the comment on template1 was cleared although its clones were never visited" + cat "$tmp/calls" + false + fi + rm -rf "$tmp" +} + +@test "release pass of postgres alone leaves the other databases alone" { + tmp=$(mktemp -d) + write_fake_psql "$tmp" + render_pass "$tmp/pass.sh" + export SYSTEM_DBS="postgres" OTHER_DBS="postgres app" + run_pass "$tmp" + + if [ "$rc" -ne 0 ]; then + echo "FAIL: the pass exited $rc" + cat "$tmp/out" "$tmp/err" + false + fi + if grep -q '^connect app ' "$tmp/calls"; then + echo "FAIL: the pass visited app although template1 was not taken over" + cat "$tmp/calls" + false + fi + rm -rf "$tmp" +} diff --git a/packages/apps/postgres/README.md b/packages/apps/postgres/README.md index 50c300aff..c508476f4 100644 --- a/packages/apps/postgres/README.md +++ b/packages/apps/postgres/README.md @@ -279,13 +279,13 @@ For `sslmode=verify-full` to work, the CA bundle retrieved above must be saved t ### Databases configuration -| Name | Description | Type | Value | -| -------------------------------- | ---------------------------------------- | ------------------- | ----- | -| `databases` | Databases configuration map. | `map[string]object` | `{}` | -| `databases[name].roles` | Roles assigned to users. | `object` | `{}` | -| `databases[name].roles.admin` | List of users with admin privileges. | `[]string` | `[]` | -| `databases[name].roles.readonly` | List of users with read-only privileges. | `[]string` | `[]` | -| `databases[name].extensions` | List of enabled PostgreSQL extensions. | `[]string` | `[]` | +| Name | Description | Type | Value | +| -------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------- | ----- | +| `databases` | Databases configuration map. The PostgreSQL system databases `postgres`, `template0` and `template1` are refused. | `map[string]object` | `{}` | +| `databases[name].roles` | Roles assigned to users. | `object` | `{}` | +| `databases[name].roles.admin` | List of users with admin privileges. | `[]string` | `[]` | +| `databases[name].roles.readonly` | List of users with read-only privileges. | `[]string` | `[]` | +| `databases[name].extensions` | List of enabled PostgreSQL extensions. | `[]string` | `[]` | ### Backup parameters diff --git a/packages/apps/postgres/templates/init-script.yaml b/packages/apps/postgres/templates/init-script.yaml index 395267ff0..6306a5fae 100644 --- a/packages/apps/postgres/templates/init-script.yaml +++ b/packages/apps/postgres/templates/init-script.yaml @@ -53,6 +53,9 @@ PostgreSQL identifiers too, capped at 63. */}} {{- range $database, $d := .Values.databases }} + {{- if has $database (list "postgres" "template0" "template1") }} + {{- fail (printf "databases.%s is refused: %q is a PostgreSQL system database. Managing it would hand it, its public schema and the auto-grant event trigger to the \"%s_admin\" role (template1 then passes all of that on to every database created afterwards), and removing the entry later could not drop it. Remove the entry and declare an application database instead; the next upgrade releases a system database a previous version took over, without dropping it" $database $database $database) }} + {{- end }} {{- if or (not (regexMatch "^[-._a-zA-Z0-9]+$" $database)) (gt (len $database) 54) }} {{- fail (printf "invalid database name %q: a database name may contain only letters, digits and the characters -._ and must be at most 54 characters (it is interpolated into superuser SQL and the init-script shell, and it seeds the \"_admin\" and \"_readonly\" PostgreSQL roles, which CREATE ROLE silently truncates past 63 bytes; the next init-job run then cannot drop the truncated role and fails). Changing only its key in values makes the init-job drop the database; to keep the data, a platform administrator first renames it as the CNPG superuser (ALTER DATABASE, and ALTER ROLE for its _admin and _readonly roles), then the key is changed" $database) }} {{- end }} @@ -320,6 +323,67 @@ stringData: EOT done + echo "== release system databases" + {{- /* + Versions that did not refuse postgres, template0 and template1 in + databases took them over like any other entry. DROP DATABASE on them can + only fail (template databases cannot be dropped, and this session is + connected to postgres), which wedged every later upgrade, so they are + handed back instead: the auto-grant trigger goes, since it names a role + about to be dropped and would make every CREATE SCHEMA fail; what the + _admin and _readonly roles own there goes to cozystack:orphaned, + as in the orphaned-roles pass and for the same reason (a tenant's + SECURITY DEFINER function must not end up owned by the superuser), and is + made SECURITY INVOKER (only a role marked "cozystack:managed" is touched: + a role that merely carries one of those names was not created by this + chart and keeps what it owns); the database itself returns to the superuser, + public to pg_database_owner (to the superuser before PostgreSQL 15), each + only when one of those marked roles owns it and before the reassignment + would move it (or when cozystack:orphaned does, after a run that stopped + in between); an owner the chart did not mark keeps what it owns, and + the comment is cleared. The orphaned-roles pass below then drops the + roles; it does not visit template1, hence the per-database pass here. + template0 accepts no connection, and the Job that commented it never got + further, so only the comment is cleared. A database created from + template1 while it was taken over inherited its trigger, naming + template1_admin, and its public schema owned by that role; the trigger + goes, public returns to its default owner if a marked template1_admin + still owns it, and a declared database gets + its trigger back from the grant pass further down. + */}} + SYSTEM_DBS=$(psql -v ON_ERROR_STOP=1 -t -A -c "SELECT datname FROM pg_database d JOIN pg_shdescription s ON d.oid = s.objoid WHERE s.description = 'database managed by helm' AND d.datname IN ('postgres', 'template0', 'template1')") + echo "system databases to release: $SYSTEM_DBS" + for db in $SYSTEM_DBS; do + if [ "$db" != template0 ]; then + psql -v ON_ERROR_STOP=1 --echo-all --dbname "$db" -v db="$db" <<\EOT + DROP EVENT TRIGGER IF EXISTS trigger_auto_grant; + DROP FUNCTION IF EXISTS auto_grant_schema_privileges(); + SELECT format('ALTER SCHEMA public OWNER TO %s', CASE WHEN current_setting('server_version_num')::int >= 150000 THEN 'pg_database_owner' ELSE 'CURRENT_USER' END) FROM pg_namespace n JOIN pg_roles o ON o.oid = n.nspowner WHERE n.nspname = 'public' AND (o.rolname = 'cozystack:orphaned' OR o.rolname IN (:'db' || '_admin', :'db' || '_readonly') AND EXISTS (SELECT FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid WHERE g.rolname = 'cozystack:managed' AND m.member = o.oid))\gexec + SELECT format('ALTER DATABASE %I OWNER TO CURRENT_USER', d.datname) FROM pg_database d JOIN pg_roles o ON o.oid = d.datdba WHERE d.datname = :'db' AND (o.rolname = 'cozystack:orphaned' OR o.rolname IN (:'db' || '_admin', :'db' || '_readonly') AND EXISTS (SELECT FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid WHERE g.rolname = 'cozystack:managed' AND m.member = o.oid))\gexec + SELECT format('REASSIGN OWNED BY %I TO "cozystack:orphaned"', r), format('DROP OWNED BY %I', r) FROM unnest(ARRAY[:'db' || '_admin', :'db' || '_readonly']) AS r WHERE EXISTS (SELECT FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid JOIN pg_roles u ON u.oid = m.member WHERE g.rolname = 'cozystack:managed' AND u.rolname = r)\gexec + SELECT format('ALTER %s %s SECURITY INVOKER', CASE p.prokind WHEN 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END, p.oid::regprocedure) FROM pg_proc p WHERE p.proowner = '"cozystack:orphaned"'::regrole AND p.prosecdef\gexec + SELECT format('ALTER VIEW %s SET (security_invoker = true)', c.oid::regclass) FROM pg_class c WHERE c.relowner = '"cozystack:orphaned"'::regrole AND c.relkind = 'v' AND current_setting('server_version_num')::int >= 150000 AND NOT coalesce('security_invoker=true' = ANY (c.reloptions), false)\gexec + EOT + fi + if [ "$db" = template1 ]; then + {{- /* + An assignment of its own, so that set -e stops the pass when the lookup + fails: inside the for list its status is discarded, the comment below is + cleared without a clone visited, and no later run selects template1 again. + */}} + CLONE_DBS=$(psql -v ON_ERROR_STOP=1 -t -A -c "SELECT datname FROM pg_database WHERE datallowconn AND NOT datistemplate") + for other in $CLONE_DBS; do + psql -v ON_ERROR_STOP=1 --echo-queries --dbname "$other" <<\EOT + SELECT 'DROP EVENT TRIGGER IF EXISTS trigger_auto_grant', 'DROP FUNCTION auto_grant_schema_privileges()' WHERE position('''template1_admin''' IN coalesce((SELECT prosrc FROM pg_proc WHERE oid = to_regprocedure('auto_grant_schema_privileges()')), '')) > 0\gexec + SELECT format('ALTER SCHEMA public OWNER TO %s', CASE WHEN current_setting('server_version_num')::int >= 150000 THEN 'pg_database_owner' ELSE 'CURRENT_USER' END) FROM pg_namespace n JOIN pg_roles o ON o.oid = n.nspowner WHERE n.nspname = 'public' AND o.rolname = 'template1_admin' AND EXISTS (SELECT FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid WHERE g.rolname = 'cozystack:managed' AND m.member = o.oid)\gexec + EOT + done + fi + psql -v ON_ERROR_STOP=1 --echo-all -v db="$db" <<\EOT + COMMENT ON DATABASE :"db" IS NULL; + EOT + done + echo "== delete databases" {{- /* A restore replaces spec.databases with the Backup's snapshot, but recovery @@ -338,7 +402,7 @@ stringData: ADOPTED_MARK=$(psql -v ON_ERROR_STOP=1 -t -A --dbname postgres -c "SELECT current_setting('cozystack.adopted_restore', true)") MANAGED_DBS=$(psql -v ON_ERROR_STOP=1 -t -A -c "SELECT datname FROM pg_database d JOIN pg_shdescription s ON d.oid = s.objoid WHERE s.description = 'database managed by helm'") DEFINED_DBS="{{ join " " (keys .Values.databases | sortAlpha) }}" - DELETE_DBS=$(for db in $MANAGED_DBS; do case " $DEFINED_DBS " in *" $db "*) :;; *) echo $db;; esac; done) + DELETE_DBS=$(for db in $MANAGED_DBS; do case " postgres template0 template1 $DEFINED_DBS " in *" $db "*) :;; *) echo $db;; esac; done) if [ -n "$RESTORE_MARK" ] && [ "$ADOPTED_MARK" != "$RESTORE_MARK" ]; then echo "databases the restore brought back that values do not declare, released from management: $DELETE_DBS" diff --git a/packages/apps/postgres/tests/system_databases_test.yaml b/packages/apps/postgres/tests/system_databases_test.yaml new file mode 100644 index 000000000..e4ff8400f --- /dev/null +++ b/packages/apps/postgres/tests/system_databases_test.yaml @@ -0,0 +1,135 @@ +suite: init-script never takes over or drops a system database + +# Each of postgres, template0 and template1 used to be accepted in databases and +# handled like an application database: owned by _admin, given the +# auto-grant event trigger, and on removal sent to DROP DATABASE, which fails on +# all three (template databases cannot be dropped, and the Job is connected to +# postgres) and wedged every later upgrade. template1 also passed the takeover +# on to every database created afterwards. + +templates: + - templates/init-script.yaml + +release: + name: pg-test + namespace: tenant-test + +set: + _cluster: + scheduling: {} + +tests: + - it: refuses postgres in databases + set: + databases: + postgres: {} + asserts: + - failedTemplate: + errorPattern: "databases.postgres is refused: \"postgres\" is a PostgreSQL system database" + + - it: refuses template0 in databases + set: + databases: + template0: {} + asserts: + - failedTemplate: + errorPattern: "databases.template0 is refused: \"template0\" is a PostgreSQL system database" + + - it: refuses template1 in databases + set: + databases: + template1: {} + asserts: + - failedTemplate: + errorPattern: "databases.template1 is refused: \"template1\" is a PostgreSQL system database" + + - it: still accepts an application database whose name contains a system name + set: + databases: + postgres_app: {} + mytemplate1: {} + documentSelector: + path: metadata.name + value: pg-test-init-script + asserts: + - matchRegex: + path: stringData["init.sh"] + pattern: 'CREATE DATABASE "postgres_app"' + + # A release that a previous version let manage a system database still carries + # its comment. The delete pass must never select it, whatever values say. + - it: never lists a system database for DROP DATABASE + set: + databases: + appdb: {} + documentSelector: + path: metadata.name + value: pg-test-init-script + asserts: + - matchRegex: + path: stringData["init.sh"] + pattern: 'DELETE_DBS=\$\(for db in \$MANAGED_DBS; do case " postgres template0 template1 \$DEFINED_DBS " in \*" \$db "\*\) :;; \*\) echo \$db;; esac; done\)' + + - it: hands a taken-over system database back before the delete pass + documentSelector: + path: metadata.name + value: pg-test-init-script + asserts: + - matchRegex: + path: stringData["init.sh"] + pattern: "SYSTEM_DBS=\\$\\(psql -v ON_ERROR_STOP=1 -t -A -c \"SELECT datname FROM pg_database d JOIN pg_shdescription s ON d.oid = s.objoid WHERE s.description = 'database managed by helm' AND d.datname IN \\('postgres', 'template0', 'template1'\\)\"\\)" + - matchRegex: + path: stringData["init.sh"] + pattern: '(?s)== release system databases.*DROP EVENT TRIGGER IF EXISTS trigger_auto_grant;\s+DROP FUNCTION IF EXISTS auto_grant_schema_privileges\(\);.*ALTER SCHEMA public OWNER TO %s.*ALTER DATABASE %I OWNER TO CURRENT_USER.*REASSIGN OWNED BY %I TO "cozystack:orphaned"''.*DROP OWNED BY %I.*COMMENT ON DATABASE :"db" IS NULL;.*== delete databases' + + # A tenant holding _admin may have created a SECURITY DEFINER function + # there; handed to the superuser it would run with superuser rights for + # anyone PUBLIC can execute it. It goes to cozystack:orphaned and is made + # SECURITY INVOKER, as in the orphaned-roles pass. + - it: never hands a released system database's objects to the superuser + documentSelector: + path: metadata.name + value: pg-test-init-script + asserts: + - notMatchRegex: + path: stringData["init.sh"] + pattern: 'REASSIGN OWNED BY %I TO CURRENT_USER' + - matchRegex: + path: stringData["init.sh"] + pattern: '(?s)== release system databases.*ALTER %s %s SECURITY INVOKER.*ALTER VIEW %s SET \(security_invoker = true\).*== delete databases' + + # A database created from template1 while it was taken over inherited the + # trigger naming template1_admin, which the orphaned-roles pass then drops; + # CREATE SCHEMA would fail there from that point on. + - it: drops the template1 trigger inherited by other databases + documentSelector: + path: metadata.name + value: pg-test-init-script + asserts: + - matchRegex: + path: stringData["init.sh"] + pattern: "(?s)== release system databases.*if \\[ \"\\$db\" = template1 \\]; then.*SELECT 'DROP EVENT TRIGGER IF EXISTS trigger_auto_grant', 'DROP FUNCTION auto_grant_schema_privileges\\(\\)' WHERE position\\('''template1_admin''' IN .*== delete databases" + + # That clone's public schema also came from template1, owned by + # template1_admin; left to the orphaned-roles pass it would end up owned by + # cozystack:orphaned and the database owner would lose CREATE on it. + - it: gives an inherited public schema back to its default owner + documentSelector: + path: metadata.name + value: pg-test-init-script + asserts: + - matchRegex: + path: stringData["init.sh"] + pattern: "(?s)if \\[ \"\\$db\" = template1 \\]; then.*SELECT format\\('ALTER SCHEMA public OWNER TO %s', .*\\) FROM pg_namespace n JOIN pg_roles o ON o.oid = n.nspowner WHERE n.nspname = 'public' AND o.rolname = 'template1_admin' AND EXISTS \\(SELECT FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid WHERE g.rolname = 'cozystack:managed' AND m.member = o.oid\\).*== delete databases" + + # template0 accepts no connection: only postgres and template1 are connected + # to and released, template0 just has its comment cleared. The behaviour is + # run against a fake psql in hack/postgres-release-system-databases.bats. + - it: connects to every released system database but template0 + documentSelector: + path: metadata.name + value: pg-test-init-script + asserts: + - matchRegex: + path: stringData["init.sh"] + pattern: "(?s)for db in \\$SYSTEM_DBS; do\\s+if \\[ \"\\$db\" != template0 \\]; then\\s+psql -v ON_ERROR_STOP=1 --echo-all --dbname \"\\$db\" -v db=\"\\$db\"" diff --git a/packages/apps/postgres/values.schema.json b/packages/apps/postgres/values.schema.json index 91747ba24..4d544ff6b 100644 --- a/packages/apps/postgres/values.schema.json +++ b/packages/apps/postgres/values.schema.json @@ -313,7 +313,7 @@ } }, "databases": { - "description": "Databases configuration map.", + "description": "Databases configuration map. The PostgreSQL system databases `postgres`, `template0` and `template1` are refused.", "type": "object", "default": {}, "additionalProperties": { diff --git a/packages/apps/postgres/values.yaml b/packages/apps/postgres/values.yaml index 83b658a64..fb4a67b35 100644 --- a/packages/apps/postgres/values.yaml +++ b/packages/apps/postgres/values.yaml @@ -287,7 +287,7 @@ users: {} ## @field {DatabaseRoles} [roles] - Roles assigned to users. ## @field {[]string} [extensions] - List of enabled PostgreSQL extensions. -## @param {map[string]Database} databases - Databases configuration map. +## @param {map[string]Database} databases - Databases configuration map. The PostgreSQL system databases `postgres`, `template0` and `template1` are refused. databases: {} ## Example: ## databases: diff --git a/packages/system/postgres-rd/cozyrds/postgres.yaml b/packages/system/postgres-rd/cozyrds/postgres.yaml index 39adc0f58..0cd63edd2 100644 --- a/packages/system/postgres-rd/cozyrds/postgres.yaml +++ b/packages/system/postgres-rd/cozyrds/postgres.yaml @@ -30,7 +30,7 @@ spec: singular: postgres plural: postgreses openAPISchema: |- - {"title":"Chart Values","type":"object","properties":{"replicas":{"description":"Number of Postgres replicas. Under active autoscaling this is the resting/seed count, not the live count — KEDA drives the live count via `/scale`. Disabling autoscaling does NOT automatically bring the live count back to `replicas`: the client-side merge writes `instances` only when the rendered value changes, so a cluster KEDA grew past `replicas` stays there with no autoscaler left to reduce it — lower `replicas` to the count you want and it rebases down (shedding the surplus replicas + PVCs). Lowering it below the live count, or disabling/dry-running while it sits below the live count, sheds in one step past the scaleDown pacing; stage it to the live count first (suspend the HelmRelease and `kubectl scale` if needed) to avoid a transient. Must stay greater than `quorum.maxSyncReplicas`.","type":"integer","default":2},"resources":{"description":"Explicit CPU and memory configuration for each PostgreSQL replica. When omitted, the preset defined in `resourcesPreset` is applied.","type":"object","default":{},"properties":{"cpu":{"description":"CPU available to each replica.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory (RAM) available to each replica.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"resourcesPreset":{"description":"Default sizing preset used when `resources` is omitted.","type":"string","default":"t1.micro","enum":["t1.nano","t1.micro","t1.small","t1.medium","t1.large","t1.xlarge","t1.2xlarge","t1.4xlarge","c1.nano","c1.micro","c1.small","c1.medium","c1.large","c1.xlarge","c1.2xlarge","c1.4xlarge","s1.nano","s1.micro","s1.small","s1.medium","s1.large","s1.xlarge","s1.2xlarge","s1.4xlarge","u1.nano","u1.micro","u1.small","u1.medium","u1.large","u1.xlarge","u1.2xlarge","u1.4xlarge","m1.nano","m1.micro","m1.small","m1.medium","m1.large","m1.xlarge","m1.2xlarge","m1.4xlarge","nano","micro","small","medium","large","xlarge","2xlarge"]},"size":{"description":"Persistent Volume Claim size available for application data.","default":"10Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"storageClass":{"description":"StorageClass used to store the data.","type":"string","default":"","x-kubernetes-validations":[{"rule":"self == oldSelf","message":"storageClass is immutable"}],"x-cozystack-options":{"source":"storageclass"}},"external":{"description":"Enable external access from outside the cluster.","type":"boolean","default":false},"version":{"description":"PostgreSQL major version to deploy","type":"string","default":"v18","enum":["v18","v17","v16","v15","v14","v13"]},"flavor":{"description":"PostgreSQL image flavor, fixed when the cluster is created. `postgresql` uses the CloudNativePG PostgreSQL image. `postgis` uses the CloudNativePG PostGIS image, which adds postgis, postgis_raster, postgis_topology, postgis_sfcgal, postgis_tiger_geocoder and address_standardizer; enable them per database in `databases..extensions`. Changing the flavor of an existing cluster is refused, and a backup restores only into a cluster of the flavor it was taken from: to change flavor, create a new cluster and move the data with a logical dump (pg_dump / pg_restore).","type":"string","default":"postgresql","enum":["postgresql","postgis"]},"tls":{"description":"TLS configuration for server connections.","type":"object","default":{},"properties":{"enabled":{"description":"Tri-state switch controlling whether the chart injects the external hostnames into the operator-managed CNPG cert via spec.certificates.serverAltDNSNames. When omitted, the chart injects them if `external: true` and skips them otherwise. Set explicitly to `true` to inject regardless of `external` (no-op when `external: false` since there is no external hostname to add). Set to `false` to skip injection. Note that CNPG keeps its built-in TLS on the wire regardless of this flag — this toggle only controls the chart-side SAN injection; to disable PostgreSQL TLS entirely set `postgresql.parameters.ssl = \"off\"` at the CNPG layer.","type":"boolean"},"required":{"description":"Tri-state switch controlling whether the server refuses sessions that are not encrypted. When omitted, it is on if `external: true` and off otherwise. When on, the chart renders `spec.postgresql.pg_hba` with `hostssl all all all scram-sha-256` (`md5` on PostgreSQL 13, matching CloudNativePG's default rule) followed by `host all all all reject`, so a client that connects with `sslmode=disable` is refused instead of getting a plaintext session; in-cluster clients are held to the same rule, since the pod network cannot be told apart from an external client. Set to `false` to keep accepting plaintext sessions on an external instance, or to `true` to require TLS on an in-cluster one. Rendering fails if `postgresql.parameters.ssl` is off while this is in effect.","type":"boolean"}}},"postgresql":{"description":"PostgreSQL server configuration.","type":"object","default":{},"properties":{"parameters":{"description":"PostgreSQL server parameters. Values may be strings or integers; integers are coerced to strings by the template (e.g. both `max_connections: 100` and `max_connections: \"100\"` are accepted). BLOCKED (enable arbitrary code execution): 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. Do NOT override CloudNativePG-managed parameters: archive_mode, primary_conninfo, wal_level, max_replication_slots.","type":"object","default":{"max_connections":"100"},"additionalProperties":{"anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}},"sharedPreloadLibraries":{"description":"Libraries loaded at server start, restricted to an allowlist of modules already shipped in the PostgreSQL image. CloudNativePG also preloads `pg_stat_statements`, `auto_explain` and `pgaudit` by itself whenever `parameters` sets a key under their prefix (e.g. `pg_stat_statements.track`), so removing one of them from this list does not unload it while such a key is set; `pg_prewarm` is preloaded only through this list. Changing the list restarts PostgreSQL on every instance, the primary included, so writes pause briefly.","type":"array","items":{"type":"string","enum":["pg_stat_statements","auto_explain","pgaudit","pg_prewarm"]}}}},"quorum":{"description":"Quorum configuration for synchronous replication.","type":"object","default":{},"required":["maxSyncReplicas","minSyncReplicas"],"properties":{"maxSyncReplicas":{"description":"Maximum number of synchronous replicas allowed (must be less than total replicas). Under autoscaling it also sets the scale-down floor (the effective minimum is `max(minReplicas, maxSyncReplicas+1, 2)`); the chart fails to render if that floor exceeds `autoscaling.maxReplicas`.","type":"integer","default":0},"minSyncReplicas":{"description":"Minimum number of synchronous replicas required for commit.","type":"integer","default":0}}},"autoscaling":{"description":"Read-replica autoscaling configuration.","type":"object","default":{},"required":["dryRun","enabled","maxReplicas","maxReplicationLagSeconds","metric","minReplicas","serverAddress","target","transition"],"properties":{"dryRun":{"description":"Recommendation mode: keep the static count and render the ScaledObject with both scaling directions paused, so KEDA keeps the HPA and keeps serving its trigger metric but actuates neither way. Note both scaling policies are disabled, so the HPA's desiredReplicas is frozen to the current count - read the recommendation from the matching read-load dashboard panel for your metric (connections or CPU): desired = 1 + ceil(read load / target), clamped to [effectiveMin..maxReplicas] where effectiveMin = max(minReplicas, maxSyncReplicas+1, 2), not from the desired-replicas line. Like disabling, flipping a LIVE autoscaled cluster into dryRun re-renders `instances: replicas` (the raw static value, which can be below the autoscaling floor); if `replicas` is below the live count, stage it to the live count first (see the enablement note) or the cluster sheds down to `replicas`. No shed occurs when the rendered value is unchanged (replicas already equals the active seed) — client-side merge is then a no-op and the live count is preserved. Permanent, unlike `transition`.","type":"boolean","default":false},"enabled":{"description":"Enable horizontal autoscaling of read replicas. Requires the platform `keda` package to be enabled by an administrator first; the chart fails to render (rather than break the release) if KEDA is not installed. Prerequisite: the queried vmselect must hold BOTH the CNPG metrics (cnpg_backends_total / container CPU) AND kube_pod_labels (kube-state-metrics). This holds for a tenant on the shared root monitoring stack (`monitoring: false`, both land in tenant-root). A tenant running its OWN Monitoring app (`monitoring: true`) has the CNPG metrics but not kube_pod_labels (KSM is root-only), so it must set `serverAddress` to a vmselect carrying both series or autoscaling never actuates.","type":"boolean","default":false},"maxReplicas":{"description":"Maximum total instances. Must be >= minReplicas and >= the synchronous-quorum floor (maxSyncReplicas+1); the chart fails to render (rather than silently run an unbounded instance count) if the effective floor exceeds it - raise maxReplicas. HAZARD: lowering maxReplicas below the live count makes the HPA cut straight to the new maximum in one step (the current>max branch bypasses the scale-down pacing), shedding the surplus replicas + PVCs at once; to lower it deliberately, suspend the HelmRelease and pin the live count with `kubectl scale` first.","type":"integer","default":6},"maxReplicationLagSeconds":{"description":"Freeze scaling while replication lag exceeds this (seconds) and the primary is writing. 0 disables the brake. Default 0: the freeze branch has only been validated on a live cluster in its non-braking (pass-through) form, so the lag brake is opt-in until the braking path is exercised under load; set a positive value (e.g. 30) to enable it.","type":"integer","default":0},"metric":{"description":"Read-load signal to scale on.","type":"string","default":"ReadConnections","enum":["ReadConnections","ReadCPUUtilization"]},"minReplicas":{"description":"Minimum total instances; raised to the synchronous-quorum floor (`maxSyncReplicas + 1`) and to 2 when either is higher.","type":"integer","default":2},"serverAddress":{"description":"Override the Prometheus/vmselect URL the ScaledObject queries. Empty (default) derives `http://vmselect-shortterm..svc:8481/select/0/prometheus`. Set it when the derived vmselect does not hold both the CNPG metrics and kube_pod_labels (e.g. `monitoring: true` tenants, or a non-default `metricsStorages` name). SSRF constraint: the host must be an in-cluster Service DNS name (`..svc[.]`) whose namespace is within the tenant's own subtree (its namespace, its parents, or `tenant-root`) — the cluster-privileged KEDA operator fetches this URL every poll, so it is refused a metadata endpoint, an external host, or a sibling/system namespace. Residual surface (accepted trade-off): `tenant-root` is always allowed because the derived default points there, so a tenant can still aim the operator at any Service in `tenant-root` on any port/path (a blind in-cluster reachability probe — the operator reads no response body back to the tenant); the egress NetworkPolicy does not narrow that.","type":"string","default":""},"target":{"description":"Target read load per read-serving replica (unit depends on `metric`: active connections, or CPU millicores). Dimensionless integer - a resource.Quantity here would accept `150m`, which PromQL reads as 150 minutes and KEDA cannot use as a threshold.","type":"integer","default":150},"transition":{"description":"Migration phase 1: render `.spec.instances` from `replicas` (which the operator stages to the current live count) AND pause the ScaledObject in both directions, so KEDA stands up with its HPA present but not actuating. Flip to false (phase 2) to hand the runtime count to KEDA. Because the both-direction pause keeps the HPA (not the HPA-deleting full pause), KEDA is genuinely warm at phase 2 — no HPA recreate — and because the active seed is max(replicas, effectiveMin), phase 2 re-renders the same instance count phase 1 did (when `replicas` is staged at/above the floor), so the flip is a merge no-op with no dip. The HPA's scaleUp stabilization window still smooths the first actuation.","type":"boolean","default":false}}},"users":{"description":"Users configuration map. Passwords are always auto-generated and stored in the `-credentials` Secret; they cannot be set from values — read a user's password from that Secret. A `password` left over in values from before the field was removed is ignored by the render and draws an admission warning, but it is not inert on an upgraded release: the chart preserves whatever password is already in the Secret, which on the first upgrade is the value that was set before removal, so that value stays the live credential until it is rotated (dedicated rotation is tracked in cozystack/community#72). The live password lives only in the Secret.","type":"object","default":{},"additionalProperties":{"type":"object","properties":{"bypassRls":{"description":"Whether the user can bypass row-level security policies (BYPASSRLS). A role can only create other roles carrying BYPASSRLS if it holds the attribute itself, so this is required alongside `createRole` to provision a privileged service role.","type":"boolean"},"createRole":{"description":"Whether the user can create and manage roles (CREATEROLE). Requires PostgreSQL 16 or newer. Setting it back to `false` withdraws the attribute only: the roles the user created, and its ADMIN and SET memberships in them, stay. Once any user has held it, the chart no longer adopts an existing role it did not create, so a user or database declared later under the name of such a role fails the init job until a platform administrator drops that role.","type":"boolean"},"replication":{"description":"Whether the user has replication privileges.","type":"boolean"}}}},"databases":{"description":"Databases configuration map.","type":"object","default":{},"additionalProperties":{"type":"object","properties":{"extensions":{"description":"List of enabled PostgreSQL extensions.","type":"array","items":{"type":"string"}},"roles":{"description":"Roles assigned to users.","type":"object","properties":{"admin":{"description":"List of users with admin privileges.","type":"array","items":{"type":"string"}},"readonly":{"description":"List of users with read-only privileges.","type":"array","items":{"type":"string"}}}}}}},"backup":{"description":"Backup configuration.","type":"object","default":{},"required":["enabled"],"properties":{"destinationPath":{"description":"DEPRECATED. Per-tenant S3 configuration is superseded by the platform-managed `cozy-default` BackupClass and the `cozy-backups` system bucket. Leave empty for new installations; the BackupClass driver picks up the system-managed coordinates. Kept for in-place upgrade compatibility.","type":"string","default":"s3://bucket/path/to/folder/"},"enabled":{"description":"Enable regular backups.","type":"boolean","default":false},"endpointCA":{"description":"DEPRECATED. Pre-existing Secret with the CA bundle the barman-cloud plugin should trust when reaching a self-signed S3 endpoint. Used for both backup and bootstrap recovery in the legacy chart-managed flow.","type":"object","default":{},"properties":{"key":{"description":"Key within the Secret containing the CA bundle. Defaults to `ca.crt`.","type":"string","default":""},"name":{"description":"Name of the Secret in the application namespace. Empty means no endpointCA is emitted (the plugin uses the system trust store).","type":"string","default":""}}},"endpointURL":{"description":"DEPRECATED. See `destinationPath`.","type":"string","default":"http://minio-gateway-service:9000"},"retentionPolicy":{"description":"Retention policy (e.g. \"30d\").","type":"string","default":"30d"},"s3AccessKey":{"description":"DEPRECATED. Tenants no longer supply S3 keys; the system Bucket Secret is projected into the tenant namespace by the backup controller. Ignored when `s3CredentialsSecret.name` is set or `useSystemBucket` is true. The chart skips materialising `-s3-creds` whenever this field is empty so a default install does not leak placeholder credentials into the tenant namespace.","type":"string","default":""},"s3CredentialsSecret":{"description":"DEPRECATED. Pre-existing Secret with S3 credentials. Use the platform-managed `cozy-default` BackupClass instead. When set, the chart references this Secret directly (legacy chart-managed flow). The CNPG backup driver writes this field on restore so credentials never land in the CR `.spec`.","type":"object","default":{},"properties":{"accessKeyIDKey":{"description":"Key in the Secret holding the access key ID. Defaults to `AWS_ACCESS_KEY_ID`.","type":"string","default":""},"name":{"description":"Name of the Secret in the application namespace. Empty means the chart materialises `-s3-creds` from `s3AccessKey`/`s3SecretKey`.","type":"string","default":""},"secretAccessKeyKey":{"description":"Key in the Secret holding the secret access key. Defaults to `AWS_SECRET_ACCESS_KEY`.","type":"string","default":""}}},"s3SecretKey":{"description":"DEPRECATED. See `s3AccessKey`.","type":"string","default":""},"schedule":{"description":"Legacy. Cron schedule (CNPG 6-field format) for the chart-emitted ScheduledBackup. Empty means no chart-managed schedule, which is the recommended setup when a `BackupClass` from `backups.cozystack.io` already drives backup orchestration. In the legacy chart-managed flow `spec.plugins` plus the barman-cloud ObjectStore is rendered when `backup.enabled=true` AND `useSystemBucket=false` AND `destinationPath` is non-empty AND inline-or-external creds are supplied; in the platform `useSystemBucket=true` flow the chart skips emitting `spec.plugins` and the CNPG driver SSA-applies the ObjectStore and patches `spec.plugins` onto the live Cluster at first BackupJob time.","type":"string","default":""},"useSystemBucket":{"description":"Opt-in: when true, the chart-emitted `-s3-creds` Secret is skipped AND `spec.plugins` (plus the barman-cloud ObjectStore) is left UNSET in the chart-rendered Cluster — the cozy-default BackupClass driver SSA-applies an ObjectStore (carrying destinationPath/endpointURL/credentials) and patches `spec.plugins` on the live Cluster when the first BackupJob runs. Consequence: plugin WAL archiving is NOT active until that first BackupJob fires; WAL accumulates on the PVC in the meantime, so fire an ad-hoc BackupJob immediately after enabling the flag on existing releases. Use together with the platform `cozy-default` BackupClass — tenants do not need to fill `s3AccessKey`/`s3SecretKey` or `destinationPath`/`endpointURL`. The destination path automatically scopes to `s3://cozy-backups///`.","type":"boolean","default":false}}},"bootstrap":{"description":"Bootstrap configuration.","type":"object","default":{},"required":["enabled","oldName"],"properties":{"backupID":{"description":"Barman ID of the base backup to recover from, passed as `recoveryTarget.backupID`. Empty lets the barman-cloud plugin pick one: the newest backup when `recoveryTime` is empty, otherwise the newest one ending at or before it, on any timeline. The CNPG backup driver sets this on restore.","type":"string","default":""},"enabled":{"description":"Whether to restore from a backup.","type":"boolean","default":false},"newServerName":{"description":"WAL-archive server name (S3 path prefix) the RESTORED cluster writes to. Must differ from `serverName` (the recovery source) so a restored cluster archives to a fresh, empty prefix and barman-cloud-check-wal-archive passes while it recovers from the source's prefix. The CNPG backup driver sets this on restore; empty means the cluster archives under its own name.","type":"string","default":""},"oldName":{"description":"Previous cluster name before deletion.","type":"string","default":""},"recoveryTime":{"description":"Timestamp (RFC3339) for point-in-time recovery; empty means latest.","type":"string","default":""},"serverName":{"description":"Server name (S3 path prefix) used by the original cluster when writing backups; passed to the barman-cloud plugin via `externalClusters[].plugin.parameters.serverName`. Defaults to `bootstrap.oldName`. Set this only when the original cluster wrote backups under an explicit server name that differed from its Kubernetes resource name.","type":"string","default":""}}}}} + {"title":"Chart Values","type":"object","properties":{"replicas":{"description":"Number of Postgres replicas. Under active autoscaling this is the resting/seed count, not the live count — KEDA drives the live count via `/scale`. Disabling autoscaling does NOT automatically bring the live count back to `replicas`: the client-side merge writes `instances` only when the rendered value changes, so a cluster KEDA grew past `replicas` stays there with no autoscaler left to reduce it — lower `replicas` to the count you want and it rebases down (shedding the surplus replicas + PVCs). Lowering it below the live count, or disabling/dry-running while it sits below the live count, sheds in one step past the scaleDown pacing; stage it to the live count first (suspend the HelmRelease and `kubectl scale` if needed) to avoid a transient. Must stay greater than `quorum.maxSyncReplicas`.","type":"integer","default":2},"resources":{"description":"Explicit CPU and memory configuration for each PostgreSQL replica. When omitted, the preset defined in `resourcesPreset` is applied.","type":"object","default":{},"properties":{"cpu":{"description":"CPU available to each replica.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"memory":{"description":"Memory (RAM) available to each replica.","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}}},"resourcesPreset":{"description":"Default sizing preset used when `resources` is omitted.","type":"string","default":"t1.micro","enum":["t1.nano","t1.micro","t1.small","t1.medium","t1.large","t1.xlarge","t1.2xlarge","t1.4xlarge","c1.nano","c1.micro","c1.small","c1.medium","c1.large","c1.xlarge","c1.2xlarge","c1.4xlarge","s1.nano","s1.micro","s1.small","s1.medium","s1.large","s1.xlarge","s1.2xlarge","s1.4xlarge","u1.nano","u1.micro","u1.small","u1.medium","u1.large","u1.xlarge","u1.2xlarge","u1.4xlarge","m1.nano","m1.micro","m1.small","m1.medium","m1.large","m1.xlarge","m1.2xlarge","m1.4xlarge","nano","micro","small","medium","large","xlarge","2xlarge"]},"size":{"description":"Persistent Volume Claim size available for application data.","default":"10Gi","pattern":"^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$","anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true},"storageClass":{"description":"StorageClass used to store the data.","type":"string","default":"","x-kubernetes-validations":[{"rule":"self == oldSelf","message":"storageClass is immutable"}],"x-cozystack-options":{"source":"storageclass"}},"external":{"description":"Enable external access from outside the cluster.","type":"boolean","default":false},"version":{"description":"PostgreSQL major version to deploy","type":"string","default":"v18","enum":["v18","v17","v16","v15","v14","v13"]},"flavor":{"description":"PostgreSQL image flavor, fixed when the cluster is created. `postgresql` uses the CloudNativePG PostgreSQL image. `postgis` uses the CloudNativePG PostGIS image, which adds postgis, postgis_raster, postgis_topology, postgis_sfcgal, postgis_tiger_geocoder and address_standardizer; enable them per database in `databases..extensions`. Changing the flavor of an existing cluster is refused, and a backup restores only into a cluster of the flavor it was taken from: to change flavor, create a new cluster and move the data with a logical dump (pg_dump / pg_restore).","type":"string","default":"postgresql","enum":["postgresql","postgis"]},"tls":{"description":"TLS configuration for server connections.","type":"object","default":{},"properties":{"enabled":{"description":"Tri-state switch controlling whether the chart injects the external hostnames into the operator-managed CNPG cert via spec.certificates.serverAltDNSNames. When omitted, the chart injects them if `external: true` and skips them otherwise. Set explicitly to `true` to inject regardless of `external` (no-op when `external: false` since there is no external hostname to add). Set to `false` to skip injection. Note that CNPG keeps its built-in TLS on the wire regardless of this flag — this toggle only controls the chart-side SAN injection; to disable PostgreSQL TLS entirely set `postgresql.parameters.ssl = \"off\"` at the CNPG layer.","type":"boolean"},"required":{"description":"Tri-state switch controlling whether the server refuses sessions that are not encrypted. When omitted, it is on if `external: true` and off otherwise. When on, the chart renders `spec.postgresql.pg_hba` with `hostssl all all all scram-sha-256` (`md5` on PostgreSQL 13, matching CloudNativePG's default rule) followed by `host all all all reject`, so a client that connects with `sslmode=disable` is refused instead of getting a plaintext session; in-cluster clients are held to the same rule, since the pod network cannot be told apart from an external client. Set to `false` to keep accepting plaintext sessions on an external instance, or to `true` to require TLS on an in-cluster one. Rendering fails if `postgresql.parameters.ssl` is off while this is in effect.","type":"boolean"}}},"postgresql":{"description":"PostgreSQL server configuration.","type":"object","default":{},"properties":{"parameters":{"description":"PostgreSQL server parameters. Values may be strings or integers; integers are coerced to strings by the template (e.g. both `max_connections: 100` and `max_connections: \"100\"` are accepted). BLOCKED (enable arbitrary code execution): 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. Do NOT override CloudNativePG-managed parameters: archive_mode, primary_conninfo, wal_level, max_replication_slots.","type":"object","default":{"max_connections":"100"},"additionalProperties":{"anyOf":[{"type":"integer"},{"type":"string"}],"x-kubernetes-int-or-string":true}},"sharedPreloadLibraries":{"description":"Libraries loaded at server start, restricted to an allowlist of modules already shipped in the PostgreSQL image. CloudNativePG also preloads `pg_stat_statements`, `auto_explain` and `pgaudit` by itself whenever `parameters` sets a key under their prefix (e.g. `pg_stat_statements.track`), so removing one of them from this list does not unload it while such a key is set; `pg_prewarm` is preloaded only through this list. Changing the list restarts PostgreSQL on every instance, the primary included, so writes pause briefly.","type":"array","items":{"type":"string","enum":["pg_stat_statements","auto_explain","pgaudit","pg_prewarm"]}}}},"quorum":{"description":"Quorum configuration for synchronous replication.","type":"object","default":{},"required":["maxSyncReplicas","minSyncReplicas"],"properties":{"maxSyncReplicas":{"description":"Maximum number of synchronous replicas allowed (must be less than total replicas). Under autoscaling it also sets the scale-down floor (the effective minimum is `max(minReplicas, maxSyncReplicas+1, 2)`); the chart fails to render if that floor exceeds `autoscaling.maxReplicas`.","type":"integer","default":0},"minSyncReplicas":{"description":"Minimum number of synchronous replicas required for commit.","type":"integer","default":0}}},"autoscaling":{"description":"Read-replica autoscaling configuration.","type":"object","default":{},"required":["dryRun","enabled","maxReplicas","maxReplicationLagSeconds","metric","minReplicas","serverAddress","target","transition"],"properties":{"dryRun":{"description":"Recommendation mode: keep the static count and render the ScaledObject with both scaling directions paused, so KEDA keeps the HPA and keeps serving its trigger metric but actuates neither way. Note both scaling policies are disabled, so the HPA's desiredReplicas is frozen to the current count - read the recommendation from the matching read-load dashboard panel for your metric (connections or CPU): desired = 1 + ceil(read load / target), clamped to [effectiveMin..maxReplicas] where effectiveMin = max(minReplicas, maxSyncReplicas+1, 2), not from the desired-replicas line. Like disabling, flipping a LIVE autoscaled cluster into dryRun re-renders `instances: replicas` (the raw static value, which can be below the autoscaling floor); if `replicas` is below the live count, stage it to the live count first (see the enablement note) or the cluster sheds down to `replicas`. No shed occurs when the rendered value is unchanged (replicas already equals the active seed) — client-side merge is then a no-op and the live count is preserved. Permanent, unlike `transition`.","type":"boolean","default":false},"enabled":{"description":"Enable horizontal autoscaling of read replicas. Requires the platform `keda` package to be enabled by an administrator first; the chart fails to render (rather than break the release) if KEDA is not installed. Prerequisite: the queried vmselect must hold BOTH the CNPG metrics (cnpg_backends_total / container CPU) AND kube_pod_labels (kube-state-metrics). This holds for a tenant on the shared root monitoring stack (`monitoring: false`, both land in tenant-root). A tenant running its OWN Monitoring app (`monitoring: true`) has the CNPG metrics but not kube_pod_labels (KSM is root-only), so it must set `serverAddress` to a vmselect carrying both series or autoscaling never actuates.","type":"boolean","default":false},"maxReplicas":{"description":"Maximum total instances. Must be >= minReplicas and >= the synchronous-quorum floor (maxSyncReplicas+1); the chart fails to render (rather than silently run an unbounded instance count) if the effective floor exceeds it - raise maxReplicas. HAZARD: lowering maxReplicas below the live count makes the HPA cut straight to the new maximum in one step (the current>max branch bypasses the scale-down pacing), shedding the surplus replicas + PVCs at once; to lower it deliberately, suspend the HelmRelease and pin the live count with `kubectl scale` first.","type":"integer","default":6},"maxReplicationLagSeconds":{"description":"Freeze scaling while replication lag exceeds this (seconds) and the primary is writing. 0 disables the brake. Default 0: the freeze branch has only been validated on a live cluster in its non-braking (pass-through) form, so the lag brake is opt-in until the braking path is exercised under load; set a positive value (e.g. 30) to enable it.","type":"integer","default":0},"metric":{"description":"Read-load signal to scale on.","type":"string","default":"ReadConnections","enum":["ReadConnections","ReadCPUUtilization"]},"minReplicas":{"description":"Minimum total instances; raised to the synchronous-quorum floor (`maxSyncReplicas + 1`) and to 2 when either is higher.","type":"integer","default":2},"serverAddress":{"description":"Override the Prometheus/vmselect URL the ScaledObject queries. Empty (default) derives `http://vmselect-shortterm..svc:8481/select/0/prometheus`. Set it when the derived vmselect does not hold both the CNPG metrics and kube_pod_labels (e.g. `monitoring: true` tenants, or a non-default `metricsStorages` name). SSRF constraint: the host must be an in-cluster Service DNS name (`..svc[.]`) whose namespace is within the tenant's own subtree (its namespace, its parents, or `tenant-root`) — the cluster-privileged KEDA operator fetches this URL every poll, so it is refused a metadata endpoint, an external host, or a sibling/system namespace. Residual surface (accepted trade-off): `tenant-root` is always allowed because the derived default points there, so a tenant can still aim the operator at any Service in `tenant-root` on any port/path (a blind in-cluster reachability probe — the operator reads no response body back to the tenant); the egress NetworkPolicy does not narrow that.","type":"string","default":""},"target":{"description":"Target read load per read-serving replica (unit depends on `metric`: active connections, or CPU millicores). Dimensionless integer - a resource.Quantity here would accept `150m`, which PromQL reads as 150 minutes and KEDA cannot use as a threshold.","type":"integer","default":150},"transition":{"description":"Migration phase 1: render `.spec.instances` from `replicas` (which the operator stages to the current live count) AND pause the ScaledObject in both directions, so KEDA stands up with its HPA present but not actuating. Flip to false (phase 2) to hand the runtime count to KEDA. Because the both-direction pause keeps the HPA (not the HPA-deleting full pause), KEDA is genuinely warm at phase 2 — no HPA recreate — and because the active seed is max(replicas, effectiveMin), phase 2 re-renders the same instance count phase 1 did (when `replicas` is staged at/above the floor), so the flip is a merge no-op with no dip. The HPA's scaleUp stabilization window still smooths the first actuation.","type":"boolean","default":false}}},"users":{"description":"Users configuration map. Passwords are always auto-generated and stored in the `-credentials` Secret; they cannot be set from values — read a user's password from that Secret. A `password` left over in values from before the field was removed is ignored by the render and draws an admission warning, but it is not inert on an upgraded release: the chart preserves whatever password is already in the Secret, which on the first upgrade is the value that was set before removal, so that value stays the live credential until it is rotated (dedicated rotation is tracked in cozystack/community#72). The live password lives only in the Secret.","type":"object","default":{},"additionalProperties":{"type":"object","properties":{"bypassRls":{"description":"Whether the user can bypass row-level security policies (BYPASSRLS). A role can only create other roles carrying BYPASSRLS if it holds the attribute itself, so this is required alongside `createRole` to provision a privileged service role.","type":"boolean"},"createRole":{"description":"Whether the user can create and manage roles (CREATEROLE). Requires PostgreSQL 16 or newer. Setting it back to `false` withdraws the attribute only: the roles the user created, and its ADMIN and SET memberships in them, stay. Once any user has held it, the chart no longer adopts an existing role it did not create, so a user or database declared later under the name of such a role fails the init job until a platform administrator drops that role.","type":"boolean"},"replication":{"description":"Whether the user has replication privileges.","type":"boolean"}}}},"databases":{"description":"Databases configuration map. The PostgreSQL system databases `postgres`, `template0` and `template1` are refused.","type":"object","default":{},"additionalProperties":{"type":"object","properties":{"extensions":{"description":"List of enabled PostgreSQL extensions.","type":"array","items":{"type":"string"}},"roles":{"description":"Roles assigned to users.","type":"object","properties":{"admin":{"description":"List of users with admin privileges.","type":"array","items":{"type":"string"}},"readonly":{"description":"List of users with read-only privileges.","type":"array","items":{"type":"string"}}}}}}},"backup":{"description":"Backup configuration.","type":"object","default":{},"required":["enabled"],"properties":{"destinationPath":{"description":"DEPRECATED. Per-tenant S3 configuration is superseded by the platform-managed `cozy-default` BackupClass and the `cozy-backups` system bucket. Leave empty for new installations; the BackupClass driver picks up the system-managed coordinates. Kept for in-place upgrade compatibility.","type":"string","default":"s3://bucket/path/to/folder/"},"enabled":{"description":"Enable regular backups.","type":"boolean","default":false},"endpointCA":{"description":"DEPRECATED. Pre-existing Secret with the CA bundle the barman-cloud plugin should trust when reaching a self-signed S3 endpoint. Used for both backup and bootstrap recovery in the legacy chart-managed flow.","type":"object","default":{},"properties":{"key":{"description":"Key within the Secret containing the CA bundle. Defaults to `ca.crt`.","type":"string","default":""},"name":{"description":"Name of the Secret in the application namespace. Empty means no endpointCA is emitted (the plugin uses the system trust store).","type":"string","default":""}}},"endpointURL":{"description":"DEPRECATED. See `destinationPath`.","type":"string","default":"http://minio-gateway-service:9000"},"retentionPolicy":{"description":"Retention policy (e.g. \"30d\").","type":"string","default":"30d"},"s3AccessKey":{"description":"DEPRECATED. Tenants no longer supply S3 keys; the system Bucket Secret is projected into the tenant namespace by the backup controller. Ignored when `s3CredentialsSecret.name` is set or `useSystemBucket` is true. The chart skips materialising `-s3-creds` whenever this field is empty so a default install does not leak placeholder credentials into the tenant namespace.","type":"string","default":""},"s3CredentialsSecret":{"description":"DEPRECATED. Pre-existing Secret with S3 credentials. Use the platform-managed `cozy-default` BackupClass instead. When set, the chart references this Secret directly (legacy chart-managed flow). The CNPG backup driver writes this field on restore so credentials never land in the CR `.spec`.","type":"object","default":{},"properties":{"accessKeyIDKey":{"description":"Key in the Secret holding the access key ID. Defaults to `AWS_ACCESS_KEY_ID`.","type":"string","default":""},"name":{"description":"Name of the Secret in the application namespace. Empty means the chart materialises `-s3-creds` from `s3AccessKey`/`s3SecretKey`.","type":"string","default":""},"secretAccessKeyKey":{"description":"Key in the Secret holding the secret access key. Defaults to `AWS_SECRET_ACCESS_KEY`.","type":"string","default":""}}},"s3SecretKey":{"description":"DEPRECATED. See `s3AccessKey`.","type":"string","default":""},"schedule":{"description":"Legacy. Cron schedule (CNPG 6-field format) for the chart-emitted ScheduledBackup. Empty means no chart-managed schedule, which is the recommended setup when a `BackupClass` from `backups.cozystack.io` already drives backup orchestration. In the legacy chart-managed flow `spec.plugins` plus the barman-cloud ObjectStore is rendered when `backup.enabled=true` AND `useSystemBucket=false` AND `destinationPath` is non-empty AND inline-or-external creds are supplied; in the platform `useSystemBucket=true` flow the chart skips emitting `spec.plugins` and the CNPG driver SSA-applies the ObjectStore and patches `spec.plugins` onto the live Cluster at first BackupJob time.","type":"string","default":""},"useSystemBucket":{"description":"Opt-in: when true, the chart-emitted `-s3-creds` Secret is skipped AND `spec.plugins` (plus the barman-cloud ObjectStore) is left UNSET in the chart-rendered Cluster — the cozy-default BackupClass driver SSA-applies an ObjectStore (carrying destinationPath/endpointURL/credentials) and patches `spec.plugins` on the live Cluster when the first BackupJob runs. Consequence: plugin WAL archiving is NOT active until that first BackupJob fires; WAL accumulates on the PVC in the meantime, so fire an ad-hoc BackupJob immediately after enabling the flag on existing releases. Use together with the platform `cozy-default` BackupClass — tenants do not need to fill `s3AccessKey`/`s3SecretKey` or `destinationPath`/`endpointURL`. The destination path automatically scopes to `s3://cozy-backups///`.","type":"boolean","default":false}}},"bootstrap":{"description":"Bootstrap configuration.","type":"object","default":{},"required":["enabled","oldName"],"properties":{"backupID":{"description":"Barman ID of the base backup to recover from, passed as `recoveryTarget.backupID`. Empty lets the barman-cloud plugin pick one: the newest backup when `recoveryTime` is empty, otherwise the newest one ending at or before it, on any timeline. The CNPG backup driver sets this on restore.","type":"string","default":""},"enabled":{"description":"Whether to restore from a backup.","type":"boolean","default":false},"newServerName":{"description":"WAL-archive server name (S3 path prefix) the RESTORED cluster writes to. Must differ from `serverName` (the recovery source) so a restored cluster archives to a fresh, empty prefix and barman-cloud-check-wal-archive passes while it recovers from the source's prefix. The CNPG backup driver sets this on restore; empty means the cluster archives under its own name.","type":"string","default":""},"oldName":{"description":"Previous cluster name before deletion.","type":"string","default":""},"recoveryTime":{"description":"Timestamp (RFC3339) for point-in-time recovery; empty means latest.","type":"string","default":""},"serverName":{"description":"Server name (S3 path prefix) used by the original cluster when writing backups; passed to the barman-cloud plugin via `externalClusters[].plugin.parameters.serverName`. Defaults to `bootstrap.oldName`. Set this only when the original cluster wrote backups under an explicit server name that differed from its Kubernetes resource name.","type":"string","default":""}}}}} release: prefix: postgres- labels: