Following the DEV-473 rocksdb-to-postgres migration, point Stalwart's bootstrap config at the PostgreSQL store and expose PGPASSWORD to the container so the `authSecret: EnvironmentVariable` lookup resolves. - stalwart-bootstrap-config.yaml: config.json switches @type RocksDb -> PostgreSql (host=stalwart-postgres, db=stalwart, authUsername=stalwart, authSecret=EnvironmentVariable/PGPASSWORD, useTls=false). - stalwart-fresh-deployment.yaml: adds PGPASSWORD env from secret/stalwart-postgres-credentials.POSTGRES_PASSWORD on the stalwart container. - stalwart-config.yaml: mirrors the switch in stalwart.toml as documentation (the running pod uses `--config /etc/stalwart/config.json`, not the toml). Board (DEV-476) explicitly asked for the manifest-level switch after the web UI save failed with a `Permission denied` on the read-only bootstrap emptyDir. Co-Authored-By: Paperclip <noreply@paperclip.ing>
433 lines
12 KiB
YAML
433 lines
12 KiB
YAML
---
|
|
apiVersion: v1
|
|
kind: Namespace
|
|
metadata:
|
|
name: stalwart
|
|
---
|
|
apiVersion: v1
|
|
kind: PersistentVolumeClaim
|
|
metadata:
|
|
name: stalwart-data
|
|
namespace: stalwart
|
|
spec:
|
|
accessModes:
|
|
- ReadWriteOnce
|
|
storageClassName: hcloud-volumes-encrypted
|
|
resources:
|
|
requests:
|
|
storage: 20Gi
|
|
---
|
|
apiVersion: v1
|
|
kind: Service
|
|
metadata:
|
|
name: stalwart-smtp
|
|
namespace: stalwart
|
|
spec:
|
|
type: LoadBalancer
|
|
externalTrafficPolicy: Cluster
|
|
selector:
|
|
app: stalwart
|
|
ports:
|
|
- name: smtp
|
|
port: 25
|
|
targetPort: 25
|
|
protocol: TCP
|
|
- name: submission
|
|
port: 587
|
|
targetPort: 587
|
|
protocol: TCP
|
|
# Port 465 (SMTPS) removed per DEV-359 approval - enforce secure protocols
|
|
---
|
|
apiVersion: v1
|
|
kind: Service
|
|
metadata:
|
|
name: stalwart-imap
|
|
namespace: stalwart
|
|
spec:
|
|
type: LoadBalancer
|
|
externalTrafficPolicy: Cluster
|
|
selector:
|
|
app: stalwart
|
|
ports:
|
|
# Port 143 (IMAP) removed per DEV-359 approval - enforce secure protocols
|
|
- name: imaps
|
|
port: 993
|
|
targetPort: 993
|
|
protocol: TCP
|
|
---
|
|
apiVersion: v1
|
|
kind: Service
|
|
metadata:
|
|
name: stalwart-http
|
|
namespace: stalwart
|
|
spec:
|
|
type: ClusterIP
|
|
selector:
|
|
app: stalwart
|
|
ports:
|
|
- name: http
|
|
port: 8080
|
|
targetPort: 8080
|
|
protocol: TCP
|
|
---
|
|
apiVersion: apps/v1
|
|
kind: StatefulSet
|
|
metadata:
|
|
name: stalwart
|
|
namespace: stalwart
|
|
annotations:
|
|
# Automatically restart this StatefulSet when the TLS certificate secret is updated
|
|
# This ensures the pod reloads new certificates after cert-manager renews them
|
|
secret.reloader.stakater.com/reload: "stalwart-tls"
|
|
spec:
|
|
serviceName: stalwart-http
|
|
replicas: 1
|
|
selector:
|
|
matchLabels:
|
|
app: stalwart
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app: stalwart
|
|
spec:
|
|
initContainers:
|
|
# Copy ConfigMap files to emptyDir to avoid subPath mounting race
|
|
- name: copy-config
|
|
image: busybox:latest
|
|
command:
|
|
- sh
|
|
- -c
|
|
- |
|
|
set -e
|
|
echo "Copying bootstrap config..."
|
|
cp /tmp/bootstrap-config/* /etc/stalwart/
|
|
echo "Copying stalwart.toml..."
|
|
cp /tmp/stalwart-config/stalwart.toml /etc/stalwart/stalwart.toml
|
|
echo "Config files copied successfully:"
|
|
ls -la /etc/stalwart/
|
|
volumeMounts:
|
|
- name: bootstrap-config-source
|
|
mountPath: /tmp/bootstrap-config
|
|
readOnly: true
|
|
- name: stalwart-config-source
|
|
mountPath: /tmp/stalwart-config
|
|
readOnly: true
|
|
- name: config
|
|
mountPath: /etc/stalwart
|
|
- name: fix-permissions
|
|
image: busybox:latest
|
|
command: ["sh", "-c", "chown -R 2000:2000 /var/lib/stalwart && chmod -R 755 /var/lib/stalwart"]
|
|
volumeMounts:
|
|
- name: data
|
|
mountPath: /var/lib/stalwart
|
|
containers:
|
|
- name: stalwart
|
|
image: stalwartlabs/stalwart:v0.16.11
|
|
ports:
|
|
- containerPort: 25
|
|
name: smtp
|
|
- containerPort: 587
|
|
name: submission
|
|
# Port 465 (SMTPS) removed - enforce STARTTLS on 587
|
|
# Port 143 (IMAP) removed - enforce TLS on 993
|
|
- containerPort: 993
|
|
name: imaps
|
|
- containerPort: 8080
|
|
name: http
|
|
env:
|
|
#- name: STALWART_RECOVERY_ADMIN
|
|
# valueFrom:
|
|
# secretKeyRef:
|
|
# name: stalwart-admin-credentials
|
|
# key: admin-password
|
|
# optional: false
|
|
- name: TLS_CERTIFICATE
|
|
value: "/etc/stalwart/certs/tls.crt"
|
|
- name: TLS_PRIVATE_KEY
|
|
value: "/etc/stalwart/certs/tls.key"
|
|
# DEV-476: bootstrap config.json resolves authSecret via this env var.
|
|
- name: PGPASSWORD
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: stalwart-postgres-credentials
|
|
key: POSTGRES_PASSWORD
|
|
volumeMounts:
|
|
- name: data
|
|
mountPath: /var/lib/stalwart
|
|
- name: config
|
|
mountPath: /etc/stalwart
|
|
- name: tls-certs
|
|
mountPath: /etc/stalwart/certs
|
|
readOnly: true
|
|
resources:
|
|
requests:
|
|
memory: "512Mi"
|
|
cpu: "250m"
|
|
limits:
|
|
memory: "2Gi"
|
|
cpu: "2000m"
|
|
startupProbe:
|
|
exec:
|
|
command:
|
|
- curl
|
|
- -f
|
|
- --max-time
|
|
- "3"
|
|
- http://localhost:8080/healthz/live
|
|
initialDelaySeconds: 60
|
|
periodSeconds: 10
|
|
timeoutSeconds: 5
|
|
failureThreshold: 6
|
|
livenessProbe:
|
|
exec:
|
|
command:
|
|
- curl
|
|
- -f
|
|
- --max-time
|
|
- "3"
|
|
- http://localhost:8080/healthz/live
|
|
initialDelaySeconds: 30
|
|
periodSeconds: 10
|
|
timeoutSeconds: 5
|
|
failureThreshold: 3
|
|
readinessProbe:
|
|
exec:
|
|
command:
|
|
- curl
|
|
- -f
|
|
- --max-time
|
|
- "3"
|
|
- http://localhost:8080/healthz/ready
|
|
initialDelaySeconds: 10
|
|
periodSeconds: 5
|
|
timeoutSeconds: 3
|
|
failureThreshold: 3
|
|
volumes:
|
|
- name: data
|
|
persistentVolumeClaim:
|
|
claimName: stalwart-data
|
|
# emptyDir for config files (populated by copy-config init-container)
|
|
- name: config
|
|
emptyDir: {}
|
|
# ConfigMap sources for init-container (no longer mounted directly via subPath)
|
|
- name: bootstrap-config-source
|
|
configMap:
|
|
name: stalwart-bootstrap-config
|
|
- name: stalwart-config-source
|
|
configMap:
|
|
name: stalwart-config
|
|
- name: tls-certs
|
|
secret:
|
|
secretName: stalwart-tls
|
|
defaultMode: 0444
|
|
---
|
|
apiVersion: networking.k8s.io/v1
|
|
kind: Ingress
|
|
metadata:
|
|
name: stalwart-web
|
|
namespace: stalwart
|
|
annotations:
|
|
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
|
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
|
spec:
|
|
ingressClassName: traefik
|
|
tls:
|
|
- hosts:
|
|
- mail.basicstack.de
|
|
secretName: stalwart-tls
|
|
rules:
|
|
- host: mail.basicstack.de
|
|
http:
|
|
paths:
|
|
- path: /
|
|
pathType: Prefix
|
|
backend:
|
|
service:
|
|
name: stalwart-http
|
|
port:
|
|
number: 8080
|
|
---
|
|
apiVersion: v1
|
|
kind: ServiceAccount
|
|
metadata:
|
|
name: stalwart-backup
|
|
namespace: stalwart
|
|
---
|
|
apiVersion: rbac.authorization.k8s.io/v1
|
|
kind: Role
|
|
metadata:
|
|
name: stalwart-backup
|
|
namespace: stalwart
|
|
rules:
|
|
- apiGroups: ["apps"]
|
|
resources: ["statefulsets"]
|
|
verbs: ["get", "list", "patch"]
|
|
- apiGroups: ["apps"]
|
|
resources: ["statefulsets/scale"]
|
|
verbs: ["get", "update", "patch"]
|
|
- apiGroups: [""]
|
|
resources: ["pods"]
|
|
verbs: ["get", "list", "watch"]
|
|
---
|
|
apiVersion: rbac.authorization.k8s.io/v1
|
|
kind: RoleBinding
|
|
metadata:
|
|
name: stalwart-backup
|
|
namespace: stalwart
|
|
roleRef:
|
|
apiGroup: rbac.authorization.k8s.io
|
|
kind: Role
|
|
name: stalwart-backup
|
|
subjects:
|
|
- kind: ServiceAccount
|
|
name: stalwart-backup
|
|
namespace: stalwart
|
|
---
|
|
apiVersion: batch/v1
|
|
kind: CronJob
|
|
metadata:
|
|
name: stalwart-backup
|
|
namespace: stalwart
|
|
spec:
|
|
schedule: "0 3 * * *" # 3 AM daily
|
|
successfulJobsHistoryLimit: 3
|
|
failedJobsHistoryLimit: 3
|
|
jobTemplate:
|
|
spec:
|
|
backoffLimit: 2
|
|
activeDeadlineSeconds: 1800 # 30 minute timeout (was 600s; retries + affinity settling can push past 10 min)
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app: stalwart-backup
|
|
spec:
|
|
serviceAccountName: stalwart-backup
|
|
restartPolicy: OnFailure
|
|
# Co-locate with stalwart-0 so both pods share the same hcloud block volume
|
|
# attachment (RWO). Without this the backup pod can be scheduled on a
|
|
# different node and hits FailedAttachVolume/Multi-Attach (DEV-468).
|
|
affinity:
|
|
podAffinity:
|
|
requiredDuringSchedulingIgnoredDuringExecution:
|
|
- labelSelector:
|
|
matchLabels:
|
|
app: stalwart
|
|
statefulset.kubernetes.io/pod-name: stalwart-0
|
|
topologyKey: kubernetes.io/hostname
|
|
containers:
|
|
- name: backup
|
|
image: alpine:3.19
|
|
command:
|
|
- /bin/sh
|
|
- -c
|
|
- |
|
|
set -e
|
|
BACKUP_START=$(date +%s)
|
|
echo "=== Starting Stalwart backup at $(date) ==="
|
|
|
|
# Install required tools
|
|
echo "Installing kubectl and restic..."
|
|
apk add --no-cache kubectl restic curl bash
|
|
|
|
# Build restic repository URL from env vars
|
|
export RESTIC_REPOSITORY="s3:${S3_ENDPOINT}/${S3_BUCKET}/stalwart"
|
|
echo "Using restic repository: $RESTIC_REPOSITORY"
|
|
|
|
# Function to scale StatefulSet
|
|
scale_statefulset() {
|
|
local replicas=$1
|
|
echo "Scaling stalwart StatefulSet to $replicas replicas..."
|
|
kubectl scale statefulset stalwart -n stalwart --replicas=$replicas
|
|
|
|
if [ "$replicas" -eq 0 ]; then
|
|
echo "Waiting for pod to terminate..."
|
|
kubectl wait --for=delete pod/stalwart-0 -n stalwart --timeout=120s || true
|
|
sleep 5
|
|
else
|
|
echo "Waiting for pod to be ready..."
|
|
kubectl wait --for=condition=ready pod/stalwart-0 -n stalwart --timeout=120s || echo "Warning: Pod not ready after 120s"
|
|
fi
|
|
}
|
|
|
|
# Trap to ensure we scale back up on any exit
|
|
cleanup() {
|
|
EXIT_CODE=$?
|
|
echo "Cleanup: scaling stalwart back to 1 replica..."
|
|
scale_statefulset 1 || echo "ERROR: Failed to scale back up!"
|
|
|
|
BACKUP_END=$(date +%s)
|
|
DURATION=$((BACKUP_END - BACKUP_START))
|
|
|
|
if [ $EXIT_CODE -eq 0 ]; then
|
|
echo "=== Backup completed successfully in ${DURATION}s at $(date) ==="
|
|
else
|
|
echo "=== Backup FAILED after ${DURATION}s at $(date) ==="
|
|
fi
|
|
|
|
exit $EXIT_CODE
|
|
}
|
|
trap cleanup EXIT INT TERM
|
|
|
|
# Scale down Stalwart
|
|
scale_statefulset 0
|
|
|
|
# Build restic repository URL from env vars (K8s doesn't expand $(VAR) in value fields)
|
|
export RESTIC_REPOSITORY="s3:${S3_ENDPOINT}/${S3_BUCKET}/stalwart"
|
|
echo "Using repository: $RESTIC_REPOSITORY"
|
|
|
|
# Initialize restic repo if needed
|
|
echo "Initializing restic repository..."
|
|
restic snapshots || restic init
|
|
|
|
# Backup the data directory
|
|
echo "Running backup..."
|
|
restic backup /var/lib/stalwart \
|
|
--tag stalwart \
|
|
--tag daily \
|
|
--host stalwart-k8s \
|
|
--verbose
|
|
|
|
# Prune old backups (keep 7 daily, 4 weekly, 6 monthly)
|
|
echo "Pruning old backups..."
|
|
restic forget \
|
|
--tag stalwart \
|
|
--keep-daily 7 \
|
|
--keep-weekly 4 \
|
|
--keep-monthly 6 \
|
|
--prune
|
|
|
|
echo "Backup operations completed"
|
|
env:
|
|
- name: RESTIC_PASSWORD
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: stalwart-s3-backup
|
|
key: restic-password
|
|
- name: AWS_ACCESS_KEY_ID
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: stalwart-s3-backup
|
|
key: access-key
|
|
- name: AWS_SECRET_ACCESS_KEY
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: stalwart-s3-backup
|
|
key: secret-key
|
|
- name: S3_ENDPOINT
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: stalwart-s3-backup
|
|
key: endpoint
|
|
- name: S3_BUCKET
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: stalwart-s3-backup
|
|
key: bucket
|
|
volumeMounts:
|
|
- name: data
|
|
mountPath: /var/lib/stalwart
|
|
readOnly: true
|
|
volumes:
|
|
- name: data
|
|
persistentVolumeClaim:
|
|
claimName: stalwart-data
|