#!/bin/bash # Methods library for docker-mailserver — sourced by install-docker-mailserver.sh # SCRIPT_DIR must be set by the caller before sourcing. _kubectl() { if command -v kubectl >/dev/null 2>&1; then command kubectl "$@" elif command -v k0s >/dev/null 2>&1; then k0s kubectl "$@" else echo "ERROR: no kubectl or k0s binary found" >&2; exit 127 fi } _require_env() { local var="$1" if [ -z "${!var:-}" ]; then echo "ERROR: required variable $var is not set" >&2; exit 1 fi } _pod_name() { _kubectl get pod \ --namespace "$DMS_NAMESPACE" \ --selector "app.kubernetes.io/name=docker-mailserver" \ -o jsonpath='{.items[0].metadata.name}' 2>/dev/null } _dms_container() { echo "docker-mailserver" } _wait_for_pod() { local timeout="${1:-300}" _kubectl wait pod \ --namespace "$DMS_NAMESPACE" \ --selector "app.kubernetes.io/name=docker-mailserver" \ --for=condition=Ready \ --timeout="${timeout}s" } # ── Manifest plan helpers ───────────────────────────────────────────────────── _plan_apply() { local file="$1" local path="$SCRIPT_DIR/manifests/${file}.yaml" [ -f "$path" ] || { echo " [skip] $file.yaml not in manifests/"; return 0; } local content content=$(sed '/^\s*#/d; /^\s*$/d' "$path" | tr -d '[:space:]') [ -n "$content" ] || { echo " [skip] ${file}.yaml has no content (template rendered empty)"; return 0; } _kubectl apply -f "$path" } _plan_apply_skip() { local file="$1" local path="$SCRIPT_DIR/manifests/${file}.yaml" [ -f "$path" ] || { echo " [skip] $file.yaml not in manifests/"; return 0; } local content content=$(sed '/^\s*#/d; /^\s*$/d' "$path" | tr -d '[:space:]') [ -n "$content" ] || { echo " [skip] ${file}.yaml has no content"; return 0; } if _kubectl get -f "$path" >/dev/null 2>&1; then echo " [skip] $file already exists in cluster" return 0 fi _kubectl apply -f "$path" } _plan_rollout_restart() { local file="$1" local path="$SCRIPT_DIR/manifests/${file}.yaml" [ -f "$path" ] || { echo " [skip] $file.yaml not in manifests/"; return 0; } _kubectl rollout restart -f "$path" } _plan_delete() { local file="$1" local path="$SCRIPT_DIR/manifests/${file}.yaml" [ -f "$path" ] || { echo " [skip] $file.yaml not in manifests/"; return 0; } _kubectl delete -f "$path" --ignore-not-found } _plan_recreate() { local file="$1" local path="$SCRIPT_DIR/manifests/${file}.yaml" [ -f "$path" ] || { echo " [skip] $file.yaml not in manifests/"; return 0; } _kubectl delete -f "$path" --ignore-not-found _kubectl apply -f "$path" } # ── Component methods ───────────────────────────────────────────────────────── _method_create-credentials() { [ -f "${SCRIPT_DIR}/_credentials.env" ] && set -a && source "${SCRIPT_DIR}/_credentials.env" && set +a _require_env RELAY_USER _require_env RELAY_PASSWORD _kubectl create secret generic docker-mailserver-credentials \ --namespace "$DMS_NAMESPACE" \ --from-literal=RELAY_USER="$RELAY_USER" \ --from-literal=RELAY_PASSWORD="$RELAY_PASSWORD" \ --dry-run=client -o yaml | _kubectl apply -f - } _method_apply-tls-secret() { # Bundle layout: templates/*.j2 are rendered into manifests/*.yaml at build time. local cert_template="$SCRIPT_DIR/manifests/certificate.yaml" if [ -s "$cert_template" ]; then [ -f "${SCRIPT_DIR}/_credentials.env" ] && set -a && source "${SCRIPT_DIR}/_credentials.env" && set +a _require_env DMS_CF_DNS_TOKEN echo " [cert-manager] applying CF DNS-01 token secret '${DMS_CF_DNS_SECRET_NAME}' in namespace 'cert-manager'..." _kubectl create secret generic "${DMS_CF_DNS_SECRET_NAME}" \ --namespace cert-manager \ --from-literal=api-token="${DMS_CF_DNS_TOKEN}" \ --dry-run=client -o yaml | _kubectl apply -f - echo " [cert-manager] applying ClusterIssuer..." local issuer_name issuer_name=$(grep -m1 'name:' "$SCRIPT_DIR/manifests/clusterissuer.yaml" | awk '{print $2}') if ! _kubectl get clusterissuer "$issuer_name" >/dev/null 2>&1; then _kubectl apply --server-side -f "$SCRIPT_DIR/manifests/clusterissuer.yaml" fi # Detach any pre-existing static secret from kubectl so cert-manager can take ownership # via server-side apply without conflicts. if _kubectl get secret "$DMS_TLS_SECRET" --namespace "$DMS_NAMESPACE" >/dev/null 2>&1; then if ! _kubectl get secret "$DMS_TLS_SECRET" --namespace "$DMS_NAMESPACE" -o jsonpath='{.metadata.labels.controller\.cert-manager\.io/fao}' 2>/dev/null | grep -q .; then echo " [cert-manager] existing static secret found — clearing kubectl annotation to allow cert-manager takeover" _kubectl annotate secret "$DMS_TLS_SECRET" \ --namespace "$DMS_NAMESPACE" \ kubectl.kubernetes.io/last-applied-configuration- 2>/dev/null || true fi fi echo " [cert-manager] applying Certificate..." _kubectl apply --server-side --force-conflicts -f "$cert_template" echo " [cert-manager] waiting for TLS secret '$DMS_TLS_SECRET' managed by cert-manager (up to 300s)..." local i=0 until _kubectl get secret "$DMS_TLS_SECRET" --namespace "$DMS_NAMESPACE" \ -o jsonpath='{.metadata.labels.controller\.cert-manager\.io/fao}' 2>/dev/null | grep -q .; do i=$((i + 1)) if [ $i -ge 60 ]; then echo "ERROR: tls secret '$DMS_TLS_SECRET' not managed by cert-manager after 300s — check ClusterIssuer and DNS challenge" >&2 _kubectl describe certificate "$DMS_TLS_SECRET" --namespace "$DMS_NAMESPACE" 2>&1 | tail -30 >&2 || true exit 1 fi sleep 5 done echo " [cert-manager] TLS secret '$DMS_TLS_SECRET' ready (cert-manager managed)" return 0 fi local tls_crt="$SCRIPT_DIR/tls.crt" local tls_key="$SCRIPT_DIR/tls.key" if [ ! -f "$tls_crt" ] || [ ! -f "$tls_key" ]; then echo "ERROR: neither certificate.yaml nor tls.crt/tls.key found — cannot create TLS secret" >&2 exit 1 fi _kubectl create secret tls "$DMS_TLS_SECRET" \ --namespace "$DMS_NAMESPACE" \ --cert="$tls_crt" \ --key="$tls_key" \ --dry-run=client -o yaml | _kubectl apply -f - } # Annotate the deployment pod template with the current TLS secret resourceVersion. # When cert-manager renews the cert, resourceVersion changes → annotation differs → # next `apply` of the deployment triggers a rolling update so Postfix/Dovecot reload # the cert. This replaces an external reloader (e.g. Stakater) for this single use case. _method_tls-checksum-annotate() { local rv rv=$(_kubectl get secret "$DMS_TLS_SECRET" --namespace "$DMS_NAMESPACE" \ -o jsonpath='{.metadata.resourceVersion}' 2>/dev/null || echo "") if [ -z "$rv" ]; then echo " [tls-checksum] secret '$DMS_TLS_SECRET' not found — skipping annotation" return 0 fi echo " [tls-checksum] secret resourceVersion=$rv → annotating deployment pod template" _kubectl patch deployment docker-mailserver \ --namespace "$DMS_NAMESPACE" \ --type=json \ -p "[{\"op\":\"add\",\"path\":\"/spec/template/metadata/annotations/checksum~1tls\",\"value\":\"${rv}\"}]" \ >/dev/null 2>&1 || \ _kubectl patch deployment docker-mailserver \ --namespace "$DMS_NAMESPACE" \ --type=merge \ -p "{\"spec\":{\"template\":{\"metadata\":{\"annotations\":{\"checksum/tls\":\"${rv}\"}}}}}" } _method_protect-volume() { local pvc="${PLAN_PARAM_PVC:-${DMS_NAMESPACE}-mail-data}" echo " waiting for PVC '$pvc' to bind..." _kubectl wait pvc "$pvc" \ --namespace "$DMS_NAMESPACE" \ --for=jsonpath='{.status.phase}'=Bound \ --timeout=120s local pv_name pv_name=$(_kubectl get pvc "$pvc" \ --namespace "$DMS_NAMESPACE" \ -o jsonpath='{.spec.volumeName}') if [ -z "$pv_name" ]; then echo " [warn] could not resolve PV name for PVC '$pvc' — skipping volume protection" return 0 fi echo " enabling Hetzner delete-protection on volume '$pv_name'..." if command -v hcloud >/dev/null 2>&1; then hcloud volume enable-protection "$pv_name" delete 2>/dev/null \ && echo " [ok] volume '$pv_name' protected against deletion" \ || echo " [warn] hcloud protect failed — enable manually: hcloud volume enable-protection $pv_name delete" else echo " [warn] hcloud CLI not found — enable manually: hcloud volume enable-protection $pv_name delete" fi } _method_bootstrap-account() { [ -f "${SCRIPT_DIR}/_credentials.env" ] && set -a && source "${SCRIPT_DIR}/_credentials.env" && set +a _require_env MAIL_ADMIN_EMAIL _require_env MAIL_ADMIN_PASS _require_env DMS_NODE local pvc_name="docker-mailserver-mail-data" _kubectl wait pvc "$pvc_name" -n "$DMS_NAMESPACE" \ --for=jsonpath='{.status.phase}'=Bound --timeout=60s 2>/dev/null || { echo " [warn] PVC $pvc_name not bound — skipping account bootstrap"; return 0 } local hash hash=$(openssl passwd -6 "$MAIL_ADMIN_PASS" 2>/dev/null) || { echo " [warn] openssl passwd -6 unavailable — cannot bootstrap account" >&2; return 0 } local pod_name="dms-bootstrap" _kubectl delete pod "$pod_name" -n "$DMS_NAMESPACE" --ignore-not-found >/dev/null 2>&1 local tmp_pod tmp_pod=$(mktemp /tmp/dms-bootstrap-XXXXXX.yaml) cat > "$tmp_pod" < "\$acf" echo " [ok] created ${MAIL_ADMIN_EMAIL}" volumeMounts: - name: mail-data mountPath: /config subPath: config volumes: - name: mail-data persistentVolumeClaim: claimName: ${pvc_name} EOF _kubectl apply -f "$tmp_pod" rm -f "$tmp_pod" _kubectl wait pod "$pod_name" -n "$DMS_NAMESPACE" \ --for=jsonpath='{.status.phase}'=Succeeded --timeout=60s 2>/dev/null || \ _kubectl wait pod "$pod_name" -n "$DMS_NAMESPACE" \ --for=jsonpath='{.status.phase}'=Failed --timeout=30s 2>/dev/null || true _kubectl logs "$pod_name" -n "$DMS_NAMESPACE" 2>/dev/null || true _kubectl delete pod "$pod_name" -n "$DMS_NAMESPACE" --ignore-not-found >/dev/null 2>&1 } _method_wait-ready() { _wait_for_pod 300 } _method_smtp-check() { local host="${PLAN_PARAM_HOST:-${DMS_LB_IPAM_IP}}" local port="${PLAN_PARAM_PORT:-${DMS_SUBMISSION_PORT}}" echo " checking SMTP $host:$port" if echo QUIT | nc -w 10 "$host" "$port" 2>/dev/null | grep -q "^220"; then echo " [ok] SMTP $host:$port responding" else echo " [warn] SMTP $host:$port not responding" fi } _method_notify-redeploy() { local message="${PLAN_PARAM_MESSAGE:-docker-mailserver redeployed}" echo " [notify] $message" if [ -n "${PLAN_PARAM_WEBHOOK:-}" ]; then curl -sf -X POST "$PLAN_PARAM_WEBHOOK" \ -H "Content-Type: application/json" \ -d "{\"text\": \"${message}\"}" || echo " [warn] webhook failed" fi }