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/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 3b9e1c97..579d6a35 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,30 @@ 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 を継がないため入っていません。 +置き場所・入れ損ないの止め方・版の扱いの仕様は +[base イメージの Bash の静的検査(shellcheck)](../specifications/base-image-shellcheck.md) +にあります。 + +> **`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-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/old/PLAN67_base-shellcheck-impl.md b/issues/old/PLAN67_base-shellcheck-impl.md new file mode 100644 index 00000000..e0a78cad --- /dev/null +++ b/issues/old/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/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 diff --git a/tests/containers/test_base_dockerfile_shellcheck.py b/tests/containers/test_base_dockerfile_shellcheck.py new file mode 100644 index 00000000..ec06bfb2 --- /dev/null +++ b/tests/containers/test_base_dockerfile_shellcheck.py @@ -0,0 +1,96 @@ +"""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 など) にあっても通ってしまう。 + 範囲は 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]:block.index(";", calls[0])] + + +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[^;&]*(?