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:
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