568 lines
21 KiB
Python
568 lines
21 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
from datetime import datetime, timezone
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from typing import Any
|
|
from urllib.error import URLError
|
|
from urllib.request import urlopen
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
DEFAULT_RUNTIME_DIR = ROOT / ".local_stack"
|
|
DEFAULT_DATA_DIR = ROOT / ".data_local"
|
|
DEFAULT_ENV_FILES = [
|
|
ROOT / ".env.production",
|
|
ROOT / ".env.local",
|
|
ROOT / ".env",
|
|
]
|
|
|
|
|
|
def _env_int(name: str, default: int, env: dict[str, str] | None = None) -> int:
|
|
source = env or os.environ
|
|
raw = str(source.get(name, "")).strip()
|
|
if not raw:
|
|
return default
|
|
try:
|
|
value = int(raw)
|
|
except ValueError as exc:
|
|
raise RuntimeError(f"{name} must be an integer") from exc
|
|
if value <= 0:
|
|
raise RuntimeError(f"{name} must be positive")
|
|
return value
|
|
|
|
|
|
def utc_now_iso() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def load_env_file(path: Path) -> dict[str, str]:
|
|
if not path.exists():
|
|
return {}
|
|
result: dict[str, str] = {}
|
|
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:
|
|
continue
|
|
if value and value[0] == value[-1] and value[0] in {'"', "'"}:
|
|
value = value[1:-1]
|
|
result[key] = value
|
|
return result
|
|
|
|
|
|
def load_stack_env(extra_env_files: list[Path] | None = None) -> dict[str, str]:
|
|
env = os.environ.copy()
|
|
for path in DEFAULT_ENV_FILES:
|
|
for key, value in load_env_file(path).items():
|
|
env.setdefault(key, value)
|
|
for path in extra_env_files or []:
|
|
for key, value in load_env_file(path).items():
|
|
env[key] = value
|
|
return env
|
|
|
|
|
|
def build_service_specs(env: dict[str, str] | None = None) -> list[dict[str, Any]]:
|
|
gateway_port = _env_int("LOCAL_STACK_GATEWAY_PORT", 8080, env)
|
|
return [
|
|
{"name": "auth", "port": 8001, "module": "services.auth_service.app:app"},
|
|
{"name": "audit", "port": 8002, "module": "services.audit_service.app:app"},
|
|
{"name": "customer", "port": 8003, "module": "services.customer_service.app:app"},
|
|
{"name": "interaction", "port": 8004, "module": "services.interaction_service.app:app"},
|
|
{"name": "routing", "port": 8005, "module": "services.routing_service.app:app"},
|
|
{"name": "voice", "port": 8006, "module": "services.voice_adapter_service.app:app"},
|
|
{"name": "recording", "port": 8013, "module": "services.recording_service.app:app"},
|
|
{"name": "ivr", "port": 8014, "module": "services.ivr_service.app:app"},
|
|
{"name": "event-bus", "port": 8015, "module": "services.event_bus_service.app:app"},
|
|
{"name": "asterisk-bridge", "port": 8016, "module": "services.asterisk_bridge_service.app:app"},
|
|
{"name": "telegram", "port": 8007, "module": "services.telegram_adapter_service.app:app"},
|
|
{"name": "whatsapp", "port": 8019, "module": "services.whatsapp_adapter_service.app:app"},
|
|
{"name": "ai", "port": 8017, "module": "services.ai_orchestrator_service.app:app"},
|
|
{"name": "ai-voice-runtime", "port": 8018, "module": "services.ai_voice_runtime_service.app:app"},
|
|
{"name": "webchat", "port": 8011, "module": "services.webchat_adapter_service.app:app"},
|
|
{"name": "email", "port": 8012, "module": "services.email_adapter_service.app:app"},
|
|
{"name": "kb", "port": 8008, "module": "services.kb_service.app:app"},
|
|
{"name": "reporting", "port": 8009, "module": "services.reporting_service.app:app"},
|
|
{"name": "supervisor", "port": 8010, "module": "services.supervisor_service.app:app"},
|
|
{"name": "gateway", "port": gateway_port, "module": "gateway.app:app"},
|
|
]
|
|
|
|
def build_gateway_env(env: dict[str, str] | None = None) -> dict[str, str]:
|
|
ports = {spec["name"]: int(spec["port"]) for spec in build_service_specs(env)}
|
|
base = "http://127.0.0.1"
|
|
return {
|
|
"AUTH_SERVICE_URL": f"{base}:{ports['auth']}",
|
|
"AUDIT_SERVICE_URL": f"{base}:{ports['audit']}",
|
|
"CUSTOMER_SERVICE_URL": f"{base}:{ports['customer']}",
|
|
"INTERACTION_SERVICE_URL": f"{base}:{ports['interaction']}",
|
|
"ROUTING_SERVICE_URL": f"{base}:{ports['routing']}",
|
|
"VOICE_ADAPTER_SERVICE_URL": f"{base}:{ports['voice']}",
|
|
"RECORDING_SERVICE_URL": f"{base}:{ports['recording']}",
|
|
"IVR_SERVICE_URL": f"{base}:{ports['ivr']}",
|
|
"EVENT_BUS_SERVICE_URL": f"{base}:{ports['event-bus']}",
|
|
"ASTERISK_BRIDGE_SERVICE_URL": f"{base}:{ports['asterisk-bridge']}",
|
|
"TELEGRAM_ADAPTER_SERVICE_URL": f"{base}:{ports['telegram']}",
|
|
"WHATSAPP_ADAPTER_SERVICE_URL": f"{base}:{ports['whatsapp']}",
|
|
"AI_ORCHESTRATOR_SERVICE_URL": f"{base}:{ports['ai']}",
|
|
"AI_VOICE_RUNTIME_SERVICE_URL": f"{base}:{ports['ai-voice-runtime']}",
|
|
"WEBCHAT_ADAPTER_SERVICE_URL": f"{base}:{ports['webchat']}",
|
|
"EMAIL_ADAPTER_SERVICE_URL": f"{base}:{ports['email']}",
|
|
"KB_SERVICE_URL": f"{base}:{ports['kb']}",
|
|
"REPORTING_SERVICE_URL": f"{base}:{ports['reporting']}",
|
|
"SUPERVISOR_SERVICE_URL": f"{base}:{ports['supervisor']}",
|
|
}
|
|
|
|
|
|
def service_bind_host(spec_name: str, env: dict[str, str]) -> str:
|
|
if spec_name == "gateway":
|
|
return env.get("LOCAL_STACK_GATEWAY_HOST", "127.0.0.1").strip() or "127.0.0.1"
|
|
return env.get("LOCAL_STACK_SERVICE_HOST", "127.0.0.1").strip() or "127.0.0.1"
|
|
|
|
|
|
def service_health_host(spec_name: str, env: dict[str, str]) -> str:
|
|
if spec_name == "gateway":
|
|
configured = env.get("LOCAL_STACK_GATEWAY_HEALTH_HOST", "").strip()
|
|
if configured:
|
|
return configured
|
|
bind_host = service_bind_host(spec_name, env)
|
|
if bind_host in {"0.0.0.0", "::"}:
|
|
return "127.0.0.1"
|
|
return bind_host
|
|
|
|
|
|
def public_base_url(env: dict[str, str] | None = None) -> str:
|
|
current_env = env or {}
|
|
default_url = "http://127.0.0.1:8080"
|
|
return current_env.get("LOCAL_STACK_PUBLIC_BASE_URL", default_url).strip() or default_url
|
|
|
|
|
|
def build_manifest_payload(
|
|
runtime_dir: Path,
|
|
data_dir: Path,
|
|
services: list[dict[str, Any]],
|
|
env: dict[str, str] | None = None,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"generated_at": utc_now_iso(),
|
|
"root": str(ROOT),
|
|
"runtime_dir": str(runtime_dir),
|
|
"data_dir": str(data_dir),
|
|
"base_url": public_base_url(env),
|
|
"services": services,
|
|
}
|
|
|
|
|
|
def manifest_path(runtime_dir: Path) -> Path:
|
|
return runtime_dir / "manifest.json"
|
|
|
|
|
|
def load_manifest(runtime_dir: Path) -> dict[str, Any] | None:
|
|
path = manifest_path(runtime_dir)
|
|
if not path.exists():
|
|
return None
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def save_manifest(runtime_dir: Path, payload: dict[str, Any]) -> None:
|
|
runtime_dir.mkdir(parents=True, exist_ok=True)
|
|
manifest_path(runtime_dir).write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
|
|
|
|
def remove_manifest(runtime_dir: Path) -> None:
|
|
path = manifest_path(runtime_dir)
|
|
if path.exists():
|
|
path.unlink()
|
|
|
|
|
|
def is_pid_running(pid: int) -> bool:
|
|
if pid <= 0:
|
|
return False
|
|
if os.name == "nt":
|
|
result = subprocess.run(
|
|
["tasklist", "/FI", f"PID eq {pid}"],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.DEVNULL,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
return False
|
|
return str(pid) in (result.stdout or "")
|
|
try:
|
|
os.kill(pid, 0)
|
|
except (OSError, SystemError):
|
|
return False
|
|
return True
|
|
|
|
|
|
def resolve_listener_pid(port: int) -> int | None:
|
|
if os.name == "nt":
|
|
result = subprocess.run(
|
|
["netstat", "-ano", "-p", "tcp"],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.DEVNULL,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
return None
|
|
markers = {f"127.0.0.1:{port}", f"0.0.0.0:{port}", f"[::]:{port}"}
|
|
for raw_line in (result.stdout or "").splitlines():
|
|
line = raw_line.strip()
|
|
if "LISTENING" not in line:
|
|
continue
|
|
parts = line.split()
|
|
if len(parts) < 5:
|
|
continue
|
|
local_address = parts[1]
|
|
state = parts[3]
|
|
pid_raw = parts[4]
|
|
if local_address not in markers or state != "LISTENING":
|
|
continue
|
|
try:
|
|
pid = int(pid_raw)
|
|
except ValueError:
|
|
continue
|
|
if pid > 0:
|
|
return pid
|
|
return None
|
|
return None
|
|
|
|
|
|
def wait_for_health(host: str, port: int, retries: int = 80, delay: float = 0.25) -> None:
|
|
url = f"http://{host}:{port}/health"
|
|
for _ in range(retries):
|
|
try:
|
|
with urlopen(url, timeout=1.5) as response: # noqa: S310 - local health probe
|
|
if response.status == 200:
|
|
return
|
|
except (OSError, URLError):
|
|
pass
|
|
time.sleep(delay)
|
|
raise RuntimeError(f"Service did not become healthy: {url}")
|
|
|
|
|
|
def create_log_paths(runtime_dir: Path, name: str) -> tuple[Path, Path]:
|
|
log_dir = runtime_dir / "logs"
|
|
log_dir.mkdir(parents=True, exist_ok=True)
|
|
return log_dir / f"{name}.stdout.log", log_dir / f"{name}.stderr.log"
|
|
|
|
|
|
def spawn_service(spec: dict[str, Any], runtime_dir: Path, data_dir: Path, base_env: dict[str, str]) -> dict[str, Any]:
|
|
env = base_env.copy()
|
|
env["CC_DATA_DIR"] = str(data_dir)
|
|
env["EVENT_BUS_ENABLED"] = env.get("EVENT_BUS_ENABLED", "0")
|
|
env["EVENT_BUS_URL"] = env.get("EVENT_BUS_URL", "amqp://guest:guest@127.0.0.1:5672/")
|
|
env["EVENT_BUS_EXCHANGE"] = env.get("EVENT_BUS_EXCHANGE", "mvpcc.domain.events")
|
|
env["EVENT_BUS_DISPATCH_BATCH_SIZE"] = env.get("EVENT_BUS_DISPATCH_BATCH_SIZE", "50")
|
|
env["EVENT_BUS_MAX_ATTEMPTS"] = env.get("EVENT_BUS_MAX_ATTEMPTS", "5")
|
|
env["EVENT_BUS_POLL_INTERVAL_SECONDS"] = env.get("EVENT_BUS_POLL_INTERVAL_SECONDS", "2")
|
|
env["EVENT_BUS_CONSUMER_ENABLED"] = env.get("EVENT_BUS_CONSUMER_ENABLED", "1")
|
|
env["EVENT_BUS_AUDIT_QUEUE"] = env.get("EVENT_BUS_AUDIT_QUEUE", "mvpcc.audit.events")
|
|
env["EVENT_BUS_REPORTING_QUEUE"] = env.get("EVENT_BUS_REPORTING_QUEUE", "mvpcc.reporting.events")
|
|
env["ASTERISK_BRIDGE_ENABLED"] = env.get("ASTERISK_BRIDGE_ENABLED", "0")
|
|
env["ASTERISK_AMI_EVENT_PREFIX"] = env.get("ASTERISK_AMI_EVENT_PREFIX", "MVPCC")
|
|
env["ASTERISK_QUEUE_MAP_JSON"] = env.get("ASTERISK_QUEUE_MAP_JSON", "{}")
|
|
env["ASTERISK_CALLCONTROL_ENABLED"] = env.get("ASTERISK_CALLCONTROL_ENABLED", "0")
|
|
env["ASTERISK_CALLCONTROL_ACTION_TIMEOUT_SECONDS"] = env.get(
|
|
"ASTERISK_CALLCONTROL_ACTION_TIMEOUT_SECONDS",
|
|
"10",
|
|
)
|
|
env["ASTERISK_WEBRTC_ENABLED"] = env.get("ASTERISK_WEBRTC_ENABLED", "0")
|
|
env["ASTERISK_WEBRTC_WS_URL"] = env.get("ASTERISK_WEBRTC_WS_URL", "")
|
|
env["ASTERISK_WEBRTC_ICE_SERVERS_JSON"] = env.get("ASTERISK_WEBRTC_ICE_SERVERS_JSON", "[]")
|
|
env["ASTERISK_OPERATOR_EXTENSION_MAP_JSON"] = env.get("ASTERISK_OPERATOR_EXTENSION_MAP_JSON", "{}")
|
|
env["ASTERISK_BROWSER_SIP_MAP_JSON"] = env.get("ASTERISK_BROWSER_SIP_MAP_JSON", "{}")
|
|
env["ASTERISK_TRANSFER_TARGET_MAP_JSON"] = env.get("ASTERISK_TRANSFER_TARGET_MAP_JSON", "{}")
|
|
env["AI_PROVIDER"] = env.get("AI_PROVIDER", "stub")
|
|
env["AI_API_BASE"] = env.get("AI_API_BASE", "")
|
|
env["AI_API_KEY"] = env.get("AI_API_KEY", "")
|
|
env["AI_MODEL"] = env.get("AI_MODEL", "stub-telegram-assistant")
|
|
env["AI_TIMEOUT_SECONDS"] = env.get("AI_TIMEOUT_SECONDS", "20")
|
|
env["AI_TELEGRAM_ENABLED"] = env.get("AI_TELEGRAM_ENABLED", "0")
|
|
env["AI_TELEGRAM_ALWAYS_REPLY"] = env.get("AI_TELEGRAM_ALWAYS_REPLY", "0")
|
|
env["AI_TELEGRAM_MAX_CONTEXT_MESSAGES"] = env.get("AI_TELEGRAM_MAX_CONTEXT_MESSAGES", "20")
|
|
env["AI_TELEGRAM_MAX_KB_RESULTS"] = env.get("AI_TELEGRAM_MAX_KB_RESULTS", "3")
|
|
env["AI_TELEGRAM_CONFIDENCE_HANDOFF_THRESHOLD"] = env.get(
|
|
"AI_TELEGRAM_CONFIDENCE_HANDOFF_THRESHOLD",
|
|
"0.65",
|
|
)
|
|
env["AI_WHATSAPP_ENABLED"] = env.get("AI_WHATSAPP_ENABLED", "0")
|
|
env["AI_WHATSAPP_ALWAYS_REPLY"] = env.get("AI_WHATSAPP_ALWAYS_REPLY", "0")
|
|
env["AI_WHATSAPP_MAX_CONTEXT_MESSAGES"] = env.get("AI_WHATSAPP_MAX_CONTEXT_MESSAGES", "20")
|
|
env["AI_WHATSAPP_MAX_KB_RESULTS"] = env.get("AI_WHATSAPP_MAX_KB_RESULTS", "3")
|
|
env["AI_WHATSAPP_CONFIDENCE_HANDOFF_THRESHOLD"] = env.get(
|
|
"AI_WHATSAPP_CONFIDENCE_HANDOFF_THRESHOLD",
|
|
"0.65",
|
|
)
|
|
env["AI_VOICE_RUNTIME_SERVICE_URL"] = env.get("AI_VOICE_RUNTIME_SERVICE_URL", "http://127.0.0.1:8018")
|
|
env["AI_VOICE_ENABLED"] = env.get("AI_VOICE_ENABLED", "0")
|
|
env["AI_VOICE_QUEUE_CONFIG_JSON"] = env.get("AI_VOICE_QUEUE_CONFIG_JSON", "{}")
|
|
env["AI_VOICE_ASR_PROVIDER"] = env.get("AI_VOICE_ASR_PROVIDER", "openai")
|
|
env["AI_VOICE_TTS_PROVIDER"] = env.get("AI_VOICE_TTS_PROVIDER", "openai")
|
|
env["AI_VOICE_MAX_CONTEXT_SEGMENTS"] = env.get("AI_VOICE_MAX_CONTEXT_SEGMENTS", "8")
|
|
env["AI_VOICE_HANDOFF_TIMEOUT_SECONDS"] = env.get("AI_VOICE_HANDOFF_TIMEOUT_SECONDS", "8")
|
|
env["AI_VOICE_RUNTIME_TRUSTED_SERVICE_SUBJECTS"] = env.get(
|
|
"AI_VOICE_RUNTIME_TRUSTED_SERVICE_SUBJECTS",
|
|
"svc:ai-voice-runtime",
|
|
)
|
|
env["ASTERISK_CALLCONTROL_CLAIM_CONTEXT"] = env.get("ASTERISK_CALLCONTROL_CLAIM_CONTEXT", "mvpcc-claim")
|
|
env["ASTERISK_CALLCONTROL_TRANSFER_CONTEXT"] = env.get(
|
|
"ASTERISK_CALLCONTROL_TRANSFER_CONTEXT",
|
|
"mvpcc-transfer",
|
|
)
|
|
if spec["name"] == "recording":
|
|
env["CC_RECORDINGS_DIR"] = str((data_dir / "recordings").resolve())
|
|
env["RECORDING_MAX_BYTES"] = env.get("RECORDING_MAX_BYTES", "26214400")
|
|
if spec["name"] == "gateway":
|
|
env.update(build_gateway_env(env))
|
|
|
|
stdout_path, stderr_path = create_log_paths(runtime_dir, str(spec["name"]))
|
|
creationflags = 0
|
|
if os.name == "nt":
|
|
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
|
|
cmd = [
|
|
sys.executable,
|
|
"-m",
|
|
"uvicorn",
|
|
str(spec["module"]),
|
|
"--host",
|
|
service_bind_host(str(spec["name"]), env),
|
|
"--port",
|
|
str(spec["port"]),
|
|
]
|
|
|
|
with stdout_path.open("ab") as stdout_file, stderr_path.open("ab") as stderr_file:
|
|
proc = subprocess.Popen(
|
|
cmd,
|
|
cwd=str(ROOT),
|
|
env=env,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=stdout_file,
|
|
stderr=stderr_file,
|
|
creationflags=creationflags,
|
|
)
|
|
|
|
time.sleep(0.15)
|
|
if proc.poll() is not None:
|
|
raise RuntimeError(f"{spec['name']} failed to start; check {stderr_path}")
|
|
|
|
return {
|
|
"name": spec["name"],
|
|
"module": spec["module"],
|
|
"port": spec["port"],
|
|
"pid": proc.pid,
|
|
"stdout_log": str(stdout_path),
|
|
"stderr_log": str(stderr_path),
|
|
}
|
|
|
|
|
|
def terminate_pid(pid: int) -> None:
|
|
if not is_pid_running(pid):
|
|
return
|
|
try:
|
|
os.kill(pid, signal.SIGTERM)
|
|
except OSError:
|
|
return
|
|
|
|
for _ in range(20):
|
|
if not is_pid_running(pid):
|
|
return
|
|
time.sleep(0.2)
|
|
|
|
if os.name == "nt":
|
|
subprocess.run(
|
|
["taskkill", "/PID", str(pid), "/T", "/F"],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
check=False,
|
|
)
|
|
return
|
|
|
|
try:
|
|
os.kill(pid, signal.SIGKILL)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def running_services(manifest: dict[str, Any] | None) -> list[dict[str, Any]]:
|
|
if not manifest or not isinstance(manifest, dict):
|
|
return []
|
|
active: list[dict[str, Any]] = []
|
|
for svc in manifest.get("services", []):
|
|
if not isinstance(svc, dict):
|
|
continue
|
|
try:
|
|
pid = int(svc.get("pid", 0))
|
|
except Exception: # noqa: BLE001
|
|
continue
|
|
if is_pid_running(pid):
|
|
active.append(svc)
|
|
return active
|
|
|
|
|
|
def start_stack(runtime_dir: Path, data_dir: Path, force_restart: bool, env: dict[str, str]) -> int:
|
|
service_specs = build_service_specs(env)
|
|
current = load_manifest(runtime_dir)
|
|
active = running_services(current)
|
|
if active and not force_restart:
|
|
print("Local stack is already running.")
|
|
print(f"Base URL: {current.get('base_url', public_base_url(env))}")
|
|
print(f"Logs: {runtime_dir / 'logs'}")
|
|
print("Use `python scripts\\local_stack.py stop` first or rerun with --force-restart.")
|
|
return 0
|
|
|
|
if active and force_restart:
|
|
stop_stack(runtime_dir)
|
|
|
|
if current and not active:
|
|
remove_manifest(runtime_dir)
|
|
|
|
runtime_dir.mkdir(parents=True, exist_ok=True)
|
|
data_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
launched: list[dict[str, Any]] = []
|
|
try:
|
|
for spec in service_specs:
|
|
meta = spawn_service(spec, runtime_dir, data_dir, env)
|
|
wait_for_health(service_health_host(str(spec["name"]), env), int(meta["port"]))
|
|
if not is_pid_running(int(meta["pid"])):
|
|
listener_pid = resolve_listener_pid(int(meta["port"]))
|
|
if listener_pid and is_pid_running(listener_pid):
|
|
meta["pid"] = listener_pid
|
|
else:
|
|
raise RuntimeError(f"{meta['name']} exited before becoming ready; check {meta['stderr_log']}")
|
|
launched.append(meta)
|
|
print(f"[ok] {meta['name']} on {meta['port']} (pid {meta['pid']})")
|
|
except Exception as exc:
|
|
print(f"[fail] {exc}")
|
|
print(f"Check logs: {runtime_dir / 'logs'}")
|
|
print("If ports are already busy, stop old local processes and rerun.")
|
|
for meta in reversed(launched):
|
|
terminate_pid(int(meta["pid"]))
|
|
return 1
|
|
|
|
save_manifest(runtime_dir, build_manifest_payload(runtime_dir, data_dir, launched, env))
|
|
print("")
|
|
print("Local stack is ready.")
|
|
print(f"UI: {public_base_url(env)}/")
|
|
print(f"Logs: {runtime_dir / 'logs'}")
|
|
print("Stop: python scripts\\local_stack.py stop")
|
|
return 0
|
|
|
|
|
|
def stop_stack(runtime_dir: Path) -> int:
|
|
current = load_manifest(runtime_dir)
|
|
if not current:
|
|
print("Local stack is not running (no manifest found).")
|
|
return 0
|
|
|
|
services = current.get("services", []) if isinstance(current, dict) else []
|
|
for meta in reversed(services):
|
|
if not isinstance(meta, dict):
|
|
continue
|
|
name = str(meta.get("name", "service"))
|
|
try:
|
|
pid = int(meta.get("pid", 0))
|
|
except Exception: # noqa: BLE001
|
|
print(f"[skip] {name} (pid invalid)")
|
|
continue
|
|
if is_pid_running(pid):
|
|
terminate_pid(pid)
|
|
print(f"[stopped] {name} (pid {pid})")
|
|
else:
|
|
print(f"[skip] {name} (pid {pid}) already stopped")
|
|
|
|
remove_manifest(runtime_dir)
|
|
print("Local stack is stopped.")
|
|
return 0
|
|
|
|
|
|
def status_stack(runtime_dir: Path, env: dict[str, str]) -> int:
|
|
current = load_manifest(runtime_dir)
|
|
if not current:
|
|
print("Local stack is not running.")
|
|
return 0
|
|
|
|
print(f"Base URL: {current.get('base_url', public_base_url(env))}")
|
|
print(f"Data dir: {current.get('data_dir', str(DEFAULT_DATA_DIR))}")
|
|
print(f"Logs: {runtime_dir / 'logs'}")
|
|
print("")
|
|
services = current.get("services", []) if isinstance(current, dict) else []
|
|
for meta in services:
|
|
if not isinstance(meta, dict):
|
|
continue
|
|
try:
|
|
pid = int(meta.get("pid", 0))
|
|
except Exception: # noqa: BLE001
|
|
pid = 0
|
|
label = "running" if is_pid_running(pid) else "stopped"
|
|
print(f"- {meta.get('name')}: {label} on {meta.get('port')} (pid {pid})")
|
|
return 0
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Manage the local MVP stack without opening extra consoles")
|
|
parser.add_argument("command", choices=["start", "stop", "status"], help="Action to perform")
|
|
parser.add_argument(
|
|
"--runtime-dir",
|
|
default=str(DEFAULT_RUNTIME_DIR),
|
|
help="Directory for manifest and logs",
|
|
)
|
|
parser.add_argument(
|
|
"--data-dir",
|
|
default=None,
|
|
help="Directory for local runtime data and recordings",
|
|
)
|
|
parser.add_argument(
|
|
"--env-file",
|
|
action="append",
|
|
default=[],
|
|
help="Additional env file to apply after the default stack env files",
|
|
)
|
|
parser.add_argument(
|
|
"--force-restart",
|
|
action="store_true",
|
|
help="Stop the existing managed stack before starting a new one",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
extra_env_files: list[Path] = []
|
|
for raw_path in args.env_file:
|
|
path = Path(raw_path)
|
|
if not path.is_absolute():
|
|
path = (ROOT / path).resolve()
|
|
extra_env_files.append(path)
|
|
|
|
env = load_stack_env(extra_env_files)
|
|
|
|
runtime_dir = Path(args.runtime_dir)
|
|
if not runtime_dir.is_absolute():
|
|
runtime_dir = (ROOT / runtime_dir).resolve()
|
|
|
|
raw_data_dir = args.data_dir or env.get("CC_DATA_DIR") or str(DEFAULT_DATA_DIR)
|
|
data_dir = Path(raw_data_dir)
|
|
if not data_dir.is_absolute():
|
|
data_dir = (ROOT / data_dir).resolve()
|
|
|
|
if args.command == "start":
|
|
return start_stack(runtime_dir, data_dir, args.force_restart, env)
|
|
if args.command == "stop":
|
|
return stop_stack(runtime_dir)
|
|
return status_stack(runtime_dir, env)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|