deploy-remote.sh 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. #!/usr/bin/env bash
  2. set -euo pipefail
  3. remote="${MAILHUB_DEPLOY_REMOTE:-}"
  4. remote_dir="${MAILHUB_DEPLOY_DIR:-}"
  5. branch="${MAILHUB_DEPLOY_BRANCH:-$(git branch --show-current)}"
  6. git_url="${MAILHUB_DEPLOY_GIT_URL:-$(git remote get-url origin)}"
  7. stash_remote="${MAILHUB_DEPLOY_STASH_REMOTE:-0}"
  8. if [[ -z "${remote}" || -z "${remote_dir}" ]]; then
  9. echo "Set MAILHUB_DEPLOY_REMOTE and MAILHUB_DEPLOY_DIR before deploying." >&2
  10. echo "Example: MAILHUB_DEPLOY_REMOTE=deploy@example.com MAILHUB_DEPLOY_DIR=/opt/mailhub npm run deploy:remote" >&2
  11. exit 1
  12. fi
  13. if [[ -z "${branch}" ]]; then
  14. echo "Unable to detect current git branch." >&2
  15. exit 1
  16. fi
  17. git fetch origin "${branch}"
  18. local_head="$(git rev-parse HEAD)"
  19. remote_head="$(git rev-parse "origin/${branch}")"
  20. if [[ "${local_head}" != "${remote_head}" ]]; then
  21. echo "Local HEAD is not pushed to origin/${branch}." >&2
  22. echo "Commit and push first, then run this deploy script." >&2
  23. exit 1
  24. fi
  25. ssh -o ServerAliveInterval=15 -o ServerAliveCountMax=4 "${remote}" \
  26. 'bash -s' -- "${remote_dir}" "${branch}" "${git_url}" "${stash_remote}" <<'REMOTE'
  27. set -euo pipefail
  28. remote_dir="$1"
  29. branch="$2"
  30. git_url="$3"
  31. stash_remote="$4"
  32. cd "${remote_dir}"
  33. wait_for_compose_health() {
  34. local timeout="${MAILHUB_DEPLOY_HEALTH_TIMEOUT:-180}"
  35. local deadline=$((SECONDS + timeout))
  36. local service container_id snapshot state health all_ready
  37. while (( SECONDS < deadline )); do
  38. all_ready=1
  39. for service in "$@"; do
  40. container_id="$(docker compose ps --all --quiet "${service}" 2>/dev/null | tail -n 1 || true)"
  41. if [[ -z "${container_id}" ]]; then
  42. all_ready=0
  43. continue
  44. fi
  45. snapshot="$(docker inspect \
  46. --format '{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{else}}missing{{end}}' \
  47. "${container_id}" 2>/dev/null || true)"
  48. state="${snapshot%% *}"
  49. health="${snapshot#* }"
  50. if [[ "${state}" == "exited" || "${state}" == "dead" ]]; then
  51. docker compose logs --tail=100 "${service}" >&2 || true
  52. return 1
  53. fi
  54. [[ "${state}" == "running" && "${health}" == "healthy" ]] || all_ready=0
  55. done
  56. [[ "${all_ready}" == "1" ]] && return 0
  57. sleep 2
  58. done
  59. docker compose ps >&2 || true
  60. docker compose logs --tail=100 "$@" >&2 || true
  61. return 1
  62. }
  63. verify_mail_runtime() {
  64. docker compose exec -T app node -e '
  65. const fs = require("node:fs");
  66. const secret = fs.readFileSync("/run/secrets/dovecot_auth_secret", "utf8").trim();
  67. fs.accessSync("/data/maildir", fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);
  68. const probe = `/data/maildir/.mailhub-app-health-${process.pid}`;
  69. try {
  70. fs.writeFileSync(probe, "", { mode: 0o600, flag: "wx" });
  71. } finally {
  72. try { fs.unlinkSync(probe); } catch {}
  73. }
  74. fetch("http://app:3001/internal/dovecot/auth", {
  75. method: "POST",
  76. headers: {
  77. authorization: `Bearer ${secret}`,
  78. "content-type": "application/json"
  79. },
  80. body: JSON.stringify({
  81. username: "mailhub-healthcheck@invalid.invalid",
  82. password: "mailhub-healthcheck-invalid-password",
  83. service: "imap",
  84. remoteIp: "127.0.0.1"
  85. }),
  86. signal: AbortSignal.timeout(5000)
  87. }).then(async (response) => {
  88. const payload = await response.json();
  89. if (response.status !== 200 || payload.authenticated !== false) process.exit(1);
  90. }).catch(() => process.exit(1));
  91. ' >/dev/null 2>&1 || {
  92. echo "MailHub authentication bridge or app Maildir access check failed." >&2
  93. return 1
  94. }
  95. docker compose exec -T --user 1000:1000 dovecot sh -ec '
  96. test -r /run/secrets/dovecot_auth_secret
  97. test -s /run/secrets/dovecot_auth_secret
  98. probe="/srv/vmail/.mailhub-dovecot-health-$$"
  99. trap '\''rm -f -- "$probe"'\'' 0 1 2 15
  100. umask 077
  101. : >"$probe"
  102. rm -f -- "$probe"
  103. trap - 0 1 2 15
  104. ' >/dev/null 2>&1 || {
  105. echo "Dovecot secret or Maildir write access check failed." >&2
  106. return 1
  107. }
  108. }
  109. if ! git remote get-url origin >/dev/null 2>&1; then
  110. git remote add origin "${git_url}"
  111. fi
  112. if [[ -n "$(git status --porcelain)" ]]; then
  113. if [[ "${stash_remote}" == "1" ]]; then
  114. git stash push -u -m "pre-deploy-$(date -u +%Y%m%d-%H%M%S)"
  115. else
  116. echo "Remote working tree is dirty. Set MAILHUB_DEPLOY_STASH_REMOTE=1 to stash it before pulling." >&2
  117. git status --short >&2
  118. exit 1
  119. fi
  120. fi
  121. previous_revision="$(git rev-parse HEAD)"
  122. stopped_app_container=""
  123. stopped_dovecot_container=""
  124. mail_services_stopped_for_migration=0
  125. maildir_cutover_committed=0
  126. on_deploy_exit() {
  127. local status=$?
  128. trap - EXIT
  129. if [[ "${status}" != "0" ]]; then
  130. echo "Deployment failed. Previous revision was ${previous_revision}; inspect the running containers before recovery." >&2
  131. if [[ "${mail_services_stopped_for_migration}" == "1" ]]; then
  132. echo "Restarting the pre-migration MailHub mail services." >&2
  133. if [[ -n "${stopped_app_container}" ]]; then
  134. docker start "${stopped_app_container}" >/dev/null 2>&1 || \
  135. echo "Unable to restart the pre-migration app container ${stopped_app_container}." >&2
  136. fi
  137. if [[ -n "${stopped_dovecot_container}" ]]; then
  138. docker start "${stopped_dovecot_container}" >/dev/null 2>&1 || \
  139. echo "Unable to restart the pre-migration Dovecot container ${stopped_dovecot_container}." >&2
  140. fi
  141. elif [[ "${maildir_cutover_committed}" == "1" ]]; then
  142. docker compose stop app dovecot >/dev/null 2>&1 || \
  143. echo "Unable to stop the post-cutover mail services; inspect their port exposure immediately." >&2
  144. echo "Maildir cutover is already committed; legacy mail services will not be restarted because that would create two conflicting sources of truth." >&2
  145. echo "The maintenance window remains active; recover the current Compose services or perform an explicit revision rollback before reopening mail traffic." >&2
  146. fi
  147. docker compose ps >&2 || true
  148. fi
  149. exit "${status}"
  150. }
  151. trap on_deploy_exit EXIT
  152. git fetch origin "${branch}"
  153. git checkout "${branch}"
  154. git pull --ff-only origin "${branch}"
  155. ./scripts/prepare-dovecot.sh
  156. if [[ "$(id -u)" == "0" ]]; then
  157. MAILHUB_CERT_READER_GID=1000 MAILHUB_CERT_RESTART=0 ./scripts/sync-tls-certificate.sh
  158. else
  159. MAILHUB_CERT_RESTART=0 ./scripts/sync-tls-certificate.sh
  160. fi
  161. docker compose build app postfix
  162. docker compose pull dovecot
  163. stopped_app_container="$(docker compose ps --all --quiet app 2>/dev/null | tail -n 1 || true)"
  164. stopped_dovecot_container="$(docker compose ps --all --quiet dovecot 2>/dev/null | tail -n 1 || true)"
  165. mail_services_stopped_for_migration=1
  166. docker compose stop app dovecot
  167. docker compose run --rm --no-deps app node scripts/migrate-sqlite-maildir.js
  168. maildir_cutover_committed=1
  169. mail_services_stopped_for_migration=0
  170. docker compose up -d
  171. wait_for_compose_health app postfix dovecot
  172. verify_mail_runtime
  173. MAILHUB_CERT_RESTART=1 ./scripts/sync-tls-certificate.sh
  174. wait_for_compose_health app postfix dovecot
  175. verify_mail_runtime
  176. docker compose ps
  177. trap - EXIT
  178. REMOTE