AutoRpa/scripts/dev.py

293 lines
8.2 KiB
Python

"""Start and inspect the local state-center integration stack.
Usage:
python scripts/dev.py start
python scripts/dev.py status
python scripts/dev.py stop
"""
from __future__ import annotations
import argparse
import json
import os
import signal
import socket
import subprocess
import sys
import time
from pathlib import Path
from urllib.error import URLError
from urllib.request import urlopen
ROOT = Path(__file__).resolve().parents[1]
RUNTIME_DIR = ROOT / ".runtime"
PID_FILE = RUNTIME_DIR / "dev-pids.json"
MYSQL_CONTAINER = "rpa-center-mysql"
MYSQL_VOLUME = "rpa-center-mysql-data"
MYSQL_IMAGE = "mysql:8"
def build_backend_env(source: dict[str, str] | None = None) -> dict[str, str]:
"""Return backend environment with safe local defaults."""
env = dict(os.environ if source is None else source)
env.setdefault("DEV_MODE", "true")
env.setdefault(
"MYSQL_DSN",
"mysql+pymysql://root:root@127.0.0.1:3306/rpa_center?charset=utf8mb4",
)
env.setdefault("AGENT_TOKEN", "dev-agent-token")
return env
def _run(command: list[str], *, check: bool = True, capture: bool = False) -> subprocess.CompletedProcess[str]:
return subprocess.run(
command,
cwd=ROOT,
check=check,
text=True,
capture_output=capture,
)
def _command_exists(command: str) -> bool:
return subprocess.run(
[command, "--version"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode == 0
def _backend_python() -> str:
configured = os.getenv("STATE_CENTER_PYTHON")
if configured:
return configured
local_python = ROOT / ".venv_test" / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
if local_python.exists():
return str(local_python)
return sys.executable
def _ensure_backend_runtime() -> str:
python = _backend_python()
probe = subprocess.run(
[python, "-c", "import fastapi, pymysql, uvicorn, cryptography"],
cwd=ROOT,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
check=False,
)
if probe.returncode != 0:
raise RuntimeError(
f"Backend dependencies are missing for {python}. "
f"Install them with: {python} -m pip install -r state_center/requirements.txt"
)
return python
def _docker_container_state() -> str | None:
if not _command_exists("docker"):
return None
result = _run(
["docker", "inspect", "-f", "{{.State.Status}}", MYSQL_CONTAINER],
check=False,
capture=True,
)
if result.returncode != 0:
return None
return result.stdout.strip()
def ensure_mysql() -> None:
if not _command_exists("docker"):
raise RuntimeError("Docker is required to start the local MySQL container.")
state = _docker_container_state()
if state == "running":
return
if state:
_run(["docker", "start", MYSQL_CONTAINER])
return
_run(
[
"docker",
"run",
"--detach",
"--name",
MYSQL_CONTAINER,
"--env",
"MYSQL_ROOT_PASSWORD=root",
"--env",
"MYSQL_DATABASE=rpa_center",
"--publish",
"3306:3306",
"--volume",
f"{MYSQL_VOLUME}:/var/lib/mysql",
"--volume",
f"{ROOT / 'state_center' / 'sql' / 'schema.sql'}:/docker-entrypoint-initdb.d/001-schema.sql:ro",
MYSQL_IMAGE,
]
)
def _port_open(host: str, port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(0.5)
return sock.connect_ex((host, port)) == 0
def wait_for_port(host: str, port: int, timeout: float = 30) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if _port_open(host, port):
return
time.sleep(0.5)
raise RuntimeError(f"Timed out waiting for {host}:{port}.")
def _spawn(name: str, command: list[str], cwd: Path, env: dict[str, str]) -> int:
RUNTIME_DIR.mkdir(exist_ok=True)
log_path = RUNTIME_DIR / f"{name}.log"
log_file = log_path.open("a", encoding="utf-8")
kwargs: dict[str, object] = {
"cwd": cwd,
"env": env,
"stdout": log_file,
"stderr": subprocess.STDOUT,
"stdin": subprocess.DEVNULL,
}
if os.name == "nt":
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
else:
kwargs["start_new_session"] = True
process = subprocess.Popen(command, **kwargs)
log_file.close()
return process.pid
def _read_pids() -> dict[str, int]:
if not PID_FILE.exists():
return {}
try:
return {key: int(value) for key, value in json.loads(PID_FILE.read_text()).items()}
except (OSError, ValueError, TypeError):
return {}
def _write_pids(pids: dict[str, int]) -> None:
RUNTIME_DIR.mkdir(exist_ok=True)
PID_FILE.write_text(json.dumps(pids, indent=2), encoding="utf-8")
def _pid_alive(pid: int) -> bool:
if pid <= 0:
return False
try:
os.kill(pid, 0)
except OSError:
return False
return True
def _service_alive(pid: int, host: str, port: int) -> bool:
return _pid_alive(pid) or _port_open(host, port)
def start() -> None:
ensure_mysql()
wait_for_port("127.0.0.1", 3306)
backend_python = _ensure_backend_runtime()
pids = _read_pids()
backend_env = build_backend_env()
if not _service_alive(pids.get("backend", -1), "127.0.0.1", 8000):
pids["backend"] = _spawn(
"backend",
[
backend_python,
"-m",
"uvicorn",
"state_center.app.main:app",
"--host",
"0.0.0.0",
"--port",
"8000",
],
ROOT,
backend_env,
)
if not _service_alive(pids.get("frontend", -1), "127.0.0.1", 5173):
pids["frontend"] = _spawn(
"frontend",
["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"],
ROOT / "state_center_web",
os.environ.copy(),
)
_write_pids(pids)
wait_for_port("127.0.0.1", 8000)
wait_for_port("127.0.0.1", 5173)
print("MySQL: 127.0.0.1:3306")
print("Backend: http://127.0.0.1:8000")
print("Frontend: http://127.0.0.1:5173")
def status() -> int:
db = _port_open("127.0.0.1", 3306)
backend = False
backend_detail = ""
try:
with urlopen("http://127.0.0.1:8000/health", timeout=2) as response:
payload = json.loads(response.read().decode("utf-8"))
backend = response.status == 200 and payload.get("database") == "ok"
backend_detail = json.dumps(payload, ensure_ascii=False)
except (OSError, URLError, json.JSONDecodeError) as exc:
backend_detail = str(exc)
frontend = _port_open("127.0.0.1", 5173)
print(f"MySQL 3306: {'UP' if db else 'DOWN'}")
print(f"Backend 8000: {'UP' if backend else 'DOWN'} {backend_detail}".rstrip())
print(f"Frontend 5173: {'UP' if frontend else 'DOWN'}")
return 0 if db and backend and frontend else 1
def _stop_pid(pid: int) -> None:
try:
if os.name == "nt":
os.kill(pid, signal.SIGTERM)
else:
os.killpg(os.getpgid(pid), signal.SIGTERM)
except OSError:
return
def stop() -> None:
for pid in _read_pids().values():
_stop_pid(pid)
if _docker_container_state() == "running":
_run(["docker", "stop", MYSQL_CONTAINER], check=False)
if PID_FILE.exists():
PID_FILE.unlink()
print("Local integration stack stopped.")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("command", choices=("start", "status", "stop"))
args = parser.parse_args()
try:
if args.command == "start":
start()
return 0
if args.command == "status":
return status()
stop()
return 0
except (OSError, RuntimeError, subprocess.CalledProcessError) as exc:
print(f"dev launcher error: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())