2026-04-27 18:42:56 +08:00

957 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import ipaddress
import json
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from urllib.parse import parse_qs, urlparse
from core.config import APP_BASE_PATH, APP_BIND, APP_PORT, INTERNAL_PROXY_SHARED_SECRET, STATIC_DIR, utc_now
from .dashboard import build_monitor_payload
from core.http_paths import join_base_path, strip_base_path
from .mongo_admin import (
aggregate_mongo_documents_payload,
create_mongo_collection_payload,
create_mongo_index_payload,
delete_mongo_documents_payload,
distinct_mongo_values_payload,
drop_mongo_collection_payload,
drop_mongo_database_payload,
drop_mongo_index_payload,
drop_mongo_secondary_indexes_payload,
get_mongo_admin_overview_payload,
get_mongo_collection_detail_payload,
insert_mongo_document_payload,
query_mongo_documents_payload,
rename_mongo_collection_payload,
replace_mongo_document_payload,
update_mongo_documents_payload,
)
from .mysql_admin import (
add_mysql_column_payload,
add_mysql_index_payload,
delete_mysql_row_payload,
drop_mysql_column_payload,
drop_mysql_index_payload,
execute_mysql_sql_payload,
get_mysql_databases_payload,
get_mysql_table_schema_payload,
get_mysql_tables_payload,
insert_mysql_row_payload,
query_mysql_table_rows_payload,
update_mysql_row_payload,
)
from .mysql_metrics import get_mysql_overview_payload
from .nacos import (
get_nacos_config_list_payload,
get_nacos_config_payload,
save_nacos_config_payload,
validate_nacos_config_payload,
)
from .runtime_config import get_go_config_payload, save_go_config_payload, validate_go_config_payload
from .service_logs import get_service_log_export_payload, get_service_log_preview_payload
from .service_release import build_update_targets_payload
from .service_update_jobs import get_update_job_log_text, get_update_job_payload, start_update_job
from .service_ops import build_restart_targets_payload, rolling_restart_payload
from .tencent_cdn import get_cdn_overview_payload, submit_cdn_purge_payload
from .chatapp_cron_deploy import (
build_chatapp_cron_overview_payload,
get_chatapp_cron_deploy_job_payload,
get_chatapp_cron_deploy_log_text,
start_chatapp_cron_deploy_job,
)
def response_json(payload: dict[str, Any] | list[Any]) -> bytes:
# 统一把 JSON 编码成 UTF-8 文本。
return (json.dumps(payload, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
class MonitorHandler(BaseHTTPRequestHandler):
# 单独标出这是 Yumi 内部实例,排查反代链路更直接。
server_version = "HyAppMonitorYumi/2.0"
def log_message(self, format: str, *args: Any) -> None: # noqa: A003
# 内部轮询很多,关闭默认访问日志。
return
def do_GET(self) -> None: # noqa: N802
# 只接受来自统一网关的代理请求。
if not self.require_internal_proxy(send_body=True):
return
self.dispatch(send_body=True)
def do_HEAD(self) -> None: # noqa: N802
# HEAD 请求同样只允许内部网关进入。
if not self.require_internal_proxy(send_body=False):
return
self.dispatch(send_body=False)
def do_POST(self) -> None: # noqa: N802
# POST 接口也必须经过统一入口。
if not self.require_internal_proxy(send_body=True):
return
self.dispatch_post()
def require_internal_proxy(self, send_body: bool) -> bool:
# 内部实例除了共享密钥,还要求请求确实来自本机回环地址。
# 这样就算运维误把端口绑到了 0.0.0.0,外部流量也不能直接绕过网关。
actual_secret = str(self.headers.get("X-HY-Internal-Proxy-Secret") or "").strip()
if self.is_loopback_client() and actual_secret == INTERNAL_PROXY_SHARED_SECRET:
return True
self.send_error_payload(
HTTPStatus.FORBIDDEN,
"internal proxy authorization required",
send_body=send_body,
)
return False
def is_loopback_client(self) -> bool:
# BaseHTTPRequestHandler 会把来源地址放在 client_address 第一个槽位。
remote_host = str((self.client_address or ("", 0))[0]).strip()
if not remote_host:
return False
try:
# IPv6 链路本地格式可能带 zone index这里先切掉再判断。
return ipaddress.ip_address(remote_host.split("%", 1)[0]).is_loopback
except ValueError:
return False
def dispatch(self, send_body: bool) -> None:
# 统一先拆 URL后面都只操作 path 和 query。
parsed = urlparse(self.path)
query = parse_qs(parsed.query, keep_blank_values=True)
local_path = strip_base_path(APP_BASE_PATH, parsed.path)
# 不属于当前应用 base path 的请求直接 404。
if local_path is None:
return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=send_body)
# /yumi 必须补成 /yumi/,这样相对静态资源路径才稳定。
if APP_BASE_PATH and parsed.path == APP_BASE_PATH:
return self.redirect(join_base_path(APP_BASE_PATH, "/"), send_body=send_body)
# Yumi 首页直接返回当前监控面板。
if local_path in {"/", "/index.html"}:
return self.serve_static("index.html", "text/html; charset=utf-8", send_body=send_body)
# 前端静态资源统一挂在应用前缀下。
if local_path == "/app.css":
return self.serve_static("app.css", "text/css; charset=utf-8", send_body=send_body)
if local_path == "/app.js":
return self.serve_static("app.js", "application/javascript; charset=utf-8", send_body=send_body)
if local_path == "/vue.js":
return self.serve_static("vue.js", "application/javascript; charset=utf-8", send_body=send_body)
# 内部实例自身的健康检查。
if local_path == "/api/health":
return self.send_payload(
HTTPStatus.OK,
response_json({"status": "ok", "time": utc_now(), "app": "yumi"}),
"application/json; charset=utf-8",
send_body=send_body,
)
# 主监控接口。
if local_path == "/api/monitor/services":
return self.send_payload(
HTTPStatus.OK,
response_json(build_monitor_payload()),
"application/json; charset=utf-8",
send_body=send_body,
)
# 返回当前 namespace 下全部 Nacos 配置元数据。
if local_path == "/api/nacos/configs":
try:
payload = get_nacos_config_list_payload()
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
response_json(payload),
"application/json; charset=utf-8",
send_body=send_body,
)
# 返回单个配置的原始文本内容。
if local_path == "/api/nacos/config":
group_name = self.query_value(query, "group")
data_id = self.query_value(query, "dataId")
if not group_name or not data_id:
return self.send_error_payload(
HTTPStatus.BAD_REQUEST,
"group and dataId are required",
send_body=send_body,
)
try:
payload = get_nacos_config_payload(group_name, data_id)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
response_json(payload),
"application/json; charset=utf-8",
send_body=send_body,
)
# 返回当前线上 golang 的运行配置。
if local_path == "/api/runtime/golang-config":
try:
payload = get_go_config_payload()
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
response_json(payload),
"application/json; charset=utf-8",
send_body=send_body,
)
# 返回 MySQL 概览指标。
if local_path == "/api/mysql/overview":
try:
payload = get_mysql_overview_payload()
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
response_json(payload),
"application/json; charset=utf-8",
send_body=send_body,
)
# 返回 MySQL 库列表。
if local_path == "/api/mysql/databases":
try:
payload = get_mysql_databases_payload()
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
response_json(payload),
"application/json; charset=utf-8",
send_body=send_body,
)
# 返回 CDN 刷新工具页概览、配额和最近记录。
if local_path == "/api/cdn/overview":
try:
payload = get_cdn_overview_payload()
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
response_json(payload),
"application/json; charset=utf-8",
send_body=send_body,
)
# 返回 chatapp-cron 部署配置和最近版本。
if local_path == "/api/chatapp-cron/overview":
try:
payload = build_chatapp_cron_overview_payload()
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
response_json(payload),
"application/json; charset=utf-8",
send_body=send_body,
)
# 返回某个库下的表列表。
if local_path == "/api/mysql/tables":
database_name = self.query_value(query, "database")
if not database_name:
return self.send_error_payload(
HTTPStatus.BAD_REQUEST,
"database is required",
send_body=send_body,
)
try:
payload = get_mysql_tables_payload(database_name)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
response_json(payload),
"application/json; charset=utf-8",
send_body=send_body,
)
# 返回 MySQL 表结构、索引和建表语句。
if local_path == "/api/mysql/table-schema":
database_name = self.query_value(query, "database")
table_name = self.query_value(query, "table")
if not database_name or not table_name:
return self.send_error_payload(
HTTPStatus.BAD_REQUEST,
"database and table are required",
send_body=send_body,
)
try:
payload = get_mysql_table_schema_payload(database_name, table_name)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
response_json(payload),
"application/json; charset=utf-8",
send_body=send_body,
)
# 返回 Mongo 管理页的数据库与集合树。
if local_path == "/api/mongo-admin/overview":
try:
payload = get_mongo_admin_overview_payload()
except ValueError as exc:
return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=send_body)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
response_json(payload),
"application/json; charset=utf-8",
send_body=send_body,
)
# 返回当前集合的 stats 和索引列表。
if local_path == "/api/mongo-admin/collection-detail":
database_name = self.query_value(query, "database")
collection_name = self.query_value(query, "collection")
if not database_name or not collection_name:
return self.send_error_payload(
HTTPStatus.BAD_REQUEST,
"database and collection are required",
send_body=send_body,
)
try:
payload = get_mongo_collection_detail_payload(database_name, collection_name)
except ValueError as exc:
return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=send_body)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
response_json(payload),
"application/json; charset=utf-8",
send_body=send_body,
)
# 返回可滚动重启的业务服务。
if local_path == "/api/ops/restart-targets":
try:
payload = build_restart_targets_payload()
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
response_json(payload),
"application/json; charset=utf-8",
send_body=send_body,
)
# 返回可构建并发布的业务服务。
if local_path == "/api/ops/update-targets":
try:
payload = build_update_targets_payload()
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
response_json(payload),
"application/json; charset=utf-8",
send_body=send_body,
)
# 返回当前服务更新任务的阶段和日志。
if local_path == "/api/ops/update-status":
operation_id = self.query_value(query, "id")
if not operation_id:
return self.send_error_payload(
HTTPStatus.BAD_REQUEST,
"id is required",
send_body=send_body,
)
try:
payload = get_update_job_payload(operation_id)
except KeyError:
return self.send_error_payload(HTTPStatus.NOT_FOUND, "update operation not found", send_body=send_body)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
response_json(payload),
"application/json; charset=utf-8",
send_body=send_body,
)
# 返回单个服务更新任务的完整文本日志。
if local_path == "/api/ops/update-log":
operation_id = self.query_value(query, "id")
if not operation_id:
return self.send_error_payload(
HTTPStatus.BAD_REQUEST,
"id is required",
send_body=send_body,
)
try:
content = get_update_job_log_text(operation_id)
except KeyError:
return self.send_error_payload(HTTPStatus.NOT_FOUND, "update log not found", send_body=send_body)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
content.encode("utf-8"),
"text/plain; charset=utf-8",
send_body=send_body,
)
# 返回 chatapp-cron 部署任务状态。
if local_path == "/api/chatapp-cron/deploy-status":
operation_id = self.query_value(query, "id")
if not operation_id:
return self.send_error_payload(
HTTPStatus.BAD_REQUEST,
"id is required",
send_body=send_body,
)
try:
payload = get_chatapp_cron_deploy_job_payload(operation_id)
except KeyError:
return self.send_error_payload(
HTTPStatus.NOT_FOUND,
"chatapp-cron deploy operation not found",
send_body=send_body,
)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
response_json(payload),
"application/json; charset=utf-8",
send_body=send_body,
)
# 返回 chatapp-cron 部署任务完整日志。
if local_path == "/api/chatapp-cron/deploy-log":
operation_id = self.query_value(query, "id")
if not operation_id:
return self.send_error_payload(
HTTPStatus.BAD_REQUEST,
"id is required",
send_body=send_body,
)
try:
content = get_chatapp_cron_deploy_log_text(operation_id)
except KeyError:
return self.send_error_payload(HTTPStatus.NOT_FOUND, "chatapp-cron deploy log not found", send_body=send_body)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
content.encode("utf-8"),
"text/plain; charset=utf-8",
send_body=send_body,
)
# 返回单个服务实例最近 1000 条容器日志。
if local_path == "/api/ops/service-log":
service_name = self.query_value(query, "service")
host_name = self.query_value(query, "host")
if not service_name or not host_name:
return self.send_error_payload(
HTTPStatus.BAD_REQUEST,
"service and host are required",
send_body=send_body,
)
try:
payload = get_service_log_preview_payload(service_name, host_name)
except KeyError:
return self.send_error_payload(HTTPStatus.NOT_FOUND, "service log target not found", send_body=send_body)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
response_json(payload),
"application/json; charset=utf-8",
send_body=send_body,
)
# 导出单个服务实例指定条数的容器日志。
if local_path == "/api/ops/service-log/export":
service_name = self.query_value(query, "service")
host_name = self.query_value(query, "host")
lines = self.query_value(query, "lines")
if not service_name or not host_name:
return self.send_error_payload(
HTTPStatus.BAD_REQUEST,
"service and host are required",
send_body=send_body,
)
try:
meta, content = get_service_log_export_payload(service_name, host_name, lines)
except KeyError:
return self.send_error_payload(HTTPStatus.NOT_FOUND, "service log target not found", send_body=send_body)
except ValueError as exc:
return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=send_body)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
filename = f"{meta['service']}-{meta['host']}-{meta['lines']}.log"
return self.send_payload(
HTTPStatus.OK,
content.encode("utf-8"),
"text/plain; charset=utf-8",
send_body=send_body,
extra_headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# favicon 直接返回空。
if local_path == "/favicon.ico":
return self.send_payload(HTTPStatus.NO_CONTENT, b"", "image/x-icon", send_body=send_body)
# 其他路径统一 404。
return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=send_body)
def dispatch_post(self) -> None:
# 只解析 path 段POST 不处理复杂 query。
parsed = urlparse(self.path)
local_path = strip_base_path(APP_BASE_PATH, parsed.path)
if local_path is None:
return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=True)
try:
payload = self.read_json_body()
except ValueError as exc:
return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=True)
if local_path == "/api/nacos/config/validate":
return self.handle_nacos_validate(payload)
if local_path == "/api/nacos/config":
return self.handle_nacos_save(payload)
if local_path == "/api/runtime/golang-config/validate":
return self.handle_go_validate(payload)
if local_path == "/api/runtime/golang-config":
return self.handle_go_save(payload)
if local_path == "/api/mysql/rows/query":
return self.handle_mysql_action(query_mysql_table_rows_payload, payload)
if local_path == "/api/mysql/rows/insert":
return self.handle_mysql_action(insert_mysql_row_payload, payload, status=HTTPStatus.CREATED)
if local_path == "/api/mysql/rows/update":
return self.handle_mysql_action(update_mysql_row_payload, payload)
if local_path == "/api/mysql/rows/delete":
return self.handle_mysql_action(delete_mysql_row_payload, payload)
if local_path == "/api/mysql/columns/add":
return self.handle_mysql_action(add_mysql_column_payload, payload)
if local_path == "/api/mysql/columns/drop":
return self.handle_mysql_action(drop_mysql_column_payload, payload)
if local_path == "/api/mysql/indexes/add":
return self.handle_mysql_action(add_mysql_index_payload, payload, status=HTTPStatus.CREATED)
if local_path == "/api/mysql/indexes/drop":
return self.handle_mysql_action(drop_mysql_index_payload, payload)
if local_path == "/api/mysql/sql/execute":
return self.handle_mysql_action(execute_mysql_sql_payload, payload)
if local_path == "/api/cdn/purge":
return self.handle_mysql_action(submit_cdn_purge_payload, payload, status=HTTPStatus.ACCEPTED)
if local_path == "/api/chatapp-cron/deploy":
return self.handle_chatapp_cron_deploy(payload)
if local_path == "/api/ops/rolling-restart":
return self.handle_rolling_restart(payload)
if local_path == "/api/ops/update-services":
return self.handle_update_services(payload)
if local_path == "/api/mongo-admin/query":
return self.handle_mongo_admin_action(query_mongo_documents_payload, payload)
if local_path == "/api/mongo-admin/documents/insert":
return self.handle_mongo_admin_action(insert_mongo_document_payload, payload, status=HTTPStatus.CREATED)
if local_path == "/api/mongo-admin/documents/replace":
return self.handle_mongo_admin_action(replace_mongo_document_payload, payload)
if local_path == "/api/mongo-admin/documents/update":
return self.handle_mongo_admin_action(update_mongo_documents_payload, payload)
if local_path == "/api/mongo-admin/documents/delete":
return self.handle_mongo_admin_action(delete_mongo_documents_payload, payload)
if local_path == "/api/mongo-admin/aggregate":
return self.handle_mongo_admin_action(aggregate_mongo_documents_payload, payload)
if local_path == "/api/mongo-admin/distinct":
return self.handle_mongo_admin_action(distinct_mongo_values_payload, payload)
if local_path == "/api/mongo-admin/indexes/create":
return self.handle_mongo_admin_action(create_mongo_index_payload, payload, status=HTTPStatus.CREATED)
if local_path == "/api/mongo-admin/indexes/drop":
return self.handle_mongo_admin_action(drop_mongo_index_payload, payload)
if local_path == "/api/mongo-admin/indexes/drop-secondary":
return self.handle_mongo_admin_action(drop_mongo_secondary_indexes_payload, payload)
if local_path == "/api/mongo-admin/collections/create":
return self.handle_mongo_admin_action(create_mongo_collection_payload, payload, status=HTTPStatus.CREATED)
if local_path == "/api/mongo-admin/collections/rename":
return self.handle_mongo_admin_action(rename_mongo_collection_payload, payload)
if local_path == "/api/mongo-admin/collections/drop":
return self.handle_mongo_admin_action(drop_mongo_collection_payload, payload)
if local_path == "/api/mongo-admin/databases/drop":
return self.handle_mongo_admin_action(drop_mongo_database_payload, payload)
return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=True)
def redirect(self, location: str, *, send_body: bool) -> None:
# 页面跳转只依赖 Location响应体保持空即可。
self.send_payload(
HTTPStatus.FOUND,
b"",
"text/plain; charset=utf-8",
send_body=send_body,
extra_headers={"Location": location},
)
def handle_nacos_validate(self, payload: dict[str, Any]) -> None:
group_name = str(payload.get("group") or "").strip()
data_id = str(payload.get("dataId") or "").strip()
content = payload.get("content")
config_type = str(payload.get("type") or "").strip() or None
if not isinstance(content, str):
return self.send_error_payload(HTTPStatus.BAD_REQUEST, "content must be a string", send_body=True)
try:
result = validate_nacos_config_payload(
content,
group_name=group_name,
data_id=data_id,
config_type=config_type,
)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=True)
return self.send_payload(
HTTPStatus.OK,
response_json(result),
"application/json; charset=utf-8",
send_body=True,
)
def handle_nacos_save(self, payload: dict[str, Any]) -> None:
group_name = str(payload.get("group") or "").strip()
data_id = str(payload.get("dataId") or "").strip()
content = payload.get("content")
config_type = str(payload.get("type") or "").strip() or None
# group / dataId 是保存现有配置的唯一键。
if not group_name or not data_id:
return self.send_error_payload(HTTPStatus.BAD_REQUEST, "group and dataId are required", send_body=True)
if not isinstance(content, str):
return self.send_error_payload(HTTPStatus.BAD_REQUEST, "content must be a string", send_body=True)
try:
result = save_nacos_config_payload(group_name, data_id, content, config_type=config_type)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=True)
return self.send_payload(
HTTPStatus.OK,
response_json(result),
"application/json; charset=utf-8",
send_body=True,
)
def handle_go_validate(self, payload: dict[str, Any]) -> None:
content = payload.get("content")
if not isinstance(content, str):
return self.send_error_payload(HTTPStatus.BAD_REQUEST, "content must be a string", send_body=True)
try:
result = validate_go_config_payload(content)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=True)
return self.send_payload(
HTTPStatus.OK,
response_json(result),
"application/json; charset=utf-8",
send_body=True,
)
def handle_go_save(self, payload: dict[str, Any]) -> None:
content = payload.get("content")
if not isinstance(content, str):
return self.send_error_payload(HTTPStatus.BAD_REQUEST, "content must be a string", send_body=True)
try:
result = save_go_config_payload(content)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=True)
return self.send_payload(
HTTPStatus.OK,
response_json(result),
"application/json; charset=utf-8",
send_body=True,
)
def handle_mysql_action(
self,
action: Any,
payload: dict[str, Any],
*,
status: HTTPStatus = HTTPStatus.OK,
) -> None:
try:
result = action(payload)
except ValueError as exc:
return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=True)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=True)
return self.send_payload(
status,
response_json(result),
"application/json; charset=utf-8",
send_body=True,
)
def handle_rolling_restart(self, payload: dict[str, Any]) -> None:
services = payload.get("services")
if not isinstance(services, list):
return self.send_error_payload(HTTPStatus.BAD_REQUEST, "services must be a list", send_body=True)
try:
result = rolling_restart_payload([str(item or "").strip() for item in services])
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=True)
return self.send_payload(
HTTPStatus.OK,
response_json(result),
"application/json; charset=utf-8",
send_body=True,
)
def handle_update_services(self, payload: dict[str, Any]) -> None:
services = payload.get("services")
if not isinstance(services, list):
return self.send_error_payload(HTTPStatus.BAD_REQUEST, "services must be a list", send_body=True)
java_git_ref = str(payload.get("javaGitRef") or "").strip()
go_git_ref = str(payload.get("goGitRef") or "").strip()
admin_git_ref = str(payload.get("adminGitRef") or "").strip()
image_tag = str(payload.get("imageTag") or "").strip()
skip_maven_build = bool(payload.get("skipMavenBuild"))
try:
result = start_update_job(
services,
java_git_ref=java_git_ref,
go_git_ref=go_git_ref,
admin_git_ref=admin_git_ref,
image_tag=image_tag,
skip_maven_build=skip_maven_build,
)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=True)
return self.send_payload(
HTTPStatus.ACCEPTED,
response_json(result),
"application/json; charset=utf-8",
send_body=True,
)
def handle_chatapp_cron_deploy(self, payload: dict[str, Any]) -> None:
git_ref = str(payload.get("gitRef") or "").strip()
try:
result = start_chatapp_cron_deploy_job(git_ref)
except ValueError as exc:
return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=True)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=True)
return self.send_payload(
HTTPStatus.ACCEPTED,
response_json(result),
"application/json; charset=utf-8",
send_body=True,
)
def handle_mongo_admin_action(
self,
builder: Any,
payload: dict[str, Any],
*,
status: HTTPStatus = HTTPStatus.OK,
) -> None:
try:
# Mongo 管理页所有写操作和查询操作都统一走这里收敛异常。
result = builder(payload)
except ValueError as exc:
return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=True)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=True)
return self.send_payload(
status,
response_json(result),
"application/json; charset=utf-8",
send_body=True,
)
def query_value(self, query: dict[str, list[str]], key: str) -> str:
# 统一读取单值 query 参数。
return str((query.get(key) or [""])[0]).strip()
def read_json_body(self) -> dict[str, Any]:
# Content-Length 缺失时按空请求体处理。
raw_length = self.headers.get("Content-Length", "0").strip() or "0"
try:
content_length = int(raw_length)
except ValueError as exc:
raise ValueError("invalid Content-Length") from exc
# 空请求体不符合当前 JSON 接口要求。
if content_length <= 0:
raise ValueError("request body is required")
# 读取原始请求体。
body = self.rfile.read(content_length).decode("utf-8", errors="replace")
try:
payload = json.loads(body)
except json.JSONDecodeError as exc:
raise ValueError("request body must be valid json") from exc
# 顶层必须是对象。
if not isinstance(payload, dict):
raise ValueError("request body must be a json object")
return payload
def serve_static(self, name: str, content_type: str, send_body: bool) -> None:
# 计算静态文件路径。
path = STATIC_DIR / name
# 文件不存在时返回 JSON 错误。
if not path.exists():
return self.send_error_payload(HTTPStatus.NOT_FOUND, "static file missing", send_body=send_body)
# 文件存在时返回原始二进制内容。
self.send_payload(HTTPStatus.OK, path.read_bytes(), content_type, send_body=send_body)
def send_error_payload(
self,
status: HTTPStatus,
message: str,
send_body: bool,
*,
extra_headers: dict[str, str] | None = None,
) -> None:
# 错误响应也统一走 JSON。
self.send_payload(
status,
response_json({"status": int(status), "error": message}),
"application/json; charset=utf-8",
send_body=send_body,
extra_headers=extra_headers,
)
def send_payload(
self,
status: HTTPStatus,
body: bytes,
content_type: str,
send_body: bool,
*,
extra_headers: dict[str, str] | None = None,
) -> None:
# 写状态码。
self.send_response(status)
# 写内容类型。
self.send_header("Content-Type", content_type)
# 页面和接口都禁用缓存。
self.send_header("Cache-Control", "no-store")
# 允许调用方补充下载文件名等额外头。
for key, value in (extra_headers or {}).items():
self.send_header(key, value)
# 写入长度,便于 HEAD 请求工作正常。
self.send_header("Content-Length", str(len(body)))
# 结束响应头。
self.end_headers()
# 需要响应体时再写内容。
if send_body and body:
self.wfile.write(body)
def main() -> None:
# 创建线程型 HTTP 服务。
server = ThreadingHTTPServer((APP_BIND, APP_PORT), MonitorHandler)
# 打印监听地址和挂载路径,部署排查时更直观。
print(f"hy-app-monitor yumi listening on http://{APP_BIND}:{APP_PORT}{APP_BASE_PATH or '/'}", flush=True)
# 进入永久监听。
server.serve_forever()