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
This commit is contained in:
2026-05-03 15:29:01 +08:00
parent f5daaa2667
commit 7450505a86
5 changed files with 377 additions and 11 deletions

View File

@@ -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 }}"

9
.opencode/opencode.json Normal file
View File

@@ -0,0 +1,9 @@
{
"$schema": "https://opencode.ai/config.json",
"permission": {
"bash": {
"make": "deny",
"make *": "deny"
}
}
}

24
.safety-net.json Normal file
View File

@@ -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."
}
]
}

0
scripts/common.py Normal file
View File

View File

@@ -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()