ソースを参照

fix: isolate remote deploy stdin

AI-Co-Authored-By: Codex
chendeben 1 ヶ月 前
コミット
4aa243ce64
2 ファイル変更97 行追加42 行削除
  1. 62 38
      scripts/deploy-remote.sh
  2. 35 4
      test/deploy-remote-script.test.js

+ 62 - 38
scripts/deploy-remote.sh

@@ -47,18 +47,18 @@ wait_for_compose_health() {
   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)"
+      container_id="$(docker compose ps --all --quiet "${service}" </dev/null 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)"
+        "${container_id}" </dev/null 2>/dev/null || true)"
       state="${snapshot%% *}"
       health="${snapshot#* }"
       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
       fi
       [[ "${state}" == "running" && "${health}" == "healthy" ]] || all_ready=0
@@ -67,14 +67,22 @@ wait_for_compose_health() {
     sleep 2
   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
 }
 
 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 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();
     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}`;
@@ -100,41 +108,57 @@ verify_mail_runtime() {
       const payload = await response.json();
       if (response.status !== 200 || payload.authenticated !== false) 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
     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 -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
     : >"$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
     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
 
-if [[ -n "$(git status --porcelain)" ]]; then
+if [[ -n "$(git status --porcelain </dev/null)" ]]; 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
     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
   fi
 fi
 
-previous_revision="$(git rev-parse HEAD)"
+previous_revision="$(git rev-parse HEAD </dev/null)"
 stopped_app_container=""
 stopped_dovecot_container=""
 mail_services_stopped_for_migration=0
@@ -147,49 +171,49 @@ on_deploy_exit() {
     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 || \
+        docker start "${stopped_app_container}" </dev/null >/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 || \
+        docker start "${stopped_dovecot_container}" </dev/null >/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 || \
+      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 "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
+    docker compose ps </dev/null >&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
+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
-  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
-  MAILHUB_CERT_RESTART=0 ./scripts/sync-tls-certificate.sh
+  MAILHUB_CERT_RESTART=0 ./scripts/sync-tls-certificate.sh </dev/null
 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
-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
 mail_services_stopped_for_migration=0
-docker compose up -d
+docker compose up -d </dev/null
 wait_for_compose_health app postfix dovecot
 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
 verify_mail_runtime
-docker compose ps
+docker compose ps </dev/null
 trap - EXIT
 REMOTE

+ 35 - 4
test/deploy-remote-script.test.js

@@ -18,13 +18,24 @@ const scriptSource = readFileSync(scriptPath, 'utf8');
 const canRun = process.platform !== 'win32';
 
 test('checks Maildir access with Dovecot mail worker uid instead of container root', () => {
-  assert.match(scriptSource, /compose exec -T --user 1000:1000 dovecot/);
+  assert.match(scriptSource, /compose exec -T --user 1000:1000 dovecot <\/dev\/null sh/);
+  assert.match(scriptSource, /fs\.statSync\(probe\)\.isFile\(\)/);
+  assert.doesNotMatch(scriptSource, /trap .*rm -f -- \"\$probe\"/);
 });
 
 test('runs the Maildir migration without consuming the SSH heredoc stdin', () => {
   assert.match(
     scriptSource,
-    /docker compose run --rm --no-deps -T app node scripts\/migrate-sqlite-maildir\.js/
+    /docker compose run --rm --no-deps -T app <\/dev\/null node scripts\/migrate-sqlite-maildir\.js/
+  );
+});
+
+test('isolates runtime probes and setup scripts from the SSH heredoc stdin', () => {
+  assert.match(scriptSource, /docker compose exec -T app <\/dev\/null node -e/);
+  assert.match(scriptSource, /\.\/scripts\/prepare-dovecot\.sh <\/dev\/null/);
+  assert.equal(
+    scriptSource.match(/\.\/scripts\/sync-tls-certificate\.sh <\/dev\/null/g)?.length,
+    3
   );
 });
 
@@ -47,10 +58,21 @@ test('waits for app and postfix health before and after certificate synchronizat
       'health:dovecot-container'
     ]
   );
-  assert.equal(events.filter((event) => event === 'runtime:app').length, 2);
+  assert.equal(events.filter((event) => event === 'runtime:app').length, 4);
   assert.equal(events.filter((event) => event === 'runtime:dovecot').length, 2);
 });
 
+test('continues after remote tools actively consume their stdin', { skip: !canRun }, (t) => {
+  const fixture = createFixture(t);
+  const result = runDeploy(fixture);
+
+  assert.equal(result.status, 0, result.stderr);
+  const events = readEvents(fixture.logFile);
+  assert.ok(events.includes('migrate'), events.join('\n'));
+  assert.ok(events.includes('sync:1'), events.join('\n'));
+  assert.equal(events.at(-1), 'final:ps', events.join('\n'));
+});
+
 test('prepares the certificate and Dovecot image before entering the maintenance window', { skip: !canRun }, (t) => {
   const fixture = createFixture(t);
   const result = runDeploy(fixture);
@@ -96,7 +118,8 @@ test('stops the app for migration and restarts the previous container if migrati
     'stop:app,dovecot',
     'migrate',
     'restart:app-container',
-    'restart:dovecot-container'
+    'restart:dovecot-container',
+    'final:ps'
   ]);
 });
 
@@ -160,6 +183,7 @@ if [ "$1" = "compose" ] && [ "$2" = "stop" ]; then
   exit 0
 fi
 if [ "$1" = "compose" ] && [ "$2" = "run" ]; then
+  cat >/dev/null
   printf '%s\\n' 'migrate' >> "$MAILHUB_DEPLOY_TEST_LOG"
   exit "\${MAILHUB_DEPLOY_TEST_MIGRATION_STATUS:-0}"
 fi
@@ -172,6 +196,7 @@ if [ "$1" = "compose" ] && [ "$2" = "up" ]; then
   exit 0
 fi
 if [ "$1" = "compose" ] && [ "$2" = "exec" ]; then
+  cat >/dev/null
   service=""
   for argument in "$@"; do
     if [ "$argument" = "app" ] || [ "$argument" = "dovecot" ]; then
@@ -185,6 +210,10 @@ if [ "$1" = "compose" ] && [ "$2" = "exec" ]; then
   fi
   exit 0
 fi
+if [ "$1" = "compose" ] && [ "$2" = "ps" ] && [ -z "\${3:-}" ]; then
+  printf '%s\\n' 'final:ps' >> "$MAILHUB_DEPLOY_TEST_LOG"
+  exit 0
+fi
 if [ "$1" = "start" ]; then
   printf 'restart:%s\\n' "$2" >> "$MAILHUB_DEPLOY_TEST_LOG"
   exit 0
@@ -200,6 +229,7 @@ fi
 `);
 
   writeExecutable(path.join(remoteScriptsDir, 'sync-tls-certificate.sh'), `#!/bin/sh
+cat >/dev/null
 printf 'sync:%s\\n' "\${MAILHUB_CERT_RESTART:-}" >> "$MAILHUB_DEPLOY_TEST_LOG"
 if [ "\${MAILHUB_CERT_RESTART:-0}" = "1" ]; then
   exit "\${MAILHUB_DEPLOY_TEST_SYNC_STATUS:-0}"
@@ -207,6 +237,7 @@ fi
 exit 0
 `);
   writeExecutable(path.join(remoteScriptsDir, 'prepare-dovecot.sh'), `#!/bin/sh
+cat >/dev/null
 exit 0
 `);