From 7450505a864ef6dd3cab82f83a6241e717d60cb3 Mon Sep 17 00:00:00 2001 From: SikongJueluo Date: Sun, 3 May 2026 15:29:01 +0800 Subject: [PATCH] feat(remote): unify remote execution workflow with env-based config - Replace hardcoded SSH/docker targets with environment variables - Add remote-check, remote-dry, remote-test, remote-test-one recipes - Add remote cmd for arbitrary command execution in docker container --- .justfile | 33 ++-- .opencode/opencode.json | 9 + .safety-net.json | 24 +++ scripts/common.py | 0 scripts/remote_docker_run.py | 322 +++++++++++++++++++++++++++++++++++ 5 files changed, 377 insertions(+), 11 deletions(-) create mode 100644 .opencode/opencode.json create mode 100644 .safety-net.json create mode 100644 scripts/common.py create mode 100644 scripts/remote_docker_run.py diff --git a/.justfile b/.justfile index 43191b5..fe58d9b 100644 --- a/.justfile +++ b/.justfile @@ -1,4 +1,11 @@ -remote_root := "ial-gpu-workstation-1:/home/ial-pangyg/workspace/projects/mini-nav" +set dotenv-required + +export MSYS2_ARG_CONV_EXCL := "*" +export MSYS2_ENV_CONV_EXCL := "*" + +remote_ssh_target := env("REMOTE_SSH_TARGET") +remote_docker_container := env("REMOTE_DOCKER_CONTAINER") +remote_root := "$REMOTE_SSH_TARGET:$REMOTE_WORKDIR" rsync_flags := "-avLh --progress --stats --itemize-changes" upload_excludes := "--exclude-from=.rsyncignore" @@ -30,10 +37,10 @@ ssh: -L 127.0.0.1:8385:127.0.0.1:8384 \ -L 127.0.0.1:9098:127.0.0.1:9098 \ -L 127.0.0.1:2718:172.30.0.2:2718 \ - ial-gpu-workstation-1 + {{ remote_ssh_target }} docker: - docker exec -it docker-ial-pangyg bash + docker exec -it {{ remote_docker_container }} bash marimo +notebook: uv run marimo edit {{ notebook }} --host 0.0.0.0 --port 2718 --no-token @@ -46,13 +53,17 @@ remove +packages: uv remove {{ packages }} --no-sync just sync-pkgs -add-dev +packages: - uv add {{ packages }} --group dev --no-sync - just sync-pkgs +remote cmd: + uv run python scripts/remote_docker_run.py run -- {{ quote(cmd) }} -remove-dev +packages: - uv remove {{ packages }} --group dev --no-sync - just sync-pkgs +remote-check: + uv run python scripts/remote_docker_run.py check -memory: - MCP_ALLOW_ANONYMOUS_ACCESS=true memory server --http +remote-dry cmd: + uv run python scripts/remote_docker_run.py run --dry-run -- {{ quote(cmd) }} + +remote-test: + uv run python scripts/remote_docker_run.py run -- "uv run pytest -q" + +remote-test-one path: + uv run python scripts/remote_docker_run.py run -- "uv run pytest -q {{ path }}" diff --git a/.opencode/opencode.json b/.opencode/opencode.json new file mode 100644 index 0000000..e1f1a1f --- /dev/null +++ b/.opencode/opencode.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "bash": { + "make": "deny", + "make *": "deny" + } + } +} diff --git a/.safety-net.json b/.safety-net.json new file mode 100644 index 0000000..283724b --- /dev/null +++ b/.safety-net.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json", + "version": 1, + "rules": [ + { + "name": "block-uv-run", + "command": "uv", + "subcommand": "run", + "block_args": [ + "python", + "python3", + "pytest", + "pip", + "-m", + "--module", + "--help", + "--version", + "-h", + "-v" + ], + "reason": "uv run is blocked in this project. Use direct command execution instead." + } + ] +} diff --git a/scripts/common.py b/scripts/common.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/remote_docker_run.py b/scripts/remote_docker_run.py new file mode 100644 index 0000000..48e9dac --- /dev/null +++ b/scripts/remote_docker_run.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import os +import shlex +import subprocess +from pathlib import Path + +import typer + +app = typer.Typer( + help="Run commands inside a Docker container on a remote host over SSH.", + no_args_is_help=True, +) + + +def load_env_file(path: Path) -> None: + if not path.exists(): + return + + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + + if not line or line.startswith("#") or "=" not in line: + continue + + key, value = line.split("=", 1) + key = key.strip() + value = value.strip() + + if not key or key in os.environ: + continue + + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + value = value[1:-1] + + os.environ[key] = value + + +def required_env(name: str) -> str: + value = os.environ.get(name) + if not value: + typer.secho( + f"error: missing required env: {name}", + fg=typer.colors.RED, + err=True, + ) + raise typer.Exit(2) + return value + + +def parse_timeout_seconds(value: str) -> float: + value = value.strip().lower() + + if value.endswith("ms"): + return float(value[:-2]) / 1000 + if value.endswith("s"): + return float(value[:-1]) + if value.endswith("m"): + return float(value[:-1]) * 60 + if value.endswith("h"): + return float(value[:-1]) * 3600 + + return float(value) + + +def build_remote_script(container: str, workdir: str, command: str) -> str: + q_container = shlex.quote(container) + q_workdir = shlex.quote(workdir) + q_command = shlex.quote(command) + + return f""" +set -Eeuo pipefail + +if ! command -v docker >/dev/null 2>&1; then + echo 'error: docker not found on remote host' >&2 + exit 127 +fi + +if ! docker inspect {q_container} >/dev/null 2>&1; then + echo 'error: docker container not found: {container}' >&2 + exit 127 +fi + +if [ "$(docker inspect -f '{{{{.State.Running}}}}' {q_container})" != "true" ]; then + echo 'error: docker container is not running: {container}' >&2 + exit 127 +fi + +docker exec -i \\ + -w {q_workdir} \\ + {q_container} \\ + bash -lc {q_command} +""".strip() + + +def build_ssh_command( + *, + ssh_target: str, + ssh_port: str, + ssh_opts: str, +) -> list[str]: + ssh_cmd = [ + "ssh", + "-p", + ssh_port, + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=accept-new", + ] + + if ssh_opts: + ssh_cmd.extend(shlex.split(ssh_opts)) + + ssh_cmd.extend([ssh_target, "bash", "-s", "--"]) + + return ssh_cmd + + +def build_user_command(command_parts: list[str]) -> str: + if command_parts and command_parts[0] == "--": + command_parts = command_parts[1:] + + if not command_parts: + typer.secho("error: missing command", fg=typer.colors.RED, err=True) + raise typer.Exit(2) + + # 支持: + # run -- "pwd && ls -la" + # run -- pwd + # run -- uv run pytest -q + if len(command_parts) == 1: + return command_parts[0] + + return " ".join(shlex.quote(part) for part in command_parts) + + +def print_remote_info( + *, + ssh_target: str, + container: str, + workdir: str, + command: str, +) -> None: + typer.echo(f"[remote] target = {ssh_target}") + typer.echo(f"[remote] container = {container}") + typer.echo(f"[remote] workdir = {workdir}") + typer.echo(f"[remote] command = {command}") + + +def run_ssh_script( + *, + ssh_cmd: list[str], + remote_script: str, + timeout_raw: str, +) -> int: + try: + # 关键点: + # 1. 手动统一为 LF + # 2. 以 bytes 写入 stdin,避免 Windows text mode 把 \n 转为 \r\n + script_bytes = ( + remote_script.replace("\r\n", "\n").replace("\r", "\n").encode("utf-8") + ) + + result = subprocess.run( + ssh_cmd, + input=script_bytes, + check=False, + timeout=parse_timeout_seconds(timeout_raw) if timeout_raw else None, + ) + return result.returncode + + except subprocess.TimeoutExpired: + typer.secho( + f"error: remote command timed out: {timeout_raw}", + fg=typer.colors.RED, + err=True, + ) + return 124 + + +@app.command( + context_settings={"allow_extra_args": True, "ignore_unknown_options": True} +) +def run( + ctx: typer.Context, + env_file: Path = typer.Option( + Path(".env"), + "--env-file", + help="Path to remote environment file.", + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Print generated ssh command and remote script without executing it.", + ), +) -> None: + """ + Run an arbitrary command inside the configured remote Docker container. + + Example: + uv run python scripts/remote_docker_run.py run -- "uv run pytest -q" + """ + load_env_file(env_file) + + user_command = build_user_command(list(ctx.args)) + + ssh_target = required_env("REMOTE_SSH_TARGET") + container = required_env("REMOTE_DOCKER_CONTAINER") + workdir = required_env("REMOTE_WORKDIR") + + ssh_port = os.environ.get("REMOTE_SSH_PORT", "22") + ssh_opts = os.environ.get("REMOTE_SSH_OPTS", "") + timeout_raw = os.environ.get("REMOTE_TIMEOUT", "") + + remote_script = build_remote_script( + container=container, + workdir=workdir, + command=user_command, + ) + + ssh_cmd = build_ssh_command( + ssh_target=ssh_target, + ssh_port=ssh_port, + ssh_opts=ssh_opts, + ) + + print_remote_info( + ssh_target=ssh_target, + container=container, + workdir=workdir, + command=user_command, + ) + + if dry_run: + typer.echo() + typer.echo("[dry-run] ssh argv:") + typer.echo(" ".join(shlex.quote(part) for part in ssh_cmd)) + + typer.echo() + typer.echo("[dry-run] remote script sent via stdin:") + typer.echo(remote_script) + + raise typer.Exit(0) + + exit_code = run_ssh_script( + ssh_cmd=ssh_cmd, + remote_script=remote_script, + timeout_raw=timeout_raw, + ) + raise typer.Exit(exit_code) + + +@app.command() +def check( + env_file: Path = typer.Option( + Path(".env"), + "--env-file", + help="Path to remote environment file.", + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Print generated ssh command and remote script without executing it.", + ), +) -> None: + """ + Check whether the configured remote Docker container is reachable. + """ + load_env_file(env_file) + + ssh_target = required_env("REMOTE_SSH_TARGET") + container = required_env("REMOTE_DOCKER_CONTAINER") + workdir = required_env("REMOTE_WORKDIR") + + ssh_port = os.environ.get("REMOTE_SSH_PORT", "22") + ssh_opts = os.environ.get("REMOTE_SSH_OPTS", "") + timeout_raw = os.environ.get("REMOTE_TIMEOUT", "") + + check_command = "pwd && python --version || true && ls -la" + + remote_script = build_remote_script( + container=container, + workdir=workdir, + command=check_command, + ) + + ssh_cmd = build_ssh_command( + ssh_target=ssh_target, + ssh_port=ssh_port, + ssh_opts=ssh_opts, + ) + + print_remote_info( + ssh_target=ssh_target, + container=container, + workdir=workdir, + command=check_command, + ) + + if dry_run: + typer.echo() + typer.echo("[dry-run] ssh argv:") + typer.echo(" ".join(shlex.quote(part) for part in ssh_cmd)) + + typer.echo() + typer.echo("[dry-run] remote script sent via stdin:") + typer.echo(remote_script) + + raise typer.Exit(0) + + exit_code = run_ssh_script( + ssh_cmd=ssh_cmd, + remote_script=remote_script, + timeout_raw=timeout_raw, + ) + raise typer.Exit(exit_code) + + +if __name__ == "__main__": + app()