From 3a876d585053cc516e9f407538ee7ad10cab8bef Mon Sep 17 00:00:00 2001 From: zhx Date: Tue, 9 Jun 2026 11:12:26 +0800 Subject: [PATCH] Update deploy platform release tooling --- AGENTS.md | 11 +- README.md | 13 +- deploy/README.md | 2 + deploy/server.mjs | 113 +++++++- deploy/standalone/README.md | 4 +- .../docker/deploy-platform.env.example | 9 + .../systemd/hyapp-deploy-platform.service | 2 +- deploy/tencent_tat/README.md | 11 + deploy/tencent_tat/edgeone_purge.py | 243 ++++++++++++++++++ .../tencent_tat/hyapp_admin_server_deploy.py | 229 +++++++++++++++++ .../tests/test_hyapp_admin_server_deploy.py | 38 +++ src/app/router.tsx | 2 + .../data-resource/api/getDataResources.ts | 24 +- .../api/getDeployApiInventory.ts | 49 ++-- .../edgeone-purge/api/purgeEdgeoneUrls.ts | 53 ++++ src/entities/service/api/deployApi.ts | 28 +- .../release-service/api/createRelease.ts | 14 +- .../release-service/ui/ReleaseDrawer.tsx | 27 +- .../cdn-refresh/CdnRefreshPage.module.css | 236 +++++++++++++++++ src/pages/cdn-refresh/CdnRefreshPage.tsx | 216 ++++++++++++++++ src/pages/cdn-refresh/index.ts | 1 + src/shared/api/authRedirect.test.ts | 58 +++++ src/shared/api/authRedirect.ts | 92 +++++++ src/shared/api/deployFetch.ts | 47 ++++ src/vite-env.d.ts | 5 + src/widgets/app-shell/AppShell.tsx | 3 +- 26 files changed, 1433 insertions(+), 97 deletions(-) create mode 100644 deploy/tencent_tat/edgeone_purge.py create mode 100644 deploy/tencent_tat/hyapp_admin_server_deploy.py create mode 100644 deploy/tencent_tat/tests/test_hyapp_admin_server_deploy.py create mode 100644 src/entities/edgeone-purge/api/purgeEdgeoneUrls.ts create mode 100644 src/pages/cdn-refresh/CdnRefreshPage.module.css create mode 100644 src/pages/cdn-refresh/CdnRefreshPage.tsx create mode 100644 src/pages/cdn-refresh/index.ts create mode 100644 src/shared/api/authRedirect.test.ts create mode 100644 src/shared/api/authRedirect.ts create mode 100644 src/shared/api/deployFetch.ts diff --git a/AGENTS.md b/AGENTS.md index 62004d0..eaa7f2c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,11 +25,20 @@ 3. 配置 YAML 只在列表右侧三点和详情配置区展示。 4. 读取 YAML 时使用 `includeConfig=1`,密钥字段保持脱敏。 +## CDN 刷新 + +1. CDN 刷新入口是 `/cdn-refresh`。 +2. EdgeOne URL 刷新 API 是 `POST /deploy/api/edgeone/purge-url`。 +3. 默认 EdgeOne 站点是 `global-interaction.com`,ZoneId 是 `zone-3qwew52ihaj2`。 +4. 默认只允许刷新 `h5.global-interaction.com`,新增域名必须通过 `EDGEONE_ALLOWED_HOSTS` 配置。 +5. 真实刷新必须带 `confirmed=true`;验证以 EdgeOne 返回的 `jobId`、`status`、`tasks` 为准。 +6. 禁止把 CDN 刷新页面做成任意域名、任意动作执行入口。 + ## 生产特殊服务 1. `admin-server` 不在标准 prod inventory 的滚动服务清单里;它部署在独立机器 `134.175.160.86` / `172.16.0.6`。 2. `admin-server` 的 systemd 是 `hyapp-admin-server`,二进制是 `/opt/hyapp/admin-server/admin-server`,配置是 `/etc/hyapp/admin-server/config.yaml`,监听 `172.16.0.6:13100`。 -3. 更新 `admin-server` 时构建 Linux 二进制,上传到 `134.175.160.86`,替换二进制后重启 `hyapp-admin-server`;发布后必须验证 systemd `active`、`13100` 监听和 `healthz/readyz`。 +3. 更新 `admin-server` 必须通过 deploy 平台触发;由 deploy 机器 `43.164.75.199` 在 `/opt/deploy/sources/hyapp-server` 拉取 Gitea `git@gitea.haiyihy.com:hy/hyapp-server.git` 的 `origin/main`,构建 Linux 二进制,上传到 `134.175.160.86` 替换 `/opt/hyapp/admin-server/admin-server` 后重启 `hyapp-admin-server`。线上只允许发布 `origin/main`;发布后必须验证 systemd `active`、`13100` 监听和 `healthz/readyz`。 4. HyApp `cron-service` 当前部署在 Saudi 主服务机器 `new-app-1`,实例 `ins-fi5zufpk`,内网 `10.2.1.4`。 5. `cron-service` 的 systemd 是 `hyapp-cron-service`,容器是 `hyapp-cron-service`,配置是 `/etc/hyapp/cron-service/config.yaml`,Docker env 是 `/etc/hyapp/cron-service/docker.env`,监听 `13007`。 6. `cron-service` 当前不在标准 prod inventory 的 `services` 清单里;更新时构建并推送 `10.2.1.3:18082/hyapp/cron-service:`,通过 Tencent TAT 到 `new-app-1` 修改 docker env 的 `IMAGE` 后重启 `hyapp-cron-service`。 diff --git a/README.md b/README.md index d2bb3e3..e0100a1 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,17 @@ make up - YAML 入口:列表右侧三点、详情配置区 - 禁止:静态假数据兜底展示运行态 +## CDN 刷新 + +- 页面:`/cdn-refresh` +- API:`POST /deploy/api/edgeone/purge-url` +- 类型:当前只开放 EdgeOne URL 刷新 +- 默认站点:`global-interaction.com` / `zone-3qwew52ihaj2` +- 默认允许域名:`h5.global-interaction.com` +- 参数:`urls`、`confirmed=true` +- 验证:接口等待 EdgeOne 任务结束后返回 `jobId`、`status`、`tasks` +- 禁止:页面刷新任意未配置域名;需要新增域名时先改 `EDGEONE_ALLOWED_HOSTS` + ## 生产特殊服务 `admin-server` 和 `cron-service` 当前不是标准 `hyapp-services.inventory.prod.example.json` 里的同一种滚动服务,更新前必须先按真实机器确认运行态。 @@ -76,7 +87,7 @@ make up - 监听:`172.16.0.6:13100` - 验证:`systemctl is-active hyapp-admin-server`、`ss -lntp | grep 13100` -`admin-server` 更新走独立机器发布:本地或 deploy 机构建 Linux 二进制,上传到 `134.175.160.86`,替换 `/opt/hyapp/admin-server/admin-server` 后重启 `hyapp-admin-server`。发布后必须确认 systemd `active`、端口监听、`healthz/readyz` 正常。 +`admin-server` 更新走 deploy 平台触发:调用 `POST /deploy/api/hyapp/admin-server/deploy`,由 deploy 机器 `43.164.75.199` 在 `/opt/deploy/sources/hyapp-server` 拉取 Gitea `git@gitea.haiyihy.com:hy/hyapp-server.git` 的 `origin/main`,构建 Linux 二进制后上传到 `134.175.160.86`,替换 `/opt/hyapp/admin-server/admin-server` 并重启 `hyapp-admin-server`。线上只允许发布 `origin/main`;发布后必须确认 systemd `active`、端口监听、`healthz/readyz` 正常。 ### cron-service diff --git a/deploy/README.md b/deploy/README.md index 10a7379..62394df 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -42,12 +42,14 @@ npm run deploy:serve 真实接口: - `GET /deploy/api/health`:只返回凭据是否存在,不返回密钥明文。 +- `POST /deploy/api/edgeone/purge-url`:提交 EdgeOne URL 刷新任务,必须带 `confirmed=true`;只允许 `EDGEONE_ALLOWED_HOSTS` 中的域名,默认 `h5.global-interaction.com`。 - `GET /deploy/api/hyapp/inventory`:读取 HyApp 生产 inventory。 - `GET /deploy/api/hyapp/plan?services=gateway-service,room-service`:生成发布计划,不执行远端动作。 - `GET /deploy/api/hyapp/discover?services=user-service&remote=1`:通过 Tencent TAT 连接 deploy 机器 `43.164.75.199`,再由 deploy 机器 SSH 到业务机器读取真实实例、systemd、Docker、镜像、资源、端口和 CLB 权重。 - `GET /deploy/api/hyapp/discover?services=user-service&remote=1&includeConfig=1`:读取详情 YAML,密钥字段脱敏。 - `GET /deploy/api/hyapp/resources?remote=1`:数据资源状态。链路:TAT -> deploy;业务服务器配置 SSH 失败后 TAT 兜底。 - `POST /deploy/api/hyapp/deploy`:真实发布,必须带 `confirmed=true`;测试时使用 `dryRun=true`。 +- `POST /deploy/api/hyapp/admin-server/deploy`:生产 `admin-server` 固定发布入口,必须带 `confirmed=true`;deploy 机器拉取 Gitea `hyapp-server` 的 `origin/main`,构建并替换 `134.175.160.86` 上的 `/opt/hyapp/admin-server/admin-server`,最后验证 `healthz/readyz`。 - `POST /deploy/api/hyapp/restart`:真实重启,必须带 `confirmed=true`;测试时使用 `dryRun=true`。 - `GET /deploy/api/hyapp/testbox/status`:读取测试机 test 分支、timer 和 Compose/systemd 服务状态。 - `POST /deploy/api/hyapp/testbox/service`:测试机服务 `update/start/stop/restart`,必须带 `confirmed=true`。 diff --git a/deploy/server.mjs b/deploy/server.mjs index 686b461..4206f31 100644 --- a/deploy/server.mjs +++ b/deploy/server.mjs @@ -26,6 +26,11 @@ const pythonBin = process.env.PYTHON_BIN || "python3"; const tencentEnvFile = process.env.TENCENT_ENV_FILE || ""; const credentialFile = resolve(process.env.TENCENT_CREDENTIALS_FILE || join(rootDir, ".env")); const deployEnv = { ...process.env }; +const edgeoneConfig = { + allowedHosts: process.env.EDGEONE_ALLOWED_HOSTS || "h5.global-interaction.com", + zoneId: process.env.EDGEONE_ZONE_ID || "zone-3qwew52ihaj2", + zoneName: process.env.EDGEONE_ZONE_NAME || "global-interaction.com", +}; const mimeTypes = new Map([ [".css", "text/css; charset=utf-8"], @@ -186,7 +191,7 @@ function readRequestJson(request) { }); } -function runDeployScript(scriptName, args) { +function runDeployScript(scriptName, args, timeoutMs = Number(process.env.DEPLOY_API_COMMAND_TIMEOUT_MS || 120_000)) { return new Promise((resolvePromise) => { const child = execFile( pythonBin, @@ -194,7 +199,7 @@ function runDeployScript(scriptName, args) { { cwd: rootDir, env: deployEnv, - timeout: Number(process.env.DEPLOY_API_COMMAND_TIMEOUT_MS || 120_000), + timeout: timeoutMs, }, (error, stdout, stderr) => { let payload = null; @@ -242,6 +247,46 @@ function parseMigrationFiles(value) { return [...new Set(files)]; } +function parsePurgeUrls(value) { + const source = Array.isArray(value) ? value : String(value || "").split(/\s+/); + const urls = source + .flatMap((item) => String(item || "").split(/\s+/)) + .map((item) => item.trim()) + .filter(Boolean); + + if (urls.length === 0) { + throw new Error("urls is required"); + } + + if (urls.length > 200) { + throw new Error("at most 200 urls are allowed"); + } + + const allowedHosts = new Set( + String(edgeoneConfig.allowedHosts || "") + .split(",") + .map((item) => item.trim().toLowerCase()) + .filter(Boolean), + ); + + for (const item of urls) { + let parsed; + try { + parsed = new URL(item); + } catch { + throw new Error(`invalid url: ${item}`); + } + if (!["http:", "https:"].includes(parsed.protocol)) { + throw new Error(`invalid url protocol: ${item}`); + } + if (!allowedHosts.has(parsed.hostname.toLowerCase())) { + throw new Error(`url host is not allowed: ${parsed.hostname}`); + } + } + + return [...new Set(urls)]; +} + function requireConfirmed(body) { if (body.dryRun === true) { return; @@ -322,6 +367,44 @@ async function handleApi(request, response, url) { return; } + if (url.pathname === "/deploy/api/edgeone/purge-url") { + if (request.method !== "POST") { + sendError(response, 405, "POST is required"); + return; + } + + const body = await readRequestJson(request); + requireConfirmed(body); + + const urls = parsePurgeUrls(body.urls); + const args = [ + "purge-url", + ...buildEnvArgs(), + "--zone-id", + edgeoneConfig.zoneId, + "--zone-name", + edgeoneConfig.zoneName, + "--allowed-hosts", + edgeoneConfig.allowedHosts, + "--wait", + ]; + + for (const item of urls) { + args.push("--urls", item); + } + + if (body.dryRun === true) { + args.push("--dry-run"); + } + + const result = await runDeployScript("edgeone_purge.py", args); + sendJson(response, result.ok ? 200 : 500, { + ...result.payload, + edgeone: edgeoneConfig, + }); + return; + } + if (url.pathname === "/deploy/api/hyapp/inventory") { const inventory = await readJson(hyappInventoryPath); sendJson(response, 200, { @@ -393,6 +476,32 @@ async function handleApi(request, response, url) { return; } + if (url.pathname === "/deploy/api/hyapp/admin-server/deploy") { + if (request.method !== "POST") { + sendError(response, 405, "POST is required"); + return; + } + + const body = await readRequestJson(request); + requireConfirmed(body); + + const args = ["deploy", ...buildEnvArgs(), "--inventory", hyappInventoryPath]; + + if (body.dryRun === true) { + args.push("--dry-run"); + } else { + args.push("--yes"); + } + + const result = await runDeployScript( + "hyapp_admin_server_deploy.py", + args, + Number(process.env.DEPLOY_ADMIN_SERVER_COMMAND_TIMEOUT_MS || 1_800_000), + ); + sendJson(response, result.ok ? 200 : 500, result.payload); + return; + } + if (url.pathname === "/deploy/api/hyapp/config") { if (request.method !== "POST") { sendError(response, 405, "POST is required"); diff --git a/deploy/standalone/README.md b/deploy/standalone/README.md index bc5916a..c6e0597 100644 --- a/deploy/standalone/README.md +++ b/deploy/standalone/README.md @@ -13,6 +13,7 @@ 同源接口: - `GET /deploy/api/health` +- `POST /deploy/api/edgeone/purge-url` - `GET /deploy/api/hyapp/inventory` - `GET /deploy/api/hyapp/plan?services=gateway-service,room-service` - `GET /deploy/api/hyapp/discover?services=gateway-service&remote=0` @@ -20,6 +21,7 @@ - `GET /deploy/api/hyapp/discover?services=user-service&remote=1&includeConfig=1` - `GET /deploy/api/hyapp/resources?remote=1` - `POST /deploy/api/hyapp/deploy` +- `POST /deploy/api/hyapp/admin-server/deploy` - `POST /deploy/api/hyapp/restart` - `GET /deploy/api/hyapp/testbox/status` - `POST /deploy/api/hyapp/testbox/service` @@ -57,7 +59,7 @@ sudo systemctl status hyapp-deploy-platform curl -fsS http://127.0.0.1:29200/deploy/api/health ``` -`docker.env` 只保存镜像、容器名和停止等待时间。腾讯云凭据不写入镜像;需要执行远端 discover 时,通过容器环境变量或 `TENCENT_ENV_FILE` 挂载注入。 +`docker.env` 保存镜像、容器名、停止等待时间和 deploy API 需要的运行时配置。腾讯云凭据不写入镜像;需要执行远端 discover 或 EdgeOne 刷新时,通过 `docker.env` 注入 `TENCENTCLOUD_SECRET_ID`、`TENCENTCLOUD_SECRET_KEY`、`TENCENTCLOUD_REGION`,或通过 `TENCENT_ENV_FILE` 指向挂载到容器内的凭据文件。 ## HyApp Service Templates diff --git a/deploy/standalone/docker/deploy-platform.env.example b/deploy/standalone/docker/deploy-platform.env.example index d34b545..0e4d10d 100644 --- a/deploy/standalone/docker/deploy-platform.env.example +++ b/deploy/standalone/docker/deploy-platform.env.example @@ -1,3 +1,12 @@ IMAGE=10.2.1.3:18082/hyapp/deploy-platform:REPLACE_TAG CONTAINER_NAME=hyapp-deploy-platform STOP_TIMEOUT_SEC=15 +EDGEONE_ZONE_ID=zone-3qwew52ihaj2 +EDGEONE_ZONE_NAME=global-interaction.com +EDGEONE_ALLOWED_HOSTS=h5.global-interaction.com +DEPLOY_ADMIN_SERVER_COMMAND_TIMEOUT_MS=1800000 + +# Tencent Cloud credentials are required for remote TAT queries and EdgeOne refresh. +# TENCENTCLOUD_SECRET_ID= +# TENCENTCLOUD_SECRET_KEY= +# TENCENTCLOUD_REGION=me-saudi-arabia diff --git a/deploy/standalone/systemd/hyapp-deploy-platform.service b/deploy/standalone/systemd/hyapp-deploy-platform.service index 3051493..2098980 100644 --- a/deploy/standalone/systemd/hyapp-deploy-platform.service +++ b/deploy/standalone/systemd/hyapp-deploy-platform.service @@ -8,7 +8,7 @@ Requires=docker.service Type=simple EnvironmentFile=/etc/hyapp/deploy-platform/docker.env ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME} -ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 ${IMAGE} +ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env-file=/etc/hyapp/deploy-platform/docker.env --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 ${IMAGE} ExecStop=/usr/bin/env docker stop -t ${STOP_TIMEOUT_SEC} ${CONTAINER_NAME} Restart=always RestartSec=3 diff --git a/deploy/tencent_tat/README.md b/deploy/tencent_tat/README.md index fdaf8fc..963bd04 100644 --- a/deploy/tencent_tat/README.md +++ b/deploy/tencent_tat/README.md @@ -148,6 +148,17 @@ python3 deploy/tencent_tat/hyapp_data_resources.py resources \ --inventory deploy/tencent_tat/hyapp-services.inventory.prod.json ``` +生产 `admin-server` 固定从 Gitea `origin/main` 发布,入口由 deploy 机器触发: + +```bash +python3 deploy/tencent_tat/hyapp_admin_server_deploy.py deploy \ + --env-file /opt/hy-app-monitor/.env \ + --inventory deploy/tencent_tat/hyapp-services.inventory.prod.json \ + --yes +``` + +这条链路会在 deploy 机器 `/opt/deploy/sources/hyapp-server` 拉取 `git@gitea.haiyihy.com:hy/hyapp-server.git` 的 `origin/main`,构建 `server/admin` Linux 二进制,替换 `134.175.160.86:/opt/hyapp/admin-server/admin-server`,再验证 `172.16.0.6:13100` 的 `/healthz` 和 `/readyz`。生产不允许切换到其他分支。 + 规则: - MySQL、Redis、MQ 是腾讯云托管资源。 diff --git a/deploy/tencent_tat/edgeone_purge.py b/deploy/tencent_tat/edgeone_purge.py new file mode 100644 index 0000000..dbbe72f --- /dev/null +++ b/deploy/tencent_tat/edgeone_purge.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from urllib.parse import urlparse, urlunparse + + +DEFAULT_EDGEONE_ZONE_ID = "zone-3qwew52ihaj2" +DEFAULT_EDGEONE_ZONE_NAME = "global-interaction.com" +DEFAULT_EDGEONE_ALLOWED_HOSTS = "h5.global-interaction.com" +TERMINAL_STATUSES = {"success", "failed", "timeout", "canceled"} + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def load_env_file(path: str) -> None: + if not path: + return + env_path = Path(path).expanduser().resolve() + if not env_path.exists(): + raise RuntimeError(f"env file not found: {env_path}") + for raw_line in env_path.read_text(encoding="utf-8", errors="replace").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().strip('"').strip("'") + if key and key not in os.environ: + os.environ[key] = value + + +def env_value(*names: str) -> str: + for name in names: + value = str(os.environ.get(name) or "").strip() + if value: + return value + return "" + + +def require_tencent_credentials() -> tuple[str, str, str]: + secret_id = env_value("TENCENTCLOUD_SECRET_ID", "TENCENT_SECRET_ID") + secret_key = env_value("TENCENTCLOUD_SECRET_KEY", "TENCENT_SECRET_KEY") + region = env_value("TENCENTCLOUD_REGION", "DEPLOY_REGION") or "ap-guangzhou" + missing = [] + if not secret_id: + missing.append("TENCENTCLOUD_SECRET_ID") + if not secret_key: + missing.append("TENCENTCLOUD_SECRET_KEY") + if missing: + raise RuntimeError("missing Tencent Cloud credentials: " + ", ".join(missing)) + return secret_id, secret_key, region + + +def edgeone_allowed_hosts(raw: str) -> set[str]: + hosts = { + item.strip().lower() + for item in str(raw or DEFAULT_EDGEONE_ALLOWED_HOSTS).split(",") + if item.strip() + } + if not hosts: + raise RuntimeError("EDGEONE_ALLOWED_HOSTS is empty") + return hosts + + +def normalize_url_item(item: str, allowed_hosts: set[str]) -> str: + parsed = urlparse(str(item or "").strip()) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError(f"invalid URL: {item}") + hostname = str(parsed.hostname or "").strip().lower() + if hostname not in allowed_hosts: + raise ValueError(f"host is not allowed for EdgeOne purge: {hostname}") + return urlunparse(parsed._replace(fragment="")) + + +def normalize_url_items(raw_items: list[str], allowed_hosts: set[str]) -> list[str]: + normalized: list[str] = [] + seen: set[str] = set() + for item in raw_items: + value = normalize_url_item(item, allowed_hosts) + if value in seen: + continue + seen.add(value) + normalized.append(value) + if not normalized: + raise ValueError("at least one URL is required") + if len(normalized) > 200: + raise ValueError("URL refresh supports at most 200 URLs per request") + return normalized + + +def build_teo_client() -> Any: + secret_id, secret_key, region = require_tencent_credentials() + try: + from tencentcloud.common import credential + from tencentcloud.common.profile.client_profile import ClientProfile + from tencentcloud.common.profile.http_profile import HttpProfile + from tencentcloud.teo.v20220901 import teo_client + except Exception as exc: # noqa: BLE001 + raise RuntimeError(f"tencentcloud EdgeOne SDK unavailable: {exc}") from exc + + return teo_client.TeoClient( + credential.Credential(secret_id, secret_key), + region, + ClientProfile(httpProfile=HttpProfile(endpoint="teo.tencentcloudapi.com")), + ) + + +def call_teo(client: Any, method_name: str, payload: dict[str, Any]) -> dict[str, Any]: + from tencentcloud.teo.v20220901 import models as teo_models + + request_class = getattr(teo_models, f"{method_name}Request") + request = request_class() + request.from_json_string(json.dumps(payload)) + response = getattr(client, method_name)(request) + return json.loads(response.to_json_string()) + + +def create_url_purge(client: Any, zone_id: str, targets: list[str]) -> dict[str, Any]: + return call_teo( + client, + "CreatePurgeTask", + { + "ZoneId": zone_id, + "Type": "purge_url", + "Targets": targets, + }, + ) + + +def describe_purge_job(client: Any, zone_id: str, job_id: str) -> dict[str, Any]: + return call_teo( + client, + "DescribePurgeTasks", + { + "ZoneId": zone_id, + "Limit": 20, + "Filters": [{"Name": "job-id", "Values": [job_id]}], + }, + ) + + +def wait_for_purge_job(client: Any, zone_id: str, job_id: str, timeout_seconds: int) -> tuple[str, list[dict[str, Any]], dict[str, Any]]: + deadline = time.time() + max(timeout_seconds, 1) + latest: dict[str, Any] = {} + tasks: list[dict[str, Any]] = [] + while time.time() < deadline: + latest = describe_purge_job(client, zone_id, job_id) + tasks = list(latest.get("Tasks") or []) + statuses = {str(item.get("Status") or "").strip().lower() for item in tasks} + if statuses and all(status in TERMINAL_STATUSES for status in statuses): + if any(status != "success" for status in statuses): + return "failed", tasks, latest + return "success", tasks, latest + time.sleep(3) + return "timeout", tasks, latest + + +def run(args: argparse.Namespace) -> dict[str, Any]: + load_env_file(args.env_file) + zone_id = args.zone_id or env_value("EDGEONE_ZONE_ID") or DEFAULT_EDGEONE_ZONE_ID + zone_name = args.zone_name or env_value("EDGEONE_ZONE_NAME") or DEFAULT_EDGEONE_ZONE_NAME + allowed_hosts = edgeone_allowed_hosts(args.allowed_hosts or env_value("EDGEONE_ALLOWED_HOSTS")) + targets = normalize_url_items(args.urls, allowed_hosts) + + if args.dry_run: + return { + "allowedHosts": sorted(allowed_hosts), + "dryRun": True, + "mode": "url", + "ok": True, + "targets": targets, + "zoneId": zone_id, + "zoneName": zone_name, + } + + client = build_teo_client() + submit = create_url_purge(client, zone_id, targets) + job_id = str(submit.get("JobId") or "").strip() + status = "submitted" + tasks: list[dict[str, Any]] = [] + describe: dict[str, Any] = {} + + if job_id and args.wait: + status, tasks, describe = wait_for_purge_job(client, zone_id, job_id, args.timeout_seconds) + + return { + "allowedHosts": sorted(allowed_hosts), + "createdAt": utc_now(), + "dryRun": False, + "failedList": list(submit.get("FailedList") or []), + "jobId": job_id, + "mode": "url", + "ok": status != "failed" and not submit.get("FailedList"), + "requestId": str(submit.get("RequestId") or "").strip(), + "status": status, + "submit": submit, + "targets": targets, + "tasks": tasks, + "describe": describe, + "zoneId": zone_id, + "zoneName": zone_name, + } + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Submit Tencent EdgeOne URL purge tasks.") + parser.add_argument("action", choices=["purge-url"]) + parser.add_argument("--env-file", default="") + parser.add_argument("--zone-id", default="") + parser.add_argument("--zone-name", default="") + parser.add_argument("--allowed-hosts", default="") + parser.add_argument("--urls", action="append", default=[]) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--wait", action="store_true") + parser.add_argument("--timeout-seconds", type=int, default=90) + args = parser.parse_args(argv) + if args.action == "purge-url" and not args.urls: + parser.error("--urls is required") + return args + + +def main(argv: list[str]) -> int: + try: + payload = run(parse_args(argv)) + except Exception as exc: # noqa: BLE001 + print(json.dumps({"error": str(exc), "ok": False}, ensure_ascii=False, indent=2)) + return 1 + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 if payload.get("ok") is not False else 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/deploy/tencent_tat/hyapp_admin_server_deploy.py b/deploy/tencent_tat/hyapp_admin_server_deploy.py new file mode 100644 index 0000000..392491f --- /dev/null +++ b/deploy/tencent_tat/hyapp_admin_server_deploy.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import shlex +import sys +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from deploy.tencent_tat.hyapp_tat_deploy import build_tat_client, load_env_file, load_inventory, run_tat_shell + +DEFAULT_REPO_URL = "git@gitea.haiyihy.com:hy/hyapp-server.git" +DEFAULT_SOURCE_DIR = "/opt/deploy/sources/hyapp-server" +DEFAULT_BRANCH = "main" +DEFAULT_ADMIN_HOST = "134.175.160.86" +DEFAULT_ADMIN_BIND_ADDR = "172.16.0.6" +DEFAULT_ADMIN_PORT = 13100 + + +def shell_join(items: list[str]) -> str: + return " ".join(shlex.quote(item) for item in items) + + +def admin_endpoint(host: str, user: str) -> str: + return f"{user}@{host}" if user else host + + +def build_remote_script(args: argparse.Namespace) -> str: + endpoint = admin_endpoint(args.admin_host, args.admin_user) + dry_run = "1" if args.dry_run else "0" + ssh_opts = [ + "ssh", + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=accept-new", + "-o", + f"ConnectTimeout={args.ssh_connect_timeout}", + ] + rsync_ssh = shell_join(ssh_opts) + admin_ssh = shell_join([*ssh_opts, endpoint]) + + return f"""#!/usr/bin/env bash +set -euo pipefail + +REPO_URL={shlex.quote(args.repo_url)} +SOURCE_DIR={shlex.quote(args.source_dir)} +BRANCH={shlex.quote(args.branch)} +DRY_RUN={dry_run} +ADMIN_ENDPOINT={shlex.quote(endpoint)} +ADMIN_BIND_ADDR={shlex.quote(args.admin_bind_addr)} +ADMIN_PORT={shlex.quote(str(args.admin_port))} +SSH_CMD={shlex.quote(rsync_ssh)} +ADMIN_SSH={shlex.quote(admin_ssh)} + +export GIT_SSH_COMMAND="ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15" + +echo "repo=$REPO_URL" +echo "source_dir=$SOURCE_DIR" +echo "branch=$BRANCH" +echo "admin_endpoint=$ADMIN_ENDPOINT" + +if [ "$DRY_RUN" = "1" ]; then + target_commit="$(git ls-remote "$REPO_URL" "refs/heads/$BRANCH" | awk '{{print $1}}')" + if [ -z "$target_commit" ]; then + echo "cannot resolve origin/$BRANCH" >&2 + exit 12 + fi + echo "target_commit=$target_commit" + $ADMIN_SSH "ADMIN_BIND_ADDR='$ADMIN_BIND_ADDR' ADMIN_PORT='$ADMIN_PORT' bash -s" <<'REMOTE' +set -e +echo "admin_active=$(systemctl is-active hyapp-admin-server || true)" +ss -lntp | grep "$ADMIN_PORT" || true +curl -fsS -m 3 "http://$ADMIN_BIND_ADDR:$ADMIN_PORT/healthz" || true +echo +REMOTE + exit 0 +fi + +if [ -d "$SOURCE_DIR/.git" ]; then + cd "$SOURCE_DIR" + git remote set-url origin "$REPO_URL" +else + mkdir -p "$(dirname "$SOURCE_DIR")" + git clone "$REPO_URL" "$SOURCE_DIR" + cd "$SOURCE_DIR" +fi + +git fetch --prune origin "$BRANCH" +git checkout -B "$BRANCH" "origin/$BRANCH" +git reset --hard "origin/$BRANCH" +git clean -fd + +commit="$(git rev-parse HEAD)" +short_commit="$(git rev-parse --short HEAD)" +build_id="$(date +%Y%m%d%H%M%S)-$short_commit" +build_image="hyapp/admin-server-build:$build_id" +local_binary="/tmp/admin-server-$build_id" +remote_binary="/tmp/admin-server-$build_id" + +echo "commit=$commit" +echo "build_id=$build_id" + +docker build -t "$build_image" -f server/admin/Dockerfile . +container_id="$(docker create "$build_image")" +trap 'docker rm -f "$container_id" >/dev/null 2>&1 || true' EXIT +docker cp "$container_id:/app/server" "$local_binary" +docker rm "$container_id" >/dev/null +trap - EXIT +chmod 0755 "$local_binary" +binary_sha="$(sha256sum "$local_binary" | awk '{{print $1}}')" +binary_size="$(stat -c %s "$local_binary")" +echo "binary_sha=$binary_sha" +echo "binary_size=$binary_size" + +rsync -az --partial -e "$SSH_CMD" "$local_binary" "$ADMIN_ENDPOINT:$remote_binary" + +$ADMIN_SSH "REMOTE_BINARY='$remote_binary' EXPECTED_SHA='$binary_sha' ADMIN_BIND_ADDR='$ADMIN_BIND_ADDR' ADMIN_PORT='$ADMIN_PORT' COMMIT='$commit' bash -s" <<'REMOTE' +set -euo pipefail + +BIN=/opt/hyapp/admin-server/admin-server +BACKUP="$BIN.bak.$(date +%Y%m%d%H%M%S)" + +actual_sha="$(sha256sum "$REMOTE_BINARY" | awk '{{print $1}}')" +if [ "$actual_sha" != "$EXPECTED_SHA" ]; then + echo "uploaded binary sha mismatch: $actual_sha != $EXPECTED_SHA" >&2 + exit 21 +fi + +cp "$BIN" "$BACKUP" +install -o root -g root -m 0755 "$REMOTE_BINARY" "$BIN.new" +mv "$BIN.new" "$BIN" +systemctl restart hyapp-admin-server + +ready=0 +for _ in $(seq 1 40); do + active="$(systemctl is-active hyapp-admin-server || true)" + if [ "$active" = "active" ] && ss -lntp | grep -q "$ADMIN_BIND_ADDR:$ADMIN_PORT"; then + if curl -fsS -m 3 "http://$ADMIN_BIND_ADDR:$ADMIN_PORT/healthz" >/tmp/hyapp-admin-healthz.out && + curl -fsS -m 3 "http://$ADMIN_BIND_ADDR:$ADMIN_PORT/readyz" >/tmp/hyapp-admin-readyz.out; then + ready=1 + break + fi + fi + sleep 2 +done + +if [ "$ready" != "1" ]; then + echo "admin-server verification failed; rollback to $BACKUP" >&2 + cp "$BACKUP" "$BIN" + systemctl restart hyapp-admin-server || true + exit 22 +fi + +echo "backup=$BACKUP" +echo "active=$(systemctl is-active hyapp-admin-server || true)" +echo "installed_sha=$(sha256sum "$BIN" | awk '{{print $1}}')" +echo "listener=$(ss -lntp | grep "$ADMIN_BIND_ADDR:$ADMIN_PORT" || true)" +echo "healthz=$(cat /tmp/hyapp-admin-healthz.out)" +echo "readyz=$(cat /tmp/hyapp-admin-readyz.out)" +REMOTE +""" + + +def run_deploy(args: argparse.Namespace) -> dict[str, Any]: + inventory = load_inventory(args.inventory) + client = build_tat_client(inventory) + script = build_remote_script(args) + result = run_tat_shell( + client, + instance_id=inventory["deploy_server"]["instance_id"], + script=script, + command_name="hyapp-admin-server-deploy", + timeout_seconds=args.timeout_seconds, + poll_seconds=int(inventory.get("tat_poll_seconds") or 2), + ) + output = str(result.get("output") or "") + return { + "adminHost": args.admin_host, + "dryRun": args.dry_run, + "ok": result.get("status") == "SUCCESS", + "output": output[-12000:], + "sourceDir": args.source_dir, + "status": result.get("status"), + "target": inventory.get("deploy_server") or {}, + } + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Deploy production admin-server from Gitea origin/main through the deploy host.") + parser.add_argument("action", choices=["deploy"]) + parser.add_argument("--env-file", default="") + parser.add_argument("--inventory", default="deploy/tencent_tat/hyapp-services.inventory.prod.example.json") + parser.add_argument("--repo-url", default=DEFAULT_REPO_URL) + parser.add_argument("--source-dir", default=DEFAULT_SOURCE_DIR) + parser.add_argument("--branch", default=DEFAULT_BRANCH) + parser.add_argument("--admin-host", default=DEFAULT_ADMIN_HOST) + parser.add_argument("--admin-user", default="") + parser.add_argument("--admin-bind-addr", default=DEFAULT_ADMIN_BIND_ADDR) + parser.add_argument("--admin-port", type=int, default=DEFAULT_ADMIN_PORT) + parser.add_argument("--ssh-connect-timeout", type=int, default=15) + parser.add_argument("--timeout-seconds", type=int, default=1800) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--yes", action="store_true") + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + try: + load_env_file(args.env_file) + if args.branch != DEFAULT_BRANCH: + raise RuntimeError("admin-server production deploy only supports origin/main") + if not args.dry_run and not args.yes: + raise RuntimeError("real execution requires --yes") + payload = run_deploy(args) + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 if payload.get("ok") else 1 + except Exception as exc: # noqa: BLE001 + print(json.dumps({"error": str(exc), "ok": False}, ensure_ascii=False, indent=2)) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deploy/tencent_tat/tests/test_hyapp_admin_server_deploy.py b/deploy/tencent_tat/tests/test_hyapp_admin_server_deploy.py new file mode 100644 index 0000000..de25a81 --- /dev/null +++ b/deploy/tencent_tat/tests/test_hyapp_admin_server_deploy.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import argparse +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from deploy.tencent_tat import hyapp_admin_server_deploy as admin_deploy + + +class AdminServerDeployScriptTests(unittest.TestCase): + def test_remote_script_pulls_gitea_main_and_verifies_admin_health(self) -> None: + args = argparse.Namespace( + admin_bind_addr="172.16.0.6", + admin_host="134.175.160.86", + admin_port=13100, + admin_user="", + branch="main", + dry_run=False, + repo_url="git@gitea.haiyihy.com:hy/hyapp-server.git", + source_dir="/opt/deploy/sources/hyapp-server", + ssh_connect_timeout=15, + ) + script = admin_deploy.build_remote_script(args) + + self.assertIn("git fetch --prune origin \"$BRANCH\"", script) + self.assertIn("git checkout -B \"$BRANCH\" \"origin/$BRANCH\"", script) + self.assertIn("docker build -t \"$build_image\" -f server/admin/Dockerfile .", script) + self.assertIn("rsync -az --partial", script) + self.assertIn("systemctl restart hyapp-admin-server", script) + self.assertIn("http://$ADMIN_BIND_ADDR:$ADMIN_PORT/healthz", script) + self.assertIn("http://$ADMIN_BIND_ADDR:$ADMIN_PORT/readyz", script) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/app/router.tsx b/src/app/router.tsx index e2ab06b..50d968f 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -1,5 +1,6 @@ import { createBrowserRouter, Navigate } from "react-router-dom"; import { App } from "./App"; +import { CdnRefreshPage } from "../pages/cdn-refresh"; import { ConfigCenterPage } from "../pages/config-center"; export const router = createBrowserRouter([ @@ -9,6 +10,7 @@ export const router = createBrowserRouter([ children: [ { index: true, element: }, { path: "config-center", element: }, + { path: "cdn-refresh", element: }, { path: "*", element: }, ], }, diff --git a/src/entities/data-resource/api/getDataResources.ts b/src/entities/data-resource/api/getDataResources.ts index 07165a1..8ee62fa 100644 --- a/src/entities/data-resource/api/getDataResources.ts +++ b/src/entities/data-resource/api/getDataResources.ts @@ -1,30 +1,12 @@ import type { DataResourcesResponse } from "../model/types"; - -async function readJsonResponse(response: Response): Promise { - const contentType = response.headers.get("content-type") ?? ""; - - if (!response.ok) { - if (contentType.includes("application/json")) { - const payload = (await response.json()) as { detail?: string; error?: string }; - const detail = payload.detail || payload.error; - throw new Error(detail ? `Deploy API HTTP ${response.status}: ${detail}` : `Deploy API HTTP ${response.status}`); - } - throw new Error(`Deploy API HTTP ${response.status}`); - } - - if (!contentType.includes("application/json")) { - throw new Error("Deploy API returned non-JSON response"); - } - - return (await response.json()) as T; -} +import { deployFetch, readDeployJsonResponse } from "../../../shared/api/deployFetch"; export async function getDataResources(): Promise { - const response = await fetch("/deploy/api/hyapp/resources?remote=1", { + const response = await deployFetch("/deploy/api/hyapp/resources?remote=1", { headers: { accept: "application/json", }, }); - return readJsonResponse(response); + return readDeployJsonResponse(response); } diff --git a/src/entities/deployment-plan/api/getDeployApiInventory.ts b/src/entities/deployment-plan/api/getDeployApiInventory.ts index 5c792a3..43f0ac0 100644 --- a/src/entities/deployment-plan/api/getDeployApiInventory.ts +++ b/src/entities/deployment-plan/api/getDeployApiInventory.ts @@ -1,3 +1,5 @@ +import { deployFetch, readDeployJsonResponse } from "../../../shared/api/deployFetch"; + export type DeployApiInventoryResponse = { hosts: Record< string, @@ -31,27 +33,8 @@ export type DeployApiInventoryResponse = { >; }; -async function readJsonResponse(response: Response): Promise { - const contentType = response.headers.get("content-type") ?? ""; - - if (!response.ok) { - if (contentType.includes("application/json")) { - const payload = (await response.json()) as { detail?: string; error?: string }; - throw new Error(payload.detail || payload.error || `Deploy API HTTP ${response.status}`); - } - - throw new Error(`Deploy API HTTP ${response.status}`); - } - - if (!contentType.includes("application/json")) { - throw new Error("Deploy API returned non-JSON response"); - } - - return (await response.json()) as T; -} - async function postJson(path: string, body: Record): Promise { - const response = await fetch(path, { + const response = await deployFetch(path, { body: JSON.stringify(body), headers: { accept: "application/json", @@ -60,17 +43,17 @@ async function postJson(path: string, body: Record): Promise method: "POST", }); - return readJsonResponse(response); + return readDeployJsonResponse(response); } export async function getDeployApiInventory(): Promise { - const response = await fetch("/deploy/api/hyapp/inventory", { + const response = await deployFetch("/deploy/api/hyapp/inventory", { headers: { accept: "application/json", }, }); - return readJsonResponse(response); + return readDeployJsonResponse(response); } export type DeployApiPlanResponse = { @@ -91,13 +74,13 @@ export async function getDeployApiPlan(services: string[]): Promise(response); + return readDeployJsonResponse(response); } export type DeployApiDiscoverResponse = { @@ -152,13 +135,13 @@ export async function getDeployApiDiscover(services: string[], remote: boolean, params.set("maxConfigBytes", "50000"); } - const response = await fetch(`/deploy/api/hyapp/discover?${params.toString()}`, { + const response = await deployFetch(`/deploy/api/hyapp/discover?${params.toString()}`, { headers: { accept: "application/json", }, }); - return readJsonResponse(response); + return readDeployJsonResponse(response); } export type DeployApiActionStep = { @@ -237,13 +220,13 @@ export async function getDeployApiTestboxConfigs(services: string[], raw = false raw: raw ? "1" : "0", services: services.join(","), }); - const response = await fetch(`/deploy/api/hyapp/testbox/configs?${params.toString()}`, { + const response = await deployFetch(`/deploy/api/hyapp/testbox/configs?${params.toString()}`, { headers: { accept: "application/json", }, }); - return readJsonResponse(response); + return readDeployJsonResponse(response); } export type DeployApiTestboxStatusResponse = { @@ -278,13 +261,13 @@ export async function getDeployApiTestboxStatus(services: string[]): Promise(response); + return readDeployJsonResponse(response); } export type DeployApiTestboxCommand = { @@ -377,11 +360,11 @@ export type DeployApiTestboxMigrationsResponse = { }; export function getDeployApiTestboxMigrations(): Promise { - return fetch("/deploy/api/hyapp/testbox/migrations", { + return deployFetch("/deploy/api/hyapp/testbox/migrations", { headers: { accept: "application/json", }, - }).then((response) => readJsonResponse(response)); + }).then((response) => readDeployJsonResponse(response)); } export function applyDeployApiTestboxMigrations(files: string[]): Promise { diff --git a/src/entities/edgeone-purge/api/purgeEdgeoneUrls.ts b/src/entities/edgeone-purge/api/purgeEdgeoneUrls.ts new file mode 100644 index 0000000..5369f9b --- /dev/null +++ b/src/entities/edgeone-purge/api/purgeEdgeoneUrls.ts @@ -0,0 +1,53 @@ +import { deployFetch, readDeployJsonResponse } from "../../../shared/api/deployFetch"; + +export type EdgeonePurgeTask = { + CreateTime?: string; + FailMessage?: string | null; + FailType?: string | null; + JobId?: string; + Method?: string; + Status?: string; + Target?: string; + Type?: string; + UpdateTime?: string; +}; + +export type EdgeonePurgeResponse = { + allowedHosts?: string[]; + createdAt?: string; + dryRun?: boolean; + failedList?: string[]; + jobId?: string; + mode?: "url"; + ok: boolean; + requestId?: string; + status?: string; + targets?: string[]; + tasks?: EdgeonePurgeTask[]; + zoneId?: string; + zoneName?: string; +}; + +export type PurgeEdgeoneUrlsInput = { + confirmed: boolean; + dryRun?: boolean; + urls: string[]; +}; + +export async function purgeEdgeoneUrls(input: PurgeEdgeoneUrlsInput): Promise { + const response = await deployFetch("/deploy/api/edgeone/purge-url", { + body: JSON.stringify(input), + headers: { + "content-type": "application/json", + }, + method: "POST", + }); + + const payload = await readDeployJsonResponse(response); + + if (!response.ok || payload.ok === false) { + throw new Error(payload.detail || payload.error || "EdgeOne URL refresh failed"); + } + + return payload; +} diff --git a/src/entities/service/api/deployApi.ts b/src/entities/service/api/deployApi.ts index 74a8523..74a2cf1 100644 --- a/src/entities/service/api/deployApi.ts +++ b/src/entities/service/api/deployApi.ts @@ -1,4 +1,5 @@ import type { ServiceStatus } from "../model/types"; +import { deployFetch, readDeployJsonResponse } from "../../../shared/api/deployFetch"; export type DeployInventoryService = { config_path?: string; @@ -74,25 +75,6 @@ export type DeployDiscoverResponse = { ok?: boolean; }; -async function readJsonResponse(response: Response): Promise { - const contentType = response.headers.get("content-type") ?? ""; - - if (!response.ok) { - if (contentType.includes("application/json")) { - const payload = (await response.json()) as { detail?: string; error?: string }; - throw new Error(payload.detail || payload.error || `Deploy API HTTP ${response.status}`); - } - - throw new Error(`Deploy API HTTP ${response.status}`); - } - - if (!contentType.includes("application/json")) { - throw new Error("Deploy API returned non-JSON response"); - } - - return (await response.json()) as T; -} - export function serviceNameToId(serviceName: string) { return `svc-${serviceName.replace(/-service$/, "")}`; } @@ -109,13 +91,13 @@ export function serviceIdToName(serviceId: string, inventory: DeployInventoryRes } export async function fetchDeployInventory(): Promise { - const response = await fetch("/deploy/api/hyapp/inventory", { + const response = await deployFetch("/deploy/api/hyapp/inventory", { headers: { accept: "application/json", }, }); - const payload = await readJsonResponse(response); + const payload = await readDeployJsonResponse(response); if (payload.ok === false || !payload.services) { throw new Error("Deploy API inventory is unavailable"); @@ -135,13 +117,13 @@ export async function fetchDeployDiscover(services: string[], includeConfig = fa params.set("maxConfigBytes", "20000"); } - const response = await fetch(`/deploy/api/hyapp/discover?${params.toString()}`, { + const response = await deployFetch(`/deploy/api/hyapp/discover?${params.toString()}`, { headers: { accept: "application/json", }, }); - const payload = await readJsonResponse(response); + const payload = await readDeployJsonResponse(response); if (payload.ok === false) { throw new Error("Deploy API discover is unavailable"); diff --git a/src/features/release-service/api/createRelease.ts b/src/features/release-service/api/createRelease.ts index 4d53663..1ff3051 100644 --- a/src/features/release-service/api/createRelease.ts +++ b/src/features/release-service/api/createRelease.ts @@ -1,4 +1,5 @@ import type { CreateReleaseRequest, CreateReleaseResult } from "../model/types"; +import { deployFetch, readDeployJsonResponse } from "../../../shared/api/deployFetch"; type DeployApiResult = { error?: string; @@ -8,19 +9,24 @@ type DeployApiResult = { export async function createRelease(request: CreateReleaseRequest): Promise { const shouldExecute = request.environment === "prod"; - const response = await fetch("/deploy/api/hyapp/deploy", { + const isAdminServer = request.serviceName === "admin-server"; + const response = await deployFetch(isAdminServer ? "/deploy/api/hyapp/admin-server/deploy" : "/deploy/api/hyapp/deploy", { body: JSON.stringify({ confirmed: shouldExecute ? request.confirmed === true : true, dryRun: !shouldExecute, - services: [request.serviceName], - tag: request.targetVersion, + ...(isAdminServer + ? {} + : { + services: [request.serviceName], + tag: request.targetVersion, + }), }), headers: { "content-type": "application/json", }, method: "POST", }); - const result = (await response.json()) as DeployApiResult; + const result = await readDeployJsonResponse(response); if (!response.ok || result.ok === false) { throw new Error(result.error || `部署接口返回 HTTP ${response.status}`); diff --git a/src/features/release-service/ui/ReleaseDrawer.tsx b/src/features/release-service/ui/ReleaseDrawer.tsx index 51f06f5..e3a2fc8 100644 --- a/src/features/release-service/ui/ReleaseDrawer.tsx +++ b/src/features/release-service/ui/ReleaseDrawer.tsx @@ -56,17 +56,26 @@ export function ReleaseDrawer({ clusterName, environment, onClose, service, user } const isProd = environment === "prod"; - const hasExecutableTarget = !isProd || Boolean(service.unit && service.container && service.targetPort); + const isAdminServer = service.name === "admin-server"; + const hasExecutableTarget = !isProd || (isAdminServer ? Boolean(service.unit && service.targetPort) : Boolean(service.unit && service.container && service.targetPort)); const canSubmit = targetVersion !== "" && targetVersion !== service.version && hasExecutableTarget && (!isProd || confirmed); const hasWarning = prechecks.some((item) => item.result === "warning"); - const deployCommand = [ - "python3 deploy/tencent_tat/hyapp_tat_deploy.py deploy", - " --env-file /opt/hy-app-monitor/.env", - " --inventory deploy/tencent_tat/hyapp-services.inventory.prod.json", - ` --services ${service.name}`, - ` --tag ${targetVersion || ""}`, - isProd ? " --yes" : " --dry-run", - ].join("\n"); + const deployCommand = isAdminServer + ? [ + "python3 deploy/tencent_tat/hyapp_admin_server_deploy.py deploy", + " --env-file /opt/hy-app-monitor/.env", + " --inventory deploy/tencent_tat/hyapp-services.inventory.prod.json", + " --branch main", + isProd ? " --yes" : " --dry-run", + ].join("\n") + : [ + "python3 deploy/tencent_tat/hyapp_tat_deploy.py deploy", + " --env-file /opt/hy-app-monitor/.env", + " --inventory deploy/tencent_tat/hyapp-services.inventory.prod.json", + ` --services ${service.name}`, + ` --tag ${targetVersion || ""}`, + isProd ? " --yes" : " --dry-run", + ].join("\n"); const targetSummary = service.unit && service.container ? `${service.unit} / ${service.container}` : service.name; async function handleSubmit() { diff --git a/src/pages/cdn-refresh/CdnRefreshPage.module.css b/src/pages/cdn-refresh/CdnRefreshPage.module.css new file mode 100644 index 0000000..a6e7b14 --- /dev/null +++ b/src/pages/cdn-refresh/CdnRefreshPage.module.css @@ -0,0 +1,236 @@ +.root { + height: 100%; + min-height: 0; +} + +.grid { + display: grid; + flex: 1; + min-height: 0; + grid-template-columns: minmax(360px, 0.78fr) minmax(0, 1.22fr); + gap: var(--space-4); +} + +.panel { + display: flex; + min-height: 0; + flex-direction: column; + border: 1px solid rgba(255, 255, 255, 0.72); + border-radius: 8px; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.66), rgba(245, 250, 255, 0.38)), + rgba(255, 255, 255, 0.36); + box-shadow: var(--shadow-glass-sm); + backdrop-filter: blur(24px) saturate(165%); + -webkit-backdrop-filter: blur(24px) saturate(165%); +} + +.panelHeader { + display: flex; + min-height: 62px; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-4); + border-bottom: 1px solid var(--color-border-soft); +} + +.panelTitle { + display: flex; + min-width: 0; + align-items: center; + gap: var(--space-2); +} + +.panelTitle svg { + flex: 0 0 auto; + color: var(--color-brand); +} + +.panelTitle strong, +.panelTitle span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.panelTitle strong { + font-size: 15px; + line-height: 20px; +} + +.panelTitle span { + color: var(--color-text-tertiary); + font-size: 12px; + line-height: 17px; +} + +.form { + display: grid; + gap: var(--space-4); + padding: var(--space-4); +} + +.field { + display: grid; + gap: 7px; +} + +.field span, +.confirm { + color: var(--color-text-secondary); + font-size: 12px; + font-weight: 800; +} + +.select, +.textarea { + width: 100%; + border: 1px solid var(--color-border-soft); + border-radius: 8px; + color: var(--color-text-primary); + background: rgba(255, 255, 255, 0.58); + outline: 0; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.78), + inset 0 -1px 0 rgba(75, 108, 148, 0.08); +} + +.select { + height: 38px; + padding: 0 var(--space-3); +} + +.textarea { + min-height: 224px; + resize: vertical; + padding: var(--space-3); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 13px; + line-height: 20px; +} + +.confirm { + display: inline-flex; + align-items: center; + gap: var(--space-2); +} + +.actions { + display: flex; + justify-content: flex-end; + gap: var(--space-2); +} + +.banner { + display: flex; + align-items: center; + gap: var(--space-2); + min-height: 36px; + padding: 0 var(--space-4); + border: 1px solid rgba(229, 72, 77, 0.2); + border-radius: 8px; + color: var(--color-danger); + background: var(--color-danger-bg); +} + +.success { + border-color: rgba(24, 169, 87, 0.22); + color: var(--color-success); + background: var(--color-success-bg); +} + +.resultBody { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + gap: var(--space-4); + padding: var(--space-4); + overflow: auto; +} + +.summaryGrid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-3); +} + +.summaryItem { + display: grid; + gap: 6px; + min-width: 0; + padding: var(--space-3); + border: 1px solid var(--color-border-soft); + border-radius: 8px; + background: rgba(255, 255, 255, 0.52); +} + +.summaryItem small { + color: var(--color-text-tertiary); + font-size: 11px; + font-weight: 800; + letter-spacing: 0; +} + +.summaryItem strong { + overflow: hidden; + color: var(--color-text-primary); + font-size: 13px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.urlList, +.taskList { + display: grid; + gap: var(--space-2); +} + +.urlItem, +.taskItem { + display: grid; + gap: 6px; + padding: var(--space-3); + border: 1px solid var(--color-border-soft); + border-radius: 8px; + background: rgba(255, 255, 255, 0.48); +} + +.urlItem strong, +.taskItem strong { + overflow-wrap: anywhere; + color: var(--color-text-primary); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 12px; + line-height: 18px; +} + +.urlItem span, +.taskMeta { + color: var(--color-text-tertiary); + font-size: 12px; + line-height: 17px; +} + +.taskMeta { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.empty { + display: grid; + min-height: 240px; + place-items: center; + color: var(--color-text-tertiary); + font-size: 13px; +} + +@media (max-width: 1180px) { + .grid { + grid-template-columns: 1fr; + } +} diff --git a/src/pages/cdn-refresh/CdnRefreshPage.tsx b/src/pages/cdn-refresh/CdnRefreshPage.tsx new file mode 100644 index 0000000..28d01b2 --- /dev/null +++ b/src/pages/cdn-refresh/CdnRefreshPage.tsx @@ -0,0 +1,216 @@ +import { FormEvent, useMemo, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { AlertTriangle, CheckCircle2, CloudLightning, RotateCcw, SendHorizonal } from "lucide-react"; +import clsx from "clsx"; +import { purgeEdgeoneUrls, type EdgeonePurgeResponse } from "../../entities/edgeone-purge/api/purgeEdgeoneUrls"; +import { Button } from "../../shared/ui/Button"; +import { StatusBadge, type StatusBadgeStatus } from "../../shared/ui/StatusBadge"; +import styles from "./CdnRefreshPage.module.css"; + +const defaultUrlText = "https://h5.global-interaction.com/"; + +function parseUrls(text: string) { + return Array.from( + new Set( + text + .split(/\s+/) + .map((item) => item.trim()) + .filter(Boolean), + ), + ); +} + +function statusBadge(status: string | undefined): StatusBadgeStatus { + const normalized = String(status || "").toLowerCase(); + if (normalized === "success") { + return "healthy"; + } + if (["failed", "timeout", "canceled"].includes(normalized)) { + return "critical"; + } + if (normalized === "submitted" || normalized === "processing") { + return "running"; + } + return "unknown"; +} + +function formatStatus(status: string | undefined) { + const labels: Record = { + canceled: "已取消", + failed: "失败", + processing: "处理中", + submitted: "已提交", + success: "成功", + timeout: "超时", + }; + const normalized = String(status || "").toLowerCase(); + return labels[normalized] ?? "未知"; +} + +function ResultPanel({ result }: { result: EdgeonePurgeResponse | undefined }) { + if (!result) { + return
暂无刷新任务
; + } + + return ( +
+
+
+ Job ID + {result.jobId || "-"} +
+
+ 状态 + {formatStatus(result.status)} +
+
+ 站点 + {result.zoneName || result.zoneId || "-"} +
+
+ +
+ {(result.tasks ?? []).map((task) => ( +
+ {task.Target || "-"} +
+ + {task.Type || "purge_url"} + {task.CreateTime || "-"} + {task.UpdateTime || "-"} +
+ {task.FailMessage ? {task.FailMessage} : null} +
+ ))} +
+ +
+ {(result.targets ?? []).map((target) => ( +
+ {target} + {result.requestId || "-"} +
+ ))} +
+
+ ); +} + +export function CdnRefreshPage() { + const [urlText, setUrlText] = useState(defaultUrlText); + const [confirmed, setConfirmed] = useState(false); + const urls = useMemo(() => parseUrls(urlText), [urlText]); + + const purgeMutation = useMutation({ + mutationFn: () => + purgeEdgeoneUrls({ + confirmed, + urls, + }), + }); + + const submitDisabled = urls.length === 0 || !confirmed || purgeMutation.isPending; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (submitDisabled) { + return; + } + purgeMutation.mutate(); + }; + + return ( +
+
+
+

CDN 刷新

+
+
+ + {purgeMutation.error ? ( +
+ + {purgeMutation.error instanceof Error ? purgeMutation.error.message : "刷新失败"} +
+ ) : null} + + {purgeMutation.isSuccess ? ( +
+ + URL 刷新已完成 +
+ ) : null} + +
+
+
+
+ +
+ EdgeOne + h5.global-interaction.com +
+
+
+ +
+ + +