From e50998e0dacada5e7a5327b7cfcde14da4a67ef5 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 3 Oct 2026 20:09:15 +0900 Subject: [PATCH 01/14] =?UTF-8?q?Feat:=20devbase=20build=20=E3=81=8C?= =?UTF-8?q?=E7=B6=99=E6=89=BF=E3=81=AE=E9=80=A3=E3=81=AA=E3=82=8A=E3=82=92?= =?UTF-8?q?=E4=B8=8B=E3=81=AE=E6=AE=B5=E3=81=8B=E3=82=89=E5=BB=BA=E3=81=A6?= =?UTF-8?q?=E3=80=81=E7=9B=B4=E3=81=AE=E8=A6=AA=E3=81=AE=E8=AA=AD=E3=81=BF?= =?UTF-8?q?=E6=96=B9=E3=82=92=20--expires=20=E3=81=AE=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E3=81=A8=E6=8F=83=E3=81=88=E3=82=8B=20(#404)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bin/devbase: check_base_image_dependency を read_devbase_parent に置き換え、 resolve_base_chain・build_base_chain で containers/<名前>/Dockerfile をたどって下の段から建てる - 段が無い・循環・名前の形に合わないときは何も建てずに止まる - lib/devbase/utils/dockerfile.py に読み方の正本 DEVBASE_FROM_PATTERN を置き、 _get_base_image_ref と bin/devbase の _DEVBASE_FROM_RE が同じ規則で読む Co-Authored-By: Claude Opus 5.5 --- bin/devbase | 241 +++++++++++++++++++++--------- lib/devbase/commands/container.py | 15 +- lib/devbase/utils/dockerfile.py | 43 ++++++ 3 files changed, 217 insertions(+), 82 deletions(-) create mode 100644 lib/devbase/utils/dockerfile.py diff --git a/bin/devbase b/bin/devbase index b1b4120d..3d3a2f88 100755 --- a/bin/devbase +++ b/bin/devbase @@ -114,19 +114,101 @@ cmd_build() { done set -- "${build_args[@]}" - # Helper: Check if Dockerfile uses devbase-* base images - check_base_image_dependency() { + # Helper: Dockerfile の直の親イメージの参照 (devbase-<名前>:<タグ>) を出す (#404)。 + # 最初に _DEVBASE_FROM_RE に当たった行を読み、タグが無ければ latest を補う。当たる行が + # 無い・ファイルが無いときは 1 (「devbase-* を使わない」の判定で、失敗ではない)。 + # 行末の CR は除いてから当てる (CRLF の Dockerfile)。 + read_devbase_parent() { local dockerfile_path="$1" - if [ -f "$dockerfile_path" ]; then - if grep -q "^FROM devbase-" "$dockerfile_path"; then - # Extract base image name (e.g., "devbase-base", "devbase-general") - grep "^FROM devbase-" "$dockerfile_path" | head -1 | awk '{print $2}' | cut -d: -f1 + [ -f "$dockerfile_path" ] || return 1 + local LC_ALL=C + local line ref + while IFS= read -r line || [ -n "$line" ]; do + line="${line%$'\r'}" + if [[ $line =~ $_DEVBASE_FROM_RE ]]; then + ref="${BASH_REMATCH[2]}" + case "$ref" in + *:*) ;; + *) ref="${ref}:latest" ;; + esac + printf '%s\n' "$ref" return 0 fi - fi + done < "$dockerfile_path" return 1 } + # Helper: 直の親イメージから containers/<名前>/Dockerfile の直の親をたどり、継承の連なりを + # 下の段からの順で _BASE_CHAIN に置く (#404)。$1 は始まりの参照、$2 はそれを読んだ Dockerfile。 + # 最初の段を建てる前に終わりまで解決し、段の名前が名前の形に合わない・段の Dockerfile が無い・ + # 循環するときは理由を 1 行出して 1 を返す。✗ の行を利用者へ届けるため、コマンド置換で呼ばない。 + resolve_base_chain() { + local ref="$1" source="$2" + local -a visited=() + local image name dir dockerfile next v loop + _BASE_CHAIN=() + while :; do + image="${ref%%@*}" + image="${image%%:*}" + name="${image#devbase-}" + # containers/ へ連結する前に名前の形で弾き、$DEVBASE_ROOT の外を指させない + if [ "$image" = "$name" ] || ! is_single_segment_name "$name"; then + echo "✗ Invalid base image reference in ${source}: ${ref}" + return 1 + fi + for v in "${visited[@]}"; do + if [ "$v" = "$image" ]; then + loop="" + for v in "${visited[@]}"; do + loop="${loop}${v} -> " + done + echo "✗ Base image chain loops: ${loop}${image}" + return 1 + fi + done + dir="${DEVBASE_ROOT}/containers/${name}" + dockerfile="${dir}/Dockerfile" + if [ ! -d "$dir" ]; then + echo "✗ Container directory not found: $dir" + return 1 + fi + if [ ! -f "$dockerfile" ]; then + echo "✗ Dockerfile not found: $dockerfile" + return 1 + fi + visited+=("$image") + next=$(read_devbase_parent "$dockerfile") || break + source="$dockerfile" + ref="$next" + done + local i + for (( i = ${#visited[@]} - 1; i >= 0; i-- )); do + _BASE_CHAIN+=("${visited[$i]}") + done + } + + # Helper: _BASE_CHAIN の段を下から順に建てる (#404)。失敗した段で止まって 1 を返す。 + # $1 は連なりが 1 段のときの行 (今の行をそのまま出す)、$2 は 2 段以上のときの段の行の後ろに + # 付ける語。残りは docker buildx build への引数 (経路が決めたキャッシュの扱い)。 + build_base_chain() { + local single_line="$1" suffix="$2" + shift 2 + if [ "${#_BASE_CHAIN[@]}" -eq 1 ]; then + echo "" + echo "$single_line" + build_base_image "${_BASE_CHAIN[0]}" "$@" + return + fi + local total=$(( ${#_BASE_CHAIN[@]} + 1 )) + local k=0 stage + for stage in "${_BASE_CHAIN[@]}"; do + k=$((k + 1)) + echo "" + echo "[${k}/${total}] Building ${stage}${suffix}..." + build_base_image "$stage" "$@" || return 1 + done + } + # Helper: Build base image build_base_image() { local base_image="$1" @@ -183,84 +265,91 @@ cmd_build() { local dockerfile_path dockerfile_path=$(resolve_project_dockerfile) || return - # check_base_image_dependency の非 0 は「devbase-* を使わない」の判定で、失敗ではない (#247) - local base_image_name - base_image_name=$(check_base_image_dependency "$dockerfile_path") || true - local base_image_to_build="${base_image_name:-devbase-base}" + # read_devbase_parent の非 0 は「devbase-* を使わない」の判定で、失敗ではない (#247) + local parent_ref + parent_ref=$(read_devbase_parent "$dockerfile_path") || true + # 経路: 通常 / --no-cache (base も project もキャッシュなし) / + # --project-no-cache (base はキャッシュあり、project だけキャッシュなし) + local mode=normal if [ "$project_no_cache" = "1" ]; then - echo "" - local -a base_cached_args=() - local _ba - for _ba in "$@"; do - if [ "$_ba" = "--no-cache" ]; then - continue - fi - base_cached_args+=("$_ba") - done - echo "[1/2] Building ${base_image_to_build} (using cache)..." - if ! build_base_image "$base_image_to_build" "${base_cached_args[@]}"; then - exit 1 - fi - - echo "" - echo "[2/2] Building project image without cache..." - if compose_with_secrets docker compose build "${DEV_SERVICE_NAME:-dev}" --no-cache "$@"; then - echo "" - echo "✓ All images built successfully" - else - echo "" - echo "✗ Failed to build project image" - exit 1 - fi - return + mode=project_no_cache + elif [[ "$*" == *"--no-cache"* ]]; then + mode=no_cache fi - # --no-cache specified: rebuild base first without cache, then rebuild - # project without cache. - if [[ "$*" == *"--no-cache"* ]]; then - echo "" - echo "[1/2] Building ${base_image_to_build}..." - if ! build_base_image "$base_image_to_build" "$@"; then - exit 1 - fi - - echo "" - echo "[2/2] Building project image..." - if compose_with_secrets docker compose build "${DEV_SERVICE_NAME:-dev}" "$@"; then - echo "" - echo "✓ All images built successfully" + # 連なりの始まり。devbase-* を使わないプロジェクトは devbase-base から建てるが、 + # 通常のビルドでは devbase-base が既にあれば建てない + local start_ref="$parent_ref" + if [ -z "$start_ref" ]; then + if [ "$mode" = normal ] && compose_with_secrets docker image inspect devbase-base:latest >/dev/null 2>&1; then + echo "[1/2] devbase-base already exists (use --no-cache to rebuild)" else - echo "" - echo "✗ Failed to build project image" - exit 1 + start_ref="devbase-base" fi - return fi - # Normal build: check if project uses devbase-* base image - if [ -n "$base_image_name" ]; then - echo "" - echo "[1/2] Project uses ${base_image_name}, building base image..." - if ! build_base_image "$base_image_name" "$@"; then + local total=2 + _BASE_CHAIN=() + if [ -n "$start_ref" ]; then + if ! resolve_base_chain "$start_ref" "$dockerfile_path"; then exit 1 fi - else - # Fallback: check if devbase-base exists - if ! compose_with_secrets docker image inspect devbase-base:latest >/dev/null 2>&1; then - echo "" - echo "[1/2] Building devbase-base..." - if ! build_base_image "devbase-base" "$@"; then - exit 1 - fi - else - echo "[1/2] devbase-base already exists (use --no-cache to rebuild)" + total=$(( ${#_BASE_CHAIN[@]} + 1 )) + local direct="${_BASE_CHAIN[$(( ${#_BASE_CHAIN[@]} - 1 ))]}" + local -a base_args=("$@") + local single_line="[1/2] Building ${direct}..." + local suffix="" + case "$mode" in + project_no_cache) + base_args=() + local _ba + for _ba in "$@"; do + if [ "$_ba" = "--no-cache" ]; then + continue + fi + base_args+=("$_ba") + done + single_line="[1/2] Building ${direct} (using cache)..." + suffix=" (using cache)" + ;; + normal) + if [ -n "$parent_ref" ]; then + single_line="[1/2] Project uses ${direct}, building base image..." + if [ "$total" -gt 2 ]; then + local listed="" stage + for stage in "${_BASE_CHAIN[@]}"; do + listed="${listed:+$listed -> }${stage}" + done + echo "" + echo "Project uses ${direct}, building its base images: ${listed}" + fi + fi + ;; + esac + if ! build_base_chain "$single_line" "$suffix" "${base_args[@]}"; then + exit 1 fi fi echo "" - echo "[2/2] Building project image..." - if compose_with_secrets docker compose build "${DEV_SERVICE_NAME:-dev}" "$@"; then + local project_rc=0 + case "$mode" in + project_no_cache) + echo "[${total}/${total}] Building project image without cache..." + compose_with_secrets docker compose build "${DEV_SERVICE_NAME:-dev}" --no-cache "$@" || project_rc=$? + ;; + no_cache) + # "$@" が --no-cache を含む + echo "[${total}/${total}] Building project image..." + compose_with_secrets docker compose build "${DEV_SERVICE_NAME:-dev}" "$@" || project_rc=$? + ;; + *) + echo "[${total}/${total}] Building project image..." + compose_with_secrets docker compose build "${DEV_SERVICE_NAME:-dev}" "$@" || project_rc=$? + ;; + esac + if [ "$project_rc" = 0 ]; then echo "" echo "✓ All images built successfully" else @@ -361,6 +450,16 @@ resolve_command() { # 同期テストが一致を見る)。 _SINGLE_SEGMENT_NAME_RE='^[A-Za-z0-9][A-Za-z0-9._-]*$' +# 直の親の読み方 (#404)。Dockerfile の行が devbase-* を FROM に取るかを読み、2 番目の組が +# イメージの参照になる。FROM の語だけ大文字小文字を区別せず、--platform=... の前置と行頭の +# 空白を許す。$'...' でタブ文字を入れ、ERE と Python の re で同じ意味になる部分だけで書く。 +# +# 同期注意: lib/devbase/utils/dockerfile.py の DEVBASE_FROM_PATTERN と同じ正規表現。 +# 通常のビルド (read_devbase_parent) と --expires の判定が同じ Dockerfile から同じ直の親を +# 読むために、片方を変えたらもう片方も変える (tests/cli/test_devbase_from_contract.py が +# 値と振る舞いの一致を見る)。 +_DEVBASE_FROM_RE=$'^[ \t]*[Ff][Rr][Oo][Mm][ \t]+(--platform=[^ \t]+[ \t]+)?(devbase-[^ \t]+)' + # $1 が名前の形なら 0。bash 3.2 の `[[ =~ ]]` は引用した右辺を文字列として比べるため、 # 正規表現は変数で渡す。`[A-Za-z]` の範囲はロケールで変わりうるので、Python の定義 # (ASCII だけ) と同じ結果になるよう LC_ALL=C で比べる (決定 4)。 diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 4f5ebc86..63b4fca3 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -33,6 +33,7 @@ from devbase.utils.config import get_project_name from devbase.utils import names from devbase.utils import docker_context +from devbase.utils.dockerfile import devbase_parent_ref from devbase.project import runtime as project_runtime from devbase.project.local_config import load_project_local_config @@ -2368,7 +2369,7 @@ def _base_image_is_fresh(dev_service: dict, max_age: int) -> bool: def _get_base_image_ref(dev_service: dict) -> Optional[str]: - """dev サービスの Dockerfile の ``FROM devbase-*`` からベースイメージ参照を得る。 + """dev サービスの Dockerfile の ``FROM devbase-*`` から直の親イメージの参照を得る。 例: ``FROM devbase-base:latest`` -> ``devbase-base:latest`` ``FROM devbase-base`` -> ``devbase-base:latest`` (tag 補完) @@ -2389,16 +2390,8 @@ def _get_base_image_ref(dev_service: dict) -> Optional[str]: text = df_path.read_text(encoding='utf-8', errors='replace') except OSError: return None - for line in text.splitlines(): - # FROM は小文字 (`from`) も許容され、`--platform=...` が前置されることがある。 - m = re.match(r'\s*FROM\s+(?:--platform=\S+\s+)?(devbase-\S+)', - line, re.IGNORECASE) - if m: - ref = m.group(1) - if ':' not in ref: - ref += ':latest' - return ref - return None + # 本文の読み (小文字の `from`・`--platform=...` を含む) は通常のビルドと同じ規則に任せる (#404)。 + return devbase_parent_ref(text) def _pull_and_mark(image_name: str) -> bool: diff --git a/lib/devbase/utils/dockerfile.py b/lib/devbase/utils/dockerfile.py new file mode 100644 index 00000000..ff724615 --- /dev/null +++ b/lib/devbase/utils/dockerfile.py @@ -0,0 +1,43 @@ +"""直の親の読み方 (#404)。 + +Dockerfile の本文から、最初に ``devbase-*`` を指す ``FROM`` の行が名指すイメージ (直の親イメージ) を読む +規則を 1 か所で決める。``--expires`` の判定 (``container._get_base_image_ref``) がここを使う。 + +同期注意: ``bin/devbase`` の ``_DEVBASE_FROM_RE`` は同じ正規表現を文字列で持つ (通常のビルドの +``read_devbase_parent`` が使う。Bash から Python を呼ぶと偽の ``uv`` のテストで連なりが決まらず、 +機密の注入も走るため)。片方を変えたらもう片方も変える。値の一致と振る舞いの一致は +``tests/cli/test_devbase_from_contract.py`` が見る。 + +正規表現は POSIX の ERE と Python の ``re`` のどちらでも同じ意味になる部分だけで書く。``\\s``・ +``(?:...)``・``[[:space:]]``・``re.IGNORECASE`` を使わない。``FROM`` の語だけ大文字小文字を区別せず、 +イメージの名前は小文字の ``devbase-`` だけを読む。2 番目の組がイメージの参照である。 + +``re`` だけに依存し、副作用を持たない。 +""" + +from __future__ import annotations + +import re +from typing import Optional + +#: 直の親の読み方の正本。空白はスペースとタブ (実際のタブ文字) だけを数える。 +DEVBASE_FROM_PATTERN = '^[ \t]*[Ff][Rr][Oo][Mm][ \t]+(--platform=[^ \t]+[ \t]+)?(devbase-[^ \t]+)' + +_DEVBASE_FROM_RE = re.compile(DEVBASE_FROM_PATTERN) + + +def devbase_parent_ref(text: str) -> Optional[str]: + """Dockerfile の本文から直の親イメージの参照を返す。 + + 例: ``FROM devbase-base:latest`` -> ``devbase-base:latest`` + ``FROM devbase-base`` -> ``devbase-base:latest`` (tag 補完) + 当たる行が無ければ None。 + """ + for line in text.split('\n'): + m = _DEVBASE_FROM_RE.match(line.rstrip('\r')) + if m: + ref = m.group(2) + if ':' not in ref: + ref += ':latest' + return ref + return None From 9b37e445e2a8bad6f06bab3d5a47ce1167d1560f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 3 Oct 2026 20:10:40 +0900 Subject: [PATCH 02/14] =?UTF-8?q?Test:=20=E7=B6=99=E6=89=BF=E3=81=AE?= =?UTF-8?q?=E9=80=A3=E3=81=AA=E3=82=8A=E3=81=AE=E5=BB=BA=E3=81=A6=E3=81=AE?= =?UTF-8?q?=E9=A0=86=E3=81=A8=E7=9B=B4=E3=81=AE=E8=A6=AA=E3=81=AE=E8=AA=AD?= =?UTF-8?q?=E3=81=BF=E6=96=B9=E3=81=AE=E5=A5=91=E7=B4=84=E3=82=92=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E3=81=99=E3=82=8B=20(#404)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- tests/cli/test_build_base_chain.py | 272 ++++++++++++++++++++++++ tests/cli/test_devbase_from_contract.py | 86 ++++++++ 2 files changed, 358 insertions(+) create mode 100644 tests/cli/test_build_base_chain.py create mode 100644 tests/cli/test_devbase_from_contract.py diff --git a/tests/cli/test_build_base_chain.py b/tests/cli/test_build_base_chain.py new file mode 100644 index 00000000..bd5296d2 --- /dev/null +++ b/tests/cli/test_build_base_chain.py @@ -0,0 +1,272 @@ +"""`devbase build` が継承の連なりを下の段から建てる (#404 受け入れ条件 1〜6・9)。 + +プロジェクトの Dockerfile が派生イメージ (`devbase-php` など) を `FROM` に取るとき、`bin/devbase build` が +`containers/<名前>/Dockerfile` の `FROM devbase-*` をたどり、下の段から建ててからプロジェクトを建てることを、 +実プロセスの `bin/devbase` と偽の `uv` で固定する。Docker は起動しない。 + +偽の `uv` は、引数に `UV_FAIL_ON` を含む呼び出しだけを失敗させる (`image inspect` を失敗させると +「`devbase-base:latest` が手元に無い」状態になる)。 +""" + +from __future__ import annotations + +import os + +import pytest + +_FAIL_UV = """\ +#!/bin/bash +echo "UV:$*" +case "$*" in + *"$UV_FAIL_ON"*) exit 1 ;; +esac +exit 0 +""" + + +def _uv_lines(result): + return [line[len("UV:"):] for line in result.stdout.splitlines() if line.startswith("UV:")] + + +def _built_images(result): + """偽の `uv` が受けた `docker buildx build --load -t <イメージ>` のイメージを順に返す。""" + images = [] + for line in _uv_lines(result): + if "docker buildx build --load -t " in line: + images.append(line.split("docker buildx build --load -t ", 1)[1].split()[0]) + return images + + +def _uv_index(result, predicate): + for i, line in enumerate(_uv_lines(result)): + if predicate(line): + return i + return None + + +def _project_build_index(result): + return _uv_index(result, lambda line: "docker compose build dev" in line) + + +def _set_fail_on(exec_wrapper, fail_on): + uv = exec_wrapper.root / "fakebin" / "uv" + uv.write_text(_FAIL_UV.replace("$UV_FAIL_ON", fail_on)) + os.chmod(uv, 0o755) + + +def _container(exec_wrapper, name, dockerfile): + path = exec_wrapper.root / "containers" / name + path.mkdir(parents=True) + (path / "Dockerfile").write_text(dockerfile) + return path + + +def _project(exec_wrapper, dockerfile): + project = exec_wrapper.work / "myproj" + project.mkdir() + (project / "Dockerfile").write_text(dockerfile) + return project + + +@pytest.fixture +def php_project(exec_wrapper): + """dev の Dockerfile が `FROM devbase-php:latest`、`containers/php` が `FROM devbase-base:latest`。""" + _container(exec_wrapper, "base", "FROM ubuntu:26.04\n") + _container(exec_wrapper, "php", "FROM devbase-base:latest\nRUN echo php\n") + return _project(exec_wrapper, "FROM devbase-php:latest\nRUN echo app\n") + + +# --------------------------------------------------------------------------- +# 受け入れ条件 1: base → devbase-php → プロジェクトの順に建てる +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("base_missing", [False, True], ids=["base-present", "base-missing"]) +def test_build_builds_the_chain_from_the_bottom(exec_wrapper, php_project, base_missing): + if base_missing: + _set_fail_on(exec_wrapper, "image inspect") + + result = exec_wrapper.run(["build"], cwd=php_project) + + assert result.returncode == 0, result.stdout + result.stderr + assert _built_images(result) == ["devbase-base:latest", "devbase-php:latest"], result.stdout + project_index = _project_build_index(result) + last_base = _uv_index(result, lambda line: "-t devbase-php:latest" in line) + assert project_index is not None and last_base < project_index, result.stdout + # 段の名前と順を出力から読み取れる + assert "building its base images: devbase-base -> devbase-php" in result.stdout + out = result.stdout + assert out.index("[1/3] Building devbase-base...") < out.index("[2/3] Building devbase-php...") + assert out.index("[2/3] Building devbase-php...") < out.index("[3/3] Building project image...") + + +# --------------------------------------------------------------------------- +# 受け入れ条件 2: --no-cache は全段とプロジェクトをキャッシュなしで建てる +# --------------------------------------------------------------------------- + +def test_no_cache_builds_every_stage_without_cache(exec_wrapper, php_project): + result = exec_wrapper.run(["build", "--no-cache"], cwd=php_project) + + assert result.returncode == 0, result.stdout + result.stderr + assert _built_images(result) == ["devbase-base:latest", "devbase-php:latest"], result.stdout + builds = [line for line in _uv_lines(result) if "buildx build" in line] + assert all("--no-cache" in line for line in builds), builds + project_line = _uv_lines(result)[_project_build_index(result)] + assert "--no-cache" in project_line + + +# --------------------------------------------------------------------------- +# 受け入れ条件 3: --project-no-cache は段をキャッシュありで、プロジェクトだけキャッシュなしで建てる +# --------------------------------------------------------------------------- + +def test_project_no_cache_builds_stages_with_cache(exec_wrapper, php_project): + result = exec_wrapper.run(["build", "--project-no-cache", "--no-cache"], cwd=php_project) + + assert result.returncode == 0, result.stdout + result.stderr + assert _built_images(result) == ["devbase-base:latest", "devbase-php:latest"], result.stdout + builds = [line for line in _uv_lines(result) if "buildx build" in line] + assert not any("--no-cache" in line for line in builds), builds + project_line = _uv_lines(result)[_project_build_index(result)] + assert "--no-cache" in project_line + assert "[1/3] Building devbase-base (using cache)..." in result.stdout + assert "[3/3] Building project image without cache..." in result.stdout + + +# --------------------------------------------------------------------------- +# 受け入れ条件 4: 下の段の失敗で止まる +# --------------------------------------------------------------------------- + +def test_failure_of_a_lower_stage_stops_before_upper_stages(exec_wrapper, php_project): + _set_fail_on(exec_wrapper, "-t devbase-base:latest") + + result = exec_wrapper.run(["build"], cwd=php_project) + + assert result.returncode != 0 + assert _built_images(result) == ["devbase-base:latest"], result.stdout + assert _project_build_index(result) is None, result.stdout + assert "✗ Failed to build devbase-base" in result.stdout + result.stderr + + +# --------------------------------------------------------------------------- +# 受け入れ条件 5: 途中の段の Dockerfile が無いとき何も建てない +# --------------------------------------------------------------------------- + +def test_missing_first_stage_directory_builds_nothing(exec_wrapper): + _container(exec_wrapper, "base", "FROM ubuntu:26.04\n") + project = _project(exec_wrapper, "FROM devbase-foo:latest\n") + + result = exec_wrapper.run(["build"], cwd=project) + + assert result.returncode != 0 + assert _built_images(result) == [] and _project_build_index(result) is None, result.stdout + assert str(exec_wrapper.root / "containers" / "foo") in result.stdout + result.stderr + + +def test_missing_second_stage_directory_builds_nothing(exec_wrapper): + _container(exec_wrapper, "foo", "FROM devbase-bar:latest\n") + project = _project(exec_wrapper, "FROM devbase-foo:latest\n") + + result = exec_wrapper.run(["build"], cwd=project) + + assert result.returncode != 0 + assert _built_images(result) == [] and _project_build_index(result) is None, result.stdout + assert str(exec_wrapper.root / "containers" / "bar") in result.stdout + result.stderr + + +def test_stage_directory_without_dockerfile_builds_nothing(exec_wrapper): + (exec_wrapper.root / "containers" / "foo").mkdir() + project = _project(exec_wrapper, "FROM devbase-foo:latest\n") + + result = exec_wrapper.run(["build"], cwd=project) + + assert result.returncode != 0 + assert _built_images(result) == [] and _project_build_index(result) is None, result.stdout + assert str(exec_wrapper.root / "containers" / "foo" / "Dockerfile") in result.stdout + + +# --------------------------------------------------------------------------- +# 受け入れ条件 6: 循環するとき何も建てずに止まる +# --------------------------------------------------------------------------- + +def test_loop_in_the_chain_builds_nothing(exec_wrapper): + _container(exec_wrapper, "a", "FROM devbase-b:latest\n") + _container(exec_wrapper, "b", "FROM devbase-a:latest\n") + project = _project(exec_wrapper, "FROM devbase-a:latest\n") + + result = exec_wrapper.run(["build"], cwd=project) + + assert result.returncode != 0 + assert _built_images(result) == [] and _project_build_index(result) is None, result.stdout + assert "✗ Base image chain loops: devbase-a -> devbase-b -> devbase-a" in result.stdout + + +def test_self_loop_builds_nothing(exec_wrapper): + _container(exec_wrapper, "a", "FROM devbase-a:latest\n") + project = _project(exec_wrapper, "FROM devbase-a:latest\n") + + result = exec_wrapper.run(["build"], cwd=project) + + assert result.returncode != 0 + assert _built_images(result) == [], result.stdout + assert "✗ Base image chain loops: devbase-a -> devbase-a" in result.stdout + + +# --------------------------------------------------------------------------- +# 決定 4: containers/ の外を指す参照は連結せずに止まる +# --------------------------------------------------------------------------- + +def test_reference_outside_containers_builds_nothing(exec_wrapper): + (exec_wrapper.root / "x").mkdir() + (exec_wrapper.root / "x" / "Dockerfile").write_text("FROM ubuntu:26.04\n") + project = _project(exec_wrapper, "FROM devbase-../x:latest\n") + + result = exec_wrapper.run(["build"], cwd=project) + + assert result.returncode != 0 + assert _built_images(result) == [] and _project_build_index(result) is None, result.stdout + assert "✗ Invalid base image reference in Dockerfile: devbase-../x:latest" in result.stdout + + +# --------------------------------------------------------------------------- +# 受け入れ条件 9: devbase-* を FROM に取らないプロジェクトは今のまま +# --------------------------------------------------------------------------- + +def test_plain_project_skips_base_when_present(exec_wrapper): + _container(exec_wrapper, "base", "FROM ubuntu:26.04\n") + project = _project(exec_wrapper, "FROM ubuntu:26.04\n") + + result = exec_wrapper.run(["build"], cwd=project) + + assert result.returncode == 0, result.stdout + result.stderr + assert _built_images(result) == [], result.stdout + assert _project_build_index(result) is not None + + +def test_plain_project_builds_base_when_missing(exec_wrapper): + _container(exec_wrapper, "base", "FROM ubuntu:26.04\n") + project = _project(exec_wrapper, "FROM ubuntu:26.04\n") + _set_fail_on(exec_wrapper, "image inspect") + + result = exec_wrapper.run(["build"], cwd=project) + + assert result.returncode == 0, result.stdout + result.stderr + assert _built_images(result) == ["devbase-base:latest"], result.stdout + base_index = _uv_index(result, lambda line: "-t devbase-base:latest" in line) + assert base_index < _project_build_index(result), result.stdout + + +# --------------------------------------------------------------------------- +# I7: 1 段の連なりは今の行と回数のまま +# --------------------------------------------------------------------------- + +def test_single_stage_keeps_the_current_lines(exec_wrapper): + _container(exec_wrapper, "base", "FROM ubuntu:26.04\n") + _container(exec_wrapper, "php", "FROM devbase-base:latest\n") + project = _project(exec_wrapper, "FROM devbase-base:latest\n") + + result = exec_wrapper.run(["build"], cwd=project) + + assert result.returncode == 0, result.stdout + result.stderr + assert _built_images(result) == ["devbase-base:latest"], result.stdout + assert "[1/2] Project uses devbase-base, building base image..." in result.stdout + assert "[2/2] Building project image..." in result.stdout + assert "building its base images" not in result.stdout diff --git a/tests/cli/test_devbase_from_contract.py b/tests/cli/test_devbase_from_contract.py new file mode 100644 index 00000000..c6966743 --- /dev/null +++ b/tests/cli/test_devbase_from_contract.py @@ -0,0 +1,86 @@ +"""直の親の読み方を、通常のビルドと `--expires` の判定が同じに持つ (#404 受け入れ条件 7)。 + +同じ Dockerfile を、通常のビルドの経路 (`bin/devbase build` を実プロセスで起動し、偽の `uv` が受けた +`docker buildx build --load -t <イメージ>` を見る) と `_get_base_image_ref` の両方に通し、表の答えと +一致することを見る。あわせて `bin/devbase` の `_DEVBASE_FROM_RE` と `DEVBASE_FROM_PATTERN` の値の一致を見る。 +""" + +from __future__ import annotations + +import re + +import pytest + +from devbase.commands import container +from devbase.utils.dockerfile import DEVBASE_FROM_PATTERN +from tests.cli.conftest import WRAPPER + +# (Dockerfile の本文, 読むイメージ。None は読まない) +CONTRACT = [ + ("FROM devbase-php:latest\n", "devbase-php:latest"), + ("FROM devbase-php\n", "devbase-php:latest"), + ("FROM --platform=linux/amd64 devbase-base:latest\n", "devbase-base:latest"), + ("from devbase-base:latest\n", "devbase-base:latest"), + ("FROM devbase-base:latest AS builder\n", "devbase-base:latest"), + (" FROM devbase-base:latest\n", "devbase-base:latest"), + ("# FROM devbase-base:latest\n", None), + ("COPY --from=devbase-base:latest /a /b\n", None), + ("FROM ubuntu:26.04\n", None), + # 入出力の契約で足した行 + ("FROM devbase-php\r\n", "devbase-php:latest"), + ("FROM ubuntu:26.04 AS x\nFROM devbase-base:latest\n", "devbase-base:latest"), + ("FROM DEVBASE-base:latest\n", None), +] + +_IDS = [ + "plain", "no-tag", "platform", "lowercase", "as-stage", "leading-space", + "comment", "copy-from", "ubuntu-only", "crlf", "second-from", "uppercase-name", +] + + +def _uv_lines(result): + return [line[len("UV:"):] for line in result.stdout.splitlines() if line.startswith("UV:")] + + +def _read_by_normal_build(exec_wrapper, text): + """通常のビルドが直の親として建てるイメージ。`devbase-base` の有無の分岐へ進んだら None。""" + for name in ("base", "php"): + path = exec_wrapper.root / "containers" / name + path.mkdir(parents=True) + (path / "Dockerfile").write_text("FROM ubuntu:26.04\n") + project = exec_wrapper.work / "myproj" + project.mkdir() + (project / "Dockerfile").write_bytes(text.encode()) + + result = exec_wrapper.run(["build"], cwd=project) + + assert result.returncode == 0, result.stdout + result.stderr + built = [line.split("docker buildx build --load -t ", 1)[1].split()[0] + for line in _uv_lines(result) if "docker buildx build --load -t " in line] + if not built: + # 偽の uv は image inspect に 0 を返す (base がある) ため、何も建てずに進む + assert "devbase-base already exists" in result.stdout, result.stdout + return None + assert len(built) == 1, built + return built[0] + + +def _read_by_expires(tmp_path, text): + (tmp_path / "Dockerfile").write_bytes(text.encode()) + return container._get_base_image_ref({"build": {"context": str(tmp_path)}}) + + +@pytest.mark.parametrize("text,expected", CONTRACT, ids=_IDS) +def test_normal_build_and_expires_read_the_same_parent(exec_wrapper, tmp_path, text, expected): + expires_dir = tmp_path / "expires" + expires_dir.mkdir() + + assert _read_by_normal_build(exec_wrapper, text) == expected + assert _read_by_expires(expires_dir, text) == expected + + +def test_wrapper_from_regex_is_synced_with_python(): + """決定 1: bin/devbase の `_DEVBASE_FROM_RE` は Python の正本と同じ正規表現。""" + found = re.findall(r"^_DEVBASE_FROM_RE=\$'([^']*)'$", WRAPPER.read_text(), re.M) + assert found, "bin/devbase から _DEVBASE_FROM_RE を抜き出せない" + assert [value.replace("\\t", "\t") for value in found] == [DEVBASE_FROM_PATTERN] From 26a97821388f2bda24d6871bdb21ea77fed9e5f8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 3 Oct 2026 20:13:33 +0900 Subject: [PATCH 03/14] =?UTF-8?q?Fix:=20=E9=80=A3=E3=81=AA=E3=82=8A?= =?UTF-8?q?=E3=81=AE=E5=BE=AA=E7=92=B0=E3=81=AE=E8=A1=8C=E3=82=92=E7=B5=84?= =?UTF-8?q?=E3=82=80=E5=86=85=E5=81=B4=E3=81=AE=E3=83=AB=E3=83=BC=E3=83=97?= =?UTF-8?q?=E3=81=AE=E5=A4=89=E6=95=B0=E3=82=92=E5=88=86=E3=81=91=E3=80=81?= =?UTF-8?q?ShellCheck=20=E3=81=AE=E8=AD=A6=E5=91=8A=E3=82=92=E6=B6=88?= =?UTF-8?q?=E3=81=99=20(#404)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- bin/devbase | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bin/devbase b/bin/devbase index 3d3a2f88..2fadd74f 100755 --- a/bin/devbase +++ b/bin/devbase @@ -145,7 +145,7 @@ cmd_build() { resolve_base_chain() { local ref="$1" source="$2" local -a visited=() - local image name dir dockerfile next v loop + local image name dir dockerfile next v w loop _BASE_CHAIN=() while :; do image="${ref%%@*}" @@ -159,8 +159,8 @@ cmd_build() { for v in "${visited[@]}"; do if [ "$v" = "$image" ]; then loop="" - for v in "${visited[@]}"; do - loop="${loop}${v} -> " + for w in "${visited[@]}"; do + loop="${loop}${w} -> " done echo "✗ Base image chain loops: ${loop}${image}" return 1 From 60ddc5eaf842487669ee0b66554b513818c7b4d2 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 3 Oct 2026 20:13:33 +0900 Subject: [PATCH 04/14] =?UTF-8?q?Docs:=20devbase=20build=20=E3=81=8C?= =?UTF-8?q?=E7=B6=99=E6=89=BF=E3=81=AE=E9=80=A3=E3=81=AA=E3=82=8A=E3=82=92?= =?UTF-8?q?=E4=B8=8B=E3=81=AE=E6=AE=B5=E3=81=8B=E3=82=89=E5=BB=BA=E3=81=A6?= =?UTF-8?q?=E3=82=8B=E3=81=93=E3=81=A8=E3=82=92=E5=88=A9=E7=94=A8=E8=80=85?= =?UTF-8?q?=E5=90=91=E3=81=91=E3=81=AE=E6=96=87=E6=9B=B8=E3=81=A8=20CHANGE?= =?UTF-8?q?LOG=20=E3=81=AB=E6=9B=B8=E3=81=8F=20(#404)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 ++++++++ docs/user/container-operations.md | 5 +++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dfbef80..e9406fe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,14 @@ `devbase down` → `devbase up` でコンテナを作り直してください。 ### Fixed +- **派生イメージを `FROM` に取るプロジェクトの `devbase build` が、base の無い端末で止まるのを直しました(#404)。** + プロジェクトの Dockerfile が `FROM devbase-php:latest` のように派生イメージを継ぐとき、`devbase build` + (と、それを呼ぶ `devbase up`・`devbase rebuild`)は `containers/<名前>/Dockerfile` の `FROM devbase-*` を + たどり、`devbase-base` → `devbase-php` → プロジェクトのように下の段から建てます。先に `devbase build base` を + 打つ必要はありません。`--no-cache` では全段をキャッシュなしで建て直します。たどる途中の段の + `containers/<名前>/` が無いときと、たどりが循環するときは、何も建てずに理由を出して止まります。 + あわせて、`FROM --platform=...` と小文字の `from` の Dockerfile でも、通常のビルドが直の親イメージを + 先に建てます(`--expires` の判定と同じ読み方になりました)。 - **スナップショットの差分バックアップが、毎回ボリューム全体を控えていたのを直しました。** 差分が前回からの変更分だけになり、1 回の時間とディスクの使い方が大きく減ります(手元の測定で フル 2.2GB・40 秒に対し、差分 1.5MB・2 秒)。直した後の最初の `devbase up` では、既存の世代へ diff --git a/docs/user/container-operations.md b/docs/user/container-operations.md index 7b3431e7..6a56d1ad 100644 --- a/docs/user/container-operations.md +++ b/docs/user/container-operations.md @@ -479,7 +479,8 @@ base は、すべての利用者が使う道具だけを持ちます。次のも **`browser` のイメージを選ぶ。** プロジェクトの `compose.yml` の dev サービスで、`build.context` を `containers/browser` にします。ほかの派生イメージと同じく、`devbase build` は base を先に建ててから -このイメージを建てます。 +このイメージを建てます。プロジェクトの Dockerfile が派生イメージを `FROM` に取るときも、`devbase build` は +`containers/<名前>/Dockerfile` の `FROM devbase-*` をたどり、下の段から順に建てます。 ```yaml services: @@ -506,7 +507,7 @@ services: | 道筋 | すること | 気をつけること | |------|----------|----------------| | `browser` のイメージを選ぶ | 上の `build.context` に替える | 元の派生イメージの道具が無くなる | -| プロジェクトの Dockerfile で足す | `FROM devbase-php:latest` などの上で、利用者 `ubuntu` が `npx playwright install --with-deps chromium` を打つ | `devbase build` は `devbase-php` を建てるが、その下の base が無いときに先に建てない。先に `devbase build base` を打つ | +| プロジェクトの Dockerfile で足す | `FROM devbase-php:latest` などの上で、利用者 `ubuntu` が `npx playwright install --with-deps chromium` を打つ | `devbase build` は継承の連なりを下の段から建てる(`devbase-base` → `devbase-php` → プロジェクト)。たどる途中の `containers/<名前>/` が無いときは、何も建てずに止まる | | 道具の起動引数でブラウザの場所を渡す | MCP の起動引数などで、上の 2 つのどちらかで用意した Chromium の実行ファイルを指させる | ブラウザと依存パッケージは、ほかの 2 つの道筋で用意する | `@playwright/mcp` や `chrome-devtools-mcp` は、既定でシステムの Chrome を探すことがあります。 From 15f5f9a1fec63dc0c1d6370d4a491f5601ca729a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 3 Oct 2026 20:22:37 +0900 Subject: [PATCH 05/14] =?UTF-8?q?Fix:=20snapshot=20list=20=E3=81=8C?= =?UTF-8?q?=E5=A0=B4=E6=89=80=E3=81=AE=E4=B8=8D=E6=AD=A3=E3=81=AA=E3=82=A8?= =?UTF-8?q?=E3=83=B3=E3=83=88=E3=83=AA=E3=82=92=20rotate=20=E3=81=A8?= =?UTF-8?q?=E5=90=8C=E3=81=98=E5=88=A4=E5=AE=9A=E3=81=A7=E5=A4=96=E3=81=99?= =?UTF-8?q?=20(#332)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SnapshotManager に検証の段 _entry_dir を足し、list() と rotate() が共有する。 名前が不正・リンク・backups/ の外・name が無い・ディレクトリでないエントリは list() が中を読まずに警告して一覧から外す。snapshot.yml は書き換えない。 devbase status と TUI の選択も外した後の一覧を使うことをテストで固定する。 Co-Authored-By: Claude Opus 5.5 --- lib/devbase/snapshot/manager.py | 50 +++++++++++--- tests/snapshot/test_manager_series.py | 93 +++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 8 deletions(-) diff --git a/lib/devbase/snapshot/manager.py b/lib/devbase/snapshot/manager.py index 7493c3f9..4f40c41d 100644 --- a/lib/devbase/snapshot/manager.py +++ b/lib/devbase/snapshot/manager.py @@ -263,6 +263,27 @@ def _safe_snap_dir(self, name: str) -> Path: raise SnapshotError(f"無効なスナップショットパス: '{name}'") return snap_dir + def _entry_dir(self, snap: dict) -> Path: + """``snapshot.yml`` のエントリから世代のディレクトリを検証して返す。 + + ``list()`` と ``rotate()`` が共有する検証の段 (#269 / #332)。次のエントリを + ``SnapshotError`` で止める。 + + - ``name`` が無い・文字列でない + - :meth:`_safe_snap_dir` が拒否する (名前が不正・リンク・``backups/`` の外) + - ディレクトリの場所にディレクトリ以外が置かれている + + ディレクトリがまだ無いエントリは通す (呼び出し側が「中身なし」として扱う)。 + """ + name = snap.get('name') if isinstance(snap, dict) else None + if not isinstance(name, str) or not name: + raise SnapshotError("名前 (name) がありません") + snap_dir = self._safe_snap_dir(name) + if snap_dir.exists() and not snap_dir.is_dir(): + raise SnapshotError( + f"スナップショット '{name}' の場所がディレクトリではありません: {snap_dir}") + return snap_dir + def create(self, name: Optional[str] = None, full: bool = False) -> str: """スナップショットを作成する。 @@ -292,18 +313,31 @@ def create(self, name: Optional[str] = None, full: bool = False) -> str: return name def list(self) -> list[dict]: - """スナップショット一覧を返す""" + """スナップショット一覧を返す。 + + 各エントリは :meth:`_entry_dir` で検証し、場所が不正なエントリ (名前が不正・ + リンク・``backups/`` の外・``name`` が無い・ディレクトリでない) は警告して + 一覧から外す。中は読まず、``snapshot.yml`` も書き換えない。 + """ meta = self._load_metadata() - snapshots = meta.get('snapshots', []) - # ディレクトリの実サイズも取得 - for snap in snapshots: - snap_dir = self.backups_dir / snap['name'] - if snap_dir.exists(): + snapshots = [] + for snap in meta.get('snapshots', []) or []: + try: + snap_dir = self._entry_dir(snap) + except SnapshotError as e: + name = snap.get('name', '') if isinstance(snap, dict) else '' + logger.warning( + "snapshot.yml の世代 '%s' は場所が不正なため、一覧から外します: %s", + name, e) + continue + # ディレクトリの実サイズも取得 + if snap_dir.is_dir(): snap['size_bytes'] = sum( f.stat().st_size for f in snap_dir.iterdir() if f.is_file() ) else: snap['size_bytes'] = 0 + snapshots.append(snap) return snapshots def entry_count(self) -> int: @@ -598,7 +632,7 @@ def rotate(self, keep: int = DEFAULT_MAX_GENERATIONS, 系列 (対象ボリュームの組) ごとに ``keep`` 世代を残し、残りの総数が ``max_total`` (省けば ``keep × 3``) を超えたら、系列をまたいで最も古い 世代から消す。**各系列の最新の世代は消さない** (次の差分の積み先のため)。 - 消す前に :meth:`_safe_snap_dir` で名前を検証し、拒否されたエントリは + 消す前に :meth:`_entry_dir` (``list()`` と共有) で検証し、拒否されたエントリは ディレクトリを消さずに一覧からだけ外す。 Args: @@ -631,7 +665,7 @@ def rotate(self, keep: int = DEFAULT_MAX_GENERATIONS, name = snap.get('name', '') removed_ids.add(index) try: - snap_dir = self._safe_snap_dir(name) + snap_dir = self._entry_dir(snap) except SnapshotError as e: logger.warning( "snapshot.yml の世代 '%s' は場所が不正なため、ディレクトリを消さずに" diff --git a/tests/snapshot/test_manager_series.py b/tests/snapshot/test_manager_series.py index 4886a086..c17ae4b9 100644 --- a/tests/snapshot/test_manager_series.py +++ b/tests/snapshot/test_manager_series.py @@ -552,6 +552,99 @@ def test_cli_rotate_removes_entries_that_list_cannot_open(tmp_path, caplog, bad) assert not any("ローテーション不要です" in r.getMessage() for r in caplog.records) +def _add_bad_entry(tmp_path: Path, backups: Path, bad: str) -> None: + """場所が不正なエントリを snapshot.yml の先頭に足す (#332)。""" + data = yaml.safe_load((backups / "snapshot.yml").read_text()) + entry = {"created_at": "2026-08-01T00:00:00", "incremental_count": 0, + "volumes": vols("acme")} + if bad == "traversal": + outside = tmp_path / "outside" + outside.mkdir() + (outside / "big.bin").write_bytes(b"x" * 12345) + entry["name"] = "../outside" + elif bad == "dir-link": + _link_outside(tmp_path) + entry["name"] = "old" + elif bad == "file-link": + target = tmp_path / "plain.txt" + target.write_text("keep") + (backups / "link").symlink_to(target) + entry["name"] = "link" + elif bad == "plain-file": + (backups / "flat").write_text("not a dir") + entry["name"] = "flat" + data["snapshots"].insert(0, entry) + (backups / "snapshot.yml").write_text(yaml.safe_dump(data)) + + +BAD_ENTRIES = ["traversal", "dir-link", "file-link", "plain-file", "no-name"] + + +@pytest.mark.parametrize("bad", BAD_ENTRIES) +def test_list_skips_entries_with_invalid_location(tmp_path, caplog, bad): + """#332: list は場所の不正なエントリの中を読まず、警告して一覧から外す。""" + backups = write_state(tmp_path, [("D1", "acme", 0), ("D2", "acme", 0)]) + _add_bad_entry(tmp_path, backups, bad) + before = (backups / "snapshot.yml").read_text() + + with caplog.at_level(logging.WARNING, logger="devbase"): + snapshots = SnapshotManager(tmp_path).list() + + assert [s["name"] for s in snapshots] == ["D1", "D2"] + assert all(s["size_bytes"] > 0 for s in snapshots) + assert any("場所が不正" in r.getMessage() for r in caplog.records) + # 読むだけで snapshot.yml は書き換えない + assert (backups / "snapshot.yml").read_text() == before + + +def test_list_keeps_entry_whose_directory_is_missing(tmp_path): + """#332: ディレクトリがまだ無いだけのエントリはサイズ 0 で残す。""" + backups = write_state(tmp_path, [("D1", "acme", 0)]) + data = yaml.safe_load((backups / "snapshot.yml").read_text()) + data["snapshots"].append({"name": "gone", "created_at": "2026-09-02T00:00:00"}) + (backups / "snapshot.yml").write_text(yaml.safe_dump(data)) + + snapshots = SnapshotManager(tmp_path).list() + assert [(s["name"], s["size_bytes"] == 0) for s in snapshots] == \ + [("D1", False), ("gone", True)] + + +@pytest.mark.parametrize("bad", BAD_ENTRIES) +def test_cli_list_prints_only_valid_entries(tmp_path, capsys, bad): + """#332: devbase snapshot list は止まらず、不正なエントリの行を出さない。""" + backups = write_state(tmp_path, [("D1", "acme", 0)]) + _add_bad_entry(tmp_path, backups, bad) + + assert cmd_snapshot(tmp_path, types.SimpleNamespace(subcommand="list")) == 0 + out = capsys.readouterr().out + assert "D1" in out + assert "outside" not in out and "link" not in out and "flat" not in out + assert "12.1" not in out + + +@pytest.mark.parametrize("bad", BAD_ENTRIES) +def test_status_and_tui_use_filtered_list(tmp_path, monkeypatch, bad): + """#332: devbase status と TUI の選択も、不正なエントリを外した一覧を使う。""" + from devbase.commands import status + from devbase.tui import actions_snapshot, menu + + backups = write_state(tmp_path, [("D1", "acme", 0), ("D2", "acme", 0)]) + _add_bad_entry(tmp_path, backups, bad) + + assert status._get_snapshot_info(tmp_path) == {"latest": "D2", "count": 2} + + seen = {} + + def fake_select(message, choices, **kwargs): + seen["values"] = [value for _, value in choices] + return "D1" + + monkeypatch.setattr(menu, "select", fake_select) + monkeypatch.setattr(menu, "text", lambda *a, **k: pytest.fail("自由入力へ縮退した")) + assert actions_snapshot._select_snapshot_name(tmp_path, "選ぶ") == "D1" + assert seen["values"] == ["D1", "D2"] + + def test_safe_snap_dir_uses_path_components(tmp_path): """決定 7: 兄弟の backups-outside/ は文字列の前方一致でも通さない。""" _link_outside(tmp_path) From b1f286b0e5e20b1c91fd4285982a8bc8c4523be4 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 3 Oct 2026 20:22:37 +0900 Subject: [PATCH 06/14] =?UTF-8?q?Docs:=20snapshot=20list=20=E3=81=8C?= =?UTF-8?q?=E4=B8=8D=E6=AD=A3=E3=81=AA=E3=82=A8=E3=83=B3=E3=83=88=E3=83=AA?= =?UTF-8?q?=E3=82=92=E5=A4=96=E3=81=99=E3=81=93=E3=81=A8=E3=82=92=20CHANGE?= =?UTF-8?q?LOG=20=E3=81=AB=E6=9B=B8=E3=81=8F=20(#332)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9406fe7..f50e048d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,12 @@ `devbase down` → `devbase up` でコンテナを作り直してください。 ### Fixed +- **`devbase snapshot list` が、`snapshot.yml` の場所の不正なエントリの中を読まないようにしました(#332)。** + 名前が `../outside` のように `backups/` の外を指すもの・シンボリックリンク・ディレクトリでないもの・ + `name` の無いものは、`rotate` と同じ判定で警告して一覧から外します。これまでは `backups/` の外の + ディレクトリのサイズを表示したり、`NotADirectoryError` や `KeyError` で止まったりしていました。 + `devbase status` のスナップショットの節と TUI のスナップショットの選択も、外した後の一覧を使います。 + `list` は `snapshot.yml` を書き換えません。 - **派生イメージを `FROM` に取るプロジェクトの `devbase build` が、base の無い端末で止まるのを直しました(#404)。** プロジェクトの Dockerfile が `FROM devbase-php:latest` のように派生イメージを継ぐとき、`devbase build` (と、それを呼ぶ `devbase up`・`devbase rebuild`)は `containers/<名前>/Dockerfile` の `FROM devbase-*` を From 8c7b968be89d1ba2599011396549ff885f1c8659 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 3 Oct 2026 20:27:45 +0900 Subject: [PATCH 07/14] =?UTF-8?q?Test:=20test=5Fwrapper=5Fdispatch=20?= =?UTF-8?q?=E3=82=92=20exec=5Fwrapper=20=E3=81=B8=E7=A7=BB=E3=81=99=20(#33?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- tests/cli/test_wrapper_dispatch.py | 102 +++++++++++++---------------- 1 file changed, 44 insertions(+), 58 deletions(-) diff --git a/tests/cli/test_wrapper_dispatch.py b/tests/cli/test_wrapper_dispatch.py index 9973930b..ee222d78 100644 --- a/tests/cli/test_wrapper_dispatch.py +++ b/tests/cli/test_wrapper_dispatch.py @@ -5,18 +5,19 @@ 含まれており、`devbase project ...` が Python 実装へルーティングされることを 検証する (含まれていないと `*)` 節で `unknown command` で終了してしまう)。 -実際の `uv run` を起動すると環境依存になるため、run_python / ensure_uv を -差し替えた薄いハーネス経由で wrapper の dispatch ロジックだけを実行する。 +wrapper は `exec_wrapper` (conftest.py) で動く。本物の bin/devbase を tmp へ複製して起動し、 +外への呼び出しの境界の `uv` だけを差し替える。Python へ届いたことは ` devbase.cli ` で +終わる `UV:` 行で確かめる。 """ -import os import re -import subprocess import sys from pathlib import Path import pytest +from tests.cli.conftest import stdout_field + REPO_ROOT = Path(__file__).resolve().parents[2] WRAPPER = REPO_ROOT / "bin" / "devbase" @@ -36,32 +37,17 @@ def _parse_wrapper_top_prefix_preferences() -> dict[str, str]: return prefs -def _run_wrapper(*args): - """run_python を no-op に差し替えて wrapper の dispatch だけを実行する。 +def _python_args(result): + """`UV:` 行から ` devbase.cli ` より後ろ (Python へ渡った引数) を返す。無ければ None。""" + uv = stdout_field(result, "UV:") + if uv is None or " devbase.cli " not in uv + " ": + return None + return (uv + " ").split(" devbase.cli ", 1)[1].rstrip() - wrapper を関数定義のみ読み込む形にできないため、`run_python` / - `ensure_uv` を export -f で先に定義し、wrapper 末尾の dispatch を - 別プロセスで評価する。wrapper は自身の run_python を再定義するので、 - `sed` で wrapper の run_python / ensure_uv 定義を取り除いてから評価する。 - """ - harness = ( - 'run_python() { echo "PYTHON:$*"; exit 0; }\n' - 'ensure_uv() { :; }\n' - # wrapper から関数再定義を除いた本体を読み込む - 'eval "$(sed -e \'/^run_python()/,/^}/d\' ' - '-e \'/^ensure_uv()/,/^}/d\' "$WRAPPER_PATH")"\n' - ) - env = { - **os.environ, - "DEVBASE_ROOT": str(REPO_ROOT), - "WRAPPER_PATH": str(WRAPPER), - } - return subprocess.run( - ["bash", "-c", harness, "devbase", *args], - capture_output=True, - text=True, - env=env, - ) + +@pytest.fixture +def run_wrapper(exec_wrapper): + return lambda *args: exec_wrapper(list(args)) class TestWrapperStaticContent: @@ -101,57 +87,57 @@ def test_top_prefix_preferences_synced_with_cli(self): class TestWrapperDispatch: - def test_project_reaches_python(self): - result = _run_wrapper("project", "--help") + def test_project_reaches_python(self, run_wrapper): + result = run_wrapper("project", "--help") assert "unknown command" not in result.stderr.lower(), result.stderr - assert "PYTHON:project --help" in result.stdout, result.stdout + assert _python_args(result) == "project --help", result.stdout - def test_project_subcommand_reaches_python(self): - result = _run_wrapper("project", "up") + def test_project_subcommand_reaches_python(self, run_wrapper): + result = run_wrapper("project", "up") assert "unknown command" not in result.stderr.lower(), result.stderr - assert "PYTHON:project up" in result.stdout, result.stdout + assert _python_args(result) == "project up", result.stdout - def test_project_prefix_resolves_to_project(self): + def test_project_prefix_resolves_to_project(self, run_wrapper): # `proj` は project に一意に解決される。 - result = _run_wrapper("proj", "up") + result = run_wrapper("proj", "up") assert "unknown command" not in result.stderr.lower(), result.stderr - assert "PYTHON:project up" in result.stdout, result.stdout + assert _python_args(result) == "project up", result.stdout - def test_unknown_command_still_errors(self): - result = _run_wrapper("bogus") + def test_unknown_command_still_errors(self, run_wrapper): + result = run_wrapper("bogus") assert "unknown command" in result.stderr.lower() assert result.returncode != 0 - def test_top_level_list_reaches_python(self): + def test_top_level_list_reaches_python(self, run_wrapper): """PLAN06 Task 3: `devbase list` シノニムが Python へルーティングされる。""" - result = _run_wrapper("list") + result = run_wrapper("list") assert "unknown command" not in result.stderr.lower(), result.stderr - assert "PYTHON:list" in result.stdout, result.stdout + assert _python_args(result) == "list", result.stdout - def test_top_level_list_interactive_flag_passthrough(self): - result = _run_wrapper("list", "--interactive") - assert "PYTHON:list --interactive" in result.stdout, result.stdout + def test_top_level_list_interactive_flag_passthrough(self, run_wrapper): + result = run_wrapper("list", "--interactive") + assert _python_args(result) == "list --interactive", result.stdout - def test_project_list_reaches_python(self): - result = _run_wrapper("project", "list") + def test_project_list_reaches_python(self, run_wrapper): + result = run_wrapper("project", "list") assert "unknown command" not in result.stderr.lower(), result.stderr - assert "PYTHON:project list" in result.stdout, result.stdout + assert _python_args(result) == "project list", result.stdout - def test_list_prefix_resolves(self): + def test_list_prefix_resolves(self, run_wrapper): # `li` は list に一意解決される (login は lo)。 - result = _run_wrapper("li") - assert "PYTHON:list" in result.stdout, result.stdout + result = run_wrapper("li") + assert _python_args(result) == "list", result.stdout - def test_l_prefix_resolves_to_login(self): + def test_l_prefix_resolves_to_login(self, run_wrapper): # 後方互換: `list` 追加で ambiguous になった `devbase l` を login に維持する # (互換性指摘 #36)。preference 無しだと unknown command 'l' になる。 - result = _run_wrapper("l") + result = run_wrapper("l") assert "unknown command" not in result.stderr.lower(), result.stderr - assert "PYTHON:login" in result.stdout, result.stdout + assert _python_args(result) == "login", result.stdout - def test_lo_prefix_resolves_to_login(self): - result = _run_wrapper("lo") - assert "PYTHON:login" in result.stdout, result.stdout + def test_lo_prefix_resolves_to_login(self, run_wrapper): + result = run_wrapper("lo") + assert _python_args(result) == "login", result.stdout if __name__ == "__main__": From 4bddb7a10d765d312ef89951b805c56086a3e966 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 3 Oct 2026 20:28:07 +0900 Subject: [PATCH 08/14] =?UTF-8?q?Test:=20test=5Fopen=5Fcommand=20=E3=82=92?= =?UTF-8?q?=20exec=5Fwrapper=20=E3=81=B8=E7=A7=BB=E3=81=97=E3=80=81Python?= =?UTF-8?q?=20=E3=81=B8=E6=B8=A1=E3=81=A3=E3=81=9F=E5=BC=95=E6=95=B0?= =?UTF-8?q?=E3=82=92=E8=AA=AD=E3=82=80=20python=5Fargs=20=E3=82=92=20conft?= =?UTF-8?q?est=20=E3=81=AB=E7=BD=AE=E3=81=8F=20(#339)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- tests/cli/conftest.py | 12 +++++++ tests/cli/test_open_command.py | 52 ++++++++---------------------- tests/cli/test_wrapper_dispatch.py | 28 ++++++---------- 3 files changed, 35 insertions(+), 57 deletions(-) diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 37f9b19e..ddf0ced4 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -98,3 +98,15 @@ def stdout_field(result: subprocess.CompletedProcess, prefix: str) -> str | None if line.startswith(prefix): return line[len(prefix):] return None + + +def python_args(result: subprocess.CompletedProcess) -> str | None: + """`UV:` 行から Python (`python -m devbase.cli`) へ渡った引数を返す。 + + `run_python` を通らなかった (`UV:` 行が無い、または `devbase.cli` を起動していない) ときは None。 + """ + uv = stdout_field(result, "UV:") + if uv is None: + return None + head, sep, tail = (uv + " ").partition(" devbase.cli ") + return tail.rstrip() if sep else None diff --git a/tests/cli/test_open_command.py b/tests/cli/test_open_command.py index 448c0459..9d5ff099 100644 --- a/tests/cli/test_open_command.py +++ b/tests/cli/test_open_command.py @@ -7,17 +7,12 @@ 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" +from tests.cli.conftest import python_args, stdout_field def _parse(*argv): @@ -118,47 +113,26 @@ def test_prefix_resolution(monkeypatch, argv, expected): # --- bin/devbase ------------------------------------------------------------- -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 _field(result, prefix): - for line in result.stdout.splitlines(): - if line.startswith(prefix): - return line[len(prefix):] - return None - - @pytest.fixture -def wrapper_root(tmp_path): - (tmp_path / "projects" / "myapp").mkdir(parents=True) - return tmp_path +def wrapper_root(exec_wrapper): + """`exec_wrapper` (conftest.py) で本物の bin/devbase を tmp から起動する。`uv` だけを差し替える。""" + exec_wrapper.project("myapp") + return exec_wrapper def test_wrapper_top_level_open_name_cds_and_strips(wrapper_root): - r = _run_wrapper(["open", "myapp", "--open-index", "2"], wrapper_root) + r = wrapper_root(["open", "myapp", "--open-index", "2"]) assert "unknown command" not in r.stderr.lower(), r.stderr - assert _field(r, "PWD:").endswith("/projects/myapp"), r.stdout - assert _field(r, "PYTHON:") == "open --open-index 2", r.stdout + assert stdout_field(r, "PWD:").endswith("/projects/myapp"), r.stdout + assert python_args(r) == "open --open-index 2", r.stdout def test_wrapper_project_open_name_cds_and_strips(wrapper_root): - r = _run_wrapper(["project", "open", "myapp"], wrapper_root) - assert _field(r, "PWD:").endswith("/projects/myapp"), r.stdout - assert _field(r, "PYTHON:") == "project open", r.stdout + r = wrapper_root(["project", "open", "myapp"]) + assert stdout_field(r, "PWD:").endswith("/projects/myapp"), r.stdout + assert python_args(r) == "project open", r.stdout def test_wrapper_open_prefix_resolves(wrapper_root): - r = _run_wrapper(["o"], wrapper_root) - assert _field(r, "PYTHON:") == "open", (r.stdout, r.stderr) + r = wrapper_root(["o"]) + assert python_args(r) == "open", (r.stdout, r.stderr) diff --git a/tests/cli/test_wrapper_dispatch.py b/tests/cli/test_wrapper_dispatch.py index ee222d78..6850b976 100644 --- a/tests/cli/test_wrapper_dispatch.py +++ b/tests/cli/test_wrapper_dispatch.py @@ -16,7 +16,7 @@ import pytest -from tests.cli.conftest import stdout_field +from tests.cli.conftest import python_args REPO_ROOT = Path(__file__).resolve().parents[2] WRAPPER = REPO_ROOT / "bin" / "devbase" @@ -37,14 +37,6 @@ def _parse_wrapper_top_prefix_preferences() -> dict[str, str]: return prefs -def _python_args(result): - """`UV:` 行から ` devbase.cli ` より後ろ (Python へ渡った引数) を返す。無ければ None。""" - uv = stdout_field(result, "UV:") - if uv is None or " devbase.cli " not in uv + " ": - return None - return (uv + " ").split(" devbase.cli ", 1)[1].rstrip() - - @pytest.fixture def run_wrapper(exec_wrapper): return lambda *args: exec_wrapper(list(args)) @@ -90,18 +82,18 @@ class TestWrapperDispatch: def test_project_reaches_python(self, run_wrapper): result = run_wrapper("project", "--help") assert "unknown command" not in result.stderr.lower(), result.stderr - assert _python_args(result) == "project --help", result.stdout + assert python_args(result) == "project --help", result.stdout def test_project_subcommand_reaches_python(self, run_wrapper): result = run_wrapper("project", "up") assert "unknown command" not in result.stderr.lower(), result.stderr - assert _python_args(result) == "project up", result.stdout + assert python_args(result) == "project up", result.stdout def test_project_prefix_resolves_to_project(self, run_wrapper): # `proj` は project に一意に解決される。 result = run_wrapper("proj", "up") assert "unknown command" not in result.stderr.lower(), result.stderr - assert _python_args(result) == "project up", result.stdout + assert python_args(result) == "project up", result.stdout def test_unknown_command_still_errors(self, run_wrapper): result = run_wrapper("bogus") @@ -112,32 +104,32 @@ def test_top_level_list_reaches_python(self, run_wrapper): """PLAN06 Task 3: `devbase list` シノニムが Python へルーティングされる。""" result = run_wrapper("list") assert "unknown command" not in result.stderr.lower(), result.stderr - assert _python_args(result) == "list", result.stdout + assert python_args(result) == "list", result.stdout def test_top_level_list_interactive_flag_passthrough(self, run_wrapper): result = run_wrapper("list", "--interactive") - assert _python_args(result) == "list --interactive", result.stdout + assert python_args(result) == "list --interactive", result.stdout def test_project_list_reaches_python(self, run_wrapper): result = run_wrapper("project", "list") assert "unknown command" not in result.stderr.lower(), result.stderr - assert _python_args(result) == "project list", result.stdout + assert python_args(result) == "project list", result.stdout def test_list_prefix_resolves(self, run_wrapper): # `li` は list に一意解決される (login は lo)。 result = run_wrapper("li") - assert _python_args(result) == "list", result.stdout + assert python_args(result) == "list", result.stdout def test_l_prefix_resolves_to_login(self, run_wrapper): # 後方互換: `list` 追加で ambiguous になった `devbase l` を login に維持する # (互換性指摘 #36)。preference 無しだと unknown command 'l' になる。 result = run_wrapper("l") assert "unknown command" not in result.stderr.lower(), result.stderr - assert _python_args(result) == "login", result.stdout + assert python_args(result) == "login", result.stdout def test_lo_prefix_resolves_to_login(self, run_wrapper): result = run_wrapper("lo") - assert _python_args(result) == "login", result.stdout + assert python_args(result) == "login", result.stdout if __name__ == "__main__": From 76214a979fc669ed4f09edffcbd4cb7692dc0634 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 3 Oct 2026 20:28:45 +0900 Subject: [PATCH 09/14] =?UTF-8?q?Test:=20test=5Fwrapper=5Fbuild=5Fcontext?= =?UTF-8?q?=20=E3=82=92=20exec=5Fwrapper=20=E3=81=B8=E7=A7=BB=E3=81=97?= =?UTF-8?q?=E3=80=81=E6=9C=AC=E7=89=A9=E3=81=AE=20cmd=5Fbuild=20=E3=81=AE?= =?UTF-8?q?=20env=20exec=20=E3=82=92=E8=A6=8B=E3=82=8B=20(#339)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- tests/cli/test_wrapper_build_context.py | 99 ++++++++++--------------- 1 file changed, 40 insertions(+), 59 deletions(-) diff --git a/tests/cli/test_wrapper_build_context.py b/tests/cli/test_wrapper_build_context.py index e4d50451..52c8780b 100644 --- a/tests/cli/test_wrapper_build_context.py +++ b/tests/cli/test_wrapper_build_context.py @@ -1,99 +1,79 @@ """shell の `devbase build --context NAME` が context を引数のまま Python へ渡す (PLAN52 Task 5)。 -wrapper テストは実際の `uv run` を避けるため、`uv` と `cmd_build` / `run_python` を -シェル関数で差し替えて dispatch と `compose_with_secrets` だけを実行する。 +wrapper は `exec_wrapper` (conftest.py) で動く。本物の bin/devbase を tmp へ複製して起動し、 +外への呼び出しの境界の `uv` だけを差し替える。`cmd_build` と `compose_with_secrets` は本物の +まま動くため、shell の経路は `=== Building devbase images ===` と、`compose_with_secrets` が +起こす `env exec` の `UV:` 行で確かめる。偽の `uv` は 0 で終わるので、通常のビルドでは +`devbase-base` が既にあるものとして扱われ、プロジェクトのイメージの行だけが出る。 """ from __future__ import annotations -import os import re -import subprocess -from pathlib import Path import pytest -REPO_ROOT = Path(__file__).resolve().parents[2] -WRAPPER = REPO_ROOT / 'bin' / 'devbase' +from tests.cli.conftest import WRAPPER, python_args +SHELL_BUILD = "=== Building devbase images ===" -def _run_wrapper(args, devbase_root, extra_env=None): - """run_python / cmd_build を出力するだけの関数に差し替え、`uv` も関数で受ける。 - `compose_with_secrets` は**実物のまま**残す (context を引数で渡す当事者のため)。 - """ - harness = ( - 'run_python() { echo "PYTHON:$*"; exit 0; }\n' - 'cmd_build() { echo "BUILD:$*"; echo "CTX:$_BUILD_CONTEXT"; ' - ' compose_with_secrets docker image inspect x; exit 0; }\n' - 'ensure_uv() { :; }\n' - 'uv() { echo "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), - **(extra_env or {})} - return subprocess.run(["bash", "-c", harness, "devbase", *args], - capture_output=True, text=True, env=env, cwd=str(devbase_root)) - - -def _line(result, prefix): - for line in result.stdout.splitlines(): - if line.startswith(prefix): - return line[len(prefix):] - return None +def _env_exec_lines(result): + """`compose_with_secrets` が起こした `env exec` の `UV:` 行の、`env exec` から後ろ。""" + return [line.split(" devbase.cli ", 1)[1] for line in result.stdout.splitlines() + if line.startswith("UV:") and " devbase.cli env exec " in line] @pytest.fixture -def wrapper_root(tmp_path): - (tmp_path / "containers" / "base").mkdir(parents=True) - (tmp_path / "projects").mkdir() - return tmp_path +def wrapper_root(exec_wrapper): + exec_wrapper.container("base") + return exec_wrapper def test_context_is_extracted_before_image_scan(wrapper_root): """`build --context NAME` の NAME を単体イメージ名として拾わない。""" - result = _run_wrapper(["build", "--context", "gpu-wsl"], wrapper_root) - assert _line(result, "PYTHON:") is None - assert _line(result, "BUILD:") == "" - assert _line(result, "CTX:") == "gpu-wsl" + result = wrapper_root(["build", "--context", "gpu-wsl"]) + assert SHELL_BUILD in result.stdout, result.stdout + assert _env_exec_lines(result) == [ + "env exec --context gpu-wsl -- docker compose build dev"], result.stdout def test_context_equals_form(wrapper_root): - result = _run_wrapper(["build", "--context=gpu-wsl", "--no-cache"], wrapper_root) - assert _line(result, "BUILD:") == "--no-cache" - assert _line(result, "CTX:") == "gpu-wsl" + result = wrapper_root(["build", "--context=gpu-wsl", "--no-cache"]) + assert SHELL_BUILD in result.stdout, result.stdout + lines = _env_exec_lines(result) + assert lines and all(line.startswith("env exec --context gpu-wsl -- ") for line in lines), lines + assert lines[-1] == "env exec --context gpu-wsl -- docker compose build dev --no-cache", lines def test_context_reaches_env_exec_as_argument(wrapper_root): - result = _run_wrapper(["build", "--context", "gpu-wsl"], wrapper_root) - uv = _line(result, "UV:") - assert uv is not None - assert re.search(r"env exec --context gpu-wsl -- docker image inspect x$", uv), uv + result = wrapper_root(["build", "--context", "gpu-wsl"]) + lines = _env_exec_lines(result) + assert lines, result.stdout + assert re.fullmatch(r"env exec --context gpu-wsl -- docker compose build dev", lines[-1]), lines def test_context_argument_beats_env_file(wrapper_root): """env に DEVBASE_DOCKER_CONTEXT=a があっても --context b が引数として届く。""" - (wrapper_root / "env").write_text("DEVBASE_DOCKER_CONTEXT=a\n") - result = _run_wrapper(["build", "--context", "b"], wrapper_root) - assert "env exec --context b --" in (_line(result, "UV:") or "") + (wrapper_root.work / "env").write_text("DEVBASE_DOCKER_CONTEXT=a\n") + result = wrapper_root(["build", "--context", "b"]) + lines = _env_exec_lines(result) + assert lines and all(line.startswith("env exec --context b -- ") for line in lines), lines def test_without_context_env_exec_has_no_flag(wrapper_root): - result = _run_wrapper(["build"], wrapper_root) - assert _line(result, "CTX:") == "" - assert "env exec -- docker image inspect x" in (_line(result, "UV:") or "") + result = wrapper_root(["build"]) + assert _env_exec_lines(result) == ["env exec -- docker compose build dev"], result.stdout def test_context_is_forwarded_to_python_single_build(wrapper_root): - result = _run_wrapper(["build", "base", "--context", "gpu-wsl"], wrapper_root) - assert _line(result, "PYTHON:") == "project build base --context gpu-wsl" + result = wrapper_root(["build", "base", "--context", "gpu-wsl"]) + assert SHELL_BUILD not in result.stdout, result.stdout + assert python_args(result) == "project build base --context gpu-wsl", result.stdout def test_missing_context_value_is_an_error(wrapper_root): - result = _run_wrapper(["build", "--context"], wrapper_root) + result = wrapper_root(["build", "--context"]) assert result.returncode == 2 assert "--context" in result.stderr @@ -111,7 +91,8 @@ def test_shell_docker_calls_go_through_env_exec(): @pytest.mark.parametrize("args", [["build", "--context", ""], ["build", "--context="], ["build", "--context", " "]]) def test_empty_context_value_is_an_error(wrapper_root, args): - result = _run_wrapper(args, wrapper_root) + result = wrapper_root(args) assert result.returncode == 2 assert "--context" in result.stderr - assert _line(result, "BUILD:") is None + assert SHELL_BUILD not in result.stdout, result.stdout + assert "UV:" not in result.stdout, result.stdout From 2c66b2ab5d5010ac69f481c99e5e8f334ca1a9e4 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 3 Oct 2026 20:28:54 +0900 Subject: [PATCH 10/14] =?UTF-8?q?Test:=20test=5Fpost=5Fstart=5Fcommand=20?= =?UTF-8?q?=E3=81=AE=E6=8C=AF=E3=82=8A=E5=88=86=E3=81=91=E3=81=AE=E7=A2=BA?= =?UTF-8?q?=E8=AA=8D=E3=82=92=20exec=5Fwrapper=20=E3=81=B8=E7=A7=BB?= =?UTF-8?q?=E3=81=99=20(#339)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- tests/cli/test_post_start_command.py | 29 +++++++--------------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/tests/cli/test_post_start_command.py b/tests/cli/test_post_start_command.py index 07eccc49..40dc7f4c 100644 --- a/tests/cli/test_post_start_command.py +++ b/tests/cli/test_post_start_command.py @@ -14,9 +14,9 @@ import pytest from devbase import cli +from tests.cli.conftest import python_args, stdout_field 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" @@ -68,29 +68,14 @@ def test_prefix_resolution(monkeypatch, argv, expected): 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(exec_wrapper): + """`exec_wrapper` (conftest.py) で本物の bin/devbase を tmp から起動する。`uv` だけを差し替える。""" + exec_wrapper.project("myapp") + r = exec_wrapper(["project", "post-start", "myapp"]) -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 + assert stdout_field(r, "PWD:").endswith("/projects/myapp"), r.stdout + assert python_args(r) == "project post-start", r.stdout def _bash_complete(words, cword, devbase_root): From e534700b4b2fb22cf04424713d5a9ab3e42ad049 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 3 Oct 2026 20:32:15 +0900 Subject: [PATCH 11/14] =?UTF-8?q?Test:=20test=5Fproject=5Fname=5Fresolutio?= =?UTF-8?q?n=20=E3=81=AE=20sed=20=E3=83=8F=E3=83=BC=E3=83=8D=E3=82=B9?= =?UTF-8?q?=E3=82=92=20exec=5Fwrapper=20=E3=81=B8=E7=A7=BB=E3=81=97?= =?UTF-8?q?=E3=80=81conftest=20=E3=81=AE=E8=AA=AC=E6=98=8E=E3=82=92?= =?UTF-8?q?=E4=BB=8A=E3=81=AE=E5=BD=A2=E3=81=AB=E7=9B=B4=E3=81=99=20(#339)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- tests/cli/conftest.py | 8 +- tests/cli/test_project_name_resolution.py | 206 ++++++++-------------- 2 files changed, 75 insertions(+), 139 deletions(-) diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index ddf0ced4..4f7ce462 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -1,10 +1,8 @@ """`bin/devbase` を実プロセスで起動するハーネス (PLAN61 決定 11)。 -既存の `sed` ハーネス (`test_wrapper_dispatch.py` など) は wrapper の本文を削って `eval` -する。その形では `DEVBASE_ROOT=` の行を削って環境変数の値を使わせるため、pytest が継承した -実環境の `DEVBASE_ROOT` を渡し忘れると実環境の `projects/` を見る。また `run_python` と -`cmd_build` を関数ごと置き換えるため、`=== Building devbase images ===` が出ないことを -確かめられない。 +wrapper の本文を `sed` で削って `eval` する形にはしない。その形では関数の表記に依存して +削る範囲が黙って外れ、`DEVBASE_ROOT=` の行を削って環境変数の値を使わせるため、pytest が +継承した実環境の `DEVBASE_ROOT` を渡し忘れると実環境の `projects/` を見る (#339)。 ここでは `bin/devbase` を `/bin/devbase` へ複製する。wrapper は自身の場所から `DEVBASE_ROOT` を `` に決め、継承した環境変数は wrapper の代入で上書きされる。外へ diff --git a/tests/cli/test_project_name_resolution.py b/tests/cli/test_project_name_resolution.py index 5dee009b..032b9d08 100644 --- a/tests/cli/test_project_name_resolution.py +++ b/tests/cli/test_project_name_resolution.py @@ -7,22 +7,21 @@ 実在するプロジェクト名のみ cd + argv strip し、login / build / scale の既存 positional と曖昧にならないこと (存在性ベースの判定)。 -wrapper テストは実際の `uv run` を避けるため run_python / cmd_build をスタブに -差し替え、DEVBASE_ROOT を一時ディレクトリへ向けた薄いハーネスで dispatch のみ -実行する (wrapper 末尾の DEVBASE_ROOT 自動解決行も sed で除去する)。 +wrapper テストは `exec_wrapper` (conftest.py) で本物の bin/devbase を tmp へ複製して +起動し、外への呼び出しの境界の `uv` だけを差し替える。 """ from __future__ import annotations import logging import os -import subprocess import sys from pathlib import Path import pytest from devbase.commands import container +from tests.cli.conftest import python_args, stdout_field REPO_ROOT = Path(__file__).resolve().parents[2] WRAPPER = REPO_ROOT / "bin" / "devbase" @@ -359,194 +358,135 @@ def test_load_project_env_escaped_dollar_and_undefined(tmp_path, monkeypatch): # wrapper: cd + argv strip + 存在性ベースの曖昧性回避 # =========================================================================== -def _run_wrapper(args, devbase_root): - """run_python / cmd_build をスタブ化し wrapper の dispatch だけを実行する。 +# `exec_wrapper` (conftest.py) は bin/devbase を tmp へ複製して起動し、`uv` だけを PATH で +# 差し替える。`maybe_cd_project` と `cmd_build` は本物のまま動く。Python の経路は +# ` devbase.cli ` で終わる `UV:` 行、shell の経路は `cmd_build` が `compose_with_secrets` +# 経由で起こす `env exec` の `UV:` 行で確かめる。`PWD:` は `uv` を起動した時点の CWD。 - - run_python -> "PWD:" と "PYTHON:" を出力 - - cmd_build -> "PWD:" と "BUILD:" を出力 - 実際の wrapper が DEVBASE_ROOT を自身のパスから再計算してしまうため、その - 代入行 (`DEVBASE_ROOT=...`) も sed で除去し、環境変数で渡した値を使わせる。 - """ - harness = ( - 'run_python() { echo "PWD:$PWD"; echo "PYTHON:$*"; exit 0; }\n' - 'cmd_build() { echo "PWD:$PWD"; 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), - ) +SHELL_BUILD = "=== Building devbase images ===" @pytest.fixture -def wrapper_root(tmp_path): - (tmp_path / "projects" / "myapp").mkdir(parents=True) - return tmp_path +def wrapper_root(exec_wrapper): + exec_wrapper.project("myapp") + return exec_wrapper def _pwd(result): - for line in result.stdout.splitlines(): - if line.startswith("PWD:"): - return line[len("PWD:"):] - return None - - -def _python_args(result): - for line in result.stdout.splitlines(): - if line.startswith("PYTHON:"): - return line[len("PYTHON:"):] - return None - + return stdout_field(result, "PWD:") -def _build_args(result): - for line in result.stdout.splitlines(): - if line.startswith("BUILD:"): - return line[len("BUILD:"):] - return None +def _env_exec_lines(result): + """`compose_with_secrets` が起こした `env exec` の `UV:` 行の、`env exec` から後ろ。""" + return [line.split(" devbase.cli ", 1)[1] for line in result.stdout.splitlines() + if line.startswith("UV:") and " devbase.cli env exec " in line] -def _run_wrapper_from(args, devbase_root, cwd): - """`_run_wrapper` と同じだが任意の CWD から起動し env 残留を検証できる版。 - run_python スタブが ``DEV_SERVICE_NAME`` の値も出力するため、呼び出し元 env の - 残留有無を判定できる。 - """ - harness = ( - 'run_python() { echo "PWD:$PWD"; echo "PYTHON:$*"; ' - 'echo "DEV_SERVICE_NAME:${DEV_SERVICE_NAME:-}"; ' - 'echo "SHARED:${SHARED:-}"; exit 0; }\n' - 'cmd_build() { echo "PWD:$PWD"; 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), - } - env.pop("DEV_SERVICE_NAME", None) - env.pop("SHARED", None) - return subprocess.run( - ["bash", "-c", harness, "devbase", *args], - capture_output=True, - text=True, - env=env, - cwd=str(cwd), - ) - - -def _stdout_field(result, prefix): - for line in result.stdout.splitlines(): - if line.startswith(prefix): - return line[len(prefix):] - return None +_FAKE_UV_WITH_ENV = """\ +#!/bin/bash +echo "PWD:$PWD" +echo "UV:$*" +echo "DEV_SERVICE_NAME:${DEV_SERVICE_NAME:-}" +echo "SHARED:${SHARED:-}" +exit 0 +""" -def test_wrapper_clears_caller_only_env_on_project_switch(tmp_path): +def test_wrapper_clears_caller_only_env_on_project_switch(exec_wrapper, monkeypatch): """別プロジェクト内から `up ` した際、呼び出し元固有 env が残らない。 codex 指摘 (bin/devbase:235) の回帰テスト。呼び出し元 caller の env にしか無い ``DEV_SERVICE_NAME`` が対象 myapp へ引き継がれず、共通キー ``SHARED`` は対象側の - 値が勝つことを wrapper 経路で固定する。 + 値が勝つことを wrapper 経路で固定する。偽の `uv` は両方の値も出す。 """ - root = tmp_path - myapp = root / "projects" / "myapp" - myapp.mkdir(parents=True) - (myapp / "env").write_text("SHARED=myapp_shared\n") - caller = root / "projects" / "caller" - caller.mkdir(parents=True) - (caller / "env").write_text("DEV_SERVICE_NAME=caller_svc\nSHARED=caller_shared\n") + monkeypatch.delenv("DEV_SERVICE_NAME", raising=False) + monkeypatch.delenv("SHARED", raising=False) + (exec_wrapper.root / "fakebin" / "uv").write_text(_FAKE_UV_WITH_ENV) + exec_wrapper.project("myapp", env="SHARED=myapp_shared\n") + caller = exec_wrapper.project("caller", env="DEV_SERVICE_NAME=caller_svc\nSHARED=caller_shared\n") - r = _run_wrapper_from(["up", "myapp"], root, caller) + r = exec_wrapper(["up", "myapp"], cwd=caller) assert _pwd(r).endswith("/projects/myapp"), r.stdout + assert python_args(r) == "up", r.stdout # 呼び出し元固有キーは残留しない - assert _stdout_field(r, "DEV_SERVICE_NAME:") == "", r.stdout + assert stdout_field(r, "DEV_SERVICE_NAME:") == "", r.stdout # 共通キーは対象プロジェクトの値が勝つ - assert _stdout_field(r, "SHARED:") == "myapp_shared", r.stdout + assert stdout_field(r, "SHARED:") == "myapp_shared", r.stdout def test_wrapper_project_up_name_cds_and_strips(wrapper_root): - r = _run_wrapper(["project", "up", "myapp"], wrapper_root) + r = wrapper_root(["project", "up", "myapp"]) assert "unknown command" not in r.stderr.lower(), r.stderr assert _pwd(r).endswith("/projects/myapp"), r.stdout # name は strip され Python へは渡らない - assert _python_args(r) == "project up", r.stdout + assert python_args(r) == "project up", r.stdout def test_wrapper_shortcut_up_name_cds_and_strips(wrapper_root): - r = _run_wrapper(["up", "myapp"], wrapper_root) + r = wrapper_root(["up", "myapp"]) assert _pwd(r).endswith("/projects/myapp"), r.stdout - assert _python_args(r) == "up", r.stdout + assert python_args(r) == "up", r.stdout def test_wrapper_unknown_name_not_stripped_no_cd(wrapper_root): """存在しない name は cd せず素通し (Python 側でエラー処理させる)。""" - r = _run_wrapper(["up", "bogus"], wrapper_root) - assert not _pwd(r).endswith("/projects/bogus"), r.stdout - assert _python_args(r) == "up bogus", r.stdout + r = wrapper_root(["up", "bogus"]) + assert _pwd(r) == str(wrapper_root.work), r.stdout + assert python_args(r) == "up bogus", r.stdout def test_wrapper_build_name_cds_via_shell(wrapper_root): """build は shell cmd_build 経路。wrapper cd で対象プロジェクトへ移動する。""" - r = _run_wrapper(["build", "myapp"], wrapper_root) + r = wrapper_root(["build", "myapp"]) + assert SHELL_BUILD in r.stdout, r.stdout assert _pwd(r).endswith("/projects/myapp"), r.stdout - assert _build_args(r) == "", r.stdout # name は strip + # name は strip され、cmd_build へは引数が渡らない + assert _env_exec_lines(r) == ["env exec -- docker compose build dev"], r.stdout def test_wrapper_build_flag_not_treated_as_name(wrapper_root): """`build --no-cache` のフラグは name とみなさず CWD でビルド。""" - r = _run_wrapper(["build", "--no-cache"], wrapper_root) - assert not _pwd(r).endswith("/projects/"), r.stdout - assert _build_args(r) == "--no-cache", r.stdout + wrapper_root.container("base") + r = wrapper_root(["build", "--no-cache"]) + assert SHELL_BUILD in r.stdout, r.stdout + assert _pwd(r) == str(wrapper_root.work), r.stdout + lines = _env_exec_lines(r) + assert lines and lines[-1] == "env exec -- docker compose build dev --no-cache", r.stdout def test_wrapper_scale_name_disambiguation(wrapper_root): """`scale myapp 3` は name+N、`scale 3` は N のみ (存在性で判定)。""" - r1 = _run_wrapper(["scale", "myapp", "3"], wrapper_root) + r1 = wrapper_root(["scale", "myapp", "3"]) assert _pwd(r1).endswith("/projects/myapp"), r1.stdout - assert _python_args(r1) == "scale 3", r1.stdout + assert python_args(r1) == "scale 3", r1.stdout - r2 = _run_wrapper(["scale", "3"], wrapper_root) - assert not _pwd(r2).endswith("/projects/3"), r2.stdout - assert _python_args(r2) == "scale 3", r2.stdout + r2 = wrapper_root(["scale", "3"]) + assert _pwd(r2) == str(wrapper_root.work), r2.stdout + assert python_args(r2) == "scale 3", r2.stdout def test_wrapper_login_index_not_treated_as_name(wrapper_root): """`login 2` の 2 は index。projects/2 が無いので cd せず素通し。""" - r = _run_wrapper(["login", "2"], wrapper_root) - assert _python_args(r) == "login 2", r.stdout + r = wrapper_root(["login", "2"]) + assert python_args(r) == "login 2", r.stdout # 一方 `login myapp` は実在プロジェクトなので cd + strip (index=1 既定) - r2 = _run_wrapper(["login", "myapp"], wrapper_root) + r2 = wrapper_root(["login", "myapp"]) assert _pwd(r2).endswith("/projects/myapp"), r2.stdout - assert _python_args(r2) == "login", r2.stdout + assert python_args(r2) == "login", r2.stdout def test_wrapper_project_scale_name_strips_keeps_subcommand(wrapper_root): - r = _run_wrapper(["project", "scale", "myapp", "3"], wrapper_root) + r = wrapper_root(["project", "scale", "myapp", "3"]) assert _pwd(r).endswith("/projects/myapp"), r.stdout - assert _python_args(r) == "project scale 3", r.stdout + assert python_args(r) == "project scale 3", r.stdout def test_wrapper_no_name_uses_cwd(wrapper_root): """name を渡さなければ cd せず従来通り (引数素通し)。""" - r = _run_wrapper(["project", "up"], wrapper_root) - assert _python_args(r) == "project up", r.stdout + r = wrapper_root(["project", "up"]) + assert _pwd(r) == str(wrapper_root.work), r.stdout + assert python_args(r) == "project up", r.stdout def test_wrapper_project_build_keeps_image_positional(wrapper_root): @@ -556,10 +496,10 @@ def test_wrapper_project_build_keeps_image_positional(wrapper_root): プロジェクト名 myapp が image と衝突しても name strip せず素通しし、Python 側で image=myapp として解釈させる (codex 指摘の衝突回避)。 """ - r = _run_wrapper(["project", "build", "myapp"], wrapper_root) + r = wrapper_root(["project", "build", "myapp"]) # cd せず (image 解決は Python 側)、myapp を strip しない - assert not _pwd(r).endswith("/projects/myapp"), r.stdout - assert _python_args(r) == "project build myapp", r.stdout + assert _pwd(r) == str(wrapper_root.work), r.stdout + assert python_args(r) == "project build myapp", r.stdout def test_wrapper_project_login_keeps_index_positional(wrapper_root): @@ -568,9 +508,9 @@ def test_wrapper_project_login_keeps_index_positional(wrapper_root): `project login` parser は name を持たず index を取る。実在プロジェクト名と 一致しても name strip せず、Python パーサに委ねる (codex 指摘の衝突回避)。 """ - r = _run_wrapper(["project", "login", "myapp"], wrapper_root) - assert not _pwd(r).endswith("/projects/myapp"), r.stdout - assert _python_args(r) == "project login myapp", r.stdout + r = wrapper_root(["project", "login", "myapp"]) + assert _pwd(r) == str(wrapper_root.work), r.stdout + assert python_args(r) == "project login myapp", r.stdout # =========================================================================== @@ -580,8 +520,6 @@ def test_wrapper_project_login_keeps_index_positional(wrapper_root): # 差し替える。maybe_cd_project は本物のまま動く (受け入れ条件 15)。 # =========================================================================== -from tests.cli.conftest import stdout_field # noqa: E402 - TOP_LEVEL_NAME_COMMANDS = ["up", "down", "ps", "scale", "login", "rebuild", "open"] PROJECT_NAME_SUBCOMMANDS = ["up", "down", "ps", "logs", "scale", "rebuild", "open"] MALFORMED_NAMES = ["../etc", "a/b", ".", ".."] From 7dc8dba64baa4a2a82aa17298723f119ca26dc37 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 3 Oct 2026 20:38:26 +0900 Subject: [PATCH 12/14] =?UTF-8?q?Test:=20TUI=20=E3=81=AE=20OpenBao=20?= =?UTF-8?q?=E3=81=AE=E6=8E=A5=E7=B6=9A=E8=A8=AD=E5=AE=9A=E3=81=A7=20url=20?= =?UTF-8?q?=E3=81=A0=E3=81=91=E5=A4=89=E3=81=88=E3=81=9F=E3=81=A8=E3=81=8D?= =?UTF-8?q?=E3=81=AE=E7=A2=BA=E8=AA=8D=E3=82=92=20version:=202=20(group=5F?= =?UTF-8?q?aliases=20=E3=81=82=E3=82=8A)=20=E3=81=A7=E3=82=82=E8=B5=B0?= =?UTF-8?q?=E3=82=89=E3=81=9B=E3=82=8B=20(#382)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit version: 1 の設定に加え、version: 2・layout: group・空でない group_aliases の 設定でもパラメータ化して走らせる。確認の段は台本でグループを選んで通す。 Co-Authored-By: Claude Opus 5.5 --- tests/cli/tui/test_actions_env_openbao.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/tests/cli/tui/test_actions_env_openbao.py b/tests/cli/tui/test_actions_env_openbao.py index 5dc67f11..b217fecb 100644 --- a/tests/cli/tui/test_actions_env_openbao.py +++ b/tests/cli/tui/test_actions_env_openbao.py @@ -58,11 +58,20 @@ def test_a_file_backend_only_shows_the_guidance(file_root, monkeypatch, prepare) assert snapshot(file_root) == before -def test_changing_only_the_url_keeps_everything_else(openbao_root, openbao, monkeypatch): +@pytest.mark.parametrize('layout', ['flat', 'group']) +def test_changing_only_the_url_keeps_everything_else(openbao_root, openbao, monkeypatch, layout): + """version: 1 (flat) でも version: 2 (group・group_aliases あり) でも url 以外を保つ (#382)""" + select = [] + if layout == 'group': + from tests.conftest import configure_openbao + + configure_openbao(openbao_root, openbao, layout='group', + group_aliases={'umbrella': 'acme'}) + select = ['acme'] # version: 2 の確認はグループを選んで通す (#386) before = bc.load(openbao_root) creds = (openbao_root / 'secrets' / 'bootstrap.env.age').read_bytes() url = f'http://localhost:{openbao.port}' - Script(monkeypatch, text=[url], secret=['', '']) + Script(monkeypatch, text=[url], secret=['', ''], select=select) rc, out = run(openbao_root) @@ -71,6 +80,10 @@ def test_changing_only_the_url_keeps_everything_else(openbao_root, openbao, monk assert after.openbao.url == url assert dataclasses.replace(after.openbao, url=before.openbao.url) == before.openbao assert (after.version, after.cache_enabled) == (before.version, before.cache_enabled) + if layout == 'group': + assert after.version == 2 + assert after.openbao.layout == bc.LAYOUT_GROUP + assert after.openbao.group_aliases == {'umbrella': 'acme'} assert (openbao_root / 'secrets' / 'bootstrap.env.age').read_bytes() == creds assert '読めた参照' in out # 保存の後に接続を確かめる From 653b9d5bab657e5394febbcc15eba46cb23c7c8b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 3 Oct 2026 20:52:38 +0900 Subject: [PATCH 13/14] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20lib/devbase/snapshot/manager.py#SnapshotManager.rotate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rotate から、計画どおりに世代を消してログを出す段を _apply_rotation_plan へ、 残りの世代の並べ直しを _rebuild_remaining へ抜き出す。振る舞いは変えない。 Item-Id: I-001 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- lib/devbase/snapshot/manager.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/devbase/snapshot/manager.py b/lib/devbase/snapshot/manager.py index 4f40c41d..5dbd0e22 100644 --- a/lib/devbase/snapshot/manager.py +++ b/lib/devbase/snapshot/manager.py @@ -658,6 +658,16 @@ def rotate(self, keep: int = DEFAULT_MAX_GENERATIONS, if not plan: return 0 + deleted_ids, removed_ids = self._apply_rotation_plan( + snapshots, plan, keep, max_total) + meta['snapshots'] = self._rebuild_remaining(snapshots, removed_ids) + meta['max_generations'] = keep + self._save_metadata(meta) + return len(deleted_ids) + + def _apply_rotation_plan(self, snapshots: list, plan: list, keep: int, + max_total: int) -> tuple: + """ローテーションの計画どおりに世代を消し、(消した添字, 一覧から外す添字) を返す。""" removed_ids = set() deleted_ids = set() for index, reason in plan: @@ -688,14 +698,14 @@ def rotate(self, keep: int = DEFAULT_MAX_GENERATIONS, logger.info( "ローテーション: %s の %d 世代を削除しました(グループごとに %d 世代保持)", label, count, keep) + return deleted_ids, removed_ids + def _rebuild_remaining(self, snapshots: list, removed_ids: set) -> list: + """一覧から外さない世代を、古い順に並べ直して返す。""" remaining = [s for i, s in enumerate(snapshots) if i not in removed_ids] order = {id(s): i for i, s in enumerate(snapshots)} remaining.sort(key=lambda s: self._entry_age(s, order[id(s)])) - meta['snapshots'] = remaining - meta['max_generations'] = keep - self._save_metadata(meta) - return len(deleted_ids) + return remaining def _rotation_plan(self, snapshots: list, keep: int, max_total: int) -> list: """ローテーションで消すエントリを決める (副作用なし)。 From 2030840d7309ed3807b38d73deb056230c4e006b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 3 Oct 2026 20:53:51 +0900 Subject: [PATCH 14/14] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20lib/devbase/snapshot/manager.py#SnapshotManager.=5Fvalidate?= =?UTF-8?q?=5Fvolumes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 共通ボリュームの規則を _reject_invalid_shared へ、グループボリュームの規則を _reject_invalid_group へ抜き出し、本体はマウント名と型の検査と振り分けだけにする。 reject は引数で渡す。振る舞いは変えない。 Item-Id: I-004 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- lib/devbase/snapshot/manager.py | 58 +++++++++++++++++++-------------- 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/lib/devbase/snapshot/manager.py b/lib/devbase/snapshot/manager.py index 5dbd0e22..c37d67a0 100644 --- a/lib/devbase/snapshot/manager.py +++ b/lib/devbase/snapshot/manager.py @@ -1159,34 +1159,42 @@ def reject(reason: str) -> None: reject(f"'{sub}' のボリューム名が文字列ではありません: {name!r}") if sub in ('', SHARED_MOUNT): - if name != HOME_UBUNTU_VOLUME: - reject(f"共通ボリュームに使えるのは {HOME_UBUNTU_VOLUME} だけです" - f" (指定: {name!r})") - continue - - # group: devbase_home_ の形で、 が妥当であること - if not name.startswith(SHARED_VOLUME_PREFIX): - reject(f"グループボリュームは {SHARED_VOLUME_PREFIX} の形で" - f"なければなりません (指定: {name!r})") - if name == LEGACY_GROUP_VOLUME: - # 旧既定のボリュームの系列 (#315 決定 8)。ロールバックの経路として一覧・ - # 復元 (元のボリュームへだけ)・コピー・削除・ローテーションを許す。 - # default は予約語で検証を通らないため、名前を明示して許す - continue - group = name[len(SHARED_VOLUME_PREFIX):] - try: - # 正規化した結果が元の名前と**一致**することまで見る。 - # 検証は前後空白を落とした名前に正規化するので、通るかどうかだけでは - # `devbase_home_ globex ` を弾けない。 - # 実際にマウントされるのは正規化前の生の名前である。 - if get_group_volume(group) != name: - reject(f"グループボリューム {name!r} は正規化された名前では" - f"ありません (期待: {get_group_volume(group)!r})") - except DevbaseError as e: - reject(f"グループボリューム {name!r} のグループ名が不正です: {e}") + SnapshotManager._reject_invalid_shared(name, reject) + else: + SnapshotManager._reject_invalid_group(name, reject) return dict(volumes) + @staticmethod + def _reject_invalid_shared(name: str, reject) -> None: + """共通側 (``''`` / ``ai``) のボリューム名が ``devbase_home_ubuntu`` でなければ拒否する。""" + if name != HOME_UBUNTU_VOLUME: + reject(f"共通ボリュームに使えるのは {HOME_UBUNTU_VOLUME} だけです" + f" (指定: {name!r})") + + @staticmethod + def _reject_invalid_group(name: str, reject) -> None: + """グループ側のボリューム名が ``devbase_home_`` の妥当な形でなければ拒否する。""" + if not name.startswith(SHARED_VOLUME_PREFIX): + reject(f"グループボリュームは {SHARED_VOLUME_PREFIX} の形で" + f"なければなりません (指定: {name!r})") + if name == LEGACY_GROUP_VOLUME: + # 旧既定のボリュームの系列 (#315 決定 8)。ロールバックの経路として一覧・ + # 復元 (元のボリュームへだけ)・コピー・削除・ローテーションを許す。 + # default は予約語で検証を通らないため、名前を明示して許す + return + group = name[len(SHARED_VOLUME_PREFIX):] + try: + # 正規化した結果が元の名前と**一致**することまで見る。 + # 検証は前後空白を落とした名前に正規化するので、通るかどうかだけでは + # `devbase_home_ globex ` を弾けない。 + # 実際にマウントされるのは正規化前の生の名前である。 + if get_group_volume(group) != name: + reject(f"グループボリューム {name!r} は正規化された名前では" + f"ありません (期待: {get_group_volume(group)!r})") + except DevbaseError as e: + reject(f"グループボリューム {name!r} のグループ名が不正です: {e}") + def _load_snap_meta(self, snap_dir: Path) -> dict: """個別スナップショットのmeta.ymlを読み込む""" meta_path = snap_dir / 'meta.yml'