Files
mytoolkit/bin/commands/server.py
T

403 lines
13 KiB
Python

"""Server management for workspace projects."""
import os
import signal
import subprocess
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Literal, Optional
import click
Mode = Literal["dev", "serve"]
@dataclass(frozen=True)
class Service:
name: str
serve_port: int
dev_port: Optional[int] = None
description: str = ""
project_dir: Optional[Path] = None
serve_cmd: Optional[list[str]] = None
dev_cmd: Optional[list[str]] = None
@property
def manageable(self) -> bool:
return self.project_dir is not None and self.serve_cmd is not None
@property
def has_dev(self) -> bool:
return self.dev_port is not None and self.dev_cmd is not None
def port_for(self, dev: bool) -> Optional[int]:
return self.dev_port if dev else self.serve_port
def cmd_for(self, dev: bool) -> Optional[list[str]]:
return self.dev_cmd if dev else self.serve_cmd
KNOWN_SERVICES: list[Service] = [
Service("metabot-api", 9100, description="MetaBot HTTP API"),
Service("metabot-memory", 8100, description="MetaBot MetaMemory"),
Service(
"mywebpage",
serve_port=3010,
dev_port=3000,
description="APAAM Lab website (Docusaurus)",
project_dir=Path.home() / "Documents" / "myWork" / "sysu" / "04-学术积累" / "mywebpage",
serve_cmd=["npm", "run", "serve"],
dev_cmd=["npm", "run", "dev"],
),
Service(
"apaam-webpage",
serve_port=3011,
dev_port=3001,
description="Phynexis docs (Docusaurus)",
project_dir=Path.home() / "Documents" / "myResearch" / "myProjects" / "apaam" / "repo" / "webpage",
serve_cmd=["npm", "run", "serve"],
dev_cmd=["npm", "run", "dev"],
),
Service(
"xiaohe-webpage",
serve_port=3012,
dev_port=3002,
description="Xiaohe company website (Docusaurus)",
project_dir=Path.home() / "Documents" / "myResearch" / "myProjects" / "apaam" / "repo" / "xiaohe-webpage",
serve_cmd=["npm", "run", "serve"],
dev_cmd=["npm", "run", "dev"],
),
]
_BY_NAME: dict[str, Service] = {s.name: s for s in KNOWN_SERVICES}
_ALL_NAMES = list(_BY_NAME.keys())
_MANAGEABLE_NAMES = [s.name for s in KNOWN_SERVICES if s.manageable]
def manageable_services() -> list[Service]:
return [s for s in KNOWN_SERVICES if s.manageable]
def get_service(name: str) -> Service:
return _BY_NAME[name]
def no_color_env() -> dict:
env = os.environ.copy()
env["NO_COLOR"] = "1"
return env
def _listening_map() -> dict[int, dict]:
"""Single batched lsof query for all TCP listening sockets, keyed by port."""
try:
result = subprocess.run(
["lsof", "-nP", "-iTCP", "-sTCP:LISTEN"],
capture_output=True,
text=True,
errors="replace",
timeout=5,
)
except (subprocess.TimeoutExpired, FileNotFoundError):
return {}
if result.returncode != 0 or not result.stdout.strip():
return {}
by_port: dict[int, dict] = {}
for line in result.stdout.strip().split("\n")[1:]:
parts = line.split()
if len(parts) < 9:
continue
listen = next((t for t in reversed(parts) if ":" in t and not t.startswith("(")), None)
if not listen:
continue
try:
port = int(listen.rsplit(":", 1)[1])
except (ValueError, IndexError):
continue
by_port[port] = {
"command": parts[0],
"pid": parts[1],
"user": parts[2],
"listen": listen,
}
return by_port
def _port_status(port: int) -> Optional[dict]:
return _listening_map().get(port)
def _pid_command(pid: str) -> str:
try:
result = subprocess.run(
["ps", "-p", pid, "-o", "comm="],
capture_output=True,
text=True,
timeout=2,
)
if result.returncode == 0:
return result.stdout.strip()
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
return ""
def _resolve(svc: Service, dev: bool) -> tuple[Optional[int], Optional[list[str]], Mode]:
return svc.port_for(dev), svc.cmd_for(dev), "dev" if dev else "serve"
@click.group(name="server")
def server_cmd():
"""Manage workspace dev/serve servers."""
pass
def _print_row(name: str, mode: str, port: int, desc: str, info: Optional[dict]) -> bool:
if info:
pid = info.get("pid", "?")
click.secho(
f"{name:<16} {mode:<6} {port:<6} {'running':<10} {pid:<8} {desc}",
fg="green",
)
return True
click.secho(
f"{name:<16} {mode:<6} {port:<6} {'stopped':<10} {'-':<8} {desc}",
fg="black",
)
return False
@server_cmd.command("list")
def server_list():
"""List all known services (serve + dev modes) and their status."""
click.echo("")
click.secho(
f"{'Service':<16} {'Mode':<6} {'Port':<6} {'Status':<10} {'PID':<8} {'Description'}",
fg="bright_blue",
bold=True,
)
click.echo("-" * 90)
listening = _listening_map()
running = 0
total = 0
for svc in KNOWN_SERVICES:
primary_label = "serve" if svc.serve_cmd is not None else "-"
if _print_row(svc.name, primary_label, svc.serve_port, svc.description, listening.get(svc.serve_port)):
running += 1
total += 1
if svc.has_dev:
assert svc.dev_port is not None
if _print_row("", "dev", svc.dev_port, "(hot reload)", listening.get(svc.dev_port)):
running += 1
total += 1
click.echo("-" * 90)
click.echo(f"Total: {total} ports, {running} running, {total - running} stopped")
click.echo("")
@server_cmd.command("check")
@click.argument("port", type=int)
def server_check(port):
"""Check if a specific port is open."""
info = _port_status(port)
if info:
cmd = _pid_command(info.get("pid", ""))
click.secho(
f"Port {port} is open (PID {info.get('pid', '?')}, {cmd or info.get('command', '')})",
fg="green",
)
else:
click.secho(f"Port {port} is not listening", fg="red")
@server_cmd.command("scan")
@click.argument("start", type=int, default=3000)
@click.argument("end", type=int, default=4000)
def server_scan(start, end):
"""Scan a port range for listening services."""
click.echo(f"Scanning ports {start}-{end} ...")
listening = _listening_map()
found = 0
for port in range(start, end + 1):
info = listening.get(port)
if info:
cmd = _pid_command(info.get("pid", ""))
click.secho(
f" {port:<5} {cmd or info.get('command', '')} (PID {info.get('pid', '?')})",
fg="green",
)
found += 1
if found == 0:
click.echo("No listening ports found.")
else:
click.echo(f"\nFound {found} open port(s).")
@server_cmd.command("start")
@click.argument("name", type=click.Choice(_MANAGEABLE_NAMES, case_sensitive=False))
@click.option("--dev", is_flag=True, help="Start hot-reload dev server on the dev port (default: serve)")
@click.option("--rebuild", is_flag=True, help="Force npm run build before serve (no effect with --dev)")
def server_start(name, dev, rebuild):
"""Start a server in the background.
By default, serve mode is used (production build) on the serve port.
Use --dev to start the hot-reload dev server on the dev port instead.
"""
svc = get_service(name)
port, cmd, mode = _resolve(svc, dev)
if port is None or cmd is None:
click.secho(f"{name} has no {mode} mode configured", fg="red")
return
assert svc.project_dir is not None # _MANAGEABLE_NAMES choice guarantees this
if _port_status(port) is not None:
click.secho(f"{name} ({mode}) is already running on port {port}", fg="yellow")
return
if not svc.project_dir.exists():
click.secho(f"Project directory not found: {svc.project_dir}", fg="red")
return
env = no_color_env()
if not dev:
build_dir = svc.project_dir / "build"
if rebuild or not build_dir.exists():
reason = "forced rebuild" if rebuild else "build directory missing"
click.secho(f"Running npm run build ({reason}) ...", fg="cyan")
build_proc = subprocess.run(
["npm", "run", "build"],
cwd=svc.project_dir,
env=env,
capture_output=True,
text=True,
)
if build_proc.returncode != 0:
click.secho(f"Build failed for {name}", fg="red")
click.echo(build_proc.stderr[-500:] if build_proc.stderr else "")
return
log_file = svc.project_dir / "logs" / f"{svc.name}-{mode}.log"
log_file.parent.mkdir(exist_ok=True)
full_cmd = ["nohup"] + cmd
click.secho(f"Starting {name} ({svc.description}) on port {port} ({mode}) ...", fg="cyan")
with open(log_file, "a") as log:
proc = subprocess.Popen(
full_cmd,
cwd=svc.project_dir,
stdout=log,
stderr=subprocess.STDOUT,
start_new_session=True,
env=env,
)
max_wait = 20 if dev else 30
for _ in range(max_wait * 2):
time.sleep(0.5)
if _port_status(port) is not None:
click.secho(f"{name} ({mode}) started successfully (PID {proc.pid})", fg="green")
return
click.secho(f"{name} may have failed to start (check {log_file})", fg="yellow")
def _kill_port(name: str, mode: Mode, port: int):
info = _port_status(port)
if info is None:
click.secho(f"{name} ({mode}) is not running on port {port}", fg="yellow")
return
pid = info["pid"]
try:
os.kill(int(pid), signal.SIGTERM)
click.secho(f"Sent SIGTERM to {name} ({mode}, PID {pid})", fg="green")
except ProcessLookupError:
click.secho(f"Process {pid} not found", fg="yellow")
return
for _ in range(10):
time.sleep(0.5)
if _port_status(port) is None:
click.secho(f"{name} ({mode}) stopped", fg="green")
return
try:
os.kill(int(pid), signal.SIGKILL)
click.secho(f"Sent SIGKILL to {name} ({mode}, PID {pid})", fg="red")
except ProcessLookupError:
pass
@server_cmd.command("stop")
@click.argument("name", type=click.Choice(_MANAGEABLE_NAMES, case_sensitive=False))
@click.option("--dev", is_flag=True, help="Stop the dev-port instance instead of serve")
@click.option("--all", "all_modes", is_flag=True, help="Stop both dev and serve instances")
def server_stop(name, dev, all_modes):
"""Stop a running server."""
svc = get_service(name)
if all_modes:
targets: list[tuple[Mode, int]] = [("serve", svc.serve_port)]
if svc.has_dev:
assert svc.dev_port is not None
targets.append(("dev", svc.dev_port))
else:
port, _, mode = _resolve(svc, dev)
if port is None:
click.secho(f"{name} has no {mode} mode configured", fg="red")
return
targets = [(mode, port)]
for target_mode, target_port in targets:
_kill_port(name, target_mode, target_port)
@server_cmd.command("restart")
@click.argument("name", type=click.Choice(_MANAGEABLE_NAMES, case_sensitive=False))
@click.option("--dev", is_flag=True, help="Restart the dev instance (default: serve)")
@click.option("--rebuild", is_flag=True, help="Force npm run build before serve (no effect with --dev)")
def server_restart(name, dev, rebuild):
"""Restart a server."""
ctx = click.get_current_context()
ctx.invoke(server_stop, name=name, dev=dev, all_modes=False)
ctx.invoke(server_start, name=name, dev=dev, rebuild=rebuild)
@server_cmd.command("status")
@click.argument("name", type=click.Choice(_ALL_NAMES, case_sensitive=False))
def server_status(name):
"""Show detailed status of a service (both serve and dev ports)."""
svc = get_service(name)
click.echo("")
click.secho(f"Service: {svc.name}", bold=True)
click.echo(f"Desc: {svc.description}")
if svc.project_dir:
click.echo(f"Project: {svc.project_dir}")
def _show(label: str, port: Optional[int]):
if port is None:
return
info = _port_status(port)
if info:
cmd = _pid_command(info.get("pid", ""))
click.secho(
f"{label:<8} {port} running (PID {info.get('pid', '?')}, {cmd or info.get('command', '')})",
fg="green",
)
else:
click.secho(f"{label:<8} {port} stopped", fg="black")
_show("Serve:", svc.serve_port)
_show("Dev:", svc.dev_port)
click.echo("")