Newer
Older
SmTIAS-LightSim / src / sm_tias_lightsim / remote / commands.py
"""研究室サーバ上で実行するシェルコマンド文字列を組み立てる純関数群.

`ssh <host> '<command>'` で送る文字列を作る.リモートのログインシェル(Ubuntu では bash)が
解釈する前提で POSIX sh の範囲で書き,ジョブ本体だけは `${PIPESTATUS[0]}` を使うため
`bash -c` で包む.ユーザー由来の値(パス・コマンド引数)はすべて `shlex.quote` でクォートし,
ジョブ名・GPU 指定・環境変数名は書式を検証してから埋め込む.

Windows から ssh へ渡す引数に改行を含めないため,複数の文は `; ` で 1 行につなぐ.
リモート側が出すメッセージは ASCII に限る(ssh の stderr は Windows コンソールへ素通しされ,
コードページによっては日本語が化けるため.日本語の説明は呼び出し側の CLI が出す).
"""

from __future__ import annotations

import re
import shlex
from collections.abc import Mapping, Sequence
from pathlib import PurePosixPath

REMOTE_OUTPUT_DIR = "output"
REMOTE_LOG_DIR = "output/_remote_logs"

# リモートスクリプトが返す終了コード.ssh 自身の 255・tar の 1/2 と衝突しない値にする
EXIT_REMOTE_DIR_MISSING = 90
EXIT_SESSION_EXISTS = 91
EXIT_NOT_FOUND = 92
EXIT_NO_SESSION = 93

# tmux のセッション名に使えない "." ":" を避け,ログのファイル名にもそのまま使える文字に限る
_JOB_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
_GPU_SPEC_RE = re.compile(r"^\d+(,\d+)*$")
_ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:")

_NOW = "\"$(date '+%Y-%m-%d %H:%M:%S')\""
_STATUS_LOG_COUNT = 10


def quote_remote_path(path: str) -> str:
    """リモートのパスをシェル用にクォートする.

    先頭の `~` はリモートの `$HOME` に展開させる必要があるため,`~/...` は
    `"$HOME"/'...'` の形に変換する(丸ごとクォートするとチルダ展開が起きない).

    Args:
        path: リモートのパス(`~/...`・絶対パス・ホーム基準の相対パス).

    Returns:
        シェルにそのまま埋め込める文字列.

    Raises:
        ValueError: 空文字,または `~ユーザー名/...` 形式の場合.
    """
    if not path or not path.strip():
        raise ValueError("リモートパスが空です.")
    if path == "~":
        return '"$HOME"'
    if path.startswith("~/"):
        rest = path[2:]
        return f'"$HOME"/{shlex.quote(rest)}' if rest else '"$HOME"'
    if path.startswith("~"):
        msg = (
            f"~ユーザー名 形式のリモートパスには対応していません: {path!r}"
            "(~/... か絶対パスを指定)"
        )
        raise ValueError(msg)
    return shlex.quote(path)


def validate_host(host: str) -> str:
    """SSH ホスト名を検証して返す(ssh のオプションとして解釈されるのを防ぐ).

    Args:
        host: `~/.ssh/config` の Host 名,またはホスト名・IP.

    Returns:
        検証済みのホスト名.

    Raises:
        ValueError: 空・`-` 始まり・空白を含む場合.
    """
    if not host or host.startswith("-") or any(c.isspace() for c in host):
        raise ValueError(f"SSH ホスト名が不正です: {host!r}")
    return host


def validate_job_name(name: str) -> str:
    """ジョブ名(tmux セッション名・ログのファイル名)を検証して返す.

    Args:
        name: ジョブ名.

    Returns:
        検証済みのジョブ名.

    Raises:
        ValueError: 英数字で始まり英数字・`_`・`-` のみ(64 文字以内)でない場合.
    """
    if not _JOB_NAME_RE.match(name):
        msg = f"ジョブ名が不正です: {name!r}(英数字で始まり,英数字・_・- のみ,64 文字以内)"
        raise ValueError(msg)
    return name


def validate_gpu_spec(spec: str) -> str:
    """CUDA_VISIBLE_DEVICES に渡す GPU 番号の指定を検証して返す.

    Args:
        spec: `0` や `0,2` のようなカンマ区切りの GPU 番号.

    Returns:
        検証済みの指定文字列.

    Raises:
        ValueError: 書式が不正な場合.
    """
    if not _GPU_SPEC_RE.match(spec):
        raise ValueError(f"GPU 指定が不正です: {spec!r}(例: 0 / 0,2)")
    return spec


def parse_env_assignment(text: str) -> tuple[str, str]:
    """`KEY=VALUE` 形式の環境変数指定を分解する.

    Args:
        text: `KEY=VALUE` 形式の文字列(VALUE は空でもよい).

    Returns:
        (KEY, VALUE).

    Raises:
        ValueError: `=` が無い,または KEY が環境変数名として不正な場合.
    """
    key, sep, value = text.partition("=")
    if not sep or not _ENV_KEY_RE.match(key):
        raise ValueError(f"環境変数の指定が不正です: {text!r}(KEY=VALUE 形式)")
    return key, value


def build_ssh_argv(ssh: str, host: str, remote_command: str, *, tty: bool = False) -> list[str]:
    """ssh を起動する引数リストを返す.

    Args:
        ssh: ssh 実行ファイル.
        host: 接続先ホスト.
        remote_command: リモートで実行するシェルコマンド(1 引数として渡す).
        tty: True なら `-t` で疑似端末を割り当てる(tmux attach 用).

    Returns:
        subprocess に渡す引数リスト.
    """
    argv = [ssh]
    if tty:
        argv.append("-t")
    argv += [validate_host(host), remote_command]
    return argv


def _cd_command(remote_dir: str) -> str:
    """リモートディレクトリへ移動し,無ければ専用の終了コードで抜ける文を返す."""
    quoted = quote_remote_path(remote_dir)
    return (
        f"cd {quoted} || {{ echo 'sm-remote: remote directory not found:' {quoted} >&2; "
        f"exit {EXIT_REMOTE_DIR_MISSING}; }}"
    )


def _job_log_path(name: str) -> str:
    """ジョブログのパス(プロジェクトルート基準.クォート前)を返す.name は検証済みとする."""
    return f"{REMOTE_LOG_DIR}/{name}.log"


def _tmux_target(name: str) -> str:
    """tmux の `-t` に渡すセッション指定をクォートして返す.name は検証済みとする.

    `=` を前置して完全一致にする(付けないと tmux は前方一致で別のジョブを選びうる).
    """
    return shlex.quote(f"={name}")


def build_push_command(remote_dir: str, *, compress: bool) -> str:
    """標準入力の tar ストリームをリモートディレクトリへ展開するコマンドを返す.

    展開前に tmux セッションが動いていれば警告を出す(実行中のジョブが途中から
    新しいコードを読み込み,結果が混ざるおそれがあるため).

    Args:
        remote_dir: 展開先(リモートのプロジェクトルート).
        compress: True なら gzip 圧縮ストリームとして展開する.

    Returns:
        リモートで実行するシェルコマンド.
    """
    quoted = quote_remote_path(remote_dir)
    flags = "-xzf" if compress else "-xf"
    warn = (
        "if tmux ls >/dev/null 2>&1; then "
        "echo 'sm-remote: warning: tmux sessions are running"
        " (running jobs may pick up the new code):' >&2; tmux ls >&2; fi"
    )
    return f"{warn}; mkdir -p {quoted} && tar {flags} - -C {quoted}"


def build_list_files_command(
    remote_dir: str, dirs: Sequence[str], skip_dir_names: Sequence[str]
) -> str:
    """リモートの指定ディレクトリ配下のファイル一覧を NUL 区切りで出力するコマンドを返す.

    Args:
        remote_dir: リモートのプロジェクトルート.
        dirs: 一覧を取るディレクトリ(プロジェクトルート基準).
        skip_dir_names: 探索しないディレクトリ名(`__pycache__` 等).

    Returns:
        リモートで実行するシェルコマンド.
    """
    names = " ".join(shlex.quote(d) for d in dirs)
    prune = " -o ".join(f"-name {shlex.quote(n)}" for n in skip_dir_names)
    return (
        f"{_cd_command(remote_dir)}; for d in {names}; do "
        f'if [ -d "$d" ]; then find "$d" -type d \\( {prune} \\) -prune -o -type f -print0; fi; '
        "done"
    )


def build_remove_files_command(remote_dir: str) -> str:
    """標準入力の NUL 区切りパス一覧をリモートで削除するコマンドを返す.

    Args:
        remote_dir: リモートのプロジェクトルート(パスはこの基準の相対パス).

    Returns:
        リモートで実行するシェルコマンド.
    """
    return f"{_cd_command(remote_dir)}; xargs -0 rm -f --"


def build_job_script(
    remote_dir: str,
    name: str,
    command: Sequence[str],
    *,
    uv_path: str,
    use_uv: bool,
    env: Mapping[str, str],
) -> str:
    """tmux セッション内で bash が実行するジョブ本体のスクリプトを返す.

    出力は `output/_remote_logs/<name>.log` に追記し(前回の同名ジョブのログも残す),
    開始行と終了コード行で挟む.標準入力は /dev/null にする(入力待ちで止まらないように).
    uv のディレクトリを PATH に足すのは,スイープスクリプトが `uv run sm-render` を
    名前で呼ぶため(非ログインの ssh では ~/.local/bin が PATH に入らない).

    Args:
        remote_dir: リモートのプロジェクトルート.
        name: ジョブ名.
        command: 実行するコマンドと引数.
        uv_path: リモートの uv 実行ファイル.
        use_uv: True なら `uv run` を前置する.
        env: ジョブに渡す環境変数.

    Returns:
        bash スクリプト(1 行).

    Raises:
        ValueError: コマンドが空,またはジョブ名・環境変数名が不正な場合.
    """
    if not command:
        raise ValueError("実行するコマンドが空です.")
    log = _job_log_path(validate_job_name(name))
    for key in env:
        if not _ENV_KEY_RE.match(key):
            raise ValueError(f"環境変数名が不正です: {key!r}")

    uv_quoted = quote_remote_path(uv_path)
    run_argv = ([uv_quoted, "run"] if use_uv else []) + [shlex.quote(arg) for arg in command]
    display = shlex.join(
        [f"{k}={v}" for k, v in env.items()] + (["uv", "run"] if use_uv else []) + list(command)
    )
    statements = [
        _cd_command(remote_dir),
        f"log={shlex.quote(log)}",
        f"printf '=== [%s] start on %s: %s\\n' {_NOW} \"$(hostname)\" {shlex.quote(display)}"
        ' >> "$log"',
        f'export PATH="$(dirname -- {uv_quoted}):$PATH"',
        *[f"export {k}={shlex.quote(v)}" for k, v in env.items()],
        f'{" ".join(run_argv)} < /dev/null 2>&1 | tee -a "$log"',
        "rc=${PIPESTATUS[0]}",
        f'printf \'=== [%s] exit %s\\n\' {_NOW} "$rc" >> "$log"',
        'exit "$rc"',
    ]
    return "; ".join(statements)


def build_run_command(remote_dir: str, name: str, job_script: str) -> str:
    """ジョブ本体を tmux の新規セッション(デタッチ状態)で起動するコマンドを返す.

    同名セッションが既にあれば起動せず `EXIT_SESSION_EXISTS` で終わる.
    tmux のサーバはデーモン化して ssh の入出力を閉じるため,ssh はすぐ戻り,
    接続が切れてもジョブは継続する.

    Args:
        remote_dir: リモートのプロジェクトルート.
        name: ジョブ名(tmux セッション名).
        job_script: `build_job_script` が返したスクリプト.

    Returns:
        リモートで実行するシェルコマンド.
    """
    name = validate_job_name(name)
    target = _tmux_target(name)
    inner = "bash -c " + shlex.quote(job_script)
    statements = [
        _cd_command(remote_dir),
        f"mkdir -p {REMOTE_LOG_DIR} || exit 1",
        f"if tmux has-session -t {target} 2>/dev/null; then "
        f"echo 'sm-remote: tmux session already exists:' {shlex.quote(name)} >&2; "
        f"exit {EXIT_SESSION_EXISTS}; fi",
        f'tmux new-session -d -s {shlex.quote(name)} -c "$PWD" {shlex.quote(inner)}',
    ]
    return "; ".join(statements)


def _last_line_command(path_expr: str) -> str:
    """ログの最後の 1 行を表示するパイプラインを返す.

    mitsuba の進捗バーは \\r で上書きするので,最後の \\r 以降だけを 1 行として見せる.
    """
    return f"tail -n 1 {path_expr} | tr '\\r' '\\n' | grep -v '^$' | tail -n 1 | cut -c1-100"


def build_status_command(remote_dir: str) -> str:
    """tmux セッション・最近のジョブログ・GPU・負荷を表示するコマンドを返す.

    Args:
        remote_dir: リモートのプロジェクトルート.

    Returns:
        リモートで実行するシェルコマンド.
    """
    last_line = _last_line_command('"$f"')
    log_loop = (
        f"for f in $(ls -t {REMOTE_LOG_DIR}/*.log 2>/dev/null | head -n {_STATUS_LOG_COUNT}); do "
        'n=$(basename "$f" .log); '
        'if tmux has-session -t "=$n" 2>/dev/null; then s=RUNNING; else s=ended; fi; '
        f"last=$({last_line}); "
        'printf \'%-28s %-8s %s\\n\' "$n" "$s" "$last"; '
        "done"
    )
    gpu = (
        "if command -v nvidia-smi >/dev/null 2>&1; then "
        "nvidia-smi --query-gpu=index,name,utilization.gpu,memory.used,memory.total"
        " --format=csv,noheader; "
        "else echo '(nvidia-smi not found)'; fi"
    )
    statements = [
        _cd_command(remote_dir),
        "echo '== tmux sessions (running jobs) =='",
        "tmux ls 2>/dev/null || echo '(none)'",
        "echo",
        f"echo '== recent job logs ({REMOTE_LOG_DIR}) =='",
        log_loop,
        "echo",
        "echo '== GPU (index, name, util, mem used, mem total) =='",
        gpu,
        "echo",
        "echo '== load average =='",
        "uptime",
    ]
    return "; ".join(statements)


def build_logs_command(remote_dir: str, name: str, *, lines: int, follow: bool) -> str:
    """ジョブログの末尾を表示する(follow なら追従する)コマンドを返す.

    追従モードでは,ssh の標準入力が閉じたら(手元で Ctrl+C・終了したら)tail を止める.
    疑似端末なしの ssh では切断しても tail に SIGHUP が届かず,ログが伸びない限り
    リモートに tail が残り続けるため.

    Args:
        remote_dir: リモートのプロジェクトルート.
        name: ジョブ名.
        lines: 表示する末尾の行数.
        follow: True なら追記を追従する.

    Returns:
        リモートで実行するシェルコマンド.
    """
    log = shlex.quote(_job_log_path(validate_job_name(name)))
    check = (
        f"[ -f {log} ] || {{ echo 'sm-remote: log not found:' {log} >&2; exit {EXIT_NOT_FOUND}; }}"
    )
    if follow:
        tail = f'tail -n {int(lines)} -F {log} & t=$!; cat >/dev/null; kill "$t" 2>/dev/null'
    else:
        tail = f"tail -n {int(lines)} {log}"
    return "; ".join([_cd_command(remote_dir), check, tail])


def build_attach_command(name: str) -> str:
    """ジョブの tmux セッションに接続するコマンドを返す(ssh -t で使う).

    Args:
        name: ジョブ名.

    Returns:
        リモートで実行するシェルコマンド.
    """
    return f"tmux attach-session -t {_tmux_target(validate_job_name(name))}"


def build_attach_check_command(remote_dir: str, name: str) -> str:
    """attach の前に,ジョブの tmux セッションがまだあるかを確かめるコマンドを返す.

    セッションがあれば 0 で終わる.無ければログの最終行(`=== [...] exit N` 等)を
    標準出力に出して `EXIT_NO_SESSION`,ログも無ければ `EXIT_NOT_FOUND` で終わる.
    `ssh -t` の対話セッションは出力を受け取れないため,事前チェックは別の ssh で行う.

    Args:
        remote_dir: リモートのプロジェクトルート.
        name: ジョブ名.

    Returns:
        リモートで実行するシェルコマンド.
    """
    name = validate_job_name(name)
    log = shlex.quote(_job_log_path(name))
    statements = [
        f"if tmux has-session -t {_tmux_target(name)} 2>/dev/null; then exit 0; fi",
        _cd_command(remote_dir),
        f"if [ -f {log} ]; then {_last_line_command(log)}; exit {EXIT_NO_SESSION}; fi",
        f"exit {EXIT_NOT_FOUND}",
    ]
    return "; ".join(statements)


def build_kill_command(remote_dir: str, name: str) -> str:
    """ジョブの tmux セッションを終了し,ログにその旨を追記するコマンドを返す.

    tmux がセッションを閉じるとジョブの bash は SIGHUP で終わり,自分では終了行を
    書けないため,終了前にここで追記する.

    Args:
        remote_dir: リモートのプロジェクトルート.
        name: ジョブ名.

    Returns:
        リモートで実行するシェルコマンド.
    """
    name = validate_job_name(name)
    target = _tmux_target(name)
    log = shlex.quote(_job_log_path(name))
    statements = [
        f"tmux has-session -t {target} 2>/dev/null || "
        f"{{ echo 'sm-remote: no such tmux session:' {shlex.quote(name)} >&2; "
        f"exit {EXIT_NO_SESSION}; }}",
        f"if cd {quote_remote_path(remote_dir)} 2>/dev/null && [ -f {log} ]; then "
        f"printf '=== [%s] killed by sm-remote kill\\n' {_NOW} >> {log}; fi",
        f"tmux kill-session -t {target}",
    ]
    return "; ".join(statements)


def normalize_output_subpath(text: str) -> str:
    """pull 対象の指定を `output/<サブパス>` 形式に正規化する.

    `offaxis_led_sweep`・`output/offaxis_led_sweep`・`output\\offaxis_led_sweep`(Windows 区切り)
    をいずれも `output/offaxis_led_sweep` にする.output/ の外や output/ 全体は指定できない.

    Args:
        text: ユーザーが指定したパス.

    Returns:
        リモートのプロジェクトルート基準の相対パス(POSIX 区切り).

    Raises:
        ValueError: 空・絶対パス・`..` を含む・output/ 全体を指す場合.
    """
    cleaned = text.strip().replace("\\", "/")
    if not cleaned:
        raise ValueError("取得するパスが空です.")
    if cleaned.startswith("/") or _WINDOWS_DRIVE_RE.match(cleaned):
        raise ValueError(f"絶対パスは指定できません: {text!r}(output/ 配下の相対パスを指定)")
    parts = [p for p in PurePosixPath(cleaned).parts if p not in ("", ".")]
    if ".." in parts:
        raise ValueError(f"'..' を含むパスは指定できません: {text!r}")
    if parts and parts[0] == REMOTE_OUTPUT_DIR:
        parts = parts[1:]
    if not parts:
        raise ValueError("output/ 全体は指定できません.サブディレクトリを指定してください.")
    return "/".join([REMOTE_OUTPUT_DIR, *parts])


def build_pull_command(remote_dir: str, rel_paths: Sequence[str], *, compress: bool) -> str:
    """指定パスを tar ストリームとして標準出力へ書き出すコマンドを返す.

    どれか 1 つでも存在しなければ tar を始める前に `EXIT_NOT_FOUND` で終わる.

    Args:
        remote_dir: リモートのプロジェクトルート.
        rel_paths: `normalize_output_subpath` で正規化済みのパス.
        compress: True なら gzip 圧縮する.

    Returns:
        リモートで実行するシェルコマンド.
    """
    if not rel_paths:
        raise ValueError("取得するパスが指定されていません.")
    quoted = " ".join(shlex.quote(p) for p in rel_paths)
    flags = "-czf" if compress else "-cf"
    statements = [
        _cd_command(remote_dir),
        f'for p in {quoted}; do [ -e "$p" ] || '
        f"{{ echo 'sm-remote: not found on remote:' \"$p\" >&2; exit {EXIT_NOT_FOUND}; }}; done",
        f"tar {flags} - {quoted}",
    ]
    return "; ".join(statements)