From adaff6125c64c8f83fadfe50362fa57f76b15c44 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 2 Oct 2026 15:20:44 +0900 Subject: [PATCH 01/17] =?UTF-8?q?fix(entrypoint):=20=E5=90=8C=E6=99=82?= =?UTF-8?q?=E8=B5=B7=E5=8B=95=E3=81=A7=E3=82=82=E3=83=AA=E3=83=B3=E3=82=AF?= =?UTF-8?q?=E3=81=AE=E6=AE=B5=E3=81=8C=E5=A4=B1=E6=95=97=E3=81=97=E3=81=AA?= =?UTF-8?q?=E3=81=84=E3=82=88=E3=81=86=E3=81=AB=E3=81=99=E3=82=8B=20(#357)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - リンクの段を共通のボリュームのルートへの flock で 1 つずつ順に走らせる (待ち時間の上限 10 秒、flock が無ければロックなしで進む。ロックはサブシェルの fd でだけ持つ) - 共有のリンクの張り替えを「消す → ln -sn → 読み直す」の試行にし、成否を読み直しで決める(上限 20 回) - 退避とプレースホルダは排他の作成(noclobber / perl の mkdir)で作れた 1 つのプロセスだけが書く - Docker を使わない同時実行のテストと、base イメージのコンテナ 8 個の同時起動のテストを足す Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 + containers/base/entrypoint.sh | 127 ++- .../containers/test_base_image_link_stage.py | 210 +++++ .../test_entrypoint_ai_settings_concurrent.py | 735 ++++++++++++++++++ 4 files changed, 1059 insertions(+), 18 deletions(-) create mode 100644 tests/containers/test_base_image_link_stage.py create mode 100644 tests/containers/test_entrypoint_ai_settings_concurrent.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d24a67be..e11d5a28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,11 @@ 差分が前回からの変更分だけになり、1 回の時間とディスクの使い方が大きく減ります(手元の測定で フル 2.2GB・40 秒に対し、差分 1.5MB・2 秒)。直した後の最初の `devbase up` では、既存の世代へ 積まずに新しい世代(フルバックアップ)を 1 回作ります。既存の世代はそのまま復元できます。 +- **同じアカウントグループのコンテナを同時に起動すると、1 つが `ln: Already exists` で落ちることがあったのを直しました**(#357)。 + ホストの再起動の後の一斉起動や、`scale` を 2 以上にしたプロジェクトの `devbase up` で起きていました。 + 共有の設定のリンクを張る処理を 1 つずつ順に走らせ、ほかのコンテナが先に張っていても成功として + 扱います。効くのは base イメージを建て直した後のコンテナからです(`devbase build base` の後、 + 派生イメージも建て直す)。 ## [4.0.0] - 2026-09-28 diff --git a/containers/base/entrypoint.sh b/containers/base/entrypoint.sh index 06ed9613..92ccd8bd 100644 --- a/containers/base/entrypoint.sh +++ b/containers/base/entrypoint.sh @@ -297,23 +297,63 @@ devbase_ensure_persistent_root() { fi } +# リンクの段のロックを待つ秒数の上限 (#357)。超えたら警告を出してロックなしで進む。 +# 止まったまま終わらない持ち主が 1 つあるだけで、ほかのすべてのコンテナの起動が +# 止まるのを避ける。ロックなしでも、張り替えの試行と排他の作成で結果は壊れない。 +DEVBASE_LINK_LOCK_WAIT=10 + +# 張り替えの試行の上限 (#357)。修正前のイメージのコンテナは 1 つにつき 1 回だけ +# 正しいリンクを消すため、同時起動 8 個なら試行は 9 回までに収まる。その 2 倍の余裕 +DEVBASE_LINK_TRIES=20 + +# エントリを**無いときだけ**作る (排他の作成、#357)。 +# +# 第 1 引数は file か dir。終了コードは 作れた 0 / 既にあった 1 / 作れない 2。 +# 同時に呼んでも 0 を返すのは 1 つのプロセスだけで、そのプロセスだけが中身を書く。 +# 通常のファイルは noclobber の `>` で作る。ディレクトリは perl から mkdir(2) を呼ぶ。 +# base イメージの mkdir (uutils) は同時に呼ぶと 2 つ以上が 0 で終わり、作れた +# プロセスを 1 つに決められないため使わない。perl が無い環境では mkdir で作る。 +devbase_create_entry() { + local kind="$1" path="$2" err + + if [ "$kind" = "file" ]; then + err=$( { set -C; : > "$path"; } 2>&1 ) && return 0 + elif command -v perl >/dev/null 2>&1; then + err=$(perl -e 'mkdir($ARGV[0]) or die "mkdir: $!\n"' "$path" 2>&1) && return 0 + else + err=$(mkdir "$path" 2>&1) && return 0 + fi + # 作る操作の終了コードではなく、読み直して決める + if [ -e "$path" ] || [ -L "$path" ]; then + return 1 + fi + echo "ERROR: エントリを作れない: ${path} (${err})" >&2 + return 2 +} + # 実体が無ければプレースホルダを作る (親ディレクトリごと)。 devbase_ensure_entry() { - local path="$1" + local path="$1" rc=0 mkdir -p "$(dirname "$path")" if [ -e "$path" ]; then return 0 fi if devbase_is_file_entry "$path"; then - if devbase_is_json_file_entry "$path"; then + devbase_create_entry file "$path" || rc=$? + # 作れたプロセスだけが中身を書く。ほかのプロセスが作ったものは触らない + if [ "$rc" -eq 0 ] && devbase_is_json_file_entry "$path"; then printf '{}' > "$path" - else - : > "$path" fi else - mkdir -p "$path" + devbase_create_entry dir "$path" || rc=$? fi + [ "$rc" -ne 2 ] +} + +# が を指す symlink か判定する (何も出力しない)。 +devbase_link_is_set() { + [ -L "$1" ] && [ "$(readlink "$1")" = "$2" ] } # を への symlink にする。 @@ -324,33 +364,52 @@ devbase_ensure_entry() { # # 既存の実体は `rm -rf` してから張り直す。symlink に対する `rm -rf` は # **リンクだけ**を消すので、共通側の実体は巻き添えにならない。 +# +# 同じグループのコンテナは同じ共有のリンクを同時に張り直す (#357)。成否は `ln` と +# `rm` の終了コードではなく、張った後に読み直した結果で決め、正しくなるまで +# DEVBASE_LINK_TRIES 回まで繰り返す。`ln` に -n を付けるのは、相手が先に張った +# ディレクトリへのリンクの中へ、入れ子のリンクを作らないためである。 devbase_link_setting() { local link_path="$1" target_path="$2" owner="${3:-${USERNAME:-ubuntu}}" + local try=1 err="" devbase_ensure_entry "$target_path" - if [ -L "$link_path" ] && [ "$(readlink "$link_path")" = "$target_path" ]; then + if devbase_link_is_set "$link_path" "$target_path"; then echo " ✓ ${link_path} (symlink exists)" return 0 fi - mkdir -p "$(dirname "$link_path")" - if [ -e "$link_path" ] || [ -L "$link_path" ]; then - echo " Removing existing ${link_path}..." - rm -rf "$link_path" - fi - - echo " Creating symlink: ${link_path} -> ${target_path}" - ln -s "$target_path" "$link_path" - chown -h "${owner}:${owner}" "$link_path" 2>/dev/null || true + mkdir -p "$(dirname "$link_path")" 2>/dev/null || true + while :; do + if [ -e "$link_path" ] || [ -L "$link_path" ]; then + [ "$try" -eq 1 ] && echo " Removing existing ${link_path}..." + err=$(rm -rf "$link_path" 2>&1) || true + fi + [ "$try" -eq 1 ] && echo " Creating symlink: ${link_path} -> ${target_path}" + err=$(ln -sn "$target_path" "$link_path" 2>&1) || true + if devbase_link_is_set "$link_path" "$target_path"; then + chown -h "${owner}:${owner}" "$link_path" 2>/dev/null || true + return 0 + fi + if [ "$try" -ge "$DEVBASE_LINK_TRIES" ]; then + echo "ERROR: symlink を張れない: ${link_path} -> ${target_path} (${err})" >&2 + return 1 + fi + try=$((try + 1)) + done } # シード元から 1 エントリを**コピー**する (既にあれば何もしない)。 # # 第 3 引数以降は「コピーしない直下の名前」。分類 A の共通資産をグループ側へ # 複製しないために使う。 +# +# コピー先は排他の作成で作り、作れたプロセスだけが中身をコピーする (#357)。 +# 同時に走る 2 つが同じディレクトリへ重ねてコピーすると、子のディレクトリの中に +# 同じ名前のディレクトリがもう 1 つ残る。 devbase_seed_entry() { - local src="$1" dest="$2" + local src="$1" dest="$2" rc=0 shift 2 if [ -e "$dest" ]; then @@ -363,12 +422,23 @@ devbase_seed_entry() { mkdir -p "$(dirname "$dest")" if [ ! -d "$src" ]; then + devbase_create_entry file "$dest" || rc=$? + case "$rc" in + 0) ;; + 1) return 0 ;; + *) return 1 ;; + esac cp -a "$src" "$dest" echo " seeded: $dest" return 0 fi - mkdir -p "$dest" + devbase_create_entry dir "$dest" || rc=$? + case "$rc" in + 0) ;; + 1) return 0 ;; + *) return 1 ;; + esac local child name excluded skip # `.[!.]*` と `..?*` で隠しファイルも拾う (`.credentials.json` 等)。 for child in "$src"/* "$src"/.[!.]* "$src"/..?*; do @@ -412,14 +482,35 @@ devbase_seed_image_claude_settings() { # 第 4 引数はアカウントグループ名 (呼び出しの形を保つために受け取るが使わない)。 # グループ側は /persistent/ai から取り込まない (#315 I13)。取り込み元は PLAN39 より前の # 置き場で、グループの実名化と合わせて初回シードを無くした。 +# +# 同じボリュームを共有するコンテナは同時に起動する (#357)。共通のボリュームの +# ルートのディレクトリそのものに flock を掛け、リンクの段どうしを 1 つずつ順に +# 走らせる。ロックのためのファイルは作らない。ロックはサブシェルの fd でだけ持ち、 +# サブシェルを抜ければ (成功でも失敗でも) 外れ、最後に exec するコマンドへ +# 引き継がない。flock が無い・待ち切れないときはロックなしで進む。 devbase_setup_ai_settings() { local home_root="$1" ai_root="$2" group_root="$3" local owner="${5:-${USERNAME:-ubuntu}}" - local entry devbase_ensure_persistent_root "$ai_root" "$owner" devbase_ensure_persistent_root "$group_root" "$owner" + ( + if command -v flock >/dev/null 2>&1; then + exec 9<"$ai_root" + if ! flock -w "$DEVBASE_LINK_LOCK_WAIT" 9; then + echo "WARNING: リンクの段のロックを ${DEVBASE_LINK_LOCK_WAIT} 秒待っても取れないため、ロックなしで進める: ${ai_root}" >&2 + fi + fi + devbase_apply_ai_settings "$home_root" "$ai_root" "$group_root" "$owner" + ) +} + +# リンクの段の本体。退避・ホームのリンク・共有のリンクをこの順で行う。 +devbase_apply_ai_settings() { + local home_root="$1" ai_root="$2" group_root="$3" owner="$4" + local entry + # symlink を張る**前**にシードする。張ったあとに走らせると、共通側を指す # symlink の中身へコピーしてしまう。 devbase_seed_image_claude_settings "$home_root" "$ai_root" diff --git a/tests/containers/test_base_image_link_stage.py b/tests/containers/test_base_image_link_stage.py new file mode 100644 index 00000000..4ba34b0f --- /dev/null +++ b/tests/containers/test_base_image_link_stage.py @@ -0,0 +1,210 @@ +"""base イメージのコンテナでの同時起動 (#357 受け入れ条件 13) + +``devbase-base:latest`` のコンテナに作業ツリーの ``entrypoint.sh`` を読み取り専用でマウントし、 +使い捨ての名前のボリュームで 8 個を同時に起動する。イメージは建て直さず、利用者のボリュームにも +``devbase up`` にも触れない (設計の決定 9)。使ったボリュームとコンテナは最後に消す。 + +base イメージの ``ln`` と ``mkdir`` は uutils coreutils で、CI の runner (GNU coreutils) とは振る舞いが +違う。排他の作成 (I9) をイメージの道具で確かめるのもここで行う。 + +**Docker が無い / イメージが無いときは skip する。** CI では skip になり、完了判定のときに開発者の +端末で走る。 +""" + +from __future__ import annotations + +import shutil +import subprocess +import uuid +from pathlib import Path + +import pytest + +from tests.conftest import host_docker_env + +IMAGE = "devbase-base:latest" +ENTRYPOINT = Path(__file__).resolve().parents[2] / "containers" / "base" / "entrypoint.sh" +ROUNDS = 10 +CONTAINERS = 8 + +# 全ケースを同じワーカーへ寄せ、8 個のコンテナの起動をほかのケースと重ねない +pytestmark = pytest.mark.xdist_group("base_image_link_stage") + + +def _docker(*args: str, timeout: float = 120, check: bool = True) -> subprocess.CompletedProcess: + return subprocess.run(["docker", *args], capture_output=True, text=True, timeout=timeout, + check=check, env=host_docker_env()) + + +def _docker_unavailable() -> str | None: + if shutil.which("docker") is None: + return "docker が PATH に無い" + try: + _docker("info", timeout=30) + except (subprocess.SubprocessError, OSError): + return "docker daemon へ繋がらない" + try: + out = _docker("image", "inspect", IMAGE, timeout=30, check=False) + except (subprocess.SubprocessError, OSError): + return f"{IMAGE} を調べられない" + if out.returncode != 0: + return f"{IMAGE} が無い" + return None + + +@pytest.fixture +def docker_ready(): + reason = _docker_unavailable() + if reason: + pytest.skip(reason) + + +@pytest.fixture +def volumes(docker_ready): + """使い捨ての名前の共通のボリューム・グループのボリューム・合図のボリューム""" + tag = uuid.uuid4().hex[:8] + names = {k: f"devbase-test357-{k}-{tag}" for k in ("ai", "grp", "go")} + for name in names.values(): + _docker("volume", "create", name) + containers: list[str] = [] + try: + yield names, containers + finally: + if containers: + _docker("rm", "-f", *containers, check=False) + _docker("volume", "rm", "-f", *names.values(), check=False) + + +def _mounts(names: dict[str, str]) -> list[str]: + return ["-v", f"{names['ai']}:/persistent/ai", "-v", f"{names['grp']}:/persistent/group", + "-v", f"{names['go']}:/go", "-v", f"{ENTRYPOINT}:/entrypoint.sh:ro"] + + +def _run(names: dict[str, str], script: str, *, user: str | None = None): + args = ["run", "--rm", *_mounts(names), "--entrypoint", "bash"] + if user: + args += ["--user", user] + return _docker(*args, IMAGE, "-c", script, timeout=300, check=False) + + +def test_eight_containers_relink_a_settings_file(volumes): + """受け入れ条件 13: 8 個とも entrypoint が 0 で終わる""" + names, containers = volumes + # 1 個だけ起動してリンクを張った状態を作る + r = _run(names, "DEVBASE_ACCOUNT_GROUP=t357 exec bash /entrypoint.sh true") + assert r.returncode == 0, r.stdout + r.stderr + for rnd in range(ROUNDS): + # 受け入れ条件 1 の状態 (settings.json だけ通常のファイル) にし、合図を消す + r = _run(names, "rm -f /go/go /persistent/group/.claude/settings.json && " + "echo '{}' > /persistent/group/.claude/settings.json && " + "chmod 777 /go", user="root") + assert r.returncode == 0, r.stderr + ids = [] + for i in range(CONTAINERS): + r = _docker("run", "-d", *_mounts(names), "-e", "DEVBASE_ACCOUNT_GROUP=t357", + "--entrypoint", "bash", IMAGE, "-c", + "until [ -e /go/go ]; do sleep 0.01; done; " + "exec bash /entrypoint.sh true") + ids.append(r.stdout.strip()) + containers.extend(ids) + r = _run(names, "touch /go/go", user="root") + assert r.returncode == 0, r.stderr + codes = [int(_docker("wait", cid, timeout=300).stdout.strip()) for cid in ids] + for cid, code in zip(ids, codes): + if code != 0: + logs = _docker("logs", cid, check=False) + pytest.fail(f"round {rnd}: {cid[:12]} が {code} で終わった\n" + f"{logs.stdout}{logs.stderr}") + r = _run(names, "readlink /persistent/group/.claude/settings.json") + assert r.stdout.strip() == "/persistent/ai/.claude/settings.json" + + +_CREATE_PROBE = r""" +set -u +DEVBASE_ENTRYPOINT_LIB_ONLY=1 . /entrypoint.sh +set +e +for kind in file dir; do + for r in 1 2 3 4 5; do + d=$(mktemp -d) + for i in 1 2 3 4 5 6 7 8; do + ( while [ ! -e "$d/go" ]; do :; done + devbase_create_entry "$kind" "$d/entry"; echo "$?" > "$d/rc$i" ) & + done + sleep 0.2; : > "$d/go"; wait + printf '%s\t%s\n' "$kind" "$(cat "$d"/rc* | sort | tr -d '\n')" + done +done +""" + +_SEED_PROBE = r""" +set -u +DEVBASE_ENTRYPOINT_LIB_ONLY=1 . /entrypoint.sh +set +e +# flock を除いた PATH (ロックなしで進むリンクの段) +nf=$(mktemp -d) +IFS=: read -r -a dirs <<< "$PATH" +for d in "${dirs[@]}"; do + for f in "$d"/*; do + n=${f##*/} + [ "$n" = flock ] && continue + [ -e "$nf/$n" ] || ln -s "$f" "$nf/$n" + done +done +PATH=$nf + +make_home() { + mkdir -p "$1/.claude/skills/s1" "$1/.claude/commands" + for k in 00 01 02 03 04 05 06 07 08 09 10 11; do + mkdir -p "$1/.claude/plugins/p$k"; echo "{\"n\": \"$k\"}" > "$1/.claude/plugins/p$k/plugin.json" + done + echo skill > "$1/.claude/skills/s1/SKILL.md"; echo cmd > "$1/.claude/commands/c1.md" + echo memo > "$1/.claude/CLAUDE.md"; echo '{"hooks": 1}' > "$1/.claude/settings.json" +} +snap() { + ( cd "$1" && find . | LC_ALL=C sort | while IFS= read -r p; do + if [ -L "$p" ]; then echo "L $p $(readlink "$p" | sed "s#^$2#ROOT#")" + elif [ -d "$p" ]; then echo "D $p" + else echo "F $p $(md5sum < "$p")"; fi + done ) +} +one=$(mktemp -d); make_home "$one/h0" +( set -e; devbase_setup_ai_settings "$one/h0" "$one/ai" "$one/grp" t357 ) >/dev/null 2>&1 || echo "single failed" +for r in 1 2 3 4 5; do + b=$(mktemp -d) + for i in 1 2 3 4 5 6 7 8; do + make_home "$b/h$i" + ( while [ ! -e "$b/go" ]; do :; done + ( set -e; devbase_setup_ai_settings "$b/h$i" "$b/ai" "$b/grp" t357 ) >/dev/null 2>&1 + echo "$?" > "$b/rc$i" ) & + done + sleep 0.2; : > "$b/go"; wait + rcs=$(cat "$b"/rc* | tr -d '\n') + same=same + [ "$(snap "$b/ai" "$b/ai")" = "$(snap "$one/ai" "$one/ai")" ] || same=diff-ai + [ "$(snap "$b/grp" "$b/ai")" = "$(snap "$one/grp" "$one/ai")" ] || same=diff-grp + printf 'seed\t%s\t%s\n' "$rcs" "$same" +done +""" + + +def test_only_one_process_creates_an_entry_with_the_image_tools(docker_ready): + """I9 (base イメージの道具): 8 個同時でも 0 を返すのは 1 個だけ""" + r = _docker("run", "--rm", "-v", f"{ENTRYPOINT}:/entrypoint.sh:ro", "--entrypoint", "bash", + IMAGE, "-c", _CREATE_PROBE, timeout=300, check=False) + assert r.returncode == 0, r.stderr + lines = [line.split("\t") for line in r.stdout.splitlines() if "\t" in line] + assert len(lines) == 10, r.stdout + r.stderr + for kind, rcs in lines: + assert rcs == "01111111", f"{kind}: {rcs}" + + +def test_eight_stages_without_flock_match_a_single_run_with_the_image_tools(docker_ready): + """I9 (base イメージの道具): ロックなしで 8 個同時でも、1 個だけ走らせた結果と同じ""" + r = _docker("run", "--rm", "-v", f"{ENTRYPOINT}:/entrypoint.sh:ro", "--entrypoint", "bash", + IMAGE, "-c", _SEED_PROBE, timeout=300, check=False) + assert r.returncode == 0, r.stderr + assert "single failed" not in r.stdout + lines = [line.split("\t") for line in r.stdout.splitlines() if line.startswith("seed\t")] + assert len(lines) == 5, r.stdout + r.stderr + for _, rcs, same in lines: + assert rcs == "00000000" and same == "same", (rcs, same) diff --git a/tests/containers/test_entrypoint_ai_settings_concurrent.py b/tests/containers/test_entrypoint_ai_settings_concurrent.py new file mode 100644 index 00000000..89dacf44 --- /dev/null +++ b/tests/containers/test_entrypoint_ai_settings_concurrent.py @@ -0,0 +1,735 @@ +"""同時起動でもリンクの段が失敗しない (#357) + +``containers/base/entrypoint.sh`` を ``DEVBASE_ENTRYPOINT_LIB_ONLY=1`` で source し、別々のホームと +同じ 2 つのボリューム (一時ディレクトリ) を渡したリンクの段を、同時に始める。Docker には依存しない。 + +**1 回の同時実行では、修正前の手順でも通ることがある。** そのため 1 件の中で同時実行を繰り返し、 +1 回でも条件が破れたら落とす。繰り返す回数は、修正前の ``entrypoint.sh`` に当てて 3 回続けて落ちる +ことを確かめて決めた。 + +``flock`` が無い環境 (macOS) では、リンクの段はロックなしで進む。ロックそのものを確かめるケースだけ +skip し、同時実行のケースは走らせる (設計の決定 7)。 +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import time +from pathlib import Path + +import pytest + +ENTRYPOINT = Path(__file__).resolve().parents[2] / "containers" / "base" / "entrypoint.sh" + +SHARED_CLAUDE = ["plugins", "skills", "commands", "CLAUDE.md", "settings.json"] + +# 同時実行を繰り返す回数。2 個のときは重なりにくいため多めにする +ROUNDS_PAIR = 15 +ROUNDS = 8 + +# ほかのプロセスとの重なりで 1 回だけ遅れても落ちないよう、要求の 30 秒をそのまま使う +TIME_LIMIT = 30.0 + +NEEDS_FLOCK = pytest.mark.skipif( + shutil.which("flock") is None, + reason="flock が無い (macOS)。ロックそのものは Linux の CI で確かめる", +) +NOT_ROOT = pytest.mark.skipif( + hasattr(os, "geteuid") and os.geteuid() == 0, + reason="root では書き込めない状態を作れない", +) + +# 修正前の手順。#357 より前の entrypoint.sh の 4 つの関数をそのまま写した。 +# 受け入れ条件 7 の相手と、移行性 (修正前の手順へ戻す) と、所要の比べる相手に使う +OLD_FUNCS = r''' +devbase_ensure_entry() { + local path="$1" + + mkdir -p "$(dirname "$path")" + if [ -e "$path" ]; then + return 0 + fi + if devbase_is_file_entry "$path"; then + if devbase_is_json_file_entry "$path"; then + printf '{}' > "$path" + else + : > "$path" + fi + else + mkdir -p "$path" + fi +} + +devbase_link_setting() { + local link_path="$1" target_path="$2" owner="${3:-${USERNAME:-ubuntu}}" + + devbase_ensure_entry "$target_path" + + if [ -L "$link_path" ] && [ "$(readlink "$link_path")" = "$target_path" ]; then + echo " ✓ ${link_path} (symlink exists)" + return 0 + fi + + mkdir -p "$(dirname "$link_path")" + if [ -e "$link_path" ] || [ -L "$link_path" ]; then + echo " Removing existing ${link_path}..." + rm -rf "$link_path" + fi + + echo " Creating symlink: ${link_path} -> ${target_path}" + ln -s "$target_path" "$link_path" + chown -h "${owner}:${owner}" "$link_path" 2>/dev/null || true +} + +devbase_seed_entry() { + local src="$1" dest="$2" + shift 2 + + if [ -e "$dest" ]; then + return 0 + fi + if [ ! -e "$src" ]; then + echo " skip (シード元なし): $src" + return 0 + fi + + mkdir -p "$(dirname "$dest")" + if [ ! -d "$src" ]; then + cp -a "$src" "$dest" + echo " seeded: $dest" + return 0 + fi + + mkdir -p "$dest" + local child name excluded skip + for child in "$src"/* "$src"/.[!.]* "$src"/..?*; do + [ -e "$child" ] || [ -L "$child" ] || continue + name="${child##*/}" + skip=0 + for excluded in "$@"; do + if [ "$name" = "$excluded" ]; then + skip=1 + break + fi + done + [ "$skip" = "1" ] && continue + cp -a "$child" "$dest/$name" + done + echo " seeded: $dest" +} + +devbase_setup_ai_settings() { + local home_root="$1" ai_root="$2" group_root="$3" + local owner="${5:-${USERNAME:-ubuntu}}" + local entry + + devbase_ensure_persistent_root "$ai_root" "$owner" + devbase_ensure_persistent_root "$group_root" "$owner" + + devbase_seed_image_claude_settings "$home_root" "$ai_root" + for entry in "${DEVBASE_GROUP_HOME_SEED_SETTINGS[@]}"; do + [ -L "$home_root/$entry" ] && continue + devbase_seed_entry "$home_root/$entry" "$group_root/$entry" + done + + for entry in "${DEVBASE_SHARED_SETTINGS[@]}"; do + devbase_link_setting "$home_root/$entry" "$ai_root/$entry" "$owner" + done + for entry in "${DEVBASE_GROUP_SETTINGS[@]}"; do + devbase_link_setting "$home_root/$entry" "$group_root/$entry" "$owner" + done + for entry in "${DEVBASE_SHARED_CLAUDE_SETTINGS[@]}"; do + devbase_link_setting "$group_root/.claude/$entry" \ + "$ai_root/.claude/$entry" "$owner" + done +} +''' + + +# --------------------------------------------------------------------------- +# 走らせる道具 +# --------------------------------------------------------------------------- + +def _base_env(path: str | None = None) -> dict: + env = {k: v for k, v in os.environ.items() if not k.startswith(("DEVBASE_", "GIT_"))} + if path is not None: + env["PATH"] = path + return env + + +def _bash() -> str: + return shutil.which("bash") or "bash" + + +def run_script(script: str, cwd: Path, *, path: str | None = None, old: bool = False, + pre: str = "", timeout: float = 120): + """関数を読み込み ``script`` を ``set -e`` で走らせる。``old`` なら修正前の手順に差し替える""" + full = (f'set -e\nDEVBASE_ENTRYPOINT_LIB_ONLY=1 . "{ENTRYPOINT}"\n' + f'{OLD_FUNCS if old else ""}\n{pre}\n{script}\n') + return subprocess.run([_bash(), "-c", full], cwd=cwd, env=_base_env(path), + capture_output=True, text=True, timeout=timeout, check=False) + + +def setup_cmd(home: Path, ai: Path, grp: Path) -> str: + return f'devbase_setup_ai_settings "{home}" "{ai}" "{grp}" acme' + + +class Job: + def __init__(self, rc: int, out: str, err: str): + self.rc, self.out, self.err = rc, out, err + + @property + def text(self) -> str: + return self.out + self.err + + +def run_concurrent(work: Path, jobs: list[str], *, path: str | None = None, + pre: str = "") -> tuple[list[Job], float]: + """``jobs`` (bash の断片) を同時に始め、終了コードと出力と全体の所要を返す。 + + 断片はそれぞれ ``set -e`` のサブシェルで走る。``old_funcs`` を読めば修正前の手順になる。 + 合図のファイルができるまで全員が待ち、同じ瞬間に走り出す。 + """ + work.mkdir(parents=True, exist_ok=True) + old = work / "old_funcs.sh" + old.write_text(OLD_FUNCS) + go = work / "go" + # entrypoint.sh は読み込んだシェルに set -e を掛ける。失敗した断片の終了コードを残すため外す + lines = [f'DEVBASE_ENTRYPOINT_LIB_ONLY=1 . "{ENTRYPOINT}"', "set +e", pre, + f'old_funcs() {{ . "{old}"; }}'] + for i, body in enumerate(jobs): + lines.append(f"job_{i}() {{\n{body}\n}}") + for i in range(len(jobs)): + lines.append( + f'{{ while [ ! -e "{go}" ]; do :; done; ' + f'( set -e; job_{i} ) >"{work}/{i}.out" 2>"{work}/{i}.err"; ' + f'echo $? >"{work}/{i}.rc"; }} &' + ) + lines.append("sleep 0.3") + lines.append(f': > "{go}"') + lines.append("wait") + script = "\n".join(lines) + "\n" + started = time.monotonic() + subprocess.run([_bash(), "-c", script], cwd=work, env=_base_env(path), + capture_output=True, text=True, timeout=120, check=False) + elapsed = time.monotonic() - started - 0.3 + results = [] + for i in range(len(jobs)): + results.append(Job(int((work / f"{i}.rc").read_text().strip()), + (work / f"{i}.out").read_text(), + (work / f"{i}.err").read_text())) + return results, elapsed + + +def path_without(tmp: Path, *names: str) -> str: + """PATH の実行ファイルを ``names`` を除いて 1 つのディレクトリへ集めた PATH を返す""" + d = tmp / ("bin-without-" + "-".join(names)) + if d.exists(): + return str(d) + d.mkdir(parents=True) + seen: set[str] = set() + for dirp in os.environ.get("PATH", "").split(os.pathsep): + try: + entries = os.listdir(dirp) + except OSError: + continue + for e in entries: + if e in seen or e in names: + continue + full = os.path.join(dirp, e) + if os.path.isfile(full) and os.access(full, os.X_OK): + seen.add(e) + os.symlink(full, d / e) + return str(d) + + +def snapshot(root: Path, subs: dict[str, str]) -> dict[str, str]: + """ボリュームの並び・リンクの先・ファイルの中身。リンクの先のルートは ``subs`` で置き換える""" + out: dict[str, str] = {} + for dirpath, dirnames, filenames in os.walk(root): + for name in dirnames + filenames: + p = os.path.join(dirpath, name) + rel = os.path.relpath(p, root) + if os.path.islink(p): + target = os.readlink(p) + for k, v in subs.items(): + target = target.replace(k, v) + out[rel] = "L " + target + elif os.path.isdir(p): + out[rel] = "D" + else: + out[rel] = "F " + Path(p).read_text(errors="replace") + return out + + +class Volumes: + """1 回ぶんの共通のボリューム・グループのボリューム・ホーム""" + + def __init__(self, base: Path): + self.base = base + self.ai = base / "persistent" / "ai" + self.grp = base / "persistent" / "group" + + def home(self, i: int | str) -> Path: + h = self.base / f"home-{i}" + h.mkdir(parents=True, exist_ok=True) + return h + + def link_once(self) -> None: + """使い捨てのホームで 1 回走らせ、すべてのリンクを正しい状態にする""" + r = run_script(setup_cmd(self.home("prep"), self.ai, self.grp), self.base) + assert r.returncode == 0, r.stderr + + def shared(self, name: str) -> Path: + return self.grp / ".claude" / name + + def stage_jobs(self, n: int, prefix: str = "") -> list[str]: + return [prefix + setup_cmd(self.home(i), self.ai, self.grp) for i in range(n)] + + def snap(self) -> tuple[dict, dict]: + subs = {str(self.ai): "", str(self.grp): ""} + return snapshot(self.ai, subs), snapshot(self.grp, subs) + + +def assert_all_ok(jobs: list[Job]) -> None: + for i, j in enumerate(jobs): + assert j.rc == 0, f"job {i} rc={j.rc}\n{j.text}" + + +def assert_no_ln_lines(jobs: list[Job]) -> None: + """受け入れ条件 10・I4: 0 で終わったリンクの段に `ln:` で始まる行が無い""" + for i, j in enumerate(jobs): + if j.rc != 0: + continue + bad = [line for line in j.text.splitlines() if line.startswith("ln:")] + assert not bad, f"job {i}: {bad}" + + +def assert_no_leftovers(v: Volumes) -> None: + """受け入れ条件 11・I8: 一時的な名前のエントリもロックのためのエントリも無い""" + assert sorted(os.listdir(v.grp / ".claude")) == sorted(SHARED_CLAUDE) + assert set(os.listdir(v.ai)) <= {".codex", ".serena", ".ssh", ".kiro", "share", ".claude"} + assert set(os.listdir(v.grp)) <= {".claude.json", ".claude", ".gemini", ".local", + ".shellrc.d"} + + +def assert_shared_links(v: Volumes) -> None: + for name in SHARED_CLAUDE: + p = v.shared(name) + assert p.is_symlink(), f"{name} が symlink でない" + assert os.readlink(p) == str(v.ai / ".claude" / name) + + +def assert_no_nested_links(v: Volumes) -> None: + """I2: 共通のボリュームのディレクトリの中に、自分自身の名前のリンクが無い""" + for name in ("plugins", "skills", "commands"): + assert not (v.ai / ".claude" / name / name).is_symlink(), f"{name}/{name} がある" + + +def check_round(v: Volumes, jobs: list[Job], elapsed: float) -> None: + assert_all_ok(jobs) + assert_no_ln_lines(jobs) + assert_shared_links(v) + assert_no_nested_links(v) + assert_no_leftovers(v) + assert elapsed < TIME_LIMIT, f"{elapsed:.1f} 秒かかった" + + +def make_home_claude(home: Path, *, plugins: int = 0) -> None: + """どのホームにも同じ中身の ~/.claude を置く""" + c = home / ".claude" + c.mkdir(parents=True, exist_ok=True) + (c / "settings.json").write_text('{"hooks": {"image": true}}') + if plugins: + for k in range(plugins): + d = c / "plugins" / f"p{k:02d}" + d.mkdir(parents=True, exist_ok=True) + (d / "plugin.json").write_text(f'{{"n": {k}}}') + (c / "skills" / "s1").mkdir(parents=True, exist_ok=True) + (c / "skills" / "s1" / "SKILL.md").write_text("skill") + (c / "commands").mkdir(exist_ok=True) + (c / "commands" / "c1.md").write_text("cmd") + (c / "CLAUDE.md").write_text("# memo") + + +# --------------------------------------------------------------------------- +# 受け入れ条件 1・2: settings.json だけが通常のファイル +# --------------------------------------------------------------------------- + +def _settings_as_file(v: Volumes) -> None: + v.link_once() + (v.ai / ".claude" / "settings.json").write_text('{"keep": true}') + p = v.shared("settings.json") + p.unlink() + p.write_text('{"replaced": true}') + + +def test_two_stages_relink_a_settings_file(tmp_path): + """受け入れ条件 1""" + for r in range(ROUNDS_PAIR): + v = Volumes(tmp_path / f"r{r}") + _settings_as_file(v) + jobs, elapsed = run_concurrent(v.base / "run", v.stage_jobs(2)) + check_round(v, jobs, elapsed) + + +def test_eight_stages_relink_a_settings_file_and_keep_the_shared_content(tmp_path): + """受け入れ条件 2""" + for r in range(ROUNDS): + v = Volumes(tmp_path / f"r{r}") + _settings_as_file(v) + jobs, elapsed = run_concurrent(v.base / "run", v.stage_jobs(8)) + check_round(v, jobs, elapsed) + assert (v.ai / ".claude" / "settings.json").read_text() == '{"keep": true}' + + +# --------------------------------------------------------------------------- +# 受け入れ条件 3: 空のボリュームから 8 個。1 個だけ走らせた結果と同じ +# --------------------------------------------------------------------------- + +def _single_run_snapshot(tmp: Path, *, plugins: int = 0, path: str | None = None): + v = Volumes(tmp) + make_home_claude(v.home(0), plugins=plugins) + r = run_script(setup_cmd(v.home(0), v.ai, v.grp), v.base, path=path) + assert r.returncode == 0, r.stderr + return v.snap() + + +def test_eight_stages_on_empty_volumes_match_a_single_run(tmp_path): + """受け入れ条件 3""" + expected = _single_run_snapshot(tmp_path / "single") + for r in range(ROUNDS): + v = Volumes(tmp_path / f"r{r}") + for i in range(8): + make_home_claude(v.home(i)) + jobs, elapsed = run_concurrent(v.base / "run", v.stage_jobs(8)) + check_round(v, jobs, elapsed) + assert v.snap() == expected + + +# --------------------------------------------------------------------------- +# 受け入れ条件 4〜6 +# --------------------------------------------------------------------------- + +def test_eight_stages_on_a_new_group_volume(tmp_path): + """受け入れ条件 4: 共通のボリュームに中身があり、グループのボリュームが空""" + for r in range(ROUNDS): + v = Volumes(tmp_path / f"r{r}") + v.link_once() + shutil.rmtree(v.grp) + jobs, elapsed = run_concurrent(v.base / "run", v.stage_jobs(8)) + check_round(v, jobs, elapsed) + + +def test_eight_stages_replace_a_real_plugins_directory(tmp_path): + """受け入れ条件 5""" + for r in range(ROUNDS): + v = Volumes(tmp_path / f"r{r}") + v.link_once() + p = v.shared("plugins") + p.unlink() + (p / "local").mkdir(parents=True) + (p / "local" / "x.json").write_text("{}") + jobs, elapsed = run_concurrent(v.base / "run", v.stage_jobs(8)) + check_round(v, jobs, elapsed) + + +@pytest.mark.parametrize("kind", ["other-target", "broken"]) +@pytest.mark.parametrize("name", ["plugins", "settings.json"]) +def test_eight_stages_fix_a_wrong_link(tmp_path, kind, name): + """受け入れ条件 6""" + for r in range(ROUNDS): + v = Volumes(tmp_path / f"r{r}") + v.link_once() + p = v.shared(name) + p.unlink() + if kind == "other-target": + other = v.base / "other" / name + other.parent.mkdir(parents=True) + other.mkdir() if name == "plugins" else other.write_text("{}") + p.symlink_to(other) + else: + p.symlink_to(v.base / "nowhere" / name) + jobs, elapsed = run_concurrent(v.base / "run", v.stage_jobs(8)) + check_round(v, jobs, elapsed) + + +# --------------------------------------------------------------------------- +# 受け入れ条件 7: 修正前の手順で張る相手と並ぶ +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("name", ["settings.json", "plugins"]) +def test_a_stage_survives_an_old_partner(tmp_path, name): + """受け入れ条件 7。相手が非 0 で終わることは許す""" + for r in range(ROUNDS_PAIR): + v = Volumes(tmp_path / f"r{r}") + v.link_once() + p = v.shared(name) + p.unlink() + if name == "plugins": + p.mkdir() + else: + p.write_text("{}") + # 相手は修正前のイメージのコンテナと同じく、リンクの段の全体を修正前の手順で走らせる + partner = "old_funcs; " + setup_cmd(v.home(1), v.ai, v.grp) + jobs, elapsed = run_concurrent( + v.base / "run", [setup_cmd(v.home(0), v.ai, v.grp), partner]) + new = jobs[0] + assert new.rc == 0, new.text + assert_no_ln_lines([new]) + assert p.is_symlink() + assert os.readlink(p) == str(v.ai / ".claude" / name) + assert elapsed < TIME_LIMIT + + +# --------------------------------------------------------------------------- +# 受け入れ条件 9・I3: 張れない状態は成功として扱わない +# --------------------------------------------------------------------------- + +@NOT_ROOT +def test_unwritable_group_claude_fails_with_the_link_path(tmp_path): + v = Volumes(tmp_path) + claude = v.grp / ".claude" + claude.mkdir(parents=True) + claude.chmod(0o555) + try: + r = run_script(setup_cmd(v.home(0), v.ai, v.grp), tmp_path) + finally: + claude.chmod(0o755) + assert r.returncode != 0 + assert any(str(claude / "plugins") in line and "ERROR" in line + for line in r.stderr.splitlines()), r.stderr + + +@NOT_ROOT +def test_unwritable_seed_destination_fails_with_the_entry_path(tmp_path): + """I9 (作れない): コピー先の親に書き込めないと非 0 で、エントリのパスを出す""" + v = Volumes(tmp_path) + make_home_claude(v.home(0)) + dest_parent = v.ai / ".claude" + dest_parent.mkdir(parents=True) + dest_parent.chmod(0o555) + try: + r = run_script(setup_cmd(v.home(0), v.ai, v.grp), tmp_path) + finally: + dest_parent.chmod(0o755) + assert r.returncode != 0 + assert any(str(dest_parent / "settings.json") in line and "ERROR" in line + for line in r.stderr.splitlines()), r.stderr + + +# --------------------------------------------------------------------------- +# I1・I2: 相手が先に張り終えていた +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("name", ["settings.json", "plugins"]) +def test_link_already_made_by_another_process_is_success(tmp_path, name): + """張る操作が失敗しても、読み直して正しければ 0。ディレクトリの中へ入れ子を作らない""" + v = Volumes(tmp_path) + v.link_once() + link = v.shared(name) + target = v.ai / ".claude" / name + # 1 回目の「消す」の直後に、ほかのプロセスが張り終えた状態を作る + pre = (f'rm() {{ command rm "$@"; command ln -s "{target}" "{link}"; ' + f'unset -f rm; }}') + link.unlink() + link.write_text("{}") if name == "settings.json" else link.mkdir() + r = run_script(f'devbase_link_setting "{link}" "{target}"', tmp_path, pre=pre) + assert r.returncode == 0, r.stderr + assert "ln:" not in r.stdout + r.stderr + assert os.readlink(link) == str(target) + assert_no_nested_links(v) + + +# --------------------------------------------------------------------------- +# I6・I9: ロックなしで進む +# --------------------------------------------------------------------------- + +def test_eight_stages_without_flock_match_a_single_run(tmp_path): + """I6・I9 (ロックなしの退避): 退避が重なっても 1 個だけの結果と同じ""" + path = path_without(tmp_path, "flock") + expected = _single_run_snapshot(tmp_path / "single", plugins=12, path=path) + for r in range(ROUNDS): + v = Volumes(tmp_path / f"r{r}") + for i in range(8): + make_home_claude(v.home(i), plugins=12) + jobs, elapsed = run_concurrent(v.base / "run", v.stage_jobs(8), path=path) + check_round(v, jobs, elapsed) + assert v.snap() == expected + + +@pytest.mark.parametrize("kind", ["file", "dir"]) +def test_only_one_process_creates_an_entry(tmp_path, kind): + """I9 (作れるのは 1 つ): 0 を返すのは 1 個だけ。既にあれば 1 で中身を変えない""" + for r in range(ROUNDS): + target = tmp_path / f"r{r}" / "entry" + target.parent.mkdir(parents=True) + body = (f'rc=0; devbase_create_entry {kind} "{target}" || rc=$?; ' + f'echo "rc=$rc"') + jobs, _ = run_concurrent(tmp_path / f"r{r}" / "run", [body] * 8) + rcs = [j.out.strip() for j in jobs] + assert rcs.count("rc=0") == 1, rcs + assert rcs.count("rc=1") == 7, rcs + existing = tmp_path / "existing" + if kind == "file": + existing.write_text("keep") + else: + existing.mkdir() + (existing / "child").write_text("keep") + r = run_script(f'rc=0; devbase_create_entry {kind} "{existing}" || rc=$?; echo "rc=$rc"', + tmp_path) + assert r.stdout.strip() == "rc=1" + if kind == "file": + assert existing.read_text() == "keep" + else: + assert (existing / "child").read_text() == "keep" + + +def test_without_perl_a_single_run_matches_the_run_with_perl(tmp_path): + """決定 10: perl が無くても 0 で終わり、結果が同じ""" + with_perl = _single_run_snapshot(tmp_path / "with", plugins=3) + without = _single_run_snapshot(tmp_path / "without", plugins=3, + path=path_without(tmp_path, "perl")) + assert without == with_perl + + +# --------------------------------------------------------------------------- +# I5・I6・I7・可用性: ロックそのもの (flock が要る) +# --------------------------------------------------------------------------- + +def _hold_lock(ai: Path, seconds: float) -> subprocess.Popen: + """共通のボリュームのルートにロックを掛けて ``seconds`` 秒持つプロセス""" + ai.mkdir(parents=True, exist_ok=True) + ready = ai.parent / "held" + p = subprocess.Popen([_bash(), "-c", + f'exec 9<"{ai}"; flock 9; : > "{ready}"; exec sleep {seconds}']) + deadline = time.monotonic() + 10 + while not ready.exists(): + assert time.monotonic() < deadline, "ロックを持つプロセスが用意できない" + time.sleep(0.02) + ready.unlink() + return p + + +@NEEDS_FLOCK +def test_stage_waits_for_the_lock_holder(tmp_path): + """I5: ロックを持つプロセスがある間は退避もリンクも始めず、手放された後に進む""" + v = Volumes(tmp_path) + make_home_claude(v.home(0)) + holder = _hold_lock(v.ai, 2) + try: + stage = subprocess.Popen( + [_bash(), "-c", f'set -e\nDEVBASE_ENTRYPOINT_LIB_ONLY=1 . "{ENTRYPOINT}"\n' + + setup_cmd(v.home(0), v.ai, v.grp)], + env=_base_env(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + time.sleep(1) + assert not (v.ai / ".claude").exists() + assert not (v.grp / ".claude").exists() + _, err = stage.communicate(timeout=TIME_LIMIT) + finally: + holder.kill() + holder.wait() + assert stage.returncode == 0, err + assert "WARNING" not in err + assert_shared_links(v) + + +@NEEDS_FLOCK +def test_stage_goes_on_without_the_lock_after_the_wait_limit(tmp_path): + """I6: 手放さない持ち主がいても、待ち時間の上限の後に警告を出して 0 で終わる""" + v = Volumes(tmp_path) + holder = _hold_lock(v.ai, 60) + try: + started = time.monotonic() + r = run_script(setup_cmd(v.home(0), v.ai, v.grp), tmp_path, + pre="DEVBASE_LINK_LOCK_WAIT=1") + elapsed = time.monotonic() - started + finally: + holder.kill() + holder.wait() + assert r.returncode == 0, r.stderr + warnings = [line for line in r.stderr.splitlines() if line.startswith("WARNING")] + assert len(warnings) == 1 and str(v.ai) in warnings[0] + assert elapsed < TIME_LIMIT + assert_shared_links(v) + + +@NEEDS_FLOCK +def test_killed_lock_holder_does_not_block_the_next_stage(tmp_path): + """可用性: 持ち主が kill されると、後から始めたリンクの段は待ち切らずに 0 で終わる""" + v = Volumes(tmp_path) + holder = _hold_lock(v.ai, 60) + stage = subprocess.Popen( + [_bash(), "-c", f'set -e\nDEVBASE_ENTRYPOINT_LIB_ONLY=1 . "{ENTRYPOINT}"\n' + + setup_cmd(v.home(0), v.ai, v.grp)], + env=_base_env(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + started = time.monotonic() + time.sleep(1) + holder.kill() + holder.wait() + _, err = stage.communicate(timeout=TIME_LIMIT) + assert stage.returncode == 0, err + assert "WARNING" not in err + assert time.monotonic() - started < TIME_LIMIT + assert_shared_links(v) + + +@NEEDS_FLOCK +def test_lock_is_released_when_the_stage_returns(tmp_path): + """I7: 戻った後、呼んだシェルが生きている間に、別のプロセスが待たずにロックを取れる""" + v = Volumes(tmp_path) + r = run_script(setup_cmd(v.home(0), v.ai, v.grp) + + f'\n( exec 8<"{v.ai}"; flock -n 8 ) && echo free || echo held', + tmp_path) + assert r.returncode == 0, r.stderr + assert r.stdout.strip().endswith("free") + + +def test_stage_without_a_link_left_by_a_killed_stage(tmp_path): + """可用性: 張りかけで終わった (共有のリンクの位置が無い) 状態から 0 で終わる""" + v = Volumes(tmp_path) + v.link_once() + v.shared("settings.json").unlink() + r = run_script(setup_cmd(v.home(1), v.ai, v.grp), tmp_path) + assert r.returncode == 0, r.stderr + assert_shared_links(v) + + +# --------------------------------------------------------------------------- +# 移行性・性能 +# --------------------------------------------------------------------------- + +def test_old_procedure_runs_after_the_new_stage(tmp_path): + """移行性: 修正後のあとに修正前の手順を走らせても 0。並びも修正前の手順と同じ""" + new = Volumes(tmp_path / "new") + make_home_claude(new.home(0), plugins=2) + assert run_script(setup_cmd(new.home(0), new.ai, new.grp), new.base).returncode == 0 + old = Volumes(tmp_path / "old") + make_home_claude(old.home(0), plugins=2) + r = run_script(setup_cmd(old.home(0), old.ai, old.grp), old.base, old=True) + assert r.returncode == 0, r.stderr + assert new.snap() == old.snap() + r = run_script(setup_cmd(new.home(1), new.ai, new.grp), new.base, old=True) + assert r.returncode == 0, r.stderr + assert_shared_links(new) + + +def test_single_stage_is_not_much_slower_than_the_old_procedure(tmp_path): + """性能: 1 個だけの所要が、修正前の手順の所要に 1 秒を足した値を超えない""" + def measure(old: bool, i: int) -> float: + v = Volumes(tmp_path / f"{'old' if old else 'new'}-{i}") + make_home_claude(v.home(0), plugins=3) + started = time.monotonic() + r = run_script(setup_cmd(v.home(0), v.ai, v.grp), v.base, old=old) + assert r.returncode == 0, r.stderr + return time.monotonic() - started + + # 並列で流すとほかのケースの負荷で 1 回ずつの所要がぶれる。交互に測り、最小を比べる + old_ts, new_ts = [], [] + for i in range(5): + old_ts.append(measure(True, i)) + new_ts.append(measure(False, i)) + old_t, new_t = min(old_ts), min(new_ts) + assert new_t <= old_t + 1.0, (old_t, new_t) From b937503ee72eb8c60e5102d9726f8d6999caa978 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 2 Oct 2026 15:26:18 +0900 Subject: [PATCH 02/17] =?UTF-8?q?docs(plugin-dev):=20quickstart=20?= =?UTF-8?q?=E3=81=AE=E3=82=A2=E3=82=AB=E3=82=A6=E3=83=B3=E3=83=88=E3=82=B0?= =?UTF-8?q?=E3=83=AB=E3=83=BC=E3=83=97=E3=82=92=E5=AE=A3=E8=A8=80=E5=BF=85?= =?UTF-8?q?=E9=A0=88=E3=81=AE=E6=9B=B8=E3=81=8D=E6=96=B9=E3=81=B8=E7=9B=B4?= =?UTF-8?q?=E3=81=99=20(#215)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit env ファイルを「中身は任意」とし、グループを「未設定なら default」と書いていた。 4.0.0(#315)から宣言は必須で default は予約語のため、宣言の書き方と止まる条件を書く。 Co-Authored-By: Claude Opus 5.5 --- docs/plugin-dev/quickstart.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/plugin-dev/quickstart.md b/docs/plugin-dev/quickstart.md index 052953d0..3d5e21cb 100644 --- a/docs/plugin-dev/quickstart.md +++ b/docs/plugin-dev/quickstart.md @@ -57,7 +57,7 @@ my-plugin/ └── my-project/ ├── compose.yml ├── project.yml - └── env # 中身は任意だが、ファイルは必須 + └── env # 必須。DEVBASE_ACCOUNT_GROUP の宣言を書く ``` ### 2.2 compose.yml の作成 @@ -138,19 +138,19 @@ repos: | `scale` | 起動するコンテナ数(既定: 2) | | `open_editor` | `devbase up` 後に VS Code を自動で開くか | -### 2.3.1 env ファイル(ファイルは必須・中身は任意) +### 2.3.1 env ファイル(必須・アカウントグループの宣言を書く) -`projects/my-project/env` には、**コンテナへ渡す環境変数**だけを書きます(`ENABLE_SSH` など)。devbase 自身の設定は `project.yml` にあります。 +`projects/my-project/env` には、**アカウントグループの宣言**(`DEVBASE_ACCOUNT_GROUP`)と、**コンテナへ渡す環境変数**(`ENABLE_SSH` など)を書きます。devbase 自身のそれ以外の設定は `project.yml` にあります。 ```bash +# projects/my-project/env +DEVBASE_ACCOUNT_GROUP=personal ENABLE_SSH=true ``` -2.2 の `compose.yml` が `env_file: - env` で参照するため、**ファイルは必ず作成してください**(実在しないと `devbase up` が compose の起動時に失敗します)。渡したい環境変数が無ければ空ファイルで構いません。 - -```bash -touch projects/my-project/env -``` +- **`DEVBASE_ACCOUNT_GROUP` の宣言は必須です。** 宣言が無い・空のとき、`devbase up` / `scale` は何も作らずに止まります。既定の値は無く、`default` は予約語のため使えません。会社の機密を使わない個人・OSS のプロジェクトは `personal` を、それ以外は使うアカウントの単位(`acme` など)を書きます。詳しくは [環境変数の説明のアカウントグループ](../user/environment-variables.md#アカウントグループ-devbase_account_group) を参照してください +- 2.2 の `compose.yml` が `env_file: - env` で参照するため、ファイルが無いと `devbase up` が compose の起動時にも失敗します +- この宣言を求めるのは devbase 4.0.0 からです。`plugin.yml` の `requires.devbase` は [契機の表](plugin-yml-reference.md#更新時の警告) に従って上げてください ### 2.4 .env ファイル(任意) @@ -324,7 +324,7 @@ flowchart LR | 作業ディレクトリ | `${COMPOSE_PROJECT_NAME}_work_${CONTAINER_INDEX:-1}` | `/work` | コンテナ専用 | - `devbase_home_ubuntu`(`/persistent/ai`)は SSH 鍵・共有ファイル・`~/.claude/plugins` など、契約に紐づかずコンテナ横断で共有したい資産の永続化に使用(entrypoint が symlink。旧 `/home/ubuntu` 直接マウントは廃止) -- `devbase_home_`(`/persistent/group`)は認証情報と会話ログ。`` は `DEVBASE_ACCOUNT_GROUP`(未設定なら `default`)で決まり、devbase が生成 compose へ自動注入する +- `devbase_home_`(`/persistent/group`)は認証情報と会話ログ。`` は `projects//env` に宣言した `DEVBASE_ACCOUNT_GROUP` で決まり、devbase が生成 compose へ自動注入する(宣言は必須で、`default` は予約語のため使えない。[2.3.1](#231-env-ファイル必須アカウントグループの宣言を書く)) - 作業ディレクトリボリュームはプロジェクトごと・コンテナインデックスごとに独立 ### 5.4 コンテナイメージの選択 From 6296b68f4aeae5b07db8b6a06e0fbfae6186ac6c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 2 Oct 2026 15:26:49 +0900 Subject: [PATCH 03/17] =?UTF-8?q?docs(plugin-dev):=20requires=20=E3=81=AE?= =?UTF-8?q?=E5=A5=91=E6=A9=9F=E8=A1=A8=E3=82=92=E6=AD=A3=E6=9C=AC=E3=81=AB?= =?UTF-8?q?=E3=81=97=E3=80=81PR=20=E3=83=86=E3=83=B3=E3=83=97=E3=83=AC?= =?UTF-8?q?=E3=83=BC=E3=83=88=E3=81=B8=201=20=E8=A1=8C=E8=B6=B3=E3=81=99?= =?UTF-8?q?=E6=89=8B=E9=A0=86=E3=82=92=E5=85=A5=E3=82=8C=E3=82=8B=20(#215)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - project.yml 移行の行に ">=3.0.0" を明記し、同じ文書の推奨と揃える - 3.6.0〜4.0.0 から Plugin が依存しうる機能(base イメージの道具・~/.shellrc.d)の行と、 4.0.0 のアカウントグループの宣言を Plugin の env に持つ行を足す - 機能を足した PR が表へ 1 行足すことを表の下と PR テンプレートの動作確認に書く Co-Authored-By: Claude Opus 5.5 --- .github/pull_request_template.md | 1 + docs/plugin-dev/plugin-yml-reference.md | 17 ++++++++++++----- docs/plugin-dev/quickstart.md | 2 +- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 4e755d9a..29569402 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -19,6 +19,7 @@ - [ ] 影響を受けるコマンドの主要シナリオを手動で確認した - [ ] 必要に応じてドキュメント (`docs/`, `README.md`) を更新した - [ ] 利用者に見える変更なら `CHANGELOG.md` の `[Unreleased]` を更新した +- [ ] Plugin から使える機能を足したら、`docs/plugin-dev/plugin-yml-reference.md` の `requires` 契機表へ 1 行足した - [ ] CI が green である ## スクリーンショット・ログ(任意) diff --git a/docs/plugin-dev/plugin-yml-reference.md b/docs/plugin-dev/plugin-yml-reference.md index 588f2ff7..e6e8b341 100644 --- a/docs/plugin-dev/plugin-yml-reference.md +++ b/docs/plugin-dev/plugin-yml-reference.md @@ -170,14 +170,21 @@ devbase 本体を更新してください (この警告を止める場合は DEV ``` > **`requires.devbase` は本体の版数と一緒に自動では上がりません。** 上げるのは、**Plugin が -> devbase の新しい機能に依存しはじめたタイミング**です。契機は次のとおりです。 +> devbase の新しい機能に依存しはじめたタイミング**です。契機は次の表のとおりで、この表が正本です。 > -> | 契機 | 上げる版数 | +> | 契機 | Plugin の側ですること | > | --- | --- | -> | Plugin が `project.yml` 形式へ移行した | その形式を読める版 | -> | Plugin のプロジェクトが `compose.yml` に `profiles:` を使う | `">=3.5.0"`([テスト用サーバを後から起動・停止する](compose-profiles.md#plugin-として配るなら-requiresdevbase-を-350-以上へ上げる)) | +> | Plugin が `project.yml` 形式へ移行した | `requires.devbase` を `">=3.0.0"` へ上げる | +> | Plugin のプロジェクトが `compose.yml` に `profiles:` を使う | `requires.devbase` を `">=3.5.0"` へ上げる([テスト用サーバを後から起動・停止する](compose-profiles.md#plugin-として配るなら-requiresdevbase-を-350-以上へ上げる)) | +> | Plugin のプロジェクトが、base イメージに後から足された道具を前提にする(`pdftoppm` などの `poppler-utils`・`python3-lxml` は 3.7.0、`shellcheck`・`tmux-go` などの tmux のセッションのコマンドは 3.8.0) | `requires.devbase` を、その道具を足した版以上へ上げる(どの版で足されたかは `CHANGELOG.md` の Added を見る) | +> | Plugin のプロジェクトが、コンテナの中で `~/.shellrc.d/`(環境変数 `DEVBASE_SHELLRC_DIR`)へシェルの設定を置く | `requires.devbase` を `">=3.8.0"` へ上げる | +> | devbase 4.0.0 以降で使われる Plugin のプロジェクト(アカウントグループの宣言が必須) | `requires.devbase` の下限ではなく、Plugin のプロジェクトの `env` に `DEVBASE_ACCOUNT_GROUP=<グループ>` を書く(`default` は予約語で使えない。[クイックスタート 2.3.1](quickstart.md#231-env-ファイル必須アカウントグループの宣言を書く))。宣言が無いと `devbase up` / `scale` が止まる | > -> どちらも、その機能を使い始めた Pull Request で一緒に上げます。 +> どれも、その機能を使い始めた Pull Request で一緒に行います。base イメージの道具とシェルの設定の置き場所は、 +> 利用者がイメージを建て直すまで効きません。 +> +> devbase の側で Plugin から使える機能(Plugin が書く設定・compose の書き方・イメージの道具・コンテナの中の置き場所など)を +> 足した Pull Request は、この表へ 1 行足します(Pull Request のテンプレートの「動作確認」に項目があります)。 ### `priority` diff --git a/docs/plugin-dev/quickstart.md b/docs/plugin-dev/quickstart.md index 3d5e21cb..b41af63e 100644 --- a/docs/plugin-dev/quickstart.md +++ b/docs/plugin-dev/quickstart.md @@ -150,7 +150,7 @@ ENABLE_SSH=true - **`DEVBASE_ACCOUNT_GROUP` の宣言は必須です。** 宣言が無い・空のとき、`devbase up` / `scale` は何も作らずに止まります。既定の値は無く、`default` は予約語のため使えません。会社の機密を使わない個人・OSS のプロジェクトは `personal` を、それ以外は使うアカウントの単位(`acme` など)を書きます。詳しくは [環境変数の説明のアカウントグループ](../user/environment-variables.md#アカウントグループ-devbase_account_group) を参照してください - 2.2 の `compose.yml` が `env_file: - env` で参照するため、ファイルが無いと `devbase up` が compose の起動時にも失敗します -- この宣言を求めるのは devbase 4.0.0 からです。`plugin.yml` の `requires.devbase` は [契機の表](plugin-yml-reference.md#更新時の警告) に従って上げてください +- この宣言を求めるのは devbase 4.0.0 からです。devbase の機能を使い始めたときに Plugin の側ですること(`requires.devbase` を上げるなど)は [`requires` の契機の表](plugin-yml-reference.md#更新時の警告) にあります ### 2.4 .env ファイル(任意) From ad0de311af4d93b74d0eb935c13c1b7bb911a339 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 2 Oct 2026 15:32:16 +0900 Subject: [PATCH 04/17] =?UTF-8?q?fix(up):=20=E8=B5=B7=E5=8B=95=E3=81=AE?= =?UTF-8?q?=E5=BE=85=E3=81=A1=E3=81=A7=201=20=E5=8F=B0=E3=81=8C=E8=90=BD?= =?UTF-8?q?=E3=81=A1=E3=81=A6=E3=82=82=E6=AE=8B=E3=82=8A=E3=82=92=E5=BE=85?= =?UTF-8?q?=E3=81=A1=E3=80=81=E8=B5=B7=E5=8B=95=E3=81=AE=E7=B5=90=E6=9E=9C?= =?UTF-8?q?=E3=82=92=E4=BE=8B=E5=A4=96=E3=81=A7=E8=BF=94=E3=81=99=20(#224?= =?UTF-8?q?=20#371)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wait_for_containers_ready は起動できなかった番号があっても残りの番号を制限まで待ち、 起動できた番号と、起動できなかった番号ごとの理由(終了した・見つからない・時間切れ)と ログの末尾を持つ ContainerStartupError を投げる。決まった番号へは問い合わせ直さない。 Co-Authored-By: Claude Opus 5.5 --- lib/devbase/utils/docker.py | 188 +++++++++++++++--------- tests/utils/test_wait_for_containers.py | 165 +++++++++++++++++++++ 2 files changed, 280 insertions(+), 73 deletions(-) create mode 100644 tests/utils/test_wait_for_containers.py diff --git a/lib/devbase/utils/docker.py b/lib/devbase/utils/docker.py index dde0962a..af5b92f2 100644 --- a/lib/devbase/utils/docker.py +++ b/lib/devbase/utils/docker.py @@ -3,6 +3,7 @@ import os import subprocess import time +from dataclasses import dataclass from pathlib import Path from typing import Dict, List, Mapping, Optional, Sequence, Tuple @@ -151,47 +152,83 @@ def get_container_status( return None -def check_containers_running( - container_prefix: str, - scale: int, - compose_file: Optional[Path] = None -) -> Tuple[bool, Optional[str]]: +_STARTUP_REASON_TEXT = { + 'exited': 'exited unexpectedly', + 'not_found': 'container not found', +} + + +@dataclass(frozen=True) +class StartupFailure: + """起動の待ちで起動できなかった 1 インスタンス。 + + ``reason`` は ``exited``・``not_found``・``timeout`` のどれかで、``logs`` は + ``exited`` のときだけ中身を持つ (ログの末尾。取れなければ空)。 """ - Check if all containers are running (not exited) - Args: - container_prefix: Container name prefix (e.g., "dev") - scale: Number of containers to check - compose_file: Compose file path (optional) + index: int + service: str + reason: str + logs: str = '' + timeout: int = 0 - Returns: - Tuple of (all_running: bool, error_message: Optional[str]) + def describe(self) -> str: + """利用者へ出す 1 インスタンス分の文言 (ログの末尾を含む)。""" + if self.reason == 'timeout': + text = f"timeout ({self.timeout}s) waiting for the entrypoint to complete" + else: + text = _STARTUP_REASON_TEXT.get(self.reason, self.reason) + lines = [f" - {self.service}: {text}"] + if self.reason == 'exited' and self.logs: + lines.append(" Last logs:") + lines.extend(f" {line}" for line in self.logs.splitlines()) + return '\n'.join(lines) + + +class ContainerStartupError(DockerError): + """起動の待ちで、起動できなかったインスタンスが 1 つ以上あった。 + + ``ready`` は起動できた番号、``failures`` は起動できなかったインスタンスで、どちらも番号の + 昇順に並び、合わせると待った番号の全部になる。 """ - for i in range(1, scale + 1): - service_name = f"{container_prefix}-{i}" - status = get_container_status(service_name, compose_file) - - if status is None: - return False, f"Container {service_name} not found" - - # Check if container exited - status_lower = status.lower() - if 'exited' in status_lower or 'dead' in status_lower: - # Get container logs for error details - try: - result = docker_compose( - ['logs', '--tail', '10', service_name], - compose_file=compose_file, - check=False, - capture_output=True, - silent_error=True - ) - logs = result.stdout.strip() or result.stderr.strip() - return False, f"Container {service_name} exited unexpectedly.\nLast logs:\n{logs}" - except Exception: - return False, f"Container {service_name} exited unexpectedly (status: {status})" - - return True, None + + def __init__(self, ready: Sequence[int], failures: Sequence[StartupFailure]): + self.ready: Tuple[int, ...] = tuple(sorted(ready)) + self.failures: Tuple[StartupFailure, ...] = tuple( + sorted(failures, key=lambda f: f.index)) + total = len(self.ready) + len(self.failures) + header = (f"Container startup failed: {len(self.failures)} of {total} " + f"instances did not become ready.") + super().__init__('\n'.join([header] + [f.describe() for f in self.failures])) + + +def _container_logs_tail(service_name: str, compose_file: Optional[Path]) -> str: + """終了したコンテナのログの末尾 (10 行まで)。取れなければ空。""" + try: + result = docker_compose( + ['logs', '--tail', '10', service_name], + compose_file=compose_file, + check=False, + capture_output=True, + silent_error=True + ) + except (OSError, subprocess.SubprocessError): + return '' # ログが取れなくても、起動できなかったことは伝える + return (result.stdout or '').strip() or (result.stderr or '').strip() + + +def _is_ready(service_name: str, ready_file: str, compose_file: Optional[Path]) -> bool: + try: + docker_compose( + ['exec', '-T', service_name, 'test', '-f', ready_file], + compose_file=compose_file, + check=True, + capture_output=True, + silent_error=True + ) + except subprocess.CalledProcessError: + return False + return True def wait_for_containers_ready( @@ -204,57 +241,62 @@ def wait_for_containers_ready( """ Wait for all containers' entrypoint to complete + 1 から ``scale`` までの番号を巡ごとに確かめる。1 巡では、まだ決まっていない番号ごとに + 状態を 1 回、動いていれば完了の印を 1 回確かめる。決まった番号 (起動できた・終了した・ + 見つからない) へは、その後の巡で問い合わせない。1 台が起動できなくても、残りの番号は + 制限まで待つ。巡の数は ``timeout`` を超えない (1 巡ごとに 1 秒休む)。 + Args: container_prefix: Container name prefix (e.g., "dev") scale: Number of containers to wait for ready_file: File path to check in container - timeout: Maximum wait time in seconds + timeout: Maximum number of 1-second rounds compose_file: Compose file path (optional) Returns: True if all containers are ready Raises: - DockerError: If containers fail to start or timeout + ContainerStartupError: 起動できなかった番号が 1 つ以上あったとき。起動できた番号と、 + 起動できなかった番号ごとの理由 (終了した・見つからない・時間切れ) を持つ """ logger.info("Waiting for container entrypoint to complete...") - waited = 0 - while waited < timeout: - # First check if containers are still running - all_running, error_msg = check_containers_running( - container_prefix, scale, compose_file - ) - if not all_running: - raise DockerError(f"Container startup failed: {error_msg}") - - all_ready = True - - for i in range(1, scale + 1): + pending = list(range(1, scale + 1)) + ready: List[int] = [] + failures: List[StartupFailure] = [] + rounds = 0 + while pending and rounds < timeout: + still_pending = [] + for i in pending: service_name = f"{container_prefix}-{i}" - - # Check if ready file exists in container - try: - cmd = ['exec', '-T', service_name, 'test', '-f', ready_file] - docker_compose( - cmd, - compose_file=compose_file, - check=True, - capture_output=True, - silent_error=True - ) - except subprocess.CalledProcessError: - all_ready = False - break - - if all_ready: - logger.info("All containers ready") - return True - + status = get_container_status(service_name, compose_file) + if status is None: + failures.append(StartupFailure(i, service_name, 'not_found')) + continue + status_lower = status.lower() + if 'exited' in status_lower or 'dead' in status_lower: + failures.append(StartupFailure( + i, service_name, 'exited', + _container_logs_tail(service_name, compose_file))) + continue + if _is_ready(service_name, ready_file, compose_file): + ready.append(i) + else: + still_pending.append(i) + pending = still_pending + if not pending: + break time.sleep(1) - waited += 1 - - raise DockerError(f"Timeout ({timeout}s) waiting for containers to be ready") + rounds += 1 + + failures.extend( + StartupFailure(i, f"{container_prefix}-{i}", 'timeout', timeout=timeout) + for i in pending) + if failures: + raise ContainerStartupError(ready, failures) + logger.info("All containers ready") + return True def docker_compose_down(compose_file: Optional[Path] = None) -> None: diff --git a/tests/utils/test_wait_for_containers.py b/tests/utils/test_wait_for_containers.py new file mode 100644 index 00000000..b99f6544 --- /dev/null +++ b/tests/utils/test_wait_for_containers.py @@ -0,0 +1,165 @@ +"""起動の待ち (``wait_for_containers_ready``) の結果 (#224・#371)。 + +1 台が起動できなくても残りを制限まで待ち、起動できた番号と、起動できなかった番号ごとの +理由 (終了した・見つからない・時間切れ) を例外で返す。実 docker には触れない。 +``docker_compose`` を差し替え、巡 (``time.sleep`` の回数) ごとの状態を台本で返す。 +""" + +from __future__ import annotations + +import json +import subprocess + +import pytest + +from devbase.errors import DevbaseError, DockerError +from devbase.utils import docker + + +class Script: + """番号ごとの状態を巡の関数で返す ``docker_compose`` の代わり。 + + ``states[n](round)`` は ``(状態, 完了の印があるか)``。状態が ``None`` なら見つからない。 + """ + + def __init__(self, states, logs=None): + self.states = states + self.logs = logs or {} + self.round = 0 + self.calls: list = [] + + def sleep(self, _seconds): + self.round += 1 + + def __call__(self, command, compose_file=None, check=True, capture_output=False, + silent_error=False): + command = list(command) + service = command[-1] if command[0] in ('ps', 'logs') else command[2] + index = int(service.rsplit('-', 1)[1]) + self.calls.append((self.round, command[0], index)) + state, ready = self.states[index](self.round) + if command[0] == 'ps': + stdout = '' if state is None else json.dumps({'State': state}) + return subprocess.CompletedProcess(command, 0, stdout, '') + if command[0] == 'logs': + return subprocess.CompletedProcess(command, 0, self.logs.get(index, ''), '') + if command[0] == 'exec': + if ready: + return subprocess.CompletedProcess(command, 0, '', '') + raise subprocess.CalledProcessError(1, command) + raise AssertionError(command) + + +def ready_at(n): + return lambda r: ('running', r >= n) + + +def exited_at(n): + return lambda r: (('exited', False) if r >= n else ('running', False)) + + +def never_ready(_r): + return ('running', False) + + +def missing(_r): + return (None, False) + + +@pytest.fixture +def run(monkeypatch): + def _run(states, scale=None, timeout=60, logs=None): + script = Script(states, logs) + monkeypatch.setattr(docker, 'docker_compose', script) + monkeypatch.setattr(docker.time, 'sleep', script.sleep) + scale = scale or len(states) + try: + result = docker.wait_for_containers_ready('dev', scale, timeout=timeout) + except docker.ContainerStartupError as e: + return script, e + return script, result + return _run + + +def test_all_ready_returns_true(run): + script, result = run({1: ready_at(0), 2: ready_at(2)}) + + assert result is True + assert script.round == 2 + + +def test_exited_instances_are_all_reported_with_logs(run): + """受け入れ条件 12・11: 最初の巡で 2 台が終了していれば、両方の名前・理由・ログが入る""" + script, error = run({1: ready_at(0), 2: exited_at(0), 3: exited_at(0)}, + logs={2: 'ln: Already exists', 3: 'boom'}) + + assert isinstance(error, DockerError) and isinstance(error, DevbaseError) + assert error.ready == (1,) + assert [(f.index, f.service, f.reason) for f in error.failures] == [ + (2, 'dev-2', 'exited'), (3, 'dev-3', 'exited')] + text = str(error) + assert text.startswith('Container startup failed: 2 of 3 instances did not become ready.') + assert ' - dev-2: exited unexpectedly' in text + assert ' - dev-3: exited unexpectedly' in text + assert ' ln: Already exists' in text and ' boom' in text + assert text.count('Last logs:') == 2 + + +def test_one_exited_does_not_stop_waiting_for_the_rest(run): + """受け入れ条件 A2・I2: dev-1 が終了しても、後から完了する dev-2 を起動できた側へ入れる""" + script, error = run({1: exited_at(0), 2: ready_at(5)}) + + assert error.ready == (2,) + assert [f.index for f in error.failures] == [1] + assert script.round == 5 + + +def test_timeout_names_the_instances_not_ready(run): + """受け入れ条件 13・A6: 制限までに完了しない番号は時間切れとして名前が入る""" + script, error = run({1: ready_at(1), 2: never_ready}, timeout=5) + + assert error.ready == (1,) + (failure,) = error.failures + assert (failure.index, failure.reason) == (2, 'timeout') + assert ' - dev-2: timeout (5s) waiting for the entrypoint to complete' in str(error) + assert script.round == 5 + + +def test_missing_container_is_not_found(run): + script, error = run({1: missing, 2: ready_at(0)}) + + assert error.ready == (2,) + assert [(f.index, f.reason) for f in error.failures] == [(1, 'not_found')] + assert ' - dev-1: container not found' in str(error) + assert 'Last logs' not in str(error) + + +def test_every_index_lands_on_exactly_one_side(run): + """I1: 待った番号は、起動できた側とできなかった側のどちらか一方にだけ現れる""" + script, error = run({1: ready_at(0), 2: exited_at(1), 3: never_ready, 4: missing, + 5: ready_at(2)}, timeout=4) + + failed = [f.index for f in error.failures] + assert sorted(list(error.ready) + failed) == [1, 2, 3, 4, 5] + assert not set(error.ready) & set(failed) + + +def test_decided_instances_are_not_asked_again(run): + """I3: 1 巡の呼び出しは番号あたり状態 1 回と完了 1 回まで。決まった番号は問い合わせない""" + script, error = run({1: ready_at(0), 2: exited_at(0), 3: ready_at(3)}, + logs={2: 'x'}) + + per_round: dict = {} + for rnd, kind, index in script.calls: + per_round.setdefault((rnd, index), []).append(kind) + for kinds in per_round.values(): + assert kinds.count('ps') <= 1 and kinds.count('exec') <= 1 + assert {i for (rnd, i) in per_round if rnd > 0} == {3} + assert [c for c in script.calls if c[1] == 'logs'] == [(0, 'logs', 2)] + + +def test_rounds_do_not_exceed_the_limit(run): + script, error = run({1: never_ready}, timeout=7) + + assert script.round == 7 + assert max(rnd for rnd, _k, _i in script.calls) == 6 From 1ca44016ce930f3e993988bb11ba6671e2eaafa8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 2 Oct 2026 15:38:22 +0900 Subject: [PATCH 05/17] =?UTF-8?q?fix(up,scale):=20=E8=B5=B7=E5=8B=95?= =?UTF-8?q?=E3=81=AE=E5=BE=8C=E3=81=AE=E5=87=A6=E7=90=86=E3=82=92=201=20?= =?UTF-8?q?=E3=81=A4=E3=81=AE=E6=AE=B5=E3=81=AB=E3=81=BE=E3=81=A8=E3=82=81?= =?UTF-8?q?=E3=80=81=E8=B5=B7=E5=8B=95=E3=81=A7=E3=81=8D=E3=81=9F=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=82=B9=E3=82=BF=E3=83=B3=E3=82=B9=E3=81=B8=E5=B1=8A?= =?UTF-8?q?=E3=81=91=E3=82=8B=20(#224=20#371)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _run_post_start: 後処理の対象の番号へ、不足リポジトリの報告 → ./deploy → token の配布 → 窓のタイトルの設定を行う。up / scale / project post-start が共有する - scale は増やしたインスタンスのうち起動できたものへ、up と同じ並びで行う (不足リポジトリの報告と窓のタイトルの設定が加わり、./deploy と token の順が up に揃う) - 起動できなかったインスタンスがあっても、起動できたものへ後処理を行い、名前・理由と 補う手順を出して 1 で終える。開く番号が起動できなければエディタを開かない - devbase project post-start: 動いていて entrypoint の完了を確かめられたインスタンスへ、 ./deploy を除く 3 つをやり直す - test_container_scale_order の 2 件を、順の入れ替えと対象の渡し方の変更に合わせて書き換えた Co-Authored-By: Claude Opus 5.5 --- lib/devbase/commands/container.py | 259 +++++++-- tests/commands/test_container_post_start.py | 548 +++++++++++++++++++ tests/commands/test_container_scale_order.py | 18 +- tests/utils/test_wait_for_containers.py | 16 +- 4 files changed, 782 insertions(+), 59 deletions(-) create mode 100644 tests/commands/test_container_post_start.py diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 81b13457..8e18b4b0 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -20,6 +20,7 @@ get_dev_service_name, ) from devbase.utils.docker import ( + ContainerStartupError, compose_env, docker_compose, docker_compose_down, @@ -926,10 +927,18 @@ def _bao_environment() -> dict: return {'BAO_ADDR': store.config.openbao.url} +def _target_indices(indices, scale: int, start: int = 1) -> list[int]: + """個々の処理が対象にする番号。``indices`` が無ければ ``start`` から ``scale`` まで。""" + return list(indices) if indices is not None else list(range(start, scale + 1)) + + def _push_bao_token(project_name: str, scale: int, dev_service_name: str, - compose_file=None, start: int = 1) -> None: + compose_file=None, start: int = 1, *, indices=None) -> None: """各 dev コンテナの ``~/.vault-token`` へ token を書く (PLAN54)。 + ``indices`` を渡すとその番号のコンテナにだけ書く (起動の後の処理の対象。#224・#371)。 + 渡さなければ ``start`` から ``scale`` まで。 + backend が ``openbao`` でなければ何もしない。token が取れない・書けないときは 警告にとどめる (起動は済んでおり、``devbase env token`` でやり直せる)。 """ @@ -944,7 +953,7 @@ def _push_bao_token(project_name: str, scale: int, dev_service_name: str, token = store.backend_for(SecretRef.for_global()).issue_token() names = [opener.resolve_container_name(dev_service_name, project_name, index, compose_file=compose_file) - for index in range(start, scale + 1)] + for index in _target_indices(indices, scale, start)] written = container_token.push(names, token) except Exception as e: # noqa: BLE001 - 付随処理で up を倒さない logger.warning("コンテナへ bao の token を書けませんでした (devbase env token で" @@ -958,7 +967,7 @@ def _push_bao_token(project_name: str, scale: int, dev_service_name: str, def _apply_window_titles(project_name: str, scale: int, dev_service_name: str, - compose_file=None) -> None: + compose_file=None, *, indices=None) -> None: """各 dev コンテナの VS Code ウィンドウタイトルをコンテナ名始まりにする。 既定のタイトルは編集中ファイル名が先頭に来るため、複数プロジェクトの窓を @@ -970,13 +979,15 @@ def _apply_window_titles(project_name: str, scale: int, dev_service_name: str, 「コンテナにアタッチ」した窓にも同じタイトルが要るため。 失敗しても ``up`` は倒さない (タイトルは付随的な体験改善のため)。 + + ``indices`` を渡すとその番号にだけ行う (起動の後の処理の対象)。渡さなければ 1 から ``scale``。 """ from devbase.editor import opener, window_title template = window_title.resolve_template() if template is None: return - for index in range(1, scale + 1): + for index in _target_indices(indices, scale): try: container_name = opener.resolve_container_name( dev_service_name, project_name, index, compose_file=compose_file) @@ -988,7 +999,8 @@ def _apply_window_titles(project_name: str, scale: int, dev_service_name: str, def _maybe_open_editor(project_name: str, open_flag: Optional[bool], open_index: Optional[int], scale: int, config, compose_file=None, - docker_context_name: Optional[str] = None) -> None: + docker_context_name: Optional[str] = None, *, + started=None) -> None: """`up` 完了後に dev コンテナへ接続したエディタを開く ([6/6])。 有効判定は ``open_flag`` (CLI ``--open``/``--no-open``) が優先、None なら @@ -1005,6 +1017,10 @@ def _maybe_open_editor(project_name: str, open_flag: Optional[bool], ``compose_file`` は実コンテナ名問い合わせ用の override compose。``up`` 起動時と 同じファイルを渡さないと ``{dev}-{index}`` サービスが見えず実名取得に失敗する。 未指定なら ``.docker-compose.scale.yml`` が存在すればそれ、無ければ None。 + + ``started`` (起動できた番号) を渡すと、開く番号がそこに無いときは開かずに警告する。 + ほかの番号へは替えない。利用者が指定した番号と違うインスタンスで作業を始めさせないため + (#371 決定 5)。 """ from devbase.editor import opener @@ -1014,6 +1030,11 @@ def _maybe_open_editor(project_name: str, open_flag: Optional[bool], return open_index = _resolve_open_index(open_index, scale) + if started is not None and open_index not in started: + logger.warning("%s-%d は起動できなかったため、エディタを開きません " + "(起こし直した後に devbase open %d で開けます)", + get_dev_service_name(), open_index, open_index) + return logger.info("[6/6] Opening editor attached to the dev container...") try: @@ -1119,7 +1140,7 @@ def cmd_open(project_name: Optional[str] = None, open_index: Optional[int] = Non def _report_missing_repos(config, scale: int, dev_service_name: str, project_name: str, - compose_file: Optional[Path] = None) -> None: + compose_file: Optional[Path] = None, *, indices=None) -> None: """``project.yml`` に書いたのに ``/work`` へ無いリポジトリを警告する (PLAN37)。 clone の失敗は entrypoint 側で warning に留めてコンテナ起動を続ける @@ -1132,8 +1153,10 @@ def _report_missing_repos(config, scale: int, dev_service_name: str, 問い合わせ自体の失敗 (コンテナが既に落ちている等) では何も言わない。``up`` は ここまでで成功しており、付随情報のために倒す価値はない。 + + ``indices`` を渡すとその番号にだけ行う (起動の後の処理の対象)。渡さなければ 1 から ``scale``。 """ - for index in range(1, scale + 1): + for index in _target_indices(indices, scale): service = f"{dev_service_name}-{index}" try: result = docker_compose( @@ -1247,6 +1270,69 @@ def _run_pre_up_checks(config) -> bool: return True +def _run_post_start(project_name: str, scale: int, indices, dev_service_name: str, + config, compose_file, run_deploy: bool = True) -> None: + """起動の後の処理の段。後処理の対象の番号へ、インスタンスごとの処理を決まった順に行う。 + + 順は、不足リポジトリの報告 → ``./deploy`` → token の配布 → 窓のタイトルの設定で、 + ``up`` / ``scale`` / ``project post-start`` が共有する (#224・#371)。対象は呼び出し元が + 決め、この段は番号を足しも引きもしない。対象が空なら何も行わない (token も発行しない)。 + ``run_deploy`` が偽なら ``./deploy`` を走らせない (``project post-start``)。 + + 個々の処理の失敗は、それぞれの今の扱い (警告か無言) にとどめ、ここから投げない。 + """ + indices = list(indices) + if not indices: + return + + # clone できなかった repo があれば伝える (揃っていれば何も出さない)。 + _report_missing_repos(config, scale, dev_service_name, project_name, + compose_file=compose_file, indices=indices) + + deploy_script = Path('./deploy') + if run_deploy and deploy_script.exists() and deploy_script.is_file(): + _run_deploy_script_for_instances(deploy_script, indices, config) + + # 起動中のコンテナの bao が使う token を書く (PLAN54)。backend が openbao の + # ときだけ。書けなくても起動は済んでいるので倒さない。 + _push_bao_token(project_name, scale, dev_service_name, compose_file=compose_file, + indices=indices) + + # VS Code のウィンドウタイトルをコンテナ名始まりに固定する + # (自動オープンの有無に関わらず、手動アタッチにも効かせるため)。 + _apply_window_titles(project_name, scale, dev_service_name, + compose_file=compose_file, indices=indices) + + +def _warn_partial_start(error: ContainerStartupError, targets, dev_service_name: str) -> None: + """起動の待ちの直後に、起動できなかった名前と、後処理を続けるかを 1 行で出す。 + + 処理先に出すのは後処理の対象だけである (``scale`` の既存のインスタンスは出さない)。 + """ + failed = ', '.join(f.service for f in error.failures) + if targets: + names = ', '.join(f"{dev_service_name}-{i}" for i in targets) + logger.warning("起動できなかったインスタンスがあります: %s。%s へ起動の後の処理を続けます", + failed, names) + else: + logger.warning("起動できなかったインスタンスがあります: %s。起動の後の処理を行う" + "インスタンスが無いため、起動の後の処理は行いません", failed) + + +def _report_startup_failure(label: str, error: ContainerStartupError, + project_name: str) -> None: + """起動できなかったインスタンスの名前・理由と、補う手順を出力の最後に出す (#371)。""" + logger.error("%s failed: %s", label, error) + logger.error("起動できなかったインスタンスには、起動の後の処理を行っていません。補う手順:") + logger.error(" 落ちたコンテナを起こし直し、その起動が終わった後に、次のコマンドで" + "起動の後の処理をやり直します(./deploy は走りません)") + logger.error(" devbase project post-start %s", project_name) + logger.error(" docker start で起こし直しただけでは、起動の後の処理" + "(token の配布・窓のタイトルの設定など)は行われません") + logger.error(" ./deploy を含めて最初からやり直すには devbase up %s を打ちます" + "(全インスタンスを作り直します)", project_name) + + def _run_deploy_pipeline(project_name: str, scale: int, config, target: docker_context.DockerTarget, dev_service_name: str) -> Path: @@ -1256,8 +1342,8 @@ def _run_deploy_pipeline(project_name: str, scale: int, config, 暗号文の破損でここが失敗しても、稼働中の開発環境を落としたままにしないため。 :func:`_previous_scale_compose` が退避した旧構成で停止し、生成した新構成で 起動して ready を待つ。生成した override compose のパスを返す (後処理の - ``_report_missing_repos`` / ``_apply_window_titles`` / ``_maybe_open_editor`` - が同じファイルを ``-f`` で使う)。 + :func:`_run_post_start` と ``_maybe_open_editor`` が同じファイルを ``-f`` で使う)。 + 起動できなかったインスタンスがあれば、起動の待ちの ``ContainerStartupError`` をそのまま通す。 """ logger.info("[1/6] Ensuring volumes exist...") ensure_volumes(scale, project_name) @@ -1331,32 +1417,28 @@ def cmd_up(project_name: str = None, scale: int = None, _auto_snapshot() try: - override_file = _run_deploy_pipeline( - project_name, scale, config, target, dev_service_name) - - # clone できなかった repo があれば伝える (揃っていれば何も出さない)。 - _report_missing_repos(config, scale, dev_service_name, project_name, - compose_file=override_file) - - # Run project-specific deploy script for each scaled instance - deploy_script = Path('./deploy') - if deploy_script.exists() and deploy_script.is_file(): - _run_deploy_script_for_instances(deploy_script, range(1, scale + 1), - config) - - # 起動中のコンテナの bao が使う token を書く (PLAN54)。backend が openbao の - # ときだけ。書けなくても起動は済んでいるので up は倒さない。 - _push_bao_token(project_name, scale, dev_service_name, compose_file=override_file) - - # VS Code のウィンドウタイトルをコンテナ名始まりに固定する - # (自動オープンの有無に関わらず、手動アタッチにも効かせるため up 側で行う)。 - _apply_window_titles(project_name, scale, dev_service_name, - compose_file=override_file) + startup_error = None + try: + override_file = _run_deploy_pipeline( + project_name, scale, config, target, dev_service_name) + started = list(range(1, scale + 1)) + except ContainerStartupError as e: + # 起動できたインスタンスへは後処理を行ってから失敗として終える (#371) + startup_error = e + override_file = _SCALE_COMPOSE_FILE + started = list(e.ready) + _warn_partial_start(e, started, dev_service_name) + + _run_post_start(project_name, scale, started, dev_service_name, config, + override_file) _maybe_open_editor(project_name, open_editor, open_index, scale, config, compose_file=override_file, - docker_context_name=target.context) + docker_context_name=target.context, started=started) + if startup_error is not None: + _report_startup_failure('Deploy', startup_error, project_name) + return 1 logger.info("=== Deploy completed successfully ===") return 0 @@ -1410,6 +1492,81 @@ def cmd_login(index: str = '1', context: Optional[str] = None) -> int: return subprocess.run(cmd, env=compose_env()).returncode +# --------------------------------------------------------------------------- +# cmd_post_start +# --------------------------------------------------------------------------- + +_ENTRYPOINT_READY_FILE = '/tmp/entrypoint-ready' + + +def _entrypoint_ready(container_name: str) -> bool: + """動いているコンテナの entrypoint の完了の印を 1 回だけ確かめる (待たない)。 + + 確認の呼び出しそのものが失敗したときも、確かめられないものとして ``False`` を返す。 + """ + try: + result = subprocess.run( + ['docker', 'exec', container_name, 'test', '-f', _ENTRYPOINT_READY_FILE], + capture_output=True, text=True, check=False) + except (OSError, subprocess.SubprocessError): + return False + return result.returncode == 0 + + +def cmd_post_start(project_name: Optional[str] = None, context: Optional[str] = None) -> int: + """動いているインスタンスへ、起動の後の処理をコンテナに触らずにやり直す (#371)。 + + 対象は、動いていて entrypoint の完了の印を確かめられたインスタンスである。不足リポジトリの + 報告 → token の配布 → 窓のタイトルの設定を行い、``./deploy`` は走らせず、エディタも + 開かない。コンテナを起動・停止・削除しない。 + + 戻り値: 対象が 1 つ以上あれば 0 (処理しなかった番号は警告で出す)。1 つも動いていない・ + 完了の印を確かめられたものが無い・状態を取得できないときは 1。 + """ + if project_name is None: + project_name = get_project_name() + + config = project_runtime.current_project_config() + # 接続先の反映と機密の任意注入は login と同じ。ボリュームも構成も作らないため、 + # グループの宣言の検査と project.yml の書き換えは行わない + _prepare_compose(context) + + dev_service_name = get_dev_service_name() + running = running_dev_instances(project_name, dev_service_name) + if running is None: + logger.error("dev コンテナの状態を取得できないため、起動の後の処理を行えません") + return 1 + if not running: + logger.error("動いている %s のインスタンスがありません。起動には devbase up を使います", + dev_service_name) + return 1 + + targets = [] + not_ready = [] + for index, name in running: + (targets if _entrypoint_ready(name) else not_ready).append(index) + + scale = config.scale if config.scale is not None else project_runtime.DEFAULT_SCALE + running_indices = {index for index, _name in running} + stopped = [i for i in range(1, scale + 1) if i not in running_indices] + if stopped: + logger.warning("動いていないため処理しません: %s", + ', '.join(f"{dev_service_name}-{i}" for i in stopped)) + if not_ready: + logger.warning("entrypoint の完了を確かめられないため処理しません: %s " + "(起動が終わってから、もう一度打ちます)", + ', '.join(f"{dev_service_name}-{i}" for i in not_ready)) + if not targets: + logger.error("起動の後の処理を行えるインスタンスがありません") + return 1 + + compose_file = _SCALE_COMPOSE_FILE if _SCALE_COMPOSE_FILE.exists() else None + _run_post_start(project_name, scale, targets, dev_service_name, config, compose_file, + run_deploy=False) + logger.info("=== Post-start completed ===") + return 0 + + # --------------------------------------------------------------------------- # cmd_ps # --------------------------------------------------------------------------- @@ -1616,8 +1773,8 @@ def _run_scale_pipeline(project_name: str, new_scale: int, current_scale: int, ``cmd_up`` の :func:`_run_deploy_pipeline` と対称の段 (PLAN65 決定 8)。``scale`` は 既存のコンテナを止めず、退避も取らない。起動が 0 以外で終わったときだけ ``Failed to start new containers`` を出して ``None`` を返す。それ以外の失敗は - ``DevbaseError`` / ``DockerError`` のまま伝播する。後処理 (bao の token・``./deploy``) - は ``cmd_scale`` の本体が行う。 + ``DevbaseError`` / ``DockerError`` のまま伝播する (起動できなかったインスタンスがあれば + ``ContainerStartupError``)。起動の後の処理は ``cmd_scale`` の本体が行う。 """ logger.info("[1/5] Updating %s: scale=%d -> %d...", project_runtime.PROJECT_CONFIG_FILENAME, current_scale, new_scale) @@ -1686,21 +1843,31 @@ def cmd_scale(new_scale: int, project_name: str = None, return 1 try: - override_file = _run_scale_pipeline(project_name, new_scale, current_scale, - config, target, dev_service_name) - if override_file is None: + startup_error = None + try: + override_file = _run_scale_pipeline(project_name, new_scale, current_scale, + config, target, dev_service_name) + if override_file is None: + return 1 + started = list(range(1, new_scale + 1)) + except ContainerStartupError as e: + startup_error = e + override_file = _SCALE_COMPOSE_FILE + started = list(e.ready) + + # 後処理の対象は増やしたインスタンスのうち起動できたもの。既存のものは up か + # 前の scale で済んでいる (#224) + targets = [i for i in started if i > current_scale] + if startup_error is not None: + _warn_partial_start(startup_error, targets, dev_service_name) + + _run_post_start(project_name, new_scale, targets, dev_service_name, config, + override_file) + + if startup_error is not None: + _report_startup_failure('Scale', startup_error, project_name) return 1 - # 増やしたインスタンスにも bao の token を書く (PLAN54。既存のものは up で書いてある) - _push_bao_token(project_name, new_scale, dev_service_name, compose_file=override_file, - start=current_scale + 1) - - # Run project-specific deploy script for newly added instances - deploy_script = Path('./deploy') - if deploy_script.exists() and deploy_script.is_file(): - _run_deploy_script_for_instances( - deploy_script, range(current_scale + 1, new_scale + 1), config) - logger.info("=== Scale completed successfully ===") logger.info("Container scale: %d -> %d", current_scale, new_scale) logger.info("You can now login to the new containers:") diff --git a/tests/commands/test_container_post_start.py b/tests/commands/test_container_post_start.py new file mode 100644 index 00000000..ee3a610e --- /dev/null +++ b/tests/commands/test_container_post_start.py @@ -0,0 +1,548 @@ +"""起動の後の処理を ``up`` / ``scale`` / ``project post-start`` で共有する (#224・#371)。 + +- ``scale`` は増やしたインスタンスへ、``up`` と同じ並び (不足リポジトリの報告 → ``./deploy`` → + token の配布 → 窓のタイトルの設定) を行う +- 起動できなかったインスタンスがあっても、起動できたインスタンス (``scale`` は増やしたもののうち + 起動できたもの) へ後処理を行い、名前・理由・補う手順を出して 1 で終える +- ``devbase project post-start`` は、動いていて entrypoint の完了を確かめられたインスタンスへ、 + ``./deploy`` を除く 3 つをやり直す + +実 docker と実 ``DEVBASE_ROOT`` には触れない。``DEVBASE_ROOT`` は tmp へ向け、backend は +偽の ``openbao`` の置き場に差し替える。docker は ``subprocess.run`` を差し替えて答える。 +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from devbase.commands import container +from devbase.editor import opener, window_title +from devbase.env import container_token +from devbase.utils import docker_context as dc +from devbase.utils.docker import ContainerStartupError, StartupFailure + +LOCAL = dc.DockerTarget(context=None, source='none', remote=False, home=None, gid=None) +TOKEN = 'hvs.SECRET-TOKEN-VALUE' +POST_START_LINE = 'devbase project post-start proj' + + +def _project_yml(scale: int) -> str: + return (f"version: 1\nscale: {scale}\nrepos:\n" + " - owner: example-org\n repo: myapp\n" + " - owner: example-org\n repo: lib\n") + + +class FakeStore: + class _Backend: + def issue_token(self): + return TOKEN + + def backend_for(self, ref): + return self._Backend() + + +class FakeDocker: + """``subprocess.run`` の代わり。docker への呼び出しと ``./deploy`` を記録して答える。""" + + def __init__(self, events: list): + self.events = events + self.work: dict = {} # 番号 -> /work の一覧 (無ければ揃っている) + self.running: list = [] # docker ps の (番号, コンテナ名) + self.ps_returncode = 0 + self.not_ready: set = set() # 完了の印が無いコンテナ名 + self.services = ['dev-1'] + + def __call__(self, cmd, **kwargs): + cmd = list(cmd) + self.events.append(('run', cmd)) + if cmd == ['bash', 'deploy']: + self.events.append(('deploy', int(kwargs['env']['DEVBASE_INSTANCE_INDEX']))) + return subprocess.CompletedProcess(cmd, 0, '', '') + if 'config' in cmd and '--services' in cmd: + return subprocess.CompletedProcess(cmd, 0, '\n'.join(self.services) + '\n', '') + if cmd[:2] == ['docker', 'ps']: + out = ''.join(f'proj-dev-{i}\tdev-{i}\n' for i, _name in self.running) + return subprocess.CompletedProcess(cmd, self.ps_returncode, out, 'daemon down') + if cmd[:2] == ['docker', 'exec'] and cmd[-3:] == ['test', '-f', '/tmp/entrypoint-ready']: + self.events.append(('ready?', cmd[2])) + return subprocess.CompletedProcess(cmd, 1 if cmd[2] in self.not_ready else 0, '', '') + if 'ls' in cmd and '/work' in cmd: + service = cmd[cmd.index('ls') - 1] + index = int(service.rsplit('-', 1)[1]) + self.events.append(('repos', index)) + listing = self.work.get(index, ['myapp', 'lib']) + return subprocess.CompletedProcess(cmd, 0, '\n'.join(listing) + '\n', '') + return subprocess.CompletedProcess(cmd, 0, '', '') + + +@pytest.fixture +def harness(tmp_path, monkeypatch, caplog): + caplog.set_level('INFO') + root = tmp_path / 'root' + project = root / 'projects' / 'proj' + project.mkdir(parents=True) + monkeypatch.setenv('DEVBASE_ROOT', str(root)) + monkeypatch.chdir(project) + for name in ('DOCKER_CONTEXT', 'DOCKER_HOST', 'DEVBASE_DOCKER_CONTEXT', + 'DEVBASE_WINDOW_TITLE', 'DEVBASE_OPEN_INDEX', 'DEVBASE_OPEN_EDITOR'): + monkeypatch.delenv(name, raising=False) + dc.reset() + + events: list = [] + fake = FakeDocker(events) + monkeypatch.setattr(subprocess, 'run', fake) + + monkeypatch.setattr(container, 'get_project_name', lambda: 'proj') + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') + monkeypatch.setattr(container, '_require_group_declaration', lambda project=None: True) + monkeypatch.setattr(container, '_resolve_docker_target', lambda context=None: LOCAL) + monkeypatch.setattr(container, '_apply_context', lambda context=None: None) + for name in ('_ensure_env_files', '_run_pre_up_hook', '_ensure_images'): + monkeypatch.setattr(container, name, lambda *a, **k: True) + monkeypatch.setattr(container, '_auto_snapshot', lambda *a, **k: None) + monkeypatch.setattr(container, '_inject_secrets', lambda *, required: None) + monkeypatch.setattr(container, 'ensure_volumes', lambda *a, **k: None) + monkeypatch.setattr(container, 'ensure_network', lambda *a, **k: None) + monkeypatch.setattr(container, 'docker_compose_down', + lambda compose_file=None: events.append(('down', None))) + monkeypatch.setattr(container, 'docker_compose_up', + lambda **k: events.append(('up', None))) + monkeypatch.setattr(container, 'default_services', lambda *a, **k: ['dev-1']) + monkeypatch.setattr(container, '_openbao_store', lambda: FakeStore()) + + def build(scale, config, project_name, target): + container._SCALE_COMPOSE_FILE.write_text('services: {}\n') + return container._SCALE_COMPOSE_FILE + + monkeypatch.setattr(container, '_build_scaled_override', build) + + wait = {'error': None} + + def fake_wait(**kwargs): + events.append(('wait', kwargs['scale'])) + if wait['error'] is not None: + raise wait['error'] + return True + + monkeypatch.setattr(container, 'wait_for_containers_ready', fake_wait) + monkeypatch.setattr(opener, '_query_container_name', + lambda dev, index, compose_file=None, runner=None: f'real-{dev}-{index}') + monkeypatch.setattr(container_token, 'push', + lambda names, token, runner=None: + events.append(('token', list(names), token)) or list(names)) + monkeypatch.setattr(window_title, 'apply_to_container', + lambda name, template=None, **k: events.append(('title', name, template))) + monkeypatch.setattr(container, '_open_editor_at', + lambda project_name, index, config, **k: + events.append(('editor', index)) or 'launch') + + def setup(scale=2, deploy=False): + (project / 'project.yml').write_text(_project_yml(scale)) + if deploy: + (project / 'deploy').write_text('#!/bin/sh\n') + + def fail(ready, *failures): + wait['error'] = ContainerStartupError( + ready, [StartupFailure(i, f'dev-{i}', reason, logs) for i, reason, logs in failures]) + + return {'events': events, 'fake': fake, 'setup': setup, 'fail': fail, + 'project': project} + + +def _hooks(events): + """インスタンスごとの処理の呼び出しを (処理, 番号) で並べる。""" + out = [] + for event in events: + kind = event[0] + if kind in ('repos', 'deploy'): + out.append((kind, event[1])) + elif kind == 'token': + out.extend(('token', int(name.rsplit('-', 1)[1])) for name in event[1]) + elif kind == 'title': + out.append(('title', int(event[1].rsplit('-', 1)[1]))) + return out + + +def _indices(events, kind): + return [i for k, i in _hooks(events) if k == kind] + + +def _messages(caplog): + return [r.getMessage() for r in caplog.records] + + +ORDER = ['repos', 'deploy', 'token', 'title'] + + +# --------------------------------------------------------------------------- +# #224: scale で増やしたインスタンスの後処理 +# --------------------------------------------------------------------------- + +def test_scale_sets_window_titles_on_the_added_instances(harness): + """受け入れ条件 1・2: dev-2 と dev-3 に実コンテナ名始まりのタイトル。dev-1 には何もしない""" + harness['setup'](scale=1, deploy=True) + + assert container.cmd_scale(3) == 0 + + titles = [(name, template) for kind, name, template in + (e for e in harness['events'] if e[0] == 'title')] + assert [name for name, _t in titles] == ['real-dev-2', 'real-dev-3'] + for name, template in titles: + assert window_title.render(template, name).startswith(name) + assert all(i != 1 for _k, i in _hooks(harness['events'])) + + +def test_scale_reports_missing_repos_like_up(harness, caplog): + """受け入れ条件 3: 増やした dev-3 の /work に無いリポジトリを up と同じ文言で出す""" + harness['setup'](scale=1) + harness['fake'].work[3] = ['myapp'] + + assert container.cmd_scale(3) == 0 + + messages = _messages(caplog) + assert 'Repositories missing in /work of dev-3 (clone may have failed):' in messages + assert any(m.strip().startswith('- lib (') for m in messages) + assert not any('of dev-2' in m or 'of dev-1' in m for m in messages) + + +def test_scale_without_missing_repos_says_nothing(harness, caplog): + """受け入れ条件 4""" + harness['setup'](scale=1) + + assert container.cmd_scale(3) == 0 + + assert not any('Repositories missing' in m for m in _messages(caplog)) + assert _indices(harness['events'], 'repos') == [2, 3] + + +def test_scale_window_title_off_sets_no_title(harness, monkeypatch): + """受け入れ条件 5""" + harness['setup'](scale=1) + monkeypatch.setenv('DEVBASE_WINDOW_TITLE', 'off') + + assert container.cmd_scale(3) == 0 + + assert _indices(harness['events'], 'title') == [] + + +def test_scale_runs_the_hooks_in_the_up_order(harness): + """受け入れ条件 6・7・I5: scale (1→3) と up (台数 2) は同じ処理を同じ順で行う""" + harness['setup'](scale=1, deploy=True) + assert container.cmd_scale(3) == 0 + scale_hooks = _hooks(harness['events']) + assert scale_hooks == [(k, i) for k in ORDER for i in (2, 3)] + + harness['events'].clear() + harness['setup'](scale=2, deploy=True) + assert container.cmd_up() == 0 + up_hooks = _hooks(harness['events']) + assert ([k for k, i in up_hooks if i == 2] == [k for k, i in scale_hooks if i == 2] + == ORDER) + + +def test_scale_success_output_is_unchanged(harness, caplog): + """受け入れ条件 8""" + harness['setup'](scale=1) + + assert container.cmd_scale(3) == 0 + + messages = _messages(caplog) + assert '=== Scale completed successfully ===' in messages + assert ' devbase login 2' in messages and ' devbase login 3' in messages + assert not any(e[0] == 'editor' for e in harness['events']) + + +# --------------------------------------------------------------------------- +# #371: 起動できなかったインスタンスがあるとき +# --------------------------------------------------------------------------- + +def test_up_all_ready_keeps_the_order_and_opens_the_editor(harness, caplog): + """受け入れ条件 9""" + harness['setup'](scale=2, deploy=True) + + assert container.cmd_up(open_editor=True) == 0 + + assert _hooks(harness['events']) == [(k, i) for k in ORDER for i in (1, 2)] + assert [e for e in harness['events'] if e[0] == 'editor'] == [('editor', 1)] + assert '=== Deploy completed successfully ===' in _messages(caplog) + + +def test_up_with_a_failed_instance_post_starts_the_ready_one(harness, caplog): + """受け入れ条件 A1・10・11・14: dev-1 に 4 つとも行い、dev-2 には行わず、1 で終える""" + harness['setup'](scale=2, deploy=True) + harness['fail']([1], (2, 'exited', 'ln: Already exists')) + + assert container.cmd_up() == 1 + + events = harness['events'] + assert _hooks(events) == [(k, 1) for k in ORDER] + assert [e for e in events if e[0] == 'deploy'] == [('deploy', 1)] + text = caplog.text + assert 'Deploy failed' in text + assert ' - dev-2: exited unexpectedly' in text and 'ln: Already exists' in text + assert '=== Deploy completed successfully ===' not in text + + +def test_up_failure_report_names_every_failed_instance(harness, caplog): + """受け入れ条件 12・13""" + harness['setup'](scale=3) + harness['fail']([1], (2, 'exited', ''), (3, 'timeout', '')) + + assert container.cmd_up() == 1 + + assert 'dev-2: exited unexpectedly' in caplog.text + assert 'dev-3: timeout' in caplog.text + + +def test_failure_output_gives_the_post_start_command_on_its_own_line(harness, caplog): + """受け入れ条件 15・B7: コマンドだけの行と、docker start では行われない旨の文""" + harness['setup'](scale=2) + harness['fail']([1], (2, 'exited', '')) + + assert container.cmd_up() == 1 + + messages = [m.strip() for m in _messages(caplog)] + assert POST_START_LINE in messages + assert any('docker start' in m and '行われません' in m for m in messages) + # 報告は出力の最後に置く (決定 12) + last_hook = max(i for i, r in enumerate(caplog.records) if 'bao の token' in r.getMessage()) + assert messages.index(POST_START_LINE) > last_hook + + +@pytest.mark.parametrize('command', ['up', 'scale']) +def test_failure_never_stops_or_removes_containers(harness, command): + """受け入れ条件 16・I8: 起動の待ちより後に down / stop / rm を呼ばない""" + harness['setup'](scale=1 if command == 'scale' else 2) + harness['fail']([1], (2, 'exited', '')) + + assert (container.cmd_up() if command == 'up' else container.cmd_scale(2)) == 1 + + events = harness['events'] + after = events[next(i for i, e in enumerate(events) if e[0] == 'wait'):] + assert not any(e[0] in ('down', 'up') for e in after) + assert not any(e[0] == 'run' and {'down', 'stop', 'rm', 'start'} & set(e[1]) + for e in after) + + +def test_scale_with_a_failed_added_instance_post_starts_the_other(harness, caplog): + """受け入れ条件 17・A5: dev-2 にだけ行い、dev-1 と dev-3 には行わない""" + harness['setup'](scale=1, deploy=True) + harness['fail']([1, 2], (3, 'exited', '')) + + assert container.cmd_scale(3) == 1 + + assert _hooks(harness['events']) == [(k, 2) for k in ORDER] + text = caplog.text + assert 'Scale failed' in text and 'dev-3' in text + assert '=== Scale completed successfully ===' not in text + + +def test_scale_with_a_failed_existing_instance_fails(harness, caplog): + """受け入れ条件 18: 既存の dev-1 が落ちても 1 で終わり、名前が出る。増やした番号へは行う""" + harness['setup'](scale=1) + harness['fail']([2, 3], (1, 'exited', '')) + + assert container.cmd_scale(3) == 1 + + assert 'dev-1: exited unexpectedly' in caplog.text + assert sorted({i for _k, i in _hooks(harness["events"])}) == [2, 3] + + +def test_up_with_no_ready_instance_does_nothing(harness, caplog): + """受け入れ条件 A4・I6: 処理もエディタも行わず、token も発行しない""" + harness['setup'](scale=2, deploy=True) + harness['fail']([], (1, 'exited', ''), (2, 'exited', '')) + + assert container.cmd_up(open_editor=True) == 1 + + assert _hooks(harness['events']) == [] + assert not any(e[0] == 'editor' for e in harness['events']) + first = next(m for m in _messages(caplog) if m.startswith('起動できなかったインスタンス')) + assert '行いません' in first and '続けます' not in first + + +def test_scale_with_only_the_added_instance_failed_does_nothing(harness, caplog): + """受け入れ条件 A4・I6: 既存の dev-1 は起動できていても処理先に入れない""" + harness['setup'](scale=1, deploy=True) + harness['fail']([1], (2, 'exited', '')) + + assert container.cmd_scale(2) == 1 + + assert _hooks(harness['events']) == [] + first = next(m for m in _messages(caplog) if m.startswith('起動できなかったインスタンス')) + assert 'dev-1' not in first and '行いません' in first + + +@pytest.mark.parametrize('command, scale, new_scale, ready, failed, target', [ + ('up', 2, None, [1], [2], 'dev-1'), + ('scale', 1, 3, [1, 2], [3], 'dev-2'), +]) +def test_first_warning_names_only_the_targets(harness, caplog, command, scale, new_scale, + ready, failed, target): + """出力の契約: 1 行目の処理先の名前は後処理の対象と一致する""" + harness['setup'](scale=scale) + harness['fail'](ready, *[(i, 'exited', '') for i in failed]) + + assert (container.cmd_up() if command == 'up' else container.cmd_scale(new_scale)) == 1 + + first = next(m for m in _messages(caplog) if m.startswith('起動できなかったインスタンス')) + head, _sep, tail = first.partition('。') + assert [f'dev-{i}' for i in failed] == [n.strip() for n in head.split(':')[1].split(',')] + assert tail.startswith(f'{target} へ') + + +def test_up_does_not_open_the_editor_on_a_failed_index(harness, caplog): + """受け入れ条件 A3・I9: 開く番号が落ちたら開かず、ほかの番号へ替えない""" + harness['setup'](scale=2) + harness['fail']([1], (2, 'exited', '')) + + assert container.cmd_up(open_editor=True, open_index=2) == 1 + + assert not any(e[0] == 'editor' for e in harness['events']) + assert any('dev-2' in r.getMessage() for r in caplog.records + if r.levelname == 'WARNING' and 'エディタ' in r.getMessage()) + + +def test_up_opens_the_editor_when_its_index_is_ready(harness): + """受け入れ条件 A3: 開く番号が起動できていれば、ほかが落ちていても開く""" + harness['setup'](scale=2) + harness['fail']([1], (2, 'exited', '')) + + assert container.cmd_up(open_editor=True) == 1 + + assert [e for e in harness['events'] if e[0] == 'editor'] == [('editor', 1)] + + +def test_timeout_post_starts_the_instances_ready_so_far(harness): + """受け入れ条件 A6""" + harness['setup'](scale=3) + harness['fail']([1, 3], (2, 'timeout', '')) + + assert container.cmd_up() == 1 + + assert sorted({i for _k, i in _hooks(harness["events"])}) == [1, 3] + + +def test_token_goes_only_to_the_targets_and_is_never_logged(harness, caplog): + """I11""" + harness['setup'](scale=2) + harness['fail']([1], (2, 'exited', '')) + + assert container.cmd_up() == 1 + + tokens = [e for e in harness['events'] if e[0] == 'token'] + assert tokens == [('token', ['real-dev-1'], TOKEN)] + assert TOKEN not in caplog.text + assert not any(TOKEN in arg for e in harness['events'] if e[0] == 'run' for arg in e[1]) + + +# --------------------------------------------------------------------------- +# devbase project post-start (やり直し) +# --------------------------------------------------------------------------- + +def _running(harness, *indices): + harness['fake'].running = [(i, f'proj-dev-{i}') for i in indices] + + +def test_post_start_reapplies_to_running_ready_instances(harness, caplog): + """受け入れ条件 B1・B6: 両方へタイトルと token。不足を up と同じ文言で。./deploy は走らない""" + harness['setup'](scale=2, deploy=True) + _running(harness, 1, 2) + harness['fake'].work[2] = ['myapp'] + + assert container.cmd_post_start() == 0 + + assert _hooks(harness['events']) == [(k, i) for k in ('repos', 'token', 'title') + for i in (1, 2)] + assert 'Repositories missing in /work of dev-2 (clone may have failed):' in _messages(caplog) + assert '=== Post-start completed ===' in _messages(caplog) + assert not any(e[0] == 'editor' for e in harness['events']) + + +def test_post_start_twice_writes_the_same(harness): + """受け入れ条件 B5: 2 回目に渡るタイトルと token の書き込みが 1 回目と同じ""" + harness['setup'](scale=2) + _running(harness, 1, 2) + + assert container.cmd_post_start() == 0 + first = [e for e in harness['events'] if e[0] in ('token', 'title')] + harness['events'].clear() + assert container.cmd_post_start() == 0 + second = [e for e in harness['events'] if e[0] in ('token', 'title')] + + assert first == second + + +def test_post_start_skips_instances_not_running(harness, caplog): + """受け入れ条件 B3""" + harness['setup'](scale=3) + _running(harness, 1, 3) + + assert container.cmd_post_start() == 0 + + assert sorted({i for _k, i in _hooks(harness["events"])}) == [1, 3] + assert any('dev-2' in r.getMessage() for r in caplog.records if r.levelname == 'WARNING') + + +def test_post_start_skips_instances_without_the_ready_mark(harness, caplog): + """I4・前提 4: 完了の印が無い dev-2 には token も書かず、確かめは 1 台 1 回""" + harness['setup'](scale=3) + _running(harness, 1, 2, 3) + harness['fake'].not_ready = {'proj-dev-2'} + + assert container.cmd_post_start() == 0 + + assert sorted({i for _k, i in _hooks(harness["events"])}) == [1, 3] + checks = [e[1] for e in harness['events'] if e[0] == 'ready?'] + assert checks == ['proj-dev-1', 'proj-dev-2', 'proj-dev-3'] + warnings = [r.getMessage() for r in caplog.records if r.levelname == 'WARNING'] + assert any('dev-2' in m and 'entrypoint' in m for m in warnings) + + +def test_post_start_without_any_ready_mark_fails(harness): + harness['setup'](scale=2) + _running(harness, 1, 2) + harness['fake'].not_ready = {'proj-dev-1', 'proj-dev-2'} + + assert container.cmd_post_start() == 1 + + assert _hooks(harness['events']) == [] + assert not any(e[0] == 'token' for e in harness['events']) + + +def test_post_start_with_nothing_running_does_not_start(harness, caplog): + """受け入れ条件 B4・I8""" + harness['setup'](scale=2) + + assert container.cmd_post_start() == 1 + + assert _hooks(harness['events']) == [] + assert not any(e[0] in ('up', 'down') for e in harness['events']) + assert not any(e[0] == 'run' and {'up', 'start', 'down', 'stop', 'rm'} & set(e[1]) + for e in harness['events']) + assert 'devbase up' in caplog.text + + +def test_post_start_fails_when_the_state_is_unknown(harness): + harness['setup'](scale=2) + _running(harness, 1) + harness['fake'].ps_returncode = 1 + + assert container.cmd_post_start() == 1 + + assert _hooks(harness['events']) == [] + + +def test_post_start_uses_the_scale_compose_file(harness): + harness['setup'](scale=1) + _running(harness, 1) + Path(container._SCALE_COMPOSE_FILE).write_text('services: {}\n') + + assert container.cmd_post_start() == 0 + + ls = [e[1] for e in harness['events'] if e[0] == 'run' and 'ls' in e[1]] + assert ls and all(str(container._SCALE_COMPOSE_FILE) in cmd for cmd in ls) + diff --git a/tests/commands/test_container_scale_order.py b/tests/commands/test_container_scale_order.py index 61813521..bc151d58 100644 --- a/tests/commands/test_container_scale_order.py +++ b/tests/commands/test_container_scale_order.py @@ -265,17 +265,23 @@ def _step(name, payload): return 'run:' + ' '.join(cmd[2:]) -def test_scale_runs_the_steps_in_order(scale_harness): +def test_scale_runs_the_steps_in_order(scale_harness, monkeypatch): """C-1: グループの検査 → write_scale → ボリューム → network → 生成 → 既定のサービス → 起動 - → ready 待ち → bao → ./deploy。""" + → ready 待ち → 起動の後の処理 (不足リポジトリの報告 → ./deploy → bao → 窓のタイトル。 + up と同じ順。#224)。""" (scale_harness['root'] / 'deploy').write_text('#!/bin/sh\n') + calls = scale_harness['calls'] + monkeypatch.setattr(container, '_report_missing_repos', + lambda *a, **k: calls.append(('repos', k['indices']))) + monkeypatch.setattr(container, '_apply_window_titles', + lambda *a, **k: calls.append(('titles', k['indices']))) assert container.cmd_scale(3) == 0 steps = [_step(name, payload) for name, payload in scale_harness['calls']] assert [s for s in steps if s != 'config'] == [ 'group', 'write_scale', 'volumes', 'network', 'generate', 'default_services', - 'up', 'wait', 'bao', 'deploy'] + 'up', 'wait', 'repos', 'deploy', 'bao', 'titles'] # 既定のサービスの解決 (config --services) は生成の後、起動の前に行う assert steps.index('generate') < steps.index('config') < steps.index('up') up = _up_calls(scale_harness['calls'])[0] @@ -299,14 +305,16 @@ def test_scale_passes_the_generated_compose_and_new_scale(scale_harness): def test_scale_hooks_cover_only_the_new_instances(scale_harness): - """C-2: bao の token と ./deploy は current + 1 から new まで。既存のインスタンスを含めない。""" + """C-2: bao の token と ./deploy は current + 1 から new まで。既存のインスタンスを含めない。 + + 対象は番号の並びで渡す (起動の後の処理の段。#224)。""" (scale_harness['root'] / 'deploy').write_text('#!/bin/sh\n') assert container.cmd_scale(4) == 0 calls = dict((name, payload) for name, payload in scale_harness['calls'] if name != 'run') assert calls['bao'] == {'args': ('proj', 4, 'dev'), - 'compose_file': scale_harness['override'], 'start': 2} + 'compose_file': scale_harness['override'], 'indices': [2, 3, 4]} assert calls['deploy'] == [2, 3, 4] diff --git a/tests/utils/test_wait_for_containers.py b/tests/utils/test_wait_for_containers.py index b99f6544..69eeac8a 100644 --- a/tests/utils/test_wait_for_containers.py +++ b/tests/utils/test_wait_for_containers.py @@ -90,8 +90,8 @@ def test_all_ready_returns_true(run): def test_exited_instances_are_all_reported_with_logs(run): """受け入れ条件 12・11: 最初の巡で 2 台が終了していれば、両方の名前・理由・ログが入る""" - script, error = run({1: ready_at(0), 2: exited_at(0), 3: exited_at(0)}, - logs={2: 'ln: Already exists', 3: 'boom'}) + _script, error = run({1: ready_at(0), 2: exited_at(0), 3: exited_at(0)}, + logs={2: 'ln: Already exists', 3: 'boom'}) assert isinstance(error, DockerError) and isinstance(error, DevbaseError) assert error.ready == (1,) @@ -126,7 +126,7 @@ def test_timeout_names_the_instances_not_ready(run): def test_missing_container_is_not_found(run): - script, error = run({1: missing, 2: ready_at(0)}) + _script, error = run({1: missing, 2: ready_at(0)}) assert error.ready == (2,) assert [(f.index, f.reason) for f in error.failures] == [(1, 'not_found')] @@ -136,8 +136,8 @@ def test_missing_container_is_not_found(run): def test_every_index_lands_on_exactly_one_side(run): """I1: 待った番号は、起動できた側とできなかった側のどちらか一方にだけ現れる""" - script, error = run({1: ready_at(0), 2: exited_at(1), 3: never_ready, 4: missing, - 5: ready_at(2)}, timeout=4) + _script, error = run({1: ready_at(0), 2: exited_at(1), 3: never_ready, 4: missing, + 5: ready_at(2)}, timeout=4) failed = [f.index for f in error.failures] assert sorted(list(error.ready) + failed) == [1, 2, 3, 4, 5] @@ -146,8 +146,8 @@ def test_every_index_lands_on_exactly_one_side(run): def test_decided_instances_are_not_asked_again(run): """I3: 1 巡の呼び出しは番号あたり状態 1 回と完了 1 回まで。決まった番号は問い合わせない""" - script, error = run({1: ready_at(0), 2: exited_at(0), 3: ready_at(3)}, - logs={2: 'x'}) + script, _error = run({1: ready_at(0), 2: exited_at(0), 3: ready_at(3)}, + logs={2: 'x'}) per_round: dict = {} for rnd, kind, index in script.calls: @@ -159,7 +159,7 @@ def test_decided_instances_are_not_asked_again(run): def test_rounds_do_not_exceed_the_limit(run): - script, error = run({1: never_ready}, timeout=7) + script, _error = run({1: never_ready}, timeout=7) assert script.round == 7 assert max(rnd for rnd, _k, _i in script.calls) == 6 From b20fa2bcbab48652a23013acd89de987b32d5a9d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 2 Oct 2026 15:40:25 +0900 Subject: [PATCH 06/17] =?UTF-8?q?feat(project):=20project=20post-start=20?= =?UTF-8?q?=E3=82=92=E7=99=BB=E9=8C=B2=E3=81=99=E3=82=8B=20(#371)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit project グループへ post-start [name] [--context NAME] を足す。前方一致・ラッパーの [name] の 解決・入口の振り分け・bash / zsh の補完に入れる。トップレベルの短縮形と container グループ、 TUI には足さない(設計の決定 2)。 Co-Authored-By: Claude Opus 5.5 --- bin/devbase | 6 +- etc/_devbase | 6 ++ etc/devbase-completion.bash | 10 ++- lib/devbase/cli.py | 13 ++- lib/devbase/commands/container.py | 1 + tests/cli/test_post_start_command.py | 124 +++++++++++++++++++++++++++ 6 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 tests/cli/test_post_start_command.py diff --git a/bin/devbase b/bin/devbase index ffd706df..b1b4120d 100755 --- a/bin/devbase +++ b/bin/devbase @@ -454,7 +454,7 @@ fi # トップレベルシノニム -> $2 # # 重要 (PLAN06 codex 指摘対応): `project` グループでは parser が `name` positional を -# 持つサブコマンド (`up`/`down`/`ps`/`logs`/`scale`/`rebuild`/`open`) に限定して $3 を +# 持つサブコマンド (`up`/`down`/`ps`/`logs`/`scale`/`rebuild`/`open`/`post-start`) に限定して $3 を # name 解決する。`project login` / `project build` は単一 positional が index / image # (旧 container 互換) であり parser が name を受け付けない (cli.py の # _add_login_subparser / _add_build_subparser 参照)。これらで $3 を name strip すると、 @@ -474,14 +474,14 @@ _DEVBASE_ARGS=("${@:2}") # 同期注意 (メンテナンス性): 下記 2 リストは cli.py の parser 定義に対応する。 # _PROJECT_NAME_SUBCOMMANDS = `project` で `name` positional を受け付けるサブコマンド # 集合。cli.py の _add_project_parser で `add_argument('name', ...)` を持つもの -# (up/down/ps/logs/scale/rebuild/open) と一致させる。login/build は index/image +# (up/down/ps/logs/scale/rebuild/open/post-start) と一致させる。login/build は index/image # 互換のため意図的に除外 (上のコメント参照)。 # _NAME_RESOLVABLE_SHORTCUTS = トップレベルシノニムのうち「実在 project なら cd」 # を許すもの。cli.py の SHORTCUTS 経由で project サブコマンドへ写像される # 集合 + shell 実装の build を含む。 # cli.py 側でサブコマンドを追加/削除した際は両リストの更新漏れに注意すること # (cli.py の _add_project_parser / SHORTCUTS にも対の注記あり)。 -_PROJECT_NAME_SUBCOMMANDS=" up down ps logs scale rebuild open " +_PROJECT_NAME_SUBCOMMANDS=" up down ps logs scale rebuild open post-start " _NAME_RESOLVABLE_SHORTCUTS=" up down ps scale login build rebuild open " case "$_resolved_cmd" in project) diff --git a/etc/_devbase b/etc/_devbase index 619be6e8..c7a850dc 100644 --- a/etc/_devbase +++ b/etc/_devbase @@ -84,6 +84,7 @@ _devbase() { 'list:List projects (NAME / PLUGIN / STATUS)' 'profile:Start / stop compose profile services' 'open:Open the editor attached to the dev container' + 'post-start:Redo the post-start steps on running instances' ) container_subcommands=( @@ -223,6 +224,11 @@ _devbase() { '--context[Docker context]:context:' \ '1:name:_devbase_project_names' ;; + post-start) + _arguments \ + '--context[Docker context]:context:' \ + '1:name:_devbase_project_names' + ;; *) _describe -t project-commands 'project command' project_subcommands ;; diff --git a/etc/devbase-completion.bash b/etc/devbase-completion.bash index 77b61505..7d44f10f 100644 --- a/etc/devbase-completion.bash +++ b/etc/devbase-completion.bash @@ -33,7 +33,7 @@ _devbase_completions() { local commands="init status shell-rc project container ct env plugin pl snapshot ss up down login build rebuild ps scale open list help" # project / container は同じサブコマンド群 (container は非推奨だが補完は維持)。 - local project_subcommands="up down ps login logs scale build rebuild list profile open" + local project_subcommands="up down ps login logs scale build rebuild list profile open post-start" local container_subcommands="up down ps login logs scale build rebuild profile open" local env_subcommands="init sync list set get delete edit project export import keygen exec encrypt decrypt rekey doctor" local plugin_subcommands="list install uninstall update info sync repo" @@ -144,6 +144,14 @@ _devbase_completions() { COMPREPLY=($(compgen -W "$(_devbase_project_names)" -- "$cur")) fi ;; + # post-start は [name] と --context を取る (#371) + post-start) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--context" -- "$cur")) + else + COMPREPLY=($(compgen -W "$(_devbase_project_names)" -- "$cur")) + fi + ;; esac fi # container subcommand arguments (非推奨: project へ移行してください) diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index a5e1bade..7a6e5cc0 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -59,7 +59,7 @@ # Subcommand map for prefix resolution: {(aliases...): [subcmds]} SUBCMD_MAP = { ('project',): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build', 'rebuild', 'list', - 'profile', 'open'], + 'profile', 'open', 'post-start'], ('container', 'ct'): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build', 'rebuild', 'profile', 'open'], ('env',): ['init', 'sync', 'list', 'set', 'get', 'delete', 'edit', 'project', 'keygen', @@ -284,7 +284,8 @@ def _add_project_parser(subparsers): ため name を受け付けない。両者は project / container で定義が完全に一致するので `_add_login_subparser` / `_add_build_subparser` に共通化している。 - 同期注意: ここで `name` positional を持つサブコマンド集合 (up/down/ps/logs/scale/rebuild/open) + 同期注意: ここで `name` positional を持つサブコマンド集合 (up/down/ps/logs/scale/rebuild/open/ + post-start) は bin/devbase の `_PROJECT_NAME_SUBCOMMANDS` と一致させる必要がある。追加/削除時は wrapper 側リストの更新漏れに注意すること。wrapper がこの集合で name を解決するのは `project` グループだけで、`container` / `ct` は `[name]` を持たず解決も通らない @@ -334,6 +335,14 @@ def _add_project_parser(subparsers): # `open` の `[name]` は up と同じく wrapper の _PROJECT_NAME_SUBCOMMANDS で cd する (PLAN59)。 _add_open_subparser(pj_sub, with_name=True) + # `post-start` は動いているインスタンスへ起動の後の処理をやり直す (#371)。`[name]` は + # up と同じく wrapper の _PROJECT_NAME_SUBCOMMANDS で cd する。失敗の後にだけ打つ + # コマンドのため、トップレベルの短縮形と `container` グループには足さない (決定 2)。 + _add_context_arg(_add_name_arg(pj_sub.add_parser( + 'post-start', + help='Redo the post-start steps (missing repos, bao token, window title) ' + 'on running instances without restarting them'))) + # `list` は lifecycle ではなく一覧表示 (commands/project.py)。name positional は # 取らない (wrapper の _PROJECT_NAME_SUBCOMMANDS にも含めない)。 _add_list_subparser(pj_sub) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 8e18b4b0..70b6714c 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -736,6 +736,7 @@ def _dispatch_lifecycle(args) -> int: 'open': lambda: cmd_open(project_name=project_name, open_index=getattr(args, 'open_index', None), **ctx), + 'post-start': lambda: cmd_post_start(project_name=project_name, **ctx), } handler = handlers.get(subcmd) diff --git a/tests/cli/test_post_start_command.py b/tests/cli/test_post_start_command.py new file mode 100644 index 00000000..07eccc49 --- /dev/null +++ b/tests/cli/test_post_start_command.py @@ -0,0 +1,124 @@ +"""``devbase project post-start [name]`` の登録 (#371 決定 2)。 + +``project`` グループだけに置き、トップレベルの短縮形と ``container`` グループには置かない。 +ラッパーの ``[name]`` の解決と、bash / zsh の補完に現れる。 +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from devbase import cli + +REPO_ROOT = Path(__file__).resolve().parents[2] +WRAPPER = REPO_ROOT / "bin" / "devbase" +BASH_COMPLETION = REPO_ROOT / "etc" / "devbase-completion.bash" +ZSH_COMPLETION = REPO_ROOT / "etc" / "_devbase" + + +def _parse(*argv): + return cli._create_parser().parse_args(list(argv)) + + +def test_project_post_start_takes_name_and_context(): + ns = _parse('project', 'post-start', 'myapp', '--context', 'remote') + assert ns.subcommand == 'post-start' + assert ns.name == 'myapp' + assert ns.context == 'remote' + + +def test_post_start_is_only_in_the_project_group(): + assert 'post-start' in cli.SUBCMD_MAP[('project',)] + assert 'post-start' not in cli.SUBCMD_MAP[('container', 'ct')] + assert 'post-start' not in cli.SHORTCUTS + with pytest.raises(SystemExit): + _parse('container', 'post-start') + with pytest.raises(SystemExit): + _parse('post-start') + + +def test_project_post_start_dispatches_to_cmd_post_start(monkeypatch): + from devbase.commands import container + + seen = {} + monkeypatch.setattr(container, '_enter_project', lambda name: seen.setdefault('entered', name)) + monkeypatch.setattr(container, 'cmd_post_start', + lambda **kw: seen.setdefault('post_start', kw) and 0) + ns = _parse('project', 'post-start', 'myapp', '--context', 'remote') + + assert cli._dispatch('project', ns) == 0 + assert seen['entered'] == 'myapp' + assert seen['post_start'] == {'project_name': 'myapp', 'context': 'remote'} + + +@pytest.mark.parametrize('argv, expected', [ + (['devbase', 'project', 'po'], ['devbase', 'project', 'post-start']), + (['devbase', 'project', 'p'], ['devbase', 'project', 'ps']), + (['devbase', 'project', 'pr'], ['devbase', 'project', 'profile']), + (['devbase', 'project', 're'], ['devbase', 'project', 'rebuild']), +]) +def test_prefix_resolution(monkeypatch, argv, expected): + monkeypatch.setattr(sys, 'argv', list(argv)) + cli._expand_argv() + assert sys.argv == expected + + +def _run_wrapper(args, devbase_root): + harness = ( + 'run_python() { echo "PWD:$PWD"; echo "PYTHON:$*"; exit 0; }\n' + 'cmd_build() { echo "BUILD:$*"; exit 0; }\n' + 'ensure_uv() { :; }\n' + 'eval "$(sed -e \'/^run_python()/,/^}/d\' ' + ' -e \'/^ensure_uv()/,/^}/d\' ' + ' -e \'/^cmd_build()/,/^}/d\' ' + ' -e \'/^DEVBASE_ROOT=/d\' "$WRAPPER_PATH")"\n' + ) + env = {**os.environ, "DEVBASE_ROOT": str(devbase_root), "WRAPPER_PATH": str(WRAPPER)} + return subprocess.run(["bash", "-c", harness, "devbase", *args], + capture_output=True, text=True, env=env, cwd=str(REPO_ROOT)) + + +def test_wrapper_project_post_start_name_cds_and_strips(tmp_path): + (tmp_path / "projects" / "myapp").mkdir(parents=True) + + r = _run_wrapper(["project", "post-start", "myapp"], tmp_path) + + lines = dict(line.split(':', 1) for line in r.stdout.splitlines() if ':' in line) + assert lines['PWD'].endswith('/projects/myapp'), r.stdout + assert lines['PYTHON'] == 'project post-start', r.stdout + + +def _bash_complete(words, cword, devbase_root): + script = f""" +set -e +source "{BASH_COMPLETION}" +COMP_WORDS=({words}) +COMP_CWORD={cword} +_devbase_completions +printf '%s\\n' "${{COMPREPLY[@]}}" +""" + env = {**os.environ, "DEVBASE_ROOT": str(devbase_root)} + proc = subprocess.run(["bash", "-c", script], capture_output=True, text=True, env=env) + assert proc.returncode == 0, proc.stderr + return [line for line in proc.stdout.splitlines() if line] + + +def test_bash_completion_lists_post_start_only_under_project(tmp_path): + (tmp_path / "projects" / "web").mkdir(parents=True) + + assert "post-start" in _bash_complete("devbase project ''", 2, tmp_path) + assert "post-start" not in _bash_complete("devbase container ''", 2, tmp_path) + assert "post-start" not in _bash_complete("devbase ''", 1, tmp_path) + assert _bash_complete("devbase project post-start ''", 3, tmp_path) == ["web"] + assert _bash_complete("devbase project post-start '-'", 3, tmp_path) == ["--context"] + + +def test_zsh_completion_lists_post_start_once(): + text = ZSH_COMPLETION.read_text() + assert text.count("'post-start:") == 1 + assert " post-start)" in text From dbf9070dd4ce3570f639ed897504f4cfc1e27152 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 2 Oct 2026 15:41:49 +0900 Subject: [PATCH 07/17] =?UTF-8?q?docs:=20scale=20=E3=81=AE=E5=BE=8C?= =?UTF-8?q?=E5=87=A6=E7=90=86=E3=83=BB=E8=B5=B7=E5=8B=95=E3=81=A7=E3=81=8D?= =?UTF-8?q?=E3=81=AA=E3=81=8B=E3=81=A3=E3=81=9F=E3=81=A8=E3=81=8D=E3=81=AE?= =?UTF-8?q?=E6=89=B1=E3=81=84=E3=83=BBproject=20post-start=20=E3=82=92?= =?UTF-8?q?=E6=9B=B8=E3=81=8F=20(#224=20#371)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI リファレンス(up・scale・post-start の節と [name] の一覧)、コンテナ操作ガイド、環境変数ガイドの ウィンドウタイトルの節、確定仕様(compose-profiles・cli-argument-resolution・secret-backend)、 CHANGELOG の [Unreleased] を更新した。 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 ++++ .../specifications/cli-argument-resolution.md | 2 +- docs/specifications/compose-profiles.md | 18 ++++-- docs/specifications/secret-backend.md | 12 +++- docs/user/cli-reference/02-project.md | 64 ++++++++++++++++++- docs/user/cli-reference/README.md | 4 +- docs/user/container-operations.md | 2 +- docs/user/environment-variables.md | 2 +- 8 files changed, 102 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d24a67be..dfa3ad58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ - **base イメージに `python3-psutil` を入れました。** コンテナの中のシステムの `python3` で `import psutil` が使えます(NDF のスクリプトがプロセスの一覧と資源の量を読むのに使います)。 反映には `devbase container build` でイメージを建て直してください。 +- **`devbase project post-start [name]` を足しました(#371)。** 動いているコンテナへ、起動の後の処理 + (clone できなかったリポジトリの報告・OpenBao の token の配布・VS Code の窓のタイトルの設定)を、 + コンテナを作り直さずにやり直します。`./deploy` は走らせず、エディタも開きません。 ### Changed - **`containers/lfm` のもとのイメージを `nvidia/cuda:13.4.1-cudnn-devel-ubuntu26.04` に上げました(#337)。** @@ -20,6 +23,16 @@ 差分が前回からの変更分だけになり、1 回の時間とディスクの使い方が大きく減ります(手元の測定で フル 2.2GB・40 秒に対し、差分 1.5MB・2 秒)。直した後の最初の `devbase up` では、既存の世代へ 積まずに新しい世代(フルバックアップ)を 1 回作ります。既存の世代はそのまま復元できます。 +- **`devbase scale` で増やしたコンテナにも、`devbase up` と同じ起動の後の処理を行うようにしました(#224)。** + clone できなかったリポジトリの警告と VS Code の窓のタイトル(コンテナ名始まり)が、増やしたコンテナにも + 届きます。処理の順は `up` と同じ(リポジトリの報告 → `./deploy` → token の配布 → 窓のタイトル)になり、 + `scale` では `./deploy` が token の配布より先に走るようになりました。 +- **`devbase up` / `devbase scale` で 1 台が起動できなくても、起動できたコンテナには起動の後の処理を + 行うようにしました(#371)。** これまでは 1 台が落ちると、ほかのコンテナの窓のタイトル・token・ + エディタの自動オープンも黙って欠けていました。起動できなかったすべてのコンテナの名前と理由 + (終了した・見つからない・時間切れ)と、補う手順(`devbase project post-start `)を出して、 + 終了コード 1 で終わります。1 台が落ちても残りのコンテナの起動は最長 60 秒まで待つため、失敗で終わる + までの時間はその分延びます。 ## [4.0.0] - 2026-09-28 diff --git a/docs/specifications/cli-argument-resolution.md b/docs/specifications/cli-argument-resolution.md index 1f75c8db..9c7ee428 100644 --- a/docs/specifications/cli-argument-resolution.md +++ b/docs/specifications/cli-argument-resolution.md @@ -10,7 +10,7 @@ | 入口 | 名前の位置 | 解決するもの | | --- | --- | --- | | `devbase `(ショートカット) | 2 番目 | `up` `down` `ps` `scale` `login` `build` `rebuild` `open` | -| `devbase project ` | 3 番目 | `up` `down` `ps` `logs` `scale` `rebuild` `open` | +| `devbase project ` | 3 番目 | `up` `down` `ps` `logs` `scale` `rebuild` `open` `post-start` | | `devbase container …` / `ct`(非推奨) | — | 解決しない(`[name]` を受け付けない) | | `python -m devbase.cli project ` | 3 番目 | Python 側のフォールバック(`_resolve_project_name`) | diff --git a/docs/specifications/compose-profiles.md b/docs/specifications/compose-profiles.md index 1eb6b3fd..5a65ac5f 100644 --- a/docs/specifications/compose-profiles.md +++ b/docs/specifications/compose-profiles.md @@ -157,6 +157,14 @@ subcommand より前に置く。`<サービス...>` はプロファイル X に `scale` には停止の段が無いので旧構成で停止する必要が無く、入れると失敗したときに生成物だけが 巻き戻り、`[1/5]` で既に書き換わった `project.yml` の `scale` と食い違うためである。 +起動の待ちの後、`cmd_scale` は増やしたインスタンス(`current + 1` から `new`)のうち起動できたものを +後処理の対象にして、`up` と共有する起動の後の処理の段(`_run_post_start`)を呼ぶ。順は +不足リポジトリの報告 → `./deploy` → bao の token → 窓のタイトルの設定で、`up` と同じである。 +起動できなかったインスタンスがあると、起動の待ちは `ContainerStartupError`(起動できた番号と、 +起動できなかった番号ごとの理由)を投げる。`cmd_scale` はそれを受け、対象へ後処理を行った後に +`Scale failed` と起動できなかった全部の名前・理由・補う手順(`devbase project post-start `)を +出して 1 を返す。停止は呼ばない。 + プロファイルを持たないプロジェクトでは、`up` / `down` / `scale` が扱うコンテナの集合と順序は 変わらない。プロファイルのサービスは scale の対象にせず、複製されるのは開発サービスだけである。 @@ -213,7 +221,7 @@ subcommand より前に置く。`<サービス...>` はプロファイル X に `cli._dispatch` が `project profile list` を `project list`(プロジェクト一覧)へ流すためである - 前方一致の省略は `project p` / `container p` を従来どおり `ps` に解決し(`SUBCMD_PREFIX_PREFERENCES`)、 `project pr` は `profile` に解決する -- `bin/devbase` の `_PROJECT_NAME_SUBCOMMANDS`(`up down ps logs scale rebuild open`)に `profile` は +- `bin/devbase` の `_PROJECT_NAME_SUBCOMMANDS`(`up down ps logs scale rebuild open post-start`)に `profile` は 入れない。wrapper は 3 番目の引数をプロジェクト名として解決するが、`profile` ではそこに `up` / `down` / `list` が来るため、同名のプロジェクトが実在すると誤って移動する。名前の解決は Python 側の `_dispatch_lifecycle` が行う @@ -295,7 +303,7 @@ subcommand より前に置く。`<サービス...>` はプロファイル X に | フック | 呼ぶ経路 | `DEVBASE_ACTIVE_PROFILES` | | --- | --- | --- | | `./pre-up` | `devbase up` のみ | 常に空 | -| `./deploy` | `devbase up` / `devbase scale` | 空 | +| `./deploy` | `devbase up` / `devbase scale`(起動の後の処理の対象のインスタンス) | 空 | | `./deploy` | `devbase project profile up X` | `X` | - `profile up` の後に新しいフックを作らず `./deploy` を呼び直す。`./deploy` は既にインスタンス @@ -431,8 +439,10 @@ TUI では、復元境界が機密の注入履歴も戻すため、別プロジ `os.environ` を変えないこと。起動の非 0 で `Failed to start new containers` と 1 になり例外を 出さないこと。プロファイルのサービスを起動の対象に入れないこと。正常系の順序 (グループの検査 → `write_scale` → ボリューム → network → 生成 → `default_services` → 起動 → - ready 待ち → bao の token → `./deploy`)、bao と `./deploy` の範囲が `current + 1` から `new` まで - であること、停止を呼ばないこと(`tests/commands/test_container_scale_order.py`) + ready 待ち → 不足リポジトリの報告 → `./deploy` → bao の token → 窓のタイトル)、bao と `./deploy` の + 範囲が `current + 1` から `new` までであること、停止を呼ばないこと + (`tests/commands/test_container_scale_order.py`)。起動できなかったインスタンスがあるときの + 後処理の対象と出力(`tests/commands/test_container_post_start.py`) - `default_services` が打ち消し用の名前で `config --services` を呼ぶこと、`profile_services` が 既定のサービスを差し引き、Compose が展開した名前を使い、プロファイルが無ければ空、解決の失敗で `DevbaseError` になること。生成物が無いときに Compose を呼ばずに 1、未知の名前で起動・停止を呼ばずに diff --git a/docs/specifications/secret-backend.md b/docs/specifications/secret-backend.md index 75fb4703..b657616c 100644 --- a/docs/specifications/secret-backend.md +++ b/docs/specifications/secret-backend.md @@ -94,7 +94,7 @@ Infisical で個人単位の機密を守るには利用者ごとに project を | AWS のプロファイルの切り出し | `lib/devbase/env/aws_profiles.py` | `~/.aws/config` の節の読み取り、連なりの解決と含めた理由、選んだ節だけの tar(時刻を固定)、切り出した中身のハッシュ、`AWS_CONFIG_BASE64` の値に入っているプロファイルの読み取り。出力も終了コードも持たない | | collector の契約 | `lib/devbase/env/collector.py` | `Collector.host_import` でホストのファイルを読むことを宣言する。`source_files` があるのに宣言しない定義は生成時に `ValueError` で、登録簿は警告を出して飛ばす。宣言した collector は `collect_fn(env_file, *, host)` で呼ばれ、`host` に既定の値は無い | | コンテナへの token の配送 | `lib/devbase/env/container_token.py` | 受け取った token を `docker exec` の stdin で各コンテナの `~/.vault-token` へ書く。token の取得と届け先の解決は持たない | -| `up` / `scale` の前処理と後処理 | `lib/devbase/commands/container.py` | backend を問わず、起動の先頭でグループの宣言を読み、ボリュームのグループ(プロセスの環境変数)と揃える(`_require_group_declaration`)。`_ensure_env_files` の子プロセスの `env init` へグループを渡す。backend が `openbao` のとき dev サービスへ `BAO_ADDR` を足し、起動後に token を書く | +| `up` / `scale` の前処理と後処理 | `lib/devbase/commands/container.py` | backend を問わず、起動の先頭でグループの宣言を読み、ボリュームのグループ(プロセスの環境変数)と揃える(`_require_group_declaration`)。`_ensure_env_files` の子プロセスの `env init` へグループを渡す。backend が `openbao` のとき dev サービスへ `BAO_ADDR` を足し、起動後に起動の後の処理の段(`_run_post_start`)で後処理の対象へ token を書く。`devbase project post-start` も同じ段で token を書き直す | | base イメージ | `containers/base/Dockerfile` | OpenBao CLI `bao` を `checksums.txt` で検証して `/usr/local/bin` へ置く | | `env backend` コマンド | `lib/devbase/commands/env_backend.py` | `status` / `use` / `test` / `migrate` | | `env` コマンド | `lib/devbase/commands/env.py` | `--user` と `--group` の受け取り、`-p` とプロジェクトのグループの照合、`edit` の分岐、一覧の保存形式表示、`env token`。`env set` は `DEVBASE_ACCOUNT_GROUP` を置き場を開く前に拒む。`env sync` の同期の書き込み先(`SyncTargets`) | @@ -929,8 +929,12 @@ backend が `openbao` のとき、`up` と `scale` は dev コンテナへ次を | 名前 | 形 | いつ | | --- | --- | --- | | `BAO_ADDR` | dev サービスの `environment` にリテラル(`openbao.url`)。機密ではない | 構成の生成時 | -| `~/.vault-token` | ファイル `0600`、token 1 行(改行なし) | `up` の [5/6] の後(`scale` は増やしたインスタンスだけ)と `env token` | +| `~/.vault-token` | ファイル `0600`、token 1 行(改行なし) | `up` / `scale` の起動の待ちの後(後処理の対象だけ)、`project post-start`、`env token` | +- `up` / `scale` が token を書くのは後処理の対象だけである。`up` は起動できたインスタンス、`scale` は + 増やしたインスタンスのうち起動できたものに書き、起動できなかったインスタンスには書かない。 + `devbase project post-start` は、動いていて entrypoint の完了(`/tmp/entrypoint-ready`)を確かめられた + インスタンスに書き直す - コンテナに置く資格情報は 1 時間で切れる token だけで、`secret_id` はホストから出ない。 `role_id` / `secret_id` をコンテナへ渡す形は、コンテナの中の CLI や npm パッケージが長期の 資格情報を持つことになるため採らない @@ -941,7 +945,7 @@ backend が `openbao` のとき、`up` と `scale` は dev コンテナへ次を argv に載せない。コンテナの中では `mktemp "$HOME/.vault-token.XXXXXX"` に書き、 `chmod 0600` の後 `mv -f` で置き換える(固定名の一時ファイルは既存の inode へ書いて `umask` が効かない。途中で切れても空の `~/.vault-token` を残さない) -- 書けなくても `up` / `scale` は失敗にしない(起動は済んでおり、`env token` でやり直せる)。 +- 書けなくても `up` / `scale` / `project post-start` は失敗にしない(起動は済んでおり、`env token` でやり直せる)。 警告を出す - コンテナの `bao` で書いた値は、ホストの控え(`secrets/cache/`)へ反映しない。控えは 読み取りにだけ使い、ホストの `set` / `delete` / `edit` は現物を読むため、到達できる限り @@ -1457,6 +1461,8 @@ groups: (`tests/env/test_container_token.py`) - `up` / `scale` が `BAO_ADDR` を足し token を書くこと、ファイル backend で何も足さないこと、 書けなくても `up` が失敗しないこと(`tests/commands/test_container_bao.py`) +- `up` / `scale` / `project post-start` が token を後処理の対象のコンテナにだけ書き、値を出力に + 載せないこと(`tests/commands/test_container_post_start.py`) - `env token` の処理の順、dev サービスの絞り込み、下位ディレクトリからの dev サービス名、 接続先の適用、`--print`(`tests/commands/test_env_token.py`) - base イメージの `bao` の版・両アーキテクチャ・チェックサムの検証の文言 diff --git a/docs/user/cli-reference/02-project.md b/docs/user/cli-reference/02-project.md index 89992d01..a9031afd 100644 --- a/docs/user/cli-reference/02-project.md +++ b/docs/user/cli-reference/02-project.md @@ -6,7 +6,7 @@ ## プロジェクト名指定(CWD 非依存) -`up` / `down` / `ps` / `logs` / `scale` / `rebuild` / `open` は省略可能な `[name]` 引数を取ります。`[name]` +`up` / `down` / `ps` / `logs` / `scale` / `rebuild` / `open` / `post-start` は省略可能な `[name]` 引数を取ります。`[name]` を指定すると、**現在のディレクトリに依存せず** `$DEVBASE_ROOT/projects/` を対象に 操作できます。 @@ -20,7 +20,7 @@ cd $DEVBASE_ROOT/projects/adminer && devbase project up > **`profile` も `[name]` を取りますが、解決の経路が違います。** `project profile up` / > `profile down` / `profile list` は `[name]` を受け付けますが、`bin/devbase` の -> `_PROJECT_NAME_SUBCOMMANDS`(`up` / `down` / `ps` / `logs` / `scale` / `rebuild` / `open`) +> `_PROJECT_NAME_SUBCOMMANDS`(`up` / `down` / `ps` / `logs` / `scale` / `rebuild` / `open` / `post-start`) > には入っていません。`profile` では 3 番目の引数に `up` / `down` / `list` が来るため、 > ラッパーでは位置で名前を解決できないからです。名前の解決は Python 側の > `_dispatch_lifecycle` が行います。 @@ -65,7 +65,7 @@ cd $DEVBASE_ROOT/projects/adminer && devbase project up ## `--context NAME`(共通オプション) `up` / `down` / `ps` / `logs` / `login` / `scale` / `build` / `rebuild` / `open` / `profile`(`project` / -`container` 配下と、トップレベルのショートカット)は `--context NAME` を受け付けます。 +`container` 配下と、トップレベルのショートカット)と `project post-start` は `--context NAME` を受け付けます。 そのコマンドの `docker` / `docker compose` を、指定した docker context の daemon へ向けます。 ```bash @@ -115,6 +115,29 @@ devbase up [name] [--context NAME] - `image:` のみ、前回 pull から7日以上経過 → `docker pull` で再取得 (前回 pull 日時は `${DEVBASE_ROOT}/.cache/pulls/` の touch-file mtime で判定) - 閾値は `DEVBASE_IMAGE_MAX_AGE_DAYS` 環境変数で上書き可能(既定 7、不正値は警告して既定値) +- コンテナを起動した後、entrypoint の完了(コンテナの中の `/tmp/entrypoint-ready`)を最長 60 回(1 秒おき)待ちます。 + 1 台が起動できなくても、残りのコンテナの完了は待ち続けます +- 起動できたコンテナへ、**起動の後の処理**を次の順で行います。どれも失敗しても `up` を失敗にはしません + 1. clone できなかったリポジトリの報告(`/work` に無いリポジトリを警告で出す) + 2. プロジェクトの `./deploy`(あれば。番号ごとに `DEVBASE_INSTANCE_INDEX` を付けて走らせる) + 3. backend が `openbao` のとき、コンテナの `~/.vault-token` へ token を書く + 4. VS Code の窓のタイトルをコンテナ名始まりにする([環境変数ガイドの「ウィンドウタイトル」](../environment-variables.md#ウィンドウタイトルどの窓がどのプロジェクトか)) + 5. エディタの自動オープン(有効なときだけ。開く番号のコンテナにだけ) + +### 起動できなかったコンテナがあるとき + +起動の待ちの間に終了した・見つからない・時間内に entrypoint が終わらなかったコンテナがあると、`up` は次のように動きます。 + +- 起動できたコンテナには、上の 1〜4 を行います。起動できなかったコンテナには行いません +- エディタは、開く番号のコンテナが起動できていれば開きます。起動できていなければ、警告を出して開きません(ほかの番号へは替えません) +- 起動できたコンテナを止めたり消したりはしません +- 出力の最後に `Deploy failed` の行、起動できなかったすべてのコンテナの名前と理由(`exited unexpectedly` とログの末尾 10 行・`container not found`・`timeout`)、補う手順を出し、終了コード `1` で終わります + +補う手順は次のとおりです。 + +1. 落ちたコンテナを起こし直す(`docker start <コンテナ名>` など) +2. その起動が終わった後に `devbase project post-start ` を打つ。`docker start` で起こし直しただけでは、起動の後の処理(token の配布・窓のタイトルの設定など)は行われません +3. `./deploy` も含めて最初からやり直すときは `devbase up ` を打つ(全コンテナを作り直します) > **Note (entrypoint / Dockerfile を変更したとき):** `containers/` 配下の `entrypoint.sh` や > Dockerfile はビルド時にイメージへ焼き込まれます。これらを変更しても、上記のとおり @@ -150,6 +173,31 @@ devbase container open [--open-index N] [--context NAME] - 番号の上限は `project.yml` の `scale` ではなく、動いているコンテナで決まります(`devbase scale` で増やした分も開けます) - `--open` / `--no-open` は受け付けません(`up` の自動オープンのためのオプションです) +## `devbase project post-start` + +動いているコンテナへ、`up` の起動の後の処理を、コンテナを作り直さずにやり直します。`up` / `scale` で起動できなかったコンテナを起こし直した後や、時間切れになったコンテナが後から起動を終えた後に使います。 + +``` +devbase project post-start [name] [--context NAME] +``` + +| パラメータ | 必須 | デフォルト | 説明 | +|-----------|------|-----------|------| +| `name` | いいえ | カレント | 対象プロジェクト名 | + +- 対象は、動いていて entrypoint の完了(`/tmp/entrypoint-ready`)を確かめられた dev コンテナです。完了を待たず、各コンテナを 1 回だけ確かめます +- 対象へ、clone できなかったリポジトリの報告 → token の配布(backend が `openbao` のとき)→ 窓のタイトルの設定を行います +- `./deploy` は走らせず、エディタも開きません(エディタは `devbase open` で開けます) +- コンテナを起動・停止・作り直しはしません。何度打っても同じ結果になります +- トップレベルの短縮形と `container` グループにはありません + +| 状態 | 動き | 終了コード | +|------|------|-----------| +| 対象のコンテナが 1 つ以上ある | 対象へ処理を行う。`project.yml` の `scale` までの番号のうち、動いていないものと、entrypoint の完了を確かめられないものを、分けて警告で出す。最後に `=== Post-start completed ===` を出す | `0` | +| 動いているが、entrypoint の完了を確かめられたものが 1 つも無い | 処理を行わない。起動が終わってからもう一度打つ | `1` | +| dev コンテナが 1 つも動いていない | 処理を行わない。起動には `devbase up` を使う(起動はしない) | `1` | +| コンテナの状態を取得できない(Docker のデーモンに届かない) | 処理を行わない | `1` | + ## `devbase project down` コンテナを停止・削除します。 @@ -240,6 +288,16 @@ devbase project scale adminer 3 `up` と同じく最初にアカウントグループの宣言を確かめ、読めなければ `project.yml` を書き換える前に 止まります。 +増やしたコンテナが起動できたら、`up` と同じ起動の後の処理(clone できなかったリポジトリの報告 → +`./deploy` → token の配布 → 窓のタイトルの設定)を、増やしたコンテナにだけ同じ順で行います。 +既存のコンテナには行いません。エディタは開きません。 + +起動できなかったコンテナがあるときは、`up` と同じく、増やしたコンテナのうち起動できたものにだけ +起動の後の処理を行い、`Scale failed` の行と、起動できなかったすべてのコンテナの名前・理由・補う手順 +(`devbase project post-start `)を出して終了コード `1` で終わります。既存のコンテナが +起動できなかったときも同じです。`project.yml` の `scale` は書き換わったまま残るため、同じ数で +`devbase scale` を打ち直すことはできません。補うには `devbase project post-start` を使います。 + ## `devbase project profile` `compose.yml` で `profiles:` を付けたサービス群を、dev コンテナに触れずに後から起動・停止します。 diff --git a/docs/user/cli-reference/README.md b/docs/user/cli-reference/README.md index 0dfe1b74..1b77e959 100644 --- a/docs/user/cli-reference/README.md +++ b/docs/user/cli-reference/README.md @@ -5,7 +5,7 @@ devbase の全コマンドの構文、オプション、使用例をまとめた | ファイル | 内容 | |---------|------| | [トップレベルコマンド](01-toplevel.md) | `init` / `status` / `bin/rc` | -| [project グループ](02-project.md) | コンテナのライフサイクル管理・一覧(`up` / `down` / `login` / `ps` / `logs` / `scale` / `build` / `rebuild` / `open` / `profile` / `list`)と非推奨の `container` グループ | +| [project グループ](02-project.md) | コンテナのライフサイクル管理・一覧(`up` / `down` / `login` / `ps` / `logs` / `scale` / `build` / `rebuild` / `open` / `post-start` / `profile` / `list`)と非推奨の `container` グループ | | [env グループ](03-env.md) | 環境変数の管理(`init` / `sync` / `list` / `set` / `get` / `delete` / `edit` / `project` / `keygen` / `encrypt` / `decrypt` / `exec` / `token` / `rekey` / `doctor` / `export` / `import`) | | [plugin グループ](04-plugin.md) | プラグインの管理(`list` / `install` / `uninstall` / `update` / `info` / `sync` / `migrate` / `repo *`) | | [snapshot グループ](05-snapshot.md) | スナップショットの管理(`create` / `list` / `restore` / `copy` / `delete` / `rotate`) | @@ -22,7 +22,7 @@ graph TD A --> E[env] A --> F[plugin / pl] A --> G[snapshot / ss] - D --> D1["up / down / ps / logs / scale / open [name]"] + D --> D1["up / down / ps / logs / scale / open / post-start [name]"] D --> D3["login [index]"] D --> D4["build [image] / rebuild [name]"] D --> D5["profile up / down / list [name]"] diff --git a/docs/user/container-operations.md b/docs/user/container-operations.md index 9224fc9a..4fd8ded9 100644 --- a/docs/user/container-operations.md +++ b/docs/user/container-operations.md @@ -5,7 +5,7 @@ devbase のコンテナ管理機能について、ライフサイクル、並行 > **コマンド体系について:** コンテナ操作は `devbase project ` グループ(および > トップレベルショートカット `devbase up` 等)で行います。旧 `devbase container ` は > 非推奨となり、`project` へのエイリアスとして警告付きで当面動作します。`project` では -> `up` / `down` / `ps` / `logs` / `scale` / `rebuild` / `open` に `[name]` を指定することで +> `up` / `down` / `ps` / `logs` / `scale` / `rebuild` / `open` / `post-start` に `[name]` を指定することで > **任意のディレクトリから** 対象プロジェクトを操作できます。`profile up` / `profile down` / > `profile list` も `[name]` を取りますが、ラッパーでは位置で解決できず Python 側が解決する > 点が違います。プロジェクト一覧は `devbase project list` を参照 diff --git a/docs/user/environment-variables.md b/docs/user/environment-variables.md index 037915ec..93305d9e 100644 --- a/docs/user/environment-variables.md +++ b/docs/user/environment-variables.md @@ -389,7 +389,7 @@ Warning: 機密の置き場(グローバル)にある DEVBASE_ACCOUNT_GROUP ### ウィンドウタイトル(どの窓がどのプロジェクトか) -VS Code の既定タイトルは編集中ファイル名が先頭に来るため、複数プロジェクトの窓を並べるとどれがどれか判別できません。devbase は `up` のたびに各 dev コンテナへ **コンテナ名始まりのタイトル**を設定します。 +VS Code の既定タイトルは編集中ファイル名が先頭に来るため、複数プロジェクトの窓を並べるとどれがどれか判別できません。devbase は `up` のたびに起動できた各 dev コンテナへ、`scale` では増やして起動できた dev コンテナへ **コンテナ名始まりのタイトル**を設定します。起動できなかったコンテナを起こし直した後は、`devbase project post-start [name]` で設定し直せます([CLI リファレンス](cli-reference/02-project.md#devbase-project-post-start))。 ``` myapp-dev-1 - main.py From 897e115ecf307ce28920ae04922a26729a9f2c2c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 2 Oct 2026 15:47:12 +0900 Subject: [PATCH 08/17] =?UTF-8?q?fix(env):=20AWS=20=E3=81=AE=20region=20?= =?UTF-8?q?=E3=81=A8=20GCP=20=E3=81=AE=E6=AD=A3=E8=A6=8F=E5=8C=96=E3=81=AE?= =?UTF-8?q?=E8=AD=A6=E5=91=8A=E3=82=92=E5=8F=96=E3=82=8A=E8=BE=BC=E3=81=BF?= =?UTF-8?q?=E3=81=AE=E6=96=B9=E9=87=9D=E3=81=AB=E5=BE=93=E3=82=8F=E3=81=9B?= =?UTF-8?q?=E3=82=8B=20(#334)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 方法 2・方法 3 の AWS_DEFAULT_REGION を _collect_region で決める。skip と断ったときは ホストの ~/.aws/config を読まず、ask で確認を出さなかったときはホストの region を既定の値として見せる - GCP の鍵ファイルの発見を google.find_credential_files にまとめ、出力を持たない形にする。 正規化と衝突の警告は取り込むと決めた名前についてだけ 1 回出す - env.py の _gcp_profile_files を消し、sync と控えの更新も同じ発見の結果で引く Co-Authored-By: Claude Opus 5.5 --- lib/devbase/commands/env.py | 30 ++------- lib/devbase/env/collectors/aws.py | 90 ++++++++++++++++++------- lib/devbase/env/collectors/google.py | 99 ++++++++++++++++++---------- lib/devbase/env/host_import.py | 9 +-- 4 files changed, 142 insertions(+), 86 deletions(-) diff --git a/lib/devbase/commands/env.py b/lib/devbase/commands/env.py index e251a4c2..d8a7ec14 100644 --- a/lib/devbase/commands/env.py +++ b/lib/devbase/commands/env.py @@ -954,30 +954,6 @@ def _sync_source(sources, targets, name, label, encode_fn, *, env_key, store, co logger.info("%s: 控えと比べられません(ハッシュか元のファイルがありません)", label) -def _gcp_profile_files(): - """GCP のプロファイル名 → 鍵ファイルを引く関数 (``env init`` の登録と同じ引き方)""" - from devbase.env.collectors.google import GCP_CREDENTIALS_DIR, LEGACY_CREDENTIALS_FILE - - file_map = {} - if GCP_CREDENTIALS_DIR.is_dir(): - from devbase.env.collectors.google import _safe_profile_name - file_map = { - _safe_profile_name(f.stem): f - for f in GCP_CREDENTIALS_DIR.iterdir() - if f.suffix == '.json' and f.is_file() - } - - def resolve(profile_name: str): - mapped = file_map.get(profile_name) - if mapped and mapped.exists(): - return mapped - if profile_name == 'default' and LEGACY_CREDENTIALS_FILE.exists(): - return LEGACY_CREDENTIALS_FILE - return None - - return resolve - - def _sync_gcp(sources, targets, *, store, counts): """GCPプロファイルの同期処理。更新件数を返す。 @@ -989,7 +965,8 @@ def _sync_gcp(sources, targets, *, store, counts): gcp_source = sources.get_source('gcp') or {} registered = gcp_source.get('profiles', {}) prefix = keys.GCP_CREDENTIALS_BASE64_PREFIX - resolve = _gcp_profile_files() + from devbase.env.collectors.google import find_credential_files + resolve = find_credential_files().lookup names = sorted({k[len(prefix):] for f in targets.files for k in f.get_all() if k.startswith(prefix)} - set(registered)) updated = 0 @@ -1651,7 +1628,8 @@ def _record_gcp_source(sources: SourcesManager, files, get) -> None: for f in reversed(files): all_vars.update(f.get_all()) prefix = keys.GCP_CREDENTIALS_BASE64_PREFIX - _resolve_gcp_path = _gcp_profile_files() + from devbase.env.collectors.google import find_credential_files + _resolve_gcp_path = find_credential_files().lookup gcp_profiles = { name: {'file': str(path), 'hash': file_hash(path)} diff --git a/lib/devbase/env/collectors/aws.py b/lib/devbase/env/collectors/aws.py index 9b813230..c4d2b213 100644 --- a/lib/devbase/env/collectors/aws.py +++ b/lib/devbase/env/collectors/aws.py @@ -4,6 +4,7 @@ import configparser import io import tarfile +from enum import Enum from pathlib import Path from typing import Optional, List, Tuple @@ -17,6 +18,15 @@ logger = get_logger(__name__) STEP = "AWS認証" +#: ホストに region が無いとき・ホストの region を使わないときの region の既定の値 +DEFAULT_REGION = "ap-northeast-1" + + +class KeyDecision(Enum): + """方法 3 で、ホストの ``[default]`` の鍵を取り込むかを決めた結果 (#334 決定 4)""" + ACCEPTED = "accepted" + DECLINED = "declined" + NOT_ASKED = "not_asked" class AWSConfigParser: @@ -43,6 +53,14 @@ def get_profiles(self) -> List[str]: return [] def get_profile_region(self, profile: str) -> Optional[str]: + found = self.find_profile_region(profile) + return found[1] if found else None + + def find_profile_region(self, profile: str) -> Optional[Tuple[str, str]]: + """プロファイルの節の名前と region を返す。節か region が無ければ ``None`` + + ``default`` は ``[default]``、ほかは ``[profile <名前>]``、無ければ ``[<名前>]`` を引く。 + """ if not self.config_path.exists(): return None config = configparser.ConfigParser() @@ -55,7 +73,7 @@ def get_profile_region(self, profile: str) -> Optional[str]: if section not in config: section = profile if section in config and 'region' in config[section]: - return config[section]['region'] + return section, config[section]['region'] except Exception as e: logger.warning("AWS configのパースに失敗: %s", e) return None @@ -151,7 +169,7 @@ def collect_aws_credentials(env_file: EnvFile, *, host: HostImport) -> None: auth_handlers = { "1": lambda: _collect_config_base64(env_file, host), - "2": lambda: _collect_sso_profile(env_file), + "2": lambda: _collect_sso_profile(env_file, host), "3": lambda: _collect_access_keys(env_file, host), "4": lambda: logger.info("AWS認証設定をスキップしました"), } @@ -251,7 +269,7 @@ def _collect_whole_config(env_file: EnvFile) -> None: logger.error("AWS設定ファイルのエンコードに失敗しました") -def _collect_sso_profile(env_file: EnvFile) -> None: +def _collect_sso_profile(env_file: EnvFile, host: HostImport) -> None: parser = AWSConfigParser() profiles = parser.get_profiles() if profiles: @@ -264,13 +282,8 @@ def _collect_sso_profile(env_file: EnvFile) -> None: return env_file.set(keys.AWS_PROFILE, profile) - region = parser.get_profile_region(profile) - if region: - env_file.set(keys.AWS_DEFAULT_REGION, region) - logger.info("%s: 自動取得完了 (%s)", keys.AWS_DEFAULT_REGION, region) - else: - region = safe_input(f"{keys.AWS_DEFAULT_REGION} (デフォルト: ap-northeast-1): ", "ap-northeast-1") - env_file.set(keys.AWS_DEFAULT_REGION, region) + # 決定 5: 方法 2 は参照に残った前の方法の region を持ち越さず、方針に従って決め直す + _collect_region(env_file, parser, profile, host, KeyDecision.NOT_ASKED, keep_existing=False) sso_url = safe_input(f"{keys.AWS_SSO_URL} (空でスキップ): ") if sso_url: @@ -280,13 +293,20 @@ def _collect_sso_profile(env_file: EnvFile) -> None: def _collect_access_keys(env_file: EnvFile, host: HostImport) -> None: parser = AWSConfigParser() access_key, secret_key = parser.get_default_credentials() - if (access_key or secret_key) and not host.importing: + if host.importing: + decision = KeyDecision.ACCEPTED + elif access_key or secret_key: if host.asking: print(f"~/.aws/credentials の [default] に {keys.AWS_ACCESS_KEY_ID} / " f"{keys.AWS_SECRET_ACCESS_KEY} があります") - if not host.confirm(STEP, "取り込みますか?"): + if host.confirm(STEP, "取り込みますか?"): + decision = KeyDecision.ACCEPTED + else: host.declined(STEP) access_key = secret_key = None + decision = KeyDecision.DECLINED + else: + decision = KeyDecision.NOT_ASKED for key_name, auto_value in {keys.AWS_ACCESS_KEY_ID: access_key, keys.AWS_SECRET_ACCESS_KEY: secret_key}.items(): @@ -298,17 +318,43 @@ def _collect_access_keys(env_file: EnvFile, host: HostImport) -> None: if value: env_file.set(key_name, value) - existing_region = env_file.get(keys.AWS_DEFAULT_REGION) - if not existing_region: - region = parser.get_profile_region('default') - if region: - env_file.set(keys.AWS_DEFAULT_REGION, region) - logger.info("%s: 自動取得完了 (%s)", keys.AWS_DEFAULT_REGION, region) + _collect_region(env_file, parser, 'default', host, decision, keep_existing=True) + + +def _collect_region(env_file: EnvFile, parser: AWSConfigParser, profile: str, + host: HostImport, decision: KeyDecision, *, keep_existing: bool) -> None: + """方法 2・方法 3 の ``AWS_DEFAULT_REGION`` を、取り込みの方針と鍵の確認の結果で決める (#334) + + - ``keep_existing`` で参照に region があれば残す (方法 3 だけ。I4) + - 「取り込む」か鍵を受け入れたら、ホストの region を入力なしで書く (I2) + - 「尋ねる」で確認を出さなかったら、ホストの region を入力の既定の値として見せる (I3) + - 「取り込まない」と鍵を断ったときは、ホストを読まずに入力へ進む (I1)。知らせは + 確認で出していないときだけ 1 行出す (I5) + """ + existing = env_file.get(keys.AWS_DEFAULT_REGION) + if keep_existing and existing: + logger.info("%s: 設定済み (%s)", keys.AWS_DEFAULT_REGION, existing) + return + + prompt = f"{keys.AWS_DEFAULT_REGION} (デフォルト: {DEFAULT_REGION}): " + default = DEFAULT_REGION + if host.importing or decision is KeyDecision.ACCEPTED: + found = parser.find_profile_region(profile) + if found: + env_file.set(keys.AWS_DEFAULT_REGION, found[1]) + logger.info("%s: 自動取得完了 (%s)", keys.AWS_DEFAULT_REGION, found[1]) + return + elif decision is KeyDecision.NOT_ASKED: + if host.asking: + found = parser.find_profile_region(profile) + if found: + section, default = found + prompt = (f"{keys.AWS_DEFAULT_REGION} (デフォルト: {default}、" + f"~/.aws/config の [{section}] から): ") else: - region = safe_input(f"{keys.AWS_DEFAULT_REGION} (デフォルト: ap-northeast-1): ", "ap-northeast-1") - env_file.set(keys.AWS_DEFAULT_REGION, region) - else: - logger.info("%s: 設定済み (%s)", keys.AWS_DEFAULT_REGION, existing_region) + host.notify_skipped(STEP) + + env_file.set(keys.AWS_DEFAULT_REGION, safe_input(prompt, default)) COLLECTOR = Collector( diff --git a/lib/devbase/env/collectors/google.py b/lib/devbase/env/collectors/google.py index 23581fe5..94452876 100644 --- a/lib/devbase/env/collectors/google.py +++ b/lib/devbase/env/collectors/google.py @@ -3,8 +3,9 @@ import base64 import json import re +from dataclasses import dataclass, field from pathlib import Path -from typing import Optional +from typing import Dict, List, Optional from devbase.log import get_logger from devbase.env import keys @@ -38,38 +39,69 @@ def _extract_project_id(file_path: Path) -> Optional[str]: def _safe_profile_name(name: str) -> str: - """プロファイル名を環境変数として安全な文字種に正規化""" - safe_name = re.sub(r'[^A-Za-z0-9_]', '_', name) - if safe_name != name: - logger.warning("プロファイル名 '%s' を '%s' に正規化しました", name, safe_name) - return safe_name + """プロファイル名を環境変数として安全な文字種に正規化する (警告は出さない。#334 I6)""" + return re.sub(r'[^A-Za-z0-9_]', '_', name) -def _discover_credential_files() -> dict: - """利用可能なcredentialファイルを検出する""" +@dataclass +class CredentialFiles: + """鍵ファイルの発見の結果 (#334) + + ``files`` はプロファイル名 → 鍵ファイル (ファイル名の昇順)。``renamed`` は正規化で変わった + 名前 → 元の名前、``collisions`` は名前 → 同じ名前に正規化され、先のファイルに負けて外した + 鍵ファイルの並び。 + """ + files: Dict[str, Path] = field(default_factory=dict) + renamed: Dict[str, str] = field(default_factory=dict) + collisions: Dict[str, List[Path]] = field(default_factory=dict) + + def lookup(self, name: str) -> Optional[Path]: + """プロファイル名から鍵ファイルを引く。``default`` で無ければ ``~/google_credential.json``""" + path = self.files.get(name) + if path and path.exists(): + return path + if name == 'default' and LEGACY_CREDENTIALS_FILE.exists(): + return LEGACY_CREDENTIALS_FILE + return None + + +def find_credential_files() -> CredentialFiles: + """``~/gcp-credentials/`` の鍵ファイルを見つける。JSON を読まず、何も出力しない (I6・I9) + + ファイル名の昇順に読み、正規化した名前が衝突したら先のファイルを採る。ディレクトリに + 鍵が無ければ ``~/google_credential.json`` を ``default`` として返す。 + """ + found = CredentialFiles() if GCP_CREDENTIALS_DIR.is_dir(): - profiles = {} for f in sorted(GCP_CREDENTIALS_DIR.iterdir()): if f.suffix != '.json' or not f.is_file(): continue - safe_name = _safe_profile_name(f.stem) - if safe_name in profiles: - logger.warning( - "プロファイル名 '%s' が衝突しています: '%s' と '%s' (後者をスキップ)", - safe_name, profiles[safe_name]['file'], str(f), - ) + name = _safe_profile_name(f.stem) + if name in found.files: + found.collisions.setdefault(name, []).append(f) continue - profiles[safe_name] = {'file': str(f), 'project_id': _extract_project_id(f)} - if profiles: - return profiles + found.files[name] = f + if name != f.stem: + found.renamed[name] = f.stem + if found.files: + return found + found = CredentialFiles() if LEGACY_CREDENTIALS_FILE.exists(): - return {'default': { - 'file': str(LEGACY_CREDENTIALS_FILE), - 'project_id': _extract_project_id(LEGACY_CREDENTIALS_FILE), - }} + found.files['default'] = LEGACY_CREDENTIALS_FILE + return found - return {} + +def _warn_for_chosen(chosen: List[str], found: CredentialFiles) -> None: + """取り込むと決めた名前について、正規化と衝突の警告を 1 回ずつ出す (I7・I8)""" + for name in chosen: + if name in found.renamed: + logger.warning("プロファイル名 '%s' を '%s' に正規化しました", found.renamed[name], name) + for dropped in found.collisions.get(name, []): + logger.warning( + "プロファイル名 '%s' が衝突しています: '%s' と '%s' (後者をスキップ)", + name, str(found.files[name]), str(dropped), + ) def collect_google_credentials(env_file: EnvFile, *, host: HostImport) -> None: @@ -80,9 +112,9 @@ def collect_google_credentials(env_file: EnvFile, *, host: HostImport) -> None: """ print("\n=== Google Cloud認証情報 ===") - profiles = _discover_credential_files() + found = find_credential_files() - if not profiles: + if not found.files: existing = env_file.get(keys.gcp_credentials_key("default")) has_key = bool(existing) if existing: @@ -99,15 +131,14 @@ def collect_google_credentials(env_file: EnvFile, *, host: HostImport) -> None: _collect_common_settings(env_file, has_key=has_key) return - names = list(profiles.keys()) + names = list(found.files) + project_ids = {name: _extract_project_id(path) for name, path in found.files.items()} if host.importing: - print(f"\n検出されたcredential ({len(profiles)}件):") - print('\n'.join(f" - {name} (project: {info.get('project_id', 'N/A')})" - for name, info in profiles.items())) + print(f"\n検出されたcredential ({len(names)}件):") + print('\n'.join(f" - {name} (project: {project_ids[name]})" for name in names)) chosen = names else: - labels = [f"{name} (project: {info.get('project_id') or 'N/A'})" - for name, info in profiles.items()] + labels = [f"{name} (project: {project_ids[name] or 'N/A'})" for name in names] selection = host.choose(STEP, f"ホストで見つけた GCP の鍵 ({len(names)}件):", labels, "例: 1,2 / all で全部 / 空で取り込まない") chosen = [names[i] for i in selection.indexes] @@ -116,6 +147,7 @@ def collect_google_credentials(env_file: EnvFile, *, host: HostImport) -> None: host.declined(STEP) host.record('gcp', []) return + _warn_for_chosen(chosen, found) default_active = 'default' if 'default' in chosen else chosen[0] active = _ask_active_profile(chosen, default_active) @@ -125,13 +157,12 @@ def collect_google_credentials(env_file: EnvFile, *, host: HostImport) -> None: return for name in chosen: - _register_profile(env_file, name, Path(profiles[name]['file'])) + _register_profile(env_file, name, found.files[name]) env_file.set(keys.GCP_ACTIVE_PROFILE, active) logger.info("%s: %s", keys.GCP_ACTIVE_PROFILE, active) - active_info = profiles.get(active, {}) - project_id = active_info.get('project_id') + project_id = project_ids.get(active) if project_id: env_file.set(keys.GOOGLE_CLOUD_PROJECT, project_id) env_file.set(keys.BIGQUERY_PROJECT, project_id) diff --git a/lib/devbase/env/host_import.py b/lib/devbase/env/host_import.py index e6c080aa..70ad90b5 100644 --- a/lib/devbase/env/host_import.py +++ b/lib/devbase/env/host_import.py @@ -134,14 +134,15 @@ def importing(self) -> bool: def _group_label(self) -> str: return self.group or 'グループの指定なし' - def _skipped(self, step: str) -> None: + def notify_skipped(self, step: str) -> None: + """「取り込まない」(端末でない「尋ねる」を含む) のために飛ばしたことを 1 行知らせる""" if self.fell_back: logger.info("%s: 標準入力が端末でないため飛ばしました (%s)", step, self._group_label()) else: logger.info("%s: 取り込まない設定のため飛ばしました (%s)", step, self._group_label()) def declined(self, step: str) -> None: - """「尋ねる」で断られたことを 1 行知らせる (「取り込まない」は ``_skipped`` が知らせ済み)""" + """「尋ねる」で断られたことを 1 行知らせる (「取り込まない」は ``notify_skipped`` が知らせ済み)""" if self.asking: logger.info("%s: 取り込みません", step) @@ -150,7 +151,7 @@ def choose(self, step: str, title: str, labels: Sequence[str], hint: str) -> Sel if self.policy is ImportPolicy.IMPORT: return Selection(list(range(len(labels))), all=True) if self.policy is ImportPolicy.SKIP: - self._skipped(step) + self.notify_skipped(step) return Selection() print(f"\n{title}") for i, label in enumerate(labels, 1): @@ -164,7 +165,7 @@ def confirm(self, step: str, question: str) -> bool: if self.policy is ImportPolicy.IMPORT: return True if self.policy is ImportPolicy.SKIP: - self._skipped(step) + self.notify_skipped(step) return False from devbase.env.store import safe_input return safe_input(f"{question} [y/N]: ", 'n').lower() == 'y' From af1275ccd5b6f1f3c5190640fe5a23a14c3d2a05 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 2 Oct 2026 15:50:05 +0900 Subject: [PATCH 09/17] =?UTF-8?q?test(env):=20AWS=20=E3=81=AE=20region=20?= =?UTF-8?q?=E3=81=A8=20GCP=20=E3=81=AE=E8=AD=A6=E5=91=8A=E3=81=AE=E5=9B=9E?= =?UTF-8?q?=E6=95=B0=E3=82=92=E6=96=B9=E9=87=9D=E3=81=94=E3=81=A8=E3=81=AB?= =?UTF-8?q?=E7=B8=9B=E3=82=8B=20(#334)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- tests/commands/test_env_host_import.py | 79 +++++++ tests/env/test_aws_region_policy.py | 303 +++++++++++++++++++++++++ tests/env/test_gcp_credential_files.py | 112 +++++++++ 3 files changed, 494 insertions(+) create mode 100644 tests/env/test_aws_region_policy.py create mode 100644 tests/env/test_gcp_credential_files.py diff --git a/tests/commands/test_env_host_import.py b/tests/commands/test_env_host_import.py index 71a6a493..89fe554d 100644 --- a/tests/commands/test_env_host_import.py +++ b/tests/commands/test_env_host_import.py @@ -781,3 +781,82 @@ def test_a_collector_reading_host_files_must_declare_it(): source_files=['~/.x']) assert Collector(name='x', display_name='X', collect_fn=lambda env_file, *, host: None, source_files=['~/.x'], host_import=True).host_import + + +# --------------------------------------------------------------------------- +# GCP の正規化と衝突の警告 (#334 AC11〜AC17・I7〜I9) +# --------------------------------------------------------------------------- + +RENAMED = "プロファイル名 'my-proj' を 'my_proj' に正規化しました" + + +def warnings(caplog) -> list: + return [r.getMessage() for r in caplog.records if r.levelno >= logging.WARNING] + + +def collisions(caplog) -> list: + return [m for m in warnings(caplog) if 'が衝突しています' in m] + + +@pytest.fixture +def renamed_key(host): + """正規化で my_proj になる鍵ファイル (候補の並びで 3 番目)""" + path = host / 'gcp-credentials' / 'my-proj.json' + path.write_text(json.dumps({'project_id': 'acme-my-proj', 'private_key': 'PRIVATE-KEY-MP'})) + return path + + +@pytest.mark.parametrize('policy, answer, expected', [ + ('skip', '', 0), + ('ask', '', 0), + ('ask', '1', 0), # analytics だけ + ('ask', '3', 1), # my_proj + ('import', '', 1), +]) +def test_one_init_warns_about_a_renamed_key_only_when_importing_it( + grouped, openbao, host, renamed_key, tty, monkeypatch, caplog, policy, answer, expected): + """AC11〜AC15・I7: 控えの更新を含む 1 回の init で、取り込む名前についてだけ 1 回""" + write_policy(grouped, f'groups:\n globex: {policy}\n') + Answers(monkeypatch, {'取り込む番号 (例: 1,2': answer}) + + assert init(grouped) == 0 + + assert warnings(caplog).count(RENAMED) == expected + stored = openbao.get(GLOBEX) or {} + assert (keys.gcp_credentials_key('my_proj') in stored) == bool(expected) + + +@pytest.mark.parametrize('policy, expected', [('skip', 0), ('import', 1)]) +def test_a_collision_is_reported_only_when_importing(grouped, openbao, host, tty, monkeypatch, + caplog, policy, expected): + """AC16・I8・I9: 衝突の警告は取り込むときだけ 1 回。init と控えの更新は先のファイルを採る""" + creds = host / 'gcp-credentials' + (creds / 'a-b.json').write_text(json.dumps({'private_key': 'FIRST'})) + (creds / 'a_b.json').write_text(json.dumps({'private_key': 'SECOND'})) + write_policy(grouped, f'groups:\n globex: {policy}\n') + Answers(monkeypatch) + + assert init(grouped) == 0 + + assert len(collisions(caplog)) == expected + if expected: + stored = openbao.get(GLOBEX) + assert base64.b64decode(stored[keys.gcp_credentials_key('a_b')]) == \ + (creds / 'a-b.json').read_bytes() + profiles = SourcesManager(grouped, 'globex').get_source('gcp')['profiles'] + assert profiles['a_b']['file'] == str(creds / 'a-b.json') + + +def test_sync_looks_up_a_renamed_key_without_warning(grouped, openbao, host, renamed_key, + caplog): + """AC17・I6・I9: 控えに無い my_proj の鍵を、警告を出さずに my-proj.json として引く""" + openbao.put(GLOBEX, {keys.gcp_credentials_key('my_proj'): 'OLD', keys.HOST_SSH_USER: 'u', + keys.HOST_SSH_HOST: 'h'}) + caplog.set_level(logging.INFO) + + assert env_cmd.cmd_env_sync(grouped, group='globex') == 0 + + assert warnings(caplog) == [] + stored = openbao.get(GLOBEX) + assert base64.b64decode(stored[keys.gcp_credentials_key('my_proj')]) == \ + renamed_key.read_bytes() diff --git a/tests/env/test_aws_region_policy.py b/tests/env/test_aws_region_policy.py new file mode 100644 index 00000000..8c5a2e58 --- /dev/null +++ b/tests/env/test_aws_region_policy.py @@ -0,0 +1,303 @@ +"""AWS の方法 2・方法 3 の region を取り込みの方針に従わせる (#334) + +偽の HOME の ``~/.aws/config`` に ``[default]`` と ``[profile dev]`` の ``region = ap-southeast-2`` を置き、 +既定の値 ``ap-northeast-1`` と区別する。利用者の実際の ``~/.aws`` は読まない。 +""" + +from __future__ import annotations + +import builtins +import logging + +import pytest + +from devbase.env import host_import, keys +from devbase.env.collectors import aws +from devbase.env.host_import import HostImport, ImportPolicy +from devbase.env.store import EnvFile + +HOST_REGION = 'ap-southeast-2' +CONFIG = f"""\ +[default] +region = {HOST_REGION} + +[profile dev] +region = {HOST_REGION} +""" +CREDENTIALS = """\ +[default] +aws_access_key_id = AKIADEFAULT +aws_secret_access_key = SECRET-DEFAULT +""" +SKIPPED = 'AWS認証: 取り込まない設定のため飛ばしました (globex)' +DECLINED = 'AWS認証: 取り込みません' +AUTO = 'AWS_DEFAULT_REGION: 自動取得完了' +REGION_PROMPT = 'AWS_DEFAULT_REGION' + + +@pytest.fixture +def home(tmp_path, monkeypatch): + path = tmp_path / 'home' + (path / '.aws').mkdir(parents=True) + (path / '.aws' / 'config').write_text(CONFIG) + monkeypatch.setenv('HOME', str(path)) + return path + + +@pytest.fixture +def with_keys(home): + (home / '.aws' / 'credentials').write_text(CREDENTIALS) + return home + + +@pytest.fixture +def env_file(tmp_path): + return EnvFile(tmp_path / '.env') + + +class Answers: + """``input`` の差し替え。質問の文に含まれる語 → 答え。当たらなければ空 (Enter)""" + + def __init__(self, monkeypatch, answers=None): + self.answers = dict(answers or {}) + self.prompts: list = [] + monkeypatch.setattr(builtins, 'input', self) + + def __call__(self, prompt=''): + self.prompts.append(prompt) + for needle, value in self.answers.items(): + if needle in prompt: + return value + return '' + + def region_prompts(self) -> list: + return [p for p in self.prompts if p.startswith(REGION_PROMPT)] + + +def place(policy: ImportPolicy) -> HostImport: + return HostImport(policy, 'globex') + + +def messages(caplog) -> list: + return [r.getMessage() for r in caplog.records if r.levelno >= logging.INFO] + + +@pytest.fixture +def no_host_region(monkeypatch): + """ホストの region を読んだら落ちる (I1)""" + def fail(self, profile): + raise AssertionError(f'ホストの region を読んだ: {profile}') + + monkeypatch.setattr(aws.AWSConfigParser, 'find_profile_region', fail) + monkeypatch.setattr(aws.AWSConfigParser, 'get_profile_region', fail) + + +# --------------------------------------------------------------------------- +# 方法 3 (Access Key) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize('credentials', [False, True]) +def test_skip_does_not_write_the_host_region(home, env_file, monkeypatch, caplog, no_host_region, + credentials): + """AC1・AC2・I1・I5: skip は ap-northeast-1 を書き、自動取得の行も出さない。知らせは 1 行""" + if credentials: + (home / '.aws' / 'credentials').write_text(CREDENTIALS) + answers = Answers(monkeypatch) + caplog.set_level(logging.INFO) + + aws._collect_access_keys(env_file, place(ImportPolicy.SKIP)) + + assert env_file.get(keys.AWS_DEFAULT_REGION) == 'ap-northeast-1' + assert answers.region_prompts() == ['AWS_DEFAULT_REGION (デフォルト: ap-northeast-1): '] + assert not any(m.startswith(AUTO) for m in messages(caplog)) + assert messages(caplog).count(SKIPPED) == 1 + assert env_file.get(keys.AWS_ACCESS_KEY_ID) is None + + +def test_non_terminal_ask_does_not_write_the_host_region(with_keys, env_file, monkeypatch, caplog, + no_host_region): + """AC3・I1: 端末でない ask から落ちた skip でも、ホストの region を書かない""" + host = host_import.resolve({}, 'globex', interactive=False) + Answers(monkeypatch, {'取り込みますか': 'y'}) + caplog.set_level(logging.INFO) + + aws._collect_access_keys(env_file, host) + + assert env_file.get(keys.AWS_DEFAULT_REGION) == 'ap-northeast-1' + assert env_file.get(keys.AWS_ACCESS_KEY_ID) is None + assert messages(caplog).count('AWS認証: 標準入力が端末でないため飛ばしました (globex)') == 1 + + +def test_ask_declined_does_not_offer_the_host_region(with_keys, env_file, monkeypatch, caplog, + no_host_region): + """AC4・I1・I5: 鍵の確認で断ると ap-northeast-1 を既定の値にした入力へ進む""" + answers = Answers(monkeypatch, {'取り込みますか': 'N'}) + caplog.set_level(logging.INFO) + + aws._collect_access_keys(env_file, place(ImportPolicy.ASK)) + + assert env_file.get(keys.AWS_DEFAULT_REGION) == 'ap-northeast-1' + assert answers.region_prompts() == ['AWS_DEFAULT_REGION (デフォルト: ap-northeast-1): '] + assert messages(caplog).count(DECLINED) == 1 + assert not any('飛ばしました' in m for m in messages(caplog)) + assert env_file.get(keys.AWS_ACCESS_KEY_ID) is None + + +def test_ask_accepted_writes_the_host_region_without_asking(with_keys, env_file, monkeypatch): + """AC5・I2""" + answers = Answers(monkeypatch, {'取り込みますか': 'y'}) + + aws._collect_access_keys(env_file, place(ImportPolicy.ASK)) + + assert env_file.get(keys.AWS_DEFAULT_REGION) == HOST_REGION + assert answers.region_prompts() == [] + assert env_file.get(keys.AWS_ACCESS_KEY_ID) == 'AKIADEFAULT' + + +@pytest.mark.parametrize('answer, expected', [('', HOST_REGION), ('us-east-1', 'us-east-1')]) +def test_ask_without_keys_offers_the_host_region_as_the_default(home, env_file, monkeypatch, + answer, expected): + """AC6・I3: 鍵が無く確認を出さないときは、ホストの region を入力の既定の値として見せる""" + answers = Answers(monkeypatch, {REGION_PROMPT: answer}) + + aws._collect_access_keys(env_file, place(ImportPolicy.ASK)) + + assert env_file.get(keys.AWS_DEFAULT_REGION) == expected + assert answers.region_prompts() == [ + f'AWS_DEFAULT_REGION (デフォルト: {HOST_REGION}、~/.aws/config の [default] から): '] + + +def test_ask_without_keys_or_host_region_uses_the_fixed_default(home, env_file, monkeypatch): + """I3: ホストに region が無ければ既定の値は ap-northeast-1""" + (home / '.aws' / 'config').write_text('[default]\noutput = json\n') + answers = Answers(monkeypatch) + + aws._collect_access_keys(env_file, place(ImportPolicy.ASK)) + + assert env_file.get(keys.AWS_DEFAULT_REGION) == 'ap-northeast-1' + assert answers.region_prompts() == ['AWS_DEFAULT_REGION (デフォルト: ap-northeast-1): '] + + +@pytest.mark.parametrize('credentials', [False, True]) +def test_import_writes_the_host_region_without_asking(home, env_file, monkeypatch, credentials): + """AC7・I2・退行 1: import は鍵とホストの region を書く""" + if credentials: + (home / '.aws' / 'credentials').write_text(CREDENTIALS) + answers = Answers(monkeypatch) + + aws._collect_access_keys(env_file, place(ImportPolicy.IMPORT)) + + assert env_file.get(keys.AWS_DEFAULT_REGION) == HOST_REGION + assert answers.region_prompts() == [] + assert not any(p.startswith('取り込みますか') for p in answers.prompts) + assert env_file.get(keys.AWS_ACCESS_KEY_ID) == ('AKIADEFAULT' if credentials else None) + + +@pytest.mark.parametrize('policy', list(ImportPolicy)) +def test_an_existing_region_is_kept(with_keys, env_file, monkeypatch, caplog, no_host_region, + policy): + """退行 2・I4: 方法 3 は参照の region を残し、ホストを読まず、入力も出さない""" + env_file.set(keys.AWS_DEFAULT_REGION, 'us-west-2') + answers = Answers(monkeypatch, {'取り込みますか': 'y'}) + caplog.set_level(logging.INFO) + + aws._collect_access_keys(env_file, place(policy)) + + assert env_file.get(keys.AWS_DEFAULT_REGION) == 'us-west-2' + assert answers.region_prompts() == [] + assert 'AWS_DEFAULT_REGION: 設定済み (us-west-2)' in messages(caplog) + # skip の知らせは鍵の確認が出す 1 行だけ (region では足さない) + assert messages(caplog).count(SKIPPED) == (1 if policy is ImportPolicy.SKIP else 0) + + +# --------------------------------------------------------------------------- +# 方法 2 (SSO Profile) +# --------------------------------------------------------------------------- + +def sso(env_file, policy, monkeypatch, region_answer=''): + answers = Answers(monkeypatch, {'AWS_PROFILE': 'dev', REGION_PROMPT: region_answer}) + aws._collect_sso_profile(env_file, place(policy)) + assert env_file.get(keys.AWS_PROFILE) == 'dev' + return answers + + +@pytest.mark.parametrize('existing', [None, 'us-west-2']) +def test_sso_skip_does_not_write_the_host_region(home, env_file, monkeypatch, caplog, + no_host_region, existing): + """AC8・I1・I4・I5""" + if existing: + env_file.set(keys.AWS_DEFAULT_REGION, existing) + caplog.set_level(logging.INFO) + + answers = sso(env_file, ImportPolicy.SKIP, monkeypatch) + + assert env_file.get(keys.AWS_DEFAULT_REGION) == 'ap-northeast-1' + assert answers.region_prompts() == ['AWS_DEFAULT_REGION (デフォルト: ap-northeast-1): '] + assert messages(caplog).count(SKIPPED) == 1 + assert not any('設定済み' in m for m in messages(caplog)) + + +@pytest.mark.parametrize('existing', [None, 'us-west-2']) +def test_sso_ask_offers_the_host_region_as_the_default(home, env_file, monkeypatch, caplog, + existing): + """AC9・I3・I4""" + if existing: + env_file.set(keys.AWS_DEFAULT_REGION, existing) + caplog.set_level(logging.INFO) + + answers = sso(env_file, ImportPolicy.ASK, monkeypatch) + + assert env_file.get(keys.AWS_DEFAULT_REGION) == HOST_REGION + assert answers.region_prompts() == [ + f'AWS_DEFAULT_REGION (デフォルト: {HOST_REGION}、~/.aws/config の [profile dev] から): '] + assert not any('設定済み' in m or '飛ばしました' in m for m in messages(caplog)) + + +def test_sso_ask_takes_a_typed_region(home, env_file, monkeypatch): + """I3: 入れた値はその値を書く""" + sso(env_file, ImportPolicy.ASK, monkeypatch, region_answer='us-east-1') + + assert env_file.get(keys.AWS_DEFAULT_REGION) == 'us-east-1' + + +@pytest.mark.parametrize('existing', [None, 'us-west-2']) +def test_sso_import_writes_the_host_region_without_asking(home, env_file, monkeypatch, caplog, + existing): + """AC10・I2・I4""" + if existing: + env_file.set(keys.AWS_DEFAULT_REGION, existing) + caplog.set_level(logging.INFO) + + answers = sso(env_file, ImportPolicy.IMPORT, monkeypatch) + + assert env_file.get(keys.AWS_DEFAULT_REGION) == HOST_REGION + assert answers.region_prompts() == [] + assert f'AWS_DEFAULT_REGION: 自動取得完了 ({HOST_REGION})' in messages(caplog) + assert not any('設定済み' in m for m in messages(caplog)) + + +def test_sso_without_a_profile_name_decides_no_region(home, env_file, monkeypatch): + """プロファイル名が空なら何も書かずに戻る""" + answers = Answers(monkeypatch) + + aws._collect_sso_profile(env_file, place(ImportPolicy.IMPORT)) + + assert env_file.get(keys.AWS_PROFILE) is None + assert env_file.get(keys.AWS_DEFAULT_REGION) is None + assert answers.region_prompts() == [] + + +# --------------------------------------------------------------------------- +# 節の名前と region の読み取り +# --------------------------------------------------------------------------- + +def test_find_profile_region_returns_the_section(home): + (home / '.aws' / 'config').write_text(CONFIG + '\n[bare]\nregion = eu-west-1\n') + parser = aws.AWSConfigParser() + + assert parser.find_profile_region('default') == ('default', HOST_REGION) + assert parser.find_profile_region('dev') == ('profile dev', HOST_REGION) + assert parser.find_profile_region('bare') == ('bare', 'eu-west-1') + assert parser.find_profile_region('missing') is None + assert parser.get_profile_region('dev') == HOST_REGION + assert parser.get_profile_region('missing') is None diff --git a/tests/env/test_gcp_credential_files.py b/tests/env/test_gcp_credential_files.py new file mode 100644 index 00000000..de46698a --- /dev/null +++ b/tests/env/test_gcp_credential_files.py @@ -0,0 +1,112 @@ +"""GCP の鍵ファイルの発見と、正規化・衝突の警告 (#334 I6〜I9)""" + +from __future__ import annotations + +import builtins +import json +import logging + +import pytest + +from devbase.env import keys +from devbase.env.collectors import google +from devbase.env.host_import import HostImport, ImportPolicy +from devbase.env.store import EnvFile + +RENAMED = "プロファイル名 'my-proj' を 'my_proj' に正規化しました" + + +@pytest.fixture +def creds(tmp_path, monkeypatch): + path = tmp_path / 'gcp-credentials' + path.mkdir() + monkeypatch.setattr(google, 'GCP_CREDENTIALS_DIR', path) + monkeypatch.setattr(google, 'LEGACY_CREDENTIALS_FILE', tmp_path / 'google_credential.json') + return path + + +def write_key(path, project='p'): + path.write_text(json.dumps({'project_id': project})) + + +def warnings(caplog) -> list: + return [r.getMessage() for r in caplog.records if r.levelno >= logging.WARNING] + + +def test_discovery_prints_and_logs_nothing(creds, caplog, capsys): + """I6: 発見は正規化と衝突を値として返し、何も出力しない""" + for name in ('a-b.json', 'a_b.json', 'my-proj.json', 'plain.json', 'note.txt'): + write_key(creds / name) + (creds / 'broken.json').write_text('{') + caplog.set_level(logging.DEBUG) + + found = google.find_credential_files() + + assert list(found.files) == ['a_b', 'broken', 'my_proj', 'plain'] + assert found.files['a_b'] == creds / 'a-b.json' + assert found.renamed == {'a_b': 'a-b', 'my_proj': 'my-proj'} + assert found.collisions == {'a_b': [creds / 'a_b.json']} + assert caplog.records == [] + assert capsys.readouterr() == ('', '') + + +def test_lookup_falls_back_to_the_legacy_file_for_default(creds, tmp_path): + """I9: ディレクトリに default が無ければ ~/google_credential.json を引く""" + write_key(creds / 'plain.json') + write_key(tmp_path / 'google_credential.json') + + found = google.find_credential_files() + + assert found.lookup('default') == tmp_path / 'google_credential.json' + assert found.lookup('plain') == creds / 'plain.json' + assert found.lookup('missing') is None + + +def test_the_legacy_file_is_found_when_the_directory_has_no_keys(creds, tmp_path): + write_key(tmp_path / 'google_credential.json') + + assert google.find_credential_files().files == { + 'default': tmp_path / 'google_credential.json'} + + +def test_warnings_are_only_for_the_chosen_names(creds, caplog): + """I7・I8: 取り込むと決めた名前についてだけ、1 回ずつ出す""" + for name in ('a-b.json', 'a_b.json', 'my-proj.json', 'plain.json'): + write_key(creds / name) + found = google.find_credential_files() + + google._warn_for_chosen(['plain'], found) + assert warnings(caplog) == [] + + google._warn_for_chosen(['a_b', 'my_proj'], found) + assert warnings(caplog) == [ + "プロファイル名 'a-b' を 'a_b' に正規化しました", + f"プロファイル名 'a_b' が衝突しています: '{creds / 'a-b.json'}' と '{creds / 'a_b.json'}' " + "(後者をスキップ)", + RENAMED, + ] + + +@pytest.mark.parametrize('policy, answer, expected', [ + (ImportPolicy.SKIP, '', 0), + (ImportPolicy.ASK, '', 0), + (ImportPolicy.ASK, '2', 0), # plain だけ + (ImportPolicy.ASK, '1', 1), # my_proj + (ImportPolicy.IMPORT, '', 1), +]) +def test_the_collector_warns_once_after_choosing(creds, tmp_path, monkeypatch, caplog, + policy, answer, expected): + """AC11〜AC15・I7: 警告は取り込むと決めた後に、決めた名前についてだけ出す""" + write_key(creds / 'my-proj.json', 'proj-a') + write_key(creds / 'plain.json', 'proj-b') + monkeypatch.setattr(builtins, 'input', + lambda prompt='': answer if '取り込む番号' in prompt else '') + env_file = EnvFile(tmp_path / '.env') + + google.collect_google_credentials(env_file, host=HostImport(policy, 'globex')) + + assert warnings(caplog).count(RENAMED) == expected + if expected: + assert env_file.get(keys.gcp_credentials_key('my_proj')) + assert env_file.get(keys.GCP_ACTIVE_PROFILE) == 'my_proj' + assert env_file.get(keys.GOOGLE_CLOUD_PROJECT) == 'proj-a' From b8ba103bb2c28ffce07b1606b7c93a672d78435e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 2 Oct 2026 15:50:39 +0900 Subject: [PATCH 10/17] =?UTF-8?q?docs:=20AWS=20=E3=81=AE=20region=20?= =?UTF-8?q?=E3=81=A8=20GCP=20=E3=81=AE=E6=AD=A3=E8=A6=8F=E5=8C=96=E3=81=AE?= =?UTF-8?q?=E8=AD=A6=E5=91=8A=E3=81=8C=E5=8F=96=E3=82=8A=E8=BE=BC=E3=81=BF?= =?UTF-8?q?=E3=81=AE=E6=96=B9=E9=87=9D=E3=81=AB=E5=BE=93=E3=81=86=E3=81=93?= =?UTF-8?q?=E3=81=A8=E3=82=92=E6=9B=B8=E3=81=8F=20(#334)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 ++++++++++ docs/specifications/secret-backend.md | 17 +++++++++++++++-- docs/user/cli-reference/03-env.md | 2 +- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfa3ad58..f10a8591 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,16 @@ (終了した・見つからない・時間切れ)と、補う手順(`devbase project post-start `)を出して、 終了コード 1 で終わります。1 台が落ちても残りのコンテナの起動は最長 60 秒まで待つため、失敗で終わる までの時間はその分延びます。 +- **`devbase env init` の AWS の `2` SSO Profile と `3` Access Key が、取り込みの方針によらず + `~/.aws/config` の region を `AWS_DEFAULT_REGION` へ書いていたのを直しました(#334)。** `skip` のグループと、 + `3` で鍵の取り込みを断ったときは `~/.aws/config` を読まずに region を尋ねます(既定 `ap-northeast-1`)。 + `ask` のグループで鍵の確認が無いとき(`2`、鍵の無い `3`)は、`~/.aws/config` の region を既定の値として + 見せてから尋ねます。`import` のグループは今までどおり尋ねずに書きます。`2` の `skip` では + `AWS認証: 取り込まない設定のため飛ばしました` の 1 行が出るようになりました。 +- **GCP の鍵ファイル名の正規化の警告(`プロファイル名 '…' を … に正規化しました`)と衝突の警告が、 + 取り込まない鍵にも、1 回の `env init` で 2 回ずつ出ていたのを直しました(#334)。** 取り込むと決めた鍵に + ついてだけ 1 回出し、`env sync` では出しません。正規化した名前が衝突したとき、`env sync` も `env init` と + 同じくファイル名の昇順で先の鍵ファイルを引くようになりました。 ## [4.0.0] - 2026-09-28 diff --git a/docs/specifications/secret-backend.md b/docs/specifications/secret-backend.md index b657616c..eed697b2 100644 --- a/docs/specifications/secret-backend.md +++ b/docs/specifications/secret-backend.md @@ -710,8 +710,8 @@ AWS は取り込みの選択の範囲だけで比べて入れ直す。控えの | GCP(候補なし) | 鍵ファイルのパスを手で入れる(空で飛ばす) | 同左 | 同左 | | AWS の認証方法の既定 | `4`(スキップ) | `4` | `1` | | AWS `1` Config Files | `~/.aws/config` のプロファイルの一覧 → 番号の選択(`all` で丸ごと、空は取り込まない)→ 含めた連なりを 1 行ずつ出す → `AWS_PROFILE` | 何も書かない | 丸ごと | -| AWS `2` SSO Profile | プロファイル名を手で入れる | 同左 | 同左 | -| AWS `3` Access Key | `~/.aws/credentials` の `[default]` の鍵があれば y/N(既定 N)。N なら手入力 | 手入力 | 自動で拾う | +| AWS `2` SSO Profile | プロファイル名を手で入れる → region の入力(既定の値はそのプロファイルのホストの region) | プロファイル名を手で入れる → region の入力(既定 `ap-northeast-1`) | プロファイル名を手で入れる → ホストの region を自動で拾う | +| AWS `3` Access Key | `~/.aws/credentials` の `[default]` の鍵があれば y/N(既定 N)。y なら鍵と `[default]` の region を拾い、N なら鍵を手入力して region の入力(既定 `ap-northeast-1`)。鍵が無ければ鍵を手入力して region の入力(既定の値は `[default]` のホストの region) | 鍵を手入力 → region の入力(既定 `ap-northeast-1`) | 鍵と `[default]` の region を自動で拾う | | Git(取り込める値あり) | 取り込めるキーの名前を並べて y/N(既定 N)。N なら Git の値を 1 つも書かず、手入力へも進まない | 何も書かない | 自動で拾う | | Git(取り込める値なし) | 手入力 | 同左 | 同左 | @@ -730,6 +730,19 @@ AWS は取り込みの選択の範囲だけで比べて入れ直す。控えの 無ければ `含めません: [<節>] が ~/.aws にありません` を出して続ける - `import` の質問の並びと、同じ入力に対して書くキーと値は、方針を持たなかった頃と同じである(アクティブ プロファイルの文に `none` の案内が加わる) +- AWS `2`・`3` の region(`AWS_DEFAULT_REGION`)も取り込みの方針に従う。ホストの region は `~/.aws/config` の + 選んだプロファイルの節(`3` は `[default]`、`2` は `[profile <名前>]`、無ければ `[<名前>]`)の `region` である。 + `skip` と、`3` の鍵の確認で断ったときはホストの region を読まない。`ask` でホストの region を既定の値として + 見せる入力は `AWS_DEFAULT_REGION (デフォルト: <値>、~/.aws/config の [<節>] から): ` で、空の答えはその値、 + 入れた値はその値を書く。ホストに region が無ければ既定の値は `ap-northeast-1` である +- AWS `3` は参照に `AWS_DEFAULT_REGION` が既にあればその値を残し、ホストを読まない。`2` は参照の region を + 見ずに、上の規則で決め直す(前の認証方法の region を新しいプロファイルへ持ち越さない) +- AWS `2`・`3` の `skip` の知らせ(`AWS認証: 取り込まない設定のため飛ばしました`)は 1 回の選択で 1 行である。 + `3` で鍵の確認が知らせたときは region のために足さない +- GCP の鍵ファイル名の拡張子を除いた部分は、`[A-Za-z0-9_]` 以外を `_` へ置き換えてプロファイル名にする + (プロファイル名の正規化)。ファイル名の昇順に読み、正規化した名前が衝突したら先のファイルを採る。 + `プロファイル名 '<元>' を '<名前>' に正規化しました` と衝突の警告は、取り込むと決めた名前についてだけ、 + アクティブプロファイルを尋ねる前に 1 回出す。`env sync` と控えの更新は同じ規則で鍵ファイルを引き、警告を出さない `env sync` はソースファイルのハッシュを控えに記録して変更を検出する。`version: 2` では控えを 置き場のグループごとに `$DEVBASE_ROOT/.env.sources..yml` へ分け、それ以外は diff --git a/docs/user/cli-reference/03-env.md b/docs/user/cli-reference/03-env.md index 1ace4ed4..1c388cb2 100644 --- a/docs/user/cli-reference/03-env.md +++ b/docs/user/cli-reference/03-env.md @@ -74,7 +74,7 @@ GCP・AWS・Git のステップは、ホストの `~/gcp-credentials/`・`~/.aws | ステップ | 尋ね方 | |---|---| | GCP | 鍵ファイルを番号で選ぶ(`1,2` か `1 2`、`all` で全部)。1 つも選ばなければ GCP の共通設定(`GOOGLE_CLOUD_LOCATION` など)も書きません。アクティブプロファイルに `none` と入れると GCP を何も書きません。選んだ鍵に無い名前は尋ね直します | -| AWS | 認証方法の既定は `4`(スキップ)。`1` Config Files では `~/.aws/config` のプロファイルを番号で選び、選んだ節と、それが `sso_session` / `source_profile` で指す節だけを `AWS_CONFIG_BASE64` に入れます(含めた節は `含めます: [...]` の行で出ます)。`all` なら `~/.aws` を丸ごと入れます。`3` Access Key は `~/.aws/credentials` の `[default]` の鍵を入れる前に `[y/N]` で確かめます | +| AWS | 認証方法の既定は `4`(スキップ)。`1` Config Files では `~/.aws/config` のプロファイルを番号で選び、選んだ節と、それが `sso_session` / `source_profile` で指す節だけを `AWS_CONFIG_BASE64` に入れます(含めた節は `含めます: [...]` の行で出ます)。`all` なら `~/.aws` を丸ごと入れます。`3` Access Key は `~/.aws/credentials` の `[default]` の鍵を入れる前に `[y/N]` で確かめ、断ると `~/.aws/config` の region も使いません。`2` SSO Profile と鍵の無い `3` では、`~/.aws/config` の region を `AWS_DEFAULT_REGION` の既定の値として見せます(Enter で受け入れます) | | Git | 取り込めるキーの名前を並べて `[y/N]` で 1 回だけ尋ねます。断ると Git の値を 1 つも書きません(後から `devbase env set` で足せます) | 標準入力が端末でないとき(`devbase up` の子プロセスの `env init` など)は、尋ねずに何も取り込みません。 From 930fa699d89db47269e4fca2713c191b3d73324a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 2 Oct 2026 16:14:53 +0900 Subject: [PATCH 11/17] =?UTF-8?q?test(entrypoint):=20=E5=90=8C=E6=99=82?= =?UTF-8?q?=E8=B5=B7=E5=8B=95=E3=81=AE=E3=83=86=E3=82=B9=E3=83=88=E3=81=AE?= =?UTF-8?q?=E6=89=80=E8=A6=81=E3=82=92=E6=B8=9B=E3=82=89=E3=81=97=E3=80=81?= =?UTF-8?q?=E6=80=A7=E8=83=BD=E3=81=AE=E6=AF=94=E8=BC=83=E3=82=92=E4=B8=A6?= =?UTF-8?q?=E5=88=97=E3=81=AE=E8=B2=A0=E8=8D=B7=E3=81=AB=E6=8F=BA=E3=82=8C?= =?UTF-8?q?=E3=81=AA=E3=81=84=E3=82=88=E3=81=86=E3=81=AB=E3=81=99=E3=82=8B?= =?UTF-8?q?=20(#357)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 同時に始める合図を空回りの待ちから名前付きパイプへ替え、待つ間に CPU を使わないようにした - 繰り返す回数を ROUNDS_PAIR 15→4・ROUNDS 8→4 にした。修正前の entrypoint.sh に当てると、 受け入れ条件 1・2 のケースは負荷の無いとき macOS・Linux とも 1〜2 回目で落ちた。その倍を取る - 性能のケースは、子プロセスの CPU 時間 (ユーザーとシステムの和) で修正前と比べる - base イメージのテストは同時起動を 10→2 回、道具の確認を 5→3 回にした macOS でリポジトリ全体の pytest tests/ -q は 106 秒 (以前は関連の 3 ファイルだけで 200 秒)。 Co-Authored-By: Claude Opus 5.5 --- .../containers/test_base_image_link_stage.py | 11 ++++--- .../test_entrypoint_ai_settings_concurrent.py | 33 +++++++++++++------ 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/tests/containers/test_base_image_link_stage.py b/tests/containers/test_base_image_link_stage.py index 4ba34b0f..17bea89a 100644 --- a/tests/containers/test_base_image_link_stage.py +++ b/tests/containers/test_base_image_link_stage.py @@ -24,7 +24,8 @@ IMAGE = "devbase-base:latest" ENTRYPOINT = Path(__file__).resolve().parents[2] / "containers" / "base" / "entrypoint.sh" -ROUNDS = 10 +# 1 回ぶんに docker run が 10 回要る。並列で流すとほかのケースと重なり 1 回 15 秒を超えるため絞る +ROUNDS = 2 CONTAINERS = 8 # 全ケースを同じワーカーへ寄せ、8 個のコンテナの起動をほかのケースと重ねない @@ -124,7 +125,7 @@ def test_eight_containers_relink_a_settings_file(volumes): DEVBASE_ENTRYPOINT_LIB_ONLY=1 . /entrypoint.sh set +e for kind in file dir; do - for r in 1 2 3 4 5; do + for r in 1 2 3; do d=$(mktemp -d) for i in 1 2 3 4 5 6 7 8; do ( while [ ! -e "$d/go" ]; do :; done @@ -169,7 +170,7 @@ def test_eight_containers_relink_a_settings_file(volumes): } one=$(mktemp -d); make_home "$one/h0" ( set -e; devbase_setup_ai_settings "$one/h0" "$one/ai" "$one/grp" t357 ) >/dev/null 2>&1 || echo "single failed" -for r in 1 2 3 4 5; do +for r in 1 2 3; do b=$(mktemp -d) for i in 1 2 3 4 5 6 7 8; do make_home "$b/h$i" @@ -193,7 +194,7 @@ def test_only_one_process_creates_an_entry_with_the_image_tools(docker_ready): IMAGE, "-c", _CREATE_PROBE, timeout=300, check=False) assert r.returncode == 0, r.stderr lines = [line.split("\t") for line in r.stdout.splitlines() if "\t" in line] - assert len(lines) == 10, r.stdout + r.stderr + assert len(lines) == 6, r.stdout + r.stderr for kind, rcs in lines: assert rcs == "01111111", f"{kind}: {rcs}" @@ -205,6 +206,6 @@ def test_eight_stages_without_flock_match_a_single_run_with_the_image_tools(dock assert r.returncode == 0, r.stderr assert "single failed" not in r.stdout lines = [line.split("\t") for line in r.stdout.splitlines() if line.startswith("seed\t")] - assert len(lines) == 5, r.stdout + r.stderr + assert len(lines) == 3, r.stdout + r.stderr for _, rcs, same in lines: assert rcs == "00000000" and same == "same", (rcs, same) diff --git a/tests/containers/test_entrypoint_ai_settings_concurrent.py b/tests/containers/test_entrypoint_ai_settings_concurrent.py index 89dacf44..7e6f3004 100644 --- a/tests/containers/test_entrypoint_ai_settings_concurrent.py +++ b/tests/containers/test_entrypoint_ai_settings_concurrent.py @@ -14,6 +14,7 @@ from __future__ import annotations import os +import resource import shutil import subprocess import time @@ -25,9 +26,10 @@ SHARED_CLAUDE = ["plugins", "skills", "commands", "CLAUDE.md", "settings.json"] -# 同時実行を繰り返す回数。2 個のときは重なりにくいため多めにする -ROUNDS_PAIR = 15 -ROUNDS = 8 +# 同時実行を繰り返す回数。修正前の entrypoint.sh に当てると、受け入れ条件 1・2 のケースは負荷の +# 無いときに macOS・Linux とも 1〜2 回目で落ちた (各 6〜10 回測った)。その倍を取る +ROUNDS_PAIR = 4 +ROUNDS = 4 # ほかのプロセスとの重なりで 1 回だけ遅れても落ちないよう、要求の 30 秒をそのまま使う TIME_LIMIT = 30.0 @@ -190,12 +192,15 @@ def run_concurrent(work: Path, jobs: list[str], *, path: str | None = None, """``jobs`` (bash の断片) を同時に始め、終了コードと出力と全体の所要を返す。 断片はそれぞれ ``set -e`` のサブシェルで走る。``old_funcs`` を読めば修正前の手順になる。 - 合図のファイルができるまで全員が待ち、同じ瞬間に走り出す。 + 全員が名前付きパイプを読む側で開いて待ち、書く側が開いた瞬間に一斉に走り出す。 + 待つ間に CPU を使わないため、並列で流すほかのケースの負荷を増やさない。書く側は全員が + 終わるまで開いたままにし、遅れて着いた断片も止まらずに走る。 """ work.mkdir(parents=True, exist_ok=True) old = work / "old_funcs.sh" old.write_text(OLD_FUNCS) go = work / "go" + os.mkfifo(go) # entrypoint.sh は読み込んだシェルに set -e を掛ける。失敗した断片の終了コードを残すため外す lines = [f'DEVBASE_ENTRYPOINT_LIB_ONLY=1 . "{ENTRYPOINT}"', "set +e", pre, f'old_funcs() {{ . "{old}"; }}'] @@ -203,13 +208,14 @@ def run_concurrent(work: Path, jobs: list[str], *, path: str | None = None, lines.append(f"job_{i}() {{\n{body}\n}}") for i in range(len(jobs)): lines.append( - f'{{ while [ ! -e "{go}" ]; do :; done; ' + f'{{ : <"{go}"; ' f'( set -e; job_{i} ) >"{work}/{i}.out" 2>"{work}/{i}.err"; ' f'echo $? >"{work}/{i}.rc"; }} &' ) lines.append("sleep 0.3") - lines.append(f': > "{go}"') + lines.append(f'exec 9>"{go}"') lines.append("wait") + lines.append("exec 9>&-") script = "\n".join(lines) + "\n" started = time.monotonic() subprocess.run([_bash(), "-c", script], cwd=work, env=_base_env(path), @@ -717,16 +723,23 @@ def test_old_procedure_runs_after_the_new_stage(tmp_path): def test_single_stage_is_not_much_slower_than_the_old_procedure(tmp_path): - """性能: 1 個だけの所要が、修正前の手順の所要に 1 秒を足した値を超えない""" + """性能: 1 個だけの所要が、修正前の手順の所要に 1 秒を足した値を超えない + + 所要は、走らせたシェルとその子が使った CPU 時間 (ユーザーとシステムの和) で測る。経過時間は + 並列で流すほかのケースの負荷で倍近くまで揺れる (修正後 2.97 秒 / 修正前 1.62 秒で落ちた)。 + 相手のいない 1 個のリンクの段は、ロックを待たず (``flock -w`` はすぐ取れる) 眠らないため、 + 所要のほとんどが CPU 時間であり、比べる意味は変わらない。 + """ def measure(old: bool, i: int) -> float: v = Volumes(tmp_path / f"{'old' if old else 'new'}-{i}") make_home_claude(v.home(0), plugins=3) - started = time.monotonic() + before = resource.getrusage(resource.RUSAGE_CHILDREN) r = run_script(setup_cmd(v.home(0), v.ai, v.grp), v.base, old=old) + after = resource.getrusage(resource.RUSAGE_CHILDREN) assert r.returncode == 0, r.stderr - return time.monotonic() - started + return (after.ru_utime - before.ru_utime) + (after.ru_stime - before.ru_stime) - # 並列で流すとほかのケースの負荷で 1 回ずつの所要がぶれる。交互に測り、最小を比べる + # 交互に測り、最小を比べる old_ts, new_ts = [], [] for i in range(5): old_ts.append(measure(True, i)) From 66077034a95a114eaf3ca6cfe0a4c1bc866f8653 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 2 Oct 2026 16:32:48 +0900 Subject: [PATCH 12/17] =?UTF-8?q?Test:=20=E7=8F=BE=E7=8A=B6=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E8=B6=B3=E3=81=99?= =?UTF-8?q?=20=E2=80=94=20etc/devbase-completion.bash#=5Fdevbase=5Fcomplet?= =?UTF-8?q?ions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item-Id: I-004 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- tests/cli/test_completion.py | 38 ++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/cli/test_completion.py b/tests/cli/test_completion.py index 80d2627a..df09bf13 100644 --- a/tests/cli/test_completion.py +++ b/tests/cli/test_completion.py @@ -217,3 +217,41 @@ def test_zsh_completion_mentions_open(): text = ZSH_COMPLETION.read_text() assert text.count("'open:") == 3 # トップレベル / project / container assert "--open-index" in text + + +# 「- で始まれば旗、そうでなければプロジェクト名」の分岐の現状固定(I-004) +_NAMES = {"web", "api", "linked"} + + +@pytest.mark.parametrize("words, cword, expected", [ + ("devbase open ''", 2, _NAMES), + ("devbase open '-'", 2, {"--open-index", "--context"}), + ("devbase ps ''", 2, _NAMES), + ("devbase ps '-'", 2, {"--all", "-a"}), + ("devbase project ps ''", 3, _NAMES), + ("devbase project ps '-'", 3, {"--all", "-a"}), + ("devbase project logs ''", 3, _NAMES), + ("devbase project logs '-'", 3, {"--follow", "-f", "--tail"}), + ("devbase project open ''", 3, _NAMES), + ("devbase project open '-'", 3, {"--open-index", "--context"}), + ("devbase project post-start ''", 3, _NAMES), + ("devbase project post-start '-'", 3, {"--context"}), + ("devbase project scale ''", 3, _NAMES), + ("devbase project up ''", 3, _NAMES), + ("devbase project down ''", 3, _NAMES), +]) +def test_bash_flag_or_project_name_branch(fake_root, words, cword, expected): + assert set(_bash_complete(words, cword, fake_root)) == expected + + +@pytest.mark.parametrize("words, cword, expected", [ + ("devbase container open '-'", 3, {"--open-index", "--context"}), + ("devbase container ps '-'", 3, {"--all", "-a"}), + ("devbase ct logs '-'", 3, {"--follow", "-f", "--tail"}), + ("devbase container open ''", 3, set()), + ("devbase container ps ''", 3, set()), + ("devbase ct logs ''", 3, set()), + ("devbase ps web ''", 3, set()), +]) +def test_bash_container_flags_only_without_names(fake_root, words, cword, expected): + assert set(_bash_complete(words, cword, fake_root)) == expected From fb147a03a32a0cf32f5ac619b913f61578df4099 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 2 Oct 2026 16:33:45 +0900 Subject: [PATCH 13/17] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20lib/devbase/commands/container.py#cmd=5Fscale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd_up と cmd_scale が別々に持っていた「パイプラインを呼び ContainerStartupError を 部分起動の結果へ組み直す」手順を _start_or_partial へ、「起動の失敗があれば最後に 報告して 1 を返す」手順を _finish_partial へ抽出した。振る舞いは変えない。 Item-Id: I-001 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- lib/devbase/commands/container.py | 63 ++++++++++++++++++------------- 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 70b6714c..1a15d0e3 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -1334,6 +1334,28 @@ def _report_startup_failure(label: str, error: ContainerStartupError, "(全インスタンスを作り直します)", project_name) +def _start_or_partial(run_pipeline, scale: int): + """起動のパイプラインを呼び、``(override_file, started, startup_error)`` を返す (#371)。 + + すべて起動できれば ``(生成した構成, [1..scale], None)``。起動できなかったインスタンスが + あれば ``(_SCALE_COMPOSE_FILE, 起動できた番号, ContainerStartupError)`` を返す。 + """ + try: + override_file = run_pipeline() + except ContainerStartupError as e: + return _SCALE_COMPOSE_FILE, list(e.ready), e + return override_file, list(range(1, scale + 1)), None + + +def _finish_partial(label: str, error: Optional[ContainerStartupError], + project_name: str) -> bool: + """起動できなかったインスタンスがあれば出力の最後に報告し、``True`` を返す。""" + if error is None: + return False + _report_startup_failure(label, error, project_name) + return True + + def _run_deploy_pipeline(project_name: str, scale: int, config, target: docker_context.DockerTarget, dev_service_name: str) -> Path: @@ -1418,17 +1440,13 @@ def cmd_up(project_name: str = None, scale: int = None, _auto_snapshot() try: - startup_error = None - try: - override_file = _run_deploy_pipeline( - project_name, scale, config, target, dev_service_name) - started = list(range(1, scale + 1)) - except ContainerStartupError as e: - # 起動できたインスタンスへは後処理を行ってから失敗として終える (#371) - startup_error = e - override_file = _SCALE_COMPOSE_FILE - started = list(e.ready) - _warn_partial_start(e, started, dev_service_name) + # 起動できたインスタンスへは後処理を行ってから失敗として終える (#371) + override_file, started, startup_error = _start_or_partial( + lambda: _run_deploy_pipeline( + project_name, scale, config, target, dev_service_name), + scale) + if startup_error is not None: + _warn_partial_start(startup_error, started, dev_service_name) _run_post_start(project_name, scale, started, dev_service_name, config, override_file) @@ -1437,8 +1455,7 @@ def cmd_up(project_name: str = None, scale: int = None, config, compose_file=override_file, docker_context_name=target.context, started=started) - if startup_error is not None: - _report_startup_failure('Deploy', startup_error, project_name) + if _finish_partial('Deploy', startup_error, project_name): return 1 logger.info("=== Deploy completed successfully ===") return 0 @@ -1844,17 +1861,12 @@ def cmd_scale(new_scale: int, project_name: str = None, return 1 try: - startup_error = None - try: - override_file = _run_scale_pipeline(project_name, new_scale, current_scale, - config, target, dev_service_name) - if override_file is None: - return 1 - started = list(range(1, new_scale + 1)) - except ContainerStartupError as e: - startup_error = e - override_file = _SCALE_COMPOSE_FILE - started = list(e.ready) + override_file, started, startup_error = _start_or_partial( + lambda: _run_scale_pipeline(project_name, new_scale, current_scale, + config, target, dev_service_name), + new_scale) + if override_file is None: + return 1 # 後処理の対象は増やしたインスタンスのうち起動できたもの。既存のものは up か # 前の scale で済んでいる (#224) @@ -1865,8 +1877,7 @@ def cmd_scale(new_scale: int, project_name: str = None, _run_post_start(project_name, new_scale, targets, dev_service_name, config, override_file) - if startup_error is not None: - _report_startup_failure('Scale', startup_error, project_name) + if _finish_partial('Scale', startup_error, project_name): return 1 logger.info("=== Scale completed successfully ===") From 3fd5e188ee0853b29bc8efa09e09b378ed90f5ce Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 2 Oct 2026 16:33:57 +0900 Subject: [PATCH 14/17] =?UTF-8?q?Refactor:=20introduce=5Fnamed=5Fconstant?= =?UTF-8?q?=20=E2=80=94=20lib/devbase/env/collectors/aws.py#=5Fcollect=5Fp?= =?UTF-8?q?rofile=5Fand=5Fregion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 方法 1 の入力の文言と既定値に直書きしていた 'ap-northeast-1' を、#334 で定義した DEFAULT_REGION へ置き換えた。振る舞いは変えない。 Item-Id: I-002 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- lib/devbase/env/collectors/aws.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/devbase/env/collectors/aws.py b/lib/devbase/env/collectors/aws.py index c4d2b213..1bdf466d 100644 --- a/lib/devbase/env/collectors/aws.py +++ b/lib/devbase/env/collectors/aws.py @@ -240,7 +240,7 @@ def _collect_profile_and_region(env_file: EnvFile, parser: "AWSConfigParser", env_file.set(keys.AWS_DEFAULT_REGION, region) logger.info("%s: 自動取得完了 (%s)", keys.AWS_DEFAULT_REGION, region) else: - region = safe_input(f"{keys.AWS_DEFAULT_REGION} (デフォルト: ap-northeast-1): ", "ap-northeast-1") + region = safe_input(f"{keys.AWS_DEFAULT_REGION} (デフォルト: {DEFAULT_REGION}): ", DEFAULT_REGION) env_file.set(keys.AWS_DEFAULT_REGION, region) From 3a6136ae712c01bd50ba57faf32555983ea1228e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 2 Oct 2026 16:34:16 +0900 Subject: [PATCH 15/17] =?UTF-8?q?Refactor:=20centralize=5Fconfiguration=20?= =?UTF-8?q?=E2=80=94=20lib/devbase/commands/container.py#=5FENTRYPOINT=5FR?= =?UTF-8?q?EADY=5FFILE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit entrypoint の完了の印 '/tmp/entrypoint-ready' を utils/docker.py の ENTRYPOINT_READY_FILE 1 か所に置き、wait_for_containers_ready の既定引数と post-start の確認の両方がそれを使うようにした。振る舞いは変えない。 Item-Id: I-003 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- lib/devbase/commands/container.py | 6 ++---- lib/devbase/utils/docker.py | 5 ++++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 1a15d0e3..4f5ebc86 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -20,6 +20,7 @@ get_dev_service_name, ) from devbase.utils.docker import ( + ENTRYPOINT_READY_FILE, ContainerStartupError, compose_env, docker_compose, @@ -1514,9 +1515,6 @@ def cmd_login(index: str = '1', context: Optional[str] = None) -> int: # cmd_post_start # --------------------------------------------------------------------------- -_ENTRYPOINT_READY_FILE = '/tmp/entrypoint-ready' - - def _entrypoint_ready(container_name: str) -> bool: """動いているコンテナの entrypoint の完了の印を 1 回だけ確かめる (待たない)。 @@ -1524,7 +1522,7 @@ def _entrypoint_ready(container_name: str) -> bool: """ try: result = subprocess.run( - ['docker', 'exec', container_name, 'test', '-f', _ENTRYPOINT_READY_FILE], + ['docker', 'exec', container_name, 'test', '-f', ENTRYPOINT_READY_FILE], capture_output=True, text=True, check=False) except (OSError, subprocess.SubprocessError): return False diff --git a/lib/devbase/utils/docker.py b/lib/devbase/utils/docker.py index af5b92f2..5385fea6 100644 --- a/lib/devbase/utils/docker.py +++ b/lib/devbase/utils/docker.py @@ -17,6 +17,9 @@ #: ``.env`` の値をどちらも無効にする。有効なプロファイルは経路ごとに ``--profile`` で決める。 NO_PROFILE = '__devbase_none__' +#: entrypoint が処理を終えたときにコンテナ内へ置く完了の印。起動の待ちと post-start が確かめる。 +ENTRYPOINT_READY_FILE = '/tmp/entrypoint-ready' + def compose_env(environ: Optional[Mapping[str, str]] = None) -> Dict[str, str]: """devbase が ``docker compose`` を呼ぶときの子プロセスの環境を返す。 @@ -234,7 +237,7 @@ def _is_ready(service_name: str, ready_file: str, compose_file: Optional[Path]) def wait_for_containers_ready( container_prefix: str, scale: int, - ready_file: str = '/tmp/entrypoint-ready', + ready_file: str = ENTRYPOINT_READY_FILE, timeout: int = 60, compose_file: Optional[Path] = None ) -> bool: From f19b899db5d6ab211f7abaa34484fb05238bc8da Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 2 Oct 2026 16:35:21 +0900 Subject: [PATCH 16/17] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20etc/devbase-completion.bash#=5Fdevbase=5Fcompletions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ps / logs / open のフラグ候補を関数内の名前付き変数へ集め、「cur が - で始まれば フラグ候補、names 指定ならそれ以外でプロジェクト名」という候補生成を _devbase_complete_flags へ抽出した。旧 container/ct は名前を補完しない呼び方の まま残し、位置・グループの判定は各呼び出し側に置いた。関数の終了状態も変えない。 Item-Id: I-004 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- etc/devbase-completion.bash | 69 ++++++++++++++----------------------- 1 file changed, 26 insertions(+), 43 deletions(-) diff --git a/etc/devbase-completion.bash b/etc/devbase-completion.bash index 7d44f10f..74f0c484 100644 --- a/etc/devbase-completion.bash +++ b/etc/devbase-completion.bash @@ -21,6 +21,17 @@ _devbase_project_names() { fi } +# cur が `-` で始まるときはフラグ候補 ($1) を COMPREPLY に入れる。 +# 第 3 引数に names を渡すと、それ以外のときにプロジェクト名を補完する。 +_devbase_complete_flags() { + local flags="$1" cur="$2" fallback="${3:-}" + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "$flags" -- "$cur")) + elif [ "$fallback" = "names" ]; then + COMPREPLY=($(compgen -W "$(_devbase_project_names)" -- "$cur")) + fi +} + _devbase_completions() { local cur prev words cword _init_completion 2>/dev/null || { @@ -39,6 +50,10 @@ _devbase_completions() { local plugin_subcommands="list install uninstall update info sync repo" local repo_subcommands="add remove list refresh" local snapshot_subcommands="create list restore copy delete rotate" + # ps / logs / open のフラグ候補 (トップレベル・project・container で共通)。 + local ps_flags="--all -a" + local logs_flags="--follow -f --tail" + local open_flags="--open-index --context" case "$cword" in 1) @@ -56,19 +71,11 @@ _devbase_completions() { ;; # open は [name] と --open-index / --context を取る (PLAN59)。 open) - if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--open-index --context" -- "$cur")) - else - COMPREPLY=($(compgen -W "$(_devbase_project_names)" -- "$cur")) - fi + _devbase_complete_flags "$open_flags" "$cur" names ;; # ps は [name] と -a フラグの両方を取る (project ps と同じ挙動)。 ps) - if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--all -a" -- "$cur")) - else - COMPREPLY=($(compgen -W "$(_devbase_project_names)" -- "$cur")) - fi + _devbase_complete_flags "$ps_flags" "$cur" names ;; # list は位置引数を取らず対話制御フラグのみ。`-*` ガードを外し # 常にフラグ候補を出す (zsh 側 _arguments と挙動を揃える)。 @@ -97,9 +104,7 @@ _devbase_completions() { # トップレベルシノニム ps: `devbase ps web -` (group=ps, cword=3) # でも name 位置が埋まった後にフラグを補完する。project ps と対称化。 if [ "$group" = "ps" ]; then - if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--all -a" -- "$cur")) - fi + _devbase_complete_flags "$ps_flags" "$cur" fi # project subcommand arguments (推奨グループ) if [ "$group" = "project" ]; then @@ -116,18 +121,10 @@ _devbase_completions() { COMPREPLY=($(compgen -W "$(_devbase_project_names)" -- "$cur")) ;; ps) - if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--all -a" -- "$cur")) - else - COMPREPLY=($(compgen -W "$(_devbase_project_names)" -- "$cur")) - fi + _devbase_complete_flags "$ps_flags" "$cur" names ;; logs) - if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--follow -f --tail" -- "$cur")) - else - COMPREPLY=($(compgen -W "$(_devbase_project_names)" -- "$cur")) - fi + _devbase_complete_flags "$logs_flags" "$cur" names ;; # list は位置引数を取らず対話制御フラグのみ。`-*` ガードを外し # 常にフラグ候補を出す (zsh 側 _arguments と挙動を揃える)。 @@ -138,11 +135,7 @@ _devbase_completions() { COMPREPLY=($(compgen -W "up down list" -- "$cur")) ;; open) - if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--open-index --context" -- "$cur")) - else - COMPREPLY=($(compgen -W "$(_devbase_project_names)" -- "$cur")) - fi + _devbase_complete_flags "$open_flags" "$cur" names ;; # post-start は [name] と --context を取る (#371) post-start) @@ -167,19 +160,13 @@ _devbase_completions() { COMPREPLY=($(compgen -W "up down list" -- "$cur")) ;; open) - if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--open-index --context" -- "$cur")) - fi + _devbase_complete_flags "$open_flags" "$cur" ;; ps) - if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--all -a" -- "$cur")) - fi + _devbase_complete_flags "$ps_flags" "$cur" ;; logs) - if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--follow -f --tail" -- "$cur")) - fi + _devbase_complete_flags "$logs_flags" "$cur" ;; esac fi @@ -267,14 +254,10 @@ _devbase_completions() { if [ "$group" = "project" ]; then case "${words[2]}" in ps) - if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--all -a" -- "$cur")) - fi + _devbase_complete_flags "$ps_flags" "$cur" ;; logs) - if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--follow -f --tail" -- "$cur")) - fi + _devbase_complete_flags "$logs_flags" "$cur" ;; esac fi From a756a313ec1ad983ede8029ce7afcee2c5ae08ba Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 2 Oct 2026 16:46:49 +0900 Subject: [PATCH 17/17] =?UTF-8?q?fix:=20entrypoint=20=E3=81=AE=E5=AE=8C?= =?UTF-8?q?=E4=BA=86=E3=81=AE=E5=8D=B0=E3=82=92=E8=B5=B7=E5=8B=95=E3=81=94?= =?UTF-8?q?=E3=81=A8=E3=81=AB=E6=B6=88=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker start で再起動したコンテナに前回の /tmp/entrypoint-ready が残り、 post-start が今回の初期化の途中でも完了と読んで token 配布などを進めていた。 entrypoint の冒頭で印を消し、最後に作り直す。 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + containers/base/entrypoint.sh | 9 ++++- .../test_entrypoint_ready_marker.py | 39 +++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 tests/containers/test_entrypoint_ready_marker.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ba55fa50..3b79b089 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ - **`devbase project post-start [name]` を足しました(#371)。** 動いているコンテナへ、起動の後の処理 (clone できなかったリポジトリの報告・OpenBao の token の配布・VS Code の窓のタイトルの設定)を、 コンテナを作り直さずにやり直します。`./deploy` は走らせず、エディタも開きません。 + `docker start` で再起動したコンテナの初期化の途中に前回の完了の印を読んで先へ進まないよう、 + entrypoint が起動のたびに印を消してから作り直します(base イメージを建て直した後のコンテナから効きます)。 ### Changed - **`containers/lfm` のもとのイメージを `nvidia/cuda:13.4.1-cudnn-devel-ubuntu26.04` に上げました(#337)。** diff --git a/containers/base/entrypoint.sh b/containers/base/entrypoint.sh index 92ccd8bd..ac402988 100644 --- a/containers/base/entrypoint.sh +++ b/containers/base/entrypoint.sh @@ -682,6 +682,13 @@ if [ -n "${DEVBASE_ENTRYPOINT_LIB_ONLY:-}" ]; then return 0 2>/dev/null || exit 0 fi +# 完了の印は起動 1 回ごとの寿命にする。docker start で再起動したコンテナには前回の印が +# /tmp に残るため、消さないとホスト側 (post-start / wait_for_containers_ready) が +# 今回の初期化の途中でも完了と読んでしまう。値は lib/devbase/utils/docker.py の +# ENTRYPOINT_READY_FILE と揃える。 +ENTRYPOINT_READY_FILE=/tmp/entrypoint-ready +rm -f "$ENTRYPOINT_READY_FILE" + # Setup authentication credentials from environment variables USERNAME="${USERNAME:-ubuntu}" @@ -876,6 +883,6 @@ devbase_write_workspace "$DEVBASE_WORK_ROOT" devbase_enter_primary_dir "$DEVBASE_WORK_ROOT" # Signal that entrypoint setup is complete -touch /tmp/entrypoint-ready +touch "$ENTRYPOINT_READY_FILE" exec "$@" diff --git a/tests/containers/test_entrypoint_ready_marker.py b/tests/containers/test_entrypoint_ready_marker.py new file mode 100644 index 00000000..6bdb9dfd --- /dev/null +++ b/tests/containers/test_entrypoint_ready_marker.py @@ -0,0 +1,39 @@ +"""entrypoint の完了の印の寿命 (起動 1 回ごと) + +docker start で再起動したコンテナには前回の ``/tmp/entrypoint-ready`` が残る。 +entrypoint が冒頭で消さないと、ホスト側の post-start が今回の初期化の途中でも +完了と読み、token 配布などを先に走らせてしまう。 +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from devbase.utils.docker import ENTRYPOINT_READY_FILE + +ENTRYPOINT = Path(__file__).resolve().parents[2] / "containers" / "base" / "entrypoint.sh" + + +def _lines() -> list[str]: + return ENTRYPOINT.read_text().splitlines() + + +def _index(pattern: str) -> int: + for i, line in enumerate(_lines()): + if re.search(pattern, line): + return i + raise AssertionError(f"pattern not found: {pattern}") + + +def test_marker_path_matches_host_side(): + assert f"ENTRYPOINT_READY_FILE={ENTRYPOINT_READY_FILE}" in _lines() + + +def test_stale_marker_removed_before_setup_and_touched_last(): + guard = _index(r'DEVBASE_ENTRYPOINT_LIB_ONLY:-') + remove = _index(r'^rm -f "\$ENTRYPOINT_READY_FILE"$') + first_setup = _index(r'^devbase_setup_cloud_config_dirs ') + touch = _index(r'^touch "\$ENTRYPOINT_READY_FILE"$') + exec_line = _index(r'^exec "\$@"$') + assert guard < remove < first_setup < touch < exec_line