293 lines
8.2 KiB
Python
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())
|