hyapp-deploly/deploy/tencent_tat/edgeone_purge.py
2026-07-08 22:33:12 +08:00

244 lines
8.4 KiB
Python

#!/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,vivagames.global-interaction.com,asset.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:]))