Update deploy platform release tooling
This commit is contained in:
parent
3d67678c20
commit
3a876d5850
11
AGENTS.md
11
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:<tag>`,通过 Tencent TAT 到 `new-app-1` 修改 docker env 的 `IMAGE` 后重启 `hyapp-cron-service`。
|
||||
|
||||
13
README.md
13
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
|
||||
|
||||
|
||||
@ -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`。
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 是腾讯云托管资源。
|
||||
|
||||
243
deploy/tencent_tat/edgeone_purge.py
Normal file
243
deploy/tencent_tat/edgeone_purge.py
Normal file
@ -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:]))
|
||||
229
deploy/tencent_tat/hyapp_admin_server_deploy.py
Normal file
229
deploy/tencent_tat/hyapp_admin_server_deploy.py
Normal file
@ -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())
|
||||
38
deploy/tencent_tat/tests/test_hyapp_admin_server_deploy.py
Normal file
38
deploy/tencent_tat/tests/test_hyapp_admin_server_deploy.py
Normal file
@ -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()
|
||||
@ -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: <Navigate to="/config-center" replace /> },
|
||||
{ path: "config-center", element: <ConfigCenterPage /> },
|
||||
{ path: "cdn-refresh", element: <CdnRefreshPage /> },
|
||||
{ path: "*", element: <Navigate to="/config-center" replace /> },
|
||||
],
|
||||
},
|
||||
|
||||
@ -1,30 +1,12 @@
|
||||
import type { DataResourcesResponse } from "../model/types";
|
||||
|
||||
async function readJsonResponse<T>(response: Response): Promise<T> {
|
||||
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<DataResourcesResponse> {
|
||||
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<DataResourcesResponse>(response);
|
||||
return readDeployJsonResponse<DataResourcesResponse>(response);
|
||||
}
|
||||
|
||||
@ -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<T>(response: Response): Promise<T> {
|
||||
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<T>(path: string, body: Record<string, unknown>): Promise<T> {
|
||||
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<T>(path: string, body: Record<string, unknown>): Promise
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
return readJsonResponse<T>(response);
|
||||
return readDeployJsonResponse<T>(response);
|
||||
}
|
||||
|
||||
export async function getDeployApiInventory(): Promise<DeployApiInventoryResponse> {
|
||||
const response = await fetch("/deploy/api/hyapp/inventory", {
|
||||
const response = await deployFetch("/deploy/api/hyapp/inventory", {
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
return readJsonResponse<DeployApiInventoryResponse>(response);
|
||||
return readDeployJsonResponse<DeployApiInventoryResponse>(response);
|
||||
}
|
||||
|
||||
export type DeployApiPlanResponse = {
|
||||
@ -91,13 +74,13 @@ export async function getDeployApiPlan(services: string[]): Promise<DeployApiPla
|
||||
const params = new URLSearchParams({
|
||||
services: services.join(","),
|
||||
});
|
||||
const response = await fetch(`/deploy/api/hyapp/plan?${params.toString()}`, {
|
||||
const response = await deployFetch(`/deploy/api/hyapp/plan?${params.toString()}`, {
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
return readJsonResponse<DeployApiPlanResponse>(response);
|
||||
return readDeployJsonResponse<DeployApiPlanResponse>(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<DeployApiDiscoverResponse>(response);
|
||||
return readDeployJsonResponse<DeployApiDiscoverResponse>(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<DeployApiTestboxConfigResponse>(response);
|
||||
return readDeployJsonResponse<DeployApiTestboxConfigResponse>(response);
|
||||
}
|
||||
|
||||
export type DeployApiTestboxStatusResponse = {
|
||||
@ -278,13 +261,13 @@ export async function getDeployApiTestboxStatus(services: string[]): Promise<Dep
|
||||
const params = new URLSearchParams({
|
||||
services: services.join(","),
|
||||
});
|
||||
const response = await fetch(`/deploy/api/hyapp/testbox/status?${params.toString()}`, {
|
||||
const response = await deployFetch(`/deploy/api/hyapp/testbox/status?${params.toString()}`, {
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
return readJsonResponse<DeployApiTestboxStatusResponse>(response);
|
||||
return readDeployJsonResponse<DeployApiTestboxStatusResponse>(response);
|
||||
}
|
||||
|
||||
export type DeployApiTestboxCommand = {
|
||||
@ -377,11 +360,11 @@ export type DeployApiTestboxMigrationsResponse = {
|
||||
};
|
||||
|
||||
export function getDeployApiTestboxMigrations(): Promise<DeployApiTestboxMigrationsResponse> {
|
||||
return fetch("/deploy/api/hyapp/testbox/migrations", {
|
||||
return deployFetch("/deploy/api/hyapp/testbox/migrations", {
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
},
|
||||
}).then((response) => readJsonResponse<DeployApiTestboxMigrationsResponse>(response));
|
||||
}).then((response) => readDeployJsonResponse<DeployApiTestboxMigrationsResponse>(response));
|
||||
}
|
||||
|
||||
export function applyDeployApiTestboxMigrations(files: string[]): Promise<DeployApiTestboxMigrationsResponse> {
|
||||
|
||||
53
src/entities/edgeone-purge/api/purgeEdgeoneUrls.ts
Normal file
53
src/entities/edgeone-purge/api/purgeEdgeoneUrls.ts
Normal file
@ -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<EdgeonePurgeResponse> {
|
||||
const response = await deployFetch("/deploy/api/edgeone/purge-url", {
|
||||
body: JSON.stringify(input),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const payload = await readDeployJsonResponse<EdgeonePurgeResponse & { detail?: string; error?: string }>(response);
|
||||
|
||||
if (!response.ok || payload.ok === false) {
|
||||
throw new Error(payload.detail || payload.error || "EdgeOne URL refresh failed");
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
@ -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<T>(response: Response): Promise<T> {
|
||||
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<DeployInventoryResponse> {
|
||||
const response = await fetch("/deploy/api/hyapp/inventory", {
|
||||
const response = await deployFetch("/deploy/api/hyapp/inventory", {
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const payload = await readJsonResponse<DeployInventoryResponse>(response);
|
||||
const payload = await readDeployJsonResponse<DeployInventoryResponse>(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<DeployDiscoverResponse>(response);
|
||||
const payload = await readDeployJsonResponse<DeployDiscoverResponse>(response);
|
||||
|
||||
if (payload.ok === false) {
|
||||
throw new Error("Deploy API discover is unavailable");
|
||||
|
||||
@ -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<CreateReleaseResult> {
|
||||
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<DeployApiResult>(response);
|
||||
|
||||
if (!response.ok || result.ok === false) {
|
||||
throw new Error(result.error || `部署接口返回 HTTP ${response.status}`);
|
||||
|
||||
@ -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 || "<tag>"}`,
|
||||
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 || "<tag>"}`,
|
||||
isProd ? " --yes" : " --dry-run",
|
||||
].join("\n");
|
||||
const targetSummary = service.unit && service.container ? `${service.unit} / ${service.container}` : service.name;
|
||||
|
||||
async function handleSubmit() {
|
||||
|
||||
236
src/pages/cdn-refresh/CdnRefreshPage.module.css
Normal file
236
src/pages/cdn-refresh/CdnRefreshPage.module.css
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
216
src/pages/cdn-refresh/CdnRefreshPage.tsx
Normal file
216
src/pages/cdn-refresh/CdnRefreshPage.tsx
Normal file
@ -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<string, string> = {
|
||||
canceled: "已取消",
|
||||
failed: "失败",
|
||||
processing: "处理中",
|
||||
submitted: "已提交",
|
||||
success: "成功",
|
||||
timeout: "超时",
|
||||
};
|
||||
const normalized = String(status || "").toLowerCase();
|
||||
return labels[normalized] ?? "未知";
|
||||
}
|
||||
|
||||
function ResultPanel({ result }: { result: EdgeonePurgeResponse | undefined }) {
|
||||
if (!result) {
|
||||
return <div className={styles.empty}>暂无刷新任务</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.resultBody}>
|
||||
<div className={styles.summaryGrid}>
|
||||
<div className={styles.summaryItem}>
|
||||
<small>Job ID</small>
|
||||
<strong>{result.jobId || "-"}</strong>
|
||||
</div>
|
||||
<div className={styles.summaryItem}>
|
||||
<small>状态</small>
|
||||
<strong>{formatStatus(result.status)}</strong>
|
||||
</div>
|
||||
<div className={styles.summaryItem}>
|
||||
<small>站点</small>
|
||||
<strong>{result.zoneName || result.zoneId || "-"}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.taskList}>
|
||||
{(result.tasks ?? []).map((task) => (
|
||||
<div className={styles.taskItem} key={`${task.JobId}-${task.Target}`}>
|
||||
<strong>{task.Target || "-"}</strong>
|
||||
<div className={styles.taskMeta}>
|
||||
<StatusBadge status={statusBadge(task.Status)} />
|
||||
<span>{task.Type || "purge_url"}</span>
|
||||
<span>{task.CreateTime || "-"}</span>
|
||||
<span>{task.UpdateTime || "-"}</span>
|
||||
</div>
|
||||
{task.FailMessage ? <span>{task.FailMessage}</span> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.urlList}>
|
||||
{(result.targets ?? []).map((target) => (
|
||||
<div className={styles.urlItem} key={target}>
|
||||
<strong>{target}</strong>
|
||||
<span>{result.requestId || "-"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (submitDisabled) {
|
||||
return;
|
||||
}
|
||||
purgeMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={clsx("page", styles.root)}>
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">CDN 刷新</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{purgeMutation.error ? (
|
||||
<div className={styles.banner} role="alert">
|
||||
<AlertTriangle size={16} />
|
||||
<span>{purgeMutation.error instanceof Error ? purgeMutation.error.message : "刷新失败"}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{purgeMutation.isSuccess ? (
|
||||
<div className={clsx(styles.banner, styles.success)} role="status">
|
||||
<CheckCircle2 size={16} />
|
||||
<span>URL 刷新已完成</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={styles.grid}>
|
||||
<form className={styles.panel} onSubmit={handleSubmit}>
|
||||
<div className={styles.panelHeader}>
|
||||
<div className={styles.panelTitle}>
|
||||
<CloudLightning size={18} />
|
||||
<div>
|
||||
<strong>EdgeOne</strong>
|
||||
<span>h5.global-interaction.com</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.form}>
|
||||
<label className={styles.field}>
|
||||
<span>刷新类型</span>
|
||||
<select className={styles.select} value="url" disabled>
|
||||
<option value="url">URL 刷新</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>URL</span>
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
onChange={(event) => setUrlText(event.target.value)}
|
||||
spellCheck={false}
|
||||
value={urlText}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className={styles.confirm}>
|
||||
<input checked={confirmed} onChange={(event) => setConfirmed(event.target.checked)} type="checkbox" />
|
||||
确认刷新线上缓存
|
||||
</label>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
disabled={purgeMutation.isPending}
|
||||
onClick={() => {
|
||||
setUrlText(defaultUrlText);
|
||||
setConfirmed(false);
|
||||
purgeMutation.reset();
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
<RotateCcw size={15} />
|
||||
重置
|
||||
</Button>
|
||||
<Button disabled={submitDisabled} isLoading={purgeMutation.isPending} type="submit" variant="primary">
|
||||
<SendHorizonal size={15} />
|
||||
提交刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section className={styles.panel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<div className={styles.panelTitle}>
|
||||
<CheckCircle2 size={18} />
|
||||
<div>
|
||||
<strong>任务结果</strong>
|
||||
<span>{urls.length} 条 URL</span>
|
||||
</div>
|
||||
</div>
|
||||
{purgeMutation.data?.status ? <StatusBadge status={statusBadge(purgeMutation.data.status)} /> : null}
|
||||
</div>
|
||||
<ResultPanel result={purgeMutation.data} />
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
1
src/pages/cdn-refresh/index.ts
Normal file
1
src/pages/cdn-refresh/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { CdnRefreshPage } from "./CdnRefreshPage";
|
||||
58
src/shared/api/authRedirect.test.ts
Normal file
58
src/shared/api/authRedirect.test.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildLoginRedirectUrl, getCurrentReturnPath, isCurrentLoginPage, isLoginUrl, shouldRedirectToLogin } from "./authRedirect";
|
||||
|
||||
const location = {
|
||||
hash: "#runtime",
|
||||
origin: "https://deploy.example.com",
|
||||
pathname: "/config-center",
|
||||
search: "?service=gateway-service&remote=1",
|
||||
} as Location;
|
||||
|
||||
function responseWithUrl(response: Response, url: string) {
|
||||
Object.defineProperty(response, "url", {
|
||||
value: url,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
describe("authRedirect", () => {
|
||||
it("keeps pathname, query, and hash in the login redirect parameter", () => {
|
||||
expect(getCurrentReturnPath(location)).toBe("/config-center?service=gateway-service&remote=1#runtime");
|
||||
expect(buildLoginRedirectUrl({ location })).toBe(
|
||||
"/login?redirect=%2Fconfig-center%3Fservice%3Dgateway-service%26remote%3D1%23runtime",
|
||||
);
|
||||
});
|
||||
|
||||
it("supports external login targets and custom redirect parameter names", () => {
|
||||
expect(
|
||||
buildLoginRedirectUrl({
|
||||
location,
|
||||
loginUrl: "https://auth.example.com/login?client=deploy-platform",
|
||||
redirectParam: "return_to",
|
||||
}),
|
||||
).toBe(
|
||||
"https://auth.example.com/login?client=deploy-platform&return_to=%2Fconfig-center%3Fservice%3Dgateway-service%26remote%3D1%23runtime",
|
||||
);
|
||||
});
|
||||
|
||||
it("detects current login page without treating other pages as login", () => {
|
||||
expect(isCurrentLoginPage("/login", { ...location, pathname: "/login", search: "", hash: "" })).toBe(true);
|
||||
expect(isCurrentLoginPage("/login", location)).toBe(false);
|
||||
});
|
||||
|
||||
it("detects expired API responses and login HTML redirects", () => {
|
||||
expect(shouldRedirectToLogin(new Response("", { status: 401 }), location)).toBe(true);
|
||||
|
||||
const loginHtml = responseWithUrl(
|
||||
new Response("<html></html>", {
|
||||
headers: {
|
||||
"content-type": "text/html",
|
||||
},
|
||||
}),
|
||||
"https://deploy.example.com/deploy/api/hyapp/resources?remote=1",
|
||||
);
|
||||
|
||||
expect(shouldRedirectToLogin(loginHtml, location)).toBe(true);
|
||||
expect(isLoginUrl("https://deploy.example.com/login?redirect=%2Fconfig-center", "/login", location)).toBe(true);
|
||||
});
|
||||
});
|
||||
92
src/shared/api/authRedirect.ts
Normal file
92
src/shared/api/authRedirect.ts
Normal file
@ -0,0 +1,92 @@
|
||||
const DEFAULT_LOGIN_URL = "/login";
|
||||
const DEFAULT_REDIRECT_PARAM = "redirect";
|
||||
const AUTH_EXPIRED_STATUS_CODES = new Set([401, 419, 440]);
|
||||
|
||||
type LocationParts = Pick<Location, "hash" | "origin" | "pathname" | "search">;
|
||||
|
||||
function getLoginUrl() {
|
||||
return import.meta.env.VITE_LOGIN_URL || DEFAULT_LOGIN_URL;
|
||||
}
|
||||
|
||||
function getRedirectParam() {
|
||||
return import.meta.env.VITE_LOGIN_REDIRECT_PARAM || DEFAULT_REDIRECT_PARAM;
|
||||
}
|
||||
|
||||
function getBrowserLocation(): Location | null {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return window.location;
|
||||
}
|
||||
|
||||
export function getCurrentReturnPath(location: LocationParts = window.location) {
|
||||
return `${location.pathname}${location.search}${location.hash}`;
|
||||
}
|
||||
|
||||
export function buildLoginRedirectUrl(options: {
|
||||
location?: LocationParts;
|
||||
loginUrl?: string;
|
||||
redirectParam?: string;
|
||||
returnPath?: string;
|
||||
} = {}) {
|
||||
const location = options.location ?? window.location;
|
||||
const loginUrl = new URL(options.loginUrl ?? getLoginUrl(), location.origin);
|
||||
const redirectParam = options.redirectParam ?? getRedirectParam();
|
||||
const returnPath = options.returnPath ?? getCurrentReturnPath(location);
|
||||
|
||||
loginUrl.searchParams.set(redirectParam, returnPath);
|
||||
|
||||
if (loginUrl.origin === location.origin) {
|
||||
return `${loginUrl.pathname}${loginUrl.search}${loginUrl.hash}`;
|
||||
}
|
||||
|
||||
return loginUrl.toString();
|
||||
}
|
||||
|
||||
export function isCurrentLoginPage(loginUrl = getLoginUrl(), location: LocationParts = window.location) {
|
||||
const login = new URL(loginUrl, location.origin);
|
||||
|
||||
return login.origin === location.origin && login.pathname === location.pathname;
|
||||
}
|
||||
|
||||
export function isLoginUrl(url: string, loginUrl = getLoginUrl(), location: LocationParts = window.location) {
|
||||
if (!url) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const target = new URL(url, location.origin);
|
||||
const login = new URL(loginUrl, location.origin);
|
||||
|
||||
return (target.origin === login.origin && target.pathname === login.pathname) || /\/login\/?$/.test(target.pathname);
|
||||
}
|
||||
|
||||
export function shouldRedirectToLogin(response: Response, location: LocationParts = window.location) {
|
||||
if (AUTH_EXPIRED_STATUS_CODES.has(response.status)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
|
||||
if (!contentType.includes("text/html")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (response.redirected || isLoginUrl(response.url, getLoginUrl(), location)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const responseUrl = new URL(response.url || location.pathname, location.origin);
|
||||
|
||||
return responseUrl.pathname.startsWith("/deploy/api/");
|
||||
}
|
||||
|
||||
export function redirectToLoginWithCurrentPage() {
|
||||
const location = getBrowserLocation();
|
||||
|
||||
if (!location || isCurrentLoginPage(getLoginUrl(), location)) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.assign(buildLoginRedirectUrl({ location }));
|
||||
}
|
||||
47
src/shared/api/deployFetch.ts
Normal file
47
src/shared/api/deployFetch.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { redirectToLoginWithCurrentPage, shouldRedirectToLogin } from "./authRedirect";
|
||||
|
||||
export class AuthRedirectError extends Error {
|
||||
constructor() {
|
||||
super("登录已过期,请重新登录");
|
||||
this.name = "AuthRedirectError";
|
||||
}
|
||||
}
|
||||
|
||||
function redirectIfSessionExpired(response: Response) {
|
||||
if (!shouldRedirectToLogin(response)) {
|
||||
return;
|
||||
}
|
||||
|
||||
redirectToLoginWithCurrentPage();
|
||||
throw new AuthRedirectError();
|
||||
}
|
||||
|
||||
export async function deployFetch(input: RequestInfo | URL, init?: RequestInit) {
|
||||
const response = await fetch(input, init);
|
||||
|
||||
redirectIfSessionExpired(response);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function readDeployJsonResponse<T>(response: Response): Promise<T> {
|
||||
redirectIfSessionExpired(response);
|
||||
|
||||
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;
|
||||
}
|
||||
5
src/vite-env.d.ts
vendored
5
src/vite-env.d.ts
vendored
@ -1 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_LOGIN_REDIRECT_PARAM?: string;
|
||||
readonly VITE_LOGIN_URL?: string;
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { ReactNode } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { Gauge, Settings2 } from "lucide-react";
|
||||
import { CloudLightning, Gauge, Settings2 } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import styles from "./AppShell.module.css";
|
||||
|
||||
@ -10,6 +10,7 @@ type AppShellProps = {
|
||||
|
||||
const navItems = [
|
||||
{ icon: Settings2, label: "配置中心", to: "/config-center" },
|
||||
{ icon: CloudLightning, label: "CDN 刷新", to: "/cdn-refresh" },
|
||||
] as const;
|
||||
|
||||
export function AppShell({ children }: AppShellProps) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user