diff --git a/helm-overrides/k8s-admin-prd-ase1/redis/custom-values.yaml b/helm-overrides/k8s-admin-prd-ase1/redis/custom-values.yaml new file mode 100644 index 0000000..a62f6ff --- /dev/null +++ b/helm-overrides/k8s-admin-prd-ase1/redis/custom-values.yaml @@ -0,0 +1,50 @@ +# Redis backing toolshed's managed cache add-on. +# +# 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: +# +# redis.redis.svc.cluster.local:6379 +# +# The admin password comes from Vault through External Secrets — see +# devops-infra-argo-config/secretstores/toolshed-redis-credentials.yaml. The +# Secret must exist before this pod can start; a missing Secret leaves the +# init container in CreateContainerConfigError rather than failing in a way +# that explains itself. +# +# Read values.yaml's `config` block before changing anything about +# authentication here. The absence of `requirepass` is deliberate and +# security-relevant, not an oversight. + +fullnameOverride: redis + +image: + repository: redis + tag: "7-alpine" + pullPolicy: IfNotPresent + +existingSecret: redis-credentials + +persistence: + enabled: true + storageClass: local-path + # Holds the ACL file and nothing else worth keeping — snapshotting is off + # (see config.save). 1Gi is already far more than needed; local-path + # cannot resize in place, so it is sized up front rather than tightly. + size: 1Gi + +config: + # The node has 8GB and was at its ceiling before Postgres was added; the + # demo apps were scaled to zero to make room for that. 48mb is a real + # cache for a handful of small internal tools and costs little. + maxmemory: 48mb + maxmemoryPolicy: allkeys-lru + save: "" + +resources: + requests: + cpu: 25m + memory: 32Mi + limits: + memory: 96Mi diff --git a/helm-templates/redis/Chart.yaml b/helm-templates/redis/Chart.yaml new file mode 100644 index 0000000..862f67e --- /dev/null +++ b/helm-templates/redis/Chart.yaml @@ -0,0 +1,23 @@ +apiVersion: v2 +name: redis +description: | + Single-instance Redis for this homelab, backing toolshed's managed cache + add-on (internal/dbprovision) — toolshed provisions a per-app ACL user + scoped to its own key prefix on request. + + Hand-written rather than vendoring Bitnami's chart, for the same reason + the sibling postgresql chart is: Broadcom has been retiring and freezing + images behind that repo (claude.md infra issue #4, where it broke Contour + twice), and Redis publishes no official Helm chart of its own. + + Authentication is defined entirely by the ACL file, with no requirepass. + That is not a style choice — see values.yaml, where the reasoning is + recorded alongside the setting it explains. Getting it wrong leaves the + server open to unauthenticated access after its first restart. + + Not highly available and not intended to be. One replica, one PVC, no + replication, no sentinel. On a single-node cluster those would be + theatre. +type: application +version: 0.1.0 +appVersion: "7" diff --git a/helm-templates/redis/templates/service.yaml b/helm-templates/redis/templates/service.yaml new file mode 100644 index 0000000..f234ede --- /dev/null +++ b/helm-templates/redis/templates/service.yaml @@ -0,0 +1,23 @@ +{{- $name := .Values.fullnameOverride | default "redis" -}} +# ClusterIP only. Nothing outside the cluster should reach Redis, and there +# is no Ingress here on purpose — Contour terminates HTTP, and exposing +# Redis'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: redis + port: {{ .Values.service.port }} + targetPort: redis + protocol: TCP diff --git a/helm-templates/redis/templates/statefulset.yaml b/helm-templates/redis/templates/statefulset.yaml new file mode 100644 index 0000000..d568768 --- /dev/null +++ b/helm-templates/redis/templates/statefulset.yaml @@ -0,0 +1,127 @@ +{{- $name := .Values.fullnameOverride | default "redis" -}} +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 runs as the redis user (uid 999 on the Alpine + # variant). fsGroup makes the provisioned volume group-writable so + # Redis can write the ACL file it is given — without it ACL SAVE + # fails at provisioning time with a permission error. + fsGroup: 999 + terminationGracePeriodSeconds: 30 + initContainers: + # Seeds the ACL file with the default (admin) user on first boot + # only. Redis will not start with an --aclfile that does not exist, + # and the default user has to be defined there rather than by + # requirepass — see the long note in values.yaml for why that + # distinction is a security property and not a preference. + # + # Never overwrites an existing file. That file is rewritten by ACL + # SAVE every time toolshed provisions an app user, so recreating it + # on every pod start would silently delete every provisioned user + # and lock those apps out — the exact failure this whole design + # exists to prevent, reintroduced from the other end. + # + # Consequence worth knowing: rotating the admin password in Vault + # does NOT propagate here, because this only ever runs against a + # missing file. Rotating means `ACL SETUSER default >newpassword` + # followed by `ACL SAVE` against the running server. + - name: seed-acl + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + env: + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.existingSecret }} + key: {{ .Values.secretKeys.password }} + command: + - sh + - -c + - | + set -e + if [ -f /data/users.acl ]; then + echo "ACL file already present; leaving it alone." + exit 0 + fi + echo "user default on >$REDIS_PASSWORD ~* &* +@all" > /data/users.acl + chmod 600 /data/users.acl + echo "Seeded ACL file with the default user." + volumeMounts: + - name: data + mountPath: /data + containers: + - name: redis + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: + - redis-server + - --aclfile + - /data/users.acl + - --maxmemory + - {{ .Values.config.maxmemory | quote }} + - --maxmemory-policy + - {{ .Values.config.maxmemoryPolicy | quote }} + - --save + - {{ .Values.config.save | quote }} + ports: + - name: redis + containerPort: 6379 + protocol: TCP + # Authenticated probes: with the ACL file in place an + # unauthenticated PING is correctly refused with NOAUTH, so a + # bare `redis-cli ping` would mark a perfectly healthy server as + # failing. Run through a shell so the environment expands — + # Kubernetes does not substitute $(VAR) inside exec probe + # commands. + env: + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.existingSecret }} + key: {{ .Values.secretKeys.password }} + readinessProbe: + exec: + command: ["sh", "-c", 'redis-cli --no-auth-warning -a "$REDIS_PASSWORD" ping | grep -q PONG'] + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + livenessProbe: + exec: + command: ["sh", "-c", 'redis-cli --no-auth-warning -a "$REDIS_PASSWORD" ping | grep -q PONG'] + initialDelaySeconds: 20 + periodSeconds: 20 + timeoutSeconds: 5 + failureThreshold: 6 + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + - name: data + mountPath: /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/redis/values.yaml b/helm-templates/redis/values.yaml new file mode 100644 index 0000000..bb8e26f --- /dev/null +++ b/helm-templates/redis/values.yaml @@ -0,0 +1,84 @@ +# Chart defaults. Real configuration lives in +# helm-overrides/k8s-admin-prd-ase1/redis/custom-values.yaml. + +fullnameOverride: redis + +image: + # Pulled from Docker Hub, like every other infra component here (gitea, + # vault, harbor, postgresql). The base-images mirror in Harbor exists to + # remove Docker Hub from the *application build* path — it is not in play + # for platform components. + repository: redis + tag: "7-alpine" + pullPolicy: IfNotPresent + +# Name of the Secret holding the admin password. Created by External +# Secrets from Vault, not by this chart — a chart that generates its own +# password regenerates it on every render, which would rewrite the ACL file +# and lock every already-provisioned app out of its own data. +existingSecret: redis-credentials +secretKeys: + password: password + +service: + port: 6379 + +persistence: + enabled: true + # local-path-provisioner, this cluster's default StorageClass. Small: this + # holds the ACL file and (if enabled) an RDB snapshot, not a dataset of + # any size — maxmemory below is the real ceiling on what Redis will hold. + # The volume is not resizable in place with this provisioner, so it is + # sized up front. + storageClass: local-path + size: 1Gi + +config: + # ACL FILE, NOT requirepass. This distinction is load-bearing and easy to + # "simplify" into a security hole, so it is written down here rather than + # left to be rediscovered: + # + # toolshed provisions per-app users with ACL SETUSER, and persists them + # with ACL SAVE (internal/dbprovision.EnsureRedisUser) — without that + # save, every provisioned user is lost on the next restart and every app + # using Redis fails to authenticate with credentials that still look + # valid. ACL SAVE requires an aclfile; that is why one is configured. + # + # But ACL SAVE also writes the *default* user's state to that file. With + # `requirepass` set and the default user defined only by it, the saved + # entry comes back as `user default on nopass ~* &* +@all` — and after + # the next restart the ACL file wins, leaving Redis accepting + # UNAUTHENTICATED connections with full access. Verified directly, not + # inferred: with requirepass the restarted server answered an + # unauthenticated PING with PONG and served a key. + # + # Defining the default user in the ACL file instead (seeded by the init + # container, see the StatefulSet) keeps its password across every + # subsequent ACL SAVE — the same restart then correctly answers + # `NOAUTH Authentication required.` + # + # If you ever add `requirepass` here, you reintroduce that hole. + maxmemory: 48mb + # allkeys-lru, because this backs a connection kind literally called + # "cache" and eviction under pressure is that contract. An app using + # Redis as its only copy of something wants noeviction instead — at + # which case writes start failing when full rather than data silently + # disappearing. Neither is safe for every use; this one matches the name. + maxmemoryPolicy: allkeys-lru + # Snapshotting off. What must survive a restart is the ACL file, which is + # written by ACL SAVE independently of RDB/AOF. Cached values are by + # definition reconstructible, and on a node at its memory ceiling a + # background save's copy-on-write spike is a real risk for no benefit. + save: "" + +# Tuned for a node with 8GB total that is already near its ceiling. The +# request is what the scheduler reserves; the limit is sized above +# maxmemory so Redis hits its own eviction policy rather than being +# OOM-killed by the kernel, which loses the whole instance instead of the +# coldest keys. +resources: + requests: + cpu: 25m + memory: 32Mi + limits: + memory: 96Mi