Files
ai-exchange/scripts/deploy_with_password.py

208 lines
8.1 KiB
Python

from __future__ import annotations
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Optional
import argparse
import os
import tarfile
import time
import urllib.request
try:
import paramiko
except ImportError as exc: # pragma: no cover - local tooling guard
raise SystemExit("paramiko_missing: install paramiko in the local venv before deploying") from exc
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_HOST = "192.168.3.90"
DEFAULT_USER = "root"
DEFAULT_REMOTE_PATH = "/root/deploy/ai-ict"
DEFAULT_REMOTE_ARCHIVE = "/root/ai-ict-deploy.tgz"
DEFAULT_SMOKE_BASE_URL = "http://192.168.3.90:8000"
DEPLOY_PATHS = [
"Dockerfile",
".dockerignore",
"docker-compose.yml",
"pyproject.toml",
"README.md",
".env.example",
"docs",
"SESSION.md",
"TASKS.md",
"src",
"scripts",
"migrations",
"data",
]
def main(argv: Optional[list[str]] = None, env=None) -> int:
env = os.environ if env is None else env
args = _parse_args(argv, env)
if not args.password:
raise SystemExit("missing_password: set AI_ICT_DEPLOY_PASSWORD or pass --password")
with TemporaryDirectory() as tmp_dir:
archive_path = Path(tmp_dir) / "ai-ict-deploy.tgz"
create_archive(archive_path)
client = connect_ssh(host=args.host, user=args.user, password=args.password, port=args.port)
sudo_password = args.password if args.use_sudo else None
try:
run_remote(client, f"mkdir -p {args.remote_path}")
upload_file(client, archive_path, args.remote_archive)
run_remote(
client,
build_deploy_command(
remote_path=args.remote_path,
remote_archive=args.remote_archive,
),
timeout=600,
sudo_password=sudo_password,
)
finally:
client.close()
smoke = run_http_smoke(args.smoke_base_url, attempts=args.smoke_attempts, sleep_seconds=args.smoke_sleep_seconds)
print(f"deploy_complete host={args.host} remote_path={args.remote_path}")
print(f"smoke_complete endpoints={len(smoke)}")
return 0
def create_archive(archive_path: Path) -> None:
with tarfile.open(archive_path, "w:gz") as archive:
for relative in DEPLOY_PATHS:
path = ROOT / relative
if not path.exists():
continue
archive.add(path, arcname=relative, filter=_tar_filter)
def connect_ssh(*, host: str, user: str, password: str, port: int):
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(
hostname=host,
port=port,
username=user,
password=password,
look_for_keys=False,
allow_agent=False,
timeout=20,
banner_timeout=20,
auth_timeout=20,
)
return client
def upload_file(client, local_path: Path, remote_path: str) -> None:
with client.open_sftp() as sftp:
sftp.put(str(local_path), remote_path)
def run_remote(client, command: str, *, timeout: int = 120, sudo_password: Optional[str] = None) -> str:
effective_command = command
if sudo_password:
effective_command = f"sudo -S -p '' bash -lc {_sh_quote(command)}"
stdin, stdout, stderr = client.exec_command(effective_command, timeout=timeout)
if sudo_password:
stdin.write(sudo_password + "\n")
stdin.flush()
exit_status = stdout.channel.recv_exit_status()
out = stdout.read().decode("utf-8", errors="replace")
err = stderr.read().decode("utf-8", errors="replace")
if exit_status != 0:
raise RuntimeError(f"remote_command_failed:{exit_status}\n{command}\nSTDOUT:\n{out}\nSTDERR:\n{err}")
return out
def build_deploy_command(*, remote_path: str, remote_archive: str) -> str:
remote_path_q = _sh_quote(remote_path)
remote_archive_q = _sh_quote(remote_archive)
env_backup = f"{remote_path.rstrip('/')}/.env.deploy.bak"
return " && ".join(
[
f"mkdir -p {remote_path_q}",
f"if [ -f {remote_path_q}/.env ]; then cp {remote_path_q}/.env {_sh_quote(env_backup)}; fi",
f"rm -rf {remote_path_q}/*",
f"tar -xzf {remote_archive_q} -C {remote_path_q}",
f"if [ -f {_sh_quote(env_backup)} ]; then mv {_sh_quote(env_backup)} {remote_path_q}/.env; fi",
f"cd {remote_path_q}",
"((docker compose down || docker-compose down) >/dev/null 2>&1 || true)",
"((docker rm -f ai-ict >/dev/null 2>&1) || true)",
"((docker compose up -d --build) || (docker-compose up -d --build))",
"((docker compose exec -T ai-ict python scripts/apply_migrations.py) || (docker-compose exec -T ai-ict python scripts/apply_migrations.py))",
"((docker compose ps) || (docker-compose ps))",
]
)
def run_http_smoke(base_url: str, *, attempts: int = 10, sleep_seconds: float = 3.0) -> list[dict]:
endpoints = [
"/health",
"/trading/runtime-status",
"/trading/research-council/optimization-cycle/latest?instrument_id=1",
"/trading/research-council/optimization-cycles?instrument_id=1&limit=1",
"/trading/research-council/optimization-review?instrument_id=1",
"/trading/review-packet?instrument_id=1",
"/trading/alerts/quality-summary?instrument_id=1",
"/trading/alerts/operator-digest?instrument_id=1",
"/trading/runtime-incidents?limit=20&refresh=0",
"/trading/maintenance-summary?instrument_id=1",
"/trading/unattended-harness?instrument_id=1",
]
results = []
for endpoint in endpoints:
url = f"{base_url.rstrip('/')}{endpoint}"
last_error = None
for attempt in range(1, attempts + 1):
try:
with urllib.request.urlopen(url, timeout=20) as response:
body = response.read(4096)
results.append({"url": url, "status": response.status, "bytes": len(body), "attempt": attempt})
break
except Exception as exc: # pragma: no cover - exercised by live deploys
last_error = exc
if attempt < attempts:
time.sleep(sleep_seconds)
else:
raise RuntimeError(f"http_smoke_failed:{url}:{last_error}")
return results
def _tar_filter(info: tarfile.TarInfo):
normalized = info.name.replace("\\", "/")
ignored_parts = {".git", ".venv", "__pycache__", ".pytest_cache", "node_modules"}
if any(part in ignored_parts for part in normalized.split("/")):
return None
return info
def _parse_args(argv: Optional[list[str]], env) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Deploy ai-exchange over password SSH without expect.")
parser.add_argument("--host", default=env.get("AI_ICT_DEPLOY_HOST", DEFAULT_HOST))
parser.add_argument("--port", type=int, default=int(env.get("AI_ICT_DEPLOY_PORT", "22")))
parser.add_argument("--user", default=env.get("AI_ICT_DEPLOY_USER", DEFAULT_USER))
parser.add_argument("--password", default=env.get("AI_ICT_DEPLOY_PASSWORD", ""))
parser.add_argument("--remote-path", default=env.get("AI_ICT_DEPLOY_REMOTE_PATH", DEFAULT_REMOTE_PATH))
parser.add_argument("--remote-archive", default=env.get("AI_ICT_DEPLOY_REMOTE_ARCHIVE", DEFAULT_REMOTE_ARCHIVE))
parser.add_argument("--smoke-base-url", default=env.get("AI_ICT_DEPLOY_SMOKE_BASE_URL", DEFAULT_SMOKE_BASE_URL))
parser.add_argument("--smoke-attempts", type=int, default=int(env.get("AI_ICT_DEPLOY_SMOKE_ATTEMPTS", "10")))
parser.add_argument("--smoke-sleep-seconds", type=float, default=float(env.get("AI_ICT_DEPLOY_SMOKE_SLEEP_SECONDS", "3")))
parser.add_argument(
"--use-sudo",
action="store_true",
default=str(env.get("AI_ICT_DEPLOY_USE_SUDO", "")).strip().lower() in {"1", "true", "yes", "on"},
help="Run the remote unpack/compose/migration step with sudo -S using the SSH password via stdin.",
)
return parser.parse_args(argv)
def _sh_quote(value: str) -> str:
return "'" + value.replace("'", "'\"'\"'") + "'"
if __name__ == "__main__":
raise SystemExit(main())