diff --git a/CHANGELOG.md b/CHANGELOG.md index 65849509..d07e94c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ ## [Unreleased] ### Added +- **VS Code Server をコンテナ再作成をまたいで保つ**ようにしました (PLAN36)。 + `~/.vscode-server` はこれまでコンテナ層 (揮発) にあったため、`devbase up` で + コンテナを作り直すたびに VS Code の attach で **215MB の再ダウンロード** + (約 55 秒) と拡張機能の再インストールが走っていました。コンテナ 1 つにつき 1 本の + named volume `devbase_vscode__` を `~/.vscode-server` へ + マウントし、本体・拡張機能・接続トークンをプロジェクトの寿命で保ちます。 + + 共有せずコンテナ単位にするのは、VS Code Server が「1 マシン 1 セット」の状態 + (`data/Machine/.connection-token-` など) を持つためです。名前に + プロジェクト名とインスタンス番号を含めるので、`scale > 1` の同時 attach でも + 別プロジェクトの同時起動でも状態が混ざりません。 + + 反映には**ベースイメージの再ビルド**が要ります (`devbase container build --no-cache`)。 + 空のボリュームは root 所有で作られるため、entrypoint が所有者を初期化します。 + VS Code 本体の更新 (`commit` ハッシュの変更) 時は従来どおり取得が走ります。 + ボリュームは `devbase down` でも残るので、使わなくなったプロジェクトの分は + [トラブルシューティング](docs/user/troubleshooting.md#vs-code-server-のボリュームが溜まっている) + の手順で削除してください。 + - **Antigravity CLI (`agy`) をベースイメージへ追加**しました。Google の AI コーディング エージェントを、既存の `claude` / `gemini` / `codex` / `kiro` と同じくコンテナ内から すぐ使えます。エイリアス `agy` は確認プロンプトを省く diff --git a/containers/base/entrypoint.sh b/containers/base/entrypoint.sh index c112037b..181f4223 100644 --- a/containers/base/entrypoint.sh +++ b/containers/base/entrypoint.sh @@ -411,6 +411,20 @@ devbase_setup_ai_settings() { done } +# =================================================================== +# PLAN36: VS Code Server の永続ディレクトリ +# =================================================================== +# ~/.vscode-server にはコンテナ 1 つにつき 1 本の named volume が割り当てられる +# (devbase_vscode__)。空のボリュームは root 所有で作られるため、 +# そのままでは開発ユーザーが VS Code Server をインストールできない。 +# +# マウントが無い構成でもディレクトリを作るだけで済み、VS Code の動作は変わらない。 +devbase_setup_vscode_server_dir() { + local home_root="$1" owner="${2:-${USERNAME:-ubuntu}}" + + devbase_ensure_persistent_root "${home_root}/.vscode-server" "$owner" +} + # =================================================================== # PLAN39: GCP の認証モードと gcloud / gws の設定ディレクトリ # =================================================================== @@ -528,6 +542,9 @@ USERNAME="${USERNAME:-ubuntu}" devbase_setup_cloud_config_dirs "$USERNAME" devbase_setup_gcp_credentials "/home/${USERNAME}" +# 1.5. VS Code Server の永続ディレクトリ (PLAN36) +devbase_setup_vscode_server_dir "/home/${USERNAME}" "$USERNAME" + # 2. Setup Git configuration if [ -n "$GIT_USER_NAME" ]; then git config --global user.name "$GIT_USER_NAME" 2>/dev/null || true diff --git a/docs/user/container-operations.md b/docs/user/container-operations.md index 3f968d4b..c8624eb1 100644 --- a/docs/user/container-operations.md +++ b/docs/user/container-operations.md @@ -139,13 +139,14 @@ graph LR ## ボリューム構造 -devbase のコンテナは 3 種類のボリュームを使用します。 +devbase のコンテナは 4 種類のボリュームを使用します。 | ボリューム名 | マウント先 | 共有範囲 | 用途 | |-------------|-----------|---------|------| | `devbase_home_ubuntu` | `/persistent/ai` | 全コンテナで共有 | 契約やテナントに紐づかない共通資産(`~/.claude/plugins` / `skills` / `commands` / `CLAUDE.md` / `settings.json`、`.codex` / `.serena` / `.kiro`、SSH 鍵、共有ファイル置き場 `share`)| | `devbase_home_{group}` | `/persistent/group` | 同じアカウントグループのコンテナで共有 | 企業テナントに紐づくもの(Claude Code の認証と会話ログ、`.gemini`、gcloud / gws の設定ディレクトリ)| | `devbase_work_{index}` | `/work` | 同じ index のコンテナで共有(プロジェクト間も共有) | プロジェクトのソースコード、作業ファイル | +| `devbase_vscode_{project}_{index}` | `/home/ubuntu/.vscode-server` | 共有しない(コンテナ 1 つに 1 本)| VS Code Server 本体・拡張機能・接続トークン | > **Note:** `devbase_home_ubuntu` は **`/persistent/ai`** にマウントされます(`/home/ubuntu` への直接マウントは廃止)。`/home/ubuntu` 直下はコンテナ層(揮発)で、永続化されるのは entrypoint が `/persistent/ai` / `/persistent/group` 配下へ symlink する設定ファイルのみです。シェル履歴など symlink 対象外のファイルは再生成で失われます。 @@ -184,6 +185,26 @@ DEVBASE_ACCOUNT_GROUP=kkg Google 認証の具体的な手順は [Google 認証ガイド](google-auth.md) を参照してください。 +### VS Code Server の永続化 + +VS Code を attach すると、コンテナ内に VS Code Server(本体 `bin/`・拡張機能・ +接続トークン)が入ります。ここは `~/.vscode-server` で、以前はコンテナ層(揮発)にあったため +`devbase up` でコンテナを作り直すたびに **215MB の再ダウンロード**(約 55 秒)が走っていました。 + +現在は `devbase_vscode_{project}_{index}` を `~/.vscode-server` にマウントするため、 +コンテナを作り直しても本体と拡張機能が残ります。 + +このボリュームだけは**共有しません**。VS Code Server は「1 マシン 1 セット」の状態 +(`data/Machine/.connection-token-` など)を持ち、複数のコンテナが同時に書くと +接続トークンを奪い合うためです。名前にプロジェクト名とインスタンス番号の両方を含めることで、 +`scale > 1` の同時 attach でも、別プロジェクトの同時起動でも状態が混ざりません。 + +- 初回 attach と VS Code 本体のバージョン更新時(`commit` ハッシュが変わるとき)は + ダウンロードが走ります。減るのは**コンテナ再作成のたびの再取得**です +- プロジェクトが `compose.yml` で `~/.vscode-server` を自分でマウントしている場合、 + devbase は上書きしません +- スナップショット(`devbase snapshot`)の対象外です。失っても attach し直せば再取得されます + ### ボリュームの永続性 - ボリュームは `devbase down` でもコンテナが削除されても保持されます @@ -202,6 +223,25 @@ docker volume ls | grep devbase docker volume inspect devbase_home_ubuntu ``` +#### 使わなくなった VS Code Server ボリュームを消す + +`devbase_vscode_*` はプロジェクトを削除しても自動では消えません。attach したコンテナ 1 つ +あたり **約 1.6GB** を使うため、使わなくなったプロジェクトの分は手で削除します。 + +```bash +# 一覧(サイズ付き) +docker volume ls --filter name=devbase_vscode_ --format '{{.Name}}' +docker system df -v | grep devbase_vscode_ + +# 使用中のコンテナが無いことを確認してから削除する +docker ps -a --filter volume=devbase_vscode__1 +docker volume rm devbase_vscode__1 +``` + +削除しても失われるのは VS Code Server のキャッシュだけです。次の attach で再取得され、 +設定(`~/.claude` などの AI 設定)には影響しません。稼働中のコンテナが掴んでいるボリュームは +`docker volume rm` が拒否するので、先に `devbase down` してください。 + > **Warning:** `devbase_home_ubuntu` ボリューム(`/persistent/ai`、および symlink 経由でアクセスする `~/.claude/plugins` / `~/share` 等)は全プロジェクトで共有されます。ここにプロジェクト固有のファイルを置くと、他のプロジェクトにも影響します。プロジェクト固有のファイルは `/work` に配置してください。 ## AI 設定の永続化 diff --git a/docs/user/troubleshooting.md b/docs/user/troubleshooting.md index b5d1a6f8..85c48837 100644 --- a/docs/user/troubleshooting.md +++ b/docs/user/troubleshooting.md @@ -428,6 +428,63 @@ docker volume ls | grep docker volume rm ``` +### VS Code Server のボリュームが溜まっている + +**症状:** + +`devbase_vscode_*` のボリュームが増え、`docker system df` のボリューム使用量が大きい。 +使わなくなったプロジェクトの分も残っている。 + +**原因:** + +`~/.vscode-server`(VS Code Server 本体・拡張機能)はコンテナ 1 つにつき 1 本の +`devbase_vscode_{project}_{index}` へ永続化されます。attach したコンテナ 1 つあたり +**約 1.6GB** で、プロジェクトを削除してもボリュームは自動では消えません。 + +**解決策:** + +```bash +# VS Code Server ボリュームの一覧 +docker volume ls --filter name=devbase_vscode_ --format '{{.Name}}' + +# サイズを確認 +docker system df -v | grep devbase_vscode_ + +# 使用中のコンテナが無いことを確認してから削除する +docker ps -a --filter volume=devbase_vscode__1 +docker volume rm devbase_vscode__1 +``` + +失われるのは VS Code Server のキャッシュだけで、次に attach すると再取得されます +(その 1 回だけダウンロードが走ります)。AI 設定や作業ファイルには影響しません。 + +> **Note:** `docker volume prune` は稼働中でないコンテナが参照するボリュームも消します。 +> 対象を絞りたい場合は上のように名前を指定して削除してください。 + +### VS Code の attach で毎回ダウンロードが走る + +**症状:** + +`devbase up` のあとに VS Code を attach すると、毎回 `Installing VS Code Server` が出て +215MB のダウンロードが走る。 + +**原因と解決策:** + +1. **entrypoint が古い** — VS Code Server の永続化にはベースイメージの再ビルドが要ります。 + `devbase container build --no-cache` を実行してから `devbase up` してください。 +2. **VS Code 本体が更新された** — `bin/` は VS Code クライアントのビルドごとに + 分かれます。クライアントを更新した直後の 1 回はダウンロードが走ります(仕様)。 +3. **ボリュームがマウントされていない** — 次で確認できます。 + +```bash +docker inspect -dev-1 \ + --format '{{range .Mounts}}{{.Name}} -> {{.Destination}}{{"\n"}}{{end}}' \ + | grep vscode +``` + +`devbase_vscode__1 -> /home/ubuntu/.vscode-server` が出ない場合は、 +`.docker-compose.scale.yml` が古いままです。`devbase up` で再生成されます。 + ## 問題が解決しない場合 上記の方法で解決しない場合は、以下の情報を添えて [GitHub Issues](https://github.com/devbasex/devbase/issues) に報告してください。 diff --git a/lib/devbase/volume/compose.py b/lib/devbase/volume/compose.py index 3c89b04f..b129a1a1 100644 --- a/lib/devbase/volume/compose.py +++ b/lib/devbase/volume/compose.py @@ -15,8 +15,10 @@ from .manager import ( get_ai_volume_for_index, get_group_volume, + get_vscode_volume_for, get_work_volume_for_index, resolve_account_group, + resolve_project_name, ) logger = get_logger(__name__) @@ -24,6 +26,10 @@ # 旧 /home/ubuntu マウントは非推奨のため scale 生成時に除去する _DEPRECATED_TARGET = '/home/ubuntu' +# VS Code Server の状態ディレクトリ (PLAN36)。コンテナの書き込みレイヤに置くと +# devbase up のたびに 215MB の再ダウンロードが走るため、named volume を宛てる。 +VSCODE_SERVER_TARGET = '/home/ubuntu/.vscode-server' + def get_dev_service_name() -> str: """Get development service name from environment variable or default to 'dev'""" @@ -70,8 +76,17 @@ def _volume_target(vol: Any) -> Optional[str]: return None +def _declares_target(service: Mapping[str, Any], target: str) -> bool: + """サービスが ``target`` へのマウントを自分で書いているか。""" + return any( + _volume_target(vol) == target + for vol in (service.get('volumes') or []) + ) + + def _replace_volumes_for_instance( volumes: list, ai_volume: str, work_volume: str, group_volume: str, + vscode_volume: Optional[str] = None, ) -> list: """Replace volume mounts in a service's volumes list for a specific instance. @@ -79,6 +94,10 @@ def _replace_volumes_for_instance( /persistent/ai is mapped to ai_volume (shared by every container). /persistent/group is mapped to group_volume (shared within the account group). /work is mapped to work_volume. + + ``vscode_volume`` は ``~/.vscode-server`` へ足すボリューム (PLAN36)。 + 上の 3 つと違って**差し替えではなく追加**で、プロジェクトが同じマウント先を + 書いている場合は呼び出し側が ``None`` を渡して手を出さない。 """ replacements = { '/persistent/ai': ai_volume, @@ -114,10 +133,15 @@ def _replace_volumes_for_instance( for target, source in replacements.items() if target not in replaced_targets ) + if vscode_volume: + new_volumes.append(f"{vscode_volume}:{VSCODE_SERVER_TARGET}") return new_volumes -def _build_volumes_section(config: dict, scale: int, group_volume: str) -> dict: +def _build_volumes_section( + config: dict, scale: int, group_volume: str, + vscode_volumes: Sequence[str] = (), +) -> dict: """Build the volumes section for a scaled compose file.""" # Copy original volumes (mysql, valkey, etc.) from config volumes: Dict[str, Any] = { @@ -136,6 +160,11 @@ def _build_volumes_section(config: dict, scale: int, group_volume: str) -> dict: for i in range(1, scale + 1): volumes[get_work_volume_for_index(i)] = {'external': True} + # Add VS Code Server volumes (PLAN36). 実際にマウントする分だけ宣言する。 + # external は実体の存在を要求するため、使わない名前を書くと起動が落ちる。 + for name in vscode_volumes: + volumes[name] = {'external': True} + return volumes @@ -355,6 +384,7 @@ def _build_dev_instance( group_volume: str, secret_env_names: Sequence[str] = (), dev_environment: Optional[Mapping[str, str]] = None, + vscode_volume: Optional[str] = None, ) -> dict: """Build the service definition for one scaled dev instance (dev-).""" service = copy.deepcopy(dev_service) @@ -374,6 +404,7 @@ def _build_dev_instance( work_volume = get_work_volume_for_index(index) service['volumes'] = _replace_volumes_for_instance( service.get('volumes', []), ai_volume, work_volume, group_volume, + vscode_volume, ) return service @@ -385,6 +416,7 @@ def _build_scaled_services( secret_names: Optional[_SecretNames] = None, secret_services: Optional[Mapping[str, Set[str]]] = None, dev_environment: Optional[Mapping[str, str]] = None, + vscode_volumes: Sequence[str] = (), ) -> dict: """Build the services section: non-dev services + dev-1..dev-N instances. @@ -394,6 +426,9 @@ def _build_scaled_services( scaled_services = {} secret_names = secret_names if secret_names is not None else _SecretNames() receivers = dict(secret_services or {}) + # インスタンス番号 → VS Code Server ボリューム。マウントしない構成 (空列) では + # 引けず None になる。 + vscode_by_index = dict(enumerate(vscode_volumes, start=1)) # Copy non-dev services (mysql, valkey, etc.) — rewriting any # `depends_on: ` reference to the scaled instances (dev-1..N) so @@ -428,6 +463,7 @@ def _build_scaled_services( dev_service, dev_service_name, i, group_volume, secret_names.for_dev, dev_environment=dev_environment, + vscode_volume=vscode_by_index.get(i), ) return scaled_services @@ -602,11 +638,24 @@ def generate_scaled_compose( secret_env_names, global_env_names, project_env_names, dev_excluded=gcp_auth.key_only_env_names(auth_mode)) + # VS Code Server は再作成をまたいで保つためコンテナ 1 つに 1 本の named + # volume を宛てる (PLAN36)。プロジェクトが自分で ~/.vscode-server を + # マウントしている場合は、その指定を奪わないよう devbase 側は何もしない。 + if _declares_target(dev_service, VSCODE_SERVER_TARGET): + vscode_volumes: List[str] = [] + else: + project_name = resolve_project_name() + vscode_volumes = [ + get_vscode_volume_for(project_name, i) + for i in range(1, scale + 1) + ] + scaled_services = _build_scaled_services( services, dev_service, dev_service_name, scale, group_volume, secret_names=secret_names, secret_services=secret_services, dev_environment=dev_environment, + vscode_volumes=vscode_volumes, ) # 列挙を絞るだけでは、元の compose.yml が environment に**直書き**している @@ -624,7 +673,8 @@ def generate_scaled_compose( scaled_config = { 'services': scaled_services, - 'volumes': _build_volumes_section(config, scale, group_volume), + 'volumes': _build_volumes_section( + config, scale, group_volume, vscode_volumes), 'networks': _build_networks_section(config), } diff --git a/lib/devbase/volume/manager.py b/lib/devbase/volume/manager.py index f24d2932..bed6e3e1 100644 --- a/lib/devbase/volume/manager.py +++ b/lib/devbase/volume/manager.py @@ -8,6 +8,7 @@ from devbase.env import keys from devbase.errors import DevbaseError, DockerError from devbase.log import get_logger +from devbase.utils.config import get_project_name logger = get_logger("devbase.volume.manager") @@ -29,6 +30,18 @@ # 共通ボリューム devbase_home_ubuntu と同じ名前になる _RESERVED_ACCOUNT_GROUPS = ("ubuntu",) +# --- VS Code Server (PLAN36) ------------------------------------------------- +# ~/.vscode-server はコンテナの書き込みレイヤ上にあり、devbase up の down → up で +# 消える。本体 (bin/) だけで 644MB あり、attach のたびに再取得が走る。 +# コンテナ 1 つにつき 1 本の named volume を割り当てて再作成をまたいで保つ。 +# +# 共有せずコンテナ単位にするのは、VS Code Server が「1 マシン 1 セット」の状態 +# (data/Machine/.connection-token-・各種 marker・ログ) を持つため。 +# 複数コンテナが同時に書くと接続トークンを奪い合う。 +VSCODE_VOLUME_PREFIX = "devbase_vscode_" +# プロジェクト名のうち Docker のボリューム名に使えない文字 +_VOLUME_UNSAFE_RE = re.compile(r"[^a-zA-Z0-9._-]") + def resolve_account_group(group: Optional[str] = None) -> str: """アカウントグループ名を解決して検証する。 @@ -84,6 +97,44 @@ def get_group_volume(group: Optional[str] = None) -> str: return f"{SHARED_VOLUME_PREFIX}{resolve_account_group(group)}" +def resolve_project_name(project_name: Optional[str] = None) -> str: + """VS Code Server ボリュームに使うプロジェクト名を解決する。 + + 省略時は :func:`devbase.utils.config.get_project_name` ( + ``COMPOSE_PROJECT_NAME`` → カレントディレクトリ名) に委ねる。``devbase up`` + は同じ関数でプロジェクト名を決めてからボリュームを作る (``commands/container.py``) + ため、ボリュームを作る側と生成 compose の双方がここを通れば必ず同じ名前になる。 + 解決経路が 2 つあると、作った名前とマウントする名前がずれる。 + """ + name = (project_name or get_project_name() or "").strip() + return name or DEFAULT_ACCOUNT_GROUP + + +def normalize_volume_component(name: str) -> str: + """ボリューム名の一部として使えるようにプロジェクト名を正規化する。 + + ``group/project`` のようにディレクトリ名由来の ``/`` が混じると + ``docker volume create`` が弾く。使えない文字は ``_`` へ置き換える。 + + 置き換えの結果 ``a/b`` と ``a_b`` は同じ名前になるが、プロジェクト名は + Compose のプロジェクト名としても使われる (``COMPOSE_PROJECT_NAME``) ため、 + 実際にこの衝突が起きる名前は Docker 側で先に弾かれる。 + """ + normalized = _VOLUME_UNSAFE_RE.sub("_", name) + return normalized or DEFAULT_ACCOUNT_GROUP + + +def get_vscode_volume_for(project_name: Optional[str], index: int) -> str: + """VS Code Server ボリューム名 (``devbase_vscode__``) を返す。 + + Args: + project_name: プロジェクト名 (省略時は ``COMPOSE_PROJECT_NAME``) + index: インスタンス番号 (1 始まり) + """ + project = normalize_volume_component(resolve_project_name(project_name)) + return f"{VSCODE_VOLUME_PREFIX}{project}_{index}" + + class VolumeManager: """Manages Docker volumes for devbase projects""" @@ -92,7 +143,8 @@ def __init__(self, project_name: str = None): Initialize VolumeManager Args: - project_name: Project name (unused, kept for backward compatibility) + project_name: Project name. VS Code Server ボリュームの名前にだけ + 使う (PLAN36)。省略時は ``COMPOSE_PROJECT_NAME`` から解決する """ self.project_name = project_name @@ -171,6 +223,7 @@ def ensure_volumes(self, scale: int, group: Optional[str] = None) -> None: - devbase_home_ubuntu: Shared home directory for all containers - devbase_home_{group}: Per-account-group directory (PLAN39) - devbase_work_{i}: Project work directory per instance + - devbase_vscode_{project}_{i}: VS Code Server state per instance (PLAN36) Args: scale: Number of container instances @@ -211,6 +264,16 @@ def ensure_volumes(self, scale: int, group: Optional[str] = None) -> None: if not self._create_volume(work_volume): raise DockerError(f"Failed to create volume {work_volume}") + # Ensure VS Code Server volume (PLAN36) + vscode_volume = get_vscode_volume_for(self.project_name, i) + if self._volume_exists(vscode_volume): + logger.info(" %s (VS Code Server, exists)", vscode_volume) + else: + logger.info(" Creating %s (VS Code Server)...", vscode_volume) + if not self._create_volume(vscode_volume): + raise DockerError( + f"Failed to create volume {vscode_volume}") + def ensure_volumes(scale: int, project_name: str = None, group: Optional[str] = None) -> None: @@ -219,14 +282,15 @@ def ensure_volumes(scale: int, project_name: str = None, All projects share the same home volume (devbase_home_ubuntu) and the volume of their account group (devbase_home_); work volumes are - per container index. + per container index. VS Code Server volumes are per project and index + (PLAN36). Args: scale: Number of container instances - project_name: Unused, kept for backward compatibility + project_name: Project name (default: resolved from COMPOSE_PROJECT_NAME) group: Account group name (default: resolved from environment) """ - manager = VolumeManager() + manager = VolumeManager(project_name) manager.ensure_volumes(scale, group) diff --git a/tests/containers/test_entrypoint_vscode_server.py b/tests/containers/test_entrypoint_vscode_server.py new file mode 100644 index 00000000..cef2e40f --- /dev/null +++ b/tests/containers/test_entrypoint_vscode_server.py @@ -0,0 +1,92 @@ +"""VS Code Server ディレクトリの所有者初期化 (PLAN36 Task 3) + +``~/.vscode-server`` には named volume がマウントされる (PLAN36 Task 2)。空の +named volume は **root 所有**で作られるため、開発ユーザー (uid 1000) のままでは +VS Code Server をインストールできず ``Permission denied`` になる (前提 6 / AC5)。 + +``containers/base/entrypoint.sh`` を ``DEVBASE_ENTRYPOINT_LIB_ONLY=1`` で source し、 +一時ディレクトリを ``$HOME`` に見立てて関数を直接呼ぶ。Docker には依存しない。 +root 所有からの ``chown`` そのものは実機の検証手順 (AC5) で確かめる。 +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +ENTRYPOINT = Path(__file__).resolve().parents[2] / "containers" / "base" / "entrypoint.sh" + + +def run(script: str, cwd: Path) -> subprocess.CompletedProcess: + base = {k: v for k, v in os.environ.items() + if not k.startswith(("DEVBASE_", "GIT_"))} + full = f'set -e\nDEVBASE_ENTRYPOINT_LIB_ONLY=1 . "{ENTRYPOINT}"\n{script}\n' + return subprocess.run( + ["bash", "-c", full], cwd=cwd, env=base, capture_output=True, text=True) + + +@pytest.fixture +def home(tmp_path: Path) -> Path: + path = tmp_path / "home" + path.mkdir() + return path + + +def setup(home: Path) -> subprocess.CompletedProcess: + result = run(f'devbase_setup_vscode_server_dir "{home}" "$(id -un)"', home) + assert result.returncode == 0, result.stderr or result.stdout + return result + + +def test_directory_is_prepared(home: Path): + """マウント先が使える状態になる (AC5)。""" + setup(home) + + assert (home / ".vscode-server").is_dir() + + +def test_existing_contents_are_kept(home: Path): + """2 回目以降は何も壊さない。再ダウンロードを避ける前提そのもの (AC1)。""" + server = home / ".vscode-server" + (server / "bin" / "abc123").mkdir(parents=True) + (server / "bin" / "abc123" / "marker").write_text("kept") + + setup(home) + + assert (server / "bin" / "abc123" / "marker").read_text() == "kept" + + +def test_is_idempotent(home: Path): + """繰り返し呼んでも失敗しない (entrypoint は起動のたびに走る)。""" + setup(home) + setup(home) + + assert (home / ".vscode-server").is_dir() + + +def test_writable_directory_does_not_invoke_sudo(home: Path): + """既に書けるなら sudo を呼ばない。sudo の無い環境でも起動を落とさない。""" + (home / ".vscode-server").mkdir() + + # PATH の先頭へ「呼ばれたら失敗する sudo」を置いて、呼び出しの有無を見る + fake_bin = home.parent / "bin" + fake_bin.mkdir() + sudo = fake_bin / "sudo" + sudo.write_text("#!/bin/sh\necho 'sudo was called' >&2\nexit 1\n") + sudo.chmod(0o755) + + result = subprocess.run( + ["bash", "-c", + f'set -e\nDEVBASE_ENTRYPOINT_LIB_ONLY=1 . "{ENTRYPOINT}"\n' + f'devbase_setup_vscode_server_dir "{home}" "$(id -un)"\n'], + cwd=home, + env={**{k: v for k, v in os.environ.items() + if not k.startswith(("DEVBASE_", "GIT_"))}, + "PATH": f"{fake_bin}:{os.environ.get('PATH', '')}"}, + capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert "sudo was called" not in result.stderr diff --git a/tests/volume/test_compose_vscode.py b/tests/volume/test_compose_vscode.py new file mode 100644 index 00000000..5a00734f --- /dev/null +++ b/tests/volume/test_compose_vscode.py @@ -0,0 +1,163 @@ +"""VS Code Server ボリュームのマウントと宣言 (PLAN36 Task 2) + +生成 compose に対して次を固定する。 + +1. dev インスタンスごとに ``/home/ubuntu/.vscode-server`` が + ``devbase_vscode__`` としてマウントされる (AC1 / AC2) +2. インスタンスごとに別のボリュームになる (AC3) +3. そのボリュームが ``volumes:`` セクションへ ``external: true`` で宣言される +4. プロジェクトが同じマウント先を書いている場合は上書きしない +""" + +from __future__ import annotations + +import pytest +import yaml + +from devbase.volume import compose + +VSCODE_TARGET = "/home/ubuntu/.vscode-server" + + +@pytest.fixture +def in_tmp_cwd(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("DEV_SERVICE_NAME", raising=False) + monkeypatch.delenv("DEVBASE_ACCOUNT_GROUP", raising=False) + monkeypatch.setenv("COMPOSE_PROJECT_NAME", "carmo-ai") + return tmp_path + + +def _write_compose(tmp_path, services: dict, volumes: dict | None = None) -> None: + document = {"services": services} + if volumes is not None: + document["volumes"] = volumes + (tmp_path / "compose.yml").write_text( + yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + + +def _load_scaled(tmp_path) -> dict: + return yaml.safe_load((tmp_path / ".docker-compose.scale.yml").read_text()) + + +def _mount_source(service: dict, target: str) -> str | None: + for vol in service.get("volumes", []): + if isinstance(vol, str): + parts = vol.split(":") + if len(parts) >= 2 and parts[1] == target: + return parts[0] + elif isinstance(vol, dict) and vol.get("target") == target: + return vol.get("source") + return None + + +# --------------------------------------------------------------------------- +# マウント (AC1 / AC2 / AC3) +# --------------------------------------------------------------------------- + +def test_vscode_mount_is_added_when_absent(in_tmp_cwd): + """プロジェクト compose が書いていなくても自動で足される。""" + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + compose.generate_scaled_compose(scale=1) + + dev = _load_scaled(in_tmp_cwd)["services"]["dev-1"] + assert _mount_source(dev, VSCODE_TARGET) == "devbase_vscode_carmo-ai_1" + + +def test_each_instance_mounts_its_own_volume(in_tmp_cwd): + """scale > 1 でインスタンスごとに別のボリュームを掴む (AC3)。""" + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + compose.generate_scaled_compose(scale=2) + + services = _load_scaled(in_tmp_cwd)["services"] + assert _mount_source(services["dev-1"], VSCODE_TARGET) == \ + "devbase_vscode_carmo-ai_1" + assert _mount_source(services["dev-2"], VSCODE_TARGET) == \ + "devbase_vscode_carmo-ai_2" + + +def test_mount_follows_project_name(in_tmp_cwd, monkeypatch): + """別プロジェクトでは別のボリュームになる (AC4)。""" + monkeypatch.setenv("COMPOSE_PROJECT_NAME", "bi-tools") + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + compose.generate_scaled_compose(scale=1) + + dev = _load_scaled(in_tmp_cwd)["services"]["dev-1"] + assert _mount_source(dev, VSCODE_TARGET) == "devbase_vscode_bi-tools_1" + + +def test_existing_mounts_are_kept(in_tmp_cwd): + """既存のマウント (/work 等) を壊さない。""" + _write_compose(in_tmp_cwd, { + "dev": {"image": "dev:latest", "volumes": ["./src:/src"]}, + }) + + compose.generate_scaled_compose(scale=1) + + dev = _load_scaled(in_tmp_cwd)["services"]["dev-1"] + assert "./src:/src" in dev["volumes"] + assert _mount_source(dev, "/work") == "devbase_work_1" + + +def test_non_dev_services_do_not_get_the_mount(in_tmp_cwd): + """VS Code が入るのは dev だけ。db 等へは足さない。""" + _write_compose(in_tmp_cwd, { + "dev": {"image": "dev:latest"}, + "db": {"image": "mysql:8"}, + }) + + compose.generate_scaled_compose(scale=1) + + db = _load_scaled(in_tmp_cwd)["services"]["db"] + assert _mount_source(db, VSCODE_TARGET) is None + + +# --------------------------------------------------------------------------- +# 宣言 (external) +# --------------------------------------------------------------------------- + +def test_vscode_volumes_are_declared_external(in_tmp_cwd): + """devbase が作るボリュームなので external: true で宣言する。""" + _write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + compose.generate_scaled_compose(scale=2) + + volumes = _load_scaled(in_tmp_cwd)["volumes"] + assert volumes["devbase_vscode_carmo-ai_1"] == {"external": True} + assert volumes["devbase_vscode_carmo-ai_2"] == {"external": True} + + +def test_project_volumes_are_kept(in_tmp_cwd): + """プロジェクトが宣言したボリュームは残る。""" + _write_compose( + in_tmp_cwd, {"dev": {"image": "dev:latest"}}, volumes={"mysql": None}) + + compose.generate_scaled_compose(scale=1) + + assert "mysql" in _load_scaled(in_tmp_cwd)["volumes"] + + +# --------------------------------------------------------------------------- +# プロジェクトの指定を尊重する +# --------------------------------------------------------------------------- + +def test_declared_vscode_mount_is_not_overridden(in_tmp_cwd): + """プロジェクトが同じマウント先を書いていたらそのまま残す。 + + ホストのディレクトリを bind したい構成を devbase が奪わないため。 + """ + _write_compose(in_tmp_cwd, { + "dev": {"image": "dev:latest", + "volumes": [f"./vscode-server:{VSCODE_TARGET}"]}, + }) + + compose.generate_scaled_compose(scale=1) + + scaled = _load_scaled(in_tmp_cwd) + dev = scaled["services"]["dev-1"] + assert _mount_source(dev, VSCODE_TARGET) == "./vscode-server" + # 使わないボリュームを宣言しない (external は実体を要求する) + assert "devbase_vscode_carmo-ai_1" not in scaled["volumes"] diff --git a/tests/volume/test_manager_vscode.py b/tests/volume/test_manager_vscode.py new file mode 100644 index 00000000..9bd39389 --- /dev/null +++ b/tests/volume/test_manager_vscode.py @@ -0,0 +1,135 @@ +"""VS Code Server ボリュームの名前解決と作成 (PLAN36 Task 1) + +``~/.vscode-server`` はコンテナの書き込みレイヤ上にあり、``devbase up`` の +``down`` → ``up`` で消える。再作成をまたいで保つため、**コンテナ 1 つにつき 1 本**の +named volume ``devbase_vscode__`` を割り当てる。 + +VS Code Server は 1 マシン 1 セットの状態 (接続トークン・marker・ログ) を持つため、 +名前にプロジェクト名とインスタンス番号の両方を含めて、scale > 1 の同時 attach +(AC3) と別プロジェクトとの同時起動 (AC4) で状態を共有させない。 +""" + +from __future__ import annotations + +import pytest + +from devbase.volume import manager + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + """外部環境の COMPOSE_PROJECT_NAME に左右されないよう既定で未設定にする。""" + monkeypatch.delenv("COMPOSE_PROJECT_NAME", raising=False) + monkeypatch.delenv("DEVBASE_ACCOUNT_GROUP", raising=False) + + +# --------------------------------------------------------------------------- +# 名前の組み立て (AC3 / AC4) +# --------------------------------------------------------------------------- + +def test_volume_name_contains_project_and_index(): + """名前は devbase_vscode__。""" + assert manager.get_vscode_volume_for("carmo-ai", 1) == \ + "devbase_vscode_carmo-ai_1" + + +def test_each_instance_gets_its_own_volume(): + """scale > 1 でインスタンスごとに別のボリュームになる (AC3)。""" + names = {manager.get_vscode_volume_for("carmo-ai", i) for i in (1, 2, 3)} + assert len(names) == 3 + + +def test_each_project_gets_its_own_volume(): + """プロジェクトが違えば別のボリュームになる (AC4)。""" + assert manager.get_vscode_volume_for("carmo-ai", 1) != \ + manager.get_vscode_volume_for("bi-tools", 1) + + +def test_project_name_falls_back_to_environment(monkeypatch): + """省略時は COMPOSE_PROJECT_NAME を読む。 + + ボリュームを作る ``ensure_volumes`` と、マウントを書く生成 compose が + 別々にプロジェクト名を決めると、作った名前とマウントする名前がずれる。 + ``devbase up`` と同じ ``get_project_name`` へ委ねて経路を 1 つにする。 + """ + monkeypatch.setenv("COMPOSE_PROJECT_NAME", "carmo-ai") + assert manager.get_vscode_volume_for(None, 1) == "devbase_vscode_carmo-ai_1" + + +def test_project_name_falls_back_to_current_directory(monkeypatch, tmp_path): + """COMPOSE_PROJECT_NAME が無ければカレントディレクトリ名 (``devbase up`` と同じ)。""" + project = tmp_path / "carmo-ai" + project.mkdir() + monkeypatch.chdir(project) + + assert manager.get_vscode_volume_for(None, 1) == "devbase_vscode_carmo-ai_1" + + +# --------------------------------------------------------------------------- +# 正規化 (リスク: Docker のボリューム名に使えない文字) +# --------------------------------------------------------------------------- + +def test_unusable_characters_are_replaced(): + """``/`` のような使えない文字は ``_`` へ置き換える。""" + assert manager.get_vscode_volume_for("group/project", 1) == \ + "devbase_vscode_group_project_1" + + +def test_usable_characters_are_kept(): + """英数字・ドット・ハイフン・アンダースコアはそのまま残す。""" + assert manager.get_vscode_volume_for("a.b-c_D9", 2) == \ + "devbase_vscode_a.b-c_D9_2" + + +def test_empty_project_name_does_not_break_the_volume_name(): + """名前として使えるものが残らない場合も壊れた名前を作らない。""" + assert manager.normalize_volume_component("") == "default" + assert manager.normalize_volume_component("//") == "__" + + +# --------------------------------------------------------------------------- +# 作成 (AC5: 空ボリュームでも attach できるよう、先に作っておく) +# --------------------------------------------------------------------------- + +def _record_docker(monkeypatch) -> list[str]: + created: list[str] = [] + monkeypatch.setattr( + manager.VolumeManager, "_volume_exists", lambda self, name: False) + monkeypatch.setattr( + manager.VolumeManager, "_create_volume", + lambda self, name: created.append(name) or True) + return created + + +def test_ensure_volumes_creates_one_per_instance(monkeypatch): + """scale の数だけ VS Code Server ボリュームを作る。""" + created = _record_docker(monkeypatch) + + manager.ensure_volumes(2, project_name="carmo-ai") + + assert "devbase_vscode_carmo-ai_1" in created + assert "devbase_vscode_carmo-ai_2" in created + + +def test_ensure_volumes_keeps_existing_volumes(monkeypatch): + """既にあるボリュームは作り直さない (中身を失わない)。""" + created: list[str] = [] + monkeypatch.setattr( + manager.VolumeManager, "_volume_exists", + lambda self, name: name == "devbase_vscode_carmo-ai_1") + monkeypatch.setattr( + manager.VolumeManager, "_create_volume", + lambda self, name: created.append(name) or True) + + manager.ensure_volumes(1, project_name="carmo-ai") + + assert "devbase_vscode_carmo-ai_1" not in created + + +def test_ensure_volumes_does_not_create_volumes_beyond_scale(monkeypatch): + """scale を超えるインスタンスのボリュームは作らない。""" + created = _record_docker(monkeypatch) + + manager.ensure_volumes(1, project_name="carmo-ai") + + assert "devbase_vscode_carmo-ai_2" not in created