From 5339270587c2b2c20595821f0f87c076eb9c8faa Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 14 Oct 2026 09:45:16 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feat(#463):=20VS=20Code=20Server=20?= =?UTF-8?q?=E3=81=AE=E6=9C=AC=E4=BD=93=E3=82=92=E5=85=A8=E3=82=B3=E3=83=B3?= =?UTF-8?q?=E3=83=86=E3=83=8A=E5=85=B1=E9=80=9A=E3=81=AE=201=20=E6=9C=AC?= =?UTF-8?q?=E3=81=AE=E3=83=9C=E3=83=AA=E3=83=A5=E3=83=BC=E3=83=A0=E3=81=AB?= =?UTF-8?q?=E7=BD=AE=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit devbase_vscode-server-bin を ~/.vscode-server/bin に付け、コンテナごとの devbase_vscode__ を作らない・付けないようにする。 旧形式の名前を読むだけの find_legacy_vscode_volumes を足す。 Co-Authored-By: Claude Opus 5.5 --- lib/devbase/volume/compose.py | 59 +++++---- lib/devbase/volume/manager.py | 110 ++++++++--------- tests/volume/test_compose_vscode.py | 121 ++++++++++++------- tests/volume/test_manager_vscode.py | 180 +++++++++++++++------------- 4 files changed, 251 insertions(+), 219 deletions(-) diff --git a/lib/devbase/volume/compose.py b/lib/devbase/volume/compose.py index 106e71e4..7942ad85 100644 --- a/lib/devbase/volume/compose.py +++ b/lib/devbase/volume/compose.py @@ -15,12 +15,11 @@ from . import bind_mounts from .manager import ( + VSCODE_SERVER_VOLUME, 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__) @@ -28,9 +27,11 @@ # 旧 /home/ubuntu マウントは非推奨のため scale 生成時に除去する _DEPRECATED_TARGET = '/home/ubuntu' -# VS Code Server の状態ディレクトリ (PLAN36)。コンテナの書き込みレイヤに置くと -# devbase up のたびに 215MB の再ダウンロードが走るため、named volume を宛てる。 +# VS Code Server の置き場 (PLAN36 / #463)。本体 (bin/) だけを全コンテナ共通の +# named volume に置く。コンテナの書き込みレイヤに置くと devbase up のたびに +# 215MB の再ダウンロードが走る。extensions/・data/ などは書き込みレイヤに残す。 VSCODE_SERVER_TARGET = '/home/ubuntu/.vscode-server' +VSCODE_BIN_TARGET = '/home/ubuntu/.vscode-server/bin' def get_dev_service_name() -> str: @@ -117,7 +118,7 @@ def _replace_volumes_for_instance( /persistent/group is mapped to group_volume (shared within the account group). /work is mapped to work_volume. - ``vscode_volume`` は ``~/.vscode-server`` へ足すボリューム (PLAN36)。 + ``vscode_volume`` は ``~/.vscode-server/bin`` へ足すボリューム (#463)。 上の 3 つと違って**差し替えではなく追加**で、プロジェクトが同じマウント先を 書いている場合は呼び出し側が ``None`` を渡して手を出さない。 """ @@ -147,13 +148,13 @@ def _replace_volumes_for_instance( if target not in replaced_targets ) if vscode_volume: - new_volumes.append(f"{vscode_volume}:{VSCODE_SERVER_TARGET}") + new_volumes.append(f"{vscode_volume}:{VSCODE_BIN_TARGET}") return new_volumes def _build_volumes_section( config: dict, scale: int, group_volume: str, - vscode_volumes: Sequence[str] = (), + vscode_volume: Optional[str] = None, ) -> dict: """Build the volumes section for a scaled compose file.""" # Copy original volumes (mysql, valkey, etc.) from config @@ -173,10 +174,10 @@ def _build_volumes_section( 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} + # Add the VS Code Server binary volume (#463). 実際にマウントするときだけ + # 宣言する。external は実体の存在を要求するため、使わない名前を書くと起動が落ちる。 + if vscode_volume: + volumes[vscode_volume] = {'external': True} return volumes @@ -543,7 +544,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] = (), + vscode_volume: Optional[str] = None, host_gateway_environment: Optional[Mapping[str, str]] = None, ) -> dict: """Build the services section: non-dev services + dev-1..dev-N instances. @@ -554,9 +555,6 @@ 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 @@ -591,7 +589,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), + vscode_volume=vscode_volume, host_gateway_environment=host_gateway_environment, ) return scaled_services @@ -848,14 +846,14 @@ def generate_scaled_compose( secret_env_names, global_env_names, project_env_names, dev_excluded=dev_excluded) - vscode_volumes = _vscode_volumes(dev_service, scale) + vscode_volume = _vscode_volume(dev_service) 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, + vscode_volume=vscode_volume, host_gateway_environment=host_gateway_environment, ) @@ -880,7 +878,7 @@ def generate_scaled_compose( scaled_config = { 'services': scaled_services, 'volumes': _build_volumes_section( - config, scale, group_volume, vscode_volumes), + config, scale, group_volume, vscode_volume), 'networks': _build_networks_section(config), } @@ -888,20 +886,19 @@ def generate_scaled_compose( return override_file -def _vscode_volumes(dev_service: dict, scale: int) -> List[str]: - """dev インスタンスごとの VS Code Server ボリューム名 (PLAN36)。 +def _vscode_volume(dev_service: dict) -> Optional[str]: + """dev インスタンスの ``~/.vscode-server/bin`` へ足すボリューム名 (#463)。 - VS Code Server は再作成をまたいで保つためコンテナ 1 つに 1 本の named - volume を宛てる。プロジェクトが自分で ~/.vscode-server を - マウントしている場合は、その指定を奪わないよう devbase 側は何もしない。 + 共通の VS Code Server ボリューム 1 本を、インスタンスの数に依らずどの dev + インスタンスにも付ける。プロジェクトが自分で ``~/.vscode-server`` か + ``~/.vscode-server/bin`` をマウントしている場合は、その指定を奪わず同じパスへ + 2 つ付けないよう ``None`` を返す。``~/.vscode-server/data`` など下の別のパス + だけの指定は、``bin`` と重ならないので足す。 """ - if _declares_target(dev_service, VSCODE_SERVER_TARGET): - return [] - project_name = resolve_project_name() - return [ - get_vscode_volume_for(project_name, i) - for i in range(1, scale + 1) - ] + if (_declares_target(dev_service, VSCODE_SERVER_TARGET) + or _declares_target(dev_service, VSCODE_BIN_TARGET)): + return None + return VSCODE_SERVER_VOLUME def _drop_dev_excluded(scaled_services: dict, dev_service_name: str, scale: int, diff --git a/lib/devbase/volume/manager.py b/lib/devbase/volume/manager.py index c7d32650..6a7d30f2 100644 --- a/lib/devbase/volume/manager.py +++ b/lib/devbase/volume/manager.py @@ -8,7 +8,6 @@ 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") @@ -35,19 +34,19 @@ #: 旧既定のグループ名。予約語で、グループ名にも置き場の読み替えにも使えない (#315) LEGACY_GROUP_NAME = "default" -# --- VS Code Server (PLAN36) ------------------------------------------------- -# ~/.vscode-server はコンテナの書き込みレイヤ上にあり、devbase up の down → up で -# 消える。本体 (bin/) だけで 644MB あり、attach のたびに再取得が走る。 -# コンテナ 1 つにつき 1 本の named volume を割り当てて再作成をまたいで保つ。 +# --- VS Code Server (PLAN36 / #463) ----------------------------------------- +# 本体 (~/.vscode-server/bin/) だけを全コンテナで 1 本のボリュームに置く。 +# 版ごとに中身が変わらないため共有して壊れず、プロジェクトとインスタンスの数だけ +# 同じ版を重ねて持たずに済む。版の数の上限はコンテナの起動 (entrypoint) が保つ。 +# extensions/・extensionsCache/・data/ はボリュームに置かず、コンテナの書き込み +# レイヤーに残す (#463 の利用者の決定)。 # -# 共有せずコンテナ単位にするのは、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._-]") -# プロジェクト名が決まらないときの VS Code Server のボリューム名の一部。グループではない -_FALLBACK_PROJECT_NAME = "default" +# 名前は旧形式 (devbase_vscode__) の接頭辞 devbase_vscode_ と +# 15 文字目の "-" で必ず違い、旧形式の案内が共通のボリュームを選ばない。 +VSCODE_SERVER_VOLUME = "devbase_vscode-server-bin" +#: #463 より前の、~/.vscode-server を丸ごと持つコンテナごとのボリュームの接頭辞 +LEGACY_VSCODE_VOLUME_PREFIX = "devbase_vscode_" +_LEGACY_VSCODE_VOLUME_RE = re.compile(r"^devbase_vscode_.+_[0-9]+$") def validate_account_group(name: str) -> str: @@ -128,42 +127,27 @@ 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 ボリュームに使うプロジェクト名を解決する。 +def find_legacy_vscode_volumes() -> list[str]: + """旧形式の VS Code Server ボリュームの名前を名前の順で返す (#463)。 - 省略時は :func:`devbase.utils.config.get_project_name` ( - ``COMPOSE_PROJECT_NAME`` → カレントディレクトリ名) に委ねる。``devbase up`` - は同じ関数でプロジェクト名を決めてからボリュームを作る (``commands/container.py``) - ため、ボリュームを作る側と生成 compose の双方がここを通れば必ず同じ名前になる。 - 解決経路が 2 つあると、作った名前とマウントする名前がずれる。 + ``docker volume ls`` の名前を読むだけで、消す・付ける・中身を読むことはしない + (C4)。一覧が取れなければ空を返す (案内のためだけに使い、起動を止めない)。 """ - name = (project_name or get_project_name() or "").strip() - return name or _FALLBACK_PROJECT_NAME - - -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 _FALLBACK_PROJECT_NAME - - -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}" + try: + result = subprocess.run( + ['docker', 'volume', 'ls', '--format', '{{.Name}}'], + capture_output=True, + text=True, + check=False, + ) + except Exception as e: # noqa: BLE001 - 案内のためだけの一覧。失敗は空として扱う + logger.debug("Failed to list volumes: %s", e) + return [] + if result.returncode != 0: + logger.debug("Failed to list volumes: %s", result.stderr) + return [] + names = {line.strip() for line in result.stdout.splitlines()} + return sorted(n for n in names if _LEGACY_VSCODE_VOLUME_RE.match(n)) class VolumeManager: @@ -174,8 +158,8 @@ def __init__(self, project_name: str = None): Initialize VolumeManager Args: - project_name: Project name. VS Code Server ボリュームの名前にだけ - 使う (PLAN36)。省略時は ``COMPOSE_PROJECT_NAME`` から解決する + project_name: Project name. ボリュームの名前には使わない + (#463 より前は VS Code Server ボリュームの名前に使った) """ self.project_name = project_name @@ -254,7 +238,8 @@ 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) + - devbase_vscode-server-bin: VS Code Server の本体, shared by all + containers (#463) Args: scale: Number of container instances @@ -283,6 +268,15 @@ def ensure_volumes(self, scale: int, group: Optional[str] = None) -> None: if not self.create_volume(group_volume): raise DockerError(f"Failed to create volume {group_volume}") + # Ensure VS Code Server binary volume (once for all containers, #463) + if self._volume_exists(VSCODE_SERVER_VOLUME): + logger.info(" %s (VS Code Server, exists)", VSCODE_SERVER_VOLUME) + else: + logger.info(" Creating %s (VS Code Server)...", VSCODE_SERVER_VOLUME) + if not self.create_volume(VSCODE_SERVER_VOLUME): + raise DockerError( + f"Failed to create volume {VSCODE_SERVER_VOLUME}") + # Create or verify work volumes for each instance for i in range(1, scale + 1): work_volume = self.get_work_volume_for_index(i) @@ -295,16 +289,6 @@ 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: @@ -313,12 +297,12 @@ 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. VS Code Server volumes are per project and index - (PLAN36). + per container index. The VS Code Server binary volume is shared by all + containers (#463). Args: scale: Number of container instances - project_name: Project name (default: resolved from COMPOSE_PROJECT_NAME) + project_name: Project name (unused for volume names since #463) group: Account group name (省略時は DEVBASE_ACCOUNT_GROUP。既定の値は無い) """ manager = VolumeManager(project_name) diff --git a/tests/volume/test_compose_vscode.py b/tests/volume/test_compose_vscode.py index 1b1d63ad..6d72167b 100644 --- a/tests/volume/test_compose_vscode.py +++ b/tests/volume/test_compose_vscode.py @@ -1,12 +1,15 @@ -"""VS Code Server ボリュームのマウントと宣言 (PLAN36 Task 2) +"""共通の VS Code Server ボリュームのマウントと宣言 (#463) 生成 compose に対して次を固定する。 -1. dev インスタンスごとに ``/home/ubuntu/.vscode-server`` が - ``devbase_vscode__`` としてマウントされる (AC1 / AC2) -2. インスタンスごとに別のボリュームになる (AC3) -3. そのボリュームが ``volumes:`` セクションへ ``external: true`` で宣言される -4. プロジェクトが同じマウント先を書いている場合は上書きしない +1. どの dev インスタンスも、共通の VS Code Server ボリューム 1 本を + ``/home/ubuntu/.vscode-server/bin`` に付ける (受け入れ条件 2・I1) +2. ``/home/ubuntu/.vscode-server`` と ``/home/ubuntu/.vscode-server/data`` には + ボリュームを付けない (拡張と状態はコンテナの書き込みレイヤーに残す) +3. そのボリュームを ``volumes:`` に ``external: true`` で 1 回だけ宣言する +4. プロジェクトが ``~/.vscode-server`` か ``~/.vscode-server/bin`` を自分で付けて + いれば何も足さない (受け入れ条件 5・I2) +5. 旧形式 (``devbase_vscode__``) が現れない (受け入れ条件 6・I3) """ from __future__ import annotations @@ -14,9 +17,12 @@ import pytest from devbase.volume import compose +from devbase.volume.manager import VSCODE_SERVER_VOLUME from tests.volume.conftest import load_scaled, mount_source, write_compose -VSCODE_TARGET = "/home/ubuntu/.vscode-server" +SERVER_TARGET = "/home/ubuntu/.vscode-server" +BIN_TARGET = "/home/ubuntu/.vscode-server/bin" +DATA_TARGET = "/home/ubuntu/.vscode-server/data" @pytest.fixture @@ -24,46 +30,48 @@ def in_tmp_cwd(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.delenv("DEV_SERVICE_NAME", raising=False) monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "acme") # up が宣言から置いた後 (#315) - monkeypatch.setenv("COMPOSE_PROJECT_NAME", "myapp-ai") + monkeypatch.setenv("COMPOSE_PROJECT_NAME", "proj-a") return tmp_path -# --------------------------------------------------------------------------- -# マウント (AC1 / AC2 / AC3) -# --------------------------------------------------------------------------- - -def test_vscode_mount_is_added_when_absent(in_tmp_cwd): - """プロジェクト compose が書いていなくても自動で足される。""" - write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) +def _sources(service: dict) -> list[str]: + result = [] + for vol in service.get("volumes", []): + if isinstance(vol, str): + result.append(vol.split(":")[0]) + elif isinstance(vol, dict): + result.append(str(vol.get("source"))) + return result - compose.generate_scaled_compose(scale=1) - - dev = load_scaled(in_tmp_cwd)["services"]["dev-1"] - assert mount_source(dev, VSCODE_TARGET) == "devbase_vscode_myapp-ai_1" +# --------------------------------------------------------------------------- +# マウント (受け入れ条件 2・I1) +# --------------------------------------------------------------------------- -def test_each_instance_mounts_its_own_volume(in_tmp_cwd): - """scale > 1 でインスタンスごとに別のボリュームを掴む (AC3)。""" +def test_every_instance_mounts_the_shared_volume_on_bin(in_tmp_cwd): + """scale 2 でも、どのインスタンスも同じ共通のボリュームを bin に 1 つ付ける。""" 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_myapp-ai_1" - assert mount_source(services["dev-2"], VSCODE_TARGET) == \ - "devbase_vscode_myapp-ai_2" + for name in ("dev-1", "dev-2"): + dev = services[name] + assert mount_source(dev, BIN_TARGET) == VSCODE_SERVER_VOLUME + assert mount_source(dev, SERVER_TARGET) is None + assert mount_source(dev, DATA_TARGET) is None + assert _sources(dev).count(VSCODE_SERVER_VOLUME) == 1 -def test_mount_follows_project_name(in_tmp_cwd, monkeypatch): - """別プロジェクトでは別のボリュームになる (AC4)。""" - monkeypatch.setenv("COMPOSE_PROJECT_NAME", "bi-tools") +def test_mount_does_not_depend_on_project_name(in_tmp_cwd, monkeypatch): + """別のプロジェクトでも同じ 1 本を付ける (受け入れ条件 4 の前提)。""" + monkeypatch.setenv("COMPOSE_PROJECT_NAME", "proj-b") 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" + assert mount_source(dev, BIN_TARGET) == VSCODE_SERVER_VOLUME def test_existing_mounts_are_kept(in_tmp_cwd): @@ -89,22 +97,34 @@ def test_non_dev_services_do_not_get_the_mount(in_tmp_cwd): compose.generate_scaled_compose(scale=1) db = load_scaled(in_tmp_cwd)["services"]["db"] - assert mount_source(db, VSCODE_TARGET) is None + assert mount_source(db, BIN_TARGET) is None # --------------------------------------------------------------------------- -# 宣言 (external) +# 宣言 (external) と旧形式 (受け入れ条件 6・I3) # --------------------------------------------------------------------------- -def test_vscode_volumes_are_declared_external(in_tmp_cwd): - """devbase が作るボリュームなので external: true で宣言する。""" +def test_shared_volume_is_declared_external_once(in_tmp_cwd): + """devbase が作るボリュームなので external: true で 1 回だけ宣言する。""" 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_myapp-ai_1"] == {"external": True} - assert volumes["devbase_vscode_myapp-ai_2"] == {"external": True} + assert volumes[VSCODE_SERVER_VOLUME] == {"external": True} + + +def test_legacy_volumes_do_not_appear(in_tmp_cwd): + """生成物のマウントと volumes: に旧形式の名前が無い。""" + write_compose(in_tmp_cwd, {"dev": {"image": "dev:latest"}}) + + compose.generate_scaled_compose(scale=2) + + scaled = load_scaled(in_tmp_cwd) + assert not [n for n in scaled["volumes"] if n.startswith("devbase_vscode_")] + for name in ("dev-1", "dev-2"): + assert not [s for s in _sources(scaled["services"][name]) + if s.startswith("devbase_vscode_")] def test_project_volumes_are_kept(in_tmp_cwd): @@ -118,23 +138,36 @@ def test_project_volumes_are_kept(in_tmp_cwd): # --------------------------------------------------------------------------- -# プロジェクトの指定を尊重する +# プロジェクトの指定を尊重する (受け入れ条件 5・I2) # --------------------------------------------------------------------------- -def test_declared_vscode_mount_is_not_overridden(in_tmp_cwd): - """プロジェクトが同じマウント先を書いていたらそのまま残す。 - - ホストのディレクトリを bind したい構成を devbase が奪わないため。 - """ +@pytest.mark.parametrize("target", [SERVER_TARGET, BIN_TARGET]) +def test_declared_vscode_mount_is_not_overridden(in_tmp_cwd, target): + """~/.vscode-server か bin を書いていたら、マウントも宣言も足さない。""" write_compose(in_tmp_cwd, { "dev": {"image": "dev:latest", - "volumes": [f"./vscode-server:{VSCODE_TARGET}"]}, + "volumes": [f"./vscode-server:{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" + assert mount_source(dev, target) == "./vscode-server" + assert VSCODE_SERVER_VOLUME not in _sources(dev) # 使わないボリュームを宣言しない (external は実体を要求する) - assert "devbase_vscode_myapp-ai_1" not in scaled["volumes"] + assert VSCODE_SERVER_VOLUME not in scaled["volumes"] + + +def test_data_only_declaration_still_gets_shared_bin(in_tmp_cwd): + """~/.vscode-server/data だけの指定なら、共通のボリュームを bin に足す。""" + write_compose(in_tmp_cwd, { + "dev": {"image": "dev:latest", + "volumes": [f"./vscode-data:{DATA_TARGET}"]}, + }) + + compose.generate_scaled_compose(scale=1) + + dev = load_scaled(in_tmp_cwd)["services"]["dev-1"] + assert mount_source(dev, DATA_TARGET) == "./vscode-data" + assert mount_source(dev, BIN_TARGET) == VSCODE_SERVER_VOLUME diff --git a/tests/volume/test_manager_vscode.py b/tests/volume/test_manager_vscode.py index 4629178b..5c042733 100644 --- a/tests/volume/test_manager_vscode.py +++ b/tests/volume/test_manager_vscode.py @@ -1,16 +1,16 @@ -"""VS Code Server ボリュームの名前解決と作成 (PLAN36 Task 1) +"""共通の VS Code Server ボリュームの名前・作成・旧形式の一覧 (#463) -``~/.vscode-server`` はコンテナの書き込みレイヤ上にあり、``devbase up`` の -``down`` → ``up`` で消える。再作成をまたいで保つため、**コンテナ 1 つにつき 1 本**の -named volume ``devbase_vscode__`` を割り当てる。 - -VS Code Server は 1 マシン 1 セットの状態 (接続トークン・marker・ログ) を持つため、 -名前にプロジェクト名とインスタンス番号の両方を含めて、scale > 1 の同時 attach -(AC3) と別プロジェクトとの同時起動 (AC4) で状態を共有させない。 +VS Code Server の本体 (``~/.vscode-server/bin/``) だけを、全コンテナで 1 本の +ボリューム ``devbase_vscode-server-bin`` に置く。プロジェクト・インスタンス・ +アカウントグループで分けない。#463 より前の ``devbase_vscode__`` +(旧形式) は、名前を読んで案内するだけで、作らない・消さない・付けない。 """ from __future__ import annotations +import re +import subprocess + import pytest from devbase.volume import manager @@ -24,112 +24,130 @@ def _clean_env(monkeypatch): # --------------------------------------------------------------------------- -# 名前の組み立て (AC3 / AC4) +# 名前 (受け入れ条件 3・I4) # --------------------------------------------------------------------------- -def test_volume_name_contains_project_and_index(): - """名前は devbase_vscode__。""" - assert manager.get_vscode_volume_for("myapp-ai", 1) == \ - "devbase_vscode_myapp-ai_1" - +def test_shared_name_does_not_look_like_legacy(): + """共通の名前は旧形式の接頭辞でも形でもない (旧形式の案内が選ばない)。""" + name = manager.VSCODE_SERVER_VOLUME -def test_each_instance_gets_its_own_volume(): - """scale > 1 でインスタンスごとに別のボリュームになる (AC3)。""" - names = {manager.get_vscode_volume_for("myapp-ai", i) for i in (1, 2, 3)} - assert len(names) == 3 + assert not name.startswith(manager.LEGACY_VSCODE_VOLUME_PREFIX) + assert not re.match(r"^devbase_vscode_.+_[0-9]+$", name) -def test_each_project_gets_its_own_volume(): - """プロジェクトが違えば別のボリュームになる (AC4)。""" - assert manager.get_vscode_volume_for("myapp-ai", 1) != \ - manager.get_vscode_volume_for("bi-tools", 1) - +# --------------------------------------------------------------------------- +# 作成 (受け入れ条件 1) +# --------------------------------------------------------------------------- -def test_project_name_falls_back_to_environment(monkeypatch): - """省略時は COMPOSE_PROJECT_NAME を読む。 +def _record_docker(monkeypatch, existing: set[str] = frozenset()) -> list[str]: + created: list[str] = [] + monkeypatch.setattr( + manager.VolumeManager, "_volume_exists", + lambda self, name: name in existing or name in created) + monkeypatch.setattr( + manager.VolumeManager, "create_volume", + lambda self, name: created.append(name) or True) + return created - ボリュームを作る ``ensure_volumes`` と、マウントを書く生成 compose が - 別々にプロジェクト名を決めると、作った名前とマウントする名前がずれる。 - ``devbase up`` と同じ ``get_project_name`` へ委ねて経路を 1 つにする。 - """ - monkeypatch.setenv("COMPOSE_PROJECT_NAME", "myapp-ai") - assert manager.get_vscode_volume_for(None, 1) == "devbase_vscode_myapp-ai_1" +def test_shared_volume_is_created_once_for_any_project_group_and_scale(monkeypatch): + """プロジェクト・グループ・scale が違っても、作るのは同じ名前の 1 本だけ。""" + created = _record_docker(monkeypatch) -def test_project_name_falls_back_to_current_directory(monkeypatch, tmp_path): - """COMPOSE_PROJECT_NAME が無ければカレントディレクトリ名 (``devbase up`` と同じ)。""" - project = tmp_path / "myapp-ai" - project.mkdir() - monkeypatch.chdir(project) + manager.ensure_volumes(2, project_name="proj-a") + manager.ensure_volumes(1, project_name="proj-b", group="team-b") - assert manager.get_vscode_volume_for(None, 1) == "devbase_vscode_myapp-ai_1" + vscode = [n for n in created if "vscode" in n] + assert vscode == [manager.VSCODE_SERVER_VOLUME] -# --------------------------------------------------------------------------- -# 正規化 (リスク: Docker のボリューム名に使えない文字) -# --------------------------------------------------------------------------- +def test_existing_shared_volume_is_not_recreated(monkeypatch): + """あれば作らない (本体を取り直さない)。""" + created = _record_docker( + monkeypatch, existing={manager.VSCODE_SERVER_VOLUME}) -def test_unusable_characters_are_replaced(): - """``/`` のような使えない文字は ``_`` へ置き換える。""" - assert manager.get_vscode_volume_for("group/project", 1) == \ - "devbase_vscode_group_project_1" + manager.ensure_volumes(1, project_name="proj-a") + assert manager.VSCODE_SERVER_VOLUME not in created -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_no_per_instance_vscode_volume_is_created(monkeypatch): + """インスタンスごとの VS Code Server のボリュームと旧形式を作らない。""" + created = _record_docker(monkeypatch) -def test_empty_project_name_does_not_break_the_volume_name(): - """名前として使えるものが残らない場合も壊れた名前を作らない。""" - assert manager.normalize_volume_component("") == "default" - assert manager.normalize_volume_component("//") == "__" + manager.ensure_volumes(3, project_name="proj-a") + assert not [n for n in created + if n.startswith(manager.LEGACY_VSCODE_VOLUME_PREFIX)] -# --------------------------------------------------------------------------- -# 作成 (AC5: 空ボリュームでも attach できるよう、先に作っておく) -# --------------------------------------------------------------------------- -def _record_docker(monkeypatch) -> list[str]: - created: list[str] = [] +def test_failure_to_create_shared_volume_raises(monkeypatch): + """作れなければ今のボリュームと同じく DockerError で止まる。""" 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 + lambda self, name: name != manager.VSCODE_SERVER_VOLUME) + with pytest.raises(manager.DockerError): + manager.ensure_volumes(1, project_name="proj-a") -def test_ensure_volumes_creates_one_per_instance(monkeypatch): - """scale の数だけ VS Code Server ボリュームを作る。""" - created = _record_docker(monkeypatch) - manager.ensure_volumes(2, project_name="myapp-ai") +# --------------------------------------------------------------------------- +# 旧形式の一覧 (受け入れ条件 3・11・12、I5) +# --------------------------------------------------------------------------- - assert "devbase_vscode_myapp-ai_1" in created - assert "devbase_vscode_myapp-ai_2" in created +def _fake_volume_ls(monkeypatch, stdout: str = "", returncode: int = 0, + raises: Exception | None = None) -> list[list[str]]: + calls: list[list[str]] = [] + def fake_run(cmd, **kwargs): + calls.append(list(cmd)) + if raises is not None: + raise raises + return subprocess.CompletedProcess(cmd, returncode, stdout, "error") -def test_ensure_volumes_keeps_existing_volumes(monkeypatch): - """既にあるボリュームは作り直さない (中身を失わない)。""" - created: list[str] = [] - monkeypatch.setattr( - manager.VolumeManager, "_volume_exists", - lambda self, name: name == "devbase_vscode_myapp-ai_1") - monkeypatch.setattr( - manager.VolumeManager, "create_volume", - lambda self, name: created.append(name) or True) + monkeypatch.setattr(manager.subprocess, "run", fake_run) + return calls - manager.ensure_volumes(1, project_name="myapp-ai") - assert "devbase_vscode_myapp-ai_1" not in created +def test_find_legacy_returns_only_legacy_names_sorted(monkeypatch): + """共通の名前やほかのボリュームが混ざっても、旧形式だけを名前の順で返す。""" + calls = _fake_volume_ls(monkeypatch, "\n".join([ + "devbase_vscode_proj-b_1", + manager.VSCODE_SERVER_VOLUME, + "devbase_home_ubuntu", + "devbase_vscode_proj-a_2", + "devbase_vscode_proj-a_1", + "devbase_vscode_notanindex", + ]) + "\n") + assert manager.find_legacy_vscode_volumes() == [ + "devbase_vscode_proj-a_1", + "devbase_vscode_proj-a_2", + "devbase_vscode_proj-b_1", + ] + # 名前の一覧を読むだけ (消す・作る・付ける呼び出しが無い) + assert calls == [["docker", "volume", "ls", "--format", "{{.Name}}"]] -def test_ensure_volumes_does_not_create_volumes_beyond_scale(monkeypatch): - """scale を超えるインスタンスのボリュームは作らない。""" - created = _record_docker(monkeypatch) - manager.ensure_volumes(1, project_name="myapp-ai") +def test_find_legacy_returns_empty_when_listing_fails(monkeypatch): + """一覧が取れなければ空を返し、例外を出さない。""" + _fake_volume_ls(monkeypatch, returncode=1) + assert manager.find_legacy_vscode_volumes() == [] + + _fake_volume_ls(monkeypatch, raises=FileNotFoundError("docker")) + assert manager.find_legacy_vscode_volumes() == [] + + +# --------------------------------------------------------------------------- +# スナップショットの対象 (受け入れ条件 13・I11) +# --------------------------------------------------------------------------- + +def test_snapshot_targets_exclude_shared_vscode_volume(tmp_path): + """スナップショットの対象に共通の VS Code Server ボリュームが入らない。""" + from devbase.snapshot.manager import SnapshotManager + + volumes = SnapshotManager(tmp_path, group="acme").volumes - assert "devbase_vscode_myapp-ai_2" not in created + assert manager.VSCODE_SERVER_VOLUME not in volumes.values() From 579b4683a527e1b55c48b7c4ec7819cf73762583 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 14 Oct 2026 09:50:17 +0900 Subject: [PATCH 02/15] =?UTF-8?q?feat(#463):=20=E3=82=B3=E3=83=B3=E3=83=86?= =?UTF-8?q?=E3=83=8A=E3=81=AE=E8=B5=B7=E5=8B=95=E3=81=A7=20VS=20Code=20Ser?= =?UTF-8?q?ver=20=E3=81=AE=20bin=20=E3=82=92=E7=94=A8=E6=84=8F=E3=81=97?= =?UTF-8?q?=E6=97=A7=E7=89=88=E3=82=92=E7=89=87=E4=BB=98=E3=81=91=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ~/.vscode-server と bin を開発ユーザーが書ける形にし、bin/ の版を product.json の 更新時刻の新しい順に 5 版だけ残す。同時の展開で残った入れ子の本体と、60 分より 古い展開の途中のディレクトリも消す。消せないものは WARNING だけで起動を止めない。 Co-Authored-By: Claude Opus 5.5 --- containers/base/entrypoint.sh | 99 ++++++- .../test_entrypoint_vscode_server.py | 246 +++++++++++++++++- 2 files changed, 331 insertions(+), 14 deletions(-) diff --git a/containers/base/entrypoint.sh b/containers/base/entrypoint.sh index 4e2d2d32..94e87bb7 100644 --- a/containers/base/entrypoint.sh +++ b/containers/base/entrypoint.sh @@ -603,17 +603,106 @@ devbase_keep_replaced_link() { } # =================================================================== -# PLAN36: VS Code Server の永続ディレクトリ +# PLAN36 / #463: VS Code Server の本体の置き場 # =================================================================== -# ~/.vscode-server にはコンテナ 1 つにつき 1 本の named volume が割り当てられる -# (devbase_vscode__)。空のボリュームは root 所有で作られるため、 -# そのままでは開発ユーザーが VS Code Server をインストールできない。 +# ~/.vscode-server/bin には全コンテナ共通の named volume (devbase_vscode-server-bin) +# が付く。本体の版 (bin/) だけを持ち、extensions/・data/ などはコンテナの +# 書き込みレイヤーに残る。空のボリュームは root 所有で付き、マウント先の親 +# ~/.vscode-server も Docker が root 所有で作るため、そのままでは開発ユーザーが +# VS Code Server を置けない。 # # マウントが無い構成でもディレクトリを作るだけで済み、VS Code の動作は変わらない。 + +# bin/ に残す本体の版の数 (#463)。1 版は約 0.6GB。VS Code はおおむね月に 1 版のため、 +# 別の端末の VS Code が数か月更新されずに古い版で attach していても消しにくい数にする +DEVBASE_VSCODE_SERVER_KEEP=5 + +# 展開の途中のディレクトリ (bin/_<ミリ秒>) を残りと見なすまでの分数。 +# 展開に 60 分かかることは無いため、動いている展開を消さない +DEVBASE_VSCODE_SERVER_STALE_MINUTES=60 + devbase_setup_vscode_server_dir() { local home_root="$1" owner="${2:-${USERNAME:-ubuntu}}" devbase_ensure_persistent_root "${home_root}/.vscode-server" "$owner" + devbase_ensure_persistent_root "${home_root}/.vscode-server/bin" "$owner" + devbase_prune_vscode_server_bin "${home_root}/.vscode-server/bin" +} + +# ファイルの更新時刻 (UNIX 秒)。GNU / uutils の stat と BSD の stat の両方で読む。 +devbase_file_mtime() { + stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null +} + +# 片付けの 1 つのパスを消す。消せずに残ったときだけ WARNING を出す。 +# +# ほかのコンテナの片付けが同時に同じパスを消すことがある。そのとき rm が途中で +# 失敗しても、パスが無くなっていれば WARNING を出さない。終了コードは常に 0。 +devbase_remove_vscode_server_path() { + local path="$1" err + + err=$(rm -rf -- "$path" 2>&1) && return 0 + # ほかのコンテナが消している途中なら、もう一度で消え切る + if [ -e "$path" ] || [ -L "$path" ]; then + err=$(rm -rf -- "$path" 2>&1) && return 0 + fi + if [ -e "$path" ] || [ -L "$path" ]; then + echo "WARNING: VS Code Server の片付けで消せなかった: ${path} (${err%%$'\n'*})" >&2 + fi + return 0 +} + +# 共通のボリュームの bin/ を片付ける (#463)。消すのは次の 3 種だけで、bin/ のほかの +# もの・~/.vscode-server の bin 以外には触らない。終了コードは常に 0。 +# +# 1. 残す版の数を超えた古い版。版は 40 桁の 16 進の名前で product.json を持つ +# ディレクトリ。product.json の更新時刻の新しい順 (同じなら名前の順) に並べる。 +# product.json は片付けが書き換えないため、同時に走る片付けどうしで並びが同じに +# なり、残る版が残す数より減らない (ディレクトリの更新時刻では減った) +# 2. 残った版の直下の入れ子の本体 (product.json を持つディレクトリ)。本体の同時の +# 展開で、遅れた側の中身が bin//vscode-server-linux-/ に残ったもの +# 3. 60 分より古い展開の途中のディレクトリ bin/_<数字> +devbase_prune_vscode_server_bin() { + local bin="$1" keep="${2:-$DEVBASE_VSCODE_SERVER_KEEP}" + local entry name mtime listing="" count=0 kept=() nested + + [ -d "$bin" ] || return 0 + + for entry in "$bin"/*; do + name="${entry##*/}" + [[ "$name" =~ ^[0-9a-f]{40}$ ]] || continue + [ -d "$entry" ] && [ -f "$entry/product.json" ] || continue + mtime=$(devbase_file_mtime "$entry/product.json") || continue + listing+="${mtime} ${name}"$'\n' + done + + while read -r mtime name; do + [ -n "$name" ] || continue + count=$((count + 1)) + if [ "$count" -le "$keep" ]; then + kept+=("$name") + else + devbase_remove_vscode_server_path "${bin}/${name}" + fi + done < <(printf '%s' "$listing" | LC_ALL=C sort -k1,1nr -k2,2) + + for name in "${kept[@]}"; do + for nested in "${bin}/${name}"/*/; do + nested="${nested%/}" + [ -d "$nested" ] && [ ! -L "$nested" ] || continue + [ -f "${nested}/product.json" ] || continue + devbase_remove_vscode_server_path "$nested" + done + done + + while IFS= read -r entry; do + name="${entry##*/}" + [[ "$name" =~ ^[0-9a-f]{40}_[0-9]+$ ]] || continue + devbase_remove_vscode_server_path "$entry" + done < <(find "$bin" -mindepth 1 -maxdepth 1 -type d \ + -mmin "+${DEVBASE_VSCODE_SERVER_STALE_MINUTES}" 2>/dev/null) + + return 0 } # =================================================================== @@ -781,7 +870,7 @@ USERNAME="${USERNAME:-ubuntu}" devbase_setup_cloud_config_dirs "$USERNAME" devbase_setup_gcp_credentials "/home/${USERNAME}" -# 1.5. VS Code Server の永続ディレクトリ (PLAN36) +# 1.5. VS Code Server の本体の置き場と片付け (PLAN36 / #463) devbase_setup_vscode_server_dir "/home/${USERNAME}" "$USERNAME" # 2. Setup Git configuration diff --git a/tests/containers/test_entrypoint_vscode_server.py b/tests/containers/test_entrypoint_vscode_server.py index cef2e40f..e99594b7 100644 --- a/tests/containers/test_entrypoint_vscode_server.py +++ b/tests/containers/test_entrypoint_vscode_server.py @@ -1,8 +1,10 @@ -"""VS Code Server ディレクトリの所有者初期化 (PLAN36 Task 3) +"""VS Code Server の本体の置き場の用意と片付け (PLAN36 / #463) -``~/.vscode-server`` には named volume がマウントされる (PLAN36 Task 2)。空の -named volume は **root 所有**で作られるため、開発ユーザー (uid 1000) のままでは -VS Code Server をインストールできず ``Permission denied`` になる (前提 6 / AC5)。 +``~/.vscode-server/bin`` には全コンテナ共通の named volume が付く (#463)。空の +named volume は **root 所有**で付き、マウント先の親 ``~/.vscode-server`` も Docker が +root 所有で作るため、開発ユーザー (uid 1000) のままでは VS Code Server を置けない。 +コンテナの起動は、共通の ``bin/`` の旧版・同時の展開の入れ子の本体・古い展開の +途中のディレクトリを片付け、版の数を残す数 (5) に収める。 ``containers/base/entrypoint.sh`` を ``DEVBASE_ENTRYPOINT_LIB_ONLY=1`` で source し、 一時ディレクトリを ``$HOME`` に見立てて関数を直接呼ぶ。Docker には依存しない。 @@ -13,6 +15,7 @@ import os import subprocess +import time from pathlib import Path import pytest @@ -42,21 +45,23 @@ def setup(home: Path) -> subprocess.CompletedProcess: def test_directory_is_prepared(home: Path): - """マウント先が使える状態になる (AC5)。""" + """~/.vscode-server と bin が開発ユーザーの書ける形で用意される (受け入れ条件 10)。""" setup(home) - assert (home / ".vscode-server").is_dir() + for path in (home / ".vscode-server", home / ".vscode-server" / "bin"): + assert path.is_dir() + assert os.access(path, os.W_OK) 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") + version = make_version(server / "bin", 0) + (version / "marker").write_text("kept") setup(home) - assert (server / "bin" / "abc123" / "marker").read_text() == "kept" + assert (version / "marker").read_text() == "kept" def test_is_idempotent(home: Path): @@ -90,3 +95,226 @@ def test_writable_directory_does_not_invoke_sudo(home: Path): assert result.returncode == 0, result.stderr assert "sudo was called" not in result.stderr + + +# --------------------------------------------------------------------------- +# bin/ の片付け (受け入れ条件 7〜9、I6〜I9) +# --------------------------------------------------------------------------- + +KEEP = 5 +NOW = time.time() + + +def commit(n: int) -> str: + return f"{n:040x}" + + +def make_version(bin_dir: Path, n: int, age_days: float = 0.0) -> Path: + """本体の版 bin/ を作る。product.json の時刻を age_days 日だけ古くする。""" + version = bin_dir / commit(n) + (version / "bin").mkdir(parents=True, exist_ok=True) + (version / "bin" / "code-server").write_text("#!/bin/sh\n") + product = version / "product.json" + product.write_text("{}") + stamp = NOW - age_days * 86400 + os.utime(product, (stamp, stamp)) + return version + + +def prune(home: Path, extra: str = "") -> subprocess.CompletedProcess: + bin_dir = home / ".vscode-server" / "bin" + result = run(f'devbase_prune_vscode_server_bin "{bin_dir}" {extra}', home) + assert result.returncode == 0, result.stderr + return result + + +def versions(bin_dir: Path) -> set[str]: + return {p.name for p in bin_dir.iterdir() if (p / "product.json").is_file()} + + +@pytest.fixture +def bin_dir(home: Path) -> Path: + path = home / ".vscode-server" / "bin" + path.mkdir(parents=True) + return path + + +def test_old_versions_beyond_keep_are_removed(home: Path, bin_dir: Path): + """8 版あれば product.json の新しい 5 版だけが残る (受け入れ条件 7)。 + + ディレクトリの更新時刻ではなく product.json の時刻で並べることを、 + ディレクトリの時刻を逆向きにして確かめる。 + """ + for n in range(8): + version = make_version(bin_dir, n, age_days=30 * n) + stamp = NOW - 30 * (8 - n) * 86400 + os.utime(version, (stamp, stamp)) + + result = prune(home) + + assert versions(bin_dir) == {commit(n) for n in range(KEEP)} + assert "WARNING" not in result.stderr + + +def test_same_time_is_ordered_by_name(home: Path, bin_dir: Path): + """時刻が同じ版は名前の順で残す (同時に走る片付けどうしで並びが同じになる)。""" + for n in range(7): + make_version(bin_dir, n, age_days=1) + + prune(home) + + assert versions(bin_dir) == {commit(n) for n in range(KEEP)} + + +def test_nothing_is_removed_within_keep(home: Path, bin_dir: Path): + """版が残す数以下なら何も消さない。""" + for n in range(KEEP): + make_version(bin_dir, n, age_days=n) + + prune(home) + + assert versions(bin_dir) == {commit(n) for n in range(KEEP)} + + +def test_concurrent_prunes_keep_exactly_keep_versions(home: Path, bin_dir: Path): + """2 つの片付けを同時に走らせても、残る版は 5 つで、WARNING は出ない (I6・I9)。""" + for n in range(8): + make_version(bin_dir, n, age_days=30 * n) + (bin_dir / commit(n) / "bin" / "payload").mkdir() + for i in range(50): + (bin_dir / commit(n) / "bin" / "payload" / f"f{i}").write_text("x") + + base = {k: v for k, v in os.environ.items() + if not k.startswith(("DEVBASE_", "GIT_"))} + script = (f'DEVBASE_ENTRYPOINT_LIB_ONLY=1 . "{ENTRYPOINT}"\n' + f'devbase_prune_vscode_server_bin "{bin_dir}"\n') + procs = [subprocess.Popen(["bash", "-c", script], env=base, + stderr=subprocess.PIPE, text=True) + for _ in range(2)] + errors = [p.communicate()[1] for p in procs] + + assert [p.returncode for p in procs] == [0, 0] + assert versions(bin_dir) == {commit(n) for n in range(KEEP)} + assert not [e for e in errors if "WARNING" in e] + + +def test_entries_that_are_not_versions_are_not_counted(home: Path, bin_dir: Path): + """product.json の無いディレクトリ・40 桁でない名前は版に数えず、消さない (I8)。""" + for n in range(KEEP): + make_version(bin_dir, n, age_days=n) + (bin_dir / commit(99)).mkdir() # product.json が無い + other = bin_dir / "not-a-version" + other.mkdir() + (other / "product.json").write_text("{}") + (bin_dir / "note.txt").write_text("kept") + + prune(home) + + assert versions(bin_dir) == {commit(n) for n in range(KEEP)} | {"not-a-version"} + assert (bin_dir / commit(99)).is_dir() + assert (bin_dir / "note.txt").is_file() + + +def test_nested_server_is_removed(home: Path, bin_dir: Path): + """同時の展開で残った入れ子の本体だけが消え、本体は残る (受け入れ条件 8・I7)。""" + version = make_version(bin_dir, 0) + nested = version / "vscode-server-linux-arm64" + (nested / "bin").mkdir(parents=True) + (nested / "product.json").write_text("{}") + (version / "node_modules").mkdir() # product.json の無い直下のものは残る + + prune(home) + + assert not nested.exists() + assert (version / "bin" / "code-server").is_file() + assert (version / "product.json").is_file() + assert (version / "node_modules").is_dir() + + +def test_nested_is_kept_when_version_has_no_product_json(home: Path, bin_dir: Path): + """bin// 自身に product.json が無いときは入れ子を消さない。""" + version = bin_dir / commit(0) + nested = version / "vscode-server-linux-arm64" + nested.mkdir(parents=True) + (nested / "product.json").write_text("{}") + + prune(home) + + assert (nested / "product.json").is_file() + + +def test_only_stale_extraction_dirs_are_removed(home: Path, bin_dir: Path): + """60 分より古い bin/_<数字> だけが消え、新しいものは残る (I8)。""" + stale = bin_dir / f"{commit(1)}_1760000000000" + fresh = bin_dir / f"{commit(2)}_1760000000001" + for path in (stale, fresh): + (path / "vscode-server-linux-arm64").mkdir(parents=True) + old = NOW - 61 * 60 + os.utime(stale, (old, old)) + odd = bin_dir / "keep_123" + odd.mkdir() + os.utime(odd, (old, old)) + + prune(home) + + assert not stale.exists() + assert fresh.is_dir() + assert odd.is_dir() + + +def test_outside_of_bin_is_untouched(home: Path, bin_dir: Path): + """~/.vscode-server の bin 以外 (extensions/・extensionsCache/・data/) に触らない。""" + server = home / ".vscode-server" + for n in range(8): + make_version(bin_dir, n, age_days=n) + for name in ("extensions", "extensionsCache", "data"): + (server / name / commit(0)).mkdir(parents=True) + (server / name / commit(0) / "product.json").write_text("{}") + + setup(home) + + for name in ("extensions", "extensionsCache", "data"): + assert (server / name / commit(0) / "product.json").is_file() + + +def test_missing_bin_does_nothing(home: Path): + """bin が無ければ何もせず 0 で終わる。""" + prune(home) + + assert not (home / ".vscode-server").exists() + + +@pytest.mark.skipif(os.geteuid() == 0, reason="root は書けないディレクトリも消せる") +def test_unremovable_path_warns_and_does_not_fail(home: Path, bin_dir: Path): + """消せないものがあっても 0 で終わり、そのパスを WARNING に 1 行出す (受け入れ条件 9)。""" + for n in range(KEEP + 1): + make_version(bin_dir, n, age_days=n) + oldest = bin_dir / commit(KEEP) + locked = oldest / "bin" + locked.chmod(0o555) + try: + result = prune(home) + finally: + locked.chmod(0o755) + + warnings = [line for line in result.stderr.splitlines() if "WARNING" in line] + assert len(warnings) == 1 + assert str(oldest) in warnings[0] + + +def test_setup_survives_prune_warning(home: Path, bin_dir: Path): + """片付けの WARNING があってもコンテナの起動の関数は 0 で終わる。""" + if os.geteuid() == 0: + pytest.skip("root は書けないディレクトリも消せる") + for n in range(KEEP + 1): + make_version(bin_dir, n, age_days=n) + locked = bin_dir / commit(KEEP) / "bin" + locked.chmod(0o555) + try: + result = run( + f'devbase_setup_vscode_server_dir "{home}" "$(id -un)"\necho done', home) + finally: + locked.chmod(0o755) + + assert result.returncode == 0, result.stderr + assert "done" in result.stdout From 08578ea5f4e1f21d152b7e0b16b96ba0c8feaca9 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 14 Oct 2026 09:50:18 +0900 Subject: [PATCH 03/15] =?UTF-8?q?feat(#463):=20=E8=B5=B7=E5=8B=95=E3=81=AE?= =?UTF-8?q?=E5=BE=8C=E3=81=AE=E5=87=A6=E7=90=86=E3=81=A7=20bin=20=E3=81=AE?= =?UTF-8?q?=E6=89=80=E6=9C=89=E8=80=85=E3=82=92=E6=8F=83=E3=81=88=E3=80=81?= =?UTF-8?q?up=20=E3=81=A7=E6=97=A7=E5=BD=A2=E5=BC=8F=E3=81=AE=E3=83=9C?= =?UTF-8?q?=E3=83=AA=E3=83=A5=E3=83=BC=E3=83=A0=E3=82=92=E6=A1=88=E5=86=85?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 建て直す前のイメージでは bin が root のまま残るため、docker exec -u 0 で ~/.vscode-server の所有者へ揃える。devbase up の最後に、旧形式の devbase_vscode__ の名前と消すコマンドを出す(消さない)。 Co-Authored-By: Claude Opus 5.5 --- lib/devbase/commands/container.py | 59 +++++++++- lib/devbase/editor/vscode_server.py | 72 ++++++++++++ tests/commands/test_container_context.py | 3 + tests/commands/test_container_post_start.py | 100 ++++++++++++++++ tests/commands/test_container_scale_order.py | 8 +- tests/editor/test_vscode_server.py | 117 +++++++++++++++++++ 6 files changed, 355 insertions(+), 4 deletions(-) create mode 100644 lib/devbase/editor/vscode_server.py create mode 100644 tests/editor/test_vscode_server.py diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 58b36414..3393eb85 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -1051,6 +1051,53 @@ def _apply_window_titles(project_name: str, scale: int, dev_service_name: str, logger.debug("window.title の設定をスキップしました (index=%d): %s", index, e) +def _prepare_vscode_server_bin(project_name: str, scale: int, dev_service_name: str, + compose_file=None, *, indices=None) -> None: + """各 dev コンテナの ``~/.vscode-server/bin`` を開発ユーザーが書ける形にする (#463)。 + + ``bin`` には全コンテナ共通のボリュームが付き、空のボリュームは root の所有で付く。 + #463 より前の entrypoint を持つイメージ (建て直す前の base と派生イメージ) では + ``bin`` が root のままで attach が失敗するため、窓を開く前にここで揃える + (詳細は :mod:`devbase.editor.vscode_server`)。 + + 失敗しても起動の後の処理は倒さない (debug の記録にとどめる)。 + ``indices`` を渡すとその番号にだけ行う。渡さなければ 1 から ``scale``。 + """ + from devbase.editor import opener, vscode_server + + for index in _target_indices(indices, scale): + try: + container_name = opener.resolve_container_name( + dev_service_name, project_name, index, compose_file=compose_file) + vscode_server.prepare_bin_dir(container_name) + except Exception as e: # noqa: BLE001 - 付随処理で起動の後の処理を倒さない + logger.debug("VS Code Server の bin の用意をスキップしました (index=%d): %s", + index, e) + + +def _report_legacy_vscode_volumes() -> None: + """旧形式の VS Code Server ボリュームが残っていれば、名前と消し方を案内する (#463)。 + + 名前の一覧を読むだけで、消す・付ける・中身を読むことはしない (C4)。消すのは + 利用者である。旧形式が無い・一覧が取れないときは何も出さない。 + """ + from devbase.volume.manager import find_legacy_vscode_volumes + + legacy = find_legacy_vscode_volumes() + if not legacy: + return + lines = [ + f"使わなくなった VS Code Server のボリュームが {len(legacy)} 本あります" + "(VS Code Server の本体は共通のボリュームへ移りました):", + *(f" {name}" for name in legacy), + "不要なら次で消せます:", + f" docker volume rm {' '.join(legacy)}", + "コンテナが付けているボリュームは docker volume rm が断ります。" + "そのプロジェクトで devbase up を打った後に消せます", + ] + logger.warning("\n".join(lines)) + + def _maybe_open_editor(project_name: str, open_flag: Optional[bool], open_index: Optional[int], scale: int, config, compose_file=None, @@ -1342,7 +1389,8 @@ def _run_post_start(deployment: _ComposeDeployment, indices, config, run_deploy: bool = True) -> None: """起動の後の処理の段。後処理の対象の番号へ、インスタンスごとの処理を決まった順に行う。 - 順は、不足リポジトリの報告 → ``./deploy`` → token の配布 → 窓のタイトルの設定で、 + 順は、不足リポジトリの報告 → ``./deploy`` → token の配布 → VS Code Server の ``bin`` の + 所有者の直し → 窓のタイトルの設定で、 ``up`` / ``scale`` / ``project post-start`` が共有する (#224・#371)。対象は呼び出し元が 決め、この段は番号を足しも引きもしない。対象が空なら何も行わない (token も発行しない)。 ``run_deploy`` が偽なら ``./deploy`` を走らせない (``project post-start``)。 @@ -1367,6 +1415,12 @@ def _run_post_start(deployment: _ComposeDeployment, indices, config, _push_bao_token(deployment.project_name, deployment.scale, deployment.dev_service_name, compose_file=deployment.compose_file, indices=indices) + # 共通の VS Code Server ボリュームの bin を開発ユーザーが書ける形にする (#463)。 + # 建て直す前のイメージの entrypoint は bin を直さないため、窓を開く前に揃える。 + _prepare_vscode_server_bin(deployment.project_name, deployment.scale, + deployment.dev_service_name, + compose_file=deployment.compose_file, indices=indices) + # VS Code のウィンドウタイトルをコンテナ名始まりに固定する # (自動オープンの有無に関わらず、手動アタッチにも効かせるため)。 _apply_window_titles(deployment.project_name, deployment.scale, @@ -1525,6 +1579,9 @@ def cmd_up(project_name: str = None, scale: int = None, config, compose_file=override_file, docker_context_name=target.context, started=started) + # 移った後の後片付けを忘れないよう、旧形式が残る限り毎回最後に案内する (#463) + _report_legacy_vscode_volumes() + if _finish_partial('Deploy', startup_error, project_name): return 1 logger.info("=== Deploy completed successfully ===") diff --git a/lib/devbase/editor/vscode_server.py b/lib/devbase/editor/vscode_server.py new file mode 100644 index 00000000..9356a9bd --- /dev/null +++ b/lib/devbase/editor/vscode_server.py @@ -0,0 +1,72 @@ +"""VS Code Server の本体の置き場をコンテナの外から書ける形にする (#463) + +``~/.vscode-server/bin`` には全コンテナ共通のボリューム ``devbase_vscode-server-bin`` +が付く。空のボリュームは root の所有で付き、#463 より前の entrypoint は +``~/.vscode-server`` だけを開発ユーザーへ直すため、``bin`` は root のままで +VS Code Server が本体を置けない (attach が失敗する)。 + +entrypoint の変更は base と派生イメージを建て直すまで効かないため、起動の後の処理 +(``devbase up`` / ``scale`` / ``project post-start``) で ``docker exec -u 0`` を 1 回打ち、 +``bin`` の所有者を ``~/.vscode-server`` の所有者へ揃える。揃っていれば何もしない +ため、新しい entrypoint のコンテナでは何も変えない。``chown`` は ``bin`` 1 つにだけ +打ち、再帰しない。パスは ``/home/ubuntu`` の絶対パスで書く (root の ``$HOME`` は +``/root``)。 +""" + +from __future__ import annotations + +import shlex +import subprocess +from typing import Callable, Optional + +from devbase.log import get_logger + +logger = get_logger(__name__) + +#: 開発ユーザーのホーム。生成する compose のマウント先と同じ +HOME_ROOT = "/home/ubuntu" + +# docker exec の待ち時間上限 (秒)。ローカルの docker への 1 コマンドなので即答する。 +_EXEC_TIMEOUT = 10 + + +def bin_fix_script(home_root: str = HOME_ROOT) -> str: + """``bin`` の所有者を ``~/.vscode-server`` の所有者へ揃えるシェルの手順。 + + ``bin`` が無ければ何もしない。所有者は GNU の ``stat -c`` で読み、使えなければ + BSD の ``stat -f`` で読む (コンテナは Linux。テストは手元でも走らせる)。 + """ + server = shlex.quote(f"{home_root}/.vscode-server") + bin_dir = shlex.quote(f"{home_root}/.vscode-server/bin") + return ( + "owner_of() { stat -c %u:%g \"$1\" 2>/dev/null || stat -f %u:%g \"$1\"; }; " + f"[ -d {bin_dir} ] || exit 0; " + f"o=$(owner_of {server}) || exit 1; " + f"b=$(owner_of {bin_dir}) || exit 1; " + f"[ \"$b\" = \"$o\" ] || chown \"$o\" {bin_dir}" + ) + + +def prepare_bin_dir(container_name: str, + runner: Optional[Callable] = None) -> bool: + """コンテナの ``~/.vscode-server/bin`` を開発ユーザーが書ける形にする。 + + 直せた・直す必要が無かったら True。docker の失敗・タイムアウトは False を + 返し、例外を出さない (起動の後の処理を止めない)。 + """ + run = runner or subprocess.run + try: + result = run( + ["docker", "exec", "-u", "0", container_name, + "sh", "-c", bin_fix_script()], + capture_output=True, text=True, timeout=_EXEC_TIMEOUT, check=False, + ) + except Exception as e: # noqa: BLE001 - docker 不在/タイムアウト等で起動を倒さない + logger.debug("VS Code Server の bin の所有者を直せませんでした (%s): %s", + container_name, e) + return False + if result.returncode != 0: + logger.debug("VS Code Server の bin の所有者を直せませんでした (%s): %s", + container_name, (result.stderr or "").strip()) + return False + return True diff --git a/tests/commands/test_container_context.py b/tests/commands/test_container_context.py index 17c1ab01..8453b8e5 100644 --- a/tests/commands/test_container_context.py +++ b/tests/commands/test_container_context.py @@ -97,6 +97,9 @@ def _f(*a, **k): monkeypatch.setattr(container, 'wait_for_containers_ready', record('wait')) monkeypatch.setattr(container, '_apply_window_titles', record('titles')) monkeypatch.setattr(container, '_report_missing_repos', lambda *a, **k: None) + # bin の所有者の直しと旧形式の案内 (#463) は docker を呼ぶ付随処理。数えない + monkeypatch.setattr(container, '_prepare_vscode_server_bin', lambda *a, **k: None) + monkeypatch.setattr(container, '_report_legacy_vscode_volumes', lambda: None) monkeypatch.setattr(container, '_maybe_open_editor', lambda *a, **k: seen.__setitem__('editor', k)) seen['_real_inject'] = container._inject_secrets diff --git a/tests/commands/test_container_post_start.py b/tests/commands/test_container_post_start.py index ee3a610e..95815003 100644 --- a/tests/commands/test_container_post_start.py +++ b/tests/commands/test_container_post_start.py @@ -54,6 +54,7 @@ def __init__(self, events: list): self.ps_returncode = 0 self.not_ready: set = set() # 完了の印が無いコンテナ名 self.services = ['dev-1'] + self.volumes: list = [] # docker volume ls の名前 (#463) def __call__(self, cmd, **kwargs): cmd = list(cmd) @@ -63,6 +64,8 @@ def __call__(self, cmd, **kwargs): return subprocess.CompletedProcess(cmd, 0, '', '') if 'config' in cmd and '--services' in cmd: return subprocess.CompletedProcess(cmd, 0, '\n'.join(self.services) + '\n', '') + if cmd[:3] == ['docker', 'volume', 'ls']: + return subprocess.CompletedProcess(cmd, 0, ''.join(f'{n}\n' for n in self.volumes), '') if cmd[:2] == ['docker', 'ps']: out = ''.join(f'proj-dev-{i}\tdev-{i}\n' for i, _name in self.running) return subprocess.CompletedProcess(cmd, self.ps_returncode, out, 'daemon down') @@ -546,3 +549,100 @@ def test_post_start_uses_the_scale_compose_file(harness): ls = [e[1] for e in harness['events'] if e[0] == 'run' and 'ls' in e[1]] assert ls and all(str(container._SCALE_COMPOSE_FILE) in cmd for cmd in ls) + + +# --------------------------------------------------------------------------- +# #463: VS Code Server の bin の所有者の直しと、旧形式のボリュームの案内 +# --------------------------------------------------------------------------- + +LEGACY = ['devbase_vscode_proj-a_2', 'devbase_vscode_proj-a_1'] + + +def _bin_fixes(events): + """``docker exec -u 0 <名前> sh -c …`` (bin の所有者の直し) を打ったコンテナ名""" + return [e[1][4] for e in events + if e[0] == 'run' and e[1][:4] == ['docker', 'exec', '-u', '0']] + + +def test_up_prepares_vscode_bin_before_the_title(harness): + """受け入れ条件 10・E12: 番号ごとに bin を直してから窓のタイトルを書く""" + harness['setup'](scale=2) + + assert container.cmd_up() == 0 + + events = harness['events'] + assert _bin_fixes(events) == ['real-dev-1', 'real-dev-2'] + order = [e[1][4] if e[0] == 'run' else e[1] for e in events + if e[0] == 'title' or (e[0] == 'run' and e[1][:4] == ['docker', 'exec', '-u', '0'])] + assert order.index('real-dev-1') < order.index('real-dev-2') + first_title = next(i for i, e in enumerate(events) if e[0] == 'title') + last_fix = max(i for i, e in enumerate(events) + if e[0] == 'run' and e[1][:4] == ['docker', 'exec', '-u', '0']) + assert last_fix < first_title + + +def test_scale_and_post_start_prepare_vscode_bin_on_the_targets(harness): + """scale は増やしたインスタンスだけ、post-start は動いているインスタンスへ""" + harness['setup'](scale=1) + assert container.cmd_scale(3) == 0 + assert _bin_fixes(harness['events']) == ['real-dev-2', 'real-dev-3'] + + harness['events'].clear() + _running(harness, 1, 2) + assert container.cmd_post_start() == 0 + assert _bin_fixes(harness['events']) == ['real-dev-1', 'real-dev-2'] + + +def test_vscode_bin_failure_does_not_stop_post_start(harness, monkeypatch): + """直せなくても起動の後の処理は続き、タイトルは書かれる""" + from devbase.editor import vscode_server + + def boom(name, runner=None): + raise RuntimeError('docker down') + + monkeypatch.setattr(vscode_server, 'prepare_bin_dir', boom) + harness['setup'](scale=1) + + assert container.cmd_up() == 0 + assert _indices(harness['events'], 'title') == [1] + + +def test_up_reports_legacy_vscode_volumes(harness, caplog): + """受け入れ条件 11・I5: 名前と消すコマンドを出し、消す・作る・付ける呼び出しは無い""" + harness['setup'](scale=1) + harness['fake'].volumes = ['devbase_vscode-server-bin', 'devbase_home_ubuntu', *LEGACY] + + assert container.cmd_up() == 0 + + warnings = [r.getMessage() for r in caplog.records if r.levelname == 'WARNING'] + notice = next(m for m in warnings if 'VS Code Server' in m) + assert 'devbase_vscode_proj-a_1' in notice and 'devbase_vscode_proj-a_2' in notice + assert 'docker volume rm devbase_vscode_proj-a_1 devbase_vscode_proj-a_2' in notice + assert 'devbase_vscode-server-bin' not in notice + docker_calls = [e[1] for e in harness['events'] if e[0] == 'run'] + assert not [c for c in docker_calls + if c[:3] in (['docker', 'volume', 'rm'], ['docker', 'volume', 'create']) + or (c[:2] == ['docker', 'run']) + or any(name in ' '.join(c) for name in LEGACY)] + + +def test_up_without_legacy_volumes_says_nothing(harness, caplog): + """受け入れ条件 12""" + harness['setup'](scale=1) + harness['fake'].volumes = ['devbase_vscode-server-bin'] + + assert container.cmd_up() == 0 + + assert not any('VS Code Server のボリューム' in m for m in _messages(caplog)) + + +def test_scale_and_post_start_do_not_report_legacy_volumes(harness, caplog): + """決定 6: 案内は up だけ""" + harness['setup'](scale=1) + harness['fake'].volumes = list(LEGACY) + + assert container.cmd_scale(2) == 0 + _running(harness, 1, 2) + assert container.cmd_post_start() == 0 + + assert not any('VS Code Server のボリューム' in m for m in _messages(caplog)) diff --git a/tests/commands/test_container_scale_order.py b/tests/commands/test_container_scale_order.py index bc151d58..f13844cb 100644 --- a/tests/commands/test_container_scale_order.py +++ b/tests/commands/test_container_scale_order.py @@ -267,12 +267,14 @@ def _step(name, payload): def test_scale_runs_the_steps_in_order(scale_harness, monkeypatch): """C-1: グループの検査 → write_scale → ボリューム → network → 生成 → 既定のサービス → 起動 - → ready 待ち → 起動の後の処理 (不足リポジトリの報告 → ./deploy → bao → 窓のタイトル。 - up と同じ順。#224)。""" + → ready 待ち → 起動の後の処理 (不足リポジトリの報告 → ./deploy → bao → VS Code Server の + bin の所有者の直し (#463) → 窓のタイトル。up と同じ順。#224)。""" (scale_harness['root'] / 'deploy').write_text('#!/bin/sh\n') calls = scale_harness['calls'] monkeypatch.setattr(container, '_report_missing_repos', lambda *a, **k: calls.append(('repos', k['indices']))) + monkeypatch.setattr(container, '_prepare_vscode_server_bin', + lambda *a, **k: calls.append(('vscode_bin', k['indices']))) monkeypatch.setattr(container, '_apply_window_titles', lambda *a, **k: calls.append(('titles', k['indices']))) @@ -281,7 +283,7 @@ def test_scale_runs_the_steps_in_order(scale_harness, monkeypatch): steps = [_step(name, payload) for name, payload in scale_harness['calls']] assert [s for s in steps if s != 'config'] == [ 'group', 'write_scale', 'volumes', 'network', 'generate', 'default_services', - 'up', 'wait', 'repos', 'deploy', 'bao', 'titles'] + 'up', 'wait', 'repos', 'deploy', 'bao', 'vscode_bin', 'titles'] # 既定のサービスの解決 (config --services) は生成の後、起動の前に行う assert steps.index('generate') < steps.index('config') < steps.index('up') up = _up_calls(scale_harness['calls'])[0] diff --git a/tests/editor/test_vscode_server.py b/tests/editor/test_vscode_server.py new file mode 100644 index 00000000..fa42ffb7 --- /dev/null +++ b/tests/editor/test_vscode_server.py @@ -0,0 +1,117 @@ +"""起動の後の処理で VS Code Server の bin の所有者を揃える (#463) + +建て直す前のイメージの entrypoint は ``~/.vscode-server`` だけを開発ユーザーへ直し、 +共通のボリュームの根 ``~/.vscode-server/bin`` は root のまま残る。``prepare_bin_dir`` +は ``docker exec -u 0`` を 1 回打ち、``bin`` の所有者を ``~/.vscode-server`` の所有者へ +揃える。手順は一時のディレクトリに当てて確かめる。``chown`` は差し替えて呼び出しを見る。 +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +from devbase.editor import vscode_server + + +# --------------------------------------------------------------------------- +# docker exec の打ち方 +# --------------------------------------------------------------------------- + +def test_runs_one_docker_exec_as_root(): + calls = [] + + def runner(cmd, **kwargs): + calls.append(cmd) + return subprocess.CompletedProcess(cmd, 0, "", "") + + assert vscode_server.prepare_bin_dir("proj-a-dev-1", runner=runner) is True + + assert len(calls) == 1 + cmd = calls[0] + assert cmd[:6] == ["docker", "exec", "-u", "0", "proj-a-dev-1", "sh"] + assert cmd[6] == "-c" + assert "/home/ubuntu/.vscode-server/bin" in cmd[7] + assert "/data" not in cmd[7] + assert "-R" not in cmd[7] + + +def test_failure_returns_false_without_raising(): + def failing(cmd, **kwargs): + return subprocess.CompletedProcess(cmd, 1, "", "no such container") + + def raising(cmd, **kwargs): + raise subprocess.TimeoutExpired(cmd, 10) + + assert vscode_server.prepare_bin_dir("x", runner=failing) is False + assert vscode_server.prepare_bin_dir("x", runner=raising) is False + + +# --------------------------------------------------------------------------- +# 手順 (一時のディレクトリに当てる) +# --------------------------------------------------------------------------- + +@pytest.fixture +def fake_bin(tmp_path: Path) -> Path: + """呼ばれた引数を記録する chown を PATH の先頭へ置く""" + path = tmp_path / "fakebin" + path.mkdir() + chown = path / "chown" + chown.write_text(f'#!/bin/sh\necho "$@" >> "{tmp_path}/chown.log"\n') + chown.chmod(0o755) + return path + + +def _run(home: Path, fake_bin: Path, stat_stub: str | None = None): + if stat_stub is not None: + stat = fake_bin / "stat" + stat.write_text(stat_stub) + stat.chmod(0o755) + env = {**os.environ, "PATH": f"{fake_bin}:{os.environ.get('PATH', '')}"} + return subprocess.run( + ["sh", "-c", vscode_server.bin_fix_script(str(home))], + env=env, capture_output=True, text=True) + + +def _chowns(tmp_path: Path) -> list[str]: + log = tmp_path / "chown.log" + return log.read_text().splitlines() if log.exists() else [] + + +def test_missing_bin_does_nothing(tmp_path: Path, fake_bin: Path): + home = tmp_path / "home" + (home / ".vscode-server").mkdir(parents=True) + + result = _run(home, fake_bin) + + assert result.returncode == 0, result.stderr + assert _chowns(tmp_path) == [] + + +def test_same_owner_does_nothing(tmp_path: Path, fake_bin: Path): + home = tmp_path / "home" + (home / ".vscode-server" / "bin").mkdir(parents=True) + + result = _run(home, fake_bin) + + assert result.returncode == 0, result.stderr + assert _chowns(tmp_path) == [] + + +def test_different_owner_is_aligned_to_the_parent(tmp_path: Path, fake_bin: Path): + """bin が root、~/.vscode-server が開発ユーザーなら、bin 1 つだけを揃える""" + home = tmp_path / "home" + (home / ".vscode-server" / "bin").mkdir(parents=True) + stub = ( + "#!/bin/sh\n" + 'for a in "$@"; do last="$a"; done\n' + 'case "$last" in */bin) echo 0:0 ;; *) echo 1000:1000 ;; esac\n' + ) + + result = _run(home, fake_bin, stat_stub=stub) + + assert result.returncode == 0, result.stderr + assert _chowns(tmp_path) == [f"1000:1000 {home}/.vscode-server/bin"] From feb6680174ab69d3d1cd763b65c5c9dc095e15f0 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 14 Oct 2026 09:53:16 +0900 Subject: [PATCH 04/15] =?UTF-8?q?docs(#463):=20VS=20Code=20Server=20?= =?UTF-8?q?=E3=81=AE=E6=9C=AC=E4=BD=93=E3=81=A0=E3=81=91=E3=82=92=E5=85=B1?= =?UTF-8?q?=E6=9C=89=E3=81=99=E3=82=8B=E6=A7=8B=E6=88=90=E3=81=A8=E7=A7=BB?= =?UTF-8?q?=E3=82=8A=E6=96=B9=E3=82=92=E6=9B=B8=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 ++++++ docs/user/container-operations.md | 85 +++++++++++++++++++++++-------- 2 files changed, 80 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d98144c..1d88dd25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,21 @@ **更新後の最初の `devbase up` の自動スナップショットは、系列ごとに新しい世代(フル)を作ります。** 除外の一覧が違う世代を `devbase snapshot create --name` で指定すると、差分を作らずに終了コード 1 で止まります。 既存の世代はそのまま一覧・復元できます。 +- **VS Code Server の本体(`~/.vscode-server/bin`)だけを全コンテナ共通の 1 本のボリューム `devbase_vscode-server-bin` に + 置くようにしました(#463)。** これまではコンテナ 1 つに 1 本の `devbase_vscode_<プロジェクト>_<番号>` が + `~/.vscode-server` を丸ごと持ち、VS Code の更新のたびに約 0.6GB の版が各ボリュームへ溜まっていました。 + 今は本体の版をプロジェクトとインスタンスの数だけ重ねて持たず、コンテナの起動が `bin/` の版を新しい順に 5 つまで残し、 + 同時の展開で残った入れ子の本体と古い展開の途中のディレクトリも消します(この片付けは base と派生イメージを + 建て直した後から効きます)。 + **拡張機能・リモート側の設定(`data/Machine/settings.json`)・ローカル履歴・拡張機能がリモート側に保存する状態は、 + ボリュームに置かずコンテナの書き込みレイヤーに残るため、`devbase up` でコンテナを作り直すたびに消えるようになります。** + 手で入れた拡張機能は入れ直しになり、Dev Containers の既定の拡張機能は作り直しの後の attach で入り直します。 + `window.title` は `devbase up` が毎回書き直します。`docker restart` と端末の再起動では消えません。 + 移った後の最初の attach で本体(約 215MB)を取り直します。以前の `devbase_vscode_<プロジェクト>_<番号>` は付けず、 + 中身も引き継がず、devbase は消しません。残っている間は `devbase up` の最後に名前と消すコマンドが出るので、 + 利用者が `docker volume rm` で消してください([使わなくなった VS Code Server ボリュームを消す](docs/user/container-operations.md#使わなくなった-vs-code-server-ボリュームを消す))。 + 建て直す前のイメージでも attach できるよう、`devbase up` / `scale` / `project post-start` の起動の後の処理が + `~/.vscode-server/bin` を開発ユーザーが書ける形に揃えます。 ### Fixed - **接続先 URL が手元のサーバ(`http://127.0.0.1:8200` など `http` の loopback)のとき、`devbase up` したコンテナの中の diff --git a/docs/user/container-operations.md b/docs/user/container-operations.md index ae049bf5..2610a3b4 100644 --- a/docs/user/container-operations.md +++ b/docs/user/container-operations.md @@ -147,7 +147,7 @@ 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`、Kiro CLI の `.local/share/kiro-cli`、gcloud / gws の設定ディレクトリ、[作り直しても残るシェルの設定](#作り直しても残るシェルの設定)の置き場所 `.shellrc.d`)| | `devbase_work_{index}` | `/work` | 同じ index のコンテナで共有(プロジェクト間も共有) | プロジェクトのソースコード、作業ファイル | -| `devbase_vscode_{project}_{index}` | `/home/ubuntu/.vscode-server` | 共有しない(コンテナ 1 つに 1 本)| VS Code Server 本体・拡張機能・接続トークン | +| `devbase_vscode-server-bin` | `/home/ubuntu/.vscode-server/bin` | 全コンテナで共有(プロジェクト・グループを問わず 1 本) | VS Code Server の本体(`bin/`)だけ。拡張機能・接続トークン・リモート側の設定は持たない | > **Note:** `devbase_home_ubuntu` は **`/persistent/ai`** にマウントされます(`/home/ubuntu` への直接マウントは廃止)。`/home/ubuntu` 直下はコンテナ層(揮発)で、永続化されるのは entrypoint が `/persistent/ai` / `/persistent/group` 配下へ symlink する設定ファイルのみです。シェル履歴など symlink 対象外のファイルは再生成で失われます。 @@ -192,23 +192,64 @@ Google 認証の具体的な手順は [Google 認証ガイド](google-auth.md) ### VS Code Server の永続化 -VS Code を attach すると、コンテナ内に VS Code Server(本体 `bin/`・拡張機能・ -接続トークン)が入ります。ここは `~/.vscode-server` で、以前はコンテナ層(揮発)にあったため -`devbase up` でコンテナを作り直すたびに **215MB の再ダウンロード**(約 55 秒)が走っていました。 +VS Code を attach すると、コンテナ内の `~/.vscode-server` に VS Code Server が入ります。 +ボリュームに置くのは本体(`bin/`、1 版で約 0.6GB)だけで、全コンテナ共通の 1 本 +`devbase_vscode-server-bin` を `~/.vscode-server/bin` にマウントします。 -現在は `devbase_vscode_{project}_{index}` を `~/.vscode-server` にマウントするため、 -コンテナを作り直しても本体と拡張機能が残ります。 +本体を共有するのは、版ごとに中身が変わらず、プロジェクトとインスタンスの数だけ同じ版を +重ねて持つ理由が無いためです。どれか 1 つのコンテナで一度取れば、ほかのコンテナの attach は本体を +取り直しません。 -このボリュームだけは**共有しません**。VS Code Server は「1 マシン 1 セット」の状態 -(`data/Machine/.connection-token-` など)を持ち、複数のコンテナが同時に書くと -接続トークンを奪い合うためです。名前にプロジェクト名とインスタンス番号の両方を含めることで、 -`scale > 1` の同時 attach でも、別プロジェクトの同時起動でも状態が混ざりません。 +| 置き場 | どこにあるか | 消えるとき | +|---|---|---| +| `bin/`(本体) | 共通のボリューム `devbase_vscode-server-bin` | コンテナの起動の片付けが古い版を消す(下記) | +| `extensions/`・`extensionsCache/`・`data/` など `bin/` 以外 | コンテナの書き込みレイヤー(ボリュームではない) | `devbase up` の作り直し(down → up)。`docker restart` と端末の再起動では残る | + +`devbase up` でコンテナを作り直すたびに、次のものが消えます。 + +- 手で入れた拡張機能。Dev Containers の既定の拡張機能は、 + 作り直しの後の最初の attach で入り直します +- リモート側の設定(`data/Machine/settings.json`)のうち、手で入れたもの。`window.title` は + `devbase up` が毎回書き直します +- ローカル履歴(`data/User/History`)と、拡張機能がリモート側に保存する状態 + (`data/User/globalStorage`・`data/User/workspaceStorage`。Copilot Chat など) +- ログ(`data/logs`)。消えるため溜まりません +- 接続トークンと `.devport`。次のサーバーの起動で作り直されます + +`bin/` 以外がコンテナごとに分かれているため、窓のタイトルと窓の再接続(ウィンドウの再読み込み)は +コンテナごとに働きます。 + +#### 本体の旧版の片付け + +VS Code を更新するたびに `bin/` へ新しい版が足されます。コンテナの起動(entrypoint)が毎回 +`bin/` を片付け、次のものを消します。 -- 初回 attach と VS Code 本体のバージョン更新時(`commit` ハッシュが変わるとき)は - ダウンロードが走ります。減るのは**コンテナ再作成のたびの再取得**です -- プロジェクトが `compose.yml` で `~/.vscode-server` を自分でマウントしている場合、 - devbase は上書きしません +- 新しい順に **5 版**を残し、それより古い版。順は各版の `product.json` の更新時刻で決めます +- 複数の窓が同じ版を同時に展開したときに、版のディレクトリの中に入れ子で残った本体 + (`bin//vscode-server-linux-/`) +- 60 分より前に始まって終わらなかった展開の途中のディレクトリ(`bin/_<数字>`) + +消せないものがあっても起動は止まらず、`WARNING: VS Code Server の片付けで消せなかった: <パス>` +の行が出ます。片付けは base と派生イメージを建て直した後から効きます。それまでは `bin/` に +版が溜まりますが、共通の 1 本なので、プロジェクトとインスタンスの数だけ重なりません。 + +- プロジェクトが `compose.yml` で `~/.vscode-server` か `~/.vscode-server/bin` を自分で + マウントしている場合、devbase は何も足しません。`~/.vscode-server/data` など下の別のパス + だけをマウントしているときは、共通のボリュームを `bin` に足します - スナップショット(`devbase snapshot`)の対象外です。失っても attach し直せば再取得されます +- 空のボリュームは root の所有で付きます。コンテナの起動と、`devbase up` / `scale` / + `project post-start` の起動の後の処理が、`~/.vscode-server/bin` を開発ユーザーが書ける形に + 揃えます(建て直す前のイメージでも attach できます) + +#### 以前の VS Code Server ボリュームから移る + +以前の devbase は `~/.vscode-server` を丸ごと、コンテナ 1 つに 1 本のボリューム +`devbase_vscode_{project}_{index}` に置いていました。今の devbase はこれを付けず、中身も +引き継ぎません。移った後の最初の attach で本体(約 215MB、約 55 秒)を取り直し、拡張機能は +入れ直しになります。 + +以前のボリュームが残っている間、`devbase up` の最後に名前と消すコマンドが出ます。devbase は +消しません。下の「使わなくなった VS Code Server ボリュームを消す」の手順で消してください。 ### ボリュームの永続性 @@ -230,11 +271,11 @@ docker volume inspect devbase_home_ubuntu #### 使わなくなった VS Code Server ボリュームを消す -`devbase_vscode_*` はプロジェクトを削除しても自動では消えません。attach したコンテナ 1 つ -あたり **約 1.6GB** を使うため、使わなくなったプロジェクトの分は手で削除します。 +以前の形式の `devbase_vscode_{project}_{index}` は、今の devbase では使いません。自動では +消えないため、手で削除します。1 本で数 GB を使うことがあります。 ```bash -# 一覧(サイズ付き) +# 一覧(共通の devbase_vscode-server-bin は出ない) docker volume ls --filter name=devbase_vscode_ --format '{{.Name}}' docker system df -v | grep devbase_vscode_ @@ -243,9 +284,13 @@ 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` してください。 +`devbase up` の案内に出る `docker volume rm …` の行をそのまま打っても構いません。削除しても +失われるのは以前の VS Code Server の本体と拡張機能だけで、設定(`~/.claude` などの AI 設定)には +影響しません。以前の devbase で作ったコンテナが付けているボリュームは `docker volume rm` が +拒否します。そのプロジェクトで `devbase up` を打ってコンテナを作り直した後に消してください。 + +共通の `devbase_vscode-server-bin` は消さなくて構いません。消したときは、次の attach で本体を +取り直します。 > **Warning:** `devbase_home_ubuntu` ボリューム(`/persistent/ai`、および symlink 経由でアクセスする `~/.claude/plugins` / `~/share` 等)は全プロジェクトで共有されます。ここにプロジェクト固有のファイルを置くと、他のプロジェクトにも影響します。プロジェクト固有のファイルは `/work` に配置してください。 From 6b40e523797b498261bcca83a9abf115f3a60a69 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 14 Oct 2026 09:57:29 +0900 Subject: [PATCH 05/15] =?UTF-8?q?docs(#463):=20=E8=B5=B7=E5=8B=95=E3=81=AE?= =?UTF-8?q?=E5=BE=8C=E3=81=AE=E5=87=A6=E7=90=86=E3=81=AE=E9=A0=86=E3=81=A8?= =?UTF-8?q?=20VS=20Code=20Server=20=E3=81=AE=E5=9B=B0=E3=82=8A=E3=81=94?= =?UTF-8?q?=E3=81=A8=E3=81=AE=E7=AF=80=E3=82=92=E4=BB=8A=E3=81=AE=E6=8C=AF?= =?UTF-8?q?=E3=82=8B=E8=88=9E=E3=81=84=E3=81=AB=E5=90=88=E3=82=8F=E3=81=9B?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/specifications/compose-profiles.md | 5 ++-- docs/specifications/post-start.md | 22 +++++++++-------- docs/user/troubleshooting.md | 32 +++++++++++++++---------- 3 files changed, 34 insertions(+), 25 deletions(-) diff --git a/docs/specifications/compose-profiles.md b/docs/specifications/compose-profiles.md index 61f32bdc..a6ee8670 100644 --- a/docs/specifications/compose-profiles.md +++ b/docs/specifications/compose-profiles.md @@ -159,7 +159,8 @@ subcommand より前に置く。`<サービス...>` はプロファイル X に 起動の待ちの後、`cmd_scale` は増やしたインスタンス(`current + 1` から `new`)のうち起動できたものを 後処理の対象にして、`up` と共有する起動の後の処理の段(`_run_post_start`)を呼ぶ。順は -不足リポジトリの報告 → `./deploy` → bao の token → 窓のタイトルの設定で、`up` と同じである。 +不足リポジトリの報告 → `./deploy` → bao の token → VS Code Server の `bin` の所有者の直し(#463)→ +窓のタイトルの設定で、`up` と同じである。 起動できなかったインスタンスがあると、起動の待ちは `ContainerStartupError`(起動できた番号と、 起動できなかった番号ごとの理由)を投げる。`cmd_scale` はそれを受け、対象へ後処理を行った後に `Scale failed` と起動できなかった全部の名前・理由・補う手順(`devbase project post-start `)を @@ -442,7 +443,7 @@ TUI では、復元境界が機密の注入履歴も戻すため、別プロジ `os.environ` を変えないこと。起動の非 0 で `Failed to start new containers` と 1 になり例外を 出さないこと。プロファイルのサービスを起動の対象に入れないこと。正常系の順序 (グループの検査 → `write_scale` → ボリューム → network → 生成 → `default_services` → 起動 → - ready 待ち → 不足リポジトリの報告 → `./deploy` → bao の token → 窓のタイトル)、bao と `./deploy` の + ready 待ち → 不足リポジトリの報告 → `./deploy` → bao の token → VS Code Server の `bin` → 窓のタイトル)、bao と `./deploy` の 範囲が `current + 1` から `new` までであること、停止を呼ばないこと (`tests/commands/test_container_scale_order.py`)。起動できなかったインスタンスがあるときの 後処理の対象と出力(`tests/commands/test_container_post_start.py`) diff --git a/docs/specifications/post-start.md b/docs/specifications/post-start.md index 9f37fd1f..a41372a3 100644 --- a/docs/specifications/post-start.md +++ b/docs/specifications/post-start.md @@ -3,7 +3,7 @@ ## 概要 `devbase up` と `devbase scale` は、コンテナを起動して entrypoint の完了を待った後、ホストの側から -決まった処理を行う。不足リポジトリの報告・`./deploy` の実行・token の配布・窓のタイトルの設定の 4 つを +決まった処理を行う。不足リポジトリの報告・`./deploy` の実行・token の配布・VS Code Server の `bin` の所有者の直し・窓のタイトルの設定の 5 つを インスタンスごとに同じ順で行い、`up` だけがその後でエディタを自動で開く。 処理を行うインスタンスの集合(後処理の対象)は、`up` / `scale` の本体が起動の結果から決め、1 つの段 @@ -19,7 +19,7 @@ 開発サービス名は [Compose の構成(`compose`)](../glossary.md#compose-の構成compose)、窓と動いているインスタンスは [エディタで開く(`editor`)](../glossary.md#エディタで開くeditor) の語である。 -この仕様の変更は `post-start` のコンテキストで閉じる。`editor`(窓のタイトルの設定・動いているインスタンスの調べ方)、 +この仕様の変更は `post-start` のコンテキストで閉じる。`editor`(窓のタイトルの設定・VS Code Server の `bin` の所有者の直し・動いているインスタンスの調べ方)、 `secret`(token の発行とコンテナへの書き込み)、`compose`(開発サービス名と生成物 `.docker-compose.scale.yml`)には 順応者として接し、それらのモデルを変えない。 @@ -30,7 +30,7 @@ | 起動の待ち | `lib/devbase/utils/docker.py` の `wait_for_containers_ready` | 1 から台数までの番号を、巡ごとに状態と完了の印で確かめる。全部が起動できたら `True` を返し、起動できなかった番号があれば、残りを制限まで待った後に `ContainerStartupError` を投げる | | 起動の結果の型 | 同ファイルの `StartupFailure`・`ContainerStartupError` | 起動できた番号と、起動できなかった番号ごとの理由・ログの末尾を運び、利用者へ出す文言を組み立てる | | 起動の後の処理の段 | `lib/devbase/commands/container.py` の `_run_post_start` | 後処理の対象を受け、インスタンスごとの処理を決まった順に呼ぶ。対象を自分で決めず、番号を足しも引きもしない。`./deploy` を走らせるかを `run_deploy` で受ける | -| 個々の処理 | 同ファイルの `_report_missing_repos`・`_run_deploy_script_for_instances`・`_push_bao_token`・`_apply_window_titles` | キーワード引数 `indices`(対象の番号の並び)を受ける。渡されなければ番号 1 から台数まで(token の配布は開始の番号から)に行う | +| 個々の処理 | 同ファイルの `_report_missing_repos`・`_run_deploy_script_for_instances`・`_push_bao_token`・`_prepare_vscode_server_bin`・`_apply_window_titles` | キーワード引数 `indices`(対象の番号の並び)を受ける。渡されなければ番号 1 から台数まで(token の配布は開始の番号から)に行う | | エディタの自動オープン | 同ファイルの `_maybe_open_editor` | キーワード引数 `started`(起動できた番号の並び)を受け、開く番号がそこに無ければ警告して開かない | | `up` の本体 | 同ファイルの `cmd_up` | 起動の待ちの例外を受けて後処理の対象を決め、段とエディタの自動オープンを呼ぶ | | `scale` の本体 | 同ファイルの `cmd_scale` | 同じく例外を受け、増やしたインスタンスのうち起動できたものを対象に段を呼ぶ | @@ -147,7 +147,7 @@ Container startup failed: 2 of 3 instances did not become ready. 後処理の対象 `indices` へ次の順で処理を行う。対象が空なら何も行わない(token も発行しない)。 `deployment` は起動した構成(`_ComposeDeployment`。`project_name`・`scale`・`dev_service_name`・`compose_file` の組)で、 `up` / `scale` / `project post-start` が組んで渡す。4 つの値は 3 つの呼び出し元で常に組で渡り、個々の処理 -(不足リポジトリの報告・token の配布・窓のタイトル)も同じ組を受けるため、凍結した 1 つの値にまとめる。 +(不足リポジトリの報告・token の配布・VS Code Server の `bin` の所有者の直し・窓のタイトル)も同じ組を受けるため、凍結した 1 つの値にまとめる。 `indices`・`config`・`run_deploy` は呼び出しごとに意味が違い(`config` は構成ではなくプロジェクトの設定)、組に入れない。 段は組をほどき、個々の処理へは今までと同じ引数で渡す。 @@ -156,7 +156,8 @@ Container startup failed: 2 of 3 instances did not become ready. | 1 | 不足リポジトリの報告(`Repositories missing in /work of <コンテナ>`) | 常に | 問い合わせに失敗すれば何も出さずに続ける | | 2 | `./deploy` の実行(番号ごとに `DEVBASE_INSTANCE_INDEX` を渡す) | `run_deploy` が真で、`./deploy` があるとき | 非 0 は警告して次の番号へ続ける | | 3 | token の配布(`~/.vault-token`) | backend が `openbao` のとき | 取れない・書けないときは警告して続ける | -| 4 | 窓のタイトルの設定 | `DEVBASE_WINDOW_TITLE` で止めていないとき | 書けないときは debug のログに出して続ける | +| 4 | VS Code Server の `bin` の所有者の直し(`docker exec -u 0` で `~/.vscode-server/bin` の所有者を `~/.vscode-server` の所有者へ揃える。再帰しない。#463) | 常に(揃っていれば何も変えない) | 直せないときは debug のログに出して続ける | +| 5 | 窓のタイトルの設定 | `DEVBASE_WINDOW_TITLE` で止めていないとき | 書けないときは debug のログに出して続ける | 個々の処理の失敗は終了コードへ反映しない。段は例外を投げない。`up` と `scale` は同じ順で処理し、やり直しは 同じ順から `./deploy` を除く。`scale` の順を `up` に合わせたのは、`up` の振る舞いを変えないためである(同じ @@ -184,7 +185,7 @@ sequenceDiagram end C->>C: 後処理の対象を決める C->>P: 対象の番号 - P->>P: 不足リポジトリの報告 → ./deploy → token の配布 → 窓のタイトルの設定 + P->>P: 不足リポジトリの報告 → ./deploy → token の配布 → VS Code Server の bin の所有者 → 窓のタイトルの設定 C->>C: エディタの自動オープン(up だけ。開く番号が起動できたとき) alt 起動できなかったものがある C-->>U: 起動できなかったときの報告・終了コード 1 @@ -253,7 +254,7 @@ Deploy failed: Container startup failed: 1 of 2 instances did not become ready. | --- | --- | | 形 | `devbase project post-start [name] [--context NAME]` | | 入力 | `name`: 対象のプロジェクト(省略時は実行時のディレクトリ)。`--context`: 接続先(ほかの `project` のサブコマンドと同じ) | -| 処理 | 後処理の対象へ、不足リポジトリの報告 → token の配布 → 窓のタイトルの設定。`./deploy` は走らせず、エディタも開かない | +| 処理 | 後処理の対象へ、不足リポジトリの報告 → token の配布 → VS Code Server の `bin` の所有者の直し → 窓のタイトルの設定。`./deploy` は走らせず、エディタも開かない | | 出力 | 個々の処理の出力、処理しなかった番号の警告、完了の行 `=== Post-start completed ===` | | 前方一致 | `project po` で解決する。`project p`(`ps`)と `project pr`(`profile`)は変わらない | @@ -285,7 +286,7 @@ Deploy failed: Container startup failed: 1 of 2 instances did not become ready. `re` で始まる名前は `project r` / `project re`(`rebuild`)の前方一致を曖昧にするため使わない。トップレベルの 短縮形・非推奨の `container` グループ・TUI の操作の一覧には置かない - やり直しは `./deploy` を走らせない。役目を devbase が自分で行う処理(不足リポジトリの報告・token の配布・ - 窓のタイトルの設定)の補いに限る。起動できたインスタンスの `./deploy` は `up` / `scale` の中で済んでいる + VS Code Server の `bin` の所有者の直し・窓のタイトルの設定)の補いに限る。起動できたインスタンスの `./deploy` は `up` / `scale` の中で済んでいる - やり直しは完了の印が置かれるまで待たない。起動の途中で止まったインスタンスが 1 つあるだけで、やり直しが 終わらなくなるためである。起動が終わってから打ち直せば足りる @@ -297,7 +298,8 @@ Deploy failed: Container startup failed: 1 of 2 instances did not become ready. - 待ちの巡の数は制限(60)を超えない。決まったインスタンスへはその後の巡で問い合わせず、1 巡の docker の 呼び出しはインスタンスあたり 2 回まで(状態と完了の印)である - インスタンスごとの処理は、後処理の対象の番号にだけ行う -- インスタンスごとの処理の順は、不足リポジトリの報告 → `./deploy` → token の配布 → 窓のタイトルの設定で、 +- インスタンスごとの処理の順は、不足リポジトリの報告 → `./deploy` → token の配布 → VS Code Server の `bin` の + 所有者の直し → 窓のタイトルの設定で、 `up` と `scale` で同じである。やり直しは同じ順から `./deploy` を除く - 後処理の対象が空なら、処理を 1 つも行わない - 起動できなかったインスタンスが 1 つでもあれば、`up` / `scale` は終了コード 1 で終わり、完了の行を出さず、 @@ -332,7 +334,7 @@ Deploy failed: Container startup failed: 1 of 2 instances did not become ready. で設定しないこと、増やしたインスタンスの不足リポジトリを `up` と同じ文言で報告し、揃っていれば何も出さないこと、 処理の順が `up` と同じであること(`tests/commands/test_container_post_start.py`・`tests/commands/test_container_scale_order.py`) - 1 台が起動できなかった `up` / `scale`(`up` の `dev-2`、`scale` の増やした `dev-3`、`scale` の既存の `dev-1`)が、 - 起動できた後処理の対象へ 4 つの処理を行い、1 で終わり、`Deploy failed` / `Scale failed` と全部の名前、 + 起動できた後処理の対象へ 5 つの処理を行い、1 で終わり、`Deploy failed` / `Scale failed` と全部の名前、 `devbase project post-start <プロジェクト名>` だけの行、`docker start` では行われない旨の文を出し、完了の行を 出さないこと。1 行目の処理先の名前が後処理の対象と一致し、対象が空なら処理を 1 つも呼ばないこと。起動の待ちより 後に `down`・`stop`・`rm` を呼ばないこと(`tests/commands/test_container_post_start.py`) diff --git a/docs/user/troubleshooting.md b/docs/user/troubleshooting.md index d3260d15..572dca54 100644 --- a/docs/user/troubleshooting.md +++ b/docs/user/troubleshooting.md @@ -434,19 +434,20 @@ docker volume rm **症状:** -`devbase_vscode_*` のボリュームが増え、`docker system df` のボリューム使用量が大きい。 -使わなくなったプロジェクトの分も残っている。 +`devbase_vscode_*` のボリュームが残り、`docker system df` のボリューム使用量が大きい。 +`devbase up` の最後に「使わなくなった VS Code Server のボリュームが N 本あります」と出る。 **原因:** -`~/.vscode-server`(VS Code Server 本体・拡張機能)はコンテナ 1 つにつき 1 本の -`devbase_vscode_{project}_{index}` へ永続化されます。attach したコンテナ 1 つあたり -**約 1.6GB** で、プロジェクトを削除してもボリュームは自動では消えません。 +以前の devbase は `~/.vscode-server` を丸ごと、コンテナ 1 つにつき 1 本の +`devbase_vscode_{project}_{index}` に置いていました(1 本で数 GB を使うことがあります)。 +今の devbase は VS Code Server の本体だけを全コンテナ共通の 1 本 `devbase_vscode-server-bin` に置き、 +以前のボリュームを付けません。以前のボリュームは自動では消えません。 **解決策:** ```bash -# VS Code Server ボリュームの一覧 +# 以前の VS Code Server ボリュームの一覧(共通の devbase_vscode-server-bin は出ません) docker volume ls --filter name=devbase_vscode_ --format '{{.Name}}' # サイズを確認 @@ -457,8 +458,11 @@ docker ps -a --filter volume=devbase_vscode__1 docker volume rm devbase_vscode__1 ``` -失われるのは VS Code Server のキャッシュだけで、次に attach すると再取得されます -(その 1 回だけダウンロードが走ります)。AI 設定や作業ファイルには影響しません。 +コンテナが付けているボリュームは `docker volume rm` が断ります。そのプロジェクトで `devbase up` を +打った後に消せます。AI 設定や作業ファイルには影響しません。 + +共通の `devbase_vscode-server-bin` は、コンテナの起動が新しい順に 5 版を残して古い版を片付けます +([VS Code Server の永続化](container-operations.md#vs-code-server-の永続化))。 > **Note:** `docker volume prune` は稼働中でないコンテナが参照するボリュームも消します。 > 対象を絞りたい場合は上のように名前を指定して削除してください。 @@ -472,11 +476,9 @@ docker volume rm devbase_vscode__1 **原因と解決策:** -1. **entrypoint が古い** — VS Code Server の永続化にはベースイメージの再ビルドが要ります。 - `devbase container build --no-cache` を実行してから `devbase up` してください。 -2. **VS Code 本体が更新された** — `bin/` は VS Code クライアントのビルドごとに +1. **VS Code 本体が更新された** — `bin/` は VS Code クライアントのビルドごとに 分かれます。クライアントを更新した直後の 1 回はダウンロードが走ります(仕様)。 -3. **ボリュームがマウントされていない** — 次で確認できます。 +2. **ボリュームがマウントされていない** — 次で確認できます。 ```bash docker inspect -dev-1 \ @@ -484,8 +486,12 @@ docker inspect -dev-1 \ | grep vscode ``` -`devbase_vscode__1 -> /home/ubuntu/.vscode-server` が出ない場合は、 +`devbase_vscode-server-bin -> /home/ubuntu/.vscode-server/bin` が出ない場合は、 `.docker-compose.scale.yml` が古いままです。`devbase up` で再生成されます。 +プロジェクトが `compose.yml` で `~/.vscode-server` か `~/.vscode-server/bin` を自分でマウントしている +場合は、devbase は足しません。 + +拡張機能は `devbase up` の作り直しで消え、作り直しの後の最初の attach で入り直します(仕様)。 ## 問題が解決しない場合 From 12ed6c372a1b4162c3f9d337d5a05ecb0a510c09 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 14 Oct 2026 10:02:29 +0900 Subject: [PATCH 06/15] =?UTF-8?q?feat(#454):=20env=20=E3=81=AE=E8=A3=9C?= =?UTF-8?q?=E5=AE=8C=E3=81=AE=E3=82=B5=E3=83=96=E3=82=B3=E3=83=9E=E3=83=B3?= =?UTF-8?q?=E3=83=89=E3=81=AE=E4=B8=80=E8=A6=A7=E3=81=AB=20token=20?= =?UTF-8?q?=E3=81=A8=20backend=20=E3=82=92=E8=B6=B3=E3=81=97=E3=80=81?= =?UTF-8?q?=E4=B8=80=E8=87=B4=E3=83=86=E3=82=B9=E3=83=88=E3=81=A7=E6=AF=94?= =?UTF-8?q?=E3=81=B9=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- etc/_devbase | 2 ++ etc/devbase-completion.bash | 2 +- tests/cli/test_name_context_consistency.py | 14 ++++++++++---- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/etc/_devbase b/etc/_devbase index 179b12b8..937eb480 100644 --- a/etc/_devbase +++ b/etc/_devbase @@ -127,10 +127,12 @@ _devbase() { 'import:Import .env bundle (age decrypt + merge)' 'keygen:Generate the devbase age key used by the secret store' 'exec:Run a command with the decrypted secrets in its environment' + 'token:Refresh the OpenBao token in running dev containers' 'encrypt:Move plaintext settings into the encrypted store' 'decrypt:Move encrypted settings back to plaintext' 'rekey:Change who can decrypt the secrets and re-encrypt them' 'doctor:Check for leftover plaintext secrets and ignore-rule gaps' + 'backend:Choose where secrets are stored (age / plaintext / openbao)' ) plugin_subcommands=( diff --git a/etc/devbase-completion.bash b/etc/devbase-completion.bash index 974091b4..fa6a82cf 100644 --- a/etc/devbase-completion.bash +++ b/etc/devbase-completion.bash @@ -49,7 +49,7 @@ _devbase_completions() { # project / container は同じサブコマンド群 (container は非推奨だが補完は維持)。 local project_subcommands="up down ps login logs scale build rebuild list profile open post-start" local container_subcommands="up down ps login logs scale build rebuild profile open" - local env_subcommands="init sync list set get delete edit project export import keygen exec encrypt decrypt rekey doctor" + local env_subcommands="init sync list set get delete edit project export import keygen exec token encrypt decrypt rekey doctor backend" local plugin_subcommands="list install uninstall update info sync repo" local repo_subcommands="add remove list refresh" local snapshot_subcommands="create list restore copy delete rotate" diff --git a/tests/cli/test_name_context_consistency.py b/tests/cli/test_name_context_consistency.py index 8f56c7ec..5d98286d 100644 --- a/tests/cli/test_name_context_consistency.py +++ b/tests/cli/test_name_context_consistency.py @@ -291,25 +291,31 @@ def test_zsh_completion_offers_context(): # --------------------------------------------------------------------------- -# 補完: project / container のサブコマンドの一覧 +# 補完: project / container / env のサブコマンドの一覧 # --------------------------------------------------------------------------- +# サブコマンドの全部を比べるグループ(`[name]`・`--context` を取るかによらない) +_ALL_SUBCOMMAND_GROUPS = ("env",) + + def _group_subcommands(group): + if group in _ALL_SUBCOMMAND_GROUPS: + return {p[1] for p, _ in _walk(cli._create_parser()) if len(p) == 2 and _canonical(p)[0] == group} # 入れ子の `profile` は親の `profile` として数える return {p[1] for p in NAME_PATHS | CONTEXT_PATHS if len(p) >= 2 and p[0] == group} -@pytest.mark.parametrize("group", ["project", "container"]) +@pytest.mark.parametrize("group", ["project", "container", "env"]) def test_bash_completion_lists_subcommands(group, fake_root): expected = _group_subcommands(group) - assert expected, f"argparse の {group} に `[name]` / `--context` を取るサブコマンドが無い" + assert expected, f"argparse の {group} に比べるサブコマンドが無い" offered = _bash_complete((group,), fake_root) missing = expected - offered assert not missing, (f"etc/devbase-completion.bash の {group} のサブコマンドの一覧に無い: " f"{sorted(missing)}") -@pytest.mark.parametrize("group", ["project", "container"]) +@pytest.mark.parametrize("group", ["project", "container", "env"]) def test_zsh_completion_lists_subcommands(group): expected = _group_subcommands(group) m = re.search(rf"^\s*{group}_subcommands=\((.*?)^\s*\)", ZSH_COMPLETION.read_text(), re.MULTILINE | re.DOTALL) From 7dd8c769442221573d6d5107ac361651daa52b3f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 14 Oct 2026 10:03:31 +0900 Subject: [PATCH 07/15] =?UTF-8?q?docs(#454):=20=E4=B8=80=E8=87=B4=E3=83=86?= =?UTF-8?q?=E3=82=B9=E3=83=88=E3=81=8C=20env=20=E3=81=AE=E3=82=B5=E3=83=96?= =?UTF-8?q?=E3=82=B3=E3=83=9E=E3=83=B3=E3=83=89=E3=81=AE=E4=B8=80=E8=A6=A7?= =?UTF-8?q?=E3=82=92=E6=AF=94=E3=81=B9=E3=82=8B=E3=81=93=E3=81=A8=E3=82=92?= =?UTF-8?q?=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98=E3=81=A8=20CHANGELOG=20?= =?UTF-8?q?=E3=81=AB=E6=9B=B8=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ docs/specifications/cli-argument-resolution.md | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d88dd25..03a23f90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,9 @@ `~/.vscode-server/bin` を開発ユーザーが書ける形に揃えます。 ### Fixed +- **`devbase env ` の候補に `token` と `backend` が出るようにしました(#454)。** bash と zsh の補完の + サブコマンドの一覧に 2 つが無く、名前を手で打たないと使えませんでした。一致テストが `env` のサブコマンドの + 全部を bash と zsh の一覧と比べるようになり、`env` のサブコマンドを足したときの取り残しで落ちます。 - **接続先 URL が手元のサーバ(`http://127.0.0.1:8200` など `http` の loopback)のとき、`devbase up` したコンテナの中の `bao` がサーバへ届くようにしました(#295)。** これまではコンテナへ渡す `BAO_ADDR` がそのまま `127.0.0.1` を指し、 コンテナ自身へつながっていました。Docker が手元の端末のものなら、コンテナの `BAO_ADDR` のホストを diff --git a/docs/specifications/cli-argument-resolution.md b/docs/specifications/cli-argument-resolution.md index 197bfc8c..846e0a42 100644 --- a/docs/specifications/cli-argument-resolution.md +++ b/docs/specifications/cli-argument-resolution.md @@ -128,8 +128,8 @@ graph TD | --- | --- | | `bin/devbase` の `_PROJECT_NAME_SUBCOMMANDS` | `project` の直下で `[name]` を取る parser の名前の集合に等しい | | `bin/devbase` の `_NAME_RESOLVABLE_SHORTCUTS` | `cli.SHORTCUTS` のキーと `build` の和に等しい。あわせて、トップレベルで `[name]` を取る parser がすべて `cli.SHORTCUTS` にあること | -| `etc/devbase-completion.bash` | 一時ディレクトリの `projects/` を `DEVBASE_ROOT` にして補完を実行し、`[name]` の集合から補完の例外を引いたすべての道の名前の位置でプロジェクト名が候補に出る。`--context` の集合のすべての道(`container` の道は `ct` で始めても)で `devbase <道> -` の候補に `--context` が出る。`[name]` の集合から補完の例外を引いた道では、`devbase <道> web -` の候補にも `--context` が出る。`--context` を取らない末端の道では、`devbase <道> -` の候補に `--context` が出ない。`project` / `container` の直下で `[name]` か `--context` を取るサブコマンド(入れ子は親の `profile` として数える)が、そのグループのサブコマンドの候補に含まれる | -| `etc/_devbase` | zsh を起動せず内容を読む。`case "$words[2]"` から字下げの深さで道の分岐(`up\|down)` のような `\|` 区切りの見出し)を辿り、`[name]` の集合から補完の例外を引いた道の本体に `_devbase_project_names` がある。`--context` の集合のすべての道の本体に、値の補完を持たない `'--context[...]:context:'`(`:context:` の後ろに補完の関数を置かない形)がある。`project_subcommands` / `container_subcommands` の一覧に bash と同じサブコマンドが含まれる | +| `etc/devbase-completion.bash` | 一時ディレクトリの `projects/` を `DEVBASE_ROOT` にして補完を実行し、`[name]` の集合から補完の例外を引いたすべての道の名前の位置でプロジェクト名が候補に出る。`--context` の集合のすべての道(`container` の道は `ct` で始めても)で `devbase <道> -` の候補に `--context` が出る。`[name]` の集合から補完の例外を引いた道では、`devbase <道> web -` の候補にも `--context` が出る。`--context` を取らない末端の道では、`devbase <道> -` の候補に `--context` が出ない。`project` / `container` の直下で `[name]` か `--context` を取るサブコマンド(入れ子は親の `profile` として数える)が、そのグループのサブコマンドの候補に含まれる。`env` の直下のサブコマンドは、`[name]`・`--context` を取るかによらず全部が `env` のサブコマンドの候補に含まれる | +| `etc/_devbase` | zsh を起動せず内容を読む。`case "$words[2]"` から字下げの深さで道の分岐(`up\|down)` のような `\|` 区切りの見出し)を辿り、`[name]` の集合から補完の例外を引いた道の本体に `_devbase_project_names` がある。`--context` の集合のすべての道の本体に、値の補完を持たない `'--context[...]:context:'`(`:context:` の後ろに補完の関数を置かない形)がある。`project_subcommands` / `container_subcommands` / `env_subcommands` の一覧に bash と同じサブコマンドが含まれる | | この節の 2 つの表 | 見出し `` #### `[name]` を受け付けるサブコマンド `` と `` #### `--context` を受け付けるサブコマンド `` の直後の表を読む。1 列目は `devbase <グループ...> ` の形で、間の語が道の頭になる。2 列目にバッククォートで並べた語を `` に入れる。表は `[name]` の集合・`--context` の集合にそれぞれ等しい | 補完の例外は `project profile up` / `down` / `list` の 3 つで、理由は「1 つ目の位置引数は、値が 1 個なら From da42e5cab2504b287fc0de33bdc1ada4815f1d79 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 14 Oct 2026 10:13:05 +0900 Subject: [PATCH 08/15] =?UTF-8?q?fix(#374):=20=E8=B5=B7=E5=8B=95=E3=81=AE?= =?UTF-8?q?=E5=BE=85=E3=81=A1=E3=81=AE=E6=99=82=E9=96=93=E5=88=87=E3=82=8C?= =?UTF-8?q?=E3=81=AE=E6=96=87=E8=A8=80=E3=81=AB=E5=B7=A1=E3=81=AE=E5=9B=9E?= =?UTF-8?q?=E6=95=B0=E3=81=A8=E5=AE=9F=E9=9A=9B=E3=81=AE=E7=B5=8C=E9=81=8E?= =?UTF-8?q?=E3=81=AE=E7=A7=92=E6=95=B0=E3=82=92=E5=87=BA=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 数え方(1 秒休む巡の回数・上限 60)は変えない。StartupFailure に elapsed(既定 0)を足し、 単調な時計で測った経過を整数の秒で入れる。時計を差し替えたテストを足した。 Co-Authored-By: Claude Opus 5.5 --- lib/devbase/utils/docker.py | 17 +++++++--- tests/utils/test_wait_for_containers.py | 43 ++++++++++++++++++++++--- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/lib/devbase/utils/docker.py b/lib/devbase/utils/docker.py index 5385fea6..bbbf2f97 100644 --- a/lib/devbase/utils/docker.py +++ b/lib/devbase/utils/docker.py @@ -166,7 +166,9 @@ class StartupFailure: """起動の待ちで起動できなかった 1 インスタンス。 ``reason`` は ``exited``・``not_found``・``timeout`` のどれかで、``logs`` は - ``exited`` のときだけ中身を持つ (ログの末尾。取れなければ空)。 + ``exited`` のときだけ中身を持つ (ログの末尾。取れなければ空)。``timeout`` は巡の回数の + 制限で、``elapsed`` は待ちを始めてから時間切れが決まるまでに実際にかかった秒数 (整数)。 + どちらも ``timeout`` のときだけ意味を持つ。 """ index: int @@ -174,11 +176,13 @@ class StartupFailure: reason: str logs: str = '' timeout: int = 0 + elapsed: int = 0 def describe(self) -> str: """利用者へ出す 1 インスタンス分の文言 (ログの末尾を含む)。""" if self.reason == 'timeout': - text = f"timeout ({self.timeout}s) waiting for the entrypoint to complete" + text = (f"timeout after {self.timeout} rounds ({self.elapsed}s elapsed) " + "waiting for the entrypoint to complete") else: text = _STARTUP_REASON_TEXT.get(self.reason, self.reason) lines = [f" - {self.service}: {text}"] @@ -247,7 +251,9 @@ def wait_for_containers_ready( 1 から ``scale`` までの番号を巡ごとに確かめる。1 巡では、まだ決まっていない番号ごとに 状態を 1 回、動いていれば完了の印を 1 回確かめる。決まった番号 (起動できた・終了した・ 見つからない) へは、その後の巡で問い合わせない。1 台が起動できなくても、残りの番号は - 制限まで待つ。巡の数は ``timeout`` を超えない (1 巡ごとに 1 秒休む)。 + 制限まで待つ。巡の数は ``timeout`` を超えない (1 巡ごとに 1 秒休む)。制限は巡の回数で + 数え、docker の呼び出しにかかる時間は含まない。時間切れの文言には、巡の回数と、単調な + 時計で測った実際の経過の秒数を出す。 Args: container_prefix: Container name prefix (e.g., "dev") @@ -268,6 +274,7 @@ def wait_for_containers_ready( pending = list(range(1, scale + 1)) ready: List[int] = [] failures: List[StartupFailure] = [] + started = time.monotonic() rounds = 0 while pending and rounds < timeout: still_pending = [] @@ -293,8 +300,10 @@ def wait_for_containers_ready( time.sleep(1) rounds += 1 + elapsed = int(time.monotonic() - started) failures.extend( - StartupFailure(i, f"{container_prefix}-{i}", 'timeout', timeout=timeout) + StartupFailure(i, f"{container_prefix}-{i}", 'timeout', timeout=timeout, + elapsed=elapsed) for i in pending) if failures: raise ContainerStartupError(ready, failures) diff --git a/tests/utils/test_wait_for_containers.py b/tests/utils/test_wait_for_containers.py index 69eeac8a..72cbc97c 100644 --- a/tests/utils/test_wait_for_containers.py +++ b/tests/utils/test_wait_for_containers.py @@ -3,6 +3,7 @@ 1 台が起動できなくても残りを制限まで待ち、起動できた番号と、起動できなかった番号ごとの 理由 (終了した・見つからない・時間切れ) を例外で返す。実 docker には触れない。 ``docker_compose`` を差し替え、巡 (``time.sleep`` の回数) ごとの状態を台本で返す。 +時計 (``time.monotonic``) も差し替え、休みと docker の呼び出しにかかる秒数を台本で進める。 """ from __future__ import annotations @@ -22,21 +23,28 @@ class Script: ``states[n](round)`` は ``(状態, 完了の印があるか)``。状態が ``None`` なら見つからない。 """ - def __init__(self, states, logs=None): + def __init__(self, states, logs=None, call_seconds=0.0): self.states = states self.logs = logs or {} + self.call_seconds = call_seconds + self.clock = 1000.0 self.round = 0 self.calls: list = [] - def sleep(self, _seconds): + def sleep(self, seconds): + self.clock += seconds self.round += 1 + def monotonic(self): + return self.clock + def __call__(self, command, compose_file=None, check=True, capture_output=False, silent_error=False): command = list(command) service = command[-1] if command[0] in ('ps', 'logs') else command[2] index = int(service.rsplit('-', 1)[1]) self.calls.append((self.round, command[0], index)) + self.clock += self.call_seconds state, ready = self.states[index](self.round) if command[0] == 'ps': stdout = '' if state is None else json.dumps({'State': state}) @@ -68,10 +76,11 @@ def missing(_r): @pytest.fixture def run(monkeypatch): - def _run(states, scale=None, timeout=60, logs=None): - script = Script(states, logs) + def _run(states, scale=None, timeout=60, logs=None, call_seconds=0.0): + script = Script(states, logs, call_seconds) monkeypatch.setattr(docker, 'docker_compose', script) monkeypatch.setattr(docker.time, 'sleep', script.sleep) + monkeypatch.setattr(docker.time, 'monotonic', script.monotonic) scale = scale or len(states) try: result = docker.wait_for_containers_ready('dev', scale, timeout=timeout) @@ -121,10 +130,34 @@ def test_timeout_names_the_instances_not_ready(run): assert error.ready == (1,) (failure,) = error.failures assert (failure.index, failure.reason) == (2, 'timeout') - assert ' - dev-2: timeout (5s) waiting for the entrypoint to complete' in str(error) + assert (' - dev-2: timeout after 5 rounds (5s elapsed) ' + 'waiting for the entrypoint to complete') in str(error) assert script.round == 5 +def test_timeout_reports_rounds_and_real_elapsed_seconds(run): + """#374: 時間切れの文言は巡の回数と、docker の呼び出しを含む実際の経過の秒数 (整数) を出す""" + # 1 巡に ps と exec が 1 回ずつ、各 0.65 秒。60 巡で 60 + 60 * 1.3 = 138 秒 + script, error = run({1: never_ready}, call_seconds=0.65) + + assert script.round == 60 + (failure,) = error.failures + assert (failure.timeout, failure.elapsed) == (60, 138) + assert (' - dev-1: timeout after 60 rounds (138s elapsed) ' + 'waiting for the entrypoint to complete') in str(error) + assert 'timeout (60s)' not in str(error) + + +def test_elapsed_seconds_are_truncated_to_an_integer(run): + """#374: 経過の秒数は整数の秒で出す (端数は切り捨てる)""" + # 3 巡 + 3 巡 * (ps + exec) * 0.3 = 3 + 1.8 = 4.8 秒 + _script, error = run({1: never_ready}, timeout=3, call_seconds=0.3) + + (failure,) = error.failures + assert failure.elapsed == 4 + assert 'timeout after 3 rounds (4s elapsed)' in str(error) + + def test_missing_container_is_not_found(run): _script, error = run({1: missing, 2: ready_at(0)}) From f18b449ff9680aba5ac4ebed845361ae4e27f697 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 14 Oct 2026 10:13:05 +0900 Subject: [PATCH 09/15] =?UTF-8?q?docs(#374):=20=E6=99=82=E9=96=93=E5=88=87?= =?UTF-8?q?=E3=82=8C=E3=81=AE=E6=96=B0=E3=81=97=E3=81=84=E6=96=87=E8=A8=80?= =?UTF-8?q?=E3=82=92=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98=E3=81=A8=20CHANGE?= =?UTF-8?q?LOG=20=E3=81=AB=E6=9B=B8=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ docs/specifications/post-start.md | 14 +++++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03a23f90..fcf29be8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,10 @@ `~/.vscode-server/bin` を開発ユーザーが書ける形に揃えます。 ### Fixed +- **`devbase up` / `devbase scale` の起動の待ちの時間切れの文言が、巡の回数と実際に待った秒数を出すようにしました(#374)。** + 制限は 1 秒休む巡の回数(60 巡)で数え、docker の呼び出しにかかる時間を含まないため、実際に待つ時間は 60 秒より + 長くなります。これまでの `timeout (60s) ...` は 60 秒で止まったと読めました。今は + `timeout after 60 rounds (73s elapsed) waiting for the entrypoint to complete` の形で出ます。数え方と制限は変わりません。 - **`devbase env ` の候補に `token` と `backend` が出るようにしました(#454)。** bash と zsh の補完の サブコマンドの一覧に 2 つが無く、名前を手で打たないと使えませんでした。一致テストが `env` のサブコマンドの 全部を bash と zsh の一覧と比べるようになり、`env` のサブコマンドを足したときの取り残しで落ちます。 diff --git a/docs/specifications/post-start.md b/docs/specifications/post-start.md index a41372a3..3613a15f 100644 --- a/docs/specifications/post-start.md +++ b/docs/specifications/post-start.md @@ -85,6 +85,7 @@ classDiagram +reason: str +logs: str +timeout: int + +elapsed: int +describe() str } DockerError <|-- ContainerStartupError @@ -92,7 +93,8 @@ classDiagram ``` `ready` と `failures` は番号の昇順で、合わせると待った番号の全部になる。`reason` は `exited`・`not_found`・ -`timeout` のどれかで、`logs` は `exited` のときだけ中身を持つ。 +`timeout` のどれかで、`logs` は `exited` のときだけ中身を持つ。`timeout`(巡の回数の制限)と `elapsed`(待ちを +始めてから時間切れが決まるまでに実際にかかった秒数)は、`reason` が `timeout` のときだけ意味を持ち、既定は 0 である。 ### 起動の待ち @@ -118,12 +120,14 @@ stateDiagram-v2 | --- | --- | --- | | 終了した(`exited`) | 状態が `exited` か `dead` を含む | `exited unexpectedly` と、続く `Last logs:` とログの末尾(10 行まで。決まったときに 1 回だけ取る) | | 見つからない(`not_found`) | 状態を取れない | `container not found` | -| 時間切れ(`timeout`) | 巡の数が制限に達した時点で、完了の印を確かめられていない | `timeout (<制限>s) waiting for the entrypoint to complete` | +| 時間切れ(`timeout`) | 巡の数が制限に達した時点で、完了の印を確かめられていない | `timeout after <制限> rounds (<経過>s elapsed) waiting for the entrypoint to complete` | 1 台が起動できなくても、残りの番号は制限まで待つ。番号の小さい 1 台が落ちたときにも、後から起動を終える インスタンスへ後処理を届けるためである。そのため 1 台が落ちた `up` / `scale` が終わるまでの時間は、最長で 制限まで延びる。制限は 1 秒の休みを挟む巡の回数で数えるため、docker の呼び出しにかかる分だけ実際の時間は -60 秒を超える。 +60 秒を超える。時間切れの文言は、この食い違いを利用者が読めるように、巡の回数(`<制限>`)と、待ちを始めてから +時間切れが決まるまでに実際にかかった秒数(`<経過>`)の両方を出す。経過は単調な時計(`time.monotonic`)で測り、 +端数を切り捨てた整数の秒で出す。 起動できなかった番号が 1 つ以上あれば `ContainerStartupError` を投げる。引数と成功時の戻り値は変えず、例外は `DockerError`(`DevbaseError` の派生)なので、受けない呼び出し元は失敗のまま終わる。内訳を戻り値で返さないのは、 @@ -134,7 +138,7 @@ Container startup failed: 2 of 3 instances did not become ready. - dev-2: exited unexpectedly Last logs: <そのコンテナのログの末尾。10 行まで> - - dev-3: timeout (60s) waiting for the entrypoint to complete + - dev-3: timeout after 60 rounds (73s elapsed) waiting for the entrypoint to complete ``` 完了の印(`/tmp/entrypoint-ready`)は、entrypoint が起動のはじめに消し、最後に置く。`docker start` で起こし直した @@ -329,7 +333,7 @@ Deploy failed: Container startup failed: 1 of 2 instances did not become ready. - 起動の待ちが、最初の巡で終了した 2 台の名前・理由・ログの末尾を内訳と文言に入れること、制限まで完了の印が無い 番号を時間切れとして入れること、待った番号が起動できた側とできなかった側のどちらか一方にだけ現れること、 番号の小さい 1 台が終了しても後から完了した番号を起動できた側へ入れること、1 巡の docker の呼び出しの回数と - 巡の上限を満たすこと(`tests/utils/test_wait_for_containers.py`) + 巡の上限を満たすこと、時間切れの文言が巡の回数と、時計を差し替えて進めた実際の経過の秒数(整数)を出すこと(`tests/utils/test_wait_for_containers.py`) - `scale`(1 から 3)が `dev-2`・`dev-3` にだけ、実コンテナ名で始まる窓のタイトルを設定し、`DEVBASE_WINDOW_TITLE=off` で設定しないこと、増やしたインスタンスの不足リポジトリを `up` と同じ文言で報告し、揃っていれば何も出さないこと、 処理の順が `up` と同じであること(`tests/commands/test_container_post_start.py`・`tests/commands/test_container_scale_order.py`) From 9ad8c53f3780812fe8a169dde1de5b60cb276efb Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 14 Oct 2026 10:27:01 +0900 Subject: [PATCH 10/15] =?UTF-8?q?Test:=20=E7=8F=BE=E7=8A=B6=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E8=B6=B3=E3=81=99?= =?UTF-8?q?=20=E2=80=94=20lib/devbase/volume/manager.py#VolumeManager.ensu?= =?UTF-8?q?re=5Fvolumes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item-Id: I-001 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- tests/volume/test_manager_ensure_volumes.py | 176 ++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 tests/volume/test_manager_ensure_volumes.py diff --git a/tests/volume/test_manager_ensure_volumes.py b/tests/volume/test_manager_ensure_volumes.py new file mode 100644 index 00000000..c6ac3a01 --- /dev/null +++ b/tests/volume/test_manager_ensure_volumes.py @@ -0,0 +1,176 @@ +"""``VolumeManager.ensure_volumes`` の現状固定テスト + +共通のホーム・グループ・VS Code Server・作業ボリュームを「あれば使い、無ければ作る」 +振る舞いを、作る順・確かめる順・出す文言・作れなかったときの例外まで固定する。 +Docker は ``_volume_exists`` と ``create_volume`` を置き換えて触らない。 +""" + +from __future__ import annotations + +import logging + +import pytest + +from devbase.errors import DevbaseError, DockerError +from devbase.volume import manager + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + monkeypatch.delenv("COMPOSE_PROJECT_NAME", raising=False) + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "acme") + + +def _fake_docker(monkeypatch, existing=frozenset(), failing=frozenset()): + """確かめた名前と作った名前を呼ばれた順に記録する。""" + calls: list[tuple[str, str]] = [] + created: set[str] = set() + + def volume_exists(self, name): + calls.append(("exists", name)) + return name in existing or name in created + + def create_volume(self, name): + calls.append(("create", name)) + if name in failing: + return False + created.add(name) + return True + + monkeypatch.setattr(manager.VolumeManager, "_volume_exists", volume_exists) + monkeypatch.setattr(manager.VolumeManager, "create_volume", create_volume) + return calls + + +def _messages(caplog): + return [r.getMessage() for r in caplog.records + if r.name == manager.logger.name] + + +def test_creates_all_volumes_in_order_when_none_exist(monkeypatch, caplog): + calls = _fake_docker(monkeypatch) + + with caplog.at_level(logging.INFO, logger=manager.logger.name): + manager.VolumeManager("proj-a").ensure_volumes(2) + + assert calls == [ + ("exists", "devbase_home_ubuntu"), + ("create", "devbase_home_ubuntu"), + ("exists", "devbase_home_acme"), + ("create", "devbase_home_acme"), + ("exists", "devbase_vscode-server-bin"), + ("create", "devbase_vscode-server-bin"), + ("exists", "devbase_work_1"), + ("create", "devbase_work_1"), + ("exists", "devbase_work_2"), + ("create", "devbase_work_2"), + ] + assert _messages(caplog) == [ + "Ensuring volumes for 2 container(s)", + " Creating devbase_home_ubuntu (shared home)...", + " Creating devbase_home_acme (account group)...", + " Creating devbase_vscode-server-bin (VS Code Server)...", + " Creating devbase_work_1...", + " Creating devbase_work_2...", + ] + + +def test_existing_volumes_are_only_checked(monkeypatch, caplog): + existing = {"devbase_home_ubuntu", "devbase_home_team-b", + "devbase_vscode-server-bin", "devbase_work_1"} + calls = _fake_docker(monkeypatch, existing=existing) + + with caplog.at_level(logging.INFO, logger=manager.logger.name): + manager.VolumeManager("proj-a").ensure_volumes(1, group="team-b") + + assert calls == [ + ("exists", "devbase_home_ubuntu"), + ("exists", "devbase_home_team-b"), + ("exists", "devbase_vscode-server-bin"), + ("exists", "devbase_work_1"), + ] + assert _messages(caplog) == [ + "Ensuring volumes for 1 container(s)", + " devbase_home_ubuntu (shared home, exists)", + " devbase_home_team-b (account group, exists)", + " devbase_vscode-server-bin (VS Code Server, exists)", + " devbase_work_1 (exists)", + ] + + +def test_mixed_existing_and_missing(monkeypatch, caplog): + existing = {"devbase_home_acme", "devbase_work_2"} + calls = _fake_docker(monkeypatch, existing=existing) + + with caplog.at_level(logging.INFO, logger=manager.logger.name): + manager.VolumeManager("proj-a").ensure_volumes(3) + + assert [name for kind, name in calls if kind == "create"] == [ + "devbase_home_ubuntu", + "devbase_vscode-server-bin", + "devbase_work_1", + "devbase_work_3", + ] + assert _messages(caplog) == [ + "Ensuring volumes for 3 container(s)", + " Creating devbase_home_ubuntu (shared home)...", + " devbase_home_acme (account group, exists)", + " Creating devbase_vscode-server-bin (VS Code Server)...", + " Creating devbase_work_1...", + " devbase_work_2 (exists)", + " Creating devbase_work_3...", + ] + + +def test_scale_zero_creates_only_shared_volumes(monkeypatch): + calls = _fake_docker(monkeypatch) + + manager.VolumeManager("proj-a").ensure_volumes(0) + + assert [name for kind, name in calls if kind == "create"] == [ + "devbase_home_ubuntu", + "devbase_home_acme", + "devbase_vscode-server-bin", + ] + + +@pytest.mark.parametrize("failing, created_before", [ + ("devbase_home_ubuntu", []), + ("devbase_home_acme", ["devbase_home_ubuntu"]), + ("devbase_vscode-server-bin", ["devbase_home_ubuntu", "devbase_home_acme"]), + ("devbase_work_1", ["devbase_home_ubuntu", "devbase_home_acme", + "devbase_vscode-server-bin"]), + ("devbase_work_2", ["devbase_home_ubuntu", "devbase_home_acme", + "devbase_vscode-server-bin", "devbase_work_1"]), +]) +def test_failed_creation_raises_and_stops(monkeypatch, failing, created_before): + calls = _fake_docker(monkeypatch, failing={failing}) + + with pytest.raises(DockerError) as excinfo: + manager.VolumeManager("proj-a").ensure_volumes(3) + + assert str(excinfo.value) == f"Failed to create volume {failing}" + assert calls[-1] == ("create", failing) + assert [name for kind, name in calls[:-1] if kind == "create"] == created_before + + +def test_invalid_group_fails_before_touching_docker(monkeypatch): + calls = _fake_docker(monkeypatch) + + with pytest.raises(DevbaseError): + manager.VolumeManager("proj-a").ensure_volumes(1, group="bad/name") + + assert calls == [] + + +def test_module_function_delegates_with_group(monkeypatch): + calls = _fake_docker(monkeypatch) + + manager.ensure_volumes(1, project_name="proj-a", group="team-b") + + assert [name for kind, name in calls if kind == "create"] == [ + "devbase_home_ubuntu", + "devbase_home_team-b", + "devbase_vscode-server-bin", + "devbase_work_1", + ] From d4da43dbdd40714f681c111ff651d524cc661d3b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 14 Oct 2026 10:27:48 +0900 Subject: [PATCH 11/15] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20lib/devbase/volume/manager.py#VolumeManager.ensure=5Fvolumes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 存在確認→ログ→作成→失敗時 DockerError の 4 回の繰り返しを _ensure_volume(name, label=None) へ抽出した。ログの文言と呼ぶ順は変えない。 Item-Id: I-001 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- lib/devbase/volume/manager.py | 48 ++++++++++++++++------------------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/lib/devbase/volume/manager.py b/lib/devbase/volume/manager.py index 6a7d30f2..86250b95 100644 --- a/lib/devbase/volume/manager.py +++ b/lib/devbase/volume/manager.py @@ -253,41 +253,37 @@ def ensure_volumes(self, scale: int, group: Optional[str] = None) -> None: group_volume = get_group_volume(group) # Ensure shared home directory volume (once for all containers) - if self._volume_exists(HOME_UBUNTU_VOLUME): - logger.info(" %s (shared home, exists)", HOME_UBUNTU_VOLUME) - else: - logger.info(" Creating %s (shared home)...", HOME_UBUNTU_VOLUME) - if not self.create_volume(HOME_UBUNTU_VOLUME): - raise DockerError(f"Failed to create volume {HOME_UBUNTU_VOLUME}") + self._ensure_volume(HOME_UBUNTU_VOLUME, "shared home") # Ensure account group volume (shared by all containers of the group) - if self._volume_exists(group_volume): - logger.info(" %s (account group, exists)", group_volume) - else: - logger.info(" Creating %s (account group)...", group_volume) - if not self.create_volume(group_volume): - raise DockerError(f"Failed to create volume {group_volume}") + self._ensure_volume(group_volume, "account group") # Ensure VS Code Server binary volume (once for all containers, #463) - if self._volume_exists(VSCODE_SERVER_VOLUME): - logger.info(" %s (VS Code Server, exists)", VSCODE_SERVER_VOLUME) - else: - logger.info(" Creating %s (VS Code Server)...", VSCODE_SERVER_VOLUME) - if not self.create_volume(VSCODE_SERVER_VOLUME): - raise DockerError( - f"Failed to create volume {VSCODE_SERVER_VOLUME}") + self._ensure_volume(VSCODE_SERVER_VOLUME, "VS Code Server") # Create or verify work volumes for each instance for i in range(1, scale + 1): - work_volume = self.get_work_volume_for_index(i) + self._ensure_volume(self.get_work_volume_for_index(i)) - # Ensure work volume - if self._volume_exists(work_volume): - logger.info(" %s (exists)", work_volume) + def _ensure_volume(self, volume_name: str, + label: Optional[str] = None) -> None: + """ボリュームが無ければ作る。label はログで名前の後ろに添える種類 + + Raises: + DockerError: ボリュームを作れなかった場合 + """ + if self._volume_exists(volume_name): + if label: + logger.info(" %s (%s, exists)", volume_name, label) else: - logger.info(" Creating %s...", work_volume) - if not self.create_volume(work_volume): - raise DockerError(f"Failed to create volume {work_volume}") + logger.info(" %s (exists)", volume_name) + return + if label: + logger.info(" Creating %s (%s)...", volume_name, label) + else: + logger.info(" Creating %s...", volume_name) + if not self.create_volume(volume_name): + raise DockerError(f"Failed to create volume {volume_name}") def ensure_volumes(scale: int, project_name: str = None, From 6471593f85493422419fcf0ac90ce039a7b24832 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 14 Oct 2026 10:28:06 +0900 Subject: [PATCH 12/15] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20lib/devbase/utils/docker.py#wait=5Ffor=5Fcontainers=5Fready?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1 つの番号の状態の分類 (not_found / exited / ready / pending) を _classify_instance へ抽出し、巡のループは戻りを振り分けるだけにした。 Item-Id: I-002 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- lib/devbase/utils/docker.py | 43 ++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/lib/devbase/utils/docker.py b/lib/devbase/utils/docker.py index bbbf2f97..4d3f8f9f 100644 --- a/lib/devbase/utils/docker.py +++ b/lib/devbase/utils/docker.py @@ -238,6 +238,32 @@ def _is_ready(service_name: str, ready_file: str, compose_file: Optional[Path]) return True +def _classify_instance( + index: int, + container_prefix: str, + ready_file: str, + compose_file: Optional[Path], +) -> Tuple[str, Optional[StartupFailure]]: + """1 つの番号の状態を 1 回確かめて分類する。 + + Returns: + ``('ready' | 'exited' | 'not_found' | 'pending', 失敗)``。失敗は + ``exited`` と ``not_found`` のときだけ持ち、ほかは ``None`` + """ + service_name = f"{container_prefix}-{index}" + status = get_container_status(service_name, compose_file) + if status is None: + return 'not_found', StartupFailure(index, service_name, 'not_found') + status_lower = status.lower() + if 'exited' in status_lower or 'dead' in status_lower: + return 'exited', StartupFailure( + index, service_name, 'exited', + _container_logs_tail(service_name, compose_file)) + if _is_ready(service_name, ready_file, compose_file): + return 'ready', None + return 'pending', None + + def wait_for_containers_ready( container_prefix: str, scale: int, @@ -279,18 +305,11 @@ def wait_for_containers_ready( while pending and rounds < timeout: still_pending = [] for i in pending: - service_name = f"{container_prefix}-{i}" - status = get_container_status(service_name, compose_file) - if status is None: - failures.append(StartupFailure(i, service_name, 'not_found')) - continue - status_lower = status.lower() - if 'exited' in status_lower or 'dead' in status_lower: - failures.append(StartupFailure( - i, service_name, 'exited', - _container_logs_tail(service_name, compose_file))) - continue - if _is_ready(service_name, ready_file, compose_file): + state, failure = _classify_instance( + i, container_prefix, ready_file, compose_file) + if failure is not None: + failures.append(failure) + elif state == 'ready': ready.append(i) else: still_pending.append(i) From 17fe0a886946b078cc4dcb38b623a9e955fe902a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 14 Oct 2026 10:32:00 +0900 Subject: [PATCH 13/15] =?UTF-8?q?Refactor:=20centralize=5Fconfiguration=20?= =?UTF-8?q?=E2=80=94=20lib/devbase/editor/vscode=5Fserver.py#bin=5Ffix=5Fs?= =?UTF-8?q?cript?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VS Code Server の置き場のパス VSCODE_SERVER_TARGET・VSCODE_BIN_TARGET を VSCODE_SERVER_VOLUME と同じ volume/manager.py へ移し、compose.py と bin_fix_script の既定はそこから読む。home_root を渡す試験の経路は残した。 生成する手順の文字列は変わらない。 Item-Id: I-004 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- lib/devbase/editor/vscode_server.py | 17 +++++++++++------ lib/devbase/volume/compose.py | 5 +++-- lib/devbase/volume/manager.py | 5 +++++ 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/lib/devbase/editor/vscode_server.py b/lib/devbase/editor/vscode_server.py index 9356a9bd..f5a49e51 100644 --- a/lib/devbase/editor/vscode_server.py +++ b/lib/devbase/editor/vscode_server.py @@ -20,24 +20,29 @@ from typing import Callable, Optional from devbase.log import get_logger +from devbase.volume.manager import VSCODE_BIN_TARGET, VSCODE_SERVER_TARGET logger = get_logger(__name__) -#: 開発ユーザーのホーム。生成する compose のマウント先と同じ -HOME_ROOT = "/home/ubuntu" - # docker exec の待ち時間上限 (秒)。ローカルの docker への 1 コマンドなので即答する。 _EXEC_TIMEOUT = 10 -def bin_fix_script(home_root: str = HOME_ROOT) -> str: +def bin_fix_script(home_root: Optional[str] = None) -> str: """``bin`` の所有者を ``~/.vscode-server`` の所有者へ揃えるシェルの手順。 ``bin`` が無ければ何もしない。所有者は GNU の ``stat -c`` で読み、使えなければ BSD の ``stat -f`` で読む (コンテナは Linux。テストは手元でも走らせる)。 + パスの既定は compose のマウント先と同じ定数から組む。``home_root`` はテストが + 手元のディレクトリで試すためだけに渡す。 """ - server = shlex.quote(f"{home_root}/.vscode-server") - bin_dir = shlex.quote(f"{home_root}/.vscode-server/bin") + if home_root is None: + server_path, bin_path = VSCODE_SERVER_TARGET, VSCODE_BIN_TARGET + else: + server_path = f"{home_root}/.vscode-server" + bin_path = f"{home_root}/.vscode-server/bin" + server = shlex.quote(server_path) + bin_dir = shlex.quote(bin_path) return ( "owner_of() { stat -c %u:%g \"$1\" 2>/dev/null || stat -f %u:%g \"$1\"; }; " f"[ -d {bin_dir} ] || exit 0; " diff --git a/lib/devbase/volume/compose.py b/lib/devbase/volume/compose.py index 7942ad85..4817e3c9 100644 --- a/lib/devbase/volume/compose.py +++ b/lib/devbase/volume/compose.py @@ -15,6 +15,8 @@ from . import bind_mounts from .manager import ( + VSCODE_BIN_TARGET, + VSCODE_SERVER_TARGET, VSCODE_SERVER_VOLUME, get_ai_volume_for_index, get_group_volume, @@ -30,8 +32,7 @@ # VS Code Server の置き場 (PLAN36 / #463)。本体 (bin/) だけを全コンテナ共通の # named volume に置く。コンテナの書き込みレイヤに置くと devbase up のたびに # 215MB の再ダウンロードが走る。extensions/・data/ などは書き込みレイヤに残す。 -VSCODE_SERVER_TARGET = '/home/ubuntu/.vscode-server' -VSCODE_BIN_TARGET = '/home/ubuntu/.vscode-server/bin' +# パス (VSCODE_SERVER_TARGET・VSCODE_BIN_TARGET) は manager に置く。 def get_dev_service_name() -> str: diff --git a/lib/devbase/volume/manager.py b/lib/devbase/volume/manager.py index 86250b95..b8eec8be 100644 --- a/lib/devbase/volume/manager.py +++ b/lib/devbase/volume/manager.py @@ -44,6 +44,11 @@ # 名前は旧形式 (devbase_vscode__) の接頭辞 devbase_vscode_ と # 15 文字目の "-" で必ず違い、旧形式の案内が共通のボリュームを選ばない。 VSCODE_SERVER_VOLUME = "devbase_vscode-server-bin" +#: VS Code Server の置き場。生成する compose のマウント先と、起動の後の処理が +#: 所有者を直す先の両方がこれを使う +VSCODE_SERVER_TARGET = '/home/ubuntu/.vscode-server' +#: 本体の置き場。:data:`VSCODE_SERVER_VOLUME` を付ける先 +VSCODE_BIN_TARGET = '/home/ubuntu/.vscode-server/bin' #: #463 より前の、~/.vscode-server を丸ごと持つコンテナごとのボリュームの接頭辞 LEGACY_VSCODE_VOLUME_PREFIX = "devbase_vscode_" _LEGACY_VSCODE_VOLUME_RE = re.compile(r"^devbase_vscode_.+_[0-9]+$") From ef4a39f01aec35b6773ba5a1e30fe4d1a3dbb0c2 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 14 Oct 2026 10:33:18 +0900 Subject: [PATCH 14/15] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20lib/devbase/commands/container.py#=5Fprepare=5Fvscode=5Fserv?= =?UTF-8?q?er=5Fbin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _apply_window_titles と _prepare_vscode_server_bin が写していた 「番号ごとに名前を引き、1 台ずつ試して失敗を debug に落とす」ループを _for_each_dev_container へ抽出した。debug の文言は変わらない。 Item-Id: I-005 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- lib/devbase/commands/container.py | 44 +++++++++++++++++++------------ 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 3393eb85..12019e0e 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -1021,6 +1021,24 @@ def _push_bao_token(project_name: str, scale: int, dev_service_name: str, logger.info("bao の token を書きました: %s", ', '.join(written)) +def _for_each_dev_container(project_name: str, scale: int, dev_service_name: str, + compose_file, indices, action, what: str) -> None: + """対象の番号の dev コンテナごとに名前を引き、``action(コンテナ名)`` を呼ぶ。 + + 起動の後の付随処理の共通の回し方。1 台の失敗は ``what`` を添えて debug に + 記録して飛ばし、残りの番号と起動の後の処理を倒さない。 + """ + from devbase.editor import opener + + for index in _target_indices(indices, scale): + try: + container_name = opener.resolve_container_name( + dev_service_name, project_name, index, compose_file=compose_file) + action(container_name) + except Exception as e: # noqa: BLE001 - 付随処理で起動の後の処理を倒さない + logger.debug("%s をスキップしました (index=%d): %s", what, index, e) + + def _apply_window_titles(project_name: str, scale: int, dev_service_name: str, compose_file=None, *, indices=None) -> None: """各 dev コンテナの VS Code ウィンドウタイトルをコンテナ名始まりにする。 @@ -1037,18 +1055,15 @@ def _apply_window_titles(project_name: str, scale: int, dev_service_name: str, ``indices`` を渡すとその番号にだけ行う (起動の後の処理の対象)。渡さなければ 1 から ``scale``。 """ - from devbase.editor import opener, window_title + from devbase.editor import window_title template = window_title.resolve_template() if template is None: return - for index in _target_indices(indices, scale): - try: - container_name = opener.resolve_container_name( - dev_service_name, project_name, index, compose_file=compose_file) - window_title.apply_to_container(container_name, template=template) - except Exception as e: # noqa: BLE001 - 付随処理で up を倒さない - logger.debug("window.title の設定をスキップしました (index=%d): %s", index, e) + _for_each_dev_container( + project_name, scale, dev_service_name, compose_file, indices, + lambda name: window_title.apply_to_container(name, template=template), + "window.title の設定") def _prepare_vscode_server_bin(project_name: str, scale: int, dev_service_name: str, @@ -1063,16 +1078,11 @@ def _prepare_vscode_server_bin(project_name: str, scale: int, dev_service_name: 失敗しても起動の後の処理は倒さない (debug の記録にとどめる)。 ``indices`` を渡すとその番号にだけ行う。渡さなければ 1 から ``scale``。 """ - from devbase.editor import opener, vscode_server + from devbase.editor import vscode_server - for index in _target_indices(indices, scale): - try: - container_name = opener.resolve_container_name( - dev_service_name, project_name, index, compose_file=compose_file) - vscode_server.prepare_bin_dir(container_name) - except Exception as e: # noqa: BLE001 - 付随処理で起動の後の処理を倒さない - logger.debug("VS Code Server の bin の用意をスキップしました (index=%d): %s", - index, e) + _for_each_dev_container( + project_name, scale, dev_service_name, compose_file, indices, + vscode_server.prepare_bin_dir, "VS Code Server の bin の用意") def _report_legacy_vscode_volumes() -> None: From f042cc47f9ddeac931be9afa3ed3f8cf78336c9f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 14 Oct 2026 10:44:19 +0900 Subject: [PATCH 15/15] =?UTF-8?q?fix(vscode-server):=20=E7=89=87=E4=BB=98?= =?UTF-8?q?=E3=81=91=E3=81=A7=E3=83=AA=E3=83=B3=E3=82=AF=E3=82=92=E8=BE=BF?= =?UTF-8?q?=E3=82=89=E3=81=9A=E3=80=81=E6=97=A7=E3=83=9C=E3=83=AA=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E3=83=A0=E5=89=8A=E9=99=A4=E3=81=AE=E6=B6=88=E5=A4=B1?= =?UTF-8?q?=E3=82=92=E6=98=8E=E7=A4=BA=E3=81=99=E3=82=8B=20(#488)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - entrypoint の bin/ の片付けで、bin/ 自身・bin/ がシンボリックリンクなら版に数えず 入れ子の掃除もしない。リンク先の bin の外のものを消さないため - 旧形式の devbase_vscode_* ボリュームは ~/.vscode-server 全体を持つため、削除でリモートの 設定・ローカル履歴・拡張機能の状態も失われることを文書と devbase up の案内に書き、退避の手順を足す Co-Authored-By: Claude Opus 5.5 --- containers/base/entrypoint.sh | 10 ++++- docs/user/container-operations.md | 20 ++++++++-- lib/devbase/commands/container.py | 3 ++ .../test_entrypoint_vscode_server.py | 37 +++++++++++++++++++ 4 files changed, 65 insertions(+), 5 deletions(-) diff --git a/containers/base/entrypoint.sh b/containers/base/entrypoint.sh index 94e87bb7..b6dcebc6 100644 --- a/containers/base/entrypoint.sh +++ b/containers/base/entrypoint.sh @@ -662,16 +662,20 @@ devbase_remove_vscode_server_path() { # 2. 残った版の直下の入れ子の本体 (product.json を持つディレクトリ)。本体の同時の # 展開で、遅れた側の中身が bin//vscode-server-linux-/ に残ったもの # 3. 60 分より古い展開の途中のディレクトリ bin/_<数字> +# +# シンボリックリンクは辿らない。bin/ 自身・bin/・入れ子がリンクなら、版にも +# 片付けの対象にも数えない。リンク先の bin の外のものを消さないためである devbase_prune_vscode_server_bin() { local bin="$1" keep="${2:-$DEVBASE_VSCODE_SERVER_KEEP}" local entry name mtime listing="" count=0 kept=() nested - [ -d "$bin" ] || return 0 + [ -d "$bin" ] && [ ! -L "$bin" ] || return 0 for entry in "$bin"/*; do name="${entry##*/}" [[ "$name" =~ ^[0-9a-f]{40}$ ]] || continue - [ -d "$entry" ] && [ -f "$entry/product.json" ] || continue + [ -d "$entry" ] && [ ! -L "$entry" ] || continue + [ -f "$entry/product.json" ] || continue mtime=$(devbase_file_mtime "$entry/product.json") || continue listing+="${mtime} ${name}"$'\n' done @@ -687,6 +691,8 @@ devbase_prune_vscode_server_bin() { done < <(printf '%s' "$listing" | LC_ALL=C sort -k1,1nr -k2,2) for name in "${kept[@]}"; do + # 数えた後にリンクへ置き換わっていれば、リンク先を辿らない + [ ! -L "$bin" ] && [ ! -L "${bin}/${name}" ] || continue for nested in "${bin}/${name}"/*/; do nested="${nested%/}" [ -d "$nested" ] && [ ! -L "$nested" ] || continue diff --git a/docs/user/container-operations.md b/docs/user/container-operations.md index 2610a3b4..da168eda 100644 --- a/docs/user/container-operations.md +++ b/docs/user/container-operations.md @@ -279,14 +279,28 @@ docker volume inspect devbase_home_ubuntu docker volume ls --filter name=devbase_vscode_ --format '{{.Name}}' docker system df -v | grep devbase_vscode_ +# 要るデータを退避する(下の注意を参照。カレントディレクトリへ tar で書き出す) +docker run --rm -v devbase_vscode__1:/from:ro -v "$PWD":/to alpine \ + tar czf /to/vscode__1.tgz -C /from . + # 使用中のコンテナが無いことを確認してから削除する docker ps -a --filter volume=devbase_vscode__1 docker volume rm devbase_vscode__1 ``` -`devbase up` の案内に出る `docker volume rm …` の行をそのまま打っても構いません。削除しても -失われるのは以前の VS Code Server の本体と拡張機能だけで、設定(`~/.claude` などの AI 設定)には -影響しません。以前の devbase で作ったコンテナが付けているボリュームは `docker volume rm` が +> **Warning:** 以前の形式のボリュームは `~/.vscode-server` **全体**を持っています。削除すると、 +> 以前の VS Code Server の本体と拡張機能(`extensions/`)に加え、次のものも**戻せなくなります**。 +> +> - リモートの設定(`data/Machine/settings.json`) +> - ローカル履歴(`data/User/History/`) +> - 拡張機能が保存した状態(`data/User/globalStorage/`・`data/User/workspaceStorage/`) +> +> 要るものがあれば、削除の前に上の `tar` の行で退避してください。戻すときは、展開した中の要る +> ファイルだけを `docker cp` でコンテナの `~/.vscode-server/` の同じ場所へ写します。AI 設定 +> (`~/.claude` など)は別のボリュームにあるため、この削除では消えません。 + +`devbase up` の案内に出る `docker volume rm …` の行は、退避が要らないと確かめた後に打ってください。 +以前の devbase で作ったコンテナが付けているボリュームは `docker volume rm` が 拒否します。そのプロジェクトで `devbase up` を打ってコンテナを作り直した後に消してください。 共通の `devbase_vscode-server-bin` は消さなくて構いません。消したときは、次の attach で本体を diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 12019e0e..b8b35bc1 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -1100,6 +1100,9 @@ def _report_legacy_vscode_volumes() -> None: f"使わなくなった VS Code Server のボリュームが {len(legacy)} 本あります" "(VS Code Server の本体は共通のボリュームへ移りました):", *(f" {name}" for name in legacy), + "これらは以前の ~/.vscode-server 全体を持っています。消すと VS Code Server の本体と" + "拡張機能に加え、リモートの設定・ローカル履歴・拡張機能の保存した状態も戻せなくなります。" + "要るものは消す前に退避してください(docs/user/container-operations.md)", "不要なら次で消せます:", f" docker volume rm {' '.join(legacy)}", "コンテナが付けているボリュームは docker volume rm が断ります。" diff --git a/tests/containers/test_entrypoint_vscode_server.py b/tests/containers/test_entrypoint_vscode_server.py index e99594b7..3374dcb3 100644 --- a/tests/containers/test_entrypoint_vscode_server.py +++ b/tests/containers/test_entrypoint_vscode_server.py @@ -231,6 +231,43 @@ def test_nested_server_is_removed(home: Path, bin_dir: Path): assert (version / "node_modules").is_dir() +def test_symlinked_version_is_not_followed(home: Path, bin_dir: Path): + """bin/ が bin の外へのリンクなら、版に数えず、リンク先の中を消さない。""" + outside = home / "outside" + nested = outside / "vscode-server-linux-arm64" + (nested / "bin").mkdir(parents=True) + (nested / "product.json").write_text("{}") + (outside / "product.json").write_text("{}") + (bin_dir / commit(0)).symlink_to(outside) + for n in range(1, KEEP + 2): + make_version(bin_dir, n, age_days=n) + + prune(home) + + assert (nested / "product.json").is_file() + assert (outside / "product.json").is_file() + assert (bin_dir / commit(0)).is_symlink() + assert versions(bin_dir) - {commit(0)} == {commit(n) for n in range(1, KEEP + 1)} + + +def test_symlinked_bin_is_not_followed(home: Path): + """bin/ 自身がリンクなら何もしない (リンク先の版も入れ子も消さない)。""" + outside = home / "outside-bin" + outside.mkdir() + for n in range(KEEP + 2): + make_version(outside, n, age_days=n) + nested = outside / commit(0) / "vscode-server-linux-arm64" + nested.mkdir() + (nested / "product.json").write_text("{}") + (home / ".vscode-server").mkdir() + (home / ".vscode-server" / "bin").symlink_to(outside) + + prune(home) + + assert versions(outside) == {commit(n) for n in range(KEEP + 2)} + assert (nested / "product.json").is_file() + + def test_nested_is_kept_when_version_has_no_product_json(home: Path, bin_dir: Path): """bin// 自身に product.json が無いときは入れ子を消さない。""" version = bin_dir / commit(0)