mirror of
https://github.com/SikongJueluo/Mini-Nav.git
synced 2026-07-13 04:25:32 +08:00
- 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
323 lines
7.7 KiB
Python
323 lines
7.7 KiB
Python
#!/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()
|