增加了一个测试工具用于初始化测试环境

This commit is contained in:
strepsiades
2026-03-11 11:40:23 +08:00
parent 1f68bd5ca8
commit b9fbf357a4
9 changed files with 1359 additions and 2 deletions
+699
View File
@@ -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)