From 3e21c0d1b4464d8d282527066d4995e61a706f13 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 23 Aug 2026 01:14:56 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(up):=20project.yml=20=E3=82=92?= =?UTF-8?q?=E8=B5=B7=E5=8B=95=E3=83=BB=E3=82=B9=E3=82=B1=E3=83=BC=E3=83=AB?= =?UTF-8?q?=E3=83=BB=E3=82=A8=E3=83=87=E3=82=A3=E3=82=BF=E3=81=AE=E5=94=AF?= =?UTF-8?q?=E4=B8=80=E3=81=AE=E8=A8=AD=E5=AE=9A=E6=BA=90=E3=81=AB=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN32 Task 2。devbase up がプロジェクトの project.yml を読み、正規化した clone プランを生成 compose の dev サービスへ環境変数として載せる。これで entrypoint が複数リポジトリを clone できる。 - lib/devbase/project/runtime.py を追加: コンテナへ渡す環境変数の組み立て (DEVBASE_REPOS / DEVBASE_PRIMARY_DIR / DEVBASE_WORKSPACE*)、workspace JSON の 生成、scale の読み書き。workspace の JSON はホスト側で組み立てて base64 で 渡すため、シェルでのエスケープが要らずテストもできる - scale の取得元と devbase scale の書き込み先を env の CONTAINER_SCALE から project.yml の scale へ移した。書き込みは行単位の置換にしてコメントと並び順を 保ち、書いた結果を読み直して壊れていれば元へ戻す - エディタは repo が 1 件なら primary のフォルダ、2 件以上なら entrypoint が 書き出した multi-root workspace を開く。自動オープンの有効判定は project.yml の open_editor > グローバル .env の DEVBASE_OPEN_EDITOR の順 - 旧 GIT_REPO / WORK_DIR / CONTAINER_SCALE を読む経路を削除した。project.yml が 無いプロジェクトは移行手順を案内するエラーで停止する (後方互換なし) Co-Authored-By: Claude Opus 5 (1M context) --- lib/devbase/commands/container.py | 121 +++++++---------- lib/devbase/editor/opener.py | 35 ++--- lib/devbase/env/runtime.py | 4 +- lib/devbase/project/runtime.py | 129 ++++++++++++++++++ lib/devbase/utils/config.py | 18 --- lib/devbase/volume/compose.py | 40 ++++++ tests/cli/test_project_dispatch.py | 34 +++-- tests/cli/test_project_name_resolution.py | 20 +-- tests/commands/test_container_up_order.py | 10 +- tests/editor/test_opener.py | 64 +++++++-- tests/env/test_runtime.py | 22 +-- tests/project/test_runtime.py | 135 +++++++++++++++++++ tests/volume/test_compose_dev_environment.py | 113 ++++++++++++++++ tests/volume/test_compose_secret_env.py | 2 +- 14 files changed, 589 insertions(+), 158 deletions(-) create mode 100644 lib/devbase/project/runtime.py create mode 100644 tests/project/test_runtime.py create mode 100644 tests/volume/test_compose_dev_environment.py diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index b34ec6f..cfd760b 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -25,7 +25,8 @@ wait_for_containers_ready, ensure_network ) -from devbase.utils.config import get_project_name, get_container_scale +from devbase.utils.config import get_project_name +from devbase.project import runtime as project_runtime logger = get_logger(__name__) @@ -85,13 +86,19 @@ def _inject_secrets(*, required: bool): return _runtime.SecretEnv() -def _generate_compose_for(scale: int, secrets) -> Path: - """機密の内訳を渡してスケール構成を生成する""" +def _generate_compose_for(scale: int, secrets, dev_environment=None) -> Path: + """機密の内訳と devbase 由来の環境変数を渡してスケール構成を生成する。 + + ``dev_environment`` は ``project.yml`` から作った clone プラン等 + (:func:`devbase.project.runtime.container_env`)。dev サービスへ載せることで、 + entrypoint がコンテナ内で複数リポジトリを clone できる。 + """ return generate_scaled_compose( scale, secret_env_names=secrets.names, global_env_names=secrets.global_names, project_env_names=secrets.project_names, + dev_environment=dev_environment, ) @@ -276,18 +283,17 @@ def _load_project_env(env_file: Path) -> None: wrapper (bin/devbase) は cd 後に ``source ./env`` で env を読み込むため、 Python フォールバック経路でも同じ KEY=VALUE を ``os.environ`` に載せて - 変数欠落 (例: project 固有の ``CONTAINER_SCALE``) を防ぐ。 + 変数欠落 (例: project 固有の ``ENABLE_SSH``) を防ぐ。 env は環境変数定義のみを想定したファイル (bin/devbase 冒頭コメント参照) の ため、ここでは ``export`` 接頭辞付き / 無しの単純な ``KEY=VALUE`` 行のみを 解釈する。``#`` コメント・空行は無視し、値の前後のクォートは除去する。 変数参照 (``$VAR`` / ``${VAR}``) は shell ``source ./env`` (wrapper 経路) と - 同様に展開する。実 env が ``WORK_DIR=/work/$GIT_REPO`` のように同一ファイル内で - 先に定義した変数を参照しており、展開しないと TUI (``list``) 経路でワークスペース - パスが ``$GIT_REPO`` 等の未展開文字列のまま VS Code で開いてしまうため - (行は file 順に ``os.environ`` へ載せるので、参照時には先行行の値が解決済み)。 - 単一引用符 ``'...'`` の値は shell 同様リテラル扱いで展開しない。 + 同様に展開する。``FOO=$BAR/baz`` のように同一ファイル内で先に定義した変数を + 参照する書き方を wrapper 経路と揃えるため (行は file 順に ``os.environ`` へ + 載せるので、参照時には先行行の値が解決済み)。単一引用符 ``'...'`` の値は + shell 同様リテラル扱いで展開しない。 .. note:: shell ``source`` との仕様乖離について @@ -323,10 +329,9 @@ def _load_project_env(env_file: Path) -> None: if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"): single_quoted = value[0] == "'" value = value[1:-1] - # shell `source ./env` 相当の変数展開 ($VAR / ${VAR}) を行う。実 env は - # `WORK_DIR=/work/$GIT_REPO` のように同一ファイル内で先に定義した変数を - # 参照しており (行順に os.environ へ載せるため参照時には解決済み)、展開 - # しないと TUI (list) 経路でワークスペースパスが未展開のまま開いてしまう。 + # shell `source ./env` 相当の変数展開 ($VAR / ${VAR}) を行う。同一ファイル内で + # 先に定義した変数を参照する書き方 (`FOO=$BAR/baz`) を wrapper 経路と揃える + # ため (行順に os.environ へ載せるため参照時には解決済み)。 # 単一引用符はリテラル ($BAR を展開しない) という shell 規則に合わせ、 # `'...'` の場合のみ展開しない。展開は _expand_env_vars に委ね、`$VAR` / # `${VAR}` のみ展開し (未定義は空文字 = shell source 準拠)、`\$` はリテラル @@ -563,11 +568,15 @@ def _resolve_open_index(open_index: Optional[int], scale: int) -> int: def _maybe_open_editor(project_name: str, open_flag: Optional[bool], open_index: Optional[int], scale: int, - compose_file=None) -> None: + config, compose_file=None) -> None: """`up` 完了後に dev コンテナへ接続したエディタを開く ([6/6])。 - 有効判定は ``open_flag`` (CLI ``--open``/``--no-open``) が優先、None なら env - ``DEVBASE_OPEN_EDITOR``。エディタ起動の成否は ``up`` の戻り値に影響させない。 + 有効判定は ``open_flag`` (CLI ``--open``/``--no-open``) が優先、None なら + ``project.yml`` の ``open_editor``、それも無ければ env ``DEVBASE_OPEN_EDITOR``。 + エディタ起動の成否は ``up`` の戻り値に影響させない。 + + 開く対象は ``config`` (``project.yml``) から決める。repo が 1 件なら primary の + フォルダ、2 件以上なら entrypoint が書き出した ``*.code-workspace``。 ``open_index`` は起動済みインスタンス範囲 ``1..scale`` 内である必要がある。 0・負数・``scale`` 超過は存在しないコンテナ URI になり原因不明な起動失敗を招くため、 @@ -579,7 +588,8 @@ def _maybe_open_editor(project_name: str, open_flag: Optional[bool], """ from devbase.editor import opener - enabled = open_flag if open_flag is not None else opener.is_open_enabled() + enabled = (open_flag if open_flag is not None + else opener.is_open_enabled(config=config)) if not enabled: return @@ -591,13 +601,18 @@ def _maybe_open_editor(project_name: str, open_flag: Optional[bool], compose_file = _SCALE_COMPOSE_FILE dev_service_name = get_dev_service_name() - workdir = opener.resolve_workdir(os.environ, project_name) + workdir = config.resolved_work_dir() + # repo が 2 件以上なら multi-root workspace を開く (entrypoint が同じパスへ + # ファイルを書き出している)。1 件なら従来どおりフォルダを開く。 + workspace = (project_runtime.workspace_path(project_name) + if len(config.repos) > 1 else None) logger.info("[6/6] Opening editor attached to the dev container...") try: opener.open_editor( project_name=project_name, dev_service_name=dev_service_name, workdir=workdir, + workspace=workspace, index=open_index, compose_file=compose_file, ) @@ -612,8 +627,11 @@ def cmd_up(project_name: str = None, scale: int = None, if project_name is None: project_name = get_project_name() + # project.yml が唯一の正 (PLAN32)。読めなければ移行手順を案内して止まる。 + config = project_runtime.current_project_config() + if scale is None: - scale = get_container_scale() + scale = config.scale if config.scale is not None else project_runtime.DEFAULT_SCALE dev_service_name = get_dev_service_name() @@ -653,7 +671,9 @@ def cmd_up(project_name: str = None, scale: int = None, # にしないため。 with _previous_scale_compose() as down_compose_file: logger.info("[2/6] Generating scaled compose file...") - override_file = _generate_compose_for(scale, _inject_secrets(required=True)) + override_file = _generate_compose_for( + scale, _inject_secrets(required=True), + dev_environment=project_runtime.container_env(config, project_name)) logger.info("Generated: %s", override_file) logger.info("[3/6] Stopping existing containers...") @@ -676,7 +696,7 @@ def cmd_up(project_name: str = None, scale: int = None, _run_deploy_script_for_instances(deploy_script, range(1, scale + 1)) _maybe_open_editor(project_name, open_editor, open_index, scale, - compose_file=override_file) + config, compose_file=override_file) logger.info("=== Deploy completed successfully ===") return 0 @@ -763,8 +783,10 @@ def cmd_scale(new_scale: int, project_name: str = None) -> int: if project_name is None: project_name = get_project_name() + config = project_runtime.current_project_config() dev_service_name = get_dev_service_name() - current_scale = _get_current_scale() + current_scale = (config.scale if config.scale is not None + else project_runtime.DEFAULT_SCALE) logger.info("Scaling project '%s' from %d to %d containers (dev service: %s)", project_name, current_scale, new_scale, dev_service_name) @@ -779,9 +801,9 @@ def cmd_scale(new_scale: int, project_name: str = None) -> int: return 1 try: - logger.info("[1/5] Updating env file: CONTAINER_SCALE=%d -> %d...", current_scale, new_scale) - if not _update_scale_in_env(new_scale): - return 1 + logger.info("[1/5] Updating %s: scale=%d -> %d...", + project_runtime.PROJECT_CONFIG_FILENAME, current_scale, new_scale) + project_runtime.write_scale(Path.cwd(), new_scale) logger.info("[2/5] Ensuring volumes exist for scale=%d...", new_scale) ensure_volumes(new_scale, project_name) @@ -791,7 +813,8 @@ def cmd_scale(new_scale: int, project_name: str = None) -> int: logger.info("[3/5] Generating scaled compose file...") override_file = _generate_compose_for( - new_scale, _inject_secrets(required=True)) + new_scale, _inject_secrets(required=True), + dev_environment=project_runtime.container_env(config, project_name)) logger.info("Generated: %s", override_file) logger.info("[4/5] Starting new containers (%d..%d)...", current_scale + 1, new_scale) @@ -1392,49 +1415,3 @@ def _mark_pulled(image_name: str) -> None: marker.touch() except OSError as e: logger.warning("Could not write pull marker for '%s': %s", image_name, e) - - -def _update_scale_in_env(new_scale: int) -> bool: - """Update CONTAINER_SCALE value in env file""" - env_file = Path('./env') - - if not env_file.exists(): - logger.error("env file not found: %s", env_file) - return False - - def _is_scale_line(line: str) -> bool: - return line.strip().startswith('CONTAINER_SCALE=') - - try: - lines = env_file.read_text().splitlines(keepends=True) - new_lines = [ - f'CONTAINER_SCALE={new_scale}\n' if _is_scale_line(line) else line - for line in lines - ] - if not any(map(_is_scale_line, lines)): - new_lines.append(f'\n# Added by devbase scale command\nCONTAINER_SCALE={new_scale}\n') - env_file.write_text(''.join(new_lines)) - return True - - except Exception as e: - logger.error("Updating env file: %s", e) - return False - - -def _get_current_scale() -> int: - """Get current CONTAINER_SCALE from env file""" - env_file = Path('./env') - - if not env_file.exists(): - return 0 - - try: - with open(env_file, 'r') as f: - for line in f: - if line.strip().startswith('CONTAINER_SCALE='): - value = line.split('=', 1)[1].strip() - return int(value) - except Exception: - pass - - return 0 diff --git a/lib/devbase/editor/opener.py b/lib/devbase/editor/opener.py index 6cfa776..0a9f7d9 100644 --- a/lib/devbase/editor/opener.py +++ b/lib/devbase/editor/opener.py @@ -253,8 +253,15 @@ def detect_context(environ=None, isatty: Optional[bool] = None, ) -def is_open_enabled(environ=None) -> bool: - """``DEVBASE_OPEN_EDITOR`` env が真かどうか (未設定は False)。""" +def is_open_enabled(environ=None, config=None) -> bool: + """エディタを自動で開くかどうか。 + + プロジェクト設定 (``project.yml`` の ``open_editor``) が指定されていればそれを + 採る。未指定なら env ``DEVBASE_OPEN_EDITOR`` (グローバル ``.env`` の既定値) + を見る。どちらも無ければ開かない。 + """ + if config is not None and config.open_editor is not None: + return config.open_editor env = os.environ if environ is None else environ value = env.get("DEVBASE_OPEN_EDITOR") if value is None: @@ -425,23 +432,17 @@ def resolve_container_name(dev_service_name: str, project_name: str, index: int return f"{project_name}-{dev_service_name}-{index}" -def resolve_workdir(environ=None, project_name: Optional[str] = None) -> str: - """コンテナ内で開くワークスペースパス (``/work/$GIT_REPO``) を返す。""" - env = os.environ if environ is None else environ - workdir = env.get("WORK_DIR") - if workdir: - return workdir - repo = env.get("GIT_REPO") or project_name - return f"/work/{repo}" if repo else "/work" - - def resolve_workspace(environ=None) -> Optional[str]: """開く VS Code ワークスペースファイル (``*.code-workspace``) のコンテナ内パス。 ``DEVBASE_WORKSPACE`` env にコンテナ内の絶対パス (例 ``/home/ubuntu/share/work/uttarov2-doc.workspace``) が指定されていればそれを返す。 - 未設定・空文字なら None を返し、呼び出し側 (:func:`open_editor`) は従来どおり - :func:`resolve_workdir` のフォルダを ``--folder-uri`` で開く。 + 未設定・空文字なら None を返し、呼び出し側 (:func:`open_editor`) はフォルダを + ``--folder-uri`` で開く。 + + 複数リポジトリのプロジェクトでは ``devbase up`` が ``project.yml`` から + workspace パスを決めて :func:`open_editor` の ``workspace`` 引数で直接渡す。 + この env はそれを手動で上書きしたい場合の口として残している。 ワークスペースファイルはコンテナ内に実在するパスを指す前提 (attach 先は コンテナ authority のため)。``/home/ubuntu/share`` 等の共有マウント配下に置けば @@ -622,6 +623,7 @@ def _launch(cmd: list, env: dict) -> None: def open_editor(*, project_name: str, dev_service_name: str, workdir: str, + workspace: Optional[str] = None, index: int = 1, compose_file=None, environ=None, isatty: Optional[bool] = None, system: Optional[str] = None, @@ -633,7 +635,8 @@ def open_editor(*, project_name: str, dev_service_name: str, workdir: str, 握り潰して warning にし、``up`` 本体を絶対に失敗させない。``isatty`` / ``system`` / ``ipc_alive`` は :func:`detect_context` への差し替え口 (テスト用)。 ``compose_file`` は実コンテナ名問い合わせ時に起動と同じ override compose を - ``-f`` で渡すため。 + ``-f`` で渡すため。``workspace`` は複数リポジトリ構成で開く + ``*.code-workspace`` のコンテナ内パス (未指定なら env ``DEVBASE_WORKSPACE``)。 """ env = os.environ if environ is None else environ ctx = detect_context(env, isatty=isatty, system=system, ipc_alive=ipc_alive) @@ -681,7 +684,7 @@ def open_editor(*, project_name: str, dev_service_name: str, workdir: str, # DEVBASE_WORKSPACE があれば *.code-workspace をワークスペースとして開く。VS Code は # `--file-uri` に渡したパスが .code-workspace 拡張子なら multi-root ワークスペースとして # 開くため、フォルダを開く `--folder-uri` と URI ターゲット・フラグの両方を切り替える。 - workspace = resolve_workspace(env) + workspace = workspace or resolve_workspace(env) open_target = workspace or workdir uri_flag = "--file-uri" if workspace else "--folder-uri" uri = build_attach_uri(container, open_target, diff --git a/lib/devbase/env/runtime.py b/lib/devbase/env/runtime.py index a8a94e6..429ace5 100644 --- a/lib/devbase/env/runtime.py +++ b/lib/devbase/env/runtime.py @@ -115,8 +115,8 @@ def _project_env_overrides(devbase_root: Path, project: str) -> Dict[str, str]: """プロジェクトの非機密設定 (``projects//env``) による上書き値。 値そのものはファイルから読まず、既に環境変数へ載っているものだけを採用する。 - ``env`` は ``WORK_DIR=/work/$GIT_REPO`` のように同一ファイル内の変数を参照 - するため、起動ラッパー (または ``_load_project_env``) が展開した後の値が + ``env`` は ``APP_ROOT=$APP_HOME/app`` のように同一ファイル内の変数を参照 + できるため、起動ラッパー (または ``_load_project_env``) が展開した後の値が 正しく、ここで生の行を読み直すと未展開の文字列を掴んでしまう。 """ path = Path(devbase_root) / 'projects' / project / 'env' diff --git a/lib/devbase/project/runtime.py b/lib/devbase/project/runtime.py new file mode 100644 index 0000000..001ba78 --- /dev/null +++ b/lib/devbase/project/runtime.py @@ -0,0 +1,129 @@ +"""``project.yml`` をコンテナ・エディタが使う形へ変換する層 (PLAN32)。 + +:mod:`devbase.project.config` が「読んで検証する」までを担い、ここは +「コンテナへ何を渡すか」「エディタに何を開かせるか」「``scale`` をどう書き戻すか」 +という実行時の関心を持つ。 +""" + +from __future__ import annotations + +import base64 +import json +import re +from pathlib import Path +from typing import Any, Dict, Mapping + +from devbase.errors import ConfigError +from devbase.project.config import ( + PROJECT_CONFIG_FILENAME, + ProjectConfig, + config_path, + encode_repo_plan, + load_project_config, +) + +#: ``scale`` 未指定時のコンテナ数 (従来の ``CONTAINER_SCALE`` 既定値と同じ) +DEFAULT_SCALE = 2 + + +def workspace_path(project_name: str) -> str: + """複数 repo をまとめて開く workspace ファイルのコンテナ内パス。""" + return f"/work/{project_name}.code-workspace" + + +def build_workspace_document(config: ProjectConfig) -> Dict[str, Any]: + """VS Code の multi-root workspace ファイル (JSON) の中身を組み立てる。 + + primary repo を先頭に置く。エディタのエクスプローラは並び順どおりに出るため、 + 作業の起点になる repo が一番上に来る方が探しやすい。 + """ + repos = sorted(config.repos, key=lambda repo: not repo.primary) + return {"folders": [{"name": repo.dir, "path": f"/work/{repo.dir}"} + for repo in repos]} + + +def container_env(config: ProjectConfig, project_name: str) -> Dict[str, str]: + """dev コンテナへ渡す環境変数を組み立てる。 + + - ``DEVBASE_REPOS``: clone プラン (base64) + - ``DEVBASE_PRIMARY_DIR``: 起動後に ``cd`` する ``/work`` 配下のディレクトリ名 + - ``DEVBASE_WORKSPACE`` / ``DEVBASE_WORKSPACE_B64``: repo が 2 件以上のときだけ。 + 1 件のときは従来どおりフォルダを開かせたいので付けない。 + + 値は base64 と検証済みの名前だけなので、``$`` や改行を含まず compose の + 変数展開に食われない。 + """ + env = { + "DEVBASE_REPOS": encode_repo_plan(config.repos), + "DEVBASE_PRIMARY_DIR": config.primary.dir, + } + if len(config.repos) > 1: + document = json.dumps(build_workspace_document(config), + ensure_ascii=False, indent=2) + env["DEVBASE_WORKSPACE"] = workspace_path(project_name) + env["DEVBASE_WORKSPACE_B64"] = base64.b64encode(document.encode()).decode() + return env + + +# --------------------------------------------------------------------------- +# scale (旧 CONTAINER_SCALE) +# --------------------------------------------------------------------------- + +_SCALE_LINE = re.compile(r'^scale:.*$', re.M) +_VERSION_LINE = re.compile(r'^version:.*$', re.M) + + +def read_scale(project_dir: Path) -> int: + """``project.yml`` の ``scale`` (未指定なら既定値)。""" + config = load_project_config(project_dir) + return config.scale if config.scale is not None else DEFAULT_SCALE + + +def write_scale(project_dir: Path, scale: int) -> None: + """``project.yml`` の ``scale`` を書き換える (無ければ ``version`` の直後へ追加)。 + + YAML を読み直して書き戻すとコメントと並び順が失われるため、行単位で置き換える。 + 書き換えた結果は読み直して検証し、壊れていれば元へ戻す。 + """ + if scale < 1: + raise ConfigError(f"scale は 1 以上の整数です ({scale!r})") + + path = config_path(project_dir) + original = path.read_text(encoding="utf-8") + + if _SCALE_LINE.search(original): + updated = _SCALE_LINE.sub(f"scale: {scale}", original, count=1) + elif _VERSION_LINE.search(original): + updated = _VERSION_LINE.sub( + lambda m: f"{m.group(0)}\nscale: {scale}", original, count=1) + else: + raise ConfigError( + f"{path}: version 行が見つからないため scale を書き込めません") + + path.write_text(updated, encoding="utf-8") + try: + load_project_config(project_dir) + except ConfigError: + path.write_text(original, encoding="utf-8") + raise + + +def current_project_config(project_dir: Path = None) -> ProjectConfig: + """カレントプロジェクト (既定は CWD) の設定を読む。 + + wrapper (``bin/devbase``) と ``_resolve_project_name`` が対象プロジェクトへ + cd 済みである前提。見つからなければ移行手順を含むエラーになる。 + """ + return load_project_config(Path(project_dir or Path.cwd())) + + +__all__ = [ + "DEFAULT_SCALE", + "PROJECT_CONFIG_FILENAME", + "build_workspace_document", + "container_env", + "current_project_config", + "read_scale", + "workspace_path", + "write_scale", +] diff --git a/lib/devbase/utils/config.py b/lib/devbase/utils/config.py index 50640f6..14429bd 100644 --- a/lib/devbase/utils/config.py +++ b/lib/devbase/utils/config.py @@ -4,7 +4,6 @@ from pathlib import Path from typing import Optional -from devbase.errors import ConfigError def get_project_name() -> str: @@ -22,23 +21,6 @@ def get_project_name() -> str: return Path.cwd().name -def get_container_scale() -> int: - """ - Get container scale from environment - - Returns: - Number of containers (default: 2) - """ - scale_str = os.environ.get('CONTAINER_SCALE', '2') - try: - scale = int(scale_str) - if scale < 1: - raise ConfigError("CONTAINER_SCALE must be >= 1") - return scale - except ValueError as e: - raise ConfigError(f"Invalid CONTAINER_SCALE value '{scale_str}': {e}") - - def get_devbase_root() -> Optional[Path]: """ Get devbase root directory from environment diff --git a/lib/devbase/volume/compose.py b/lib/devbase/volume/compose.py index 4b8a219..2b9203d 100644 --- a/lib/devbase/volume/compose.py +++ b/lib/devbase/volume/compose.py @@ -259,9 +259,40 @@ def for_targets(self, targets: Iterable[str]) -> List[str]: return [name for name in self.all if name in allowed] +def _apply_dev_environment(service: dict, extra: Mapping[str, str]) -> None: + """dev サービスへ devbase 由来の環境変数を載せる (PLAN32: clone プラン等)。 + + ``environment`` は辞書形と ``KEY=VALUE`` のリスト形の両方が使われるため、 + 元の形を保ったまま追記する。同名キーは devbase 側の値で上書きする + (clone プランはプロジェクト設定から毎回生成される正のため)。 + """ + if not extra: + return + + existing = service.get('environment') + if isinstance(existing, dict): + existing.update(extra) + return + if isinstance(existing, list): + names = set(extra) + kept = [entry for entry in existing + if not (isinstance(entry, str) + and entry.split('=', 1)[0] in names)] + service['environment'] = kept + [f"{k}={v}" for k, v in extra.items()] + return + if existing is None: + service['environment'] = dict(extra) + return + + logger.warning( + "environment の形式 (%s) を解釈できないため、devbase の環境変数を " + "追記できませんでした", type(existing).__name__) + + def _build_dev_instance( dev_service: dict, dev_service_name: str, index: int, secret_env_names: Sequence[str] = (), + dev_environment: Optional[Mapping[str, str]] = None, ) -> dict: """Build the service definition for one scaled dev instance (dev-).""" service = copy.deepcopy(dev_service) @@ -272,6 +303,9 @@ def _build_dev_instance( service.setdefault('init', True) _mask_secret_environment(service, secret_env_names) + # 機密の伏せ字化のあとに載せる。devbase 由来の値 (clone プラン等) は機密では + # なく、そのままコンテナへ渡す必要があるため。 + _apply_dev_environment(service, dev_environment or {}) # Update volume mounts for /persistent/ai and /work ai_volume = get_ai_volume_for_index(index) @@ -287,6 +321,7 @@ def _build_scaled_services( services: dict, dev_service: dict, dev_service_name: str, scale: int, secret_names: Optional[_SecretNames] = None, secret_services: Optional[Mapping[str, Set[str]]] = None, + dev_environment: Optional[Mapping[str, str]] = None, ) -> dict: """Build the services section: non-dev services + dev-1..dev-N instances. @@ -328,6 +363,7 @@ def _build_scaled_services( for i in range(1, scale + 1): scaled_services[f'{dev_service_name}-{i}'] = _build_dev_instance( dev_service, dev_service_name, i, secret_names.all, + dev_environment=dev_environment, ) return scaled_services @@ -436,6 +472,7 @@ def generate_scaled_compose( secret_env_names: Sequence[str] = (), global_env_names: Optional[Sequence[str]] = None, project_env_names: Optional[Sequence[str]] = None, + dev_environment: Optional[Mapping[str, str]] = None, ) -> Path: """ Generate scaled docker-compose file with per-instance volumes @@ -447,6 +484,8 @@ def generate_scaled_compose( secret_env_names: コンテナへ列挙する機密の変数名 (全件) global_env_names: そのうち共通機密 (``$DEVBASE_ROOT/.env``) 由来のキー project_env_names: そのうちプロジェクト機密由来のキー + dev_environment: dev サービスへ載せる devbase 由来の環境変数 + (PLAN32 の clone プラン ``DEVBASE_REPOS`` 等。機密ではない) 非 dev サービスへは、そのサービスが元々 ``env_file`` で参照していた由来の キーだけを列挙する。由来の内訳が渡されない場合 (両方 ``None``) は全キーを @@ -481,6 +520,7 @@ def generate_scaled_compose( services, dev_service, dev_service_name, scale, secret_names=secret_names, secret_services=secret_services, + dev_environment=dev_environment, ), 'volumes': _build_volumes_section(config, scale), 'networks': _build_networks_section(config), diff --git a/tests/cli/test_project_dispatch.py b/tests/cli/test_project_dispatch.py index 679e791..b53e406 100644 --- a/tests/cli/test_project_dispatch.py +++ b/tests/cli/test_project_dispatch.py @@ -353,15 +353,25 @@ def test_lifecycle_propagates_open_args_to_cmd_up(monkeypatch): assert captured == {'open_editor': True, 'open_index': 2} +def _project_config(*repos): + """_maybe_open_editor に渡すプロジェクト設定 (既定は単一 repo)。""" + from devbase.project.config import parse_project_config + return parse_project_config({ + "version": 1, + "defaults": {"owner": "volareinc"}, + "repos": [{"repo": r} for r in (repos or ("carmo",))], + }, source="project.yml") + + def test_maybe_open_editor_disabled_by_default(monkeypatch): """open_flag=None かつ env 未設定なら open_editor を呼ばない。""" from devbase.commands import container from devbase.editor import opener - monkeypatch.setattr(opener, 'is_open_enabled', lambda environ=None: False) + monkeypatch.setattr(opener, 'is_open_enabled', lambda environ=None, config=None: False) called = [] monkeypatch.setattr(opener, 'open_editor', lambda **kw: called.append(kw) or 'launch') - container._maybe_open_editor('carmo', None, None, 1) + container._maybe_open_editor('carmo', None, None, 1, _project_config()) assert called == [] @@ -369,12 +379,12 @@ def test_maybe_open_editor_flag_overrides_env(monkeypatch): """open_flag=True なら env が False でも開く。""" from devbase.commands import container from devbase.editor import opener - monkeypatch.setattr(opener, 'is_open_enabled', lambda environ=None: False) + monkeypatch.setattr(opener, 'is_open_enabled', lambda environ=None, config=None: False) called = [] monkeypatch.setattr(opener, 'open_editor', lambda **kw: called.append(kw) or 'launch') monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') - container._maybe_open_editor('carmo', True, 1, 1) + container._maybe_open_editor('carmo', True, 1, 1, _project_config()) assert len(called) == 1 assert called[0]['project_name'] == 'carmo' @@ -383,14 +393,14 @@ def test_maybe_open_editor_failure_does_not_raise(monkeypatch): """open_editor が例外でも _maybe_open_editor は伝播させない (up を倒さない)。""" from devbase.commands import container from devbase.editor import opener - monkeypatch.setattr(opener, 'is_open_enabled', lambda environ=None: True) + monkeypatch.setattr(opener, 'is_open_enabled', lambda environ=None, config=None: True) monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') def boom(**kw): raise RuntimeError("x") monkeypatch.setattr(opener, 'open_editor', boom) - container._maybe_open_editor('carmo', None, None, 1) # 例外が出なければ OK + container._maybe_open_editor('carmo', None, None, 1, _project_config()) # 例外が出なければ OK @pytest.mark.parametrize('bad_index', [0, -1, 3]) @@ -398,12 +408,12 @@ def test_maybe_open_editor_out_of_range_index_falls_back(monkeypatch, bad_index) """0・負数・scale 超過の index は既定 (1) へフォールバックする (scale=2)。""" from devbase.commands import container from devbase.editor import opener - monkeypatch.setattr(opener, 'is_open_enabled', lambda environ=None: True) + monkeypatch.setattr(opener, 'is_open_enabled', lambda environ=None, config=None: True) monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') called = [] monkeypatch.setattr(opener, 'open_editor', lambda **kw: called.append(kw) or 'launch') - container._maybe_open_editor('carmo', True, bad_index, 2) + container._maybe_open_editor('carmo', True, bad_index, 2, _project_config()) assert len(called) == 1 assert called[0]['index'] == 1 @@ -412,12 +422,12 @@ def test_maybe_open_editor_valid_index_within_scale(monkeypatch): """範囲内 (1..scale) の index はそのまま使われる。""" from devbase.commands import container from devbase.editor import opener - monkeypatch.setattr(opener, 'is_open_enabled', lambda environ=None: True) + monkeypatch.setattr(opener, 'is_open_enabled', lambda environ=None, config=None: True) monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') called = [] monkeypatch.setattr(opener, 'open_editor', lambda **kw: called.append(kw) or 'launch') - container._maybe_open_editor('carmo', True, 2, 3) + container._maybe_open_editor('carmo', True, 2, 3, _project_config()) assert called[0]['index'] == 2 @@ -425,11 +435,11 @@ def test_maybe_open_editor_forwards_compose_file(monkeypatch): """compose_file 引数が open_editor まで伝播する (実コンテナ名問い合わせ用)。""" from devbase.commands import container from devbase.editor import opener - monkeypatch.setattr(opener, 'is_open_enabled', lambda environ=None: True) + monkeypatch.setattr(opener, 'is_open_enabled', lambda environ=None, config=None: True) monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') called = [] monkeypatch.setattr(opener, 'open_editor', lambda **kw: called.append(kw) or 'launch') - container._maybe_open_editor('carmo', True, 1, 1, + container._maybe_open_editor('carmo', True, 1, 1, _project_config(), compose_file='override.yml') assert called[0]['compose_file'] == 'override.yml' diff --git a/tests/cli/test_project_name_resolution.py b/tests/cli/test_project_name_resolution.py index f6022b8..5523c3a 100644 --- a/tests/cli/test_project_name_resolution.py +++ b/tests/cli/test_project_name_resolution.py @@ -267,26 +267,26 @@ def test_load_project_env_diverges_from_shell_source(tmp_path, monkeypatch): def test_load_project_env_expands_variable_references(tmp_path, monkeypatch): """``$VAR`` / ``${VAR}`` を shell ``source`` 同様に展開する回帰テスト。 - 実 env の ``WORK_DIR=/work/$GIT_REPO`` (同一ファイル内で先に定義した変数を参照) + env は ``APP_ROOT=/srv/$APP_NAME`` のように同一ファイル内で先に定義した変数を参照 が TUI (``list``) 経路で未展開のまま VS Code に渡る不具合の回帰防止。 単一引用符値はリテラル扱いで展開しないことも併せて pin する。 """ - for k in ("GIT_REPO", "WORK_DIR", "WORK_DIR_BRACE", "SINGLE_Q"): + for k in ("APP_NAME", "APP_ROOT", "APP_ROOT_BRACE", "SINGLE_Q"): monkeypatch.delenv(k, raising=False) env_path = tmp_path / "env" env_path.write_text( - "GIT_REPO=adminer\n" - "WORK_DIR=/work/$GIT_REPO\n" # 行順に解決済みの GIT_REPO を展開 - "WORK_DIR_BRACE=/work/${GIT_REPO}\n" # ${VAR} 形式も展開 - "SINGLE_Q='/work/$GIT_REPO'\n" # 単一引用符はリテラル + "APP_NAME=adminer\n" + "APP_ROOT=/srv/$APP_NAME\n" # 行順に解決済みの APP_NAME を展開 + "APP_ROOT_BRACE=/srv/${APP_NAME}\n" # ${VAR} 形式も展開 + "SINGLE_Q='/srv/$APP_NAME'\n" # 単一引用符はリテラル ) container._load_project_env(env_path) - assert os.environ["GIT_REPO"] == "adminer" - assert os.environ["WORK_DIR"] == "/work/adminer" - assert os.environ["WORK_DIR_BRACE"] == "/work/adminer" - assert os.environ["SINGLE_Q"] == "/work/$GIT_REPO" + assert os.environ["APP_NAME"] == "adminer" + assert os.environ["APP_ROOT"] == "/srv/adminer" + assert os.environ["APP_ROOT_BRACE"] == "/srv/adminer" + assert os.environ["SINGLE_Q"] == "/srv/$APP_NAME" def test_load_project_env_escaped_dollar_and_undefined(tmp_path, monkeypatch): diff --git a/tests/commands/test_container_up_order.py b/tests/commands/test_container_up_order.py index a96c850..fac04d3 100644 --- a/tests/commands/test_container_up_order.py +++ b/tests/commands/test_container_up_order.py @@ -27,10 +27,12 @@ def up_harness(tmp_path, monkeypatch): """cmd_up の外部作用をすべてスタブ化し、呼び出し順を記録する。""" monkeypatch.chdir(tmp_path) + # PLAN32: cmd_up は project.yml を唯一の正として読む + (tmp_path / 'project.yml').write_text( + "version: 1\nscale: 1\nrepos:\n - owner: volareinc\n repo: carmo\n") calls: list = [] monkeypatch.setattr(container, 'get_project_name', lambda: 'proj') - monkeypatch.setattr(container, 'get_container_scale', lambda: 1) monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') monkeypatch.setattr(container, '_ensure_env_files', lambda: True) monkeypatch.setattr(container, '_run_pre_up_hook', lambda: True) @@ -56,7 +58,7 @@ def test_generate_precedes_down_and_uses_previous_compose(up_harness, monkeypatc calls = up_harness container._SCALE_COMPOSE_FILE.write_text(OLD_COMPOSE) - def fake_generate(scale, secrets): + def fake_generate(scale, secrets, dev_environment=None): calls.append(('generate', scale)) container._SCALE_COMPOSE_FILE.write_text(NEW_COMPOSE) return container._SCALE_COMPOSE_FILE @@ -95,7 +97,7 @@ def test_generation_failure_restores_previous_compose(up_harness, monkeypatch): calls = up_harness container._SCALE_COMPOSE_FILE.write_text(OLD_COMPOSE) - def half_written(scale, secrets): + def half_written(scale, secrets, dev_environment=None): container._SCALE_COMPOSE_FILE.write_text('services:\n dev-1:') raise DevbaseError('compose.yml が壊れています') @@ -114,7 +116,7 @@ def test_first_run_without_previous_compose(up_harness, monkeypatch): calls = up_harness assert not container._SCALE_COMPOSE_FILE.exists() - def fake_generate(scale, secrets): + def fake_generate(scale, secrets, dev_environment=None): container._SCALE_COMPOSE_FILE.write_text(NEW_COMPOSE) return container._SCALE_COMPOSE_FILE diff --git a/tests/editor/test_opener.py b/tests/editor/test_opener.py index 74bf58c..772274c 100644 --- a/tests/editor/test_opener.py +++ b/tests/editor/test_opener.py @@ -11,6 +11,7 @@ import pytest from devbase.editor import opener +from devbase.project.config import parse_project_config @dataclass @@ -296,6 +297,23 @@ def test_is_open_enabled(value, expected): assert opener.is_open_enabled(env) is expected +@pytest.mark.parametrize("open_editor,env_value,expected", [ + (True, "0", True), # project.yml が env の既定を上書きする + (False, "1", False), + (None, "1", True), # project.yml 未指定なら env (グローバル既定) に従う + (None, None, False), +]) +def test_is_open_enabled_prefers_the_project_config(open_editor, env_value, expected): + config = parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + **({} if open_editor is None else {"open_editor": open_editor}), + }, source="project.yml") + env = {} if env_value is None else {"DEVBASE_OPEN_EDITOR": env_value} + + assert opener.is_open_enabled(env, config=config) is expected + + # --------------------------------------------------------------------------- # resolve_editor_cmd # --------------------------------------------------------------------------- @@ -602,18 +620,6 @@ def test_parse_compose_ps_name_empty_and_invalid(): assert opener._parse_compose_ps_name("[]") is None -def test_resolve_workdir_prefers_work_dir_env(): - assert opener.resolve_workdir({"WORK_DIR": "/work/x"}, "y") == "/work/x" - - -def test_resolve_workdir_from_git_repo(): - assert opener.resolve_workdir({"GIT_REPO": "myrepo"}, None) == "/work/myrepo" - - -def test_resolve_workdir_fallback_project_name(): - assert opener.resolve_workdir({}, "proj") == "/work/proj" - - def test_resolve_workspace_none_when_unset(): assert opener.resolve_workspace({}) is None @@ -628,6 +634,40 @@ def test_resolve_workspace_returns_path(): "/home/ubuntu/share/work/uttarov2-doc.workspace" +def test_open_editor_opens_the_given_workspace_as_a_file(monkeypatch, tmp_path): + """複数 repo 構成では workspace ファイルを --file-uri で開く""" + calls = [] + monkeypatch.setattr(opener, "resolve_editor_cmd", lambda env=None: ["code"]) + monkeypatch.setattr(opener, "resolve_container_name", + lambda *a, **kw: "carmo-dev-1") + + action = opener.open_editor( + project_name="carmo", dev_service_name="dev", workdir="/work/carmo", + workspace="/work/carmo.code-workspace", + environ={"TERM": "xterm"}, isatty=True, system="Linux", + launcher=lambda cmd, env: calls.append(cmd), + ) + + assert action == "launch" + assert calls[0][1] == "--file-uri" + assert "carmo.code-workspace" in calls[0][2] + + +def test_open_editor_opens_the_folder_without_a_workspace(monkeypatch): + calls = [] + monkeypatch.setattr(opener, "resolve_editor_cmd", lambda env=None: ["code"]) + monkeypatch.setattr(opener, "resolve_container_name", + lambda *a, **kw: "carmo-dev-1") + + opener.open_editor( + project_name="carmo", dev_service_name="dev", workdir="/work/carmo", + environ={"TERM": "xterm"}, isatty=True, system="Linux", + launcher=lambda cmd, env: calls.append(cmd), + ) + + assert calls[0][1] == "--folder-uri" + + # --------------------------------------------------------------------------- # decide_action (§2.4 マトリクス全分岐) # --------------------------------------------------------------------------- diff --git a/tests/env/test_runtime.py b/tests/env/test_runtime.py index 7dffd53..17c5258 100644 --- a/tests/env/test_runtime.py +++ b/tests/env/test_runtime.py @@ -94,35 +94,35 @@ def test_project_env_overrides_global_for_the_same_key(root, store, monkeypatch) def test_project_env_only_keys_are_not_listed(root, store, monkeypatch): """非機密設定は env_file が直接読むので変数名を列挙しない""" - (root / 'projects' / 'web' / 'env').write_text('GIT_REPO=web\n') - monkeypatch.setenv('GIT_REPO', 'web') + (root / 'projects' / 'web' / 'env').write_text('APP_NAME=web\n') + monkeypatch.setenv('APP_NAME', 'web') store.age.save(GLOBAL, {'TOKEN': 't'}) resolved = runtime.resolve(root, 'web', store=store) assert resolved.names == ['TOKEN'] - assert 'GIT_REPO' not in resolved.values + assert 'APP_NAME' not in resolved.values def test_project_env_value_comes_from_the_environment(root, store, monkeypatch): """展開済みの値を採用する (生の行を読み直さない)""" - (root / 'projects' / 'web' / 'env').write_text('WORK_DIR=/work/$GIT_REPO\n') - monkeypatch.setenv('WORK_DIR', '/work/web') - store.age.save(GLOBAL, {'WORK_DIR': '/work/unset'}) + (root / 'projects' / 'web' / 'env').write_text('APP_ROOT=/srv/$APP_NAME\n') + monkeypatch.setenv('APP_ROOT', '/srv/web') + store.age.save(GLOBAL, {'APP_ROOT': '/srv/unset'}) resolved = runtime.resolve(root, 'web', store=store) - assert resolved.values['WORK_DIR'] == '/work/web' + assert resolved.values['APP_ROOT'] == '/srv/web' def test_project_env_is_ignored_when_not_in_the_environment(root, store, monkeypatch): - monkeypatch.delenv('WORK_DIR', raising=False) - (root / 'projects' / 'web' / 'env').write_text('WORK_DIR=/work/$GIT_REPO\n') - store.age.save(GLOBAL, {'WORK_DIR': '/work/global'}) + monkeypatch.delenv('APP_ROOT', raising=False) + (root / 'projects' / 'web' / 'env').write_text('APP_ROOT=/srv/$APP_NAME\n') + store.age.save(GLOBAL, {'APP_ROOT': '/srv/global'}) resolved = runtime.resolve(root, 'web', store=store) - assert resolved.values['WORK_DIR'] == '/work/global' + assert resolved.values['APP_ROOT'] == '/srv/global' def test_resolve_without_any_secrets_is_empty(root, store): diff --git a/tests/project/test_runtime.py b/tests/project/test_runtime.py new file mode 100644 index 0000000..22e33e5 --- /dev/null +++ b/tests/project/test_runtime.py @@ -0,0 +1,135 @@ +"""project.yml をコンテナ・エディタへ渡す形へ変換する層 (PLAN32 Task 2)""" + +from __future__ import annotations + +import base64 +import json + +import pytest + +from devbase.errors import ConfigError +from devbase.project.config import decode_repo_plan, parse_project_config +from devbase.project.runtime import ( + build_workspace_document, + container_env, + read_scale, + workspace_path, + write_scale, +) + + +def config_of(*repos, **top): + return parse_project_config( + {"version": 1, "defaults": {"owner": "volareinc"}, + "repos": [dict(repo=r) if isinstance(r, str) else r for r in repos], + **top}, + source="project.yml") + + +# --------------------------------------------------------------------------- +# コンテナへ渡す環境変数 +# --------------------------------------------------------------------------- + +def test_container_env_carries_the_clone_plan_and_primary_dir(): + env = container_env(config_of("carmo", "carmo-batch"), project_name="carmo") + + entries = decode_repo_plan(env["DEVBASE_REPOS"]) + assert [(e.url, e.dir) for e in entries] == [ + ("https://github.com/volareinc/carmo.git", "carmo"), + ("https://github.com/volareinc/carmo-batch.git", "carmo-batch"), + ] + assert env["DEVBASE_PRIMARY_DIR"] == "carmo" + + +def test_multi_repo_projects_get_a_workspace_file(): + env = container_env(config_of("carmo", "carmo-batch"), project_name="carmo") + + assert env["DEVBASE_WORKSPACE"] == "/work/carmo.code-workspace" + document = json.loads(base64.b64decode(env["DEVBASE_WORKSPACE_B64"]).decode()) + assert document["folders"] == [ + {"name": "carmo", "path": "/work/carmo"}, + {"name": "carmo-batch", "path": "/work/carmo-batch"}, + ] + + +def test_single_repo_projects_open_a_plain_folder(): + """repo が 1 件なら従来どおりフォルダを開く (workspace ファイルを作らない)""" + env = container_env(config_of("carmo"), project_name="carmo") + + assert "DEVBASE_WORKSPACE" not in env + assert "DEVBASE_WORKSPACE_B64" not in env + + +def test_workspace_path_is_derived_from_the_project_name(): + assert workspace_path("carmo") == "/work/carmo.code-workspace" + + +def test_workspace_document_lists_the_primary_repo_first(): + config = config_of("carmo-doc", {"repo": "carmo", "primary": True}) + + document = build_workspace_document(config) + + assert [f["name"] for f in document["folders"]] == ["carmo", "carmo-doc"] + + +def test_container_env_values_are_safe_for_compose(): + """base64 と単純な名前だけなので、compose の変数展開に食われない""" + env = container_env(config_of("carmo", "carmo-batch"), project_name="carmo") + + assert all("$" not in value and "\n" not in value for value in env.values()) + + +# --------------------------------------------------------------------------- +# scale の読み書き (旧 CONTAINER_SCALE) +# --------------------------------------------------------------------------- + +def test_read_scale_uses_the_project_config(tmp_path): + (tmp_path / "project.yml").write_text( + "version: 1\nscale: 3\nrepos:\n - owner: volareinc\n repo: carmo\n") + + assert read_scale(tmp_path) == 3 + + +def test_read_scale_falls_back_to_the_default(tmp_path): + (tmp_path / "project.yml").write_text( + "version: 1\nrepos:\n - owner: volareinc\n repo: carmo\n") + + assert read_scale(tmp_path) == 2 + + +def test_read_scale_reports_a_missing_config(tmp_path): + with pytest.raises(ConfigError, match="project.yml"): + read_scale(tmp_path) + + +def test_write_scale_updates_the_existing_key_and_keeps_comments(tmp_path): + (tmp_path / "project.yml").write_text( + "version: 1\n# 並行開発用のコンテナ数\nscale: 1\nrepos:\n" + " - owner: volareinc\n repo: carmo\n") + + write_scale(tmp_path, 4) + + text = (tmp_path / "project.yml").read_text() + assert "scale: 4" in text + assert "# 並行開発用のコンテナ数" in text + assert read_scale(tmp_path) == 4 + + +def test_write_scale_adds_the_key_when_absent(tmp_path): + (tmp_path / "project.yml").write_text( + "version: 1\nrepos:\n - owner: volareinc\n repo: carmo\n") + + write_scale(tmp_path, 2) + + assert read_scale(tmp_path) == 2 + # repos の配下ではなく最上位に書かれること + assert (tmp_path / "project.yml").read_text().splitlines()[1] == "scale: 2" + + +def test_write_scale_rejects_a_broken_result(tmp_path): + (tmp_path / "project.yml").write_text( + "version: 1\nrepos:\n - owner: volareinc\n repo: carmo\n") + + with pytest.raises(ConfigError, match="scale"): + write_scale(tmp_path, 0) + assert "scale" not in (tmp_path / "project.yml").read_text() diff --git a/tests/volume/test_compose_dev_environment.py b/tests/volume/test_compose_dev_environment.py new file mode 100644 index 0000000..e220718 --- /dev/null +++ b/tests/volume/test_compose_dev_environment.py @@ -0,0 +1,113 @@ +"""dev サービスへの追加環境変数の注入 (PLAN32: clone プランの受け渡し)""" + +from __future__ import annotations + +import pytest +import yaml + +from devbase.volume.compose import generate_scaled_compose + + +COMPOSE_DICT_ENV = """services: + dev: + image: alpine + environment: + FEATURE_FLAG: enabled + volumes: + - x:/work + db: + image: mysql +volumes: + x: {} +""" + +COMPOSE_LIST_ENV = """services: + dev: + image: alpine + environment: + - FEATURE_FLAG=enabled + volumes: + - x:/work +volumes: + x: {} +""" + +COMPOSE_NO_ENV = """services: + dev: + image: alpine + volumes: + - x:/work +volumes: + x: {} +""" + +REPO_ENV = {"DEVBASE_REPOS": "cGxhbg==", "DEVBASE_PRIMARY_DIR": "carmo"} + + +@pytest.fixture +def project(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + return tmp_path + + +def generated(project): + return yaml.safe_load((project / ".docker-compose.scale.yml").read_text()) + + +def env_of(service) -> dict: + environment = service.get("environment") + if isinstance(environment, dict): + return environment + return dict(entry.split("=", 1) for entry in environment) + + +def test_dev_instances_receive_the_extra_environment(project): + (project / "compose.yml").write_text(COMPOSE_DICT_ENV) + + generate_scaled_compose(2, dev_environment=REPO_ENV) + + services = generated(project)["services"] + for name in ("dev-1", "dev-2"): + assert env_of(services[name])["DEVBASE_REPOS"] == "cGxhbg==" + assert env_of(services[name])["DEVBASE_PRIMARY_DIR"] == "carmo" + # 元からある値は残す + assert env_of(services[name])["FEATURE_FLAG"] == "enabled" + + +def test_non_dev_services_do_not_receive_it(project): + """clone するのは dev コンテナだけ。他サービスへ余計な変数を増やさない""" + (project / "compose.yml").write_text(COMPOSE_DICT_ENV) + + generate_scaled_compose(1, dev_environment=REPO_ENV) + + db = generated(project)["services"]["db"] + assert "DEVBASE_REPOS" not in (db.get("environment") or {}) + + +def test_list_form_environment_is_supported(project): + (project / "compose.yml").write_text(COMPOSE_LIST_ENV) + + generate_scaled_compose(1, dev_environment=REPO_ENV) + + dev = generated(project)["services"]["dev-1"] + assert env_of(dev) == { + "FEATURE_FLAG": "enabled", + "DEVBASE_REPOS": "cGxhbg==", + "DEVBASE_PRIMARY_DIR": "carmo", + } + + +def test_environment_section_is_created_when_absent(project): + (project / "compose.yml").write_text(COMPOSE_NO_ENV) + + generate_scaled_compose(1, dev_environment=REPO_ENV) + + assert env_of(generated(project)["services"]["dev-1"]) == REPO_ENV + + +def test_without_extra_environment_nothing_is_added(project): + (project / "compose.yml").write_text(COMPOSE_NO_ENV) + + generate_scaled_compose(1) + + assert "environment" not in generated(project)["services"]["dev-1"] diff --git a/tests/volume/test_compose_secret_env.py b/tests/volume/test_compose_secret_env.py index 82894c4..723dd75 100644 --- a/tests/volume/test_compose_secret_env.py +++ b/tests/volume/test_compose_secret_env.py @@ -51,7 +51,7 @@ @pytest.fixture def project(tmp_path, monkeypatch): (tmp_path / 'compose.yml').write_text(COMPOSE) - (tmp_path / 'env').write_text('GIT_REPO=web\n') + (tmp_path / 'env').write_text('APP_NAME=web\n') monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path / 'root')) (tmp_path / 'root').mkdir() monkeypatch.chdir(tmp_path) From c0ae50f6a08b6410209c9e925be963e4d4340e60 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 23 Aug 2026 01:24:03 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(scale):=20write=5Fscale=20=E3=81=8C=20s?= =?UTF-8?q?cale=20=E8=A1=8C=E3=81=AE=E8=A1=8C=E5=86=85=E3=82=B3=E3=83=A1?= =?UTF-8?q?=E3=83=B3=E3=83=88=E3=82=92=E6=B6=88=E3=81=95=E3=81=AA=E3=81=84?= =?UTF-8?q?=E3=82=88=E3=81=86=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `^scale:.*$` の一括置換で `scale: 1 # 並列数` の行内コメントごと 消えており、「コメントを保持する」という関数の契約に反していた。 値部分と行内コメントを別々に捕まえ、値だけを差し替える。 Co-Authored-By: Claude Opus 5 (1M context) --- lib/devbase/project/runtime.py | 17 +++++++++++++++-- tests/project/test_runtime.py | 13 +++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/lib/devbase/project/runtime.py b/lib/devbase/project/runtime.py index 001ba78..ac97007 100644 --- a/lib/devbase/project/runtime.py +++ b/lib/devbase/project/runtime.py @@ -69,10 +69,22 @@ def container_env(config: ProjectConfig, project_name: str) -> Dict[str, str]: # scale (旧 CONTAINER_SCALE) # --------------------------------------------------------------------------- -_SCALE_LINE = re.compile(r'^scale:.*$', re.M) +#: ``scale: 1 # 並列数`` の値部分と行内コメントを別々に捕まえる。 +#: 値だけを差し替えてコメントをそのまま残すため。 +_SCALE_LINE = re.compile(r'^scale:(?P[^#\n]*)(?P#[^\n]*)?$', re.M) _VERSION_LINE = re.compile(r'^version:.*$', re.M) +def _rewrite_scale_line(match: "re.Match[str]", scale: int) -> str: + """``scale`` 行の値だけを差し替え、行内コメントは元の間隔ごと残す。""" + comment = match.group("comment") + if not comment: + return f"scale: {scale}" + value = match.group("value") + gap = value[len(value.rstrip()):] or " " + return f"scale: {scale}{gap}{comment}" + + def read_scale(project_dir: Path) -> int: """``project.yml`` の ``scale`` (未指定なら既定値)。""" config = load_project_config(project_dir) @@ -92,7 +104,8 @@ def write_scale(project_dir: Path, scale: int) -> None: original = path.read_text(encoding="utf-8") if _SCALE_LINE.search(original): - updated = _SCALE_LINE.sub(f"scale: {scale}", original, count=1) + updated = _SCALE_LINE.sub( + lambda m: _rewrite_scale_line(m, scale), original, count=1) elif _VERSION_LINE.search(original): updated = _VERSION_LINE.sub( lambda m: f"{m.group(0)}\nscale: {scale}", original, count=1) diff --git a/tests/project/test_runtime.py b/tests/project/test_runtime.py index 22e33e5..d08f8af 100644 --- a/tests/project/test_runtime.py +++ b/tests/project/test_runtime.py @@ -115,6 +115,19 @@ def test_write_scale_updates_the_existing_key_and_keeps_comments(tmp_path): assert read_scale(tmp_path) == 4 +def test_write_scale_keeps_an_inline_comment(tmp_path): + (tmp_path / "project.yml").write_text( + "version: 1\nscale: 1 # 並列数\nrepos:\n" + " - owner: volareinc\n repo: carmo\n") + + write_scale(tmp_path, 4) + + # 値だけが差し替わり、行内コメントと間隔がそのまま残ること + assert (tmp_path / "project.yml").read_text().splitlines()[1] == ( + "scale: 4 # 並列数") + assert read_scale(tmp_path) == 4 + + def test_write_scale_adds_the_key_when_absent(tmp_path): (tmp_path / "project.yml").write_text( "version: 1\nrepos:\n - owner: volareinc\n repo: carmo\n")