第2回: DRY-RUN を既定にする破壊的 API 呼び出しの設計 — MySQL DB を 3 連 POST で作る

TL;DR

  • 破壊的 API は 「DRY-RUN を既定 / --apply で初めて実行 / 直前に y/N プロンプト」の 3 段ガードを入れる。
  • DB 作成は POST /dbPOST /db/userPOST /db/user/<user>/grant3 連 POST。途中失敗時のロールバックは書かず早期 exit で止める。
  • ランダム PW を tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 16 で生成する常套句は、set -o pipefail 下では tr が SIGPIPE で 141 を返して落ちる。一時的に set +o pipefail
  • 生成した接続情報は deploy/.env.production.secretschmod 600 で書き出し、Laravel の .env.production に貼り付ける運用に倒す。

なぜ DRY-RUN を既定にするのか

--apply を打たないと何も起きない」設計にすると、3 つの利点がある:

  1. 誤実行が物理的に起きないbash xs-db-create.sh が常に副作用ゼロ。AI エージェントに任せても怖くない。
  2. レビュー素材になる — DRY-RUN の出力をそのままチャットや PR に貼って「これで OK?」と聞ける。
  3. --apply の重みが揃う — 全スクリプトで --apply を統一旗にしておくと、それ自体が「ここで本気を出すぞ」というマーカーになる。

DRY-RUN は「リクエストを送らず、送る内容だけ表示する」モード。echo で十分:

if [[ ${APPLY} -eq 0 ]]; then
  echo "[DRY-RUN] 以下のリクエストが送られます (実行はしません)。"
  echo
  echo "POST ${BASE}/db"
  echo "  body: {\"name_suffix\": \"${SUFFIX}\", \"character_set\": \"utf8mb4\", \"memo\": \"${MEMO}\"}"
  echo
  echo "実行する場合は --apply を付けて再度実行してください。"
  exit 0
fi

引数パーサ

bash の素朴な while + case で十分:

APPLY=0
SUFFIX="myapp"
MEMO="myproject (SaaS Phase 4)"
PASSWORD=""

while [[ $# -gt 0 ]]; do
  case "$1" in
    --apply)    APPLY=1; shift ;;
    --suffix)   SUFFIX="$2"; shift 2 ;;
    --memo)     MEMO="$2"; shift 2 ;;
    --password) PASSWORD="$2"; shift 2 ;;
    -h|--help)
      sed -n '/^# /{s/^# \?//;p;}' "$0" | head -30
      exit 0
      ;;
    *) echo "未知のオプション: $1" >&2; exit 2 ;;
  esac
done

-h|--help の中身がトリッキー。スクリプト先頭の # コメント から行頭の # と続く空白だけ sed で削って head -30 で出している。「ドキュメントをコード冒頭に書く」と「--help で出す」を二重管理しなくていい、という小ワザ。

DB 名を機械的に組み立てる

Xserver は契約ごとに DB 名 / ユーザ名に サーバ ID プレフィクス が強制される。myserver.xsrv.jp 契約なら DB 名は myserver_xxxx の形でしか作れない。

# myserver.xsrv.jp → myserver
SERVER_PREFIX="${XSERVER_TARGET_SERVER%%.*}"
DB_NAME="${SERVER_PREFIX}_${SUFFIX}"
DB_USER="${DB_NAME}"   # 同一名で揃える方が運用が楽

${VAR%%.*} は Bash パラメータ展開で「. 以降を最長マッチで削る」。cutsed を持ち出さなくていい。

DB 名と DB ユーザ名は揃える。1 DB に 1 ユーザの個人開発スケールでは、それで十分。

ランダムパスワード生成と SIGPIPE の罠

Xserver の MySQL ユーザは パスワード 16 文字上限 という地味な制約がある。32 字 UUID は弾かれる。素朴に /dev/urandom から 16 文字:

PASSWORD=$(LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 16)

これを set -euo pipefail 下で動かすと 落ちる。理由:

  • head -c 16 は 16 バイト読んだら閉じる
  • 直前の tr がまだ書き込もうとしているところに SIGPIPE が飛ぶ
  • tr の終了コードは 141 (= 128 + 13、SIGPIPE)
  • pipefail 下では「パイプ内のどれかが非ゼロなら全体も非ゼロ」なのでスクリプトが死ぬ

解決は その行だけ pipefail を切る:

if [[ -z "${PASSWORD}" ]]; then
  set +o pipefail
  PASSWORD=$(LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 16)
  set -o pipefail
fi

tr | head は Bash イディオムとして頻出するので、覚えておくと刺さりにくい。

--apply の確認プロンプト

--apply を渡しても、もう一段「本当に?」を挟む:

read -r -p "上記の内容で実際に本番サーバへ送信します。続けますか? [y/N] " ans
case "${ans:-N}" in
  [yY]|[yY][eE][sS]) ;;
  *) echo "中止しました。"; exit 0 ;;
esac
  • read -r でバックスラッシュ展開を抑制
  • ${ans:-N} で空入力時のデフォルトを N に。[y/N] 表記と整合
  • casey, Y, yes, YES 全部許す (“yes” を打ち込みたい人もいる)

3 連 POST の本体

DB 作成 → ユーザ作成 → 権限付与 を順に投げる:

# 1) DB
db_res=$(curl -sS -X POST -H "${AUTH}" -H 'Content-Type: application/json' \
  -d "{\"name_suffix\": \"${SUFFIX}\", \"character_set\": \"utf8mb4\", \"memo\": \"${MEMO}\"}" \
  "${BASE}/db")
[[ "${db_res}" == *'"error"'* ]] && { echo "ERROR: DB 作成に失敗"; exit 1; }

# 2) ユーザ
user_res=$(curl -sS -X POST -H "${AUTH}" -H 'Content-Type: application/json' \
  -d "{\"name_suffix\": \"${SUFFIX}\", \"password\": \"${PASSWORD}\", \"memo\": \"${MEMO}\"}" \
  "${BASE}/db/user")
# パスワードは絶対にログに出さない
echo "  response: $(echo "${user_res}" | sed 's/"password":[^,}]*/"password":"***"/g')"
[[ "${user_res}" == *'"error"'* ]] && { echo "ERROR: ユーザ作成に失敗"; exit 1; }

# 3) grant
grant_res=$(curl -sS -X POST -H "${AUTH}" -H 'Content-Type: application/json' \
  -d "{\"db_name\": \"${DB_NAME}\"}" \
  "${BASE}/db/user/${DB_USER}/grant")
[[ "${grant_res}" == *'"error"'* ]] && { echo "ERROR: 権限付与に失敗"; exit 1; }

ポイント:

  • エラー判定は素朴に *'"error"'* — Xserver API のエラー応答は {"error": {...}} を返すので、JSON にこの文字列が入っていれば失敗と判断できる。jq -e で完全な判定もできるが、個人開発スケールでは substring match で十分。
  • POST /db/user のレスポンスからパスワードを sed で消す — Xserver API の応答にはリクエストエコーとして password が含まれることがある。s/"password":[^,}]*/"password":"***"/g で必ず潰す。
  • ロールバックを書かない — 途中失敗時は手動で残骸を消す (Xserver API に DELETE もある)。個人開発スケールなら「2 回も失敗するなら何かが根本的におかしい」のでロールバックより根本対応を促す方が健全。

secrets ファイル書き出し

成功したら、生成 PW を含む接続情報を Laravel の .env にコピペできる形で書き出す:

SECRETS_FILE="${REPO_ROOT}/deploy/.env.production.secrets"
{
  echo "# 自動生成: $(date '+%Y-%m-%d %H:%M:%S') by xs-db-create.sh"
  echo "# このファイルは gitignored。api/.env.production の DB_* に丸ごと貼って使う。"
  echo ""
  echo "DB_CONNECTION=mysql"
  echo "DB_HOST=mysql${SERVER_PREFIX}.xserver.jp"
  echo "DB_PORT=3306"
  echo "DB_DATABASE=${DB_NAME}"
  echo "DB_USERNAME=${DB_USER}"
  echo "DB_PASSWORD=\"${PASSWORD}\""
} > "${SECRETS_FILE}"
chmod 600 "${SECRETS_FILE}"

chmod 600 は必須。.gitignore でリポジトリにも絶対に入れない。

{ echo; echo; } > FILE複数行を 1 ファイルにアトミックに書く Bash イディオム。echo ... >> FILE を連発するより、サブシェルでまとめて 1 回書き込む方が安全で速い。

「次の手順」を最後に出す設計

スクリプトの最終出力で 人間がやる次の作業を誘導する:

echo "  次の手順:"
echo "    1) bash deploy/scripts/xs-info.sh db  で作成を確認"
echo "    2) ${SECRETS_FILE} の中身を api/.env.production の DB_* に貼る"
echo "    3) ssh myserver 'mysql -h mysql${SERVER_PREFIX}.xserver.jp -u ${DB_USER} -p ${DB_NAME}'  で接続テスト"

これは AI エージェントとの分担にも効く。エージェントが --apply を叩いた後、ターミナル出力にこの「次の手順」が残っていれば、続きの作業を引き継ぎやすい。

まとめと次回予告

  • DRY-RUN を既定にする 3 段ガード設計
  • 3 連 POST を素朴な curl の連鎖で書く
  • ランダム PW の SIGPIPE 罠は pipefail の局所オフで回避
  • secrets はローカルファイル経由で .env に渡す

次回はこの設計を 「非同期な API」に適用する。POST /mail は事前にドメイン認証 TXT が立っていないと弾かれる仕様なので、GET /server-info から token を取って TXT を投入し、DNS 反映を待ってリトライする、というワンクッションが必要になる。

コメント

タイトルとURLをコピーしました