Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bin/devbase
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ run_python() {
# Resolve abbreviated command to full command name via unique prefix matching
resolve_command() {
local input="$1"
local commands="init status project container ct env plugin pl snapshot ss up down login build rebuild ps scale list help"
local commands="init status project container ct env plugin pl snapshot ss orca up down login build rebuild ps scale list help"
local matches=()
for cmd in $commands; do
[[ "$cmd" == "$input"* ]] && matches+=("$cmd")
Expand Down Expand Up @@ -402,7 +402,7 @@ case "$_resolved_cmd" in
# Python-implemented commands
--version|-V)
run_python "$@" ;;
init|status|project|container|ct|env|plugin|pl|snapshot|ss|up|down|login|ps|scale|rebuild|list)
init|status|project|container|ct|env|plugin|pl|snapshot|ss|orca|up|down|login|ps|scale|rebuild|list)
run_python "${_resolved_cmd}" "${_DEVBASE_ARGS[@]}" ;;
# Shell-implemented commands
#
Expand Down
19 changes: 18 additions & 1 deletion lib/devbase/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
('env',): ['init', 'sync', 'list', 'set', 'get', 'delete', 'edit', 'project', 'export', 'import'],
('plugin', 'pl'): ['list', 'install', 'uninstall', 'update', 'info', 'sync', 'repo', 'migrate'],
('snapshot', 'ss'): ['create', 'list', 'restore', 'copy', 'delete', 'rotate'],
('orca',): ['sync', 'prune', 'status'],
}

# 後方互換: prefix が複数候補にマッチする場合に、特定の入力を特定のサブコマンドに
Expand Down Expand Up @@ -452,6 +453,20 @@ def _add_snapshot_parser(subparsers):
s_rotate.add_argument('--keep', type=int, default=3, help='Generations to keep')


def _add_orca_parser(subparsers):
"""Orca group parser (PLAN33)。

Orca 用の隔離 SSH config を生成/剪定/表示する。sync/prune/status いずれも
追加の引数を取らない (稼働中コンテナから毎回全再生成する)。
"""
orca_parser = subparsers.add_parser('orca', help='Manage the Orca SSH config')
orca_sub = orca_parser.add_subparsers(dest='subcommand')

orca_sub.add_parser('sync', help='Regenerate the Orca SSH config from running containers')
orca_sub.add_parser('prune', help='Remove stopped-container entries (= regenerate)')
orca_sub.add_parser('status', help='Show the Orca SSH config path, contents, and import steps')


def _add_shortcuts(subparsers):
"""Top-level shortcut parsers.

Expand Down Expand Up @@ -535,6 +550,7 @@ def _create_parser():
_add_env_parser(subparsers)
_add_plugin_parser(subparsers)
_add_snapshot_parser(subparsers)
_add_orca_parser(subparsers)
_add_shortcuts(subparsers)

return parser
Expand Down Expand Up @@ -569,7 +585,7 @@ def _expand_argv():
# bin/devbase が build を shell 実装に委譲するため Python 側には top-level
# build parser が無い。project build / container build は引き続き利用可能。
commands = ['init', 'status', 'project', 'container', 'ct', 'env', 'plugin', 'pl',
'snapshot', 'ss', 'up', 'down', 'login', 'ps', 'scale', 'rebuild', 'list', 'help']
'snapshot', 'ss', 'orca', 'up', 'down', 'login', 'ps', 'scale', 'rebuild', 'list', 'help']
repo_subcmds = ['add', 'remove', 'list', 'refresh']

if len(sys.argv) >= 2 and not sys.argv[1].startswith('-'):
Expand Down Expand Up @@ -618,6 +634,7 @@ def main():
'env': ('devbase.commands.env', 'cmd_env', True),
'plugin': ('devbase.commands.plugin', 'cmd_plugin', True),
'snapshot': ('devbase.commands.snapshot', 'cmd_snapshot', True),
'orca': ('devbase.commands.orca', 'cmd_orca', True),
}


Expand Down
40 changes: 40 additions & 0 deletions lib/devbase/commands/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,40 @@ def _auto_snapshot() -> None:
logger.warning("スナップショットの自動作成に失敗しましたがデプロイは続行します: %s", e)


def _ssh_enabled() -> bool:
"""ENABLE_SSH が真値 (true/1) かどうか (compose.py の判定と揃える)。"""
return os.environ.get('ENABLE_SSH', '').lower() in ('true', '1')


def _maybe_orca_sync() -> None:
"""up 完了後に Orca 用 SSH config を best-effort で再生成する (PLAN33)。

ENABLE_SSH が有効なときのみ実行する (SSH 無効なら同期不要)。失敗しても
warning のみで up の戻り値には影響させない。import は遅延させて起動コストを避ける。
"""
if not _ssh_enabled():
return
try:
from devbase.commands.orca import regenerate_config
targets, path = regenerate_config()
logger.info("Orca SSH config を同期しました (%d 件): %s", len(targets), path)
except Exception as e: # noqa: BLE001 - Orca 同期で up を倒さない
logger.warning("Orca SSH config の同期に失敗しましたがデプロイは成功しています: %s", e)


def _maybe_orca_prune() -> None:
"""down 後に Orca 用 SSH config を best-effort で剪定する (PLAN33)。

稼働中コンテナから再生成するだけで停止済みエントリは自然に落ちる (prune ≡
regenerate)。ENABLE_SSH の有無に依らず実行してよい。失敗しても warning のみ。
"""
try:
from devbase.commands.orca import regenerate_config
regenerate_config()
except Exception as e: # noqa: BLE001 - Orca 剪定で down を倒さない
logger.warning("Orca SSH config の剪定に失敗しました: %s", e)


def _resolve_open_index(open_index: Optional[int], scale: int) -> int:
"""開く dev インスタンス番号を解決する (CLI 引数 → env ``DEVBASE_OPEN_INDEX`` → 既定 1)。

Expand Down Expand Up @@ -574,6 +608,9 @@ def cmd_up(project_name: str = None, scale: int = None,
_maybe_open_editor(project_name, open_editor, open_index, scale,
compose_file=override_file)

# Orca 連携: SSH 有効時に隔離 SSH config を再生成する (PLAN33)。
_maybe_orca_sync()

logger.info("=== Deploy completed successfully ===")
return 0

Expand Down Expand Up @@ -603,6 +640,9 @@ def cmd_down() -> int:
except Exception as e:
logger.warning("スナップショットのローテーションに失敗: %s", e)

# Orca 連携: 停止したコンテナのエントリを隔離 SSH config から剪定する (PLAN33)。
_maybe_orca_prune()

return 0


Expand Down
262 changes: 262 additions & 0 deletions lib/devbase/commands/orca.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
"""devbase orca ... — Orca 用の隔離 SSH config を生成/剪定/表示する (PLAN33)。

Orca (https://www.onorca.dev/) から devbase コンテナへ SSH 接続するため、稼働中の
SSH publish 済みコンテナを列挙して専用ファイル ``~/.config/devbase/orca/ssh_config``
を全生成する。ホストの ``~/.ssh/config`` は一切触らず、Orca にはこのファイルだけを
import させることで他ホストとの隔離を実現する。

サブコマンド:
- ``sync`` : 稼働中コンテナを集約して config を再生成 (毎回上書き)。
- ``prune`` : 停止済みコンテナのエントリを除去する。稼働中コンテナから再生成
するだけで停止済みは自然に落ちるため ``sync`` と同義。
- ``status`` : 現在の config パス・内容・Orca への import 手順を表示する。

詳細: docs/user/orca.md
"""

from __future__ import annotations

import json
import os
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, List, Optional, Sequence, Tuple

from devbase.env import keys
from devbase.log import get_logger

logger = get_logger(__name__)

DEFAULT_HOSTNAME = "127.0.0.1"
DEFAULT_USER = "ubuntu"

# 生成ファイル先頭に置く管理ブロックのヘッダ (docs/user/orca.md と一致させる)。
_HEADER = (
"# Managed by devbase — do not edit. "
"Import this file into Orca (Settings → SSH)."
)


@dataclass(frozen=True)
class SSHTarget:
"""1 コンテナぶんの Orca SSH target。"""
project: str
index: int
port: int


def _config_path() -> Path:
"""Orca 用隔離 SSH config の絶対パス (``~/.config/devbase/orca/ssh_config``)。"""
return Path.home() / ".config" / "devbase" / "orca" / "ssh_config"


# ---------------------------------------------------------------------------
# コンテナ列挙 (docker inspect ベース。名前の dash split はしない)
# ---------------------------------------------------------------------------

def _parse_index(raw) -> int:
"""compose の container-number ラベルを 1 始まり index に変換する。"""
try:
return int(raw)
except (TypeError, ValueError):
return 1


def _pick_host_port(port_bindings: Sequence[dict], bind: Optional[str]) -> Optional[int]:
"""``22/tcp`` の publish 一覧から採用するホストポートを 1 つ選ぶ。

``bind`` (DEVBASE_SSH_BIND) に一致する HostIp のエントリを優先し、無ければ
最初に見つかった HostPort を採用する。整数化できなければ None。
"""
chosen = None
for entry in port_bindings or []:
host_port = entry.get("HostPort")
if not host_port:
continue
if bind and entry.get("HostIp") == bind:
chosen = host_port
break
if chosen is None:
chosen = host_port
if chosen is None:
return None
try:
return int(chosen)
except (TypeError, ValueError):
return None


def _parse_inspect(containers, bind: Optional[str] = None) -> List[SSHTarget]:
"""``docker inspect`` の JSON (コンテナ配列) から SSH target を抽出する純関数。

compose project ラベルを持ち、かつ ``22/tcp`` を publish しているコンテナだけを
対象にする。この 2 条件によるフィルタが隔離を担保する (devbase の SSH 有効
コンテナだけが Orca config に現れる)。project ラベルが無い / ``22/tcp`` を
publish しないコンテナ (= Orca SSH target ではない) は除外する。

コンテナ名を dash で split して project/index を得る方式は取らない
(project 名自体が dash を含みうるため)。ラベルから直接読む。
"""
targets: List[SSHTarget] = []
for container in containers or []:
config = container.get("Config") or {}
labels = config.get("Labels") or {}
project = labels.get("com.docker.compose.project")
if not project:
continue
net = container.get("NetworkSettings") or {}
port_bindings = (net.get("Ports") or {}).get("22/tcp")
if not port_bindings:
continue
host_port = _pick_host_port(port_bindings, bind)
if host_port is None:
continue
index = _parse_index(labels.get("com.docker.compose.container-number"))
targets.append(SSHTarget(project=project, index=index, port=host_port))
return targets


def _docker_json(args: Sequence[str]) -> Optional[str]:
"""``docker <args>`` を実行し stdout を返す。失敗時は warning を出して None。

docker が無い / 異常終了しても呼び出し側 (up/down フック) を倒さないため
例外は握り、None を返す。
"""
try:
result = subprocess.run(
["docker", *args], capture_output=True, text=True, check=False
)
except (OSError, subprocess.SubprocessError) as e:
logger.warning("docker %s に失敗しました (Orca 同期をスキップ): %s", args[0], e)
return None
if result.returncode != 0:
logger.warning(
"docker %s に失敗しました (Orca 同期をスキップ): %s",
args[0], (result.stderr or "").strip(),
)
return None
return result.stdout


def _running_ssh_targets() -> List[SSHTarget]:
"""稼働中の devbase SSH コンテナを docker から列挙する (best-effort)。

``docker ps -q`` で稼働中コンテナ id を集め、``docker inspect`` の JSON を
:func:`_parse_inspect` に渡す。docker が無い / 失敗した場合は空リストを返す。
"""
ps_out = _docker_json(["ps", "-q"])
if ps_out is None:
return []
ids = ps_out.split()
if not ids:
return []
inspect_out = _docker_json(["inspect", *ids])
if inspect_out is None:
return []
try:
containers = json.loads(inspect_out)
except json.JSONDecodeError as e:
logger.warning("docker inspect の出力を解析できませんでした (Orca 同期をスキップ): %s", e)
return []
bind = os.environ.get(keys.DEVBASE_SSH_BIND) or None
return _parse_inspect(containers, bind=bind)


# ---------------------------------------------------------------------------
# config レンダリング / 書き込み
# ---------------------------------------------------------------------------

def _render_config(targets: Sequence[SSHTarget], hostname: str, user: str) -> str:
"""SSH target 群から config テキストを生成する純関数。

エントリは (project, index) 昇順で安定ソートする。target が空でもヘッダのみの
安全な空ファイルを返す。
"""
lines = [_HEADER, ""]
for t in sorted(targets, key=lambda x: (x.project, x.index)):
lines.append(f"Host devbase-{t.project}-{t.index}")
lines.append(f" HostName {hostname}")
lines.append(f" Port {t.port}")
lines.append(f" User {user}")
lines.append(" IdentityFile ~/.ssh/id_ed25519")
lines.append(" StrictHostKeyChecking accept-new")
lines.append("")
return "\n".join(lines).rstrip("\n") + "\n"


def _write_config(targets: Sequence[SSHTarget]) -> Path:
"""config を全再生成して書き込み、パスを返す。親ディレクトリは作成する。"""
path = _config_path()
path.parent.mkdir(parents=True, exist_ok=True)
hostname = os.environ.get(keys.DEVBASE_ORCA_HOSTNAME) or DEFAULT_HOSTNAME
user = os.environ.get("USERNAME") or DEFAULT_USER
path.write_text(_render_config(targets, hostname, user), encoding="utf-8")
return path


def regenerate_config(
targets_provider: Optional[Callable[[], List[SSHTarget]]] = None,
) -> Tuple[List[SSHTarget], Path]:
"""稼働中コンテナを列挙して config を全再生成する。``(targets, path)`` を返す。

up/down フックからも呼べる共通エントリ。``targets_provider`` はテスト注入用。
"""
provider = targets_provider or _running_ssh_targets
targets = list(provider())
path = _write_config(targets)
return targets, path


# ---------------------------------------------------------------------------
# サブコマンド
# ---------------------------------------------------------------------------

def _cmd_regenerate(targets_provider: Optional[Callable[[], List[SSHTarget]]]) -> int:
"""sync / prune 共通の再生成処理。停止済みは列挙から外れるため両者は同義。"""
targets, path = regenerate_config(targets_provider)
if targets:
logger.info("Orca SSH config を生成しました (%d 件): %s", len(targets), path)
else:
logger.info("稼働中の SSH 対象コンテナがありません。ヘッダのみの config を書き出しました: %s", path)
logger.info("ENABLE_SSH=true で `devbase up` するとコンテナが対象になります。")
return 0


def _cmd_status() -> int:
"""現在の config パス・内容・import 手順を表示する。"""
path = _config_path()
print(f"Orca SSH config: {path}")
print("")
if path.exists():
print("--- 現在の内容 ---")
print(path.read_text(encoding="utf-8"), end="")
else:
print("(まだ生成されていません。`devbase orca sync` を実行してください)")
print("")
print("Orca への登録: Orca の Settings → SSH でこのファイルを import してください。")
return 0


def cmd_orca(
devbase_root: Path, args,
targets_provider: Optional[Callable[[], List[SSHTarget]]] = None,
) -> int:
"""``devbase orca <sub>`` ディスパッチャ。

``targets_provider`` はテスト用のコンテナ列挙注入口 (通常は None で
:func:`_running_ssh_targets` を使う)。
"""
subcmd = getattr(args, "subcommand", None)

handlers = {
"sync": lambda: _cmd_regenerate(targets_provider),
"prune": lambda: _cmd_regenerate(targets_provider),
"status": _cmd_status,
}

handler = handlers.get(subcmd)
if not handler:
logger.error("サブコマンドを指定してください: %s", ", ".join(handlers))
return 1
return handler()
Loading