#!/usr/bin/env bash set -euo pipefail remote="${MAILHUB_DEPLOY_REMOTE:-}" remote_dir="${MAILHUB_DEPLOY_DIR:-}" branch="${MAILHUB_DEPLOY_BRANCH:-$(git branch --show-current)}" git_url="${MAILHUB_DEPLOY_GIT_URL:-$(git remote get-url origin)}" stash_remote="${MAILHUB_DEPLOY_STASH_REMOTE:-0}" if [[ -z "${remote}" || -z "${remote_dir}" ]]; then echo "Set MAILHUB_DEPLOY_REMOTE and MAILHUB_DEPLOY_DIR before deploying." >&2 echo "Example: MAILHUB_DEPLOY_REMOTE=deploy@example.com MAILHUB_DEPLOY_DIR=/opt/mailhub npm run deploy:remote" >&2 exit 1 fi if [[ -z "${branch}" ]]; then echo "Unable to detect current git branch." >&2 exit 1 fi git fetch origin "${branch}" local_head="$(git rev-parse HEAD)" remote_head="$(git rev-parse "origin/${branch}")" if [[ "${local_head}" != "${remote_head}" ]]; then echo "Local HEAD is not pushed to origin/${branch}." >&2 echo "Commit and push first, then run this deploy script." >&2 exit 1 fi ssh -o ServerAliveInterval=15 -o ServerAliveCountMax=4 "${remote}" \ 'bash -s' -- "${remote_dir}" "${branch}" "${git_url}" "${stash_remote}" <<'REMOTE' set -euo pipefail remote_dir="$1" branch="$2" git_url="$3" stash_remote="$4" cd "${remote_dir}" wait_for_compose_health() { local timeout="${MAILHUB_DEPLOY_HEALTH_TIMEOUT:-180}" local deadline=$((SECONDS + timeout)) local service container_id snapshot state health all_ready while (( SECONDS < deadline )); do all_ready=1 for service in "$@"; do container_id="$(docker compose ps --all --quiet "${service}" 2>/dev/null | tail -n 1 || true)" if [[ -z "${container_id}" ]]; then all_ready=0 continue fi snapshot="$(docker inspect \ --format '{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{else}}missing{{end}}' \ "${container_id}" 2>/dev/null || true)" state="${snapshot%% *}" health="${snapshot#* }" if [[ "${state}" == "exited" || "${state}" == "dead" ]]; then docker compose logs --tail=100 "${service}" >&2 || true return 1 fi [[ "${state}" == "running" && "${health}" == "healthy" ]] || all_ready=0 done [[ "${all_ready}" == "1" ]] && return 0 sleep 2 done docker compose ps >&2 || true docker compose logs --tail=100 "$@" >&2 || true return 1 } verify_mail_runtime() { docker compose exec -T app node -e ' const fs = require("node:fs"); const secret = fs.readFileSync("/run/secrets/dovecot_auth_secret", "utf8").trim(); fs.accessSync("/data/maildir", fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK); const probe = `/data/maildir/.mailhub-app-health-${process.pid}`; try { fs.writeFileSync(probe, "", { mode: 0o600, flag: "wx" }); } finally { try { fs.unlinkSync(probe); } catch {} } fetch("http://app:3001/internal/dovecot/auth", { method: "POST", headers: { authorization: `Bearer ${secret}`, "content-type": "application/json" }, body: JSON.stringify({ username: "mailhub-healthcheck@invalid.invalid", password: "mailhub-healthcheck-invalid-password", service: "imap", remoteIp: "127.0.0.1" }), signal: AbortSignal.timeout(5000) }).then(async (response) => { const payload = await response.json(); if (response.status !== 200 || payload.authenticated !== false) process.exit(1); }).catch(() => process.exit(1)); ' >/dev/null 2>&1 || { echo "MailHub authentication bridge or app Maildir access check failed." >&2 return 1 } docker compose exec -T --user 1000:1000 dovecot sh -ec ' test -r /run/secrets/dovecot_auth_secret test -s /run/secrets/dovecot_auth_secret probe="/srv/vmail/.mailhub-dovecot-health-$$" trap '\''rm -f -- "$probe"'\'' 0 1 2 15 umask 077 : >"$probe" rm -f -- "$probe" trap - 0 1 2 15 ' >/dev/null 2>&1 || { echo "Dovecot secret or Maildir write access check failed." >&2 return 1 } } if ! git remote get-url origin >/dev/null 2>&1; then git remote add origin "${git_url}" fi if [[ -n "$(git status --porcelain)" ]]; then if [[ "${stash_remote}" == "1" ]]; then git stash push -u -m "pre-deploy-$(date -u +%Y%m%d-%H%M%S)" else echo "Remote working tree is dirty. Set MAILHUB_DEPLOY_STASH_REMOTE=1 to stash it before pulling." >&2 git status --short >&2 exit 1 fi fi previous_revision="$(git rev-parse HEAD)" stopped_app_container="" stopped_dovecot_container="" mail_services_stopped_for_migration=0 maildir_cutover_committed=0 on_deploy_exit() { local status=$? trap - EXIT if [[ "${status}" != "0" ]]; then echo "Deployment failed. Previous revision was ${previous_revision}; inspect the running containers before recovery." >&2 if [[ "${mail_services_stopped_for_migration}" == "1" ]]; then echo "Restarting the pre-migration MailHub mail services." >&2 if [[ -n "${stopped_app_container}" ]]; then docker start "${stopped_app_container}" >/dev/null 2>&1 || \ echo "Unable to restart the pre-migration app container ${stopped_app_container}." >&2 fi if [[ -n "${stopped_dovecot_container}" ]]; then docker start "${stopped_dovecot_container}" >/dev/null 2>&1 || \ echo "Unable to restart the pre-migration Dovecot container ${stopped_dovecot_container}." >&2 fi elif [[ "${maildir_cutover_committed}" == "1" ]]; then docker compose stop app dovecot >/dev/null 2>&1 || \ echo "Unable to stop the post-cutover mail services; inspect their port exposure immediately." >&2 echo "Maildir cutover is already committed; legacy mail services will not be restarted because that would create two conflicting sources of truth." >&2 echo "The maintenance window remains active; recover the current Compose services or perform an explicit revision rollback before reopening mail traffic." >&2 fi docker compose ps >&2 || true fi exit "${status}" } trap on_deploy_exit EXIT git fetch origin "${branch}" git checkout "${branch}" git pull --ff-only origin "${branch}" ./scripts/prepare-dovecot.sh if [[ "$(id -u)" == "0" ]]; then MAILHUB_CERT_READER_GID=1000 MAILHUB_CERT_RESTART=0 ./scripts/sync-tls-certificate.sh else MAILHUB_CERT_RESTART=0 ./scripts/sync-tls-certificate.sh fi docker compose build app postfix docker compose pull dovecot stopped_app_container="$(docker compose ps --all --quiet app 2>/dev/null | tail -n 1 || true)" stopped_dovecot_container="$(docker compose ps --all --quiet dovecot 2>/dev/null | tail -n 1 || true)" mail_services_stopped_for_migration=1 docker compose stop app dovecot docker compose run --rm --no-deps app node scripts/migrate-sqlite-maildir.js maildir_cutover_committed=1 mail_services_stopped_for_migration=0 docker compose up -d wait_for_compose_health app postfix dovecot verify_mail_runtime MAILHUB_CERT_RESTART=1 ./scripts/sync-tls-certificate.sh wait_for_compose_health app postfix dovecot verify_mail_runtime docker compose ps trap - EXIT REMOTE