Update deploy platform release tooling

This commit is contained in:
zhx 2026-06-09 11:12:26 +08:00
parent 3d67678c20
commit 3a876d5850
26 changed files with 1433 additions and 97 deletions

View File

@ -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`

View File

@ -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

View File

@ -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`

View File

@ -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");

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 是腾讯云托管资源。

View 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:]))

View 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())

View 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()

View File

@ -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 /> },
],
},

View File

@ -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);
}

View File

@ -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> {

View 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;
}

View File

@ -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");

View File

@ -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}`);

View File

@ -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() {

View 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;
}
}

View 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>
);
}

View File

@ -0,0 +1 @@
export { CdnRefreshPage } from "./CdnRefreshPage";

View 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);
});
});

View 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 }));
}

View 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
View File

@ -1 +1,6 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_LOGIN_REDIRECT_PARAM?: string;
readonly VITE_LOGIN_URL?: string;
}

View File

@ -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) {