diff --git a/control-plane/README.md b/control-plane/README.md index df72c2c..cfe11bd 100644 --- a/control-plane/README.md +++ b/control-plane/README.md @@ -78,6 +78,7 @@ WXAGENT_SCALE_WEB_PASSWORD='' \ ``` `control-plane-data.sh backup`/`restore` 用于停机前后的手工备份和原子恢复;自动备份由 Store 按 `WXAGENT_CONTROL_PLANE_BACKUP_INTERVAL`(默认 5 分钟)产生。当前 JSON 存储使用 active/passive 单写锁,不支持 active-active 多实例共享写入。 + ## Windows 手工验收 1. 在已登录且未锁定的微信桌面会话中运行 `WxAgent.Host doctor` 和 `WxAgent.Host inspect-ui --output artifacts/ui-tree.json`,确认账号绑定使用稳定 `accountId`,会话使用稳定 `AutomationId`,不使用昵称/PID/窗口句柄猜测。 diff --git a/scripts/control-plane-data.sh b/scripts/control-plane-data.sh index 7fc3157..3a881b4 100755 --- a/scripts/control-plane-data.sh +++ b/scripts/control-plane-data.sh @@ -12,31 +12,40 @@ source=$2 target=$3 case "$command" in - backup) - [ -f "$source" ] || { echo "data file does not exist: $source" >&2; exit 1; } - mkdir -p "$target" - name="$(basename "$source").$(date -u +%Y%m%dT%H%M%SZ).manual.json" - temporary="$target/.wxagent-backup-$$.tmp" - trap 'rm -f "$temporary"' EXIT - install -m 600 "$source" "$temporary" - mv -f "$temporary" "$target/$name" - sha256sum "$target/$name" - ;; - restore) - [ -f "$source" ] || { echo "backup file does not exist: $source" >&2; exit 1; } - directory=$(dirname "$target") - mkdir -p "$directory" - lock="$target.lock" - exec 9>"$lock" - flock -n 9 || { echo "data file is in use; stop the active control plane first" >&2; exit 1; } - temporary="$directory/.wxagent-restore-$$.tmp" - trap 'rm -f "$temporary"' EXIT - install -m 600 "$source" "$temporary" - python3 -m json.tool "$temporary" >/dev/null - mv -f "$temporary" "$target" - sha256sum "$target" - ;; - *) - usage - ;; +backup) + [ -f "$source" ] || { + echo "data file does not exist: $source" >&2 + exit 1 + } + mkdir -p "$target" + name="$(basename "$source").$(date -u +%Y%m%dT%H%M%SZ).manual.json" + temporary="$target/.wxagent-backup-$$.tmp" + trap 'rm -f "$temporary"' EXIT + install -m 600 "$source" "$temporary" + mv -f "$temporary" "$target/$name" + sha256sum "$target/$name" + ;; +restore) + [ -f "$source" ] || { + echo "backup file does not exist: $source" >&2 + exit 1 + } + directory=$(dirname "$target") + mkdir -p "$directory" + lock="$target.lock" + exec 9>"$lock" + flock -n 9 || { + echo "data file is in use; stop the active control plane first" >&2 + exit 1 + } + temporary="$directory/.wxagent-restore-$$.tmp" + trap 'rm -f "$temporary"' EXIT + install -m 600 "$source" "$temporary" + python3 -m json.tool "$temporary" >/dev/null + mv -f "$temporary" "$target" + sha256sum "$target" + ;; +*) + usage + ;; esac diff --git a/scripts/control-plane-scale-smoke.py b/scripts/control-plane-scale-smoke.py index 00e2a7b..99e4cc8 100755 --- a/scripts/control-plane-scale-smoke.py +++ b/scripts/control-plane-scale-smoke.py @@ -16,10 +16,22 @@ import uuid from urllib.parse import urlsplit -def request(base_url: str, path: str, method: str, body: object | None, headers: dict[str, str], timeout: float): +def request( + base_url: str, + path: str, + method: str, + body: object | None, + headers: dict[str, str], + timeout: float, +): url = base_url.rstrip("/") + path parts = urlsplit(url) - if parts.scheme.lower() not in {"http", "https"} or not parts.hostname or parts.username or parts.password: + if ( + parts.scheme.lower() not in {"http", "https"} + or not parts.hostname + or parts.username + or parts.password + ): raise ValueError("base URL must be an http/https URL without user information") target = parts.path or "/" if parts.query: @@ -27,12 +39,21 @@ def request(base_url: str, path: str, method: str, body: object | None, headers: data = None if body is None else json.dumps(body, ensure_ascii=False).encode() connection: http.client.HTTPConnection if parts.scheme.lower() == "https": - connection = http.client.HTTPSConnection(parts.hostname, parts.port, timeout=timeout, context=ssl.create_default_context()) + connection = http.client.HTTPSConnection( + parts.hostname, + parts.port, + timeout=timeout, + context=ssl.create_default_context(), + ) else: - connection = http.client.HTTPConnection(parts.hostname, parts.port, timeout=timeout) + connection = http.client.HTTPConnection( + parts.hostname, parts.port, timeout=timeout + ) started = time.perf_counter() try: - connection.request(method, target, body=data, headers={"Accept": "application/json", **headers}) + connection.request( + method, target, body=data, headers={"Accept": "application/json", **headers} + ) response = connection.getresponse() return response.status, time.perf_counter() - started, response.read() except OSError as error: @@ -51,23 +72,37 @@ def percentile(values: list[float], p: float) -> float: def main() -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--base-url", default=os.getenv("WXAGENT_CONTROL_PLANE_URL", "http://127.0.0.1:8090")) + parser.add_argument( + "--base-url", + default=os.getenv("WXAGENT_CONTROL_PLANE_URL", "http://127.0.0.1:8090"), + ) parser.add_argument("--mode", choices=("ready", "reads"), default="reads") - parser.add_argument("--web-user", default=os.getenv("WXAGENT_SCALE_WEB_USER", "admin")) + parser.add_argument( + "--web-user", default=os.getenv("WXAGENT_SCALE_WEB_USER", "admin") + ) parser.add_argument("--node-id", default=os.getenv("WXAGENT_SCALE_NODE_ID", "")) - parser.add_argument("--account-id", default=os.getenv("WXAGENT_SCALE_ACCOUNT_ID", "")) + parser.add_argument( + "--account-id", default=os.getenv("WXAGENT_SCALE_ACCOUNT_ID", "") + ) parser.add_argument("--requests", type=int, default=100) parser.add_argument("--workers", type=int, default=10) parser.add_argument("--timeout", type=float, default=15.0) args = parser.parse_args() - if args.requests < 1 or args.requests > 5000 or args.workers < 1 or args.workers > 100: + if ( + args.requests < 1 + or args.requests > 5000 + or args.workers < 1 + or args.workers > 100 + ): parser.error("requests must be 1..5000 and workers must be 1..100") headers: dict[str, str] = {} if args.mode == "reads": password = os.getenv("WXAGENT_SCALE_WEB_PASSWORD") if not password or not args.node_id or not args.account_id: - parser.error("reads mode requires WXAGENT_SCALE_WEB_PASSWORD, --node-id and --account-id") + parser.error( + "reads mode requires WXAGENT_SCALE_WEB_PASSWORD, --node-id and --account-id" + ) status, _, payload = request( args.base_url, "/v1/auth/login", @@ -77,19 +112,37 @@ def main() -> int: args.timeout, ) if status != 200: - print(json.dumps({"ok": False, "phase": "login", "status": status, "body": payload[:256].decode(errors="replace")})) + print( + json.dumps( + { + "ok": False, + "phase": "login", + "status": status, + "body": payload[:256].decode(errors="replace"), + } + ) + ) return 1 try: login = json.loads(payload) access_token = login["access_token"] except (UnicodeDecodeError, json.JSONDecodeError, KeyError, TypeError) as error: - print(json.dumps({"ok": False, "phase": "login-response", "error": str(error)})) + print( + json.dumps( + {"ok": False, "phase": "login-response", "error": str(error)} + ) + ) return 1 - headers = {"Authorization": "Bearer " + access_token, "Content-Type": "application/json"} + headers = { + "Authorization": "Bearer " + access_token, + "Content-Type": "application/json", + } def one(index: int) -> tuple[int, float]: if args.mode == "ready": - status, elapsed, _ = request(args.base_url, "/readyz", "GET", None, {}, args.timeout) + status, elapsed, _ = request( + args.base_url, "/readyz", "GET", None, {}, args.timeout + ) else: body = { "node_id": args.node_id, @@ -98,7 +151,9 @@ def main() -> int: "limit": 1, "offset": 0, } - status, elapsed, _ = request(args.base_url, "/v1/reads/sessions", "POST", body, headers, args.timeout) + status, elapsed, _ = request( + args.base_url, "/v1/reads/sessions", "POST", body, headers, args.timeout + ) return status, elapsed with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as executor: @@ -114,7 +169,10 @@ def main() -> int: "failed": args.requests - success, "p50_ms": round(statistics.median(elapsed) * 1000, 2) if elapsed else 0, "p95_ms": round(percentile(elapsed, 0.95) * 1000, 2), - "statuses": {str(status): sum(item[0] == status for item in results) for status, _ in results}, + "statuses": { + str(status): sum(item[0] == status for item in results) + for status, _ in results + }, } print(json.dumps(summary, ensure_ascii=False, sort_keys=True)) return 0 if summary["ok"] else 1 diff --git a/scripts/revoke-client-certificate.sh b/scripts/revoke-client-certificate.sh index 74826ff..4218423 100755 --- a/scripts/revoke-client-certificate.sh +++ b/scripts/revoke-client-certificate.sh @@ -7,10 +7,16 @@ if [ "$#" -ne 2 ]; then fi certificate=$1 list=$2 -[ -f "$certificate" ] || { echo "certificate does not exist: $certificate" >&2; exit 1; } +[ -f "$certificate" ] || { + echo "certificate does not exist: $certificate" >&2 + exit 1 +} fingerprint=$(openssl x509 -in "$certificate" -outform DER | sha256sum | awk '{print $1}') -[ "${#fingerprint}" -eq 64 ] || { echo "could not calculate certificate fingerprint" >&2; exit 1; } +[ "${#fingerprint}" -eq 64 ] || { + echo "could not calculate certificate fingerprint" >&2 + exit 1 +} mkdir -p "$(dirname "$list")" lock="$list.lock" exec 9>"$lock" @@ -22,9 +28,9 @@ fi temporary="${list}.tmp.$$" trap 'rm -f "$temporary"' EXIT if [ -f "$list" ]; then - cat "$list" > "$temporary" + cat "$list" >"$temporary" fi -printf '%s\n' "$fingerprint" >> "$temporary" +printf '%s\n' "$fingerprint" >>"$temporary" chmod 600 "$temporary" mv -f "$temporary" "$list" printf '%s\n' "$fingerprint"