Files
Yasar Rahman f6f879cf38 fix(postgres)!: refuse plaintext sessions on external instances
CloudNativePG offers TLS but its built-in last pg_hba rule, host all all
all, also accepts plaintext, so a client outside the cluster that connects
with sslmode=disable gets an unencrypted session. tls.enabled only decides
whether the external hostname goes into the certificate and cannot prevent
this.

When external is true the chart now renders spec.postgresql.pg_hba with
hostssl all all all scram-sha-256 followed by host all all all reject.
CloudNativePG places user rules after its fixed rules and before its
default rule, and the first match wins, so only TLS sessions are accepted.
The pod network cannot be told apart from an external client, so in-cluster
clients are held to the same rule; libpq's default sslmode=prefer already
negotiates TLS.

The new tri-state tls.required follows external when omitted. Setting it to
false keeps accepting plaintext on an external instance and true requires
TLS on an in-cluster one.

The hostssl rule uses the same password method as CloudNativePG's own
default rule (md5 before PostgreSQL 14, scram-sha-256 from 14), so
existing md5 verifiers keep working on PostgreSQL 13. Rendering fails
when postgresql.parameters.ssl is off while TLS is required, since
hostssl would never match and every remote session would be refused.

BREAKING CHANGE: external clients that connect without TLS are refused
after the upgrade. Set tls.required to false to keep accepting them.
Assisted-by: LLM
Signed-off-by: Yasar Rahman <yasar.rehman@gmail.com>
2026-10-11 00:26:22 +05:00

484 lines
26 KiB
JSON

{
"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.\u003cname\u003e.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 \u003e= minReplicas and \u003e= 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\u003emax 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.\u003cmonitoring-ns\u003e.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 (`\u003csvc\u003e.\u003cns\u003e.svc[.\u003cclusterDomain\u003e]`) 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 `\u003crelease\u003e-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 `\u003crelease\u003e-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 `\u003crelease\u003e-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 `\u003crelease\u003e-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/\u003cnamespace\u003e/\u003crelease\u003e/`.",
"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": ""
}
}
}
}
}