diff --git a/etc/devbase-completion.bash b/etc/devbase-completion.bash index fa6a82cf..b2c5583c 100644 --- a/etc/devbase-completion.bash +++ b/etc/devbase-completion.bash @@ -1,11 +1,17 @@ # bash completion for devbase +# devbase のルートを解決し、呼び出し元の `local devbase_root` へ入れる。 +# 標準出力で返すとコマンド置換で値の末尾の改行が落ちるため、変数へ直に入れる。 +_devbase_resolve_root() { + devbase_root="${DEVBASE_ROOT:-$(dirname "$(dirname "$(command -v devbase 2>/dev/null)")" 2>/dev/null)}" +} + # projects/ 配下のプロジェクト名 (symlink / 実ディレクトリ) を列挙する。 # `devbase project up ` やトップレベルシノニム `devbase up ` の # name 補完に使う。 _devbase_project_names() { local devbase_root - devbase_root="${DEVBASE_ROOT:-$(dirname "$(dirname "$(command -v devbase 2>/dev/null)")" 2>/dev/null)}" + _devbase_resolve_root local projects_dir="${devbase_root}/projects" if [ -d "$projects_dir" ]; then # -L で symlink を辿り -type d で判定することで、実ディレクトリと @@ -35,6 +41,329 @@ _devbase_complete_flags() { fi } +# 以下の _devbase_complete_* は引数を取らず、cur・prev・words・cword とフラグの一覧 +# (ctx_flags・ps_flags・logs_flags・open_flags) を _devbase_completions の local から読む。 +# どの関数も最後の文を if / case にし、当たらないときも終了の状態を 0 に保つ。 + +# 位置 2: 1 語目ごとのサブコマンド・フラグ・プロジェクト名。 +_devbase_complete_subcommands() { + # 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 token encrypt decrypt rekey doctor backend" + local plugin_subcommands="list install uninstall update info sync repo" + local snapshot_subcommands="create list restore copy delete rotate" + case "$prev" in + login) + _devbase_complete_flags "$ctx_flags" "$cur" "1 2" + ;; + # build は image を取る (候補は出さない)。 + build) + _devbase_complete_flags "$ctx_flags" "$cur" + ;; + # トップレベルシノニム: up/down/scale/rebuild は [name] を取るため + # プロジェクト名を補完する (login=index / build=image は対象外)。 + up|down|scale|rebuild) + _devbase_complete_flags "$ctx_flags" "$cur" names + ;; + # open は [name] と --open-index / --context を取る (PLAN59)。 + open) + _devbase_complete_flags "$open_flags" "$cur" names + ;; + # ps は [name] と -a フラグの両方を取る (project ps と同じ挙動)。 + ps) + _devbase_complete_flags "$ps_flags" "$cur" names + ;; + # list は位置引数を取らず対話制御フラグのみ。`-*` ガードを外し + # 常にフラグ候補を出す (zsh 側 _arguments と挙動を揃える)。 + list) + COMPREPLY=($(compgen -W "--no-interactive --plain -P --interactive -i" -- "$cur")) + ;; + project) + COMPREPLY=($(compgen -W "$project_subcommands" -- "$cur")) + ;; + container|ct) + COMPREPLY=($(compgen -W "$container_subcommands" -- "$cur")) + ;; + env) + COMPREPLY=($(compgen -W "$env_subcommands" -- "$cur")) + ;; + plugin|pl) + COMPREPLY=($(compgen -W "$plugin_subcommands" -- "$cur")) + ;; + snapshot|ss) + COMPREPLY=($(compgen -W "$snapshot_subcommands" -- "$cur")) + ;; + esac +} + +# 位置 3: 1 語目 (グループ) で振り分ける。 +_devbase_complete_args() { + case "${words[1]}" in + # トップレベルシノニム: `devbase ps web -` (group=ps, cword=3) + # のように name 位置が埋まった後でもフラグを補完する。project と対称化。 + ps) + _devbase_complete_flags "$ps_flags" "$cur" + ;; + open) + _devbase_complete_flags "$open_flags" "$cur" + ;; + up|down|scale|rebuild) + _devbase_complete_flags "$ctx_flags" "$cur" + ;; + project) + _devbase_complete_project_args + ;; + container|ct) + _devbase_complete_container_args + ;; + env) + _devbase_complete_env_args + ;; + plugin|pl) + _devbase_complete_plugin_args + ;; + snapshot|ss) + _devbase_complete_snapshot_args + ;; + esac +} + +# project subcommand arguments (推奨グループ) +_devbase_complete_project_args() { + case "$prev" in + up|down|rebuild) + _devbase_complete_flags "$ctx_flags" "$cur" names + ;; + login) + _devbase_complete_flags "$ctx_flags" "$cur" "1 2" + ;; + scale) + # `project scale N` / `project scale N` の両形。 + # name 補完を提示する (数値はユーザが直接入力)。 + _devbase_complete_flags "$ctx_flags" "$cur" names + ;; + build) + _devbase_complete_flags "$ctx_flags" "$cur" + ;; + ps) + _devbase_complete_flags "$ps_flags" "$cur" names + ;; + logs) + _devbase_complete_flags "$logs_flags" "$cur" names + ;; + # list は位置引数を取らず対話制御フラグのみ。`-*` ガードを外し + # 常にフラグ候補を出す (zsh 側 _arguments と挙動を揃える)。 + list) + COMPREPLY=($(compgen -W "--no-interactive --plain -P --interactive -i" -- "$cur")) + ;; + profile) + COMPREPLY=($(compgen -W "up down list" -- "$cur")) + ;; + open) + _devbase_complete_flags "$open_flags" "$cur" names + ;; + # post-start は [name] と --context を取る (#371) + post-start) + _devbase_complete_flags "$ctx_flags" "$cur" names + ;; + esac +} + +# container subcommand arguments (非推奨: project へ移行してください) +_devbase_complete_container_args() { + case "$prev" in + up|down|build|rebuild) + _devbase_complete_flags "$ctx_flags" "$cur" + ;; + login) + _devbase_complete_flags "$ctx_flags" "$cur" "1 2" + ;; + scale) + _devbase_complete_flags "$ctx_flags" "$cur" "1 2 3 4 5" + ;; + profile) + COMPREPLY=($(compgen -W "up down list" -- "$cur")) + ;; + open) + _devbase_complete_flags "$open_flags" "$cur" + ;; + ps) + _devbase_complete_flags "$ps_flags" "$cur" + ;; + logs) + _devbase_complete_flags "$logs_flags" "$cur" + ;; + esac +} + +# env subcommand arguments +_devbase_complete_env_args() { + case "$prev" in + # exec / token は --context を取る (exec は `--` より前だけ) + exec|token) + _devbase_complete_flags "$ctx_flags" "$cur" + ;; + init) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--reset" -- "$cur")) + fi + ;; + list) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--global -g --project -p --reveal -r --keys -k" -- "$cur")) + fi + ;; + set|delete|edit) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--project -p" -- "$cur")) + fi + ;; + export) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--include-project --exclude-project --no-global --no-metadata --recipient --passphrase-env --passphrase-stdin --force-unencrypted --unsafe-allow-unencrypted-bucket" -- "$cur")) + fi + ;; + import) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--merge --replace-keys --replace --dry-run --identity --passphrase-env --passphrase-stdin --include-project --exclude-project --no-global --no-metadata --merge-metadata --backup-dir --keep-last" -- "$cur")) + fi + ;; + esac +} + +# plugin subcommand arguments +_devbase_complete_plugin_args() { + local repo_subcommands="add remove list refresh" + case "$prev" in + list) + COMPREPLY=($(compgen -W "--available" -- "$cur")) + ;; + install) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--link --all" -- "$cur")) + fi + ;; + uninstall|update|info) + local devbase_root + _devbase_resolve_root + local plugin_dir="${devbase_root}/plugins" + if [ -d "$plugin_dir" ]; then + local plugins + plugins=$(find "$plugin_dir" -mindepth 1 -maxdepth 1 -type d -o -type l | xargs -I{} basename {} 2>/dev/null) + COMPREPLY=($(compgen -W "$plugins" -- "$cur")) + fi + ;; + repo) + COMPREPLY=($(compgen -W "$repo_subcommands" -- "$cur")) + ;; + esac +} + +# snapshot subcommand arguments +_devbase_complete_snapshot_args() { + case "$prev" in + create) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--name --full" -- "$cur")) + fi + ;; + restore) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--point" -- "$cur")) + fi + ;; + rotate) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--keep" -- "$cur")) + fi + ;; + esac +} + +# 位置 4: 1 語目で振り分ける。 +_devbase_complete_after_args() { + case "${words[1]}" in + project) + _devbase_complete_project_flags + _devbase_complete_profile_flags + ;; + container|ct) + _devbase_complete_profile_flags + ;; + plugin|pl) + _devbase_complete_plugin_after_args + ;; + esac +} + +# project: name 位置が埋まった後 (例: `project ps web -`) +# でもフラグを補完する。subcommand は words[2]。 +_devbase_complete_project_flags() { + case "${words[2]}" in + ps) + _devbase_complete_flags "$ps_flags" "$cur" + ;; + logs) + _devbase_complete_flags "$logs_flags" "$cur" + ;; + open) + _devbase_complete_flags "$open_flags" "$cur" + ;; + up|down|rebuild|scale|post-start) + _devbase_complete_flags "$ctx_flags" "$cur" + ;; + esac +} + +# project / container の profile up|down|list の直後 (例: `project profile up -`) +_devbase_complete_profile_flags() { + if [ "${words[2]}" = "profile" ]; then + case "$prev" in + up|down|list) + _devbase_complete_flags "$ctx_flags" "$cur" + ;; + esac + fi +} + +# plugin install の引数の後のフラグと、plugin repo のサブコマンドの引数 +_devbase_complete_plugin_after_args() { + # plugin install flags after source argument + if [ "${words[2]}" = "install" ]; then + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--link --all" -- "$cur")) + fi + fi + # plugin repo subcommand arguments + if [ "${words[2]}" = "repo" ]; then + case "$prev" in + add) + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--name" -- "$cur")) + fi + ;; + remove|refresh) + local devbase_root + _devbase_resolve_root + local yml="${devbase_root}/plugins.yml" + if [ -f "$yml" ] && command -v python3 >/dev/null 2>&1; then + local repos + repos=$(python3 -c " +import yaml, sys +try: + d = yaml.safe_load(open('$yml')) + print(' '.join(r['name'] for r in (d or {}).get('repositories', []))) +except Exception: + pass +" 2>/dev/null) + COMPREPLY=($(compgen -W "$repos" -- "$cur")) + fi + ;; + esac + fi +} + _devbase_completions() { local cur prev words cword _init_completion 2>/dev/null || { @@ -46,13 +375,6 @@ _devbase_completions() { } local commands="init status shell-rc project container ct env plugin pl snapshot ss up down login build rebuild ps scale open list help" - # 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 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" # --context NAME を取るサブコマンドのフラグ候補 (トップレベル・project・container で共通)。 # 道の直後と、[name] を取るものはプロジェクト名の直後で出す (#435)。 local ctx_flags="--context" @@ -71,276 +393,13 @@ _devbase_completions() { COMPREPLY=($(compgen -W "$commands" -- "$cur")) ;; 2) - case "$prev" in - login) - _devbase_complete_flags "$ctx_flags" "$cur" "1 2" - ;; - # build は image を取る (候補は出さない)。 - build) - _devbase_complete_flags "$ctx_flags" "$cur" - ;; - # トップレベルシノニム: up/down/scale/rebuild は [name] を取るため - # プロジェクト名を補完する (login=index / build=image は対象外)。 - up|down|scale|rebuild) - _devbase_complete_flags "$ctx_flags" "$cur" names - ;; - # open は [name] と --open-index / --context を取る (PLAN59)。 - open) - _devbase_complete_flags "$open_flags" "$cur" names - ;; - # ps は [name] と -a フラグの両方を取る (project ps と同じ挙動)。 - ps) - _devbase_complete_flags "$ps_flags" "$cur" names - ;; - # list は位置引数を取らず対話制御フラグのみ。`-*` ガードを外し - # 常にフラグ候補を出す (zsh 側 _arguments と挙動を揃える)。 - list) - COMPREPLY=($(compgen -W "--no-interactive --plain -P --interactive -i" -- "$cur")) - ;; - project) - COMPREPLY=($(compgen -W "$project_subcommands" -- "$cur")) - ;; - container|ct) - COMPREPLY=($(compgen -W "$container_subcommands" -- "$cur")) - ;; - env) - COMPREPLY=($(compgen -W "$env_subcommands" -- "$cur")) - ;; - plugin|pl) - COMPREPLY=($(compgen -W "$plugin_subcommands" -- "$cur")) - ;; - snapshot|ss) - COMPREPLY=($(compgen -W "$snapshot_subcommands" -- "$cur")) - ;; - esac + _devbase_complete_subcommands ;; 3) - local group="${words[1]}" - # トップレベルシノニム: `devbase ps web -` (group=ps, cword=3) - # のように name 位置が埋まった後でもフラグを補完する。project と対称化。 - case "$group" in - ps) - _devbase_complete_flags "$ps_flags" "$cur" - ;; - open) - _devbase_complete_flags "$open_flags" "$cur" - ;; - up|down|scale|rebuild) - _devbase_complete_flags "$ctx_flags" "$cur" - ;; - esac - # project subcommand arguments (推奨グループ) - if [ "$group" = "project" ]; then - case "$prev" in - up|down|rebuild) - _devbase_complete_flags "$ctx_flags" "$cur" names - ;; - login) - _devbase_complete_flags "$ctx_flags" "$cur" "1 2" - ;; - scale) - # `project scale N` / `project scale N` の両形。 - # name 補完を提示する (数値はユーザが直接入力)。 - _devbase_complete_flags "$ctx_flags" "$cur" names - ;; - build) - _devbase_complete_flags "$ctx_flags" "$cur" - ;; - ps) - _devbase_complete_flags "$ps_flags" "$cur" names - ;; - logs) - _devbase_complete_flags "$logs_flags" "$cur" names - ;; - # list は位置引数を取らず対話制御フラグのみ。`-*` ガードを外し - # 常にフラグ候補を出す (zsh 側 _arguments と挙動を揃える)。 - list) - COMPREPLY=($(compgen -W "--no-interactive --plain -P --interactive -i" -- "$cur")) - ;; - profile) - COMPREPLY=($(compgen -W "up down list" -- "$cur")) - ;; - open) - _devbase_complete_flags "$open_flags" "$cur" names - ;; - # post-start は [name] と --context を取る (#371) - post-start) - _devbase_complete_flags "$ctx_flags" "$cur" names - ;; - esac - fi - # container subcommand arguments (非推奨: project へ移行してください) - if [ "$group" = "container" ] || [ "$group" = "ct" ]; then - case "$prev" in - up|down|build|rebuild) - _devbase_complete_flags "$ctx_flags" "$cur" - ;; - login) - _devbase_complete_flags "$ctx_flags" "$cur" "1 2" - ;; - scale) - _devbase_complete_flags "$ctx_flags" "$cur" "1 2 3 4 5" - ;; - profile) - COMPREPLY=($(compgen -W "up down list" -- "$cur")) - ;; - open) - _devbase_complete_flags "$open_flags" "$cur" - ;; - ps) - _devbase_complete_flags "$ps_flags" "$cur" - ;; - logs) - _devbase_complete_flags "$logs_flags" "$cur" - ;; - esac - fi - # env subcommand arguments - if [ "$group" = "env" ]; then - case "$prev" in - # exec / token は --context を取る (exec は `--` より前だけ) - exec|token) - _devbase_complete_flags "$ctx_flags" "$cur" - ;; - init) - if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--reset" -- "$cur")) - fi - ;; - list) - if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--global -g --project -p --reveal -r --keys -k" -- "$cur")) - fi - ;; - set|delete|edit) - if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--project -p" -- "$cur")) - fi - ;; - export) - if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--include-project --exclude-project --no-global --no-metadata --recipient --passphrase-env --passphrase-stdin --force-unencrypted --unsafe-allow-unencrypted-bucket" -- "$cur")) - fi - ;; - import) - if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--merge --replace-keys --replace --dry-run --identity --passphrase-env --passphrase-stdin --include-project --exclude-project --no-global --no-metadata --merge-metadata --backup-dir --keep-last" -- "$cur")) - fi - ;; - esac - fi - # plugin subcommand arguments - if [ "$group" = "plugin" ] || [ "$group" = "pl" ]; then - case "$prev" in - list) - COMPREPLY=($(compgen -W "--available" -- "$cur")) - ;; - install) - if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--link --all" -- "$cur")) - fi - ;; - uninstall|update|info) - local devbase_root - devbase_root="${DEVBASE_ROOT:-$(dirname "$(dirname "$(command -v devbase 2>/dev/null)")" 2>/dev/null)}" - local plugin_dir="${devbase_root}/plugins" - if [ -d "$plugin_dir" ]; then - local plugins - plugins=$(find "$plugin_dir" -mindepth 1 -maxdepth 1 -type d -o -type l | xargs -I{} basename {} 2>/dev/null) - COMPREPLY=($(compgen -W "$plugins" -- "$cur")) - fi - ;; - repo) - COMPREPLY=($(compgen -W "$repo_subcommands" -- "$cur")) - ;; - esac - fi - # snapshot subcommand arguments - if [ "$group" = "snapshot" ] || [ "$group" = "ss" ]; then - case "$prev" in - create) - if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--name --full" -- "$cur")) - fi - ;; - restore) - if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--point" -- "$cur")) - fi - ;; - rotate) - if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--keep" -- "$cur")) - fi - ;; - esac - fi + _devbase_complete_args ;; 4) - local group="${words[1]}" - # project: name 位置が埋まった後 (例: `project ps web -`) - # でもフラグを補完する。subcommand は words[2]。 - if [ "$group" = "project" ]; then - case "${words[2]}" in - ps) - _devbase_complete_flags "$ps_flags" "$cur" - ;; - logs) - _devbase_complete_flags "$logs_flags" "$cur" - ;; - open) - _devbase_complete_flags "$open_flags" "$cur" - ;; - up|down|rebuild|scale|post-start) - _devbase_complete_flags "$ctx_flags" "$cur" - ;; - esac - fi - # project / container の profile up|down|list の直後 (例: `project profile up -`) - if [ "$group" = "project" ] || [ "$group" = "container" ] || [ "$group" = "ct" ]; then - if [ "${words[2]}" = "profile" ]; then - case "$prev" in - up|down|list) - _devbase_complete_flags "$ctx_flags" "$cur" - ;; - esac - fi - fi - # plugin install flags after source argument - if [ "$group" = "plugin" ] || [ "$group" = "pl" ]; then - if [ "${words[2]}" = "install" ]; then - if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--link --all" -- "$cur")) - fi - fi - # plugin repo subcommand arguments - if [ "${words[2]}" = "repo" ]; then - case "$prev" in - add) - if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--name" -- "$cur")) - fi - ;; - remove|refresh) - local devbase_root - devbase_root="${DEVBASE_ROOT:-$(dirname "$(dirname "$(command -v devbase 2>/dev/null)")" 2>/dev/null)}" - local yml="${devbase_root}/plugins.yml" - if [ -f "$yml" ] && command -v python3 >/dev/null 2>&1; then - local repos - repos=$(python3 -c " -import yaml, sys -try: - d = yaml.safe_load(open('$yml')) - print(' '.join(r['name'] for r in (d or {}).get('repositories', []))) -except Exception: - pass -" 2>/dev/null) - COMPREPLY=($(compgen -W "$repos" -- "$cur")) - fi - ;; - esac - fi - fi + _devbase_complete_after_args ;; esac } diff --git a/lib/devbase/commands/env.py b/lib/devbase/commands/env.py index 8bd326ad..61f5a105 100644 --- a/lib/devbase/commands/env.py +++ b/lib/devbase/commands/env.py @@ -875,25 +875,31 @@ def _aws_sync_plan(ctx: _SyncContext): プロファイルの集合が値のプロファイルの集合と一致すれば丸ごと。値が読めない、または値にあって 今のファイルに無いプロファイルがあれば書かない (決定 10) """ - from devbase.env.collectors.aws import _encode_aws_config_files - from devbase.env.host_import import ALL - source = ctx.sources.get_source('aws') if source and source.get('type') == 'aws_profiles': return _aws_plan_for_selection(ctx, list(source.get('profiles') or [])) if source: - return _encode_aws_config_files, ALL + return _aws_whole_plan() return _aws_plan_for_unregistered(ctx) +def _aws_whole_plan(): + """``~/.aws`` を丸ごと入れ直す計画 ``(encode_fn, ALL)`` (I8)""" + from devbase.env.collectors.aws import _encode_aws_config_files + from devbase.env.host_import import ALL + + return _encode_aws_config_files, ALL + + def _aws_plan_for_selection(ctx: _SyncContext, profiles): """控えが ``aws_profiles`` のとき、選んだプロファイルとその連なりだけを切り出す計画 (I7)""" from devbase.env import aws_profiles + plan = (lambda: aws_profiles.build_from_home(profiles).encode()), profiles key = keys.AWS_CONFIG_BASE64 targets = ctx.targets if targets.holder(key) is None: - return (lambda: aws_profiles.build_from_home(profiles).encode()), profiles + return plan payload = aws_profiles.build_from_home(profiles) _report_unknown_profiles(payload) if _report_profile_conflicts(payload): @@ -905,14 +911,13 @@ def _aws_plan_for_selection(ctx: _SyncContext, profiles): "(選択はグループの控えのもの。入れ直すなら devbase env init --reset)", _AWS_LABEL, ctx.store.display_label(dest.ref)) return None - return (lambda: aws_profiles.build_from_home(profiles).encode()), profiles + return plan def _aws_plan_for_unregistered(ctx: _SyncContext): """控えに項目が無いとき、参照の値のプロファイルを選択とみなす計画 (決定 10)""" from devbase.env import aws_profiles from devbase.env.collectors.aws import _encode_aws_config_files - from devbase.env.host_import import ALL key = keys.AWS_CONFIG_BASE64 targets = ctx.targets @@ -931,7 +936,7 @@ def _aws_plan_for_unregistered(ctx: _SyncContext): current = (set(aws_profiles.profile_names(config_text)) | set(aws_profiles.credential_names(credentials_text))) if current == set(in_value): - return _encode_aws_config_files, ALL + return _aws_whole_plan() payload = aws_profiles.build(config_text, credentials_text, in_value) if payload.unknown: _report_unknown_profiles(payload) @@ -1687,7 +1692,6 @@ def cmd_env_keygen(devbase_root: Path, force: bool = False, 生成先と探索先が構造的に一致する契約を保つ。 """ from devbase.env import agekeys - from devbase.errors import DevbaseError path = agekeys.key_file_path() diff --git a/lib/devbase/volume/compose.py b/lib/devbase/volume/compose.py index 4817e3c9..b1e3a78e 100644 --- a/lib/devbase/volume/compose.py +++ b/lib/devbase/volume/compose.py @@ -15,6 +15,7 @@ from . import bind_mounts from .manager import ( + CONTAINER_HOME, VSCODE_BIN_TARGET, VSCODE_SERVER_TARGET, VSCODE_SERVER_VOLUME, @@ -27,7 +28,7 @@ logger = get_logger(__name__) # 旧 /home/ubuntu マウントは非推奨のため scale 生成時に除去する -_DEPRECATED_TARGET = '/home/ubuntu' +_DEPRECATED_TARGET = CONTAINER_HOME # VS Code Server の置き場 (PLAN36 / #463)。本体 (bin/) だけを全コンテナ共通の # named volume に置く。コンテナの書き込みレイヤに置くと devbase up のたびに diff --git a/lib/devbase/volume/manager.py b/lib/devbase/volume/manager.py index 2b445a9f..a85ba8e8 100644 --- a/lib/devbase/volume/manager.py +++ b/lib/devbase/volume/manager.py @@ -16,6 +16,8 @@ WORK_VOLUME_PREFIX = "devbase_work_" # 全コンテナで共有するホームディレクトリボリューム HOME_UBUNTU_VOLUME = "devbase_home_ubuntu" +#: コンテナの中の利用者 ubuntu のホーム +CONTAINER_HOME = '/home/ubuntu' # --- アカウントグループ (PLAN39 / #315) -------------------------------------- # アカウントグループは「使用する Google / AWS アカウントの単位」。グループごとに @@ -46,9 +48,9 @@ VSCODE_SERVER_VOLUME = "devbase_vscode-server-bin" #: VS Code Server の置き場。生成する compose のマウント先と、起動の後の処理が #: 所有者を直す先の両方がこれを使う -VSCODE_SERVER_TARGET = '/home/ubuntu/.vscode-server' +VSCODE_SERVER_TARGET = f'{CONTAINER_HOME}/.vscode-server' #: 本体の置き場。:data:`VSCODE_SERVER_VOLUME` を付ける先 -VSCODE_BIN_TARGET = '/home/ubuntu/.vscode-server/bin' +VSCODE_BIN_TARGET = f'{VSCODE_SERVER_TARGET}/bin' #: #463 より前の、~/.vscode-server を丸ごと持つコンテナごとのボリュームの接頭辞 LEGACY_VSCODE_VOLUME_PREFIX = "devbase_vscode_" _LEGACY_VSCODE_VOLUME_RE = re.compile(r"^devbase_vscode_.+_[0-9]+$") diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 62e9b9d6..47eec1da 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -23,6 +23,7 @@ REPO_ROOT = Path(__file__).resolve().parents[2] WRAPPER = REPO_ROOT / "bin" / "devbase" +BASH_COMPLETION = REPO_ROOT / "etc" / "devbase-completion.bash" _FAKE_UV = """\ #!/bin/bash @@ -213,3 +214,28 @@ def compose_wrapper(exec_wrapper) -> ComposeWrapperRoot: wrapper = ComposeWrapperRoot(root) wrapper.extra_env = {} return wrapper + + +def run_bash_completion(comp_words: str, cword: int, devbase_root) -> subprocess.CompletedProcess: + """bash 補完を source して ``_devbase_completions`` を実行し、COMPREPLY を改行区切りで出す。 + + ``comp_words`` は ``COMP_WORDS=(...)`` の中へそのまま入れる語の並び (引用符を含む)。 + ``DEVBASE_ROOT`` は ``devbase_root`` を渡し、実環境の値を継承しない。 + """ + script = f""" +set -e +source "{BASH_COMPLETION}" +COMP_WORDS=({comp_words}) +COMP_CWORD={cword} +_devbase_completions +printf '%s\\n' "${{COMPREPLY[@]}}" +""" + env = {**os.environ, "DEVBASE_ROOT": str(devbase_root)} + return subprocess.run(["bash", "-c", script], capture_output=True, text=True, env=env, check=False) + + +def bash_complete(comp_words: str, cword: int, devbase_root) -> list[str]: + """bash 補完を source して COMPREPLY を改行区切りで返す。""" + proc = run_bash_completion(comp_words, cword, devbase_root) + assert proc.returncode == 0, proc.stderr + return [line for line in proc.stdout.splitlines() if line] diff --git a/tests/cli/test_completion.py b/tests/cli/test_completion.py index 50562866..fe9ee8e8 100644 --- a/tests/cli/test_completion.py +++ b/tests/cli/test_completion.py @@ -8,34 +8,19 @@ from __future__ import annotations -import os import shutil import subprocess from pathlib import Path import pytest +from tests.cli.conftest import bash_complete as _bash_complete + REPO_ROOT = Path(__file__).resolve().parents[2] BASH_COMPLETION = REPO_ROOT / "etc" / "devbase-completion.bash" ZSH_COMPLETION = REPO_ROOT / "etc" / "_devbase" -def _bash_complete(words, cword, devbase_root): - """bash 補完を source して COMPREPLY を改行区切りで返す。""" - script = f""" -set -e -source "{BASH_COMPLETION}" -COMP_WORDS=({words}) -COMP_CWORD={cword} -_devbase_completions -printf '%s\\n' "${{COMPREPLY[@]}}" -""" - env = {**os.environ, "DEVBASE_ROOT": str(devbase_root)} - proc = subprocess.run(["bash", "-c", script], capture_output=True, text=True, env=env) - assert proc.returncode == 0, proc.stderr - return [line for line in proc.stdout.splitlines() if line] - - @pytest.fixture def fake_root(tmp_path): projects = tmp_path / "projects" @@ -281,3 +266,132 @@ def test_bash_container_flags_only_without_names(fake_root, words, cword, expect ]) def test_bash_context_value_is_not_completed(fake_root, words, cword): assert _bash_complete(words, cword, fake_root) == [] + + +# #452: bash の補完の各分岐の候補の現状固定(R9・R10 で分岐を関数へ移す前に縛る) +_PLUGINS = {"alpha", "beta"} +_REPOS = ["official", "extra"] +_ENV_EXPORT_FLAGS = ["--include-project", "--exclude-project", "--no-global", "--no-metadata", + "--recipient", "--passphrase-env", "--passphrase-stdin", "--force-unencrypted", + "--unsafe-allow-unencrypted-bucket"] +_ENV_IMPORT_FLAGS = ["--merge", "--replace-keys", "--replace", "--dry-run", "--identity", + "--passphrase-env", "--passphrase-stdin", "--include-project", "--exclude-project", + "--no-global", "--no-metadata", "--merge-metadata", "--backup-dir", "--keep-last"] +_LIST_FLAGS = ["--no-interactive", "--plain", "-P", "--interactive", "-i"] +_CONTAINER_SUBCOMMANDS = ["up", "down", "ps", "login", "logs", "scale", "build", "rebuild", "profile", "open"] +_ENV_SUBCOMMANDS = ["init", "sync", "list", "set", "get", "delete", "edit", "project", "export", "import", + "keygen", "exec", "token", "encrypt", "decrypt", "rekey", "doctor", "backend"] +_PLUGIN_SUBCOMMANDS = ["list", "install", "uninstall", "update", "info", "sync", "repo"] +_SNAPSHOT_SUBCOMMANDS = ["create", "list", "restore", "copy", "delete", "rotate"] + + +@pytest.fixture +def fake_root_with_plugins(fake_root): + """プラグインの置き場(ディレクトリと symlink)と plugins.yml を持つ root。""" + plugins = fake_root / "plugins" + plugins.mkdir() + (plugins / "alpha").mkdir() + (fake_root / "beta_target").mkdir() + (plugins / "beta").symlink_to(fake_root / "beta_target") + (fake_root / "plugins.yml").write_text( + "repositories:\n - name: official\n - name: extra\n") + return fake_root + + +@pytest.mark.parametrize("words, cword, expected", [ + # 位置 2: 1 語目ごと + ("devbase login ''", 2, ["1", "2"]), + ("devbase login '-'", 2, ["--context"]), + ("devbase build ''", 2, []), + ("devbase build '-'", 2, ["--context"]), + ("devbase scale '-'", 2, ["--context"]), + ("devbase rebuild ''", 2, _NAMES), + ("devbase list ''", 2, _LIST_FLAGS), + ("devbase list '-'", 2, _LIST_FLAGS), + ("devbase container ''", 2, _CONTAINER_SUBCOMMANDS), + ("devbase ct ''", 2, _CONTAINER_SUBCOMMANDS), + ("devbase env ''", 2, _ENV_SUBCOMMANDS), + ("devbase plugin ''", 2, _PLUGIN_SUBCOMMANDS), + ("devbase pl ''", 2, _PLUGIN_SUBCOMMANDS), + ("devbase snapshot ''", 2, _SNAPSHOT_SUBCOMMANDS), + ("devbase ss ''", 2, _SNAPSHOT_SUBCOMMANDS), + ("devbase status ''", 2, []), + # 位置 3: トップレベルのシノニム + ("devbase open web '-'", 3, ["--open-index", "--context"]), + ("devbase up web '-'", 3, ["--context"]), + ("devbase scale web ''", 3, []), + # 位置 3: project + ("devbase project login ''", 3, ["1", "2"]), + ("devbase project login '-'", 3, ["--context"]), + ("devbase project build '-'", 3, ["--context"]), + ("devbase project build ''", 3, []), + ("devbase project rebuild '-'", 3, ["--context"]), + ("devbase project profile ''", 3, ["up", "down", "list"]), + # 位置 3: container / ct + ("devbase container up '-'", 3, ["--context"]), + ("devbase container up ''", 3, []), + ("devbase ct build '-'", 3, ["--context"]), + ("devbase container login ''", 3, ["1", "2"]), + ("devbase container scale ''", 3, ["1", "2", "3", "4", "5"]), + ("devbase ct scale '-'", 3, ["--context"]), + # 位置 3: env + ("devbase env exec '-'", 3, ["--context"]), + ("devbase env token '-'", 3, ["--context"]), + ("devbase env init '-'", 3, ["--reset"]), + ("devbase env init ''", 3, []), + ("devbase env list '-'", 3, ["--global", "-g", "--project", "-p", "--reveal", "-r", "--keys", "-k"]), + ("devbase env set '-'", 3, ["--project", "-p"]), + ("devbase env delete '-'", 3, ["--project", "-p"]), + ("devbase env edit '-'", 3, ["--project", "-p"]), + ("devbase env export '-'", 3, _ENV_EXPORT_FLAGS), + ("devbase env import '-'", 3, _ENV_IMPORT_FLAGS), + ("devbase env sync '-'", 3, []), + # 位置 3: plugin / pl + ("devbase plugin list ''", 3, ["--available"]), + ("devbase pl install '-'", 3, ["--link", "--all"]), + ("devbase plugin install ''", 3, []), + ("devbase plugin uninstall ''", 3, _PLUGINS), + ("devbase pl update ''", 3, _PLUGINS), + ("devbase plugin info ''", 3, _PLUGINS), + ("devbase plugin repo ''", 3, ["add", "remove", "list", "refresh"]), + # 位置 3: snapshot / ss + ("devbase snapshot create '-'", 3, ["--name", "--full"]), + ("devbase ss restore '-'", 3, ["--point"]), + ("devbase snapshot rotate '-'", 3, ["--keep"]), + ("devbase snapshot create ''", 3, []), + # 位置 4 + ("devbase project open web '-'", 4, ["--open-index", "--context"]), + ("devbase project up web '-'", 4, ["--context"]), + ("devbase project down web '-'", 4, ["--context"]), + ("devbase project rebuild web '-'", 4, ["--context"]), + ("devbase project scale web '-'", 4, ["--context"]), + ("devbase project post-start web '-'", 4, ["--context"]), + ("devbase project up web ''", 4, []), + ("devbase project profile up '-'", 4, ["--context"]), + ("devbase container profile down '-'", 4, ["--context"]), + ("devbase ct profile list '-'", 4, ["--context"]), + ("devbase container ps web '-'", 4, []), + ("devbase plugin install src '-'", 4, ["--link", "--all"]), + ("devbase pl install src ''", 4, []), + ("devbase plugin repo add '-'", 4, ["--name"]), + ("devbase env set KEY '-'", 4, []), +]) +def test_bash_completion_branches(fake_root_with_plugins, words, cword, expected): + out = _bash_complete(words, cword, fake_root_with_plugins) + if isinstance(expected, set): + assert set(out) == expected + else: + assert out == expected + + +def _python3_has_yaml(): + python3 = shutil.which("python3") + if python3 is None: + return False + return subprocess.run([python3, "-c", "import yaml"], capture_output=True).returncode == 0 + + +@pytest.mark.skipif(not _python3_has_yaml(), reason="python3 か yaml が無い(リポジトリ名は python3 で plugins.yml を読む)") +@pytest.mark.parametrize("words", ["devbase pl repo remove ''", "devbase plugin repo refresh ''"]) +def test_bash_completion_plugin_repo_names(fake_root_with_plugins, words): + assert _bash_complete(words, 4, fake_root_with_plugins) == _REPOS diff --git a/tests/cli/test_name_context_consistency.py b/tests/cli/test_name_context_consistency.py index 5d98286d..08e78f6a 100644 --- a/tests/cli/test_name_context_consistency.py +++ b/tests/cli/test_name_context_consistency.py @@ -18,18 +18,16 @@ from __future__ import annotations import argparse -import os import re -import subprocess from pathlib import Path import pytest from devbase import cli +from tests.cli.conftest import run_bash_completion REPO_ROOT = Path(__file__).resolve().parents[2] WRAPPER = REPO_ROOT / "bin" / "devbase" -BASH_COMPLETION = REPO_ROOT / "etc" / "devbase-completion.bash" ZSH_COMPLETION = REPO_ROOT / "etc" / "_devbase" SPEC = REPO_ROOT / "docs" / "specifications" / "cli-argument-resolution.md" @@ -178,16 +176,7 @@ def fake_root(tmp_path): def _bash_complete(words, devbase_root, cur=""): """`devbase ` の最後の位置で bash 補完を実行し、候補を返す。""" quoted = " ".join(f'"{w}"' for w in ("devbase",) + tuple(words) + (cur,)) - script = f""" -set -e -source "{BASH_COMPLETION}" -COMP_WORDS=({quoted}) -COMP_CWORD={len(words) + 1} -_devbase_completions -printf '%s\\n' "${{COMPREPLY[@]}}" -""" - env = {**os.environ, "DEVBASE_ROOT": str(devbase_root)} - proc = subprocess.run(["bash", "-c", script], capture_output=True, text=True, env=env, check=False) + proc = run_bash_completion(quoted, len(words) + 1, devbase_root) assert proc.returncode == 0, f"etc/devbase-completion.bash の実行に失敗: {words} {proc.stderr}" return {line for line in proc.stdout.splitlines() if line} diff --git a/tests/cli/test_post_start_command.py b/tests/cli/test_post_start_command.py index 40dc7f4c..f7846d17 100644 --- a/tests/cli/test_post_start_command.py +++ b/tests/cli/test_post_start_command.py @@ -6,18 +6,16 @@ from __future__ import annotations -import os -import subprocess import sys from pathlib import Path import pytest from devbase import cli +from tests.cli.conftest import bash_complete as _bash_complete from tests.cli.conftest import python_args, stdout_field REPO_ROOT = Path(__file__).resolve().parents[2] -BASH_COMPLETION = REPO_ROOT / "etc" / "devbase-completion.bash" ZSH_COMPLETION = REPO_ROOT / "etc" / "_devbase" @@ -78,21 +76,6 @@ def test_wrapper_project_post_start_name_cds_and_strips(exec_wrapper): assert python_args(r) == "project post-start", r.stdout -def _bash_complete(words, cword, devbase_root): - script = f""" -set -e -source "{BASH_COMPLETION}" -COMP_WORDS=({words}) -COMP_CWORD={cword} -_devbase_completions -printf '%s\\n' "${{COMPREPLY[@]}}" -""" - env = {**os.environ, "DEVBASE_ROOT": str(devbase_root)} - proc = subprocess.run(["bash", "-c", script], capture_output=True, text=True, env=env) - assert proc.returncode == 0, proc.stderr - return [line for line in proc.stdout.splitlines() if line] - - def test_bash_completion_lists_post_start_only_under_project(tmp_path): (tmp_path / "projects" / "web").mkdir(parents=True) diff --git a/tests/conftest.py b/tests/conftest.py index e9e247a5..2668948b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -315,21 +315,38 @@ def do_DELETE(self): def do_GET(self): rec = self._record('GET') - state = self.server_state path = rec.kv_path + if self._refuse_read(rec, path): + return None + return self._read_secret(path) + + def _refuse_read(self, rec: Received, path: Optional[str]) -> bool: + """読み出しの前の拒否 (mount・認可・禁止) と、失敗と応答の注入。応答を返したら真""" + state = self.server_state if path is None or not self._mount_ok(rec): - return self._send(404, {'errors': []}) + self._send(404, {'errors': []}) + return True if not self._authorized(rec): - return self._send(403, {'errors': ['permission denied']}) + self._send(403, {'errors': ['permission denied']}) + return True if self._forbidden(path): - return self._send(403, {'errors': ['permission denied']}) + self._send(403, {'errors': ['permission denied']}) + return True state.get_attempts += 1 if state.get_attempts in state.fail_get_attempts: - return self._send(503, {'errors': ['unavailable']}) + self._send(503, {'errors': ['unavailable']}) + return True if state.get_body is not None: - return self._send(state.get_status or 200, raw=state.get_body) + self._send(state.get_status or 200, raw=state.get_body) + return True if state.get_status is not None: - return self._send(state.get_status, {'errors': ['error']}) + self._send(state.get_status, {'errors': ['error']}) + return True + return False + + def _read_secret(self, path: str): + """保存した値から応答を組み立てる (無い・論理削除済み・読み戻しの改ざんを当てた値)""" + state = self.server_state if path not in state.versions: return self._send(404, {'errors': []}) version = state.versions[path] diff --git a/tests/containers/docker_images.py b/tests/containers/docker_images.py new file mode 100644 index 00000000..654aad7c --- /dev/null +++ b/tests/containers/docker_images.py @@ -0,0 +1,36 @@ +"""手元の Docker とイメージが使えるかを調べる共通の部品。 + +建てたイメージの中身を見るテストが、使えないときに飛ばす理由を作るのに使う。Docker を起動するため、 +Docker を起動せずに Dockerfile の形を読む ``dockerfile_parse.py`` とは分けて置く。 +``docker_images.py`` は ``test_`` で始まらないため pytest は収集しない。 +""" + +from __future__ import annotations + +import shutil +import subprocess + +from tests.conftest import host_docker_env + + +def image_unavailable(image: str, missing_suffix: str = "") -> str | None: + """Docker が使えない / ``image`` が無い理由。使えるなら None + + docker の有無 → daemon → イメージの有無の順に調べる。イメージが無いときの文は + ``f"{image} が無い{missing_suffix}"`` で、建て方の案内は呼び出し側が ``missing_suffix`` で渡す。 + """ + if shutil.which("docker") is None: + return "docker が PATH に無い" + try: + subprocess.run(["docker", "info"], capture_output=True, timeout=30, check=True, + env=host_docker_env()) + except (subprocess.SubprocessError, OSError): + return "docker daemon へ繋がらない" + try: + out = subprocess.run(["docker", "image", "inspect", image], + capture_output=True, timeout=30, env=host_docker_env()) + except (subprocess.SubprocessError, OSError): + return f"{image} を調べられない" + if out.returncode != 0: + return f"{image} が無い{missing_suffix}" + return None diff --git a/tests/containers/dockerfile_parse.py b/tests/containers/dockerfile_parse.py index e4e5d95c..41d19c5f 100644 --- a/tests/containers/dockerfile_parse.py +++ b/tests/containers/dockerfile_parse.py @@ -49,3 +49,27 @@ def parse(text: str) -> list[Instruction]: keyword, _, args = joined.partition(" ") instructions.append(Instruction(keyword.upper(), args.strip())) return instructions + + +def run_blocks(text: str) -> list[str]: + """Dockerfile を RUN ブロック単位 (行継続を含む 1 命令分) に分ける + + ``RUN`` の本文は ``\\`` の行継続で複数行にまたがる。``RUN`` で始まる**行**だけを + 見ると、拾えるのは 1 行目 (``RUN set -eux; \\``) だけでパッケージ名は 1 つも + 入らない。命令ごとに継続行まで連結してから検査する。 + """ + blocks: list[str] = [] + block: list[str] | None = None + for line in text.splitlines(): + if block is None: + if not line.startswith("RUN "): + continue + block = [] + block.append(line) + if not line.rstrip().endswith("\\"): + blocks.append("\n".join(block)) + block = None + if block is not None: # 最終行が \ で終わっていても取りこぼさない + blocks.append("\n".join(block)) + assert blocks, "RUN が 1 つも見つからない" + return blocks diff --git a/tests/containers/test_base_dockerfile_fonts.py b/tests/containers/test_base_dockerfile_fonts.py index 0df33109..c57f7eb3 100644 --- a/tests/containers/test_base_dockerfile_fonts.py +++ b/tests/containers/test_base_dockerfile_fonts.py @@ -24,6 +24,8 @@ import pytest +from .dockerfile_parse import run_blocks + BASE_DIR = Path(__file__).resolve().parents[2] / "containers" / "base" DOCKERFILE = BASE_DIR / "Dockerfile" FONTS_CONF = BASE_DIR / "fonts-local.conf" @@ -73,28 +75,8 @@ def _statements() -> str: def _run_blocks() -> list[str]: - """Dockerfile を RUN ブロック単位 (行継続を含む 1 命令分) に分ける - - ``RUN`` の本文は ``\\`` の行継続で複数行にまたがる。``RUN`` で始まる**行**だけを - 見ると、拾えるのは 1 行目 (``RUN set -eux; \\``) だけでパッケージ名は 1 つも - 入らない。命令ごとに継続行まで連結してから検査する。 - """ - lines = _statements().splitlines() - blocks: list[str] = [] - block: list[str] | None = None - for line in lines: - if block is None: - if not line.startswith("RUN "): - continue - block = [] - block.append(line) - if not line.rstrip().endswith("\\"): - blocks.append("\n".join(block)) - block = None - if block is not None: # 最終行が \ で終わっていても取りこぼさない - blocks.append("\n".join(block)) - assert blocks, "RUN が 1 つも見つからない" - return blocks + """Dockerfile を RUN ブロック単位 (行継続を含む 1 命令分) に分ける""" + return run_blocks(_statements()) def _first_run_block() -> str: diff --git a/tests/containers/test_base_dockerfile_shellcheck.py b/tests/containers/test_base_dockerfile_shellcheck.py index 9b9d8b44..a8f71eda 100644 --- a/tests/containers/test_base_dockerfile_shellcheck.py +++ b/tests/containers/test_base_dockerfile_shellcheck.py @@ -19,6 +19,8 @@ import yaml +from .dockerfile_parse import run_blocks + REPO_ROOT = Path(__file__).resolve().parents[2] DOCKERFILE = REPO_ROOT / "containers" / "base" / "Dockerfile" CI_YML = REPO_ROOT / ".github" / "workflows" / "ci.yml" @@ -34,25 +36,8 @@ def _statements() -> str: def _run_blocks() -> list[str]: - """Dockerfile を RUN ブロック単位 (行継続を含む 1 命令分) に分ける - - ``RUN`` で始まる**行**だけを見ると、行継続の先にあるパッケージ名を 1 つも拾えない。 - """ - blocks: list[str] = [] - block: list[str] | None = None - for line in _statements().splitlines(): - if block is None: - if not line.startswith("RUN "): - continue - block = [] - block.append(line) - if not line.rstrip().endswith("\\"): - blocks.append("\n".join(block)) - block = None - if block is not None: # 最終行が \ で終わっていても取りこぼさない - blocks.append("\n".join(block)) - assert blocks, "RUN が 1 つも見つからない" - return blocks + """Dockerfile を RUN ブロック単位 (行継続を含む 1 命令分) に分ける""" + return run_blocks(_statements()) def _first_apt_install(block: str) -> str: diff --git a/tests/containers/test_base_image_font_matching.py b/tests/containers/test_base_image_font_matching.py index a11de9a1..a9db435a 100644 --- a/tests/containers/test_base_image_font_matching.py +++ b/tests/containers/test_base_image_font_matching.py @@ -16,13 +16,14 @@ from __future__ import annotations -import shutil import subprocess import pytest from tests.conftest import host_docker_env +from .docker_images import image_unavailable + IMAGE = "devbase-base:latest" BUILD_HINT = f"`devbase build base --no-cache` で {IMAGE} を建て直すと、この検査が効く" @@ -125,21 +126,7 @@ def _docker_unavailable() -> str | None: """Docker が使えない / イメージが無い理由。使えるなら None""" - if shutil.which("docker") is None: - return "docker が PATH に無い" - try: - subprocess.run(["docker", "info"], capture_output=True, timeout=30, check=True, - env=host_docker_env()) - except (subprocess.SubprocessError, OSError): - return "docker daemon へ繋がらない" - try: - out = subprocess.run(["docker", "image", "inspect", IMAGE], - capture_output=True, timeout=30, env=host_docker_env()) - except (subprocess.SubprocessError, OSError): - return f"{IMAGE} を調べられない" - if out.returncode != 0: - return f"{IMAGE} が無い。{BUILD_HINT}" - return None + return image_unavailable(IMAGE, f"。{BUILD_HINT}") @pytest.fixture(scope="session") diff --git a/tests/containers/test_base_image_link_stage.py b/tests/containers/test_base_image_link_stage.py index fbecd355..38d87eea 100644 --- a/tests/containers/test_base_image_link_stage.py +++ b/tests/containers/test_base_image_link_stage.py @@ -13,7 +13,6 @@ from __future__ import annotations -import shutil import subprocess import uuid from pathlib import Path @@ -22,6 +21,8 @@ from tests.conftest import host_docker_env +from .docker_images import image_unavailable + IMAGE = "devbase-base:latest" ENTRYPOINT = Path(__file__).resolve().parents[2] / "containers" / "base" / "entrypoint.sh" # 1 回ぶんに docker run が 10 回要る。並列で流すとほかのケースと重なり 1 回 15 秒を超えるため絞る @@ -38,19 +39,7 @@ def _docker(*args: str, timeout: float = 120, check: bool = True) -> subprocess. def _docker_unavailable() -> str | None: - if shutil.which("docker") is None: - return "docker が PATH に無い" - try: - _docker("info", timeout=30) - except (subprocess.SubprocessError, OSError): - return "docker daemon へ繋がらない" - try: - out = _docker("image", "inspect", IMAGE, timeout=30, check=False) - except (subprocess.SubprocessError, OSError): - return f"{IMAGE} を調べられない" - if out.returncode != 0: - return f"{IMAGE} が無い" - return None + return image_unavailable(IMAGE) @pytest.fixture diff --git a/tests/containers/test_base_image_without_browser.py b/tests/containers/test_base_image_without_browser.py index f234ddc8..85933ffa 100644 --- a/tests/containers/test_base_image_without_browser.py +++ b/tests/containers/test_base_image_without_browser.py @@ -22,13 +22,14 @@ from __future__ import annotations import os -import shutil import subprocess import pytest from tests.conftest import host_docker_env +from .docker_images import image_unavailable + DEFAULT_IMAGE = "devbase-base:latest" _IMAGE_FROM_ENV = os.environ.get("DEVBASE_TEST_BASE_IMAGE", "").strip() IMAGE = _IMAGE_FROM_ENV or DEFAULT_IMAGE @@ -76,22 +77,7 @@ def _docker_unavailable() -> str | None: - if shutil.which("docker") is None: - return "docker が PATH に無い" - try: - subprocess.run(["docker", "info"], capture_output=True, timeout=30, check=True, - env=host_docker_env()) - except (subprocess.SubprocessError, OSError): - return "docker daemon へ繋がらない" - try: - out = subprocess.run(["docker", "image", "inspect", IMAGE], - capture_output=True, timeout=30, env=host_docker_env()) - except (subprocess.SubprocessError, OSError): - return f"{IMAGE} を調べられない" - if out.returncode != 0: - hint = "" if IMAGE_IS_EXPLICIT else f"。{BUILD_HINT}" - return f"{IMAGE} が無い{hint}" - return None + return image_unavailable(IMAGE, "" if IMAGE_IS_EXPLICIT else f"。{BUILD_HINT}") @pytest.fixture(scope="session") diff --git a/tests/containers/test_browser_image.py b/tests/containers/test_browser_image.py index 556b5371..d445c0ca 100644 --- a/tests/containers/test_browser_image.py +++ b/tests/containers/test_browser_image.py @@ -29,13 +29,14 @@ from __future__ import annotations import os -import shutil import subprocess import pytest from tests.conftest import host_docker_env +from .docker_images import image_unavailable + DEFAULT_IMAGE = "devbase-browser:latest" # 隔離の fixture より前 (読み込み時) に読む。隔離の一覧に入っていないが、入っても効くように _IMAGE_FROM_ENV = os.environ.get("DEVBASE_TEST_BROWSER_IMAGE", "").strip() @@ -114,22 +115,7 @@ def _docker_unavailable() -> str | None: """Docker が使えない / イメージが無い理由。使えるなら None""" - if shutil.which("docker") is None: - return "docker が PATH に無い" - try: - subprocess.run(["docker", "info"], capture_output=True, timeout=30, check=True, - env=host_docker_env()) - except (subprocess.SubprocessError, OSError): - return "docker daemon へ繋がらない" - try: - out = subprocess.run(["docker", "image", "inspect", IMAGE], - capture_output=True, timeout=30, env=host_docker_env()) - except (subprocess.SubprocessError, OSError): - return f"{IMAGE} を調べられない" - if out.returncode != 0: - hint = "" if IMAGE_IS_EXPLICIT else f"。{BUILD_HINT}" - return f"{IMAGE} が無い{hint}" - return None + return image_unavailable(IMAGE, "" if IMAGE_IS_EXPLICIT else f"。{BUILD_HINT}") @pytest.fixture(scope="session") diff --git a/tests/volume/conftest.py b/tests/volume/conftest.py index 1705fa8e..b2903691 100644 --- a/tests/volume/conftest.py +++ b/tests/volume/conftest.py @@ -4,7 +4,8 @@ へ置いてから構成を作る (#315)。構成の生成とボリュームの作成を単独で呼ぶテストは、その後の 状態 (グループが決まっている) を前提にする。未設定の振る舞いを見るテストは自分で消す。 -compose の生成を見るテストが共有する補助 (``write_compose``・``load_scaled``・``mount_source``) +compose の生成を見るテストが共有する補助 (``write_compose``・``load_scaled``・``mount_source``・ +``DEVBASE_MANAGED``) もここに置き、``from tests.volume.conftest import ...`` で取り込む。 """ @@ -14,6 +15,16 @@ import yaml +# devbase 自身が dev サービスへ常に載せる環境変数 (PLAN39)。``DEVBASE_ACCOUNT_GROUP`` の値は +# 下の ``_declared_account_group`` が置く ``acme`` に対応する。 +DEVBASE_MANAGED = { + "DEVBASE_ACCOUNT_GROUP": "acme", + "CLOUDSDK_CONFIG": "/persistent/group/gcloud", + "GOOGLE_WORKSPACE_CLI_CONFIG_DIR": "/persistent/group/gws", + "GCP_AUTH_MODE": "adc", +} + + @pytest.fixture(autouse=True) def _declared_account_group(_isolate_env, monkeypatch): monkeypatch.setenv('DEVBASE_ACCOUNT_GROUP', 'acme') diff --git a/tests/volume/test_compose_dev_environment.py b/tests/volume/test_compose_dev_environment.py index 8f3c29a7..5e5801c2 100644 --- a/tests/volume/test_compose_dev_environment.py +++ b/tests/volume/test_compose_dev_environment.py @@ -8,6 +8,7 @@ import yaml from devbase.volume.compose import generate_scaled_compose +from tests.volume.conftest import DEVBASE_MANAGED COMPOSE_DICT_ENV = """services: @@ -45,17 +46,6 @@ REPO_ENV = {"DEVBASE_REPOS": "cGxhbg==", "DEVBASE_PRIMARY_DIR": "myapp"} -# devbase 自身が dev サービスへ常に載せる環境変数 (PLAN39)。 -# 個々のテストの期待値からは除いて比較し、内容そのものは -# test_devbase_managed_environment_is_always_present で固定する。 -DEVBASE_MANAGED = { - "DEVBASE_ACCOUNT_GROUP": "acme", - "CLOUDSDK_CONFIG": "/persistent/group/gcloud", - "GOOGLE_WORKSPACE_CLI_CONFIG_DIR": "/persistent/group/gws", - "GCP_AUTH_MODE": "adc", -} - - @pytest.fixture def project(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) @@ -83,6 +73,9 @@ def env_of(service) -> dict: return dict(entry.split("=", 1) for entry in environment) +# devbase 自身が dev サービスへ常に載せる環境変数 (PLAN39、DEVBASE_MANAGED)。 +# 個々のテストの期待値からは除いて比較し、内容そのものは +# test_devbase_managed_environment_is_always_present で固定する。 def user_env(service) -> dict: """devbase 自身が載せる変数を除いた environment""" return {k: v for k, v in env_of(service).items() if k not in DEVBASE_MANAGED} diff --git a/tests/volume/test_compose_secret_env.py b/tests/volume/test_compose_secret_env.py index f471aae2..24210dbe 100644 --- a/tests/volume/test_compose_secret_env.py +++ b/tests/volume/test_compose_secret_env.py @@ -8,18 +8,11 @@ import yaml from devbase.volume.compose import generate_scaled_compose +from tests.volume.conftest import DEVBASE_MANAGED -# devbase 自身が dev サービスへ常に載せる変数 (PLAN39)。機密ではないので、 +# devbase 自身が dev サービスへ常に載せる変数 (PLAN39、DEVBASE_MANAGED は conftest)。機密ではないので、 # 「機密の渡し方」を見るこのファイルの期待値からは除いて比較する。 -DEVBASE_MANAGED = { - "DEVBASE_ACCOUNT_GROUP": "acme", - "CLOUDSDK_CONFIG": "/persistent/group/gcloud", - "GOOGLE_WORKSPACE_CLI_CONFIG_DIR": "/persistent/group/gws", - "GCP_AUTH_MODE": "adc", -} - - def _without_managed(environment): """environment から devbase 由来の変数を除く (map / list の記法は保つ)""" if isinstance(environment, dict):