847 lines
34 KiB
Python
847 lines
34 KiB
Python
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
|
||
|
||
|
||
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,
|
||
)
|
||
|
||
# 返回某个库下的表列表。
|
||
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,
|
||
)
|
||
|
||
# 返回单个服务实例最近 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/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_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()
|