From b7556c8fb9113cf1c2718a443dd2c2048d31e7b8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 24 Sep 2026 08:08:28 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat(PLAN67):=20base=20=E3=82=A4=E3=83=A1?= =?UTF-8?q?=E3=83=BC=E3=82=B8=E3=81=AB=20shellcheck=20=E3=82=92=E5=85=A5?= =?UTF-8?q?=E3=82=8C=E3=82=8B=20(#249)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 1 つ目の RUN の 1 回目の apt-get install の一覧へ shellcheck を足す - 版の確認の RUN へ shellcheck --version を足し、入れ損ないをビルドで止める - Dockerfile の形を固定する test_base_dockerfile_shellcheck.py を新設 - 利用者向け文書(イメージの詳細の表・Bash の静的検査の小節)と CHANGELOG - 実装計画 issues/PLAN67_base-shellcheck-impl.md Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 8 ++ containers/base/Dockerfile | 7 +- docs/user/container-operations.md | 23 ++++- issues/PLAN67_base-shellcheck-impl.md | 78 +++++++++++++++ .../test_base_dockerfile_shellcheck.py | 94 +++++++++++++++++++ 5 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 issues/PLAN67_base-shellcheck-impl.md create mode 100644 tests/containers/test_base_dockerfile_shellcheck.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 03201f42..375a35f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ ## [Unreleased] +### Added +- **base イメージに `shellcheck` を入れました(PLAN67 / #249)。** base と、base を継ぐ派生 + イメージ(`general` / `php` など)のコンテナで、Bash スクリプトの静的検査ができます。 + bash-language-server などの言語サーバが返す Bash の診断もこれを使います。版は固定せず、 + Ubuntu のアーカイブの版(2026-09 時点で 0.11.0)が入ります。入れ損ないはビルドの版の確認で + 止まります。`lfm` / `snapshot` は base を継がないため入りません。 + **反映には `devbase build base --no-cache` と、使っている派生イメージの建て直しが要ります。** + ## [3.7.0] - 2026-09-23 ### Added diff --git a/containers/base/Dockerfile b/containers/base/Dockerfile index f0d94798..90743b18 100644 --- a/containers/base/Dockerfile +++ b/containers/base/Dockerfile @@ -25,7 +25,10 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ fonts-crosextra-carlito fonts-crosextra-caladea \ # 文書を扱う軽量の道具 (#160)。PDF を画像にする・調べる、OOXML を壊さずに読み書きする。 # LibreOffice (展開 372〜459MB) と pip は入れない。Python パッケージは uv / uvx で賄う。 - poppler-utils python3-pil python3-defusedxml python3-lxml; \ + poppler-utils python3-pil python3-defusedxml python3-lxml \ + # Bash の静的検査 (#249)。bash-language-server は診断を shellcheck に任せており、 + # 無いとエラーも警告も出さずに診断が空になる。 + shellcheck; \ # ロケール設定 locale-gen en_US.UTF-8; \ update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8; \ @@ -184,7 +187,7 @@ RUN set -eux; \ bao version # 確認 -RUN gh --version && node --version && npm --version && aws --version && gcloud --version && session-manager-plugin --version +RUN gh --version && node --version && npm --version && aws --version && gcloud --version && session-manager-plugin --version && shellcheck --version USER ${USERNAME} WORKDIR /tmp diff --git a/docs/user/container-operations.md b/docs/user/container-operations.md index 3b9e1c97..0cb84f66 100644 --- a/docs/user/container-operations.md +++ b/docs/user/container-operations.md @@ -363,7 +363,7 @@ graph TD | イメージ | ベース | 主な内容 | 用途 | |---------|-------|---------|------| -| **base** | Ubuntu 26.04 | Docker CLI、Python 3、日本語フォント、PDF / OOXML の道具 | 最小限の開発環境 | +| **base** | Ubuntu 26.04 | Docker CLI、Python 3、日本語フォント、PDF / OOXML の道具、shellcheck | 最小限の開発環境 | | **general** | base | AWS CLI、gcloud、Terraform、Node.js 20、AI CLI | 汎用開発環境 | | **php** | general | PHP 8.5、Composer、MySQL Shell | PHP 8.5 系 開発 | | **php85** | general | PHP 8.5、Composer、MySQL Shell | PHP 8.5 系 開発 | @@ -418,6 +418,27 @@ fontconfig は Chromium / Playwright のスクリーンショット、PDF の生 [base イメージの文字の描画と、文書を扱う道具](../specifications/base-image-rendering.md) にあります。 +### Bash の静的検査(base 以降) + +base イメージには [ShellCheck](https://www.shellcheck.net/)(`shellcheck`)が入っています。 +コンテナの中で Bash スクリプトを検査できます。 + +```bash +shellcheck path/to/script.sh +``` + +指摘は `SC2086` のような番号つきで出て、指摘があれば終了コード 1 で終わります。 +bash-language-server などの言語サーバは Bash の診断を `shellcheck` に任せているため、 +コンテナの中で言語サーバを動かすときもこれが使われます(言語サーバ自体は base に入っていません)。 + +版は固定しておらず、base を建てた時点の Ubuntu のアーカイブの版が入ります。 +`containers/lfm` と `containers/snapshot` は base を継がないため入っていません。 + +> **`devbase build base --no-cache` で base を建て直すと反映されます。** `devbase up` だけでは +> 反映されません。派生イメージ(`general` / `php` など)を使っているプロジェクトは、その +> 派生イメージも建て直し、稼働中のコンテナは `devbase down` → `devbase up` で作り直して +> ください。`devbase rebuild` では建て直りません。 + ### AI CLI エイリアス general イメージ以降のコンテナ内では、以下の AI CLI ツールがエイリアスとして利用可能です。 diff --git a/issues/PLAN67_base-shellcheck-impl.md b/issues/PLAN67_base-shellcheck-impl.md new file mode 100644 index 00000000..e0a78cad --- /dev/null +++ b/issues/PLAN67_base-shellcheck-impl.md @@ -0,0 +1,78 @@ +# PLAN67: base イメージに shellcheck を入れる の実装計画 + +## 関連リンク + +- 課題: devbasex/devbase#249 +- 要求と受け入れ条件: [PLAN67_base-shellcheck.md](PLAN67_base-shellcheck.md)(受け入れ条件 1〜9) +- 設計: [PLAN67_base-shellcheck-design.md](PLAN67_base-shellcheck-design.md)(決定 1〜5) +- 設計の Pull Request: devbasex/devbase#250(マージ済み) + +## モード + +`standard`。base イメージが同梱するコマンドを変え、建て直した全員に届く(要求の文書の根拠のとおり)。 + +## 目的と非目的 + +達成したい状態: + +- base と派生イメージのコンテナで `shellcheck` が使え、診断を返す +- shellcheck が入っていないイメージはビルドの時点で止まる + +やらないこと(要求の文書の「含まない」のとおり): + +- bash-language-server の導入、#247 の片付け、CI の変更、版の固定、lfm / snapshot への導入 +- 「イメージの詳細」の表の、base 以外の行のベース列の訂正(実装中に見つけた。#251 として起票) + +## 修正対象 + +- `containers/base/Dockerfile` +- `tests/containers/test_base_dockerfile_shellcheck.py`(新設) +- `docs/user/container-operations.md` +- `CHANGELOG.md` + +## タスク分解 + +### Task 1: 1 回目の apt-get install の一覧へ shellcheck を足す + +- **対象ファイル:** `tests/containers/test_base_dockerfile_shellcheck.py`、`containers/base/Dockerfile` +- **変更内容:** `poppler-utils ...;` の行の `;` を `\` に変え、理由のコメントと `shellcheck; \` を足す(設計「入出力の契約」) +- **満たす受け入れ条件:** 5 +- **進め方:** 失敗するテスト(1 回目の一覧に `shellcheck` がある / `shellcheck` を入れる別の `RUN` が無く `apt-get update` が 2 回のまま)→ Dockerfile の変更 → 整理 + +### Task 2: 版の確認の RUN へ `shellcheck --version` を足す + +- **対象ファイル:** 同上 +- **変更内容:** `session-manager-plugin --version` の後へ `&& shellcheck --version` +- **満たす受け入れ条件:** 4 +- **進め方:** 失敗するテスト(版の確認の `RUN` に `shellcheck --version` がある)→ Dockerfile の変更 + +### Task 3: 利用者向け文書と CHANGELOG + +- **対象ファイル:** `docs/user/container-operations.md`、`CHANGELOG.md` +- **変更内容:** 「イメージの詳細」の表の base の行へ shellcheck、新しい小節「Bash の静的検査(base 以降)」、`[Unreleased]` の `### Added`。表以外の 2 か所に `devbase build base --no-cache` が要ることを書く +- **満たす受け入れ条件:** 9 +- **進め方:** 文書のためテスト駆動を適用しない。差分の目視で確かめる + +### Task 4: 手元で建てて確かめる + +- **対象:** 手元の arm64 の Docker +- **変更内容:** 無し(証跡の採取)。変更前の `devbase-base:latest` で受け入れ条件 6 の `apt-get install -s` を採ってから、`devbase build base --no-cache` → `devbase-general` / `devbase-php` の建て直し → 受け入れ条件 1・2・3・6 のコマンド +- **満たす受け入れ条件:** 1・2・3・6・8。7 は `uv run --locked pytest tests/ -q` +- **進め方:** 出力を Pull Request 本文へ貼る。CI はイメージを建てない(要求の文書の前提 2) + +## リスクと対処 + +| リスク | 対処 | +| --- | --- | +| Docker のビルドキャッシュが壊れた層を配る | `--no-cache` で建てる。0 バイトのファイルが出たら builder prune の後に建て直す | +| 変更前のイメージを建て直しで失う | 受け入れ条件 6 の `apt-get install -s` を建て直しの前に採る | +| pytest が実環境の `DEVBASE_ROOT` を継承する | 新しいテストは Dockerfile の文字列しか読まない | + +## 切り戻し手順 + +要求の文書の「切り戻し手順」のとおり(revert → `devbase build base --no-cache` → 派生イメージの建て直し → コンテナの作り直し)。 + +## 完了の定義 + +- [ ] 受け入れ条件 1〜9 をすべて満たし、条件ごとの検証手段と結果を Pull Request 本文に載せた +- [ ] `uv run --locked pytest tests/ -q` が終了コード 0 diff --git a/tests/containers/test_base_dockerfile_shellcheck.py b/tests/containers/test_base_dockerfile_shellcheck.py new file mode 100644 index 00000000..9dc5c525 --- /dev/null +++ b/tests/containers/test_base_dockerfile_shellcheck.py @@ -0,0 +1,94 @@ +"""base イメージの shellcheck の導入の「形」 (PLAN67 / #249) + +Docker を起動せず、``containers/base/Dockerfile`` の文字列だけを固定する。イメージの中に +入っていることは、版の確認の ``RUN`` がビルドの時点で守る (設計の決定 2)。ここで固定するのは +次の 3 つである (設計の決定 4)。 + +- ``shellcheck`` が **1 つ目の RUN の 1 回目の** ``apt-get install`` の一覧にある (受け入れ条件 5) +- ``shellcheck`` を入れる ``RUN`` が他に無く、``apt-get update`` が 2 回のまま (受け入れ条件 5) +- 版の確認の ``RUN`` に ``shellcheck --version`` がある (受け入れ条件 4) + +補助の関数は ``test_base_dockerfile_fonts.py`` から import しない。テストのファイルどうしを +依存させない (``test_base_dockerfile_bao.py`` も自前の ``_statements`` を持つ)。 +""" + +from __future__ import annotations + +import re +from pathlib import Path + +DOCKERFILE = Path(__file__).resolve().parents[2] / "containers" / "base" / "Dockerfile" +SHELLCHECK = re.compile(r"(? str: + """コメント行を除いた Dockerfile の本文 (説明の注記に assertion が反応しないように)""" + return "\n".join( + line for line in DOCKERFILE.read_text().splitlines() + if not line.lstrip().startswith("#") + ) + + +def _run_blocks() -> list[str]: + """Dockerfile を RUN ブロック単位 (行継続を含む 1 命令分) に分ける + + ``RUN`` で始まる**行**だけを見ると、行継続の先にあるパッケージ名を 1 つも拾えない。 + """ + blocks: list[str] = [] + block: list[str] | None = None + for line in _statements().splitlines(): + if block is None: + if not line.startswith("RUN "): + continue + block = [] + block.append(line) + if not line.rstrip().endswith("\\"): + blocks.append("\n".join(block)) + block = None + if block is not None: # 最終行が \ で終わっていても取りこぼさない + blocks.append("\n".join(block)) + assert blocks, "RUN が 1 つも見つからない" + return blocks + + +def _first_apt_install(block: str) -> str: + """1 つ目の RUN の**1 回目**の apt-get install の一覧だけを取り出す + + 1 つ目の RUN は apt-get install を 2 回呼ぶ。RUN の本文全体で探すと、2 回目の一覧 + (後から足したリポジトリの docker-ce / gh / nodejs など) にあっても通ってしまう。 + """ + calls = [m.start() for m in re.finditer(r"apt-get install", block)] + assert len(calls) >= 2, "1 つ目の RUN に apt-get install が 2 回無い" + return block[calls[0]:calls[1]] + + +def _version_check_run() -> str: + """gh / node / aws などの版を確かめる RUN の 1 命令分""" + found = [b for b in _run_blocks() if "gh --version" in b and "session-manager-plugin --version" in b] + assert len(found) == 1, "版の確認の RUN がちょうど 1 つではない" + return found[0] + + +def test_shellcheck_is_in_the_first_apt_install(): + """決定 1。標準のアーカイブのパッケージなので、1 回目の一覧へ置く""" + assert SHELLCHECK.search(_first_apt_install(_run_blocks()[0])) + + +def test_shellcheck_is_not_in_the_second_apt_install(): + """2 回目は後から足したリポジトリのパッケージを入れる場所で、混ぜない""" + first = _run_blocks()[0] + second = first[[m.start() for m in re.finditer(r"apt-get install", first)][1]:] + assert not SHELLCHECK.search(second) + + +def test_no_extra_run_installs_shellcheck(): + """決定 1。新しい RUN を立てず、apt-get update をもう 1 回走らせない""" + for block in _run_blocks()[1:]: + assert not re.search(r"apt-get\s+install[^;&]*(? Date: Thu, 24 Sep 2026 08:16:49 +0900 Subject: [PATCH 2/8] test: characterize base image browser, npm globals, and apt packages Add characterization tests for the amd64 browser branch, offline npm global replacement by the default user, and both apt package sets. Leave the Dockerfile unchanged. Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../test_base_dockerfile_browser.py | 29 +++++++ .../test_base_dockerfile_shellcheck.py | 25 +++++++ .../containers/test_base_image_npm_globals.py | 75 +++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 tests/containers/test_base_dockerfile_browser.py create mode 100644 tests/containers/test_base_image_npm_globals.py diff --git a/tests/containers/test_base_dockerfile_browser.py b/tests/containers/test_base_dockerfile_browser.py new file mode 100644 index 00000000..e52fbcca --- /dev/null +++ b/tests/containers/test_base_dockerfile_browser.py @@ -0,0 +1,29 @@ +"""1 つ目の RUN にあるブラウザのアーキテクチャ分岐の現状固定。""" + +import re +from pathlib import Path + +DOCKERFILE = Path(__file__).resolve().parents[2] / "containers/base/Dockerfile" + + +def test_chrome_is_amd64_only_and_chromium_is_unconditional(): + lines = [line for line in DOCKERFILE.read_text().splitlines() + if not line.lstrip().startswith("#")] + statements = "\n".join(lines).replace("\\\n", " ") + first = next(line for line in statements.splitlines() if line.startswith("RUN ")) + branch = re.search(r'if \[ "\$arch" = "amd64" \]; then\s+(.*?)\s+fi;', first) + assert branch is not None + before, body, after = first[:branch.start()], branch[1], first[branch.end():] + assert 'arch="$(dpkg --print-architecture)";' in before + assert 'BROWSER_PKG="";' in before + assert 'BROWSER_PKG="google-chrome-stable";' in body + assert "https://dl.google.com/linux/linux_signing_key.pub" in body + assert "gpg --dearmor -o /etc/apt/keyrings/google-chrome.gpg" in body + assert 'http://dl.google.com/linux/chrome/deb/ stable main"' in body + assert "> /etc/apt/sources.list.d/google-chrome.list;" in body + assert "google-chrome" not in before + after + assert "BROWSER_PKG=" not in after + assert not re.search(r"\b(?:if|else|elif)\b", body + after) + install = re.search(r"apt-get install\s+([^;]+);", after) + assert install is not None + assert {"chromium-browser", "$BROWSER_PKG"} <= set(install[1].split()) diff --git a/tests/containers/test_base_dockerfile_shellcheck.py b/tests/containers/test_base_dockerfile_shellcheck.py index 9dc5c525..88d9022c 100644 --- a/tests/containers/test_base_dockerfile_shellcheck.py +++ b/tests/containers/test_base_dockerfile_shellcheck.py @@ -92,3 +92,28 @@ def test_version_check_run_calls_shellcheck(): """決定 2。入れ損ないを、版の確認の RUN で止める (無ければ終了コード 127)""" commands = [c.strip() for c in _version_check_run().removeprefix("RUN ").split("&&")] assert "shellcheck --version" in commands + + +def test_first_run_apt_package_sets_match_current_dockerfile(): + """現行 RUN から抽出して観測した 2 回分の集合。順序・オプションは固定しない。""" + calls = re.findall(r"apt-get\s+install\s+([^;]+);", _run_blocks()[0]) + packages = [ + {word for word in call.replace("\\\n", " ").split() + if word != "\\" and not word.startswith("-")} + for call in calls + ] + assert packages == [ + { + "ca-certificates", "curl", "fonts-crosextra-caladea", + "fonts-crosextra-carlito", "fonts-noto-cjk", "fonts-noto-cjk-extra", + "git", "gnupg", "jq", "libnss3", "libxrandr2", "libxss1", "locales", + "lsb-release", "make", "nano", "openssh-client", "poppler-utils", + "python3-defusedxml", "python3-lxml", "python3-pil", "shellcheck", + "sudo", "tmux", "unzip", "vim", "wget", + }, + { + "$BROWSER_PKG", "chromium-browser", "containerd.io", + "docker-buildx-plugin", "docker-ce", "docker-ce-cli", + "docker-compose-plugin", "gh", "nodejs", "terraform", + }, + ] diff --git a/tests/containers/test_base_image_npm_globals.py b/tests/containers/test_base_image_npm_globals.py new file mode 100644 index 00000000..4e924cdc --- /dev/null +++ b/tests/containers/test_base_image_npm_globals.py @@ -0,0 +1,75 @@ +"""既定ユーザーによる npm globals 更新の現状固定。 + +実行前に現行のソースから専用イメージを用意する: + docker build -t devbase-base:npm-globals-test containers/base +ネットワーク、ホストのマウント、sudo、prefix/PATH の変更は使わない。 +""" + +import shutil +import subprocess +import uuid + +import pytest + +IMAGE = "devbase-base:npm-globals-test" + +PROBE = r""" +set -eu +printf 'uid=%s\n' "$(id -u)" +test "$(id -u)" -ne 0 +# 置換対象が既にインストールされていることを確認する。 +npm ls -g @openai/codex --depth=0 >/dev/null +fixture_dir=$(mktemp -d) +cd "$fixture_dir" +cat > package.json <<'JSON' +{"name":"@openai/codex","version":"0.0.0-characterization","bin":{"codex":"codex.js"}} +JSON +cat > codex.js <<'JS' +#!/usr/bin/env node +console.log("devbase-npm-global-characterization"); +JS +tarball=$(npm pack --offline --ignore-scripts --no-audit --no-fund --silent) +set +e +npm install -g --offline --ignore-scripts --no-audit --no-fund "$fixture_dir/$tarball" +install_status=$? +output=$(/bin/bash -c codex) +command_status=$? +printf 'install_status=%s\ncommand_status=%s\noutput=%s\n' \ + "$install_status" "$command_status" "$output" +""" + + +def test_default_user_can_replace_and_run_root_installed_npm_global(): + if shutil.which("docker") is None: + pytest.skip("docker が PATH に無い") + try: + subprocess.run(["docker", "info"], capture_output=True, timeout=30, check=True) + except (subprocess.SubprocessError, OSError) as error: + pytest.skip(f"docker daemon が利用できない: {error}") + inspected = subprocess.run( + ["docker", "image", "inspect", IMAGE], capture_output=True, text=True, timeout=30, + ) + if inspected.returncode != 0: + pytest.skip(f"{IMAGE} が無い。モジュール冒頭の docker build を実行する: {inspected.stderr}") + + name = f"devbase-npm-globals-{uuid.uuid4().hex}" + try: + result = subprocess.run( + ["docker", "run", "--rm", "--name", name, "--network=none", + "--entrypoint=/bin/bash", IMAGE, "-c", PROBE], + capture_output=True, text=True, timeout=180, + ) + finally: + # --rm に加え、タイムアウトでクライアントが終了してもコンテナを残さない。 + subprocess.run(["docker", "rm", "-f", name], capture_output=True, timeout=30) + + assert result.returncode == 0, result.stdout + result.stderr + observed = dict(line.split("=", 1) for line in result.stdout.splitlines() + if line.startswith(("uid=", "install_status=", "command_status=", "output="))) + assert int(observed.pop("uid")) != 0 + # 現行 Dockerfile からのイメージで実測: UID=1000、両終了コード=0、下記識別値。 + assert observed == { + "install_status": "0", + "command_status": "0", + "output": "devbase-npm-global-characterization", + }, result.stdout + result.stderr From 38b1a52ae7d40afd650b683b2afe25b4d4f387df Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 24 Sep 2026 08:20:34 +0900 Subject: [PATCH 3/8] =?UTF-8?q?Test:=20characterization=20=E2=80=94=20cont?= =?UTF-8?q?ainers/base/Dockerfile#RUN=20aws-gcloud-uv-npm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit session-manager-plugin と gcloud SDK のアーキテクチャ分岐を現状固定する。 ssm_arch の case (amd64→ubuntu_64bit / arm64→ubuntu_arm64 / *→exit 1) と gcloud_arch の case (x86_64→x86_64 / aarch64→arm / *→exit 1) の各枝、および 選んだ値がダウンロード URL の ${ssm_arch} / ${gcloud_arch} で参照されることを 固定した。RUN を分けたりまとめたりしたときに片方の対応や未対応時の停止が 欠けても検出できる。対象コードは変更していない。 Item-Id: R1-004 Round: 1 Impl-Runtime: kiro Impl-Model: default --- .../test_base_dockerfile_shellcheck.py | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/containers/test_base_dockerfile_shellcheck.py b/tests/containers/test_base_dockerfile_shellcheck.py index 88d9022c..a0ac81aa 100644 --- a/tests/containers/test_base_dockerfile_shellcheck.py +++ b/tests/containers/test_base_dockerfile_shellcheck.py @@ -117,3 +117,61 @@ def test_first_run_apt_package_sets_match_current_dockerfile(): "docker-compose-plugin", "gh", "nodejs", "terraform", }, ] + + +# --------------------------------------------------------------------------- +# AWS CLI + gcloud SDK + uv + npm globals の RUN のアーキテクチャ分岐 (R1-004) +# +# session-manager-plugin と gcloud SDK は、ダウンロードするアーキテクチャを ``case`` で +# 選ぶ。この RUN を分けたりまとめたりしたときに、片方の対応や未対応時の停止が欠けても +# 検出できるよう、現状の分岐をそのまま固定する。正しさは主張せず現状を記録する。 +# --------------------------------------------------------------------------- + + +def _aws_gcloud_run() -> str: + """AWS CLI / session-manager-plugin / gcloud SDK / uv / npm を入れる RUN の 1 命令分""" + found = [ + b for b in _run_blocks() + if "ssm_arch=" in b and "gcloud_arch=" in b + ] + assert len(found) == 1, "ssm_arch と gcloud_arch を持つ RUN がちょうど 1 つではない" + return found[0] + + +def _case_branches(block: str, var: str) -> dict[str, str]: + """``) ="値" ;;`` 形式の枝を {パターン: 値} に、``*) ... exit N`` の枝を + {"*": "exit N"} に写し取る。行継続の ``\\`` と空白は畳んで扱う。 + """ + flat = block.replace("\\\n", " ") + branches: dict[str, str] = {} + for pat, value in re.findall( + rf'([\w*]+)\)\s+{re.escape(var)}="([^"]+)"\s*;;', flat + ): + branches[pat] = value + fallback = re.search(r"\*\)[^;]*?(exit\s+\d+)", flat) + if fallback: + branches["*"] = fallback.group(1) + return branches + + +def test_ssm_arch_case_maps_current_architectures(): + """session-manager-plugin の case。amd64 / arm64 が現状の値へ、それ以外は exit 1""" + branches = _case_branches(_aws_gcloud_run(), "ssm_arch") + assert branches["amd64"] == "ubuntu_64bit" + assert branches["arm64"] == "ubuntu_arm64" + assert branches["*"] == "exit 1" + + +def test_gcloud_arch_case_maps_current_architectures(): + """gcloud SDK の case。x86_64 / aarch64 が現状の値へ、それ以外は exit 1""" + branches = _case_branches(_aws_gcloud_run(), "gcloud_arch") + assert branches["x86_64"] == "x86_64" + assert branches["aarch64"] == "arm" + assert branches["*"] == "exit 1" + + +def test_ssm_and_gcloud_arch_values_are_used_in_download_urls(): + """case で選んだ値がダウンロード URL の ${ssm_arch} / ${gcloud_arch} で参照される""" + block = _aws_gcloud_run() + assert "session-manager-downloads" in block and "${ssm_arch}" in block + assert "google-cloud-cli-linux-${gcloud_arch}" in block From 8414aaa23e04b1a9ee1c6f13105fda17f225b105 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 24 Sep 2026 08:23:08 +0900 Subject: [PATCH 4/8] =?UTF-8?q?Test:=20characterization=20=E2=80=94=20cont?= =?UTF-8?q?ainers/base/Dockerfile#RUN=20version-check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 版の確認の RUN のコマンド一式 (gh / node / npm / aws / gcloud / session-manager-plugin / shellcheck の --version) を集合で固定する。 RUN を組み替えたときに確認が黙って欠けても検出できるようにする。 Item-Id: R1-005 Round: 1 Impl-Runtime: claude Impl-Model: default Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/containers/test_base_dockerfile_shellcheck.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/containers/test_base_dockerfile_shellcheck.py b/tests/containers/test_base_dockerfile_shellcheck.py index a0ac81aa..a64fe1d7 100644 --- a/tests/containers/test_base_dockerfile_shellcheck.py +++ b/tests/containers/test_base_dockerfile_shellcheck.py @@ -175,3 +175,15 @@ def test_ssm_and_gcloud_arch_values_are_used_in_download_urls(): block = _aws_gcloud_run() assert "session-manager-downloads" in block and "${ssm_arch}" in block assert "google-cloud-cli-linux-${gcloud_arch}" in block + + +def test_version_check_run_commands_match_current_dockerfile(): + """版の確認の RUN の現状のコマンド一式 (R1-005)。順序は仕様ではないので集合で比べる + + shellcheck 以外の 6 つも固定し、RUN を組み替えたときに確認が黙って欠けないようにする。 + """ + commands = {c.strip() for c in _version_check_run().removeprefix("RUN ").split("&&")} + assert commands == { + "gh --version", "node --version", "npm --version", "aws --version", + "gcloud --version", "session-manager-plugin --version", "shellcheck --version", + } From f2f2c818f5124970fed20ec94f8b3f73a959d418 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 24 Sep 2026 08:30:20 +0900 Subject: [PATCH 5/8] test: characterize agy failures and bao and AWS CLI paths Add characterization tests for R2-001, R2-002, and R2-005 without changing the Dockerfile. Item-Id: R2-001 Round: 2 Impl-Runtime: codex Impl-Model: default --- .../test_base_dockerfile_aws_cli.py | 37 +++++++ tests/containers/test_base_dockerfile_bao.py | 17 +++ .../test_base_dockerfile_shellcheck.py | 103 ++++++++++++++++++ 3 files changed, 157 insertions(+) create mode 100644 tests/containers/test_base_dockerfile_aws_cli.py diff --git a/tests/containers/test_base_dockerfile_aws_cli.py b/tests/containers/test_base_dockerfile_aws_cli.py new file mode 100644 index 00000000..9d8d90e4 --- /dev/null +++ b/tests/containers/test_base_dockerfile_aws_cli.py @@ -0,0 +1,37 @@ +"""AWS CLI v2 の現行ダウンロード・展開・導入を Dockerfile の文言で固定する。""" + +from __future__ import annotations + +import re +from pathlib import Path + +DOCKERFILE = Path(__file__).resolve().parents[2] / "containers" / "base" / "Dockerfile" + + +def _aws_gcloud_run() -> str: + blocks = [] + block = [] + for line in DOCKERFILE.read_text().splitlines(): + if line.lstrip().startswith("#"): + continue + if not block and not line.startswith("RUN "): + continue + block.append(line) + if not line.rstrip().endswith("\\"): + blocks.append("\n".join(block)) + block = [] + found = [b for b in blocks if "ssm_arch=" in b and "gcloud_arch=" in b] + assert len(found) == 1 + return found[0] + + +def test_aws_cli_uses_uname_directly_then_unpacks_and_installs(): + """case を挟まず uname -m を URL に埋め、その ZIP を展開して導入する現状。""" + flat = _aws_gcloud_run().replace("\\\n", " ") + assert re.search( + r'curl\s+-fsSL\s+"https://awscli\.amazonaws\.com/' + r'awscli-exe-linux-\$\(uname -m\)\.zip"\s+-o\s+/tmp/awscliv2\.zip;\s*' + r'unzip\s+-q\s+/tmp/awscliv2\.zip\s+-d\s+/tmp;\s*' + r'/tmp/aws/install;', + flat, + ) diff --git a/tests/containers/test_base_dockerfile_bao.py b/tests/containers/test_base_dockerfile_bao.py index 699d27c7..6199f6e3 100644 --- a/tests/containers/test_base_dockerfile_bao.py +++ b/tests/containers/test_base_dockerfile_bao.py @@ -51,6 +51,23 @@ def test_both_architectures_are_selectable(): assert "openbao_${BAO_VERSION}_linux_${bao_arch}.tar.gz" in block +def test_architecture_case_rejects_unsupported_architectures(): + """現状の対応表と、未対応時にメッセージを出して exit 1 する枝を固定する。""" + flat = _bao_run_block().replace("\\\n", " ") + case = re.search(r"\bcase\b.*?\bin\b(.*?)\besac\b", flat) + assert case is not None + branches = dict(re.findall(r"([\w*]+)\)\s*(.*?)\s*;;", case.group(1))) + architectures = { + pattern: re.fullmatch(r'bao_arch="([^"]+)"', body).group(1) + for pattern, body in branches.items() if pattern != "*" + } + assert architectures == {"amd64": "amd64", "arm64": "arm64"} + assert re.fullmatch( + r'echo "Unsupported architecture for bao: \$\(dpkg --print-architecture\)"' + r"\s*&&\s*exit 1", branches["*"], + ) + + def test_tarball_is_verified_against_the_release_checksums(): block = _bao_run_block() assert 'bao_base="https://github.com/openbao/openbao/releases/download/v${BAO_VERSION}"' in block diff --git a/tests/containers/test_base_dockerfile_shellcheck.py b/tests/containers/test_base_dockerfile_shellcheck.py index a64fe1d7..4a1736a8 100644 --- a/tests/containers/test_base_dockerfile_shellcheck.py +++ b/tests/containers/test_base_dockerfile_shellcheck.py @@ -15,8 +15,12 @@ from __future__ import annotations import re +import subprocess +import sys from pathlib import Path +import pytest + DOCKERFILE = Path(__file__).resolve().parents[2] / "containers" / "base" / "Dockerfile" SHELLCHECK = re.compile(r"(? "$ARTIFACTS/{name}"\n') + (fixtures / "agy").write_text( + 'printf started > "$ARTIFACTS/agy-installer"\n' + + ("exit 42\n" if failure == "installer" else "exit 0\n") + ) + # PATH にホストのコマンドを含めず、シェルだけ本物で fixture を実行する。 + for name in ("sh", "bash"): + (bin_dir / name).symlink_to(f"/bin/{name}") + stub = tmp_path / "command-stub" + stub.write_text( + f"#!{sys.executable}\n" + + '''import os +import sys +from pathlib import Path + +name = Path(sys.argv[0]).name +args = sys.argv[1:] +artifacts = Path(os.environ["ARTIFACTS"]) +if name == "curl": + url = next(arg for arg in args if arg.startswith("https://")) + sources = { + "https://astral.sh/uv/install.sh": "uv", + "https://claude.ai/install.sh": "claude", + "https://antigravity.google/cli/install.sh": "agy", + } + if url in sources: + source = sources[url] + if source == "agy": + (artifacts / "agy-download").write_text("attempted") + if os.environ["FAILURE"] == "download": + sys.exit(22) + data = (Path(os.environ["FIXTURES"]) / source).read_text() + elif url.startswith("https://desktop-release.q.us-east-1.amazonaws.com/"): + (artifacts / "kiro-download").write_text("downloaded") + data = "kiro fixture" + else: + raise AssertionError(url) + if "-o" in args: + Path(args[args.index("-o") + 1]).write_text(data) + else: + sys.stdout.write(data) +elif name == "uname": + print("x86_64") +elif name == "unzip": + Path("kirocli").mkdir(exist_ok=True) + installer = Path("kirocli/install.sh") + installer.write_text("#!/bin/sh\\nexit 0\\n") + installer.chmod(0o755) +elif name == "npx": + (artifacts / "playwright-install").write_text("installed") +elif name in ("rm", "sudo"): + pass # ホストの削除・権限昇格は実行しない。 +else: + raise AssertionError(name) +''' + ) + stub.chmod(0o755) + for name in ("curl", "uname", "unzip", "npx", "rm", "sudo"): + (bin_dir / name).symlink_to(stub) + result = subprocess.run( + ["/bin/sh", "-c", blocks[0].removeprefix("RUN ")], + cwd=tmp_path, + env={ + "HOME": str(home), "PATH": str(bin_dir), + "ARTIFACTS": str(artifacts), "FIXTURES": str(fixtures), + "FAILURE": failure, + }, + capture_output=True, text=True, timeout=10, + ) + assert result.returncode == exit_code, result.stderr + assert (artifacts / "uv").read_text() == "installed" + assert (artifacts / "claude").read_text() == "installed" + assert (artifacts / "agy-download").exists() + assert (artifacts / "agy-installer").exists() == (failure != "download") + assert not (artifacts / "kiro-download").exists() + assert not (artifacts / "playwright-install").exists() From e379a6c14adb3695a0b9d49960d9ad66cbf1fdc3 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 24 Sep 2026 08:32:11 +0900 Subject: [PATCH 6/8] =?UTF-8?q?revert:=20=E6=A7=8B=E9=80=A0=E6=94=B9?= =?UTF-8?q?=E5=96=84=E3=81=AE=E7=8F=BE=E7=8A=B6=E5=9B=BA=E5=AE=9A=E3=83=86?= =?UTF-8?q?=E3=82=B9=E3=83=88=E3=82=92=20#249=20=E3=81=AE=E7=AF=84?= =?UTF-8?q?=E5=9B=B2=E5=A4=96=E3=81=A8=E3=81=97=E3=81=A6=E6=88=BB=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cross-refactoring のテスト整備ラウンドが --scope(containers/base/Dockerfile)を ファイル単位で読み、#249 と関係しない既存の命令(AWS CLI / gcloud / bao / agy / ブラウザ / npm のグローバル)の現状固定テストを 4 コミット積んだ。shellcheck の 2 か所は既存のテストが固定しているため、4 コミットをまとめて戻す。 Revert f2f2c81 8414aaa 38b1a52 a67ca10 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../test_base_dockerfile_aws_cli.py | 37 ---- tests/containers/test_base_dockerfile_bao.py | 17 -- .../test_base_dockerfile_browser.py | 29 --- .../test_base_dockerfile_shellcheck.py | 198 ------------------ .../containers/test_base_image_npm_globals.py | 75 ------- 5 files changed, 356 deletions(-) delete mode 100644 tests/containers/test_base_dockerfile_aws_cli.py delete mode 100644 tests/containers/test_base_dockerfile_browser.py delete mode 100644 tests/containers/test_base_image_npm_globals.py diff --git a/tests/containers/test_base_dockerfile_aws_cli.py b/tests/containers/test_base_dockerfile_aws_cli.py deleted file mode 100644 index 9d8d90e4..00000000 --- a/tests/containers/test_base_dockerfile_aws_cli.py +++ /dev/null @@ -1,37 +0,0 @@ -"""AWS CLI v2 の現行ダウンロード・展開・導入を Dockerfile の文言で固定する。""" - -from __future__ import annotations - -import re -from pathlib import Path - -DOCKERFILE = Path(__file__).resolve().parents[2] / "containers" / "base" / "Dockerfile" - - -def _aws_gcloud_run() -> str: - blocks = [] - block = [] - for line in DOCKERFILE.read_text().splitlines(): - if line.lstrip().startswith("#"): - continue - if not block and not line.startswith("RUN "): - continue - block.append(line) - if not line.rstrip().endswith("\\"): - blocks.append("\n".join(block)) - block = [] - found = [b for b in blocks if "ssm_arch=" in b and "gcloud_arch=" in b] - assert len(found) == 1 - return found[0] - - -def test_aws_cli_uses_uname_directly_then_unpacks_and_installs(): - """case を挟まず uname -m を URL に埋め、その ZIP を展開して導入する現状。""" - flat = _aws_gcloud_run().replace("\\\n", " ") - assert re.search( - r'curl\s+-fsSL\s+"https://awscli\.amazonaws\.com/' - r'awscli-exe-linux-\$\(uname -m\)\.zip"\s+-o\s+/tmp/awscliv2\.zip;\s*' - r'unzip\s+-q\s+/tmp/awscliv2\.zip\s+-d\s+/tmp;\s*' - r'/tmp/aws/install;', - flat, - ) diff --git a/tests/containers/test_base_dockerfile_bao.py b/tests/containers/test_base_dockerfile_bao.py index 6199f6e3..699d27c7 100644 --- a/tests/containers/test_base_dockerfile_bao.py +++ b/tests/containers/test_base_dockerfile_bao.py @@ -51,23 +51,6 @@ def test_both_architectures_are_selectable(): assert "openbao_${BAO_VERSION}_linux_${bao_arch}.tar.gz" in block -def test_architecture_case_rejects_unsupported_architectures(): - """現状の対応表と、未対応時にメッセージを出して exit 1 する枝を固定する。""" - flat = _bao_run_block().replace("\\\n", " ") - case = re.search(r"\bcase\b.*?\bin\b(.*?)\besac\b", flat) - assert case is not None - branches = dict(re.findall(r"([\w*]+)\)\s*(.*?)\s*;;", case.group(1))) - architectures = { - pattern: re.fullmatch(r'bao_arch="([^"]+)"', body).group(1) - for pattern, body in branches.items() if pattern != "*" - } - assert architectures == {"amd64": "amd64", "arm64": "arm64"} - assert re.fullmatch( - r'echo "Unsupported architecture for bao: \$\(dpkg --print-architecture\)"' - r"\s*&&\s*exit 1", branches["*"], - ) - - def test_tarball_is_verified_against_the_release_checksums(): block = _bao_run_block() assert 'bao_base="https://github.com/openbao/openbao/releases/download/v${BAO_VERSION}"' in block diff --git a/tests/containers/test_base_dockerfile_browser.py b/tests/containers/test_base_dockerfile_browser.py deleted file mode 100644 index e52fbcca..00000000 --- a/tests/containers/test_base_dockerfile_browser.py +++ /dev/null @@ -1,29 +0,0 @@ -"""1 つ目の RUN にあるブラウザのアーキテクチャ分岐の現状固定。""" - -import re -from pathlib import Path - -DOCKERFILE = Path(__file__).resolve().parents[2] / "containers/base/Dockerfile" - - -def test_chrome_is_amd64_only_and_chromium_is_unconditional(): - lines = [line for line in DOCKERFILE.read_text().splitlines() - if not line.lstrip().startswith("#")] - statements = "\n".join(lines).replace("\\\n", " ") - first = next(line for line in statements.splitlines() if line.startswith("RUN ")) - branch = re.search(r'if \[ "\$arch" = "amd64" \]; then\s+(.*?)\s+fi;', first) - assert branch is not None - before, body, after = first[:branch.start()], branch[1], first[branch.end():] - assert 'arch="$(dpkg --print-architecture)";' in before - assert 'BROWSER_PKG="";' in before - assert 'BROWSER_PKG="google-chrome-stable";' in body - assert "https://dl.google.com/linux/linux_signing_key.pub" in body - assert "gpg --dearmor -o /etc/apt/keyrings/google-chrome.gpg" in body - assert 'http://dl.google.com/linux/chrome/deb/ stable main"' in body - assert "> /etc/apt/sources.list.d/google-chrome.list;" in body - assert "google-chrome" not in before + after - assert "BROWSER_PKG=" not in after - assert not re.search(r"\b(?:if|else|elif)\b", body + after) - install = re.search(r"apt-get install\s+([^;]+);", after) - assert install is not None - assert {"chromium-browser", "$BROWSER_PKG"} <= set(install[1].split()) diff --git a/tests/containers/test_base_dockerfile_shellcheck.py b/tests/containers/test_base_dockerfile_shellcheck.py index 4a1736a8..9dc5c525 100644 --- a/tests/containers/test_base_dockerfile_shellcheck.py +++ b/tests/containers/test_base_dockerfile_shellcheck.py @@ -15,12 +15,8 @@ from __future__ import annotations import re -import subprocess -import sys from pathlib import Path -import pytest - DOCKERFILE = Path(__file__).resolve().parents[2] / "containers" / "base" / "Dockerfile" SHELLCHECK = re.compile(r"(? str: - """AWS CLI / session-manager-plugin / gcloud SDK / uv / npm を入れる RUN の 1 命令分""" - found = [ - b for b in _run_blocks() - if "ssm_arch=" in b and "gcloud_arch=" in b - ] - assert len(found) == 1, "ssm_arch と gcloud_arch を持つ RUN がちょうど 1 つではない" - return found[0] - - -def _case_branches(block: str, var: str) -> dict[str, str]: - """``) ="値" ;;`` 形式の枝を {パターン: 値} に、``*) ... exit N`` の枝を - {"*": "exit N"} に写し取る。行継続の ``\\`` と空白は畳んで扱う。 - """ - flat = block.replace("\\\n", " ") - branches: dict[str, str] = {} - for pat, value in re.findall( - rf'([\w*]+)\)\s+{re.escape(var)}="([^"]+)"\s*;;', flat - ): - branches[pat] = value - fallback = re.search(r"\*\)[^;]*?(exit\s+\d+)", flat) - if fallback: - branches["*"] = fallback.group(1) - return branches - - -def test_ssm_arch_case_maps_current_architectures(): - """session-manager-plugin の case。amd64 / arm64 が現状の値へ、それ以外は exit 1""" - branches = _case_branches(_aws_gcloud_run(), "ssm_arch") - assert branches["amd64"] == "ubuntu_64bit" - assert branches["arm64"] == "ubuntu_arm64" - assert branches["*"] == "exit 1" - - -def test_gcloud_arch_case_maps_current_architectures(): - """gcloud SDK の case。x86_64 / aarch64 が現状の値へ、それ以外は exit 1""" - branches = _case_branches(_aws_gcloud_run(), "gcloud_arch") - assert branches["x86_64"] == "x86_64" - assert branches["aarch64"] == "arm" - assert branches["*"] == "exit 1" - - -def test_ssm_and_gcloud_arch_values_are_used_in_download_urls(): - """case で選んだ値がダウンロード URL の ${ssm_arch} / ${gcloud_arch} で参照される""" - block = _aws_gcloud_run() - assert "session-manager-downloads" in block and "${ssm_arch}" in block - assert "google-cloud-cli-linux-${gcloud_arch}" in block - - -def test_version_check_run_commands_match_current_dockerfile(): - """版の確認の RUN の現状のコマンド一式 (R1-005)。順序は仕様ではないので集合で比べる - - shellcheck 以外の 6 つも固定し、RUN を組み替えたときに確認が黙って欠けないようにする。 - """ - commands = {c.strip() for c in _version_check_run().removeprefix("RUN ").split("&&")} - assert commands == { - "gh --version", "node --version", "npm --version", "aws --version", - "gcloud --version", "session-manager-plugin --version", "shellcheck --version", - } - - -@pytest.mark.parametrize( - ("failure", "exit_code"), - [ - ("download", 22), ("installer", 42), - # macOS の /bin/sh は実体のない絶対パスの実行で 1、Linux は 127。 - ("missing_binary", 1 if sys.platform == "darwin" else 127), - ], -) -def test_agy_failure_stops_user_tools_run(tmp_path, failure, exit_code): - """現状固定: agy の各失敗を伝播し、後続ツールの成果を作らない。""" - blocks = [b for b in _run_blocks() if "https://antigravity.google/cli/install.sh" in b] - assert len(blocks) == 1 - home = tmp_path / "home" - home.mkdir() - bin_dir = tmp_path / "bin" - bin_dir.mkdir() - artifacts = tmp_path / "artifacts" - artifacts.mkdir() - fixtures = tmp_path / "fixtures" - fixtures.mkdir() - for name in ("uv", "claude"): - (fixtures / name).write_text(f'printf installed > "$ARTIFACTS/{name}"\n') - (fixtures / "agy").write_text( - 'printf started > "$ARTIFACTS/agy-installer"\n' - + ("exit 42\n" if failure == "installer" else "exit 0\n") - ) - # PATH にホストのコマンドを含めず、シェルだけ本物で fixture を実行する。 - for name in ("sh", "bash"): - (bin_dir / name).symlink_to(f"/bin/{name}") - stub = tmp_path / "command-stub" - stub.write_text( - f"#!{sys.executable}\n" - + '''import os -import sys -from pathlib import Path - -name = Path(sys.argv[0]).name -args = sys.argv[1:] -artifacts = Path(os.environ["ARTIFACTS"]) -if name == "curl": - url = next(arg for arg in args if arg.startswith("https://")) - sources = { - "https://astral.sh/uv/install.sh": "uv", - "https://claude.ai/install.sh": "claude", - "https://antigravity.google/cli/install.sh": "agy", - } - if url in sources: - source = sources[url] - if source == "agy": - (artifacts / "agy-download").write_text("attempted") - if os.environ["FAILURE"] == "download": - sys.exit(22) - data = (Path(os.environ["FIXTURES"]) / source).read_text() - elif url.startswith("https://desktop-release.q.us-east-1.amazonaws.com/"): - (artifacts / "kiro-download").write_text("downloaded") - data = "kiro fixture" - else: - raise AssertionError(url) - if "-o" in args: - Path(args[args.index("-o") + 1]).write_text(data) - else: - sys.stdout.write(data) -elif name == "uname": - print("x86_64") -elif name == "unzip": - Path("kirocli").mkdir(exist_ok=True) - installer = Path("kirocli/install.sh") - installer.write_text("#!/bin/sh\\nexit 0\\n") - installer.chmod(0o755) -elif name == "npx": - (artifacts / "playwright-install").write_text("installed") -elif name in ("rm", "sudo"): - pass # ホストの削除・権限昇格は実行しない。 -else: - raise AssertionError(name) -''' - ) - stub.chmod(0o755) - for name in ("curl", "uname", "unzip", "npx", "rm", "sudo"): - (bin_dir / name).symlink_to(stub) - result = subprocess.run( - ["/bin/sh", "-c", blocks[0].removeprefix("RUN ")], - cwd=tmp_path, - env={ - "HOME": str(home), "PATH": str(bin_dir), - "ARTIFACTS": str(artifacts), "FIXTURES": str(fixtures), - "FAILURE": failure, - }, - capture_output=True, text=True, timeout=10, - ) - assert result.returncode == exit_code, result.stderr - assert (artifacts / "uv").read_text() == "installed" - assert (artifacts / "claude").read_text() == "installed" - assert (artifacts / "agy-download").exists() - assert (artifacts / "agy-installer").exists() == (failure != "download") - assert not (artifacts / "kiro-download").exists() - assert not (artifacts / "playwright-install").exists() diff --git a/tests/containers/test_base_image_npm_globals.py b/tests/containers/test_base_image_npm_globals.py deleted file mode 100644 index 4e924cdc..00000000 --- a/tests/containers/test_base_image_npm_globals.py +++ /dev/null @@ -1,75 +0,0 @@ -"""既定ユーザーによる npm globals 更新の現状固定。 - -実行前に現行のソースから専用イメージを用意する: - docker build -t devbase-base:npm-globals-test containers/base -ネットワーク、ホストのマウント、sudo、prefix/PATH の変更は使わない。 -""" - -import shutil -import subprocess -import uuid - -import pytest - -IMAGE = "devbase-base:npm-globals-test" - -PROBE = r""" -set -eu -printf 'uid=%s\n' "$(id -u)" -test "$(id -u)" -ne 0 -# 置換対象が既にインストールされていることを確認する。 -npm ls -g @openai/codex --depth=0 >/dev/null -fixture_dir=$(mktemp -d) -cd "$fixture_dir" -cat > package.json <<'JSON' -{"name":"@openai/codex","version":"0.0.0-characterization","bin":{"codex":"codex.js"}} -JSON -cat > codex.js <<'JS' -#!/usr/bin/env node -console.log("devbase-npm-global-characterization"); -JS -tarball=$(npm pack --offline --ignore-scripts --no-audit --no-fund --silent) -set +e -npm install -g --offline --ignore-scripts --no-audit --no-fund "$fixture_dir/$tarball" -install_status=$? -output=$(/bin/bash -c codex) -command_status=$? -printf 'install_status=%s\ncommand_status=%s\noutput=%s\n' \ - "$install_status" "$command_status" "$output" -""" - - -def test_default_user_can_replace_and_run_root_installed_npm_global(): - if shutil.which("docker") is None: - pytest.skip("docker が PATH に無い") - try: - subprocess.run(["docker", "info"], capture_output=True, timeout=30, check=True) - except (subprocess.SubprocessError, OSError) as error: - pytest.skip(f"docker daemon が利用できない: {error}") - inspected = subprocess.run( - ["docker", "image", "inspect", IMAGE], capture_output=True, text=True, timeout=30, - ) - if inspected.returncode != 0: - pytest.skip(f"{IMAGE} が無い。モジュール冒頭の docker build を実行する: {inspected.stderr}") - - name = f"devbase-npm-globals-{uuid.uuid4().hex}" - try: - result = subprocess.run( - ["docker", "run", "--rm", "--name", name, "--network=none", - "--entrypoint=/bin/bash", IMAGE, "-c", PROBE], - capture_output=True, text=True, timeout=180, - ) - finally: - # --rm に加え、タイムアウトでクライアントが終了してもコンテナを残さない。 - subprocess.run(["docker", "rm", "-f", name], capture_output=True, timeout=30) - - assert result.returncode == 0, result.stdout + result.stderr - observed = dict(line.split("=", 1) for line in result.stdout.splitlines() - if line.startswith(("uid=", "install_status=", "command_status=", "output="))) - assert int(observed.pop("uid")) != 0 - # 現行 Dockerfile からのイメージで実測: UID=1000、両終了コード=0、下記識別値。 - assert observed == { - "install_status": "0", - "command_status": "0", - "output": "devbase-npm-global-characterization", - }, result.stdout + result.stderr From 5990a30cc7b83dddda2d7a6cfd3fda5a4a8bf2fa Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 24 Sep 2026 08:43:09 +0900 Subject: [PATCH 7/8] =?UTF-8?q?test:=20shellcheck=20=E3=81=AE=201=20?= =?UTF-8?q?=E5=9B=9E=E7=9B=AE=E3=81=AE=E4=B8=80=E8=A6=A7=E3=81=AE=E7=AF=84?= =?UTF-8?q?=E5=9B=B2=E3=82=92=E6=9C=80=E5=88=9D=E3=81=AE=20;=20=E3=81=BE?= =?UTF-8?q?=E3=81=A7=E3=81=AB=E7=B5=9E=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _first_apt_install が 2 回目の apt-get install の直前までを返しており、 locale-gen やリポジトリの設定に語が残るだけでも通ってしまっていた。 Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/containers/test_base_dockerfile_shellcheck.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/containers/test_base_dockerfile_shellcheck.py b/tests/containers/test_base_dockerfile_shellcheck.py index 9dc5c525..ec06bfb2 100644 --- a/tests/containers/test_base_dockerfile_shellcheck.py +++ b/tests/containers/test_base_dockerfile_shellcheck.py @@ -56,10 +56,12 @@ def _first_apt_install(block: str) -> str: 1 つ目の RUN は apt-get install を 2 回呼ぶ。RUN の本文全体で探すと、2 回目の一覧 (後から足したリポジトリの docker-ce / gh / nodejs など) にあっても通ってしまう。 + 範囲は 1 回目の apt-get install から最初の ``;`` まで。2 回目の直前までにすると、 + 間にある locale-gen やリポジトリの設定の語でも通ってしまう。 """ calls = [m.start() for m in re.finditer(r"apt-get install", block)] assert len(calls) >= 2, "1 つ目の RUN に apt-get install が 2 回無い" - return block[calls[0]:calls[1]] + return block[calls[0]:block.index(";", calls[0])] def _version_check_run() -> str: From 1d4c3b4d251b453748bd4b7b1cb574c0078ee48b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 24 Sep 2026 08:54:22 +0900 Subject: [PATCH 8/8] =?UTF-8?q?docs(PLAN67):=20shellcheck=20=E3=81=AE?= =?UTF-8?q?=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98=E3=82=92=20docs/specificat?= =?UTF-8?q?ions=20=E3=81=B8=E7=A7=BB=E3=81=99=20(#249)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN67 の要求・設計・実装計画を issues/old/ へ移し、置き場所・入れ損ないの 止め方・版の扱い・伝播の規則・テスト観点を base-image-shellcheck.md として 確定仕様にした。利用者向けの小節から新しい仕様へリンクする。 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specifications/base-image-shellcheck.md | 129 ++++++++++++++++++ docs/user/container-operations.md | 3 + .../PLAN67_base-shellcheck-design.md | 0 .../{ => old}/PLAN67_base-shellcheck-impl.md | 0 issues/{ => old}/PLAN67_base-shellcheck.md | 0 5 files changed, 132 insertions(+) create mode 100644 docs/specifications/base-image-shellcheck.md rename issues/{ => old}/PLAN67_base-shellcheck-design.md (100%) rename issues/{ => old}/PLAN67_base-shellcheck-impl.md (100%) rename issues/{ => old}/PLAN67_base-shellcheck.md (100%) diff --git a/docs/specifications/base-image-shellcheck.md b/docs/specifications/base-image-shellcheck.md new file mode 100644 index 00000000..f9b5cb43 --- /dev/null +++ b/docs/specifications/base-image-shellcheck.md @@ -0,0 +1,129 @@ +# base イメージの Bash の静的検査(shellcheck) + +## 概要 + +base イメージは [ShellCheck](https://www.shellcheck.net/)(`shellcheck`)を同梱する。base と、 +base を継ぐ派生イメージのコンテナの中で、Bash スクリプトの静的検査をそのまま走らせられる。 +bash-language-server などの言語サーバは Bash の診断を `shellcheck` に任せており、無いとエラーも +警告も出さずに診断が空になる。base に置くことで、コンテナの中で動かす言語サーバも診断を返せる。 + +**shellcheck が入っていないイメージは建たない。** 入れ損ないはビルドの時点で止まる。 + +利用者向けの読み方は +[コンテナ操作ガイド: Bash の静的検査](../user/container-operations.md#bash-の静的検査base-以降) +にある。 + +## 対象範囲 + +- base イメージへの `shellcheck` の導入と、入れ損ないをビルドで止める仕組み +- base から派生するイメージ(`general` / `go` / `php` / `php85` / `bi-tools` / `latex` / + `trygroup`)への伝播の規則 +- `containers/lfm` と `containers/snapshot` は base を継がないため対象に含まない +- bash-language-server 自体は同梱しない +- CI の ShellCheck ジョブは runner の shellcheck を使い、この仕様の対象ではない + +## 構成要素 + +| 要素 | 置き場所 | 責務 | +| --- | --- | --- | +| 導入 | `containers/base/Dockerfile` の 1 つ目の `RUN` の 1 回目の `apt-get install` | `poppler-utils` などの行の後に、理由のコメントとともに `shellcheck` を置く | +| 入れ損ないの検出 | `containers/base/Dockerfile` の版の確認の `RUN` | `gh --version && ... && session-manager-plugin --version && shellcheck --version`。無ければ `shellcheck: command not found`(終了コード 127)でビルドが止まる | +| 形の検査 | `tests/containers/test_base_dockerfile_shellcheck.py` | Docker を起動せずに、上の 2 か所の形を固定する | + +型(クラス)は持たない。Dockerfile の命令だけで構成する。 + +## 仕様 + +### 置き場所 + +`shellcheck` は Ubuntu の標準のアーカイブ(`universe`)にあり、外部のリポジトリを要さない。 +そのため 1 つ目の `RUN` の**1 回目**の `apt-get install` の一覧に置く。2 回目の一覧は後から +足したリポジトリのパッケージ(`docker-ce` / `terraform` / `gh` / `nodejs` / `chromium-browser`) +のためにあり、標準のアーカイブのパッケージを混ぜない。 + +shellcheck のための `RUN` は立てない。立てると派生イメージ 7 つが積む層が 1 つ増え、 +`apt-get update` とクリーンアップ(`apt-get clean` / `rm -rf`)をもう 1 か所に持つことになる。 +Dockerfile の `apt-get update` は 2 回である。 + +### 入れ損ないを止める + +版の確認の `RUN` は `USER ${USERNAME}` より前にあり root で走る。`/usr/bin/shellcheck` は +`PATH` にあるため、パスを書かずに呼ぶ。 + +**止める役はテストではなくビルドが持つ。** `apt-get install` はパッケージが一覧から消えても +失敗しないため、一覧の編集で `shellcheck` が落ちたときに止まるのは版の確認の側だけである。 +テストは、版の確認の 1 語が消えないことを固定する。 + +### 版 + +版は固定せず、base を建てた時点で Ubuntu のアーカイブが配る版を入れる(2026-09 時点で +`0.11.0-2`、`shellcheck --version` は `version: 0.11.0`)。`gh` / `terraform` / `nodejs` と同じ +扱いである。`bao` のように `ARG` とチェックサムで固定しないのは、サーバの版と揃える制約が +無いためである。`shellcheck=<版>` と書かないのは、アーカイブが版を上げるとその版が消えて +ビルドが止まるためである。 + +## データ・設定 + +依存を含めて新しく入るパッケージは `shellcheck` と `libnuma1` の 2 つである(`libc6` / +`libffi8` / `libgmp10` は既に入っている)。置き場所は `/usr/bin/shellcheck`。 + +| 項目 | 値 | +| --- | --- | +| `Installed-Size`(`shellcheck`) | 24971 KB(arm64)/ 22867 KB(amd64、アーカイブの `Packages.gz` の値) | +| `/usr` の増分 | 約 25 MB(arm64 の実測) | + +環境変数・設定ファイルは持たない。 + +## 運用 + +- 変更は**イメージを建て直すまで反映されない**。`devbase build base --no-cache` で base を + 建て直し、使っている派生イメージ(いずれも `FROM devbase-base:latest`)も建て直し、稼働中の + コンテナは `devbase down` → `devbase up` で作り直す。`devbase up` だけでは反映されない +- **`devbase rebuild` はここでは使えない。** `devbase build --expires=7` のシノニム + (`lib/devbase/commands/container.py` の `cmd_rebuild`)で、期限内ならビルドを飛ばし、 + コンテナも作り直さない +- `containers/lfm` は `FROM nvidia/cuda:...` で base を継がず、base からは `/usr/local` / + `/usr/bin/gh` / `/usr/bin/node` / `/opt` などを選んで `COPY` するだけのため、 + `/usr/bin/shellcheck` は届かない。`containers/snapshot` は `FROM ubuntu:26.04` で base を + 継がない。どちらかで要るようになったときは、そのイメージの `apt-get install` か `COPY` の + 一覧へ足す +- 建てて確かめてあるのは arm64 である。amd64 はアーカイブに同じ版があり、依存も同じである + ことまで確かめている + +## テスト観点 + +`tests/containers/test_base_dockerfile_shellcheck.py`(Docker を要さない): + +- `shellcheck` が 1 つ目の `RUN` の 1 回目の `apt-get install` の一覧(最初の `;` まで)に + あること。2 回目の一覧に無いこと +- 2 つ目以降の `RUN` に `shellcheck` を入れる `apt-get install` が無く、`apt-get update` が + 2 回のままであること +- 版の確認の `RUN` を `&&` で分けた命令に `shellcheck --version` があること + +補助の関数(コメント行を除く本文・`RUN` ブロックへの分割・1 回目の一覧の切り出し)は +このファイルに持ち、`test_base_dockerfile_fonts.py` から import しない。テストのファイル +どうしを依存させない。 + +イメージの中の検査は Docker のテストにしない。入っていることはビルドが守る。 +`test_base_image_font_matching.py` の期待値へ足すと、この変更より前に建てた base を持つ全員の +`pytest tests/` が赤くなり、赤の意味が「壊れている」と「イメージが古い」で混ざる。 +「無ければ skip」の別のテストは「無い」を検査できない。 + +建てたイメージで手で確かめる観点: + +- `docker run --rm --entrypoint /bin/bash devbase-base:latest -c 'shellcheck --version'` が + 終了コード 0 で終わり、`version:` の行を出すこと。base を建て直した後に建てた + `devbase-general:latest` / `devbase-php:latest` でも同じであること +- `echo $foo` の 1 行を持つ Bash スクリプトへ `shellcheck` を走らせると、出力に `SC2086` を + 含み、終了コード 1 で終わること(言語サーバが使うのはこの診断である) +- 新しく入るのが `shellcheck` と `libnuma1` の 2 パッケージで、 + `dpkg-query -W -f='${Installed-Size}\n' shellcheck libnuma1` の合計が 30720 KB 以下であること。 + `docker images` の前後の差では測らない(`--no-cache` の建て直しは他の取得物の版も入れ替える) + +CI はイメージを建てるジョブを持たないため、イメージの中の観点は CI では確かめない。 + +## 関連リンク + +- [コンテナ操作ガイド: Bash の静的検査](../user/container-operations.md#bash-の静的検査base-以降) +- [base イメージの文字の描画と、文書を扱う道具](base-image-rendering.md) +- [ShellCheck](https://www.shellcheck.net/) diff --git a/docs/user/container-operations.md b/docs/user/container-operations.md index 0cb84f66..579d6a35 100644 --- a/docs/user/container-operations.md +++ b/docs/user/container-operations.md @@ -433,6 +433,9 @@ bash-language-server などの言語サーバは Bash の診断を `shellcheck` 版は固定しておらず、base を建てた時点の Ubuntu のアーカイブの版が入ります。 `containers/lfm` と `containers/snapshot` は base を継がないため入っていません。 +置き場所・入れ損ないの止め方・版の扱いの仕様は +[base イメージの Bash の静的検査(shellcheck)](../specifications/base-image-shellcheck.md) +にあります。 > **`devbase build base --no-cache` で base を建て直すと反映されます。** `devbase up` だけでは > 反映されません。派生イメージ(`general` / `php` など)を使っているプロジェクトは、その diff --git a/issues/PLAN67_base-shellcheck-design.md b/issues/old/PLAN67_base-shellcheck-design.md similarity index 100% rename from issues/PLAN67_base-shellcheck-design.md rename to issues/old/PLAN67_base-shellcheck-design.md diff --git a/issues/PLAN67_base-shellcheck-impl.md b/issues/old/PLAN67_base-shellcheck-impl.md similarity index 100% rename from issues/PLAN67_base-shellcheck-impl.md rename to issues/old/PLAN67_base-shellcheck-impl.md diff --git a/issues/PLAN67_base-shellcheck.md b/issues/old/PLAN67_base-shellcheck.md similarity index 100% rename from issues/PLAN67_base-shellcheck.md rename to issues/old/PLAN67_base-shellcheck.md