refactor(scripts): extract shared utilities into common module

- Move load_env_file, parse_timeout_seconds, build_remote_script, and
  build_ssh_command to scripts/common.py
- Update remote_docker_run.py to import from common module
- Improves code organization and reusability
This commit is contained in:
2026-05-03 19:01:35 +08:00
parent 8b8e4d3118
commit 0ae6d757dc
5 changed files with 136 additions and 110 deletions

View File

@@ -10,18 +10,10 @@ rsync_flags := "-avLh --progress --stats --itemize-changes"
upload_excludes := "--exclude-from=.rsyncignore" upload_excludes := "--exclude-from=.rsyncignore"
upload: upload:
if command -v cygpath >/dev/null 2>&1 || test -n "${MSYSTEM:-}" || test -n "${CYGWIN:-}"; then \ rsync {{ rsync_flags }} {{ upload_excludes }} . {{ remote_root }}/; \
env MSYS2_ARG_CONV_EXCL='*' rsync {{ rsync_flags }} {{ upload_excludes }} . {{ remote_root }}/; \
else \
rsync {{ rsync_flags }} {{ upload_excludes }} . {{ remote_root }}/; \
fi
download: download:
if command -v cygpath >/dev/null 2>&1 || test -n "${MSYSTEM:-}" || test -n "${CYGWIN:-}"; then \ rsync {{ rsync_flags }} {{ remote_root }}/outputs .; \
env MSYS2_ARG_CONV_EXCL='*' rsync {{ rsync_flags }} {{ remote_root }}/outputs .; \
else \
rsync {{ rsync_flags }} {{ remote_root }}/outputs .; \
fi
sync-pkgs: sync-pkgs:
uv sync --inexact uv sync --inexact
@@ -61,9 +53,3 @@ remote-check:
remote-dry cmd: remote-dry cmd:
uv run python scripts/remote_docker_run.py run --dry-run -- {{ quote(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 }}"

View File

@@ -2,8 +2,17 @@
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json",
"permission": { "permission": {
"bash": { "bash": {
"python": "deny",
"python *": "deny",
"make": "deny", "make": "deny",
"make *": "deny" "make *": "deny",
"just remote *": "ask"
},
"read": {
".env": "deny"
},
"edit": {
".env": "deny"
} }
} }
} }

View File

@@ -18,7 +18,13 @@
"-h", "-h",
"-v" "-v"
], ],
"reason": "uv run is blocked in this project. Use direct command execution instead." "reason": "Local Environment is not avaiable. Please use \"just remote <cmd>\" to run commands in the remote docker. And remember to run \"just upload\" before run commands in the remote docker."
},
{
"name": "block-uv-sync",
"command": "uv",
"block_args": ["sync"],
"reason": "The project is managed by micromamba and uv. Do not sync packages directly by uv. Use \"just sync-pkgs\" or \"uv sync --inexact\" instead."
} }
] ]
} }

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Shared utilities for remote Docker / SSH scripts."""
from __future__ import annotations
import os
import shlex
from pathlib import Path
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 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)
inner_command = (
"set -Eeuo pipefail; "
'if [ -f "$HOME/.bashrc" ]; then source "$HOME/.bashrc"; fi; '
"if command -v direnv >/dev/null 2>&1 && [ -f .envrc ]; then "
" direnv allow . >/dev/null 2>&1 || true; "
' eval "$(direnv export bash)"; '
"fi; "
f"{command}"
)
q_command = shlex.quote(inner_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

View File

@@ -8,35 +8,19 @@ from pathlib import Path
import typer import typer
from common import (
build_remote_script,
build_ssh_command,
load_env_file,
parse_timeout_seconds,
)
app = typer.Typer( app = typer.Typer(
help="Run commands inside a Docker container on a remote host over SSH.", help="Run commands inside a Docker container on a remote host over SSH.",
no_args_is_help=True, 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: def required_env(name: str) -> str:
value = os.environ.get(name) value = os.environ.get(name)
if not value: if not value:
@@ -49,75 +33,6 @@ def required_env(name: str) -> str:
return value 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: def build_user_command(command_parts: list[str]) -> str:
if command_parts and command_parts[0] == "--": if command_parts and command_parts[0] == "--":
command_parts = command_parts[1:] command_parts = command_parts[1:]