mirror of
https://github.com/SikongJueluo/Mini-Nav.git
synced 2026-07-12 20:15:31 +08:00
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:
33
.justfile
33
.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 }}"
|
||||
|
||||
9
.opencode/opencode.json
Normal file
9
.opencode/opencode.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"permission": {
|
||||
"bash": {
|
||||
"make": "deny",
|
||||
"make *": "deny"
|
||||
}
|
||||
}
|
||||
}
|
||||
24
.safety-net.json
Normal file
24
.safety-net.json
Normal 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
0
scripts/common.py
Normal file
322
scripts/remote_docker_run.py
Normal file
322
scripts/remote_docker_run.py
Normal 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()
|
||||
Reference in New Issue
Block a user