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"
|
rsync_flags := "-avLh --progress --stats --itemize-changes"
|
||||||
upload_excludes := "--exclude-from=.rsyncignore"
|
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:8385:127.0.0.1:8384 \
|
||||||
-L 127.0.0.1:9098:127.0.0.1:9098 \
|
-L 127.0.0.1:9098:127.0.0.1:9098 \
|
||||||
-L 127.0.0.1:2718:172.30.0.2:2718 \
|
-L 127.0.0.1:2718:172.30.0.2:2718 \
|
||||||
ial-gpu-workstation-1
|
{{ remote_ssh_target }}
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
docker exec -it docker-ial-pangyg bash
|
docker exec -it {{ remote_docker_container }} bash
|
||||||
|
|
||||||
marimo +notebook:
|
marimo +notebook:
|
||||||
uv run marimo edit {{ notebook }} --host 0.0.0.0 --port 2718 --no-token
|
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
|
uv remove {{ packages }} --no-sync
|
||||||
just sync-pkgs
|
just sync-pkgs
|
||||||
|
|
||||||
add-dev +packages:
|
remote cmd:
|
||||||
uv add {{ packages }} --group dev --no-sync
|
uv run python scripts/remote_docker_run.py run -- {{ quote(cmd) }}
|
||||||
just sync-pkgs
|
|
||||||
|
|
||||||
remove-dev +packages:
|
remote-check:
|
||||||
uv remove {{ packages }} --group dev --no-sync
|
uv run python scripts/remote_docker_run.py check
|
||||||
just sync-pkgs
|
|
||||||
|
|
||||||
memory:
|
remote-dry cmd:
|
||||||
MCP_ALLOW_ANONYMOUS_ACCESS=true memory server --http
|
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