|
@@ -47,18 +47,18 @@ wait_for_compose_health() {
|
|
|
while (( SECONDS < deadline )); do
|
|
while (( SECONDS < deadline )); do
|
|
|
all_ready=1
|
|
all_ready=1
|
|
|
for service in "$@"; do
|
|
for service in "$@"; do
|
|
|
- container_id="$(docker compose ps --all --quiet "${service}" 2>/dev/null | tail -n 1 || true)"
|
|
|
|
|
|
|
+ container_id="$(docker compose ps --all --quiet "${service}" </dev/null 2>/dev/null | tail -n 1 || true)"
|
|
|
if [[ -z "${container_id}" ]]; then
|
|
if [[ -z "${container_id}" ]]; then
|
|
|
all_ready=0
|
|
all_ready=0
|
|
|
continue
|
|
continue
|
|
|
fi
|
|
fi
|
|
|
snapshot="$(docker inspect \
|
|
snapshot="$(docker inspect \
|
|
|
--format '{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{else}}missing{{end}}' \
|
|
--format '{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{else}}missing{{end}}' \
|
|
|
- "${container_id}" 2>/dev/null || true)"
|
|
|
|
|
|
|
+ "${container_id}" </dev/null 2>/dev/null || true)"
|
|
|
state="${snapshot%% *}"
|
|
state="${snapshot%% *}"
|
|
|
health="${snapshot#* }"
|
|
health="${snapshot#* }"
|
|
|
if [[ "${state}" == "exited" || "${state}" == "dead" ]]; then
|
|
if [[ "${state}" == "exited" || "${state}" == "dead" ]]; then
|
|
|
- docker compose logs --tail=100 "${service}" >&2 || true
|
|
|
|
|
|
|
+ docker compose logs --tail=100 "${service}" </dev/null >&2 || true
|
|
|
return 1
|
|
return 1
|
|
|
fi
|
|
fi
|
|
|
[[ "${state}" == "running" && "${health}" == "healthy" ]] || all_ready=0
|
|
[[ "${state}" == "running" && "${health}" == "healthy" ]] || all_ready=0
|
|
@@ -67,14 +67,22 @@ wait_for_compose_health() {
|
|
|
sleep 2
|
|
sleep 2
|
|
|
done
|
|
done
|
|
|
|
|
|
|
|
- docker compose ps >&2 || true
|
|
|
|
|
- docker compose logs --tail=100 "$@" >&2 || true
|
|
|
|
|
|
|
+ docker compose ps </dev/null >&2 || true
|
|
|
|
|
+ docker compose logs --tail=100 "$@" </dev/null >&2 || true
|
|
|
return 1
|
|
return 1
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
verify_mail_runtime() {
|
|
verify_mail_runtime() {
|
|
|
- docker compose exec -T app node -e '
|
|
|
|
|
|
|
+ local dovecot_probe_name=".mailhub-dovecot-runtime-probe"
|
|
|
|
|
+
|
|
|
|
|
+ docker compose exec -T app </dev/null node -e '
|
|
|
const fs = require("node:fs");
|
|
const fs = require("node:fs");
|
|
|
|
|
+ const dovecotProbe = `/data/maildir/${process.argv[1]}`;
|
|
|
|
|
+ try {
|
|
|
|
|
+ fs.unlinkSync(dovecotProbe);
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ if (error.code !== "ENOENT") throw error;
|
|
|
|
|
+ }
|
|
|
const secret = fs.readFileSync("/run/secrets/dovecot_auth_secret", "utf8").trim();
|
|
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);
|
|
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}`;
|
|
const probe = `/data/maildir/.mailhub-app-health-${process.pid}`;
|
|
@@ -100,41 +108,57 @@ verify_mail_runtime() {
|
|
|
const payload = await response.json();
|
|
const payload = await response.json();
|
|
|
if (response.status !== 200 || payload.authenticated !== false) process.exit(1);
|
|
if (response.status !== 200 || payload.authenticated !== false) process.exit(1);
|
|
|
}).catch(() => process.exit(1));
|
|
}).catch(() => process.exit(1));
|
|
|
- ' >/dev/null 2>&1 || {
|
|
|
|
|
|
|
+ ' "${dovecot_probe_name}" >/dev/null 2>&1 || {
|
|
|
echo "MailHub authentication bridge or app Maildir access check failed." >&2
|
|
echo "MailHub authentication bridge or app Maildir access check failed." >&2
|
|
|
return 1
|
|
return 1
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- docker compose exec -T --user 1000:1000 dovecot sh -ec '
|
|
|
|
|
|
|
+ if ! docker compose exec -T --user 1000:1000 dovecot </dev/null sh -ec '
|
|
|
test -r /run/secrets/dovecot_auth_secret
|
|
test -r /run/secrets/dovecot_auth_secret
|
|
|
test -s /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
|
|
|
|
|
|
|
+ probe="/srv/vmail/$1"
|
|
|
|
|
+ test ! -e "$probe"
|
|
|
umask 077
|
|
umask 077
|
|
|
: >"$probe"
|
|
: >"$probe"
|
|
|
- rm -f -- "$probe"
|
|
|
|
|
- trap - 0 1 2 15
|
|
|
|
|
- ' >/dev/null 2>&1 || {
|
|
|
|
|
|
|
+ test -f "$probe"
|
|
|
|
|
+ ' -- "${dovecot_probe_name}" >/dev/null 2>&1; then
|
|
|
|
|
+ docker compose exec -T app </dev/null node -e '
|
|
|
|
|
+ const fs = require("node:fs");
|
|
|
|
|
+ try { fs.unlinkSync(`/data/maildir/${process.argv[1]}`); } catch {}
|
|
|
|
|
+ ' "${dovecot_probe_name}" >/dev/null 2>&1 || true
|
|
|
echo "Dovecot secret or Maildir write access check failed." >&2
|
|
echo "Dovecot secret or Maildir write access check failed." >&2
|
|
|
return 1
|
|
return 1
|
|
|
|
|
+ fi
|
|
|
|
|
+
|
|
|
|
|
+ docker compose exec -T app </dev/null node -e '
|
|
|
|
|
+ const fs = require("node:fs");
|
|
|
|
|
+ const probe = `/data/maildir/${process.argv[1]}`;
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (!fs.statSync(probe).isFile()) process.exitCode = 1;
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ try { fs.unlinkSync(probe); } catch {}
|
|
|
|
|
+ }
|
|
|
|
|
+ ' "${dovecot_probe_name}" >/dev/null 2>&1 || {
|
|
|
|
|
+ echo "Dovecot Maildir write probe was not visible to the MailHub app." >&2
|
|
|
|
|
+ return 1
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-if ! git remote get-url origin >/dev/null 2>&1; then
|
|
|
|
|
- git remote add origin "${git_url}"
|
|
|
|
|
|
|
+if ! git remote get-url origin </dev/null >/dev/null 2>&1; then
|
|
|
|
|
+ git remote add origin "${git_url}" </dev/null
|
|
|
fi
|
|
fi
|
|
|
|
|
|
|
|
-if [[ -n "$(git status --porcelain)" ]]; then
|
|
|
|
|
|
|
+if [[ -n "$(git status --porcelain </dev/null)" ]]; then
|
|
|
if [[ "${stash_remote}" == "1" ]]; then
|
|
if [[ "${stash_remote}" == "1" ]]; then
|
|
|
- git stash push -u -m "pre-deploy-$(date -u +%Y%m%d-%H%M%S)"
|
|
|
|
|
|
|
+ git stash push -u -m "pre-deploy-$(date -u +%Y%m%d-%H%M%S)" </dev/null
|
|
|
else
|
|
else
|
|
|
echo "Remote working tree is dirty. Set MAILHUB_DEPLOY_STASH_REMOTE=1 to stash it before pulling." >&2
|
|
echo "Remote working tree is dirty. Set MAILHUB_DEPLOY_STASH_REMOTE=1 to stash it before pulling." >&2
|
|
|
- git status --short >&2
|
|
|
|
|
|
|
+ git status --short </dev/null >&2
|
|
|
exit 1
|
|
exit 1
|
|
|
fi
|
|
fi
|
|
|
fi
|
|
fi
|
|
|
|
|
|
|
|
-previous_revision="$(git rev-parse HEAD)"
|
|
|
|
|
|
|
+previous_revision="$(git rev-parse HEAD </dev/null)"
|
|
|
stopped_app_container=""
|
|
stopped_app_container=""
|
|
|
stopped_dovecot_container=""
|
|
stopped_dovecot_container=""
|
|
|
mail_services_stopped_for_migration=0
|
|
mail_services_stopped_for_migration=0
|
|
@@ -147,49 +171,49 @@ on_deploy_exit() {
|
|
|
if [[ "${mail_services_stopped_for_migration}" == "1" ]]; then
|
|
if [[ "${mail_services_stopped_for_migration}" == "1" ]]; then
|
|
|
echo "Restarting the pre-migration MailHub mail services." >&2
|
|
echo "Restarting the pre-migration MailHub mail services." >&2
|
|
|
if [[ -n "${stopped_app_container}" ]]; then
|
|
if [[ -n "${stopped_app_container}" ]]; then
|
|
|
- docker start "${stopped_app_container}" >/dev/null 2>&1 || \
|
|
|
|
|
|
|
+ docker start "${stopped_app_container}" </dev/null >/dev/null 2>&1 || \
|
|
|
echo "Unable to restart the pre-migration app container ${stopped_app_container}." >&2
|
|
echo "Unable to restart the pre-migration app container ${stopped_app_container}." >&2
|
|
|
fi
|
|
fi
|
|
|
if [[ -n "${stopped_dovecot_container}" ]]; then
|
|
if [[ -n "${stopped_dovecot_container}" ]]; then
|
|
|
- docker start "${stopped_dovecot_container}" >/dev/null 2>&1 || \
|
|
|
|
|
|
|
+ docker start "${stopped_dovecot_container}" </dev/null >/dev/null 2>&1 || \
|
|
|
echo "Unable to restart the pre-migration Dovecot container ${stopped_dovecot_container}." >&2
|
|
echo "Unable to restart the pre-migration Dovecot container ${stopped_dovecot_container}." >&2
|
|
|
fi
|
|
fi
|
|
|
elif [[ "${maildir_cutover_committed}" == "1" ]]; then
|
|
elif [[ "${maildir_cutover_committed}" == "1" ]]; then
|
|
|
- docker compose stop app dovecot >/dev/null 2>&1 || \
|
|
|
|
|
|
|
+ docker compose stop app dovecot </dev/null >/dev/null 2>&1 || \
|
|
|
echo "Unable to stop the post-cutover mail services; inspect their port exposure immediately." >&2
|
|
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 "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
|
|
echo "The maintenance window remains active; recover the current Compose services or perform an explicit revision rollback before reopening mail traffic." >&2
|
|
|
fi
|
|
fi
|
|
|
- docker compose ps >&2 || true
|
|
|
|
|
|
|
+ docker compose ps </dev/null >&2 || true
|
|
|
fi
|
|
fi
|
|
|
exit "${status}"
|
|
exit "${status}"
|
|
|
}
|
|
}
|
|
|
trap on_deploy_exit EXIT
|
|
trap on_deploy_exit EXIT
|
|
|
|
|
|
|
|
-git fetch origin "${branch}"
|
|
|
|
|
-git checkout "${branch}"
|
|
|
|
|
-git pull --ff-only origin "${branch}"
|
|
|
|
|
-./scripts/prepare-dovecot.sh
|
|
|
|
|
|
|
+git fetch origin "${branch}" </dev/null
|
|
|
|
|
+git checkout "${branch}" </dev/null
|
|
|
|
|
+git pull --ff-only origin "${branch}" </dev/null
|
|
|
|
|
+./scripts/prepare-dovecot.sh </dev/null
|
|
|
if [[ "$(id -u)" == "0" ]]; then
|
|
if [[ "$(id -u)" == "0" ]]; then
|
|
|
- MAILHUB_CERT_READER_GID=1000 MAILHUB_CERT_RESTART=0 ./scripts/sync-tls-certificate.sh
|
|
|
|
|
|
|
+ MAILHUB_CERT_READER_GID=1000 MAILHUB_CERT_RESTART=0 ./scripts/sync-tls-certificate.sh </dev/null
|
|
|
else
|
|
else
|
|
|
- MAILHUB_CERT_RESTART=0 ./scripts/sync-tls-certificate.sh
|
|
|
|
|
|
|
+ MAILHUB_CERT_RESTART=0 ./scripts/sync-tls-certificate.sh </dev/null
|
|
|
fi
|
|
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)"
|
|
|
|
|
|
|
+docker compose build app postfix </dev/null
|
|
|
|
|
+docker compose pull dovecot </dev/null
|
|
|
|
|
+stopped_app_container="$(docker compose ps --all --quiet app </dev/null 2>/dev/null | tail -n 1 || true)"
|
|
|
|
|
+stopped_dovecot_container="$(docker compose ps --all --quiet dovecot </dev/null 2>/dev/null | tail -n 1 || true)"
|
|
|
mail_services_stopped_for_migration=1
|
|
mail_services_stopped_for_migration=1
|
|
|
-docker compose stop app dovecot
|
|
|
|
|
-docker compose run --rm --no-deps -T app node scripts/migrate-sqlite-maildir.js
|
|
|
|
|
|
|
+docker compose stop app dovecot </dev/null
|
|
|
|
|
+docker compose run --rm --no-deps -T app </dev/null node scripts/migrate-sqlite-maildir.js
|
|
|
maildir_cutover_committed=1
|
|
maildir_cutover_committed=1
|
|
|
mail_services_stopped_for_migration=0
|
|
mail_services_stopped_for_migration=0
|
|
|
-docker compose up -d
|
|
|
|
|
|
|
+docker compose up -d </dev/null
|
|
|
wait_for_compose_health app postfix dovecot
|
|
wait_for_compose_health app postfix dovecot
|
|
|
verify_mail_runtime
|
|
verify_mail_runtime
|
|
|
-MAILHUB_CERT_RESTART=1 ./scripts/sync-tls-certificate.sh
|
|
|
|
|
|
|
+MAILHUB_CERT_RESTART=1 ./scripts/sync-tls-certificate.sh </dev/null
|
|
|
wait_for_compose_health app postfix dovecot
|
|
wait_for_compose_health app postfix dovecot
|
|
|
verify_mail_runtime
|
|
verify_mail_runtime
|
|
|
-docker compose ps
|
|
|
|
|
|
|
+docker compose ps </dev/null
|
|
|
trap - EXIT
|
|
trap - EXIT
|
|
|
REMOTE
|
|
REMOTE
|