deploy-remote.sh 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  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. run_maildir_migration="${MAILHUB_DEPLOY_RUN_MAILDIR_MIGRATION:-0}"
  9. if [[ -z "${remote}" || -z "${remote_dir}" ]]; then
  10. echo "Set MAILHUB_DEPLOY_REMOTE and MAILHUB_DEPLOY_DIR before deploying." >&2
  11. echo "Example: MAILHUB_DEPLOY_REMOTE=deploy@example.com MAILHUB_DEPLOY_DIR=/opt/mailhub npm run deploy:remote" >&2
  12. exit 1
  13. fi
  14. if [[ -z "${branch}" ]]; then
  15. echo "Unable to detect current git branch." >&2
  16. exit 1
  17. fi
  18. git fetch origin "${branch}"
  19. local_head="$(git rev-parse HEAD)"
  20. remote_head="$(git rev-parse "origin/${branch}")"
  21. if [[ "${local_head}" != "${remote_head}" ]]; then
  22. echo "Local HEAD is not pushed to origin/${branch}." >&2
  23. echo "Commit and push first, then run this deploy script." >&2
  24. exit 1
  25. fi
  26. ssh -o ServerAliveInterval=15 -o ServerAliveCountMax=4 "${remote}" \
  27. 'bash -s' -- "${remote_dir}" "${branch}" "${git_url}" "${stash_remote}" "${run_maildir_migration}" <<'REMOTE'
  28. set -euo pipefail
  29. remote_dir="$1"
  30. branch="$2"
  31. git_url="$3"
  32. stash_remote="$4"
  33. run_maildir_migration="$5"
  34. cd "${remote_dir}"
  35. wait_for_compose_health() {
  36. local timeout="${MAILHUB_DEPLOY_HEALTH_TIMEOUT:-180}"
  37. local deadline=$((SECONDS + timeout))
  38. local service container_id snapshot state health all_ready
  39. while (( SECONDS < deadline )); do
  40. all_ready=1
  41. for service in "$@"; do
  42. container_id="$(docker compose ps --all --quiet "${service}" </dev/null 2>/dev/null | tail -n 1 || true)"
  43. if [[ -z "${container_id}" ]]; then
  44. all_ready=0
  45. continue
  46. fi
  47. snapshot="$(docker inspect \
  48. --format '{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{else}}missing{{end}}' \
  49. "${container_id}" </dev/null 2>/dev/null || true)"
  50. state="${snapshot%% *}"
  51. health="${snapshot#* }"
  52. if [[ "${state}" == "exited" || "${state}" == "dead" ]]; then
  53. docker compose logs --tail=100 "${service}" </dev/null >&2 || true
  54. return 1
  55. fi
  56. [[ "${state}" == "running" && "${health}" == "healthy" ]] || all_ready=0
  57. done
  58. [[ "${all_ready}" == "1" ]] && return 0
  59. sleep 2
  60. done
  61. docker compose ps </dev/null >&2 || true
  62. docker compose logs --tail=100 "$@" </dev/null >&2 || true
  63. return 1
  64. }
  65. verify_mail_runtime() {
  66. local dovecot_probe_name=".mailhub-dovecot-runtime-probe"
  67. docker compose exec -T app </dev/null node -e '
  68. const fs = require("node:fs");
  69. const dovecotProbe = `/data/maildir/${process.argv[1]}`;
  70. try {
  71. fs.unlinkSync(dovecotProbe);
  72. } catch (error) {
  73. if (error.code !== "ENOENT") throw error;
  74. }
  75. const secret = fs.readFileSync("/run/secrets/dovecot_auth_secret", "utf8").trim();
  76. fs.accessSync("/data/maildir", fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);
  77. const probe = `/data/maildir/.mailhub-app-health-${process.pid}`;
  78. try {
  79. fs.writeFileSync(probe, "", { mode: 0o600, flag: "wx" });
  80. } finally {
  81. try { fs.unlinkSync(probe); } catch {}
  82. }
  83. fetch("http://app:3001/internal/dovecot/auth", {
  84. method: "POST",
  85. headers: {
  86. authorization: `Bearer ${secret}`,
  87. "content-type": "application/json"
  88. },
  89. body: JSON.stringify({
  90. username: "mailhub-healthcheck@invalid.invalid",
  91. password: "mailhub-healthcheck-invalid-password",
  92. service: "imap",
  93. remoteIp: "127.0.0.1"
  94. }),
  95. signal: AbortSignal.timeout(5000)
  96. }).then(async (response) => {
  97. const payload = await response.json();
  98. if (response.status !== 200 || payload.authenticated !== false) process.exit(1);
  99. }).catch(() => process.exit(1));
  100. ' "${dovecot_probe_name}" >/dev/null 2>&1 || {
  101. echo "MailHub authentication bridge or app Maildir access check failed." >&2
  102. return 1
  103. }
  104. docker compose exec -T app </dev/null node -e '
  105. const tls = require("node:tls");
  106. const socket = tls.connect({
  107. host: "dovecot",
  108. port: 31993,
  109. servername: process.env.MAIL_HOSTNAME || "mailhub.local",
  110. rejectUnauthorized: false
  111. });
  112. socket.setEncoding("utf8");
  113. let buffer = "";
  114. let finished = false;
  115. let loginSent = false;
  116. const timer = setTimeout(() => finish(false), 15000);
  117. function finish(ok) {
  118. if (finished) return;
  119. finished = true;
  120. clearTimeout(timer);
  121. socket.destroy();
  122. process.exit(ok ? 0 : 1);
  123. }
  124. socket.on("data", (chunk) => {
  125. buffer += chunk;
  126. const lines = buffer.split(/\r?\n/).filter(Boolean);
  127. if (!loginSent && lines.some((line) => /^\* OK\b/i.test(line))) {
  128. loginSent = true;
  129. socket.write("A1 LOGIN \"mailhub-healthcheck@invalid.invalid\" \"mailhub-healthcheck-invalid-password\"\r\n");
  130. }
  131. const tagged = lines.find((line) => /^A1\b/i.test(line));
  132. if (!tagged) return;
  133. if (/temporary authentication failure|unavailable/i.test(tagged)) return finish(false);
  134. finish(/\bNO\b/i.test(tagged) && /auth/i.test(tagged));
  135. });
  136. socket.on("error", () => finish(false));
  137. socket.on("end", () => finish(false));
  138. ' >/dev/null 2>&1 || {
  139. echo "Dovecot IMAPS authentication path check failed." >&2
  140. return 1
  141. }
  142. if ! docker compose exec -T --user 1000:1000 dovecot </dev/null sh -ec '
  143. test -r /run/secrets/dovecot_auth_secret
  144. test -s /run/secrets/dovecot_auth_secret
  145. probe="/srv/vmail/$1"
  146. test ! -e "$probe"
  147. umask 077
  148. : >"$probe"
  149. test -f "$probe"
  150. ' -- "${dovecot_probe_name}" >/dev/null 2>&1; then
  151. docker compose exec -T app </dev/null node -e '
  152. const fs = require("node:fs");
  153. try { fs.unlinkSync(`/data/maildir/${process.argv[1]}`); } catch {}
  154. ' "${dovecot_probe_name}" >/dev/null 2>&1 || true
  155. echo "Dovecot secret or Maildir write access check failed." >&2
  156. return 1
  157. fi
  158. docker compose exec -T app </dev/null node -e '
  159. const fs = require("node:fs");
  160. const probe = `/data/maildir/${process.argv[1]}`;
  161. try {
  162. if (!fs.statSync(probe).isFile()) process.exitCode = 1;
  163. } finally {
  164. try { fs.unlinkSync(probe); } catch {}
  165. }
  166. ' "${dovecot_probe_name}" >/dev/null 2>&1 || {
  167. echo "Dovecot Maildir write probe was not visible to the MailHub app." >&2
  168. return 1
  169. }
  170. }
  171. if ! git remote get-url origin </dev/null >/dev/null 2>&1; then
  172. git remote add origin "${git_url}" </dev/null
  173. fi
  174. if [[ -n "$(git status --porcelain </dev/null)" ]]; then
  175. if [[ "${stash_remote}" == "1" ]]; then
  176. git stash push -u -m "pre-deploy-$(date -u +%Y%m%d-%H%M%S)" </dev/null
  177. else
  178. echo "Remote working tree is dirty. Set MAILHUB_DEPLOY_STASH_REMOTE=1 to stash it before pulling." >&2
  179. git status --short </dev/null >&2
  180. exit 1
  181. fi
  182. fi
  183. previous_revision="$(git rev-parse HEAD </dev/null)"
  184. stopped_app_container=""
  185. stopped_dovecot_container=""
  186. mail_services_stopped_for_migration=0
  187. maildir_cutover_committed=0
  188. on_deploy_exit() {
  189. local status=$?
  190. trap - EXIT
  191. if [[ "${status}" != "0" ]]; then
  192. echo "Deployment failed. Previous revision was ${previous_revision}; inspect the running containers before recovery." >&2
  193. if [[ "${mail_services_stopped_for_migration}" == "1" ]]; then
  194. echo "Restarting the pre-migration MailHub mail services." >&2
  195. if [[ -n "${stopped_app_container}" ]]; then
  196. docker start "${stopped_app_container}" </dev/null >/dev/null 2>&1 || \
  197. echo "Unable to restart the pre-migration app container ${stopped_app_container}." >&2
  198. fi
  199. if [[ -n "${stopped_dovecot_container}" ]]; then
  200. docker start "${stopped_dovecot_container}" </dev/null >/dev/null 2>&1 || \
  201. echo "Unable to restart the pre-migration Dovecot container ${stopped_dovecot_container}." >&2
  202. fi
  203. elif [[ "${maildir_cutover_committed}" == "1" ]]; then
  204. docker compose stop app dovecot </dev/null >/dev/null 2>&1 || \
  205. echo "Unable to stop the post-cutover mail services; inspect their port exposure immediately." >&2
  206. echo "Maildir cutover is already committed; legacy mail services will not be restarted because that would create two conflicting sources of truth." >&2
  207. echo "The maintenance window remains active; recover the current Compose services or perform an explicit revision rollback before reopening mail traffic." >&2
  208. fi
  209. docker compose ps </dev/null >&2 || true
  210. fi
  211. exit "${status}"
  212. }
  213. trap on_deploy_exit EXIT
  214. git fetch origin "${branch}" </dev/null
  215. git checkout "${branch}" </dev/null
  216. git pull --ff-only origin "${branch}" </dev/null
  217. ./scripts/prepare-dovecot.sh </dev/null
  218. if [[ "$(id -u)" == "0" ]]; then
  219. MAILHUB_CERT_READER_GID=1000 MAILHUB_CERT_RESTART=0 ./scripts/sync-tls-certificate.sh </dev/null
  220. else
  221. MAILHUB_CERT_RESTART=0 ./scripts/sync-tls-certificate.sh </dev/null
  222. fi
  223. docker compose build app postfix </dev/null
  224. docker compose pull dovecot </dev/null
  225. if [[ "${run_maildir_migration}" == "1" ]]; then
  226. stopped_app_container="$(docker compose ps --all --quiet app </dev/null 2>/dev/null | tail -n 1 || true)"
  227. stopped_dovecot_container="$(docker compose ps --all --quiet dovecot </dev/null 2>/dev/null | tail -n 1 || true)"
  228. mail_services_stopped_for_migration=1
  229. docker compose stop app dovecot </dev/null
  230. docker compose run --rm --no-deps -T app </dev/null node scripts/migrate-sqlite-maildir.js
  231. mail_services_stopped_for_migration=0
  232. else
  233. echo "Skipping Maildir migration check; set MAILHUB_DEPLOY_RUN_MAILDIR_MIGRATION=1 to run it."
  234. fi
  235. maildir_cutover_committed=1
  236. docker compose up -d </dev/null
  237. wait_for_compose_health app postfix dovecot
  238. verify_mail_runtime
  239. MAILHUB_CERT_RESTART=1 ./scripts/sync-tls-certificate.sh </dev/null
  240. wait_for_compose_health app postfix dovecot
  241. verify_mail_runtime
  242. docker compose ps </dev/null
  243. trap - EXIT
  244. REMOTE