This commit is contained in:
@@ -78,6 +78,7 @@ WXAGENT_SCALE_WEB_PASSWORD='<test-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/窗口句柄猜测。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user