diff --git a/helm-overrides/k8s-admin-prd-ase1/postgresql/custom-values.yaml b/helm-overrides/k8s-admin-prd-ase1/postgresql/custom-values.yaml new file mode 100644 index 0000000..3a60ac0 --- /dev/null +++ b/helm-overrides/k8s-admin-prd-ase1/postgresql/custom-values.yaml @@ -0,0 +1,48 @@ +# PostgreSQL for toolshed's control plane. +# +# Deployed as shared infrastructure in its own namespace rather than inside +# the toolshed namespace, so it is addressed over cluster DNS like any other +# platform component and its lifecycle is independent of the application +# that happens to be its first consumer: +# +# postgresql.postgres.svc.cluster.local:5432 +# +# Credentials come from Vault through External Secrets — see +# devops-infra-argo-config/secretstores/toolshed-postgres-credentials.yaml. +# The Secret must exist before this pod can start; a missing Secret leaves it +# in CreateContainerConfigError rather than failing in a way that explains +# itself. + +fullnameOverride: postgresql + +image: + repository: postgres + tag: "16-alpine" + pullPolicy: IfNotPresent + +existingSecret: postgresql-credentials +database: toolshed + +persistence: + enabled: true + # local-path-provisioner, this cluster's default StorageClass — installed + # right after Cilium precisely because kubeadm ships no default (unlike + # k3s). 5Gi is generous for control-plane metadata; the volume is not + # resizable in place with this provisioner, so it is sized up front. + storageClass: local-path + size: 5Gi + +config: + # Deliberately far below PostgreSQL's 128MB default. The node has 8GB and + # was already at its ceiling before this; the demo apps were scaled to zero + # to make room. Revisit only if query performance actually suffers, which + # for a handful of control-plane tables it will not. + sharedBuffers: 32MB + maxConnections: "50" + +resources: + requests: + cpu: 50m + memory: 64Mi + limits: + memory: 256Mi diff --git a/helm-templates/postgresql/Chart.yaml b/helm-templates/postgresql/Chart.yaml new file mode 100644 index 0000000..33acd8a --- /dev/null +++ b/helm-templates/postgresql/Chart.yaml @@ -0,0 +1,18 @@ +apiVersion: v2 +name: postgresql +description: | + Single-instance PostgreSQL for this homelab. + + Hand-written rather than vendoring Bitnami's chart: Broadcom has been + retiring and freezing images behind that repo (see claude.md infra issue + #4, where it broke Contour twice), and PostgreSQL publishes no official + Helm chart of its own. A single StatefulSet with one PVC is small enough + that owning it outright costs less than depending on an unstable + repackage. + + Not highly available and not intended to be. One replica, one PVC, no + replication, no connection pooler. Adding any of those to a single-node + cluster would be theatre. +type: application +version: 0.1.0 +appVersion: "16" diff --git a/helm-templates/postgresql/templates/service.yaml b/helm-templates/postgresql/templates/service.yaml new file mode 100644 index 0000000..d7c2b1b --- /dev/null +++ b/helm-templates/postgresql/templates/service.yaml @@ -0,0 +1,23 @@ +{{- $name := .Values.fullnameOverride | default "postgresql" -}} +# ClusterIP only. Nothing outside the cluster should reach the database, and +# there is no Ingress here on purpose — Contour terminates HTTP, and exposing +# PostgreSQL's wire protocol through it is neither possible nor wanted. +# +# Consumers address this as: +# {{ $name }}.{{ .Release.Namespace }}.svc.cluster.local:{{ .Values.service.port }} +apiVersion: v1 +kind: Service +metadata: + name: {{ $name }} + namespace: {{ .Release.Namespace }} + labels: + app: {{ $name }} +spec: + type: ClusterIP + selector: + app: {{ $name }} + ports: + - name: postgres + port: {{ .Values.service.port }} + targetPort: postgres + protocol: TCP diff --git a/helm-templates/postgresql/templates/statefulset.yaml b/helm-templates/postgresql/templates/statefulset.yaml new file mode 100644 index 0000000..544819f --- /dev/null +++ b/helm-templates/postgresql/templates/statefulset.yaml @@ -0,0 +1,89 @@ +{{- $name := .Values.fullnameOverride | default "postgresql" -}} +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ $name }} + namespace: {{ .Release.Namespace }} + labels: + app: {{ $name }} +spec: + serviceName: {{ $name }} + replicas: 1 + selector: + matchLabels: + app: {{ $name }} + template: + metadata: + labels: + app: {{ $name }} + spec: + securityContext: + # The official image starts as root, initialises the data directory, + # then drops to the postgres user (uid 70 on the Alpine variant). + # fsGroup makes the provisioned volume group-writable so that drop + # still leaves the data directory usable. + fsGroup: 70 + terminationGracePeriodSeconds: 60 + containers: + - name: postgres + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: + - -c + - shared_buffers={{ .Values.config.sharedBuffers }} + - -c + - max_connections={{ .Values.config.maxConnections }} + env: + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: {{ .Values.existingSecret }} + key: {{ .Values.secretKeys.username }} + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.existingSecret }} + key: {{ .Values.secretKeys.password }} + - name: POSTGRES_DB + value: {{ .Values.database | quote }} + # PGDATA must be a SUBDIRECTORY of the mount, not the mount + # itself: initdb refuses to run in a directory that already has + # contents, and a freshly provisioned volume is not always empty. + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + ports: + - name: postgres + containerPort: 5432 + protocol: TCP + # Run through a shell so the environment expands — Kubernetes does + # not substitute $(VAR) inside exec probe commands. + readinessProbe: + exec: + command: ["sh", "-c", 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" -h 127.0.0.1'] + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + livenessProbe: + exec: + command: ["sh", "-c", 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" -h 127.0.0.1'] + initialDelaySeconds: 30 + periodSeconds: 20 + timeoutSeconds: 5 + failureThreshold: 6 + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data +{{- if .Values.persistence.enabled }} + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + storageClassName: {{ .Values.persistence.storageClass | quote }} + resources: + requests: + storage: {{ .Values.persistence.size | quote }} +{{- end }} diff --git a/helm-templates/postgresql/values.yaml b/helm-templates/postgresql/values.yaml new file mode 100644 index 0000000..2b27b07 --- /dev/null +++ b/helm-templates/postgresql/values.yaml @@ -0,0 +1,45 @@ +# Chart defaults. Real configuration lives in +# helm-overrides/k8s-admin-prd-ase1/postgresql/custom-values.yaml. + +fullnameOverride: postgresql + +image: + # Pulled from Docker Hub, like every other infra component here (gitea, + # vault, harbor). The base-images mirror in Harbor exists to remove + # Docker Hub from the *application build* path and to minimise shipped + # app images — it is not in play for platform components. + repository: postgres + tag: "16-alpine" + pullPolicy: IfNotPresent + +# Name of the Secret holding username/password. Created by External +# Secrets from Vault, not by this chart — a chart that generates its own +# database password regenerates it on every render, which silently locks +# you out of an existing volume. +existingSecret: postgresql-credentials +secretKeys: + username: username + password: password + +database: toolshed + +service: + port: 5432 + +persistence: + enabled: true + storageClass: local-path + size: 5Gi + +# Tuned down hard. The box has 8GB total and is at its ceiling; PostgreSQL's +# default shared_buffers of 128MB is most of this pod's budget on its own. +config: + sharedBuffers: 32MB + maxConnections: "50" + +resources: + requests: + cpu: 50m + memory: 64Mi + limits: + memory: 256Mi