增加了一个测试工具用于初始化测试环境
This commit is contained in:
@@ -0,0 +1,699 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
from urllib import error, request
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
STATE_DIR = PROJECT_ROOT / "runtime" / "remote_env"
|
||||
STATE_FILE = STATE_DIR / "state.json"
|
||||
|
||||
|
||||
DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"couchdb": {
|
||||
"enabled": True,
|
||||
"base_url": "http://localhost:5984",
|
||||
"url": "http://127.0.0.1",
|
||||
"port": "5984",
|
||||
"database": "test_push_auto",
|
||||
"username": "admin",
|
||||
"password": "password123",
|
||||
"recreate_after_delete": False,
|
||||
},
|
||||
"data": {
|
||||
"data_dir": "${PROJECT_ROOT}/working_local_datasource/filtered",
|
||||
},
|
||||
"service": {
|
||||
"ecm_root": "/Users/zyl/my_projects/GroupSideProjectManagementSystem",
|
||||
"python_path": "/Users/zyl/my_projects/GroupSideProjectManagementSystem/.venv/bin/python",
|
||||
"cwd": "${ECM_ROOT}",
|
||||
"start_command": ["${PYTHON}", "run.py"],
|
||||
"restore_command": [
|
||||
"${PYTHON}",
|
||||
"run.py",
|
||||
"restore-database",
|
||||
"-d",
|
||||
"${COUCHDB_DATABASE}",
|
||||
"-s",
|
||||
"${DATA_DIR}",
|
||||
],
|
||||
"env": {},
|
||||
"ready_url": "http://localhost:8000/docs",
|
||||
"ready_timeout_seconds": 120,
|
||||
"ready_interval_seconds": 2,
|
||||
"log_file": "${PROJECT_ROOT}/runtime/remote_env/ecm_remote.log",
|
||||
},
|
||||
"env_file": {
|
||||
"enabled": True,
|
||||
"path": "${ECM_ROOT}/.env",
|
||||
"restore_on_stop": True,
|
||||
"overrides": {
|
||||
"COUCHDB_DBNAME": "${COUCHDB_DATABASE}",
|
||||
"COUCHDB_USER": "${COUCHDB_USERNAME}",
|
||||
"COUCHDB_PASSWORD": "${COUCHDB_PASSWORD}",
|
||||
"COUCHDB_URL": "${COUCHDB_URL}",
|
||||
"COUCHDB_PORT": "${COUCHDB_PORT}",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
||||
merged = deepcopy(base)
|
||||
for key, value in override.items():
|
||||
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
||||
merged[key] = _deep_merge(merged[key], value)
|
||||
else:
|
||||
merged[key] = value
|
||||
return merged
|
||||
|
||||
|
||||
def _replace_placeholders(value: Any, placeholders: dict[str, str]) -> Any:
|
||||
if isinstance(value, str):
|
||||
result = value
|
||||
for _ in range(5):
|
||||
previous = result
|
||||
for key, replacement in placeholders.items():
|
||||
result = result.replace(f"${{{key}}}", replacement)
|
||||
if result == previous:
|
||||
break
|
||||
return result
|
||||
if isinstance(value, list):
|
||||
return [_replace_placeholders(item, placeholders) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {key: _replace_placeholders(item, placeholders) for key, item in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def _ensure_parent(path: str | Path) -> Path:
|
||||
resolved = Path(path)
|
||||
resolved.parent.mkdir(parents=True, exist_ok=True)
|
||||
return resolved
|
||||
|
||||
|
||||
def _load_raw_config(config_path: str | Path) -> tuple[dict[str, Any], Path]:
|
||||
path = Path(config_path)
|
||||
if not path.is_absolute():
|
||||
path = PROJECT_ROOT / path
|
||||
payload = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError(f"Config root must be a mapping: {path}")
|
||||
return payload, path
|
||||
|
||||
|
||||
def _apply_cli_overrides(config: dict[str, Any], args: argparse.Namespace) -> dict[str, Any]:
|
||||
result = deepcopy(config)
|
||||
couchdb = result.setdefault("couchdb", {})
|
||||
service = result.setdefault("service", {})
|
||||
data = result.setdefault("data", {})
|
||||
|
||||
if args.db_name is not None:
|
||||
couchdb["database"] = args.db_name
|
||||
if args.db_user is not None:
|
||||
couchdb["username"] = args.db_user
|
||||
if args.db_password is not None:
|
||||
couchdb["password"] = args.db_password
|
||||
if args.couchdb_base_url is not None:
|
||||
couchdb["base_url"] = args.couchdb_base_url
|
||||
if args.couchdb_url is not None:
|
||||
couchdb["url"] = args.couchdb_url
|
||||
if args.couchdb_port is not None:
|
||||
couchdb["port"] = str(args.couchdb_port)
|
||||
if args.ecm_root is not None:
|
||||
service["ecm_root"] = args.ecm_root
|
||||
if args.python_path is not None:
|
||||
service["python_path"] = args.python_path
|
||||
if args.data_dir is not None:
|
||||
data["data_dir"] = args.data_dir
|
||||
if args.ready_url is not None:
|
||||
service["ready_url"] = args.ready_url
|
||||
return result
|
||||
|
||||
|
||||
def load_config(config_path: str | Path, args: argparse.Namespace) -> tuple[dict[str, Any], Path]:
|
||||
raw_config, resolved_path = _load_raw_config(config_path)
|
||||
merged = _deep_merge(DEFAULT_CONFIG, raw_config)
|
||||
merged = _apply_cli_overrides(merged, args)
|
||||
|
||||
config_dir = resolved_path.parent.resolve()
|
||||
service = merged.setdefault("service", {})
|
||||
couchdb = merged.setdefault("couchdb", {})
|
||||
data = merged.setdefault("data", {})
|
||||
|
||||
placeholders = {
|
||||
"PROJECT_ROOT": str(PROJECT_ROOT),
|
||||
"CONFIG_DIR": str(config_dir),
|
||||
"ECM_ROOT": str(service.get("ecm_root", "")),
|
||||
"PYTHON": str(service.get("python_path", "")),
|
||||
"DATA_DIR": str(data.get("data_dir", "")),
|
||||
"COUCHDB_DATABASE": str(couchdb.get("database", "")),
|
||||
"COUCHDB_USERNAME": str(couchdb.get("username", "")),
|
||||
"COUCHDB_PASSWORD": str(couchdb.get("password", "")),
|
||||
"COUCHDB_URL": str(couchdb.get("url", "")),
|
||||
"COUCHDB_PORT": str(couchdb.get("port", "")),
|
||||
}
|
||||
|
||||
resolved = _replace_placeholders(merged, placeholders)
|
||||
service = resolved.setdefault("service", {})
|
||||
couchdb = resolved.setdefault("couchdb", {})
|
||||
data = resolved.setdefault("data", {})
|
||||
env_file = resolved.setdefault("env_file", {})
|
||||
|
||||
for key in ("ecm_root", "python_path", "cwd", "log_file"):
|
||||
if service.get(key):
|
||||
service[key] = str(Path(service[key]).expanduser().resolve())
|
||||
if data.get("data_dir"):
|
||||
data["data_dir"] = str(Path(data["data_dir"]).expanduser().resolve())
|
||||
if env_file.get("path"):
|
||||
env_file["path"] = str(Path(env_file["path"]).expanduser().resolve())
|
||||
|
||||
service["ready_timeout_seconds"] = int(service.get("ready_timeout_seconds", 120))
|
||||
service["ready_interval_seconds"] = int(service.get("ready_interval_seconds", 2))
|
||||
return resolved, resolved_path
|
||||
|
||||
|
||||
def validate_config(config: dict[str, Any], command: str) -> None:
|
||||
couchdb = config.get("couchdb", {})
|
||||
data = config.get("data", {})
|
||||
service = config.get("service", {})
|
||||
env_file = config.get("env_file", {})
|
||||
|
||||
required_service = [
|
||||
(service.get("ecm_root"), "service.ecm_root"),
|
||||
(service.get("python_path"), "service.python_path"),
|
||||
]
|
||||
missing_service = [name for value, name in required_service if value in (None, "")]
|
||||
if missing_service:
|
||||
raise ValueError("Missing required config fields: " + ", ".join(missing_service))
|
||||
|
||||
if not Path(service["ecm_root"]).exists():
|
||||
raise FileNotFoundError(f"ECM root does not exist: {service['ecm_root']}")
|
||||
if not Path(service["python_path"]).exists():
|
||||
raise FileNotFoundError(f"Python path does not exist: {service['python_path']}")
|
||||
|
||||
if not isinstance(service.get("start_command"), list) or not service.get("start_command"):
|
||||
raise ValueError("service.start_command must be a non-empty list")
|
||||
if not isinstance(service.get("restore_command"), list) or not service.get("restore_command"):
|
||||
raise ValueError("service.restore_command must be a non-empty list")
|
||||
|
||||
if env_file.get("enabled", True):
|
||||
env_path = Path(env_file.get("path", ""))
|
||||
if not env_path.exists():
|
||||
raise FileNotFoundError(f"env_file.path does not exist: {env_path}")
|
||||
|
||||
if command in {"clear", "init"} and couchdb.get("enabled", True):
|
||||
required_couchdb = [
|
||||
(couchdb.get("base_url"), "couchdb.base_url"),
|
||||
(couchdb.get("database"), "couchdb.database"),
|
||||
]
|
||||
missing_couchdb = [name for value, name in required_couchdb if value in (None, "")]
|
||||
if missing_couchdb:
|
||||
raise ValueError("Missing required config fields: " + ", ".join(missing_couchdb))
|
||||
|
||||
if command == "init":
|
||||
if data.get("data_dir") in (None, ""):
|
||||
raise ValueError("Missing required config fields: data.data_dir")
|
||||
if not Path(data["data_dir"]).exists():
|
||||
raise FileNotFoundError(f"Data directory does not exist: {data['data_dir']}")
|
||||
|
||||
|
||||
def _auth_header(username: str, password: str) -> dict[str, str]:
|
||||
if not username:
|
||||
return {}
|
||||
token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii")
|
||||
return {"Authorization": f"Basic {token}"}
|
||||
|
||||
|
||||
def _delete_couchdb_database(base_url: str, database: str, username: str = "", password: str = "") -> int:
|
||||
url = f"{base_url.rstrip('/')}/{database}"
|
||||
req = request.Request(url, method="DELETE", headers=_auth_header(username, password))
|
||||
try:
|
||||
with request.urlopen(req, timeout=10) as response:
|
||||
return response.status
|
||||
except error.HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
return 404
|
||||
raise
|
||||
|
||||
|
||||
def _create_couchdb_database(base_url: str, database: str, username: str = "", password: str = "") -> int:
|
||||
url = f"{base_url.rstrip('/')}/{database}"
|
||||
req = request.Request(url, method="PUT", headers=_auth_header(username, password))
|
||||
with request.urlopen(req, timeout=10) as response:
|
||||
return response.status
|
||||
|
||||
|
||||
def _run_command(command: list[str], *, cwd: str | Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
command,
|
||||
cwd=str(cwd),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def _run_or_raise(command: list[str], *, cwd: str | Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
|
||||
result = _run_command(command, cwd=cwd, env=env)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
"Command failed\n"
|
||||
f"command={command}\n"
|
||||
f"stdout:\n{result.stdout}\n\n"
|
||||
f"stderr:\n{result.stderr}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _find_backend_pgids(backend_root_dir: str | Path) -> list[int]:
|
||||
backend_root = str(Path(backend_root_dir).resolve())
|
||||
result = _run_command(["ps", "-ax", "-o", "pid=,pgid=,command="], cwd=PROJECT_ROOT)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
|
||||
pgids: list[int] = []
|
||||
for line in result.stdout.splitlines():
|
||||
raw = line.strip()
|
||||
if not raw or backend_root not in raw or " run.py" not in raw:
|
||||
continue
|
||||
parts = raw.split(None, 2)
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
try:
|
||||
pgid = int(parts[1])
|
||||
except ValueError:
|
||||
continue
|
||||
if pgid not in pgids:
|
||||
pgids.append(pgid)
|
||||
return pgids
|
||||
|
||||
|
||||
def _port_from_ready_url(url: str | None) -> int | None:
|
||||
if not url:
|
||||
return None
|
||||
parsed = urlparse(url)
|
||||
if parsed.port is not None:
|
||||
return int(parsed.port)
|
||||
if parsed.scheme == "https":
|
||||
return 443
|
||||
if parsed.scheme == "http":
|
||||
return 80
|
||||
return None
|
||||
|
||||
|
||||
def _find_port_listener_pgids(port: int | None) -> list[int]:
|
||||
if port is None:
|
||||
return []
|
||||
result = _run_command(["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"], cwd=PROJECT_ROOT)
|
||||
if result.returncode not in (0, 1):
|
||||
return []
|
||||
|
||||
pgids: list[int] = []
|
||||
for line in result.stdout.splitlines():
|
||||
raw = line.strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
pid = int(raw)
|
||||
pgid = os.getpgid(pid)
|
||||
except (ValueError, ProcessLookupError, PermissionError):
|
||||
continue
|
||||
if pgid not in pgids:
|
||||
pgids.append(pgid)
|
||||
return pgids
|
||||
|
||||
|
||||
def _find_managed_pgids(service: dict[str, Any]) -> list[int]:
|
||||
pgids: list[int] = []
|
||||
for pgid in _find_backend_pgids(service["ecm_root"]):
|
||||
if pgid not in pgids:
|
||||
pgids.append(pgid)
|
||||
for pgid in _find_port_listener_pgids(_port_from_ready_url(service.get("ready_url"))):
|
||||
if pgid not in pgids:
|
||||
pgids.append(pgid)
|
||||
return pgids
|
||||
|
||||
|
||||
def _kill_process_group(pid: int) -> bool:
|
||||
try:
|
||||
os.killpg(pid, signal.SIGTERM)
|
||||
time.sleep(1)
|
||||
return True
|
||||
except PermissionError:
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
time.sleep(1)
|
||||
return True
|
||||
except (PermissionError, ProcessLookupError):
|
||||
return False
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
|
||||
|
||||
def _wait_http_ready(url: str, timeout_seconds: int, interval_seconds: int) -> None:
|
||||
deadline = time.time() + timeout_seconds
|
||||
last_error = ""
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with request.urlopen(url, timeout=5) as response:
|
||||
if 200 <= response.status < 500:
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_error = str(exc)
|
||||
time.sleep(interval_seconds)
|
||||
raise TimeoutError(f"Service not ready: {url}; last_error={last_error}")
|
||||
|
||||
|
||||
def _load_state() -> dict[str, Any]:
|
||||
if not STATE_FILE.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(STATE_FILE.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
|
||||
def _save_state(state: dict[str, Any]) -> None:
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def _clear_state() -> None:
|
||||
STATE_FILE.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _parse_env_line(line: str) -> tuple[str, str] | None:
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
||||
return None
|
||||
key, value = stripped.split("=", 1)
|
||||
return key.strip(), value
|
||||
|
||||
|
||||
def _read_env_file(path: Path) -> list[str]:
|
||||
if not path.exists():
|
||||
return []
|
||||
return path.read_text(encoding="utf-8").splitlines()
|
||||
|
||||
|
||||
def _write_env_file(path: Path, lines: list[str]) -> None:
|
||||
_ensure_parent(path)
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _apply_env_overlay(config: dict[str, Any]) -> dict[str, Any]:
|
||||
env_config = config.get("env_file", {})
|
||||
if not env_config.get("enabled", True):
|
||||
return {"applied": False, "reason": "disabled"}
|
||||
|
||||
env_path = Path(env_config["path"])
|
||||
overrides = {str(key): str(value) for key, value in (env_config.get("overrides") or {}).items()}
|
||||
state = _load_state()
|
||||
env_state = state.setdefault("env_file", {})
|
||||
|
||||
if not env_state.get("backup_path"):
|
||||
backup_dir = STATE_DIR / "env_backup"
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
backup_path = backup_dir / env_path.name
|
||||
shutil.copy2(env_path, backup_path)
|
||||
env_state["backup_path"] = str(backup_path)
|
||||
env_state["path"] = str(env_path)
|
||||
|
||||
lines = _read_env_file(env_path)
|
||||
key_to_index: dict[str, int] = {}
|
||||
for index, line in enumerate(lines):
|
||||
parsed = _parse_env_line(line)
|
||||
if parsed is None:
|
||||
continue
|
||||
key_to_index[parsed[0]] = index
|
||||
|
||||
for key, value in overrides.items():
|
||||
new_line = f"{key}={value}"
|
||||
if key in key_to_index:
|
||||
lines[key_to_index[key]] = new_line
|
||||
else:
|
||||
lines.append(new_line)
|
||||
|
||||
_write_env_file(env_path, lines)
|
||||
_save_state(state)
|
||||
return {
|
||||
"applied": True,
|
||||
"path": str(env_path),
|
||||
"overrides": sorted(overrides.keys()),
|
||||
"backup_path": env_state["backup_path"],
|
||||
}
|
||||
|
||||
|
||||
def _restore_env_overlay(config: dict[str, Any]) -> dict[str, Any]:
|
||||
env_config = config.get("env_file", {})
|
||||
if not env_config.get("enabled", True) or not env_config.get("restore_on_stop", True):
|
||||
return {"restored": False, "reason": "disabled"}
|
||||
|
||||
state = _load_state()
|
||||
env_state = state.get("env_file") or {}
|
||||
backup_path = env_state.get("backup_path")
|
||||
env_path = env_state.get("path") or env_config.get("path")
|
||||
if not backup_path or not env_path:
|
||||
return {"restored": False, "reason": "no_backup"}
|
||||
|
||||
backup = Path(backup_path)
|
||||
target = Path(env_path)
|
||||
if not backup.exists():
|
||||
state.pop("env_file", None)
|
||||
_save_state(state)
|
||||
return {"restored": False, "reason": "backup_missing"}
|
||||
|
||||
shutil.copy2(backup, target)
|
||||
backup.unlink(missing_ok=True)
|
||||
state.pop("env_file", None)
|
||||
if state:
|
||||
_save_state(state)
|
||||
else:
|
||||
_clear_state()
|
||||
return {"restored": True, "path": str(target)}
|
||||
|
||||
|
||||
def _build_process_env(config: dict[str, Any]) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
env.update({key: str(value) for key, value in (config.get("service", {}).get("env") or {}).items()})
|
||||
return env
|
||||
|
||||
|
||||
def _state_pid() -> int | None:
|
||||
state = _load_state()
|
||||
pid = state.get("pid")
|
||||
return int(pid) if isinstance(pid, int) else None
|
||||
|
||||
|
||||
def _set_state_pid(pid: int | None) -> None:
|
||||
state = _load_state()
|
||||
if pid is None:
|
||||
state.pop("pid", None)
|
||||
else:
|
||||
state["pid"] = pid
|
||||
if state:
|
||||
_save_state(state)
|
||||
else:
|
||||
_clear_state()
|
||||
|
||||
|
||||
def clear_database(config: dict[str, Any]) -> dict[str, Any]:
|
||||
couchdb = config["couchdb"]
|
||||
if not couchdb.get("enabled", True):
|
||||
return {"enabled": False}
|
||||
|
||||
delete_status = _delete_couchdb_database(
|
||||
couchdb["base_url"],
|
||||
couchdb["database"],
|
||||
couchdb.get("username", ""),
|
||||
couchdb.get("password", ""),
|
||||
)
|
||||
create_status = None
|
||||
if couchdb.get("recreate_after_delete", False):
|
||||
create_status = _create_couchdb_database(
|
||||
couchdb["base_url"],
|
||||
couchdb["database"],
|
||||
couchdb.get("username", ""),
|
||||
couchdb.get("password", ""),
|
||||
)
|
||||
return {
|
||||
"database": couchdb["database"],
|
||||
"delete_status": delete_status,
|
||||
"create_status": create_status,
|
||||
}
|
||||
|
||||
|
||||
def import_init_data(config: dict[str, Any]) -> dict[str, Any]:
|
||||
service = config["service"]
|
||||
result = _run_or_raise(service["restore_command"], cwd=service["cwd"], env=_build_process_env(config))
|
||||
return {
|
||||
"data_dir": config["data"]["data_dir"],
|
||||
"restore_command": service["restore_command"],
|
||||
"stdout_tail": result.stdout[-2000:],
|
||||
"stderr_tail": result.stderr[-2000:],
|
||||
}
|
||||
|
||||
|
||||
def stop_service(config: dict[str, Any], *, restore_env: bool = True) -> dict[str, Any]:
|
||||
service = config["service"]
|
||||
killed: list[int] = []
|
||||
pid = _state_pid()
|
||||
if pid is not None and _kill_process_group(pid):
|
||||
killed.append(pid)
|
||||
for pgid in _find_managed_pgids(service):
|
||||
if pgid in killed:
|
||||
continue
|
||||
if _kill_process_group(pgid):
|
||||
killed.append(pgid)
|
||||
_set_state_pid(None)
|
||||
env_restore = _restore_env_overlay(config) if restore_env else {"restored": False, "reason": "skipped"}
|
||||
return {
|
||||
"stopped": bool(killed),
|
||||
"killed_pgids": killed,
|
||||
"env_file": env_restore,
|
||||
}
|
||||
|
||||
|
||||
def run_service(config: dict[str, Any]) -> dict[str, Any]:
|
||||
service = config["service"]
|
||||
existing_pgids = _find_managed_pgids(service)
|
||||
if existing_pgids:
|
||||
return {
|
||||
"started": False,
|
||||
"reason": "already_running",
|
||||
"pgids": existing_pgids,
|
||||
"log_file": service["log_file"],
|
||||
}
|
||||
|
||||
env_result = _apply_env_overlay(config)
|
||||
log_path = _ensure_parent(service["log_file"])
|
||||
handle = open(log_path, "a", encoding="utf-8")
|
||||
process = subprocess.Popen(
|
||||
service["start_command"],
|
||||
cwd=service["cwd"],
|
||||
stdout=handle,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
start_new_session=True,
|
||||
env=_build_process_env(config),
|
||||
)
|
||||
_set_state_pid(process.pid)
|
||||
_wait_http_ready(
|
||||
str(service["ready_url"]),
|
||||
int(service.get("ready_timeout_seconds", 120)),
|
||||
int(service.get("ready_interval_seconds", 2)),
|
||||
)
|
||||
return {
|
||||
"started": True,
|
||||
"pid": process.pid,
|
||||
"log_file": str(log_path),
|
||||
"cwd": service["cwd"],
|
||||
"command": service["start_command"],
|
||||
"ready_url": service["ready_url"],
|
||||
"env_file": env_result,
|
||||
}
|
||||
|
||||
|
||||
def do_clear(config: dict[str, Any]) -> dict[str, Any]:
|
||||
stop_result = stop_service(config, restore_env=True)
|
||||
clear_result = clear_database(config)
|
||||
return {
|
||||
"command": "clear",
|
||||
"stop": stop_result,
|
||||
"database": clear_result,
|
||||
}
|
||||
|
||||
|
||||
def do_init(config: dict[str, Any]) -> dict[str, Any]:
|
||||
stop_result = stop_service(config, restore_env=False)
|
||||
env_result = _apply_env_overlay(config)
|
||||
clear_result = clear_database(config)
|
||||
init_result = import_init_data(config)
|
||||
return {
|
||||
"command": "init",
|
||||
"stop": stop_result,
|
||||
"env_file": env_result,
|
||||
"database": clear_result,
|
||||
"init": init_result,
|
||||
}
|
||||
|
||||
|
||||
def do_run(config: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"command": "run",
|
||||
"service": run_service(config),
|
||||
}
|
||||
|
||||
|
||||
def do_stop(config: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"command": "stop",
|
||||
"service": stop_service(config, restore_env=True),
|
||||
}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="远程 ECM 测试环境管理工具")
|
||||
parser.add_argument(
|
||||
"command",
|
||||
choices=["clear", "init", "run", "stop"],
|
||||
help="clear: 清空 CouchDB 测试库;init: 通过 run.py restore-database 恢复数据;run: 后台启动远程服务;stop: 停止远程服务并恢复 .env",
|
||||
)
|
||||
parser.add_argument("--config", default="tools/remote_env.example.yaml", help="YAML 配置文件路径")
|
||||
parser.add_argument("--db-name", help="覆盖 couchdb.database")
|
||||
parser.add_argument("--db-user", help="覆盖 couchdb.username")
|
||||
parser.add_argument("--db-password", help="覆盖 couchdb.password")
|
||||
parser.add_argument("--couchdb-base-url", help="覆盖 couchdb.base_url")
|
||||
parser.add_argument("--couchdb-url", help="覆盖 couchdb.url")
|
||||
parser.add_argument("--couchdb-port", help="覆盖 couchdb.port")
|
||||
parser.add_argument("--ecm-root", help="覆盖 service.ecm_root")
|
||||
parser.add_argument("--python-path", help="覆盖 service.python_path")
|
||||
parser.add_argument("--data-dir", help="覆盖 data.data_dir")
|
||||
parser.add_argument("--ready-url", help="覆盖 service.ready_url")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
config, config_path = load_config(args.config, args)
|
||||
validate_config(config, args.command)
|
||||
|
||||
if args.command == "clear":
|
||||
result = do_clear(config)
|
||||
elif args.command == "init":
|
||||
result = do_init(config)
|
||||
elif args.command == "run":
|
||||
result = do_run(config)
|
||||
elif args.command == "stop":
|
||||
result = do_stop(config)
|
||||
else:
|
||||
raise AssertionError(f"Unhandled command: {args.command}")
|
||||
|
||||
result["config_path"] = str(config_path)
|
||||
result["state_file"] = str(STATE_FILE)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(json.dumps({"error": str(exc)}, ensure_ascii=False, indent=2), file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
Reference in New Issue
Block a user