d
This commit is contained in:
@@ -4,6 +4,7 @@ from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.core.config import settings
|
||||
from app.schemas.common import ApiResponse
|
||||
from app.services.detect_job_service import (
|
||||
append_detect_job_event,
|
||||
@@ -12,6 +13,7 @@ from app.services.detect_job_service import (
|
||||
get_detect_job_summary,
|
||||
get_detect_queue_health,
|
||||
list_detect_jobs,
|
||||
normalize_detect_step_code,
|
||||
)
|
||||
from app.services.detect_service import get_detect_status
|
||||
from app.services.detect_run_service import create_detect_run_snapshot, finalize_detect_run, mark_detect_run_stopping
|
||||
@@ -22,6 +24,13 @@ from app.services.worker_control_service import send_worker_command, start_worke
|
||||
router = APIRouter(tags=["detect"])
|
||||
|
||||
|
||||
def _local_worker_expected_on_this_node() -> bool:
|
||||
return not (
|
||||
str(settings.node_region or "").strip() == "overseas"
|
||||
and str(settings.node_role or "").strip() == "control"
|
||||
)
|
||||
|
||||
|
||||
def _build_detect_action_result(
|
||||
*,
|
||||
action: str,
|
||||
@@ -255,6 +264,11 @@ def detect_queue_summary(window_minutes: int = 15) -> ApiResponse:
|
||||
return ApiResponse(data=get_detect_queue_health(window_minutes=window_minutes))
|
||||
|
||||
|
||||
@router.get("/detect/queue-health", response_model=ApiResponse)
|
||||
def detect_queue_health_alias(window_minutes: int = 15) -> ApiResponse:
|
||||
return ApiResponse(data=get_detect_queue_health(window_minutes=window_minutes))
|
||||
|
||||
|
||||
@router.get("/detect/jobs/{job_id}", response_model=ApiResponse)
|
||||
def detect_job_detail(job_id: int) -> ApiResponse:
|
||||
data = get_detect_job_summary(job_id, event_limit=100)
|
||||
@@ -264,8 +278,17 @@ def detect_job_detail(job_id: int) -> ApiResponse:
|
||||
|
||||
|
||||
@router.post("/detect/start", response_model=ApiResponse)
|
||||
def start_detect() -> ApiResponse:
|
||||
job_summary = create_detect_job_if_needed(limit=1000, created_by="api")
|
||||
def start_detect(step_code: str | None = None) -> ApiResponse:
|
||||
normalized_step_code = normalize_detect_step_code(step_code)
|
||||
if step_code and not normalized_step_code:
|
||||
result = _build_detect_action_result(
|
||||
action="start",
|
||||
ok=False,
|
||||
message=f"暂不支持步骤任务: {step_code}",
|
||||
data={"job": None, "step_code": str(step_code or "").strip()},
|
||||
)
|
||||
return ApiResponse(code=1, message=f"暂不支持步骤任务: {step_code}", data=result)
|
||||
job_summary = create_detect_job_if_needed(limit=10000, created_by="api", step_code=step_code)
|
||||
if not job_summary:
|
||||
result = _build_detect_action_result(
|
||||
action="start",
|
||||
@@ -292,42 +315,58 @@ def start_detect() -> ApiResponse:
|
||||
},
|
||||
)
|
||||
|
||||
ok, message = start_worker()
|
||||
if not ok:
|
||||
result = _build_detect_action_result(
|
||||
action="start",
|
||||
ok=False,
|
||||
message=message,
|
||||
data={"job": job_summary},
|
||||
local_worker_expected = _local_worker_expected_on_this_node()
|
||||
if local_worker_expected:
|
||||
ok, message = start_worker()
|
||||
if not ok:
|
||||
result = _build_detect_action_result(
|
||||
action="start",
|
||||
ok=False,
|
||||
message=message,
|
||||
data={"job": job_summary},
|
||||
)
|
||||
append_detect_job_event(
|
||||
job_summary["job_id"],
|
||||
event_type="job_dispatch_failed",
|
||||
level="error",
|
||||
message=f"启动 Worker 失败: {message}",
|
||||
payload={"cycle_token": cycle_token},
|
||||
)
|
||||
return ApiResponse(
|
||||
code=1,
|
||||
message=message,
|
||||
data=result,
|
||||
)
|
||||
|
||||
command_ok, command_message = send_worker_command(
|
||||
"start_detection",
|
||||
payload={
|
||||
"cycle_token": cycle_token,
|
||||
"job_id": job_summary["job_id"],
|
||||
"job_code": job_summary["job_code"],
|
||||
"task_mode": job_summary.get("task_mode", ""),
|
||||
"step_code": job_summary.get("step_code", ""),
|
||||
},
|
||||
)
|
||||
append_detect_job_event(
|
||||
job_summary["job_id"],
|
||||
event_type="job_dispatch_failed",
|
||||
level="error",
|
||||
message=f"启动 Worker 失败: {message}",
|
||||
event_type="job_dispatch_sent" if command_ok else "job_dispatch_rejected",
|
||||
level="info" if command_ok else "error",
|
||||
message=command_message,
|
||||
payload={"cycle_token": cycle_token},
|
||||
)
|
||||
return ApiResponse(
|
||||
code=1,
|
||||
message=message,
|
||||
data=result,
|
||||
else:
|
||||
ok = True
|
||||
message = "当前节点为海外控制面,仅负责派单,不启动本机 Worker"
|
||||
command_ok = True
|
||||
command_message = "已跳过本机 Worker 启动,转为仅向大陆执行节点派发检测动作"
|
||||
append_detect_job_event(
|
||||
job_summary["job_id"],
|
||||
event_type="job_dispatch_skipped_local",
|
||||
level="info",
|
||||
message=command_message,
|
||||
payload={"cycle_token": cycle_token},
|
||||
)
|
||||
|
||||
command_ok, command_message = send_worker_command(
|
||||
"start_detection",
|
||||
payload={
|
||||
"cycle_token": cycle_token,
|
||||
"job_id": job_summary["job_id"],
|
||||
"job_code": job_summary["job_code"],
|
||||
},
|
||||
)
|
||||
append_detect_job_event(
|
||||
job_summary["job_id"],
|
||||
event_type="job_dispatch_sent" if command_ok else "job_dispatch_rejected",
|
||||
level="info" if command_ok else "error",
|
||||
message=command_message,
|
||||
payload={"cycle_token": cycle_token},
|
||||
)
|
||||
snapshot = get_detect_status()
|
||||
settings_payload = get_settings_payload()
|
||||
settings_summary = _build_settings_summary(settings_payload)
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.services.ops_agent_service import (
|
||||
agent_heartbeat,
|
||||
agent_mark_job_started,
|
||||
agent_pull_jobs,
|
||||
agent_pull_runtime_config,
|
||||
agent_register,
|
||||
build_node_agent_bootstrap_plan,
|
||||
issue_node_agent_token,
|
||||
@@ -72,6 +73,12 @@ def ops_agent_pull(payload: dict, limit: int = 1, x_domaincheck_agent_token: Opt
|
||||
return _build_agent_response(ok, message, data)
|
||||
|
||||
|
||||
@router.post("/ops/agent/runtime-config", response_model=ApiResponse)
|
||||
def ops_agent_runtime_config(payload: dict, x_domaincheck_agent_token: Optional[str] = Header(default=None)) -> ApiResponse:
|
||||
ok, message, data = agent_pull_runtime_config(payload, token=_resolve_agent_token(x_domaincheck_agent_token))
|
||||
return _build_agent_response(ok, message, data)
|
||||
|
||||
|
||||
@router.post("/ops/agent/jobs/{job_id}/start", response_model=ApiResponse)
|
||||
def ops_agent_job_start(job_id: int, payload: dict, x_domaincheck_agent_token: Optional[str] = Header(default=None)) -> ApiResponse:
|
||||
ok, message, data = agent_mark_job_started(job_id, payload, token=_resolve_agent_token(x_domaincheck_agent_token))
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Header
|
||||
from fastapi import APIRouter, Body, Header
|
||||
|
||||
from app.schemas.common import ApiResponse
|
||||
from app.services.build_info_service import get_runtime_build_info
|
||||
from app.services.cluster_runtime_service import get_cluster_snapshot
|
||||
from app.services.debug_event_service import get_debug_diagnosis, get_debug_event_overview, get_debug_handoff_report, ingest_debug_event, list_debug_events
|
||||
from app.services.detect_job_service import get_detect_queue_health
|
||||
from app.services.runtime_control_service import runtime_action
|
||||
from app.services.runtime_status_service import get_runtime_preflight, get_runtime_status
|
||||
from app.services.sync_push_service import (
|
||||
@@ -103,6 +104,36 @@ def runtime_debug_handoff(window_minutes: int = 10, source_region: Optional[str]
|
||||
)
|
||||
|
||||
|
||||
@router.get("/runtime/queue-health", response_model=ApiResponse)
|
||||
def runtime_queue_health(window_minutes: int = 15) -> ApiResponse:
|
||||
return ApiResponse(data=get_detect_queue_health(window_minutes=window_minutes))
|
||||
|
||||
|
||||
@router.get("/runtime/health-handover", response_model=ApiResponse)
|
||||
def runtime_health_handover(
|
||||
node_code: Optional[str] = None,
|
||||
window_minutes: int = 10,
|
||||
source_region: Optional[str] = None,
|
||||
) -> ApiResponse:
|
||||
sync_summary = get_sync_summary()
|
||||
runtime_status_payload = get_runtime_status()
|
||||
data = get_debug_handoff_report(
|
||||
window_minutes=window_minutes,
|
||||
source_region=source_region,
|
||||
sync_summary=sync_summary,
|
||||
readiness=runtime_status_payload.get("readiness") or {},
|
||||
)
|
||||
normalized_node_code = str(node_code or "").strip()
|
||||
if normalized_node_code:
|
||||
data = dict(data)
|
||||
data["nodes"] = [
|
||||
item
|
||||
for item in list(data.get("nodes") or [])
|
||||
if str(item.get("node_code") or "").strip() == normalized_node_code
|
||||
]
|
||||
return ApiResponse(data=data)
|
||||
|
||||
|
||||
@router.post("/runtime/sync-ingest", response_model=ApiResponse)
|
||||
def runtime_sync_ingest(payload: dict, x_domaincheck_sync_token: Optional[str] = Header(default=None)) -> ApiResponse:
|
||||
ok, message, data = ingest_runtime_projection(payload, shared_token=x_domaincheck_sync_token)
|
||||
@@ -110,7 +141,7 @@ def runtime_sync_ingest(payload: dict, x_domaincheck_sync_token: Optional[str] =
|
||||
|
||||
|
||||
@router.get("/runtime/task-export", response_model=ApiResponse)
|
||||
def runtime_task_export(limit: int = 200, x_domaincheck_sync_token: Optional[str] = Header(default=None)) -> ApiResponse:
|
||||
def runtime_task_export(limit: int = 1000, x_domaincheck_sync_token: Optional[str] = Header(default=None)) -> ApiResponse:
|
||||
ok, message, data = export_detect_task_projection(limit=limit, shared_token=x_domaincheck_sync_token)
|
||||
return ApiResponse(code=0 if ok else 1, message=message, data=data)
|
||||
|
||||
@@ -128,6 +159,6 @@ def runtime_debug_ingest(payload: dict, x_domaincheck_sync_token: Optional[str]
|
||||
|
||||
|
||||
@router.post("/runtime/actions/{action}", response_model=ApiResponse)
|
||||
def runtime_action_trigger(action: str) -> ApiResponse:
|
||||
ok, message, data = runtime_action(action)
|
||||
def runtime_action_trigger(action: str, payload: Optional[dict] = Body(default=None)) -> ApiResponse:
|
||||
ok, message, data = runtime_action(action, payload=payload)
|
||||
return ApiResponse(code=0 if ok else 1, message=message, data=data)
|
||||
|
||||
@@ -40,8 +40,12 @@ class Settings(BaseSettings):
|
||||
sync_target_region: str = "overseas"
|
||||
sync_target_api_base_url: str = ""
|
||||
sync_shared_token: str = ""
|
||||
sync_batch_size: int = 200
|
||||
sync_poll_interval_seconds: int = 30
|
||||
sync_batch_size: int = 5000
|
||||
sync_poll_interval_seconds: int = 2
|
||||
sync_pipeline_process_limit: int = 5000
|
||||
sync_pull_max_pending_items: int = 0
|
||||
sync_pull_max_register_pending_items: int = 0
|
||||
sync_pull_max_downstream_pending_items: int = 0
|
||||
build_manifest_path: str = ""
|
||||
build_commit_sha: str = ""
|
||||
build_commit_ref: str = ""
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from contextlib import contextmanager
|
||||
from functools import wraps
|
||||
import time
|
||||
|
||||
import psycopg2
|
||||
from psycopg2 import errors
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
@@ -18,3 +21,39 @@ def get_db():
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
_RETRYABLE_READ_ERRORS = (
|
||||
errors.DeadlockDetected,
|
||||
errors.SerializationFailure,
|
||||
errors.LockNotAvailable,
|
||||
)
|
||||
|
||||
|
||||
def is_retryable_read_error(exc: Exception) -> bool:
|
||||
return isinstance(exc, _RETRYABLE_READ_ERRORS)
|
||||
|
||||
|
||||
def is_retryable_db_error(exc: Exception) -> bool:
|
||||
return isinstance(exc, _RETRYABLE_READ_ERRORS)
|
||||
|
||||
|
||||
def db_read_retry(*, attempts: int = 3, initial_delay_seconds: float = 0.05, backoff: float = 2.0):
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
delay = max(0.0, float(initial_delay_seconds or 0.0))
|
||||
for attempt in range(1, max(1, int(attempts or 1)) + 1):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as exc:
|
||||
if not is_retryable_read_error(exc) or attempt >= max(1, int(attempts or 1)):
|
||||
raise
|
||||
if delay > 0:
|
||||
time.sleep(delay)
|
||||
delay *= max(1.0, float(backoff or 1.0))
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
@@ -63,9 +64,35 @@ def tail_lines(relative_path: str, max_lines: int = 120) -> list[str]:
|
||||
|
||||
|
||||
def runtime_root() -> Path:
|
||||
path = Path(__file__).resolve().parents[2] / "runtime"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
env_override = str(os.getenv("DOMAIN_API_RUNTIME_ROOT", "") or "").strip()
|
||||
candidates: list[Path] = []
|
||||
if env_override:
|
||||
candidates.append(Path(env_override))
|
||||
|
||||
base_dir = Path(__file__).resolve().parents[2]
|
||||
for parent in base_dir.parents:
|
||||
if parent.name != "releases":
|
||||
continue
|
||||
# Released builds live under /opt/domaincheck/releases/<release>/domain-api.
|
||||
# Runtime state must not be written back into the immutable release tree,
|
||||
# otherwise sync-agent / detect runtime snapshots fail with permission errors.
|
||||
candidates.append(parent.parent / "runtime" / "domain-api")
|
||||
break
|
||||
|
||||
candidates.append(base_dir / "runtime")
|
||||
|
||||
last_error: OSError | None = None
|
||||
for candidate in candidates:
|
||||
try:
|
||||
candidate.mkdir(parents=True, exist_ok=True)
|
||||
return candidate
|
||||
except OSError as exc:
|
||||
last_error = exc
|
||||
continue
|
||||
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
raise RuntimeError("failed to resolve runtime root")
|
||||
|
||||
|
||||
def read_runtime_json(filename: str, default: dict | list | None = None):
|
||||
@@ -164,7 +191,16 @@ def load_detect_records() -> list[dict]:
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
except (json.JSONDecodeError, UnicodeDecodeError, OSError):
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
decoder = json.JSONDecoder()
|
||||
payload, _ = decoder.raw_decode(text)
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import traceback
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlparse
|
||||
from uuid import uuid4
|
||||
|
||||
from app.services.ops_action_executor_core import (
|
||||
@@ -28,6 +31,44 @@ NODE_CODE = str(os.getenv("NODE_CODE", "")).strip()
|
||||
NODE_REGION = str(os.getenv("NODE_REGION", "mainland")).strip() or "mainland"
|
||||
NODE_ROLE = str(os.getenv("NODE_ROLE", "worker")).strip() or "worker"
|
||||
AGENT_POLL_INTERVAL_SECONDS = max(2, int(os.getenv("OPS_AGENT_POLL_INTERVAL_SECONDS", "5") or 5))
|
||||
AGENT_RUNTIME_CONFIG_SYNC_INTERVAL_SECONDS = max(
|
||||
10,
|
||||
int(os.getenv("OPS_AGENT_RUNTIME_CONFIG_SYNC_INTERVAL_SECONDS", "30") or 30),
|
||||
)
|
||||
AGENT_HTTP_TIMEOUT_SECONDS = max(10, int(os.getenv("OPS_AGENT_HTTP_TIMEOUT_SECONDS", "60") or 60))
|
||||
AGENT_REGISTER_TIMEOUT_SECONDS = max(
|
||||
10,
|
||||
int(os.getenv("OPS_AGENT_REGISTER_TIMEOUT_SECONDS", str(AGENT_HTTP_TIMEOUT_SECONDS)) or AGENT_HTTP_TIMEOUT_SECONDS),
|
||||
)
|
||||
AGENT_HEARTBEAT_TIMEOUT_SECONDS = max(
|
||||
10,
|
||||
int(os.getenv("OPS_AGENT_HEARTBEAT_TIMEOUT_SECONDS", str(AGENT_HTTP_TIMEOUT_SECONDS)) or AGENT_HTTP_TIMEOUT_SECONDS),
|
||||
)
|
||||
AGENT_PULL_TIMEOUT_SECONDS = max(
|
||||
10,
|
||||
int(os.getenv("OPS_AGENT_PULL_TIMEOUT_SECONDS", str(AGENT_HTTP_TIMEOUT_SECONDS)) or AGENT_HTTP_TIMEOUT_SECONDS),
|
||||
)
|
||||
AGENT_RUNTIME_CONFIG_TIMEOUT_SECONDS = max(
|
||||
10,
|
||||
int(
|
||||
os.getenv("OPS_AGENT_RUNTIME_CONFIG_TIMEOUT_SECONDS", str(AGENT_HTTP_TIMEOUT_SECONDS))
|
||||
or AGENT_HTTP_TIMEOUT_SECONDS
|
||||
),
|
||||
)
|
||||
AGENT_JOB_COMPLETE_TIMEOUT_SECONDS = max(
|
||||
10,
|
||||
int(
|
||||
os.getenv("OPS_AGENT_JOB_COMPLETE_TIMEOUT_SECONDS", str(AGENT_HTTP_TIMEOUT_SECONDS))
|
||||
or AGENT_HTTP_TIMEOUT_SECONDS
|
||||
),
|
||||
)
|
||||
AGENT_JOB_EVENT_TIMEOUT_SECONDS = max(
|
||||
10,
|
||||
int(
|
||||
os.getenv("OPS_AGENT_JOB_EVENT_TIMEOUT_SECONDS", str(max(15, AGENT_HTTP_TIMEOUT_SECONDS // 2)))
|
||||
or max(15, AGENT_HTTP_TIMEOUT_SECONDS // 2)
|
||||
),
|
||||
)
|
||||
WORKER_SERVICE_NAME = str(os.getenv("WORKER_SERVICE_NAME", os.getenv("WORKER_SERVICE", "domaincheck-worker"))).strip() or "domaincheck-worker"
|
||||
API_SERVICE_NAME = str(os.getenv("API_SERVICE_NAME", "domaincheck-api")).strip() or "domaincheck-api"
|
||||
SYNC_AGENT_SERVICE_NAME = str(os.getenv("SYNC_AGENT_SERVICE_NAME", "domaincheck-sync-agent")).strip() or "domaincheck-sync-agent"
|
||||
@@ -50,6 +91,7 @@ _LAST_QUEUE_FLUSH_SUMMARY = {
|
||||
"dead_letter": 0,
|
||||
"last_flush_at": "",
|
||||
}
|
||||
_LAST_RUNTIME_CONFIG_HASH = ""
|
||||
|
||||
|
||||
def _normalize_text_list(raw_value: object) -> list[str]:
|
||||
@@ -98,6 +140,7 @@ AGENT_CAPABILITIES = _json_env(
|
||||
"runtime.restart_api",
|
||||
"runtime.start_sync_agent",
|
||||
"runtime.stop_sync_agent",
|
||||
"runtime.reset_lab_state",
|
||||
"health.snapshot",
|
||||
"logs.collect",
|
||||
"diagnostics.collect",
|
||||
@@ -601,7 +644,7 @@ def _headers() -> dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
def _request(method: str, path: str, payload: dict | None = None, timeout: int = 30) -> dict:
|
||||
def _request(method: str, path: str, payload: dict | None = None, timeout: int = AGENT_HTTP_TIMEOUT_SECONDS) -> dict:
|
||||
if not CONTROL_PLANE_BASE_URL:
|
||||
raise RuntimeError("OPS_CONTROL_PLANE_BASE_URL 未配置")
|
||||
if not AGENT_TOKEN:
|
||||
@@ -614,22 +657,286 @@ def _request(method: str, path: str, payload: dict | None = None, timeout: int =
|
||||
return json.loads(body or "{}")
|
||||
|
||||
|
||||
def _post(path: str, payload: dict, timeout: int = 30) -> dict:
|
||||
def _post(path: str, payload: dict, timeout: int = AGENT_HTTP_TIMEOUT_SECONDS) -> dict:
|
||||
return _request("POST", path, payload, timeout=timeout)
|
||||
|
||||
|
||||
def _hostname() -> str:
|
||||
def _pull_runtime_config(timeout: int = AGENT_RUNTIME_CONFIG_TIMEOUT_SECONDS) -> dict:
|
||||
response = _post("/api/v1/ops/agent/runtime-config", {"node_code": NODE_CODE}, timeout=timeout)
|
||||
_ensure_ok_response(response, "agent runtime config pull failed")
|
||||
data = response.get("data") or {}
|
||||
return dict(data.get("bundle") or {})
|
||||
|
||||
|
||||
def _publish_local_config_update(config_type: str) -> None:
|
||||
try:
|
||||
return socket.gethostname()
|
||||
from app.core.redis_client import get_redis
|
||||
|
||||
redis_client = get_redis()
|
||||
redis_client.publish("domain_tool:config_update", str(config_type or "").strip() or "config")
|
||||
except Exception:
|
||||
return ""
|
||||
return
|
||||
|
||||
|
||||
def _apply_runtime_config(bundle: dict) -> bool:
|
||||
global _LAST_RUNTIME_CONFIG_HASH
|
||||
|
||||
normalized_bundle = dict(bundle or {})
|
||||
bundle_hash = str(normalized_bundle.get("config_hash") or "").strip()
|
||||
if not bundle_hash:
|
||||
bundle_hash = hashlib.sha256(
|
||||
json.dumps(normalized_bundle, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
||||
).hexdigest()
|
||||
if bundle_hash and bundle_hash == _LAST_RUNTIME_CONFIG_HASH:
|
||||
return False
|
||||
|
||||
from app.core.files import write_json
|
||||
from app.services.runtime_settings_service import update_runtime_settings
|
||||
from app.services.sensitive_words_service import save_sensitive_words_payload
|
||||
|
||||
detect_options = dict(normalized_bundle.get("detect_options") or {})
|
||||
proxy_config = dict(normalized_bundle.get("proxy_config") or {})
|
||||
thread_count = int(normalized_bundle.get("thread_count", 2) or 2)
|
||||
node_thread_counts = dict(normalized_bundle.get("node_thread_counts") or {})
|
||||
runtime_settings = dict(normalized_bundle.get("runtime_settings") or {})
|
||||
sensitive_words = dict(normalized_bundle.get("sensitive_words") or {})
|
||||
sensitive_words_text = str(sensitive_words.get("text") or "")
|
||||
sensitive_word_items = list(sensitive_words.get("items") or [])
|
||||
|
||||
write_json("detect_options.json", detect_options)
|
||||
write_json("proxy_config.json", proxy_config)
|
||||
write_json("thread_count.json", {"thread_count": str(thread_count)})
|
||||
write_json("node_thread_counts.json", node_thread_counts)
|
||||
write_json("runtime_settings.json", runtime_settings)
|
||||
write_json("runtime/runtime_settings.json", runtime_settings)
|
||||
write_json(
|
||||
"runtime/sensitive_words.json",
|
||||
{
|
||||
"items": sensitive_word_items,
|
||||
"text": sensitive_words_text,
|
||||
"total": int(sensitive_words.get("total", 0) or 0),
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
update_runtime_settings(runtime_settings)
|
||||
except Exception as exc:
|
||||
_log(f"runtime settings local api sync skipped: {exc}")
|
||||
|
||||
try:
|
||||
save_sensitive_words_payload({"text": sensitive_words_text})
|
||||
except Exception as exc:
|
||||
_log(f"sensitive words db sync skipped: {exc}")
|
||||
|
||||
try:
|
||||
from app.core.redis_client import get_redis
|
||||
|
||||
redis_client = get_redis()
|
||||
redis_client.set("domain_tool:detect_options", json.dumps(detect_options, ensure_ascii=False))
|
||||
redis_client.set("domain_tool:proxy_config", json.dumps(proxy_config, ensure_ascii=False))
|
||||
redis_client.set("domain_tool:thread_count", thread_count)
|
||||
redis_client.set("domain_tool:node_thread_counts", json.dumps(node_thread_counts, ensure_ascii=False))
|
||||
redis_client.set("domain_tool:runtime_settings", json.dumps(runtime_settings, ensure_ascii=False))
|
||||
redis_client.set("domain_tool:sensitive_words", json.dumps(sensitive_word_items, ensure_ascii=False))
|
||||
redis_client.publish("domain_tool:config_update", "detect_options")
|
||||
redis_client.publish("domain_tool:config_update", "proxy_config")
|
||||
redis_client.publish("domain_tool:config_update", "thread_count")
|
||||
redis_client.publish("domain_tool:config_update", "node_thread_counts")
|
||||
redis_client.publish("domain_tool:config_update", "runtime_settings")
|
||||
redis_client.publish("domain_tool:config_update", "sensitive_words")
|
||||
except Exception:
|
||||
_publish_local_config_update("detect_options")
|
||||
_publish_local_config_update("proxy_config")
|
||||
_publish_local_config_update("thread_count")
|
||||
_publish_local_config_update("node_thread_counts")
|
||||
_publish_local_config_update("runtime_settings")
|
||||
_publish_local_config_update("sensitive_words")
|
||||
|
||||
_LAST_RUNTIME_CONFIG_HASH = bundle_hash
|
||||
_log(
|
||||
"runtime config applied: "
|
||||
f"thread_count={thread_count} "
|
||||
f"node_override={node_thread_counts.get(NODE_CODE)} "
|
||||
f"sensitive_words={int(sensitive_words.get('total', 0) or 0)}"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _hostname() -> str:
|
||||
generic_values = {"localhost", "localhost.localdomain", "ip6-localhost", "localhost6"}
|
||||
candidates: list[str] = []
|
||||
try:
|
||||
candidates.append(socket.gethostname())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
candidates.append(socket.getfqdn())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
candidates.append(os.uname().nodename)
|
||||
except Exception:
|
||||
pass
|
||||
candidates.append(str(os.getenv("HOSTNAME", "")).strip())
|
||||
|
||||
for value in candidates:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
continue
|
||||
if normalized.lower() in generic_values:
|
||||
continue
|
||||
return normalized
|
||||
return NODE_CODE or ""
|
||||
|
||||
|
||||
def _control_plane_host_port() -> tuple[str, int]:
|
||||
parsed = urlparse(CONTROL_PLANE_BASE_URL if "://" in CONTROL_PLANE_BASE_URL else f"http://{CONTROL_PLANE_BASE_URL}")
|
||||
host = str(parsed.hostname or "").strip()
|
||||
if not host:
|
||||
return "", 0
|
||||
port = int(parsed.port or (443 if parsed.scheme == "https" else 80))
|
||||
return host, port
|
||||
|
||||
|
||||
def _first_non_loopback_ip(values: list[str]) -> str:
|
||||
for value in values:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized or normalized.startswith("127.") or normalized == "::1":
|
||||
continue
|
||||
return normalized
|
||||
return ""
|
||||
|
||||
|
||||
def _ip() -> str:
|
||||
explicit_ip = str(os.getenv("OPS_AGENT_IP", "")).strip()
|
||||
if explicit_ip:
|
||||
return explicit_ip
|
||||
|
||||
host, port = _control_plane_host_port()
|
||||
if host and port:
|
||||
for family in (socket.AF_INET, socket.AF_INET6):
|
||||
sock = None
|
||||
try:
|
||||
sock = socket.socket(family, socket.SOCK_DGRAM)
|
||||
sock.connect((host, port))
|
||||
local_ip = str(sock.getsockname()[0] or "").strip()
|
||||
if local_ip and not local_ip.startswith("127.") and local_ip != "::1":
|
||||
return local_ip
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
if sock is not None:
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
hostname = _hostname()
|
||||
if hostname:
|
||||
try:
|
||||
addrinfo = socket.getaddrinfo(hostname, None)
|
||||
resolved_ips = [str(item[4][0] or "").strip() for item in addrinfo if item and len(item) >= 5]
|
||||
best_ip = _first_non_loopback_ip(resolved_ips)
|
||||
if best_ip:
|
||||
return best_ip
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
return socket.gethostbyname(socket.gethostname())
|
||||
fallback_ip = str(socket.gethostbyname(socket.gethostname()) or "").strip()
|
||||
if fallback_ip and not fallback_ip.startswith("127."):
|
||||
return fallback_ip
|
||||
except Exception:
|
||||
return ""
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _detect_runtime_snapshot() -> dict:
|
||||
worker_runtime = {}
|
||||
try:
|
||||
from app.services.worker_control_service import detect_worker_runtime
|
||||
|
||||
worker_runtime = detect_worker_runtime() or {}
|
||||
except Exception as exc:
|
||||
worker_runtime = {
|
||||
"running": False,
|
||||
"process_count": 0,
|
||||
"latest_start_time": "",
|
||||
"message": "",
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
try:
|
||||
from app.services.detect_service import get_detect_status
|
||||
|
||||
detect_status = get_detect_status() or {}
|
||||
runtime_state = detect_status.get("runtime_state") if isinstance(detect_status.get("runtime_state"), dict) else {}
|
||||
active_job = detect_status.get("active_job") if isinstance(detect_status.get("active_job"), dict) else {}
|
||||
|
||||
active_threads = max(0, int(detect_status.get("active_thread_count", 0) or 0))
|
||||
max_threads = max(0, int(detect_status.get("max_thread_count", 0) or 0))
|
||||
current_load = max(
|
||||
active_threads,
|
||||
int(active_job.get("items_running", 0) or 0),
|
||||
)
|
||||
phase_label = str(detect_status.get("phase_label") or "").strip()
|
||||
phase_detail = str(
|
||||
detect_status.get("phase_detail")
|
||||
or detect_status.get("recent_event")
|
||||
or detect_status.get("worker_runtime_message")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
inferred_worker_online = bool(
|
||||
worker_runtime.get("running", False)
|
||||
or detect_status.get("worker_online", False)
|
||||
or runtime_state.get("service_running", False)
|
||||
or current_load > 0
|
||||
or active_threads > 0
|
||||
)
|
||||
return {
|
||||
"worker_online": inferred_worker_online,
|
||||
"service_running": bool(
|
||||
runtime_state.get("service_running", False)
|
||||
or worker_runtime.get("running", False)
|
||||
or inferred_worker_online
|
||||
),
|
||||
"detecting": bool(
|
||||
detect_status.get("detecting", False)
|
||||
or runtime_state.get("detecting", False)
|
||||
or current_load > 0
|
||||
or active_threads > 0
|
||||
),
|
||||
"active_threads": active_threads,
|
||||
"max_threads": max_threads,
|
||||
"current_load": current_load,
|
||||
"phase_label": phase_label,
|
||||
"phase_detail": phase_detail,
|
||||
"recent_warning": str(detect_status.get("recent_warning") or "").strip(),
|
||||
"updated_at": str(runtime_state.get("updated_at") or "").strip(),
|
||||
"detect_participating": bool(
|
||||
detect_status.get("detect_participating", False)
|
||||
or current_load > 0
|
||||
or active_threads > 0
|
||||
),
|
||||
}
|
||||
except Exception as exc:
|
||||
worker_online = bool(worker_runtime.get("running", False))
|
||||
worker_message = str(worker_runtime.get("message") or "").strip()
|
||||
return {
|
||||
"worker_online": worker_online,
|
||||
"service_running": worker_online,
|
||||
"detecting": False,
|
||||
"active_threads": 0,
|
||||
"max_threads": 0,
|
||||
"current_load": 0,
|
||||
"phase_label": "",
|
||||
"phase_detail": worker_message,
|
||||
"recent_warning": "",
|
||||
"updated_at": str(worker_runtime.get("latest_start_time") or "").strip(),
|
||||
"detect_participating": False,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
|
||||
def _base_payload() -> dict:
|
||||
@@ -651,12 +958,37 @@ def _base_payload() -> dict:
|
||||
"node_agent": NODE_AGENT_SERVICE_NAME,
|
||||
},
|
||||
"delivery_queue": _delivery_queue_snapshot(),
|
||||
"detect_runtime": _detect_runtime_snapshot(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _run(command: list[str], timeout: int = 60) -> tuple[int, str, str]:
|
||||
completed = subprocess.run(command, capture_output=True, text=True, timeout=timeout)
|
||||
normalized_command = [str(part or "").strip() for part in command]
|
||||
combined_output = f"{completed.stdout or ''}\n{completed.stderr or ''}".lower()
|
||||
needs_sudo_retry = (
|
||||
normalized_command
|
||||
and normalized_command[0] == "systemctl"
|
||||
and completed.returncode != 0
|
||||
and "sudo" not in normalized_command
|
||||
and any(
|
||||
marker in combined_output
|
||||
for marker in (
|
||||
"interactive authentication required",
|
||||
"authentication is required",
|
||||
"authorization not available",
|
||||
"polkit",
|
||||
)
|
||||
)
|
||||
)
|
||||
if needs_sudo_retry:
|
||||
completed = subprocess.run(
|
||||
["sudo", "-n", *normalized_command],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
return completed.returncode, completed.stdout.strip(), completed.stderr.strip()
|
||||
|
||||
|
||||
@@ -727,18 +1059,22 @@ def _execute_action(
|
||||
|
||||
|
||||
def _register() -> None:
|
||||
response = _post("/api/v1/ops/agent/register", _base_payload())
|
||||
response = _post("/api/v1/ops/agent/register", _base_payload(), timeout=AGENT_REGISTER_TIMEOUT_SECONDS)
|
||||
_ensure_ok_response(response, "agent register failed")
|
||||
_log(f"registered: {response.get('message')}")
|
||||
|
||||
|
||||
def _heartbeat() -> None:
|
||||
response = _post("/api/v1/ops/agent/heartbeat", _base_payload())
|
||||
response = _post("/api/v1/ops/agent/heartbeat", _base_payload(), timeout=AGENT_HEARTBEAT_TIMEOUT_SECONDS)
|
||||
_ensure_ok_response(response, "agent heartbeat failed")
|
||||
|
||||
|
||||
def _pull_jobs() -> list[dict]:
|
||||
response = _post(f"/api/v1/ops/agent/pull?limit=1", {"node_code": NODE_CODE})
|
||||
response = _post(
|
||||
f"/api/v1/ops/agent/pull?limit=1",
|
||||
{"node_code": NODE_CODE},
|
||||
timeout=AGENT_PULL_TIMEOUT_SECONDS,
|
||||
)
|
||||
_ensure_ok_response(response, "agent pull failed")
|
||||
data = response.get("data") or {}
|
||||
return list(data.get("jobs") or [])
|
||||
@@ -847,7 +1183,7 @@ def _job_complete(
|
||||
path=f"/api/v1/ops/agent/jobs/{job_id}/complete",
|
||||
payload=payload,
|
||||
request_id=request_id,
|
||||
timeout=30,
|
||||
timeout=AGENT_JOB_COMPLETE_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
@@ -883,7 +1219,7 @@ def _job_event(
|
||||
path=f"/api/v1/ops/agent/jobs/{job_id}/events",
|
||||
payload=delivery_payload,
|
||||
request_id=request_id,
|
||||
timeout=15,
|
||||
timeout=AGENT_JOB_EVENT_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
@@ -931,7 +1267,22 @@ def _process_job(job: dict) -> None:
|
||||
"start_delivery_error": start_delivery_error,
|
||||
},
|
||||
)
|
||||
ok, message, result = _execute_action(action, payload, job_id=job_id, job_context=normalized_job)
|
||||
try:
|
||||
ok, message, result = _execute_action(action, payload, job_id=job_id, job_context=normalized_job)
|
||||
except Exception as exc:
|
||||
ok = False
|
||||
message = f"executor exception: {exc}"
|
||||
result = {
|
||||
"stdout": "",
|
||||
"stderr": traceback.format_exc(),
|
||||
"summary_text": message,
|
||||
"exception_type": exc.__class__.__name__,
|
||||
}
|
||||
_log(
|
||||
"job execute exception: "
|
||||
f"id={job_id} code={normalized_job.get('job_code') or '-'} "
|
||||
f"action={action} error={exc}"
|
||||
)
|
||||
stdout = str(result.get("stdout") or "")
|
||||
stderr = str(result.get("stderr") or "")
|
||||
duration_ms = max(0, int((time.monotonic() - started_at) * 1000))
|
||||
@@ -956,6 +1307,57 @@ def _process_job(job: dict) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _process_job_with_guard(job: dict) -> None:
|
||||
normalized_job = _normalize_agent_job(job)
|
||||
job_id = int(normalized_job.get("job_id") or 0)
|
||||
action = str(normalized_job.get("action") or "").strip()
|
||||
started_at = time.monotonic()
|
||||
try:
|
||||
_process_job(normalized_job)
|
||||
except Exception as exc:
|
||||
duration_ms = max(0, int((time.monotonic() - started_at) * 1000))
|
||||
traceback_text = traceback.format_exc()
|
||||
summary_text = f"node-agent fatal exception: {exc}"
|
||||
_log(
|
||||
"job fatal exception: "
|
||||
f"id={job_id} code={normalized_job.get('job_code') or '-'} "
|
||||
f"action={action or '-'} error={exc}"
|
||||
)
|
||||
if job_id <= 0:
|
||||
raise
|
||||
try:
|
||||
delivery = _job_complete(
|
||||
job_id,
|
||||
status="failed",
|
||||
stdout="",
|
||||
stderr=traceback_text,
|
||||
result={
|
||||
"stdout": "",
|
||||
"stderr": traceback_text,
|
||||
"summary_text": summary_text,
|
||||
"exception_type": exc.__class__.__name__,
|
||||
"fatal_loop_exception": True,
|
||||
},
|
||||
error_message=summary_text,
|
||||
duration_ms=duration_ms,
|
||||
summary_text=summary_text,
|
||||
focus_ref=dict(normalized_job.get("focus_ref") or {}),
|
||||
step_ref=dict(normalized_job.get("step_ref") or {}),
|
||||
release_context=dict(normalized_job.get("release_context") or {}),
|
||||
)
|
||||
_log(
|
||||
"job fatal exception completion: "
|
||||
f"id={job_id} code={normalized_job.get('job_code') or '-'} "
|
||||
f"action={action or '-'} delivery={delivery.get('state')}"
|
||||
)
|
||||
except Exception as completion_exc:
|
||||
_log(
|
||||
"job fatal exception completion failed: "
|
||||
f"id={job_id} code={normalized_job.get('job_code') or '-'} "
|
||||
f"action={action or '-'} error={completion_exc}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not NODE_CODE:
|
||||
raise RuntimeError("NODE_CODE 未配置")
|
||||
@@ -963,6 +1365,7 @@ def main() -> None:
|
||||
_ensure_queue_dirs()
|
||||
_register()
|
||||
last_heartbeat_at = 0.0
|
||||
last_runtime_config_sync_at = 0.0
|
||||
|
||||
while True:
|
||||
now = time.time()
|
||||
@@ -970,13 +1373,17 @@ def main() -> None:
|
||||
delivery_summary = _flush_delivery_queue(limit=AGENT_QUEUE_FLUSH_LIMIT)
|
||||
if delivery_summary["delivered"] or delivery_summary["dead_letter"]:
|
||||
_log(f"delivery queue flush: {delivery_summary}")
|
||||
if now - last_runtime_config_sync_at >= AGENT_RUNTIME_CONFIG_SYNC_INTERVAL_SECONDS:
|
||||
bundle = _pull_runtime_config()
|
||||
_apply_runtime_config(bundle)
|
||||
last_runtime_config_sync_at = now
|
||||
if now - last_heartbeat_at >= 15:
|
||||
_heartbeat()
|
||||
last_heartbeat_at = now
|
||||
jobs = _pull_jobs()
|
||||
if jobs:
|
||||
for job in jobs:
|
||||
_process_job(job)
|
||||
_process_job_with_guard(job)
|
||||
else:
|
||||
time.sleep(AGENT_POLL_INTERVAL_SECONDS)
|
||||
except urllib.error.HTTPError as exc:
|
||||
|
||||
@@ -2,10 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
from app.core.db import db_read_retry, get_db
|
||||
|
||||
|
||||
_RUNTIME_SCHEMA_SQL = """
|
||||
@@ -29,6 +30,8 @@ CREATE TABLE IF NOT EXISTS detect_jobs (
|
||||
job_code VARCHAR(64) NOT NULL UNIQUE,
|
||||
source VARCHAR(64) NOT NULL DEFAULT 'manual',
|
||||
plan_hash VARCHAR(128) NOT NULL DEFAULT '',
|
||||
task_mode VARCHAR(32) NOT NULL DEFAULT 'domain_pipeline',
|
||||
step_code VARCHAR(64) NOT NULL DEFAULT '',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
remark TEXT NOT NULL DEFAULT '',
|
||||
created_by VARCHAR(64) NOT NULL DEFAULT '',
|
||||
@@ -41,6 +44,7 @@ CREATE TABLE IF NOT EXISTS detect_job_items (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
job_id BIGINT NOT NULL REFERENCES detect_jobs(id) ON DELETE CASCADE,
|
||||
domain_id BIGINT NOT NULL,
|
||||
step_code VARCHAR(64) NOT NULL DEFAULT '',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
claimed_by VARCHAR(64) NOT NULL DEFAULT '',
|
||||
claim_token VARCHAR(64) NOT NULL DEFAULT '',
|
||||
@@ -48,16 +52,32 @@ CREATE TABLE IF NOT EXISTS detect_job_items (
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
result_version VARCHAR(64) NOT NULL DEFAULT '',
|
||||
step_payload_json JSONB,
|
||||
result_payload_json JSONB,
|
||||
started_at TIMESTAMP,
|
||||
finished_at TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uq_detect_job_items_job_domain UNIQUE (job_id, domain_id)
|
||||
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_detect_job_items_status_lease
|
||||
ON detect_job_items(status, lease_expires_at);
|
||||
|
||||
ALTER TABLE detect_jobs
|
||||
ADD COLUMN IF NOT EXISTS task_mode VARCHAR(32) NOT NULL DEFAULT 'domain_pipeline',
|
||||
ADD COLUMN IF NOT EXISTS step_code VARCHAR(64) NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE detect_job_items
|
||||
ADD COLUMN IF NOT EXISTS step_code VARCHAR(64) NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS step_payload_json JSONB,
|
||||
ADD COLUMN IF NOT EXISTS result_payload_json JSONB;
|
||||
|
||||
ALTER TABLE detect_job_items
|
||||
DROP CONSTRAINT IF EXISTS uq_detect_job_items_job_domain;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_detect_job_items_job_domain_step
|
||||
ON detect_job_items(job_id, domain_id, step_code);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS detect_run_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
job_id BIGINT REFERENCES detect_jobs(id) ON DELETE SET NULL,
|
||||
@@ -90,6 +110,9 @@ _STALE_AFTER_SECONDS = 90
|
||||
_OFFLINE_AFTER_MINUTES = 5
|
||||
_PRUNE_IMPORTED_AFTER_MINUTES = 30
|
||||
_PRUNE_GENERAL_AFTER_HOURS = 6
|
||||
_RUNTIME_SCHEMA_READY = False
|
||||
_RUNTIME_SCHEMA_LOCK = threading.Lock()
|
||||
_RUNTIME_SCHEMA_ADVISORY_LOCK_ID = 62021001
|
||||
|
||||
|
||||
def _resolve_local_ip() -> str:
|
||||
@@ -110,12 +133,85 @@ def _decode_json(value: object) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def _control_node_supports_worker(*, region: object, metadata: dict | None) -> bool:
|
||||
normalized_region = str(region or "").strip()
|
||||
runtime_metadata = dict(metadata or {})
|
||||
active_threads = int(runtime_metadata.get("active_threads", 0) or 0)
|
||||
max_threads = int(runtime_metadata.get("max_threads", 0) or 0)
|
||||
if normalized_region != "mainland":
|
||||
return False
|
||||
return bool(
|
||||
runtime_metadata.get("worker_online", False)
|
||||
or runtime_metadata.get("detect_participating", False)
|
||||
or active_threads > 0
|
||||
or max_threads > 0
|
||||
)
|
||||
|
||||
|
||||
def _metadata_idle_without_runtime_work(metadata: dict | None) -> bool:
|
||||
runtime_metadata = dict(metadata or {})
|
||||
phase = str(
|
||||
runtime_metadata.get("phase")
|
||||
or runtime_metadata.get("phase_label")
|
||||
or ""
|
||||
).strip().lower()
|
||||
if phase not in {"idle", "completed", "stopped"}:
|
||||
return False
|
||||
|
||||
active_job_code = str(runtime_metadata.get("active_job_code") or "").strip()
|
||||
counters = (
|
||||
int(runtime_metadata.get("job_items_total", 0) or 0),
|
||||
int(runtime_metadata.get("job_items_claimed", 0) or 0),
|
||||
int(runtime_metadata.get("job_items_running", 0) or 0),
|
||||
int(runtime_metadata.get("job_items_completed", 0) or 0),
|
||||
int(runtime_metadata.get("job_items_failed", 0) or 0),
|
||||
)
|
||||
if active_job_code:
|
||||
return False
|
||||
return not any(value > 0 for value in counters)
|
||||
|
||||
|
||||
def _load_managed_node_overlays() -> dict[str, dict]:
|
||||
try:
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT node_code, metadata_json, last_seen_at
|
||||
FROM ops_managed_nodes
|
||||
WHERE is_enabled = TRUE
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
overlays: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
node_code = str(row[0] or "").strip()
|
||||
if not node_code:
|
||||
continue
|
||||
overlays[node_code] = {
|
||||
"metadata": _decode_json(row[1]),
|
||||
"last_seen_at": row[2],
|
||||
}
|
||||
return overlays
|
||||
|
||||
|
||||
def ensure_runtime_schema() -> None:
|
||||
with get_db() as conn:
|
||||
conn.autocommit = False
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(_RUNTIME_SCHEMA_SQL)
|
||||
conn.commit()
|
||||
global _RUNTIME_SCHEMA_READY
|
||||
if _RUNTIME_SCHEMA_READY:
|
||||
return
|
||||
with _RUNTIME_SCHEMA_LOCK:
|
||||
if _RUNTIME_SCHEMA_READY:
|
||||
return
|
||||
with get_db() as conn:
|
||||
conn.autocommit = False
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT pg_advisory_xact_lock(%s)", (_RUNTIME_SCHEMA_ADVISORY_LOCK_ID,))
|
||||
cur.execute(_RUNTIME_SCHEMA_SQL)
|
||||
conn.commit()
|
||||
_RUNTIME_SCHEMA_READY = True
|
||||
|
||||
|
||||
def register_node_heartbeat(
|
||||
@@ -195,6 +291,43 @@ def cleanup_imported_runtime_nodes(*, region: str, role: str, keep_node_code: st
|
||||
conn.commit()
|
||||
|
||||
|
||||
def cleanup_imported_runtime_nodes_many(*, region: str, role: str, keep_node_codes: list[str] | tuple[str, ...] | set[str]) -> None:
|
||||
normalized_region = str(region or "").strip() or "unknown"
|
||||
normalized_role = str(role or "").strip() or "unknown"
|
||||
preserved_node_codes = sorted(
|
||||
{
|
||||
str(node_code or "").strip()
|
||||
for node_code in (keep_node_codes or [])
|
||||
if str(node_code or "").strip()
|
||||
}
|
||||
)
|
||||
if not preserved_node_codes:
|
||||
return
|
||||
|
||||
placeholders = ", ".join(["%s"] * len(preserved_node_codes))
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
DELETE FROM detect_worker_nodes
|
||||
WHERE region = %s
|
||||
AND role = %s
|
||||
AND (
|
||||
node_code = %s
|
||||
OR (metadata_json->>'service') = 'runtime-ingest'
|
||||
)
|
||||
AND node_code NOT IN ({placeholders})
|
||||
""",
|
||||
(
|
||||
normalized_region,
|
||||
normalized_role,
|
||||
f"{normalized_region}-{normalized_role}-imported",
|
||||
*preserved_node_codes,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def prune_expired_runtime_nodes() -> None:
|
||||
imported_cutoff = datetime.now() - timedelta(minutes=_PRUNE_IMPORTED_AFTER_MINUTES)
|
||||
general_cutoff = datetime.now() - timedelta(hours=_PRUNE_GENERAL_AFTER_HOURS)
|
||||
@@ -219,10 +352,12 @@ def prune_expired_runtime_nodes() -> None:
|
||||
|
||||
def register_local_control_heartbeat() -> None:
|
||||
from app.services.detect_job_service import get_active_detect_job_summary
|
||||
from app.services.detect_service import get_detect_status
|
||||
from app.services.worker_control_service import detect_worker_runtime
|
||||
|
||||
worker_runtime = detect_worker_runtime()
|
||||
worker_online = bool(worker_runtime.get("running", False))
|
||||
detect_status = get_detect_status()
|
||||
active_job = get_active_detect_job_summary(event_limit=5) or {}
|
||||
node_stats = list(active_job.get("node_stats") or [])
|
||||
local_bucket = next(
|
||||
@@ -233,7 +368,9 @@ def register_local_control_heartbeat() -> None:
|
||||
items_claimed = int(local_bucket.get("items_claimed", 0) or 0)
|
||||
items_running = int(local_bucket.get("items_running", 0) or 0)
|
||||
items_completed = int(local_bucket.get("items_completed", 0) or 0)
|
||||
current_load = max(items_running, items_claimed, 0)
|
||||
active_threads = int(detect_status.get("active_thread_count", 0) or 0)
|
||||
max_threads = int(detect_status.get("max_thread_count", 0) or 0)
|
||||
current_load = max(items_running, active_threads, 0)
|
||||
detect_participating = bool(worker_online and (items_total > 0 or current_load > 0))
|
||||
node_status = "busy" if current_load > 0 else "online"
|
||||
register_node_heartbeat(
|
||||
@@ -256,6 +393,10 @@ def register_local_control_heartbeat() -> None:
|
||||
"job_items_claimed": items_claimed,
|
||||
"job_items_running": items_running,
|
||||
"job_items_completed": items_completed,
|
||||
"active_threads": active_threads,
|
||||
"max_threads": max_threads,
|
||||
"phase_label": str(detect_status.get("phase_label") or ""),
|
||||
"phase_detail": str(detect_status.get("phase_detail") or ""),
|
||||
"updated_at": datetime.now().isoformat(timespec="seconds"),
|
||||
},
|
||||
)
|
||||
@@ -274,9 +415,11 @@ def _normalize_node_status(raw_status: str, last_heartbeat_at: datetime | None)
|
||||
return status
|
||||
|
||||
|
||||
@db_read_retry()
|
||||
def get_cluster_snapshot() -> dict:
|
||||
prune_expired_runtime_nodes()
|
||||
register_local_control_heartbeat()
|
||||
managed_overlays = _load_managed_node_overlays()
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
@@ -297,21 +440,50 @@ def get_cluster_snapshot() -> dict:
|
||||
cur.execute("SELECT count(*) FROM detect_job_items WHERE status IN ('pending', 'claimed', 'running')")
|
||||
active_items = cur.fetchone()[0]
|
||||
|
||||
nodes = [
|
||||
{
|
||||
"node_code": row[0],
|
||||
"region": row[1],
|
||||
"role": row[2],
|
||||
"hostname": row[3],
|
||||
"ip": row[4],
|
||||
"status": _normalize_node_status(row[5], row[9]),
|
||||
"worker_version": row[6],
|
||||
"current_load": row[7],
|
||||
"metadata": _decode_json(row[8]),
|
||||
"last_heartbeat_at": row[9].isoformat(sep=" ", timespec="seconds") if row[9] else "",
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
nodes = []
|
||||
for row in rows:
|
||||
node_code = str(row[0] or "").strip()
|
||||
metadata = _decode_json(row[8])
|
||||
current_load = int(row[7] or 0)
|
||||
sanitized_idle_runtime = _metadata_idle_without_runtime_work(metadata)
|
||||
if sanitized_idle_runtime:
|
||||
current_load = 0
|
||||
metadata["active_threads"] = 0
|
||||
metadata["detect_participating"] = False
|
||||
metadata["sanitized_runtime_state"] = "idle_phase_zeroed"
|
||||
runtime_last_heartbeat = row[9]
|
||||
managed_overlay = managed_overlays.get(node_code) or {}
|
||||
managed_last_seen = managed_overlay.get("last_seen_at")
|
||||
overlay_is_newer = bool(
|
||||
managed_last_seen
|
||||
and (not runtime_last_heartbeat or managed_last_seen > runtime_last_heartbeat)
|
||||
)
|
||||
effective_last_heartbeat = managed_last_seen if overlay_is_newer else runtime_last_heartbeat
|
||||
normalized_status = _normalize_node_status(row[5], effective_last_heartbeat)
|
||||
if sanitized_idle_runtime and normalized_status == "busy":
|
||||
normalized_status = "online"
|
||||
if overlay_is_newer and normalized_status in {"offline", "stale"}:
|
||||
normalized_status = "busy" if current_load > 0 else "online"
|
||||
if managed_last_seen:
|
||||
metadata["agent_last_seen_at"] = managed_last_seen.isoformat(sep=" ", timespec="seconds")
|
||||
if overlay_is_newer:
|
||||
metadata["cluster_status_source"] = "managed-agent-overlay"
|
||||
nodes.append(
|
||||
{
|
||||
"node_code": node_code,
|
||||
"region": row[1],
|
||||
"role": row[2],
|
||||
"hostname": row[3],
|
||||
"ip": row[4],
|
||||
"status": normalized_status,
|
||||
"worker_version": row[6],
|
||||
"current_load": current_load,
|
||||
"metadata": metadata,
|
||||
"last_heartbeat_at": effective_last_heartbeat.isoformat(sep=" ", timespec="seconds")
|
||||
if effective_last_heartbeat
|
||||
else "",
|
||||
}
|
||||
)
|
||||
status_counts: dict[str, int] = {}
|
||||
role_counts: dict[str, int] = {}
|
||||
region_counts: dict[str, int] = {}
|
||||
@@ -330,6 +502,12 @@ def get_cluster_snapshot() -> dict:
|
||||
metadata = node.get("metadata") or {}
|
||||
node_current_load = int(node.get("current_load", 0) or 0)
|
||||
effective_worker = False
|
||||
if node_role == "control" and not _control_node_supports_worker(region=node_region, metadata=metadata):
|
||||
node_current_load = 0
|
||||
node["current_load"] = 0
|
||||
if node_status == "busy":
|
||||
node_status = "online"
|
||||
node["status"] = "online"
|
||||
|
||||
status_counts[node_status] = status_counts.get(node_status, 0) + 1
|
||||
role_counts[node_role] = role_counts.get(node_role, 0) + 1
|
||||
@@ -345,14 +523,16 @@ def get_cluster_snapshot() -> dict:
|
||||
dedicated_online_worker_nodes += 1
|
||||
effective_worker = True
|
||||
elif node_role == "control" and node_status in {"online", "busy"}:
|
||||
if bool(metadata.get("worker_online", False)) or bool(metadata.get("detect_participating", False)) or node_current_load > 0:
|
||||
if _control_node_supports_worker(region=node_region, metadata=metadata):
|
||||
effective_worker = True
|
||||
if effective_worker:
|
||||
online_worker_nodes += 1
|
||||
if node_role == "control" and node_status in {"online", "busy"}:
|
||||
online_control_nodes += 1
|
||||
node["is_effective_worker"] = effective_worker
|
||||
node["detect_participating"] = bool(metadata.get("detect_participating", False) or node_current_load > 0)
|
||||
node["detect_participating"] = bool(
|
||||
effective_worker and (metadata.get("detect_participating", False) or node_current_load > 0)
|
||||
)
|
||||
|
||||
return {
|
||||
"nodes": nodes,
|
||||
|
||||
@@ -1,10 +1,390 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.services.detect_job_service import (
|
||||
_build_step_bucket,
|
||||
get_active_detect_job_summary,
|
||||
get_detect_capacity_plan,
|
||||
get_detect_queue_health,
|
||||
)
|
||||
from app.services.runtime_status_service import get_runtime_status
|
||||
|
||||
|
||||
def _empty_active_jobs_aggregate(window_minutes: int) -> dict:
|
||||
return {
|
||||
"window_minutes": int(window_minutes or 15),
|
||||
"active_jobs_total": 0,
|
||||
"queue": {
|
||||
"items_total": 0,
|
||||
"pending": 0,
|
||||
"claimed": 0,
|
||||
"running": 0,
|
||||
"completed": 0,
|
||||
"blacklisted": 0,
|
||||
"failed": 0,
|
||||
"terminal": 0,
|
||||
},
|
||||
"throughput": {
|
||||
"processed_recent": 0,
|
||||
"processed_per_minute": 0,
|
||||
"completed_recent": 0,
|
||||
"blacklisted_recent": 0,
|
||||
"failed_recent": 0,
|
||||
},
|
||||
"steps": [],
|
||||
"nodes": [],
|
||||
"retry_total": 0,
|
||||
}
|
||||
|
||||
|
||||
def _merge_step_queues_with_runtime_activity(
|
||||
base_steps: list[dict] | None,
|
||||
*,
|
||||
runtime_activity: dict | None = None,
|
||||
window_minutes: int = 15,
|
||||
limit: int = 8,
|
||||
) -> list[dict]:
|
||||
safe_window_minutes = max(1, int(window_minutes or 15))
|
||||
normalized_limit = max(1, int(limit or 8))
|
||||
step_map: dict[str, dict] = {}
|
||||
|
||||
for item in list(base_steps or []):
|
||||
step_code = str(item.get("step_code") or "").strip()
|
||||
if not step_code:
|
||||
continue
|
||||
bucket = _build_step_bucket(step_code)
|
||||
bucket.update(
|
||||
{
|
||||
"items_total": int(item.get("items_total", 0) or 0),
|
||||
"items_pending": int(item.get("items_pending", 0) or 0),
|
||||
"items_claimed": int(item.get("items_claimed", 0) or 0),
|
||||
"items_running": int(item.get("items_running", 0) or 0),
|
||||
"items_completed": int(item.get("items_completed", 0) or 0),
|
||||
"items_blacklisted": int(item.get("items_blacklisted", 0) or 0),
|
||||
"items_failed": int(item.get("items_failed", 0) or 0),
|
||||
"started_recent": int(item.get("started_recent", 0) or 0),
|
||||
"processed_recent": int(item.get("processed_recent", 0) or 0),
|
||||
"processed_per_minute": float(item.get("processed_per_minute", 0) or 0),
|
||||
"completed_recent": int(item.get("completed_recent", 0) or 0),
|
||||
"blacklisted_recent": int(item.get("blacklisted_recent", 0) or 0),
|
||||
"failed_recent": int(item.get("failed_recent", 0) or 0),
|
||||
}
|
||||
)
|
||||
step_map[step_code] = bucket
|
||||
|
||||
runtime_step_stats = dict((runtime_activity or {}).get("step_stats") or {})
|
||||
for step_code, stats in runtime_step_stats.items():
|
||||
normalized_step_code = str(step_code or "").strip()
|
||||
if not normalized_step_code:
|
||||
continue
|
||||
bucket = step_map.setdefault(normalized_step_code, _build_step_bucket(normalized_step_code))
|
||||
started_recent = int((stats or {}).get("started_recent", 0) or 0)
|
||||
processed_recent = int((stats or {}).get("processed_recent", 0) or 0)
|
||||
completed_recent = int((stats or {}).get("completed_recent", 0) or 0)
|
||||
blacklisted_recent = int((stats or {}).get("blacklisted_recent", 0) or 0)
|
||||
failed_recent = int((stats or {}).get("failed_recent", 0) or 0)
|
||||
bucket["started_recent"] = max(int(bucket.get("started_recent", 0) or 0), started_recent)
|
||||
bucket["processed_recent"] = max(int(bucket.get("processed_recent", 0) or 0), processed_recent)
|
||||
bucket["completed_recent"] = max(int(bucket.get("completed_recent", 0) or 0), completed_recent)
|
||||
bucket["blacklisted_recent"] = max(int(bucket.get("blacklisted_recent", 0) or 0), blacklisted_recent)
|
||||
bucket["failed_recent"] = max(int(bucket.get("failed_recent", 0) or 0), failed_recent)
|
||||
bucket["processed_per_minute"] = max(
|
||||
float(bucket.get("processed_per_minute", 0) or 0),
|
||||
round(processed_recent / safe_window_minutes, 2),
|
||||
)
|
||||
|
||||
return sorted(
|
||||
step_map.values(),
|
||||
key=lambda item: (
|
||||
-int(item.get("items_pending", 0) or 0),
|
||||
-int(item.get("items_running", 0) or 0),
|
||||
-int(item.get("started_recent", 0) or 0),
|
||||
-int(item.get("processed_recent", 0) or 0),
|
||||
str(item.get("step_code") or ""),
|
||||
),
|
||||
)[:normalized_limit]
|
||||
|
||||
|
||||
def _align_active_jobs_aggregate_with_runtime(
|
||||
aggregate: dict,
|
||||
*,
|
||||
runtime: dict,
|
||||
queue_health: dict,
|
||||
) -> dict:
|
||||
normalized = dict(aggregate or {})
|
||||
node_payload = dict((runtime or {}).get("node") or {})
|
||||
if str(node_payload.get("region") or "").strip() != "overseas" or str(node_payload.get("role") or "").strip() != "control":
|
||||
return normalized
|
||||
|
||||
backlog = dict(((runtime or {}).get("detect") or {}).get("backlog") or {})
|
||||
snapshot_backlog = dict(queue_health.get("runtime_snapshot_backlog") or {})
|
||||
|
||||
def _backlog_value(key: str) -> int:
|
||||
return max(int(backlog.get(key, 0) or 0), int(snapshot_backlog.get(key, 0) or 0))
|
||||
|
||||
pending_total = _backlog_value("pending_total")
|
||||
claimed_total = _backlog_value("claimed_total")
|
||||
running_total = _backlog_value("running_total")
|
||||
completed_total = _backlog_value("completed_total")
|
||||
blacklisted_total = _backlog_value("blacklisted_total")
|
||||
failed_total = _backlog_value("failed_total")
|
||||
|
||||
queue = dict(queue_health.get("queue") or {})
|
||||
throughput = dict(queue_health.get("throughput") or {})
|
||||
terminal_total = max(
|
||||
completed_total + blacklisted_total + failed_total,
|
||||
int(queue.get("completed", 0) or 0) + int(queue.get("blacklisted", 0) or 0) + int(queue.get("failed", 0) or 0),
|
||||
)
|
||||
items_total = pending_total + claimed_total + running_total + terminal_total
|
||||
has_runtime_work = items_total > 0 or bool(queue_health.get("has_active_job"))
|
||||
|
||||
normalized["active_jobs_total"] = max(
|
||||
int(normalized.get("active_jobs_total", 0) or 0),
|
||||
1 if has_runtime_work else 0,
|
||||
)
|
||||
normalized["queue"] = {
|
||||
"items_total": items_total,
|
||||
"pending": pending_total,
|
||||
"claimed": claimed_total,
|
||||
"running": running_total,
|
||||
"completed": max(completed_total, int(queue.get("completed", 0) or 0)),
|
||||
"blacklisted": max(blacklisted_total, int(queue.get("blacklisted", 0) or 0)),
|
||||
"failed": max(failed_total, int(queue.get("failed", 0) or 0)),
|
||||
"terminal": terminal_total,
|
||||
}
|
||||
normalized["throughput"] = {
|
||||
"processed_recent": int(throughput.get("processed_recent", 0) or 0),
|
||||
"processed_per_minute": float(throughput.get("processed_per_minute", 0) or 0),
|
||||
"completed_recent": int(throughput.get("completed_recent", 0) or 0),
|
||||
"blacklisted_recent": int(throughput.get("blacklisted_recent", 0) or 0),
|
||||
"failed_recent": int(throughput.get("failed_recent", 0) or 0),
|
||||
}
|
||||
normalized["steps"] = _merge_step_queues_with_runtime_activity(
|
||||
list(queue_health.get("steps") or []),
|
||||
runtime_activity=dict(queue_health.get("runtime_activity") or {}),
|
||||
window_minutes=int(queue_health.get("window_minutes", normalized.get("window_minutes", 15)) or 15),
|
||||
limit=8,
|
||||
)
|
||||
normalized["nodes"] = list(queue_health.get("nodes") or [])
|
||||
return normalized
|
||||
|
||||
|
||||
def _fetch_active_jobs_aggregate(window_minutes: int = 15) -> dict:
|
||||
safe_window_minutes = max(5, min(int(window_minutes or 15), 120))
|
||||
payload = _empty_active_jobs_aggregate(safe_window_minutes)
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM detect_jobs
|
||||
WHERE status IN ('pending', 'running', 'partial_failed')
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN status = 'running' THEN 0
|
||||
WHEN status = 'pending' THEN 1
|
||||
WHEN status = 'partial_failed' THEN 2
|
||||
ELSE 3
|
||||
END ASC,
|
||||
COALESCE(started_at, created_at) DESC,
|
||||
id DESC
|
||||
"""
|
||||
)
|
||||
job_ids = [int(row[0]) for row in cur.fetchall() if row and row[0] is not None]
|
||||
if not job_ids:
|
||||
return payload
|
||||
|
||||
payload["active_jobs_total"] = len(job_ids)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) AS items_total,
|
||||
COUNT(*) FILTER (WHERE status = 'pending') AS items_pending,
|
||||
COUNT(*) FILTER (WHERE status = 'claimed') AS items_claimed,
|
||||
COUNT(*) FILTER (WHERE status = 'running') AS items_running,
|
||||
COUNT(*) FILTER (WHERE status = 'completed') AS items_completed,
|
||||
COUNT(*) FILTER (WHERE status = 'blacklisted') AS items_blacklisted,
|
||||
COUNT(*) FILTER (WHERE status = 'failed') AS items_failed
|
||||
FROM detect_job_items
|
||||
WHERE job_id = ANY(%s)
|
||||
""",
|
||||
(job_ids,),
|
||||
)
|
||||
queue_row = cur.fetchone() or (0, 0, 0, 0, 0, 0, 0)
|
||||
payload["queue"] = {
|
||||
"items_total": int(queue_row[0] or 0),
|
||||
"pending": int(queue_row[1] or 0),
|
||||
"claimed": int(queue_row[2] or 0),
|
||||
"running": int(queue_row[3] or 0),
|
||||
"completed": int(queue_row[4] or 0),
|
||||
"blacklisted": int(queue_row[5] or 0),
|
||||
"failed": int(queue_row[6] or 0),
|
||||
"terminal": int(queue_row[4] or 0) + int(queue_row[5] or 0) + int(queue_row[6] or 0),
|
||||
}
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) AS processed_recent,
|
||||
COUNT(*) FILTER (WHERE event_type = 'domain_completed') AS completed_recent,
|
||||
COUNT(*) FILTER (WHERE event_type = 'domain_blacklisted') AS blacklisted_recent,
|
||||
COUNT(*) FILTER (WHERE event_type = 'domain_failed') AS failed_recent
|
||||
FROM detect_run_events
|
||||
WHERE job_id = ANY(%s)
|
||||
AND event_type IN ('domain_completed', 'domain_blacklisted', 'domain_failed')
|
||||
AND created_at >= CURRENT_TIMESTAMP - (%s || ' minutes')::interval
|
||||
""",
|
||||
(job_ids, safe_window_minutes),
|
||||
)
|
||||
throughput_row = cur.fetchone() or (0, 0, 0, 0)
|
||||
processed_recent = int(throughput_row[0] or 0)
|
||||
payload["throughput"] = {
|
||||
"processed_recent": processed_recent,
|
||||
"processed_per_minute": round(processed_recent / safe_window_minutes, 2),
|
||||
"completed_recent": int(throughput_row[1] or 0),
|
||||
"blacklisted_recent": int(throughput_row[2] or 0),
|
||||
"failed_recent": int(throughput_row[3] or 0),
|
||||
}
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM detect_job_items
|
||||
WHERE job_id = ANY(%s)
|
||||
AND attempt_count > 1
|
||||
""",
|
||||
(job_ids,),
|
||||
)
|
||||
payload["retry_total"] = int((cur.fetchone() or [0])[0] or 0)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
COALESCE(NULLIF(step_code, ''), 'domain_pipeline') AS step_code,
|
||||
COUNT(*) AS items_total,
|
||||
COUNT(*) FILTER (WHERE status = 'pending') AS items_pending,
|
||||
COUNT(*) FILTER (WHERE status = 'claimed') AS items_claimed,
|
||||
COUNT(*) FILTER (WHERE status = 'running') AS items_running,
|
||||
COUNT(*) FILTER (WHERE status = 'completed') AS items_completed,
|
||||
COUNT(*) FILTER (WHERE status = 'blacklisted') AS items_blacklisted,
|
||||
COUNT(*) FILTER (WHERE status = 'failed') AS items_failed,
|
||||
COUNT(*) FILTER (
|
||||
WHERE status IN ('completed', 'blacklisted', 'failed')
|
||||
AND finished_at >= CURRENT_TIMESTAMP - (%s || ' minutes')::interval
|
||||
) AS processed_recent
|
||||
FROM detect_job_items
|
||||
WHERE job_id = ANY(%s)
|
||||
GROUP BY COALESCE(NULLIF(step_code, ''), 'domain_pipeline')
|
||||
ORDER BY
|
||||
COUNT(*) FILTER (WHERE status = 'pending') DESC,
|
||||
COUNT(*) FILTER (WHERE status = 'running') DESC,
|
||||
COUNT(*) DESC,
|
||||
COALESCE(NULLIF(step_code, ''), 'domain_pipeline') ASC
|
||||
LIMIT 8
|
||||
""",
|
||||
(safe_window_minutes, job_ids),
|
||||
)
|
||||
steps: list[dict] = []
|
||||
for row in cur.fetchall():
|
||||
step_code = str(row[0] or "domain_pipeline")
|
||||
bucket = _build_step_bucket(step_code)
|
||||
bucket.update(
|
||||
{
|
||||
"items_total": int(row[1] or 0),
|
||||
"items_pending": int(row[2] or 0),
|
||||
"items_claimed": int(row[3] or 0),
|
||||
"items_running": int(row[4] or 0),
|
||||
"items_completed": int(row[5] or 0),
|
||||
"items_blacklisted": int(row[6] or 0),
|
||||
"items_failed": int(row[7] or 0),
|
||||
"processed_recent": int(row[8] or 0),
|
||||
"processed_per_minute": round(int(row[8] or 0) / safe_window_minutes, 2),
|
||||
}
|
||||
)
|
||||
steps.append(bucket)
|
||||
payload["steps"] = steps
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
COALESCE(NULLIF(claimed_by, ''), 'unassigned') AS node_code,
|
||||
COUNT(*) FILTER (WHERE status = 'claimed') AS items_claimed,
|
||||
COUNT(*) FILTER (WHERE status = 'running') AS items_running,
|
||||
COUNT(*) FILTER (WHERE status = 'completed') AS items_completed_total
|
||||
FROM detect_job_items
|
||||
WHERE job_id = ANY(%s)
|
||||
GROUP BY COALESCE(NULLIF(claimed_by, ''), 'unassigned')
|
||||
""",
|
||||
(job_ids,),
|
||||
)
|
||||
node_map = {
|
||||
str(row[0] or "unassigned"): {
|
||||
"node_code": str(row[0] or "unassigned"),
|
||||
"items_running": int(row[2] or 0),
|
||||
"items_claimed": int(row[1] or 0),
|
||||
"items_completed": int(row[3] or 0),
|
||||
"processed_recent": 0,
|
||||
"processed_per_minute": 0,
|
||||
"completed_recent": 0,
|
||||
"failed_recent": 0,
|
||||
"blacklisted_recent": 0,
|
||||
}
|
||||
for row in cur.fetchall()
|
||||
}
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
COALESCE(NULLIF(node_code, ''), 'unassigned') AS node_code,
|
||||
COUNT(*) AS processed_recent,
|
||||
COUNT(*) FILTER (WHERE event_type = 'domain_completed') AS completed_recent,
|
||||
COUNT(*) FILTER (WHERE event_type = 'domain_blacklisted') AS blacklisted_recent,
|
||||
COUNT(*) FILTER (WHERE event_type = 'domain_failed') AS failed_recent
|
||||
FROM detect_run_events
|
||||
WHERE job_id = ANY(%s)
|
||||
AND event_type IN ('domain_completed', 'domain_blacklisted', 'domain_failed')
|
||||
AND created_at >= CURRENT_TIMESTAMP - (%s || ' minutes')::interval
|
||||
GROUP BY COALESCE(NULLIF(node_code, ''), 'unassigned')
|
||||
""",
|
||||
(job_ids, safe_window_minutes),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
node_code = str(row[0] or "unassigned")
|
||||
bucket = node_map.setdefault(
|
||||
node_code,
|
||||
{
|
||||
"node_code": node_code,
|
||||
"items_running": 0,
|
||||
"items_claimed": 0,
|
||||
"items_completed": 0,
|
||||
"processed_recent": 0,
|
||||
"processed_per_minute": 0,
|
||||
"completed_recent": 0,
|
||||
"failed_recent": 0,
|
||||
"blacklisted_recent": 0,
|
||||
},
|
||||
)
|
||||
processed_recent = int(row[1] or 0)
|
||||
bucket["processed_recent"] = processed_recent
|
||||
bucket["processed_per_minute"] = round(processed_recent / safe_window_minutes, 2)
|
||||
bucket["completed_recent"] = int(row[2] or 0)
|
||||
bucket["blacklisted_recent"] = int(row[3] or 0)
|
||||
bucket["failed_recent"] = int(row[4] or 0)
|
||||
|
||||
payload["nodes"] = sorted(
|
||||
node_map.values(),
|
||||
key=lambda item: (
|
||||
-int(item.get("items_running", 0) or 0),
|
||||
-int(item.get("processed_recent", 0) or 0),
|
||||
-int(item.get("items_claimed", 0) or 0),
|
||||
str(item.get("node_code") or ""),
|
||||
),
|
||||
)[:8]
|
||||
return payload
|
||||
|
||||
|
||||
def fetch_overview() -> dict:
|
||||
window_minutes = 15
|
||||
queries = {
|
||||
"domains_total": "select count(*) from domains",
|
||||
"pending_total": "select count(*) from domains where detect_status = 0",
|
||||
@@ -12,6 +392,8 @@ def fetch_overview() -> dict:
|
||||
"running_total": "select count(*) from domains where detect_status = 2",
|
||||
"blacklist_total": "select count(*) from domains where detect_status = 3",
|
||||
"failed_total": "select count(*) from domains where detect_status = 4",
|
||||
"registerable_total": "select count(*) from domains where detect_status = 1 and register_status = 2",
|
||||
"purchasable_total": "select count(*) from domains where detect_status = 1 and register_status = 2 and coalesce(use_status, 0) = 0",
|
||||
"sensitive_words_total": "select count(*) from sensitive_words",
|
||||
}
|
||||
result: dict[str, int | str] = {}
|
||||
@@ -23,6 +405,11 @@ def fetch_overview() -> dict:
|
||||
result[key] = cur.fetchone()[0]
|
||||
except Exception:
|
||||
result[key] = 0
|
||||
|
||||
active_jobs_aggregate = _fetch_active_jobs_aggregate(window_minutes=window_minutes)
|
||||
active_job = get_active_detect_job_summary(event_limit=20) or {}
|
||||
aggregate_queue = active_jobs_aggregate.get("queue") or {}
|
||||
|
||||
runtime = get_runtime_status()
|
||||
cluster_summary = ((runtime.get("cluster") or {}).get("summary") or {})
|
||||
online_worker_nodes = int(cluster_summary.get("online_worker_nodes", 0) or 0)
|
||||
@@ -37,4 +424,274 @@ def fetch_overview() -> dict:
|
||||
result["worker_mode"] = runtime["worker"]["mode"]
|
||||
result["node_region"] = runtime["node"]["region"]
|
||||
result["node_role"] = runtime["node"]["role"]
|
||||
|
||||
queue_health = get_detect_queue_health(window_minutes=window_minutes)
|
||||
active_jobs_aggregate = _align_active_jobs_aggregate_with_runtime(
|
||||
active_jobs_aggregate,
|
||||
runtime=runtime,
|
||||
queue_health=queue_health,
|
||||
)
|
||||
runtime_snapshot_backlog = dict(queue_health.get("runtime_snapshot_backlog") or {})
|
||||
aggregate_queue = active_jobs_aggregate.get("queue") or {}
|
||||
aggregate_queue_health = {
|
||||
"has_active_job": bool(int(active_jobs_aggregate.get("active_jobs_total", 0) or 0) > 0),
|
||||
"queue": aggregate_queue,
|
||||
"throughput": active_jobs_aggregate.get("throughput") or {},
|
||||
}
|
||||
selected_queue_health = aggregate_queue_health if aggregate_queue_health["has_active_job"] else queue_health
|
||||
if float((queue_health.get("throughput") or {}).get("processed_per_minute", 0) or 0) > float(
|
||||
(selected_queue_health.get("throughput") or {}).get("processed_per_minute", 0) or 0
|
||||
):
|
||||
selected_queue_health = queue_health
|
||||
capacity_plan = get_detect_capacity_plan(
|
||||
queue_health=selected_queue_health,
|
||||
online_worker_nodes=online_worker_nodes,
|
||||
)
|
||||
|
||||
retry_total = int(active_jobs_aggregate.get("retry_total", 0) or 0)
|
||||
step_queue: list[dict] = []
|
||||
node_throughput: list[dict] = []
|
||||
bottleneck_step: dict | None = None
|
||||
active_job_summary: dict | None = None
|
||||
|
||||
if queue_health.get("has_active_job"):
|
||||
job_payload = queue_health.get("job") or {}
|
||||
queue_payload = queue_health.get("queue") or {}
|
||||
throughput_payload = queue_health.get("throughput") or {}
|
||||
runtime_job_code = str(job_payload.get("runtime_job_code") or "").strip()
|
||||
display_job_code = runtime_job_code or str(job_payload.get("job_code") or "")
|
||||
active_job_summary = {
|
||||
"job_id": int(job_payload.get("job_id", 0) or 0),
|
||||
"job_code": display_job_code,
|
||||
"db_job_code": str(job_payload.get("job_code") or ""),
|
||||
"runtime_job_code": runtime_job_code,
|
||||
"status": str(job_payload.get("status") or ""),
|
||||
"progress_percent": float(job_payload.get("progress_percent", 0) or 0),
|
||||
"items_total": int(queue_payload.get("items_total", 0) or 0),
|
||||
"items_pending": int(queue_payload.get("pending", 0) or 0),
|
||||
"items_claimed": int(queue_payload.get("display_claimed", queue_payload.get("claimed", 0)) or 0),
|
||||
"items_running": int(queue_payload.get("running", 0) or 0),
|
||||
"items_display_running": int(queue_payload.get("display_running", queue_payload.get("running", 0)) or 0),
|
||||
"items_completed": int(queue_payload.get("completed", 0) or 0),
|
||||
"items_blacklisted": int(queue_payload.get("blacklisted", 0) or 0),
|
||||
"items_failed": int(queue_payload.get("failed", 0) or 0),
|
||||
"processed_per_minute": float(throughput_payload.get("processed_per_minute", 0) or 0),
|
||||
"processed_recent": int(throughput_payload.get("processed_recent", 0) or 0),
|
||||
"completed_recent": int(throughput_payload.get("completed_recent", 0) or 0),
|
||||
"failed_recent": int(throughput_payload.get("failed_recent", 0) or 0),
|
||||
"blacklisted_recent": int(throughput_payload.get("blacklisted_recent", 0) or 0),
|
||||
"active_jobs_total": int(active_jobs_aggregate.get("active_jobs_total", 0) or 0),
|
||||
}
|
||||
|
||||
queue_pending_total = 0
|
||||
queue_claimed_total = 0
|
||||
queue_running_total = 0
|
||||
queue_display_running_total = 0
|
||||
queue_completed_total = 0
|
||||
queue_blacklist_total = 0
|
||||
queue_failed_total = 0
|
||||
backlog_payload = ((runtime.get("detect") or {}).get("backlog") or {})
|
||||
backlog_pending_total = max(
|
||||
int(backlog_payload.get("pending_total", 0) or 0),
|
||||
int(runtime_snapshot_backlog.get("pending_total", 0) or 0),
|
||||
)
|
||||
backlog_claimed_total = max(
|
||||
int(backlog_payload.get("claimed_total", 0) or 0),
|
||||
int(runtime_snapshot_backlog.get("claimed_total", 0) or 0),
|
||||
)
|
||||
backlog_running_total = max(
|
||||
int(backlog_payload.get("running_total", 0) or 0),
|
||||
int(runtime_snapshot_backlog.get("running_total", 0) or 0),
|
||||
)
|
||||
backlog_register_pending_total = max(
|
||||
int(backlog_payload.get("register_pending", 0) or 0),
|
||||
int(runtime_snapshot_backlog.get("register_pending", 0) or 0),
|
||||
)
|
||||
backlog_downstream_pending_total = max(
|
||||
int(backlog_payload.get("downstream_pending", 0) or 0),
|
||||
int(runtime_snapshot_backlog.get("downstream_pending", 0) or 0),
|
||||
)
|
||||
if queue_health.get("has_active_job"):
|
||||
queue_payload = queue_health.get("queue") or {}
|
||||
queue_pending_total = int(queue_payload.get("pending", 0) or 0)
|
||||
queue_claimed_total = int(queue_payload.get("display_claimed", queue_payload.get("claimed", 0)) or 0)
|
||||
queue_running_total = int(queue_payload.get("running", 0) or 0)
|
||||
queue_display_running_total = int(queue_payload.get("display_running", queue_payload.get("running", 0)) or 0)
|
||||
queue_completed_total = int(queue_payload.get("completed", 0) or 0)
|
||||
queue_blacklist_total = int(queue_payload.get("blacklisted", 0) or 0)
|
||||
queue_failed_total = int(queue_payload.get("failed", 0) or 0)
|
||||
elif int(active_jobs_aggregate.get("active_jobs_total", 0) or 0) > 0:
|
||||
queue_pending_total = int(aggregate_queue.get("pending", 0) or 0)
|
||||
queue_claimed_total = int(aggregate_queue.get("claimed", 0) or 0)
|
||||
queue_running_total = int(aggregate_queue.get("running", 0) or 0)
|
||||
queue_display_running_total = queue_running_total
|
||||
queue_completed_total = int(aggregate_queue.get("completed", 0) or 0)
|
||||
queue_blacklist_total = int(aggregate_queue.get("blacklisted", 0) or 0)
|
||||
queue_failed_total = int(aggregate_queue.get("failed", 0) or 0)
|
||||
elif active_job:
|
||||
queue_pending_total = int(active_job.get("items_pending", 0) or 0)
|
||||
queue_claimed_total = int(active_job.get("items_claimed", 0) or 0)
|
||||
queue_running_total = int(active_job.get("items_running", 0) or 0)
|
||||
queue_display_running_total = int(
|
||||
active_job.get("display_items_running", active_job.get("items_running", 0)) or 0
|
||||
)
|
||||
queue_completed_total = int(active_job.get("items_completed", 0) or 0)
|
||||
queue_blacklist_total = int(active_job.get("items_blacklisted", 0) or 0)
|
||||
queue_failed_total = int(active_job.get("items_failed", 0) or 0)
|
||||
|
||||
aggregate_step_queue = [
|
||||
{
|
||||
"step_code": str(item.get("step_code") or ""),
|
||||
"step_name": str(item.get("step_name") or ""),
|
||||
"items_pending": int(item.get("items_pending", 0) or 0),
|
||||
"items_running": int(item.get("items_running", 0) or 0),
|
||||
"items_claimed": int(item.get("items_claimed", 0) or 0),
|
||||
"items_completed": int(item.get("items_completed", 0) or 0),
|
||||
"items_blacklisted": int(item.get("items_blacklisted", 0) or 0),
|
||||
"items_failed": int(item.get("items_failed", 0) or 0),
|
||||
"started_recent": int(item.get("started_recent", 0) or 0),
|
||||
"processed_per_minute": float(item.get("processed_per_minute", 0) or 0),
|
||||
"processed_recent": int(item.get("processed_recent", 0) or 0),
|
||||
"completed_recent": int(item.get("completed_recent", 0) or 0),
|
||||
"blacklisted_recent": int(item.get("blacklisted_recent", 0) or 0),
|
||||
"failed_recent": int(item.get("failed_recent", 0) or 0),
|
||||
}
|
||||
for item in list(active_jobs_aggregate.get("steps") or [])[:8]
|
||||
]
|
||||
aggregate_node_throughput = [
|
||||
{
|
||||
"node_code": str(item.get("node_code") or ""),
|
||||
"items_pending": int(item.get("items_pending", 0) or 0),
|
||||
"items_running": int(item.get("items_running", 0) or 0),
|
||||
"display_running": int(item.get("display_running", item.get("items_running", 0)) or 0),
|
||||
"items_claimed": int(item.get("items_claimed", 0) or 0),
|
||||
"current_load": int(item.get("current_load", item.get("display_running", 0)) or 0),
|
||||
"active_threads": int(item.get("active_threads", 0) or 0),
|
||||
"max_threads": int(item.get("max_threads", 0) or 0),
|
||||
"processed_recent": int(item.get("processed_recent", 0) or 0),
|
||||
"processed_per_minute": float(item.get("processed_per_minute", 0) or 0),
|
||||
"completed_recent": int(item.get("completed_recent", 0) or 0),
|
||||
"failed_recent": int(item.get("failed_recent", 0) or 0),
|
||||
"blacklisted_recent": int(item.get("blacklisted_recent", 0) or 0),
|
||||
}
|
||||
for item in list(active_jobs_aggregate.get("nodes") or [])[:8]
|
||||
]
|
||||
queue_step_queue = [
|
||||
{
|
||||
"step_code": str(item.get("step_code") or ""),
|
||||
"step_name": str(item.get("step_name") or ""),
|
||||
"items_pending": int(item.get("items_pending", 0) or 0),
|
||||
"items_running": int(item.get("items_running", 0) or 0),
|
||||
"items_claimed": int(item.get("items_claimed", 0) or 0),
|
||||
"items_completed": int(item.get("items_completed", 0) or 0),
|
||||
"items_blacklisted": int(item.get("items_blacklisted", 0) or 0),
|
||||
"items_failed": int(item.get("items_failed", 0) or 0),
|
||||
"started_recent": int(item.get("started_recent", 0) or 0),
|
||||
"processed_per_minute": float(item.get("processed_per_minute", 0) or 0),
|
||||
"processed_recent": int(item.get("processed_recent", 0) or 0),
|
||||
"completed_recent": int(item.get("completed_recent", 0) or 0),
|
||||
"blacklisted_recent": int(item.get("blacklisted_recent", 0) or 0),
|
||||
"failed_recent": int(item.get("failed_recent", 0) or 0),
|
||||
}
|
||||
for item in _merge_step_queues_with_runtime_activity(
|
||||
list(queue_health.get("steps") or []),
|
||||
runtime_activity=dict(queue_health.get("runtime_activity") or {}),
|
||||
window_minutes=window_minutes,
|
||||
limit=8,
|
||||
)
|
||||
]
|
||||
queue_node_throughput = [
|
||||
{
|
||||
"node_code": str(item.get("node_code") or ""),
|
||||
"items_pending": int(item.get("items_pending", 0) or 0),
|
||||
"items_running": int(item.get("items_running", 0) or 0),
|
||||
"display_running": int(item.get("display_running", item.get("items_running", 0)) or 0),
|
||||
"items_claimed": int(item.get("items_claimed", 0) or 0),
|
||||
"current_load": int(item.get("current_load", item.get("display_running", 0)) or 0),
|
||||
"active_threads": int(item.get("active_threads", 0) or 0),
|
||||
"max_threads": int(item.get("max_threads", 0) or 0),
|
||||
"processed_recent": int(item.get("processed_recent", 0) or 0),
|
||||
"processed_per_minute": float(item.get("processed_per_minute", 0) or 0),
|
||||
"completed_recent": int(item.get("completed_recent", 0) or 0),
|
||||
"failed_recent": int(item.get("failed_recent", 0) or 0),
|
||||
"blacklisted_recent": int(item.get("blacklisted_recent", 0) or 0),
|
||||
}
|
||||
for item in list(queue_health.get("nodes") or [])[:8]
|
||||
]
|
||||
step_queue = aggregate_step_queue
|
||||
node_throughput = aggregate_node_throughput
|
||||
aggregate_ppm = float((active_jobs_aggregate.get("throughput") or {}).get("processed_per_minute", 0) or 0)
|
||||
queue_ppm = float((queue_health.get("throughput") or {}).get("processed_per_minute", 0) or 0)
|
||||
if queue_health.get("has_active_job") or queue_ppm > aggregate_ppm:
|
||||
step_queue = queue_step_queue
|
||||
node_throughput = queue_node_throughput
|
||||
if step_queue:
|
||||
bottleneck_step = max(
|
||||
step_queue,
|
||||
key=lambda item: (
|
||||
int(item.get("items_pending", 0) or 0),
|
||||
int(item.get("items_running", 0) or 0),
|
||||
-float(item.get("processed_per_minute", 0) or 0),
|
||||
),
|
||||
)
|
||||
|
||||
result["active_job"] = active_job_summary or {}
|
||||
result["queue_health"] = queue_health
|
||||
result["active_jobs_aggregate"] = active_jobs_aggregate
|
||||
result["capacity_plan"] = capacity_plan
|
||||
result["step_queue"] = step_queue
|
||||
result["node_throughput"] = node_throughput
|
||||
result["retry_total"] = retry_total
|
||||
result["bottleneck_step"] = bottleneck_step or {}
|
||||
aggregate_throughput = active_jobs_aggregate.get("throughput") or {}
|
||||
queue_throughput = queue_health.get("throughput") or {}
|
||||
ops_processed_per_minute = float(aggregate_throughput.get("processed_per_minute", 0) or 0)
|
||||
ops_processed_recent = int(aggregate_throughput.get("processed_recent", 0) or 0)
|
||||
ops_completed_recent = int(aggregate_throughput.get("completed_recent", 0) or 0)
|
||||
ops_failed_recent = int(aggregate_throughput.get("failed_recent", 0) or 0)
|
||||
ops_blacklisted_recent = int(aggregate_throughput.get("blacklisted_recent", 0) or 0)
|
||||
if queue_health.get("has_active_job") or float(queue_throughput.get("processed_per_minute", 0) or 0) > ops_processed_per_minute:
|
||||
ops_processed_per_minute = float(queue_throughput.get("processed_per_minute", 0) or 0)
|
||||
ops_processed_recent = int(queue_throughput.get("processed_recent", 0) or 0)
|
||||
ops_completed_recent = int(queue_throughput.get("completed_recent", 0) or 0)
|
||||
ops_failed_recent = int(queue_throughput.get("failed_recent", 0) or 0)
|
||||
ops_blacklisted_recent = int(queue_throughput.get("blacklisted_recent", 0) or 0)
|
||||
result["ops_summary"] = {
|
||||
"active_jobs_total": int(active_jobs_aggregate.get("active_jobs_total", 0) or 0),
|
||||
"processed_per_minute": ops_processed_per_minute,
|
||||
"processed_recent": ops_processed_recent,
|
||||
"completed_recent": ops_completed_recent,
|
||||
"failed_recent": ops_failed_recent,
|
||||
"blacklisted_recent": ops_blacklisted_recent,
|
||||
"estimated_hours_remaining": float(capacity_plan.get("estimated_hours_remaining", 0) or 0),
|
||||
"remaining_items": int(capacity_plan.get("remaining_items", 0) or 0),
|
||||
"recommended_additional_workers": int(capacity_plan.get("recommended_additional_workers", 0) or 0),
|
||||
"online_worker_nodes": online_worker_nodes,
|
||||
"dedicated_online_worker_nodes": dedicated_online_worker_nodes,
|
||||
"active_execution_nodes": sum(
|
||||
1
|
||||
for item in node_throughput
|
||||
if int(item.get("items_running", 0) or 0) > 0
|
||||
or int(item.get("items_claimed", 0) or 0) > 0
|
||||
or int(item.get("processed_recent", 0) or 0) > 0
|
||||
),
|
||||
}
|
||||
result["processed_per_minute"] = ops_processed_per_minute
|
||||
result["processed_recent"] = ops_processed_recent
|
||||
result["completed_recent"] = ops_completed_recent
|
||||
result["failed_recent"] = ops_failed_recent
|
||||
result["blacklisted_recent"] = ops_blacklisted_recent
|
||||
result["active_execution_nodes"] = int(result["ops_summary"]["active_execution_nodes"] or 0)
|
||||
result["queue_pending_total"] = queue_pending_total
|
||||
result["queue_claimed_total"] = queue_claimed_total
|
||||
result["queue_running_total"] = queue_running_total
|
||||
result["queue_display_running_total"] = max(queue_display_running_total, queue_running_total)
|
||||
result["queue_completed_total"] = queue_completed_total
|
||||
result["queue_blacklist_total"] = queue_blacklist_total
|
||||
result["queue_failed_total"] = queue_failed_total
|
||||
result["backlog_pending_total"] = max(backlog_pending_total, queue_pending_total)
|
||||
result["backlog_claimed_total"] = max(backlog_claimed_total, queue_claimed_total)
|
||||
result["backlog_running_total"] = max(backlog_running_total, queue_running_total)
|
||||
result["backlog_register_pending_total"] = backlog_register_pending_total
|
||||
result["backlog_downstream_pending_total"] = backlog_downstream_pending_total
|
||||
return result
|
||||
|
||||
@@ -2,12 +2,13 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
from app.core.db import db_read_retry, get_db
|
||||
|
||||
|
||||
_DEBUG_SCHEMA_SQL = """
|
||||
@@ -27,6 +28,9 @@ CREATE INDEX IF NOT EXISTS idx_detect_debug_events_created
|
||||
ON detect_debug_events(created_at DESC);
|
||||
"""
|
||||
|
||||
_DEBUG_SCHEMA_READY = False
|
||||
_DEBUG_SCHEMA_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _format_time(value: datetime | None) -> str:
|
||||
return value.isoformat(sep=" ", timespec="seconds") if value else ""
|
||||
@@ -82,11 +86,18 @@ def _debug_ingest_url(base_url: str) -> str:
|
||||
|
||||
|
||||
def ensure_debug_event_schema() -> None:
|
||||
with get_db() as conn:
|
||||
conn.autocommit = False
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(_DEBUG_SCHEMA_SQL)
|
||||
conn.commit()
|
||||
global _DEBUG_SCHEMA_READY
|
||||
if _DEBUG_SCHEMA_READY:
|
||||
return
|
||||
with _DEBUG_SCHEMA_LOCK:
|
||||
if _DEBUG_SCHEMA_READY:
|
||||
return
|
||||
with get_db() as conn:
|
||||
conn.autocommit = False
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(_DEBUG_SCHEMA_SQL)
|
||||
conn.commit()
|
||||
_DEBUG_SCHEMA_READY = True
|
||||
|
||||
|
||||
def append_debug_event(
|
||||
@@ -159,12 +170,185 @@ def append_debug_event(
|
||||
return record_id
|
||||
|
||||
|
||||
def _load_debug_event_record(record_id: int) -> dict | None:
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, source_region, node_code, service, event_type, level, message, payload_json, created_at
|
||||
FROM detect_debug_events
|
||||
WHERE id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(int(record_id),),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"id": int(row[0]),
|
||||
"source_region": str(row[1] or ""),
|
||||
"node_code": str(row[2] or ""),
|
||||
"service": str(row[3] or ""),
|
||||
"event_type": str(row[4] or ""),
|
||||
"level": str(row[5] or "info"),
|
||||
"message": str(row[6] or ""),
|
||||
"payload": row[7] if isinstance(row[7], dict) else {},
|
||||
"created_at": _format_time(row[8]),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_worker_log_event(debug_event: dict) -> dict | None:
|
||||
payload = dict(debug_event.get("payload") or {})
|
||||
message = _normalize_message(debug_event.get("message"), fallback="worker_log")
|
||||
domain = str(payload.get("domain") or "").strip().lower()
|
||||
status = str(payload.get("status") or "").strip().lower()
|
||||
if not domain and ":" in message:
|
||||
domain = message.rsplit(":", 1)[-1].strip().lower()
|
||||
|
||||
event_type = ""
|
||||
if status == "completed":
|
||||
event_type = "domain_completed"
|
||||
elif status == "failed":
|
||||
event_type = "domain_failed"
|
||||
elif status == "blacklisted":
|
||||
event_type = "domain_blacklisted"
|
||||
elif "开始检测域名" in message:
|
||||
event_type = "domain_started"
|
||||
elif "域名检测完成" in message:
|
||||
event_type = "domain_completed"
|
||||
payload.setdefault("status", "completed")
|
||||
elif "域名已命中黑名单" in message or "命中黑名单" in message:
|
||||
event_type = "domain_blacklisted"
|
||||
payload.setdefault("status", "blacklisted")
|
||||
elif "域名检测失败" in message:
|
||||
event_type = "domain_failed"
|
||||
payload.setdefault("status", "failed")
|
||||
|
||||
if not event_type or not domain:
|
||||
return None
|
||||
|
||||
payload.setdefault("domain", domain)
|
||||
payload["imported_from_debug_event"] = True
|
||||
payload["debug_event_record_id"] = int(debug_event.get("id") or 0)
|
||||
payload["debug_event_source_region"] = str(debug_event.get("source_region") or "")
|
||||
return {
|
||||
"node_code": str(debug_event.get("node_code") or "").strip(),
|
||||
"event_type": event_type,
|
||||
"level": str(debug_event.get("level") or "info"),
|
||||
"message": message,
|
||||
"payload": payload,
|
||||
"created_at": str(debug_event.get("created_at") or "").strip(),
|
||||
}
|
||||
|
||||
|
||||
def _ingest_worker_log_into_active_job(debug_event: dict) -> dict:
|
||||
if str(debug_event.get("event_type") or "").strip() != "worker_log":
|
||||
return {"imported": False, "reason": "not_worker_log"}
|
||||
|
||||
normalized_event = _normalize_worker_log_event(debug_event)
|
||||
if not normalized_event:
|
||||
return {"imported": False, "reason": "not_domain_progress_event"}
|
||||
|
||||
from app.services.detect_job_service import get_active_detect_job_summary
|
||||
from app.services.sync_push_service import (
|
||||
_apply_detect_result_event_to_domain,
|
||||
_apply_detect_result_event_to_job_item,
|
||||
)
|
||||
|
||||
active_job = get_active_detect_job_summary(event_limit=1) or {}
|
||||
target_job_id = int(active_job.get("job_id") or 0)
|
||||
if target_job_id <= 0:
|
||||
return {"imported": False, "reason": "no_active_job"}
|
||||
|
||||
debug_event_record_id = int(debug_event.get("id") or 0)
|
||||
updated_job_items = 0
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM detect_run_events
|
||||
WHERE job_id = %s
|
||||
AND (payload_json->>'debug_event_record_id') = %s
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(target_job_id, str(debug_event_record_id)),
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
if existing:
|
||||
return {
|
||||
"imported": False,
|
||||
"reason": "deduplicated",
|
||||
"target_job_id": target_job_id,
|
||||
"detect_run_event_id": int(existing[0]),
|
||||
}
|
||||
|
||||
created_at = _parse_time(normalized_event.get("created_at"))
|
||||
payload_json = _safe_json_dumps(normalized_event.get("payload") or {})
|
||||
if created_at:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO detect_run_events (
|
||||
job_id, node_code, event_type, level, message, payload_json, created_at
|
||||
) VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
target_job_id,
|
||||
normalized_event["node_code"],
|
||||
normalized_event["event_type"],
|
||||
normalized_event["level"],
|
||||
normalized_event["message"],
|
||||
payload_json,
|
||||
created_at,
|
||||
),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO detect_run_events (
|
||||
job_id, node_code, event_type, level, message, payload_json
|
||||
) VALUES (%s, %s, %s, %s, %s, %s::jsonb)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
target_job_id,
|
||||
normalized_event["node_code"],
|
||||
normalized_event["event_type"],
|
||||
normalized_event["level"],
|
||||
normalized_event["message"],
|
||||
payload_json,
|
||||
),
|
||||
)
|
||||
detect_run_event_id = int((cur.fetchone() or [0])[0] or 0)
|
||||
_apply_detect_result_event_to_domain(cur, normalized_event)
|
||||
updated_job_items = _apply_detect_result_event_to_job_item(
|
||||
cur,
|
||||
target_job_id=target_job_id,
|
||||
event=normalized_event,
|
||||
)
|
||||
conn.commit()
|
||||
return {
|
||||
"imported": True,
|
||||
"reason": "imported",
|
||||
"target_job_id": target_job_id,
|
||||
"detect_run_event_id": detect_run_event_id,
|
||||
"updated_job_items": updated_job_items,
|
||||
"event_type": normalized_event["event_type"],
|
||||
"domain": str((normalized_event.get("payload") or {}).get("domain") or ""),
|
||||
}
|
||||
|
||||
|
||||
@db_read_retry()
|
||||
def list_debug_events(
|
||||
limit: int = 50,
|
||||
*,
|
||||
service: str | None = None,
|
||||
event_type: str | None = None,
|
||||
source_region: str | None = None,
|
||||
node_code: str | None = None,
|
||||
level: str | None = None,
|
||||
before_id: int | None = None,
|
||||
after_id: int | None = None,
|
||||
@@ -183,6 +367,9 @@ def list_debug_events(
|
||||
if str(source_region or "").strip():
|
||||
conditions.append("source_region = %s")
|
||||
params.append(str(source_region).strip())
|
||||
if str(node_code or "").strip():
|
||||
conditions.append("node_code = %s")
|
||||
params.append(str(node_code).strip())
|
||||
if str(level or "").strip():
|
||||
conditions.append("level = %s")
|
||||
params.append(str(level).strip())
|
||||
@@ -237,6 +424,7 @@ def list_debug_events(
|
||||
}
|
||||
|
||||
|
||||
@db_read_retry()
|
||||
def get_debug_event_overview(*, window_minutes: int = 10, source_region: str | None = None) -> dict:
|
||||
ensure_debug_event_schema()
|
||||
safe_window = max(1, min(int(window_minutes or 10), 180))
|
||||
@@ -671,7 +859,24 @@ def ingest_debug_event(payload: dict, *, shared_token: str | None = None) -> tup
|
||||
message=_normalize_message(payload.get("message"), fallback="remote debug event"),
|
||||
payload=payload.get("payload") or {},
|
||||
)
|
||||
return True, "调试事件接收成功", {"record_id": record_id}
|
||||
debug_event = _load_debug_event_record(record_id) or {
|
||||
"id": int(record_id),
|
||||
"source_region": str(payload.get("source_region") or settings.node_region),
|
||||
"node_code": str(payload.get("node_code") or ""),
|
||||
"service": str(payload.get("service") or "unknown"),
|
||||
"event_type": str(payload.get("event_type") or "event"),
|
||||
"level": str(payload.get("level") or "info"),
|
||||
"message": _normalize_message(payload.get("message"), fallback="remote debug event"),
|
||||
"payload": payload.get("payload") or {},
|
||||
"created_at": "",
|
||||
}
|
||||
job_import = {}
|
||||
if str(debug_event.get("service") or "").strip() == "worker-event":
|
||||
try:
|
||||
job_import = _ingest_worker_log_into_active_job(debug_event)
|
||||
except Exception as exc:
|
||||
job_import = {"imported": False, "reason": f"job_import_failed: {exc}"}
|
||||
return True, "调试事件接收成功", {"record_id": record_id, "job_import": job_import}
|
||||
|
||||
|
||||
def push_debug_event(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,10 +2,14 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
from app.core.files import resolve_domain_path, tail_lines
|
||||
from app.core.redis_client import get_redis
|
||||
from app.services.debug_event_service import list_debug_events
|
||||
from app.services.cluster_runtime_service import ensure_runtime_schema
|
||||
from app.services.runtime_settings_service import get_runtime_settings
|
||||
from app.services.detect_run_service import sync_detect_runs
|
||||
from app.services.detect_job_service import get_active_detect_job_summary
|
||||
@@ -16,9 +20,79 @@ from app.services.worker_control_service import detect_worker_runtime
|
||||
|
||||
_PROXY_COUNT_RE = re.compile(r"当前可用代理数[::]\s*(\d+)")
|
||||
_THREAD_COUNT_RE = re.compile(r"当前实际线程数量[::]\s*(\d+)\s*/\s*(\d+)")
|
||||
_STEP_TRACE_DOMAIN_RE = re.compile(r"domain=([^\s|]+)")
|
||||
_REGISTER_DOMAIN_RE = re.compile(r"检测注册状态[::]\s*([^\s]+)")
|
||||
_RUNTIME_STATE_KEY = "domain_tool:detect_runtime_state"
|
||||
_TIMESTAMP_FORMATS = ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S")
|
||||
_SYSLOG_TIMESTAMP_FORMAT = "%b %d %H:%M:%S"
|
||||
_REMOTE_LOG_MAX_CHARS = 500
|
||||
_REMOTE_DEBUG_EVENT_TYPES = {
|
||||
"worker_log",
|
||||
"active_job_snapshot",
|
||||
"domain_started",
|
||||
"domain_completed",
|
||||
"domain_failed",
|
||||
"domain_blacklisted",
|
||||
"task_pull_success",
|
||||
"task_pull_partial",
|
||||
"task_pull_failed",
|
||||
"queue_overdue_leases",
|
||||
}
|
||||
|
||||
|
||||
def _extract_remote_log_node_code(line: str) -> str:
|
||||
text = str(line or "").strip()
|
||||
if not text.startswith("["):
|
||||
return ""
|
||||
first_close = text.find("]")
|
||||
if first_close < 0:
|
||||
return ""
|
||||
second_open = text.find("[", first_close + 1)
|
||||
second_close = text.find("]", second_open + 1) if second_open >= 0 else -1
|
||||
if second_open < 0 or second_close < 0:
|
||||
return ""
|
||||
return text[second_open + 1:second_close].strip()
|
||||
|
||||
|
||||
def _slice_remote_log_lines_fairly(lines: list[str], *, limit: int = 240, min_per_node: int = 12) -> list[str]:
|
||||
safe_limit = max(1, int(limit or 240))
|
||||
if len(lines) <= safe_limit:
|
||||
return list(lines or [])
|
||||
|
||||
normalized_lines = [str(line or "").strip() for line in list(lines or []) if str(line or "").strip()]
|
||||
if len(normalized_lines) <= safe_limit:
|
||||
return normalized_lines
|
||||
|
||||
if min_per_node <= 0:
|
||||
return normalized_lines[-safe_limit:]
|
||||
|
||||
kept_indexes: set[int] = set()
|
||||
per_node_counts: dict[str, int] = {}
|
||||
for index in range(len(normalized_lines) - 1, -1, -1):
|
||||
node_code = _extract_remote_log_node_code(normalized_lines[index])
|
||||
if not node_code:
|
||||
continue
|
||||
current_count = int(per_node_counts.get(node_code, 0) or 0)
|
||||
if current_count >= min_per_node:
|
||||
continue
|
||||
kept_indexes.add(index)
|
||||
per_node_counts[node_code] = current_count + 1
|
||||
if len(kept_indexes) >= safe_limit:
|
||||
break
|
||||
|
||||
for index in range(len(normalized_lines) - 1, -1, -1):
|
||||
if len(kept_indexes) >= safe_limit:
|
||||
break
|
||||
kept_indexes.add(index)
|
||||
|
||||
return [normalized_lines[index] for index in sorted(kept_indexes)]
|
||||
|
||||
|
||||
def _runtime_state_key(node_code: str | None = None) -> str:
|
||||
normalized_node_code = str(node_code or settings.node_code or "").strip()
|
||||
if not normalized_node_code:
|
||||
return _RUNTIME_STATE_KEY
|
||||
return f"{_RUNTIME_STATE_KEY}:{normalized_node_code}"
|
||||
|
||||
|
||||
def _extract_dependency_alerts(lines: list[str]) -> list[dict]:
|
||||
@@ -83,6 +157,26 @@ def _extract_active_thread_snapshot(lines: list[str]) -> dict:
|
||||
return {"active": 0, "max": 0}
|
||||
|
||||
|
||||
def _estimate_active_threads_from_recent_lines(lines: list[str], *, limit: int) -> int:
|
||||
if not lines:
|
||||
return 0
|
||||
|
||||
active_domains: list[str] = []
|
||||
seen_domains: set[str] = set()
|
||||
for line in reversed(lines[-80:]):
|
||||
match = _STEP_TRACE_DOMAIN_RE.search(line) or _REGISTER_DOMAIN_RE.search(line)
|
||||
if not match:
|
||||
continue
|
||||
domain = str(match.group(1) or "").strip()
|
||||
if not domain or domain in seen_domains:
|
||||
continue
|
||||
seen_domains.add(domain)
|
||||
active_domains.append(domain)
|
||||
if len(active_domains) >= int(limit):
|
||||
break
|
||||
return len(active_domains)
|
||||
|
||||
|
||||
def _parse_time(raw: str | None) -> datetime | None:
|
||||
if not raw:
|
||||
return None
|
||||
@@ -101,10 +195,12 @@ def _parse_time(raw: str | None) -> datetime | None:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_log_time(line: str) -> datetime | None:
|
||||
def _extract_log_time(line: str, *, reference_year: int | None = None) -> datetime | None:
|
||||
if len(line) < 19:
|
||||
return None
|
||||
candidates = [line[:26], line[:19]]
|
||||
text = str(line or "").strip()
|
||||
else:
|
||||
text = str(line or "")
|
||||
candidates = [text[:26], text[:19]]
|
||||
for candidate in candidates:
|
||||
for fmt in _TIMESTAMP_FORMATS:
|
||||
if len(candidate) != len(datetime.now().strftime(fmt)):
|
||||
@@ -113,14 +209,79 @@ def _extract_log_time(line: str) -> datetime | None:
|
||||
return datetime.strptime(candidate, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
syslog_candidate = str(text[:15] or "").strip()
|
||||
if syslog_candidate:
|
||||
try:
|
||||
parsed = datetime.strptime(syslog_candidate, _SYSLOG_TIMESTAMP_FORMAT)
|
||||
return parsed.replace(year=int(reference_year or datetime.now().year))
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _read_worker_journal_lines(service_name: str, *, max_lines: int) -> tuple[list[str], str | None]:
|
||||
normalized_service_name = str(service_name or "").strip()
|
||||
if not normalized_service_name:
|
||||
return [], None
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["journalctl", "-u", normalized_service_name, "-n", str(max_lines), "--no-pager"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=12,
|
||||
)
|
||||
except Exception:
|
||||
return [], None
|
||||
|
||||
output = str(completed.stdout or "").strip()
|
||||
if completed.returncode != 0 or not output:
|
||||
return [], None
|
||||
|
||||
lines = [str(line or "").rstrip() for line in output.splitlines() if str(line or "").strip()]
|
||||
if not lines:
|
||||
return [], None
|
||||
return lines[-max_lines:], datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _load_recent_worker_lines(runtime_settings: dict, *, max_lines: int = 160) -> tuple[bool, str | None, list[str]]:
|
||||
worker_log = resolve_domain_path("detect_worker.log", "logs/detect_worker.log")
|
||||
|
||||
worker_online = False
|
||||
last_log_time: str | None = None
|
||||
recent_lines = tail_lines("detect_worker.log", max_lines=max_lines)
|
||||
if worker_log and worker_log.exists():
|
||||
modified = datetime.fromtimestamp(worker_log.stat().st_mtime, tz=timezone.utc)
|
||||
last_log_time = modified.isoformat()
|
||||
worker_online = (datetime.now(timezone.utc) - modified).total_seconds() < 180
|
||||
|
||||
if str(runtime_settings.get("worker_mode") or "").strip() == "linux-systemd":
|
||||
service_name = str(runtime_settings.get("worker_service_name") or "").strip() or "domaincheck-worker"
|
||||
journal_lines, journal_last_time = _read_worker_journal_lines(service_name, max_lines=max_lines)
|
||||
if journal_lines:
|
||||
recent_lines = journal_lines
|
||||
worker_online = True
|
||||
if journal_last_time:
|
||||
last_log_time = journal_last_time
|
||||
|
||||
return worker_online, last_log_time, recent_lines
|
||||
|
||||
|
||||
def _filter_lines_since(lines: list[str], started_at: str | None) -> list[str]:
|
||||
started_time = _parse_time(started_at)
|
||||
if not started_time:
|
||||
return lines
|
||||
filtered = [line for line in lines if (_extract_log_time(line) or started_time) >= started_time]
|
||||
filtered: list[str] = []
|
||||
parsed_any = False
|
||||
for line in lines:
|
||||
line_time = _extract_log_time(line, reference_year=started_time.year)
|
||||
if line_time is None:
|
||||
continue
|
||||
parsed_any = True
|
||||
if line_time >= started_time:
|
||||
filtered.append(line)
|
||||
if not parsed_any:
|
||||
return lines
|
||||
return filtered or lines
|
||||
|
||||
|
||||
@@ -258,6 +419,204 @@ def _build_remote_log_snapshot(
|
||||
}
|
||||
|
||||
|
||||
def _build_remote_log_snapshot_from_debug_events(
|
||||
active_job: dict | None,
|
||||
*,
|
||||
enabled: bool,
|
||||
mode: str,
|
||||
limit: int = 240,
|
||||
) -> dict:
|
||||
if not enabled:
|
||||
return {
|
||||
"lines": [],
|
||||
"line_count": 0,
|
||||
"last_at": "",
|
||||
"last_line": "",
|
||||
"source_nodes": [],
|
||||
"source_node_count": 0,
|
||||
"source_node_summaries": [],
|
||||
}
|
||||
|
||||
normalized_mode = str(mode or "key").strip().lower()
|
||||
if normalized_mode not in {"key", "full"}:
|
||||
normalized_mode = "key"
|
||||
|
||||
participating_node_codes = {
|
||||
str(item.get("node_code") or "").strip()
|
||||
for item in list((active_job or {}).get("node_stats") or [])
|
||||
if str(item.get("node_code") or "").strip() and str(item.get("node_code") or "").strip() != "unassigned"
|
||||
}
|
||||
created_after = (datetime.now() - timedelta(hours=6)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
safe_limit = max(1, int(limit or 240))
|
||||
node_limit = max(40, min(200, safe_limit))
|
||||
records: list[dict] = []
|
||||
if participating_node_codes:
|
||||
for node_code in sorted(participating_node_codes):
|
||||
payload = list_debug_events(
|
||||
limit=node_limit,
|
||||
created_after=created_after,
|
||||
node_code=node_code,
|
||||
)
|
||||
records.extend(list(payload.get("records") or []))
|
||||
records.sort(
|
||||
key=lambda item: (
|
||||
str(item.get("created_at") or ""),
|
||||
int(item.get("id") or 0),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
else:
|
||||
payload = list_debug_events(limit=max(safe_limit * 4, 240), created_after=created_after)
|
||||
records = list(payload.get("records") or [])
|
||||
if not records:
|
||||
return {
|
||||
"lines": [],
|
||||
"line_count": 0,
|
||||
"last_at": "",
|
||||
"last_line": "",
|
||||
"source_nodes": [],
|
||||
"source_node_count": 0,
|
||||
"source_node_summaries": [],
|
||||
}
|
||||
|
||||
lines: list[str] = []
|
||||
source_nodes: set[str] = set()
|
||||
source_node_summaries: dict[str, dict] = {}
|
||||
last_at = ""
|
||||
last_line = ""
|
||||
|
||||
for record in reversed(records):
|
||||
event_type = str(record.get("event_type") or "").strip()
|
||||
if event_type not in _REMOTE_DEBUG_EVENT_TYPES:
|
||||
continue
|
||||
node_code = str(record.get("node_code") or "").strip() or "unknown"
|
||||
if participating_node_codes and node_code not in participating_node_codes:
|
||||
continue
|
||||
message = str(record.get("message") or "").strip()
|
||||
if not message:
|
||||
continue
|
||||
created_at = str(record.get("created_at") or "").strip()
|
||||
payload = record.get("payload") if isinstance(record.get("payload"), dict) else {}
|
||||
event_mode = str(payload.get("log_mode") or "key").strip().lower()
|
||||
if event_mode not in {"key", "full"}:
|
||||
event_mode = "key"
|
||||
if normalized_mode != "full" and event_mode == "full":
|
||||
continue
|
||||
if len(message) > _REMOTE_LOG_MAX_CHARS:
|
||||
message = f"{message[:_REMOTE_LOG_MAX_CHARS]}..."
|
||||
formatted_line = f"[{created_at}] [{node_code}] {message}"
|
||||
lines.append(formatted_line)
|
||||
source_nodes.add(node_code)
|
||||
node_summary = source_node_summaries.setdefault(
|
||||
node_code,
|
||||
{
|
||||
"node_code": node_code,
|
||||
"line_count": 0,
|
||||
"key_line_count": 0,
|
||||
"full_line_count": 0,
|
||||
"last_at": "",
|
||||
"last_line": "",
|
||||
},
|
||||
)
|
||||
node_summary["line_count"] += 1
|
||||
if event_mode == "full":
|
||||
node_summary["full_line_count"] += 1
|
||||
else:
|
||||
node_summary["key_line_count"] += 1
|
||||
node_summary["last_at"] = created_at
|
||||
node_summary["last_line"] = formatted_line
|
||||
last_at = created_at
|
||||
last_line = formatted_line
|
||||
|
||||
sliced_lines = _slice_remote_log_lines_fairly(lines, limit=safe_limit)
|
||||
sorted_source_node_summaries = sorted(
|
||||
source_node_summaries.values(),
|
||||
key=lambda item: (
|
||||
str(item.get("last_at") or ""),
|
||||
str(item.get("node_code") or ""),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
return {
|
||||
"lines": sliced_lines,
|
||||
"line_count": len(sliced_lines),
|
||||
"last_at": last_at,
|
||||
"last_line": last_line,
|
||||
"source_nodes": sorted(source_nodes),
|
||||
"source_node_count": len(source_nodes),
|
||||
"source_node_summaries": sorted_source_node_summaries,
|
||||
}
|
||||
|
||||
|
||||
def _merge_remote_log_snapshots(primary: dict, secondary: dict, *, limit: int = 240) -> dict:
|
||||
merged_lines: list[str] = []
|
||||
seen_lines: set[str] = set()
|
||||
for raw_line in list(primary.get("lines") or []) + list(secondary.get("lines") or []):
|
||||
line = str(raw_line or "").strip()
|
||||
if not line or line in seen_lines:
|
||||
continue
|
||||
seen_lines.add(line)
|
||||
merged_lines.append(line)
|
||||
if limit > 0:
|
||||
merged_lines = _slice_remote_log_lines_fairly(merged_lines, limit=limit)
|
||||
|
||||
summaries: dict[str, dict] = {}
|
||||
for snapshot in (primary, secondary):
|
||||
for raw_summary in list(snapshot.get("source_node_summaries") or []):
|
||||
if not isinstance(raw_summary, dict):
|
||||
continue
|
||||
node_code = str(raw_summary.get("node_code") or "").strip()
|
||||
if not node_code:
|
||||
continue
|
||||
summary = summaries.setdefault(
|
||||
node_code,
|
||||
{
|
||||
"node_code": node_code,
|
||||
"line_count": 0,
|
||||
"key_line_count": 0,
|
||||
"full_line_count": 0,
|
||||
"last_at": "",
|
||||
"last_line": "",
|
||||
},
|
||||
)
|
||||
summary["line_count"] = max(int(summary.get("line_count", 0) or 0), int(raw_summary.get("line_count", 0) or 0))
|
||||
summary["key_line_count"] = max(int(summary.get("key_line_count", 0) or 0), int(raw_summary.get("key_line_count", 0) or 0))
|
||||
summary["full_line_count"] = max(int(summary.get("full_line_count", 0) or 0), int(raw_summary.get("full_line_count", 0) or 0))
|
||||
raw_last_at = str(raw_summary.get("last_at") or "")
|
||||
if raw_last_at >= str(summary.get("last_at") or ""):
|
||||
summary["last_at"] = raw_last_at
|
||||
summary["last_line"] = str(raw_summary.get("last_line") or "")
|
||||
|
||||
source_nodes = sorted(
|
||||
{
|
||||
str(node_code or "").strip()
|
||||
for node_code in list(primary.get("source_nodes") or []) + list(secondary.get("source_nodes") or [])
|
||||
if str(node_code or "").strip()
|
||||
}
|
||||
)
|
||||
last_at = max(str(primary.get("last_at") or ""), str(secondary.get("last_at") or ""))
|
||||
last_line = str(primary.get("last_line") or "")
|
||||
if str(secondary.get("last_at") or "") >= str(primary.get("last_at") or ""):
|
||||
last_line = str(secondary.get("last_line") or last_line)
|
||||
|
||||
return {
|
||||
"lines": merged_lines,
|
||||
"line_count": len(merged_lines),
|
||||
"last_at": last_at,
|
||||
"last_line": last_line,
|
||||
"source_nodes": source_nodes,
|
||||
"source_node_count": len(source_nodes),
|
||||
"source_node_summaries": sorted(
|
||||
summaries.values(),
|
||||
key=lambda item: (
|
||||
str(item.get("last_at") or ""),
|
||||
str(item.get("node_code") or ""),
|
||||
),
|
||||
reverse=True,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _resolve_remote_log_lines(
|
||||
active_job: dict | None,
|
||||
runs: list[dict],
|
||||
@@ -277,17 +636,72 @@ def _resolve_remote_log_snapshot(
|
||||
mode: str,
|
||||
limit: int = 240,
|
||||
) -> dict:
|
||||
return _build_remote_log_snapshot(active_job, enabled=enabled, mode=mode, limit=limit)
|
||||
primary_snapshot = _build_remote_log_snapshot(active_job, enabled=enabled, mode=mode, limit=limit)
|
||||
debug_snapshot = _build_remote_log_snapshot_from_debug_events(active_job, enabled=enabled, mode=mode, limit=limit)
|
||||
if int(primary_snapshot.get("line_count", 0) or 0) <= 0:
|
||||
return debug_snapshot
|
||||
if int(debug_snapshot.get("line_count", 0) or 0) <= 0:
|
||||
return primary_snapshot
|
||||
return _merge_remote_log_snapshots(primary_snapshot, debug_snapshot, limit=limit)
|
||||
|
||||
|
||||
def _load_runtime_state() -> dict:
|
||||
try:
|
||||
redis_client = get_redis()
|
||||
raw = redis_client.get(_RUNTIME_STATE_KEY)
|
||||
if not raw:
|
||||
for key in (_runtime_state_key(), _RUNTIME_STATE_KEY):
|
||||
raw = redis_client.get(key)
|
||||
if not raw:
|
||||
continue
|
||||
data = json.loads(raw)
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
if key == _RUNTIME_STATE_KEY:
|
||||
payload_node_code = str(data.get("node_code") or "").strip()
|
||||
if payload_node_code and payload_node_code != str(settings.node_code or "").strip():
|
||||
continue
|
||||
return data
|
||||
return {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _load_runtime_state_from_cluster_node() -> dict:
|
||||
try:
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT current_load, metadata_json, last_heartbeat_at
|
||||
FROM detect_worker_nodes
|
||||
WHERE node_code = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(settings.node_code,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return {}
|
||||
data = json.loads(raw)
|
||||
return data if isinstance(data, dict) else {}
|
||||
current_load, metadata_json, last_heartbeat_at = row
|
||||
metadata = metadata_json if isinstance(metadata_json, dict) else {}
|
||||
if not metadata:
|
||||
return {}
|
||||
runtime_state = {
|
||||
"node_code": settings.node_code,
|
||||
"phase": str(metadata.get("phase") or metadata.get("phase_label") or "").strip(),
|
||||
"detail": str(metadata.get("detail") or metadata.get("phase_detail") or "").strip(),
|
||||
"service_running": True,
|
||||
"detecting": bool(metadata.get("detecting", False) or int(current_load or 0) > 0),
|
||||
"stop_requested": False,
|
||||
"available_proxy_count": int(metadata.get("available_proxy_count", 0) or 0),
|
||||
"active_threads": int(metadata.get("active_threads", 0) or 0),
|
||||
"max_threads": int(metadata.get("max_threads", 0) or 0),
|
||||
"job_id": metadata.get("job_id"),
|
||||
"job_code": str(metadata.get("job_code") or metadata.get("active_job_code") or "").strip(),
|
||||
"updated_at": str(metadata.get("updated_at") or (_format_time(last_heartbeat_at) if last_heartbeat_at else "")).strip(),
|
||||
}
|
||||
if runtime_state["detail"] or runtime_state["active_threads"] > 0 or runtime_state["max_threads"] > 0:
|
||||
return runtime_state
|
||||
return {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
@@ -361,12 +775,51 @@ def _build_proxy_runtime_snapshot(settings_payload: dict, runtime_state: dict, a
|
||||
"supplier_empty": False,
|
||||
}
|
||||
|
||||
if refresh_status in {"", "未刷新"} and source_count > 0:
|
||||
if allow_direct:
|
||||
return {
|
||||
"state": "degraded_direct",
|
||||
"label": "等待首刷",
|
||||
"detail": f"代理配置已下发,但代理池尚未完成首轮刷新;当前先按直连继续执行;最近状态:{refresh_status or '未刷新'}",
|
||||
"direct_fallback_active": True,
|
||||
"reason": "proxy_not_refreshed_yet",
|
||||
"last_refresh_status": refresh_status or "未刷新",
|
||||
"last_refresh_time": refresh_time,
|
||||
"source_count": source_count,
|
||||
"raw_items": raw_items,
|
||||
"validated_count": validated,
|
||||
"available_count": available,
|
||||
"source_stats": source_stats,
|
||||
"supplier_empty": False,
|
||||
}
|
||||
return {
|
||||
"state": "warming_up",
|
||||
"label": "等待首刷",
|
||||
"detail": "代理配置已下发,但代理池尚未完成首轮刷新;由于未允许直连,检测链路会等待代理刷新完成",
|
||||
"direct_fallback_active": False,
|
||||
"reason": "proxy_not_refreshed_yet",
|
||||
"last_refresh_status": refresh_status or "未刷新",
|
||||
"last_refresh_time": refresh_time,
|
||||
"source_count": source_count,
|
||||
"raw_items": raw_items,
|
||||
"validated_count": validated,
|
||||
"available_count": available,
|
||||
"source_stats": source_stats,
|
||||
"supplier_empty": False,
|
||||
}
|
||||
|
||||
if allow_direct:
|
||||
detail = "代理池当前无可用代理,已自动降级为直连继续执行"
|
||||
reason = "no_available_proxy"
|
||||
if supplier_empty:
|
||||
reason = "supplier_empty_pool"
|
||||
detail = "代理源最近都返回正常响应,但原始代理数为 0,当前判断为供应池为空;系统已自动降级为直连继续执行"
|
||||
elif raw_items > 0 and validated > 0:
|
||||
reason = "proxy_validation_zero"
|
||||
detail = (
|
||||
f"代理源最近返回了 {raw_items} 个代理,已验证 {validated} 个,但当前 0 个可用;"
|
||||
"系统已自动降级为直连继续执行"
|
||||
)
|
||||
if refresh_status:
|
||||
detail = f"{detail};最近状态:{refresh_status}"
|
||||
return {
|
||||
@@ -390,6 +843,12 @@ def _build_proxy_runtime_snapshot(settings_payload: dict, runtime_state: dict, a
|
||||
if supplier_empty:
|
||||
reason = "supplier_empty_pool"
|
||||
detail = "代理源最近都返回正常响应,但原始代理数为 0,当前判断为供应池为空;由于未允许直连,检测链路会等待代理恢复"
|
||||
elif raw_items > 0 and validated > 0:
|
||||
reason = "proxy_validation_zero"
|
||||
detail = (
|
||||
f"代理源最近返回了 {raw_items} 个代理,已验证 {validated} 个,但当前 0 个可用;"
|
||||
"由于未允许直连,检测链路会等待代理恢复"
|
||||
)
|
||||
if refresh_status:
|
||||
detail = f"{detail};最近状态:{refresh_status}"
|
||||
return {
|
||||
@@ -410,36 +869,45 @@ def _build_proxy_runtime_snapshot(settings_payload: dict, runtime_state: dict, a
|
||||
|
||||
|
||||
def get_detect_status() -> dict:
|
||||
try:
|
||||
ensure_runtime_schema()
|
||||
except Exception:
|
||||
# Node agent heartbeats should degrade gracefully even if runtime schema
|
||||
# initialization is temporarily unavailable.
|
||||
pass
|
||||
|
||||
queries = {
|
||||
"pending": "select count(*) from domains where detect_status = 0",
|
||||
"completed": "select count(*) from domains where detect_status = 1",
|
||||
"running": "select count(*) from domains where detect_status = 2",
|
||||
"blacklisted": "select count(*) from domains where detect_status = 3",
|
||||
"failed": "select count(*) from domains where detect_status = 4",
|
||||
"registerable": "select count(*) from domains where detect_status = 1 and register_status = 2",
|
||||
}
|
||||
progress: dict[str, int] = {}
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
for key, query in queries.items():
|
||||
try:
|
||||
cur.execute(query)
|
||||
progress[key] = cur.fetchone()[0]
|
||||
except Exception:
|
||||
progress[key] = 0
|
||||
progress: dict[str, int] = {key: 0 for key in queries}
|
||||
try:
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
for key, query in queries.items():
|
||||
try:
|
||||
cur.execute(query)
|
||||
progress[key] = cur.fetchone()[0]
|
||||
except Exception:
|
||||
progress[key] = 0
|
||||
except Exception:
|
||||
# Worker runtime/status pages should still render using runtime-state and
|
||||
# cluster fallbacks even when the local DB endpoint is temporarily wrong
|
||||
# or unreachable (for example remote worker nodes without direct DB access).
|
||||
pass
|
||||
|
||||
settings_payload = get_settings_payload()
|
||||
worker_log = resolve_domain_path("detect_worker.log", "logs/detect_worker.log")
|
||||
|
||||
worker_online = False
|
||||
last_log_time = None
|
||||
if worker_log and worker_log.exists():
|
||||
modified = datetime.fromtimestamp(worker_log.stat().st_mtime, tz=timezone.utc)
|
||||
last_log_time = modified.isoformat()
|
||||
worker_online = (datetime.now(timezone.utc) - modified).total_seconds() < 180
|
||||
|
||||
recent_lines = tail_lines("detect_worker.log", max_lines=160)
|
||||
runtime_settings = get_runtime_settings()
|
||||
worker_online, last_log_time, recent_lines = _load_recent_worker_lines(runtime_settings, max_lines=160)
|
||||
runtime = detect_worker_runtime()
|
||||
runtime_state = _load_runtime_state()
|
||||
runtime_started_at = runtime.get("latest_start_time", "") if 'runtime' in locals() else ""
|
||||
if not runtime_state:
|
||||
runtime_state = _load_runtime_state_from_cluster_node()
|
||||
runtime_started_at = runtime.get("latest_start_time", "")
|
||||
recent_lines = _filter_lines_since(recent_lines, runtime_started_at)
|
||||
available_proxy_count = _extract_available_proxy_count(recent_lines)
|
||||
active_thread_snapshot = _extract_active_thread_snapshot(recent_lines)
|
||||
@@ -449,21 +917,35 @@ def get_detect_status() -> dict:
|
||||
"active": int(runtime_state.get("active_threads", active_thread_snapshot["active"]) or active_thread_snapshot["active"]),
|
||||
"max": int(runtime_state.get("max_threads", active_thread_snapshot["max"]) or active_thread_snapshot["max"]),
|
||||
}
|
||||
progress_total = sum(progress.values())
|
||||
registerable_count = int(progress.get("registerable", 0) or 0)
|
||||
progress_total = sum(
|
||||
int(progress.get(key, 0) or 0)
|
||||
for key in ("pending", "completed", "running", "blacklisted", "failed")
|
||||
)
|
||||
progress_done = progress.get("completed", 0) + progress.get("blacklisted", 0) + progress.get("failed", 0)
|
||||
progress_percent = round((progress_done / progress_total) * 100, 2) if progress_total > 0 else 0
|
||||
runtime = detect_worker_runtime()
|
||||
runtime_started_at = runtime.get("latest_start_time", "")
|
||||
recent_lines = _filter_lines_since(recent_lines, runtime_started_at)
|
||||
recent_proxy_warning = _normalize_recent_warning(runtime_state, recent_lines, available_proxy_count)
|
||||
proxy_runtime = _build_proxy_runtime_snapshot(settings_payload, runtime_state, available_proxy_count)
|
||||
thread_count_resolution = resolve_thread_count(settings_payload=settings_payload)
|
||||
effective_thread_count = int(thread_count_resolution["effective_thread_count"])
|
||||
runtime_settings = get_runtime_settings()
|
||||
worker_online = worker_online or runtime.get("running", False)
|
||||
if runtime_state.get("service_running") is True:
|
||||
worker_online = True
|
||||
if not runtime_state.get("detecting", False) and not progress.get("running", 0):
|
||||
if active_thread_snapshot["active"] <= 0 and (runtime_state.get("detecting", False) or runtime.get("running", False)):
|
||||
estimated_active_threads = _estimate_active_threads_from_recent_lines(
|
||||
recent_lines,
|
||||
limit=max(1, effective_thread_count),
|
||||
)
|
||||
if estimated_active_threads > 0:
|
||||
active_thread_snapshot["active"] = estimated_active_threads
|
||||
inferred_detecting = bool(
|
||||
runtime_state.get("detecting", False)
|
||||
or int(progress.get("running", 0) or 0) > 0
|
||||
or int(active_thread_snapshot.get("active", 0) or 0) > 0
|
||||
)
|
||||
if not inferred_detecting:
|
||||
active_thread_snapshot = {"active": 0, "max": active_thread_snapshot["max"] or effective_thread_count}
|
||||
settings_summary = {
|
||||
"thread_count": effective_thread_count,
|
||||
@@ -475,10 +957,59 @@ def get_detect_status() -> dict:
|
||||
"allow_direct": settings_payload["proxy_config"].get("allow_direct", False),
|
||||
"proxy_pool_count": len(settings_payload["proxy_config"].get("proxy_urls", [])),
|
||||
}
|
||||
active_job = get_active_detect_job_summary(event_limit=240)
|
||||
try:
|
||||
active_job = get_active_detect_job_summary(event_limit=240)
|
||||
except Exception:
|
||||
active_job = None
|
||||
if settings.node_region == "overseas" and settings.node_role == "control" and active_job:
|
||||
progress = {
|
||||
"pending": int(active_job.get("items_pending", 0) or 0),
|
||||
"running": int(
|
||||
active_job.get("display_active_threads", active_job.get("display_items_running", active_job.get("items_running", 0)))
|
||||
or 0
|
||||
),
|
||||
"completed": int(active_job.get("items_completed", 0) or 0),
|
||||
"failed": int(active_job.get("items_failed", 0) or 0),
|
||||
"blacklisted": int(active_job.get("items_blacklisted", 0) or 0),
|
||||
"registerable": registerable_count,
|
||||
}
|
||||
progress_percent = float(active_job.get("progress_percent", 0) or 0)
|
||||
local_node_bucket = {}
|
||||
for item in list((active_job or {}).get("node_stats") or []):
|
||||
if str(item.get("node_code") or "").strip() == str(settings.node_code or "").strip():
|
||||
local_node_bucket = item
|
||||
break
|
||||
local_runtime_load = int(
|
||||
local_node_bucket.get("active_threads")
|
||||
or local_node_bucket.get("items_running")
|
||||
or 0
|
||||
)
|
||||
local_runtime_max_threads = int(local_node_bucket.get("max_threads", 0) or 0)
|
||||
if active_thread_snapshot["active"] <= 0 and local_runtime_load > 0:
|
||||
active_thread_snapshot["active"] = local_runtime_load
|
||||
if active_thread_snapshot["max"] <= 0:
|
||||
active_thread_snapshot["max"] = local_runtime_max_threads or effective_thread_count
|
||||
if settings.node_region == "overseas" and settings.node_role == "control" and active_job:
|
||||
distributed_node_stats = list(active_job.get("distributed_node_stats") or active_job.get("node_stats") or [])
|
||||
aggregated_active_threads = 0
|
||||
aggregated_max_threads = 0
|
||||
for item in distributed_node_stats:
|
||||
node_code = str(item.get("node_code") or "").strip()
|
||||
if not node_code or node_code == "unassigned":
|
||||
continue
|
||||
aggregated_active_threads += int(
|
||||
item.get("active_threads")
|
||||
or item.get("items_running")
|
||||
or 0
|
||||
)
|
||||
aggregated_max_threads += int(item.get("max_threads", 0) or 0)
|
||||
if aggregated_active_threads > 0:
|
||||
active_thread_snapshot["active"] = aggregated_active_threads
|
||||
if aggregated_max_threads > 0:
|
||||
active_thread_snapshot["max"] = aggregated_max_threads
|
||||
runtime_snapshot = {
|
||||
**runtime,
|
||||
"detecting": runtime_state.get("detecting", False),
|
||||
"detecting": inferred_detecting,
|
||||
"proxy_runtime_state": proxy_runtime["state"],
|
||||
"proxy_runtime_label": proxy_runtime["label"],
|
||||
"proxy_runtime_detail": proxy_runtime["detail"],
|
||||
@@ -518,7 +1049,7 @@ def get_detect_status() -> dict:
|
||||
"runtime_state": runtime_state,
|
||||
"phase_label": runtime_state.get("phase", ""),
|
||||
"phase_detail": runtime_state.get("detail", ""),
|
||||
"detecting": runtime_state.get("detecting", False),
|
||||
"detecting": inferred_detecting,
|
||||
"thread_count": effective_thread_count,
|
||||
"thread_count_default": int(thread_count_resolution["default_thread_count"]),
|
||||
"thread_count_source": str(thread_count_resolution["source"]),
|
||||
@@ -549,6 +1080,7 @@ def get_detect_status() -> dict:
|
||||
"progress_percent": progress_percent,
|
||||
"recent_event": runtime_state.get("detail") or _recent_event(recent_lines),
|
||||
"recent_warning": recent_proxy_warning,
|
||||
"aggregate_detect_view": bool(settings.node_region == "overseas" and settings.node_role == "control" and active_job),
|
||||
"log_lines": recent_lines,
|
||||
"remote_log_lines": remote_log_lines,
|
||||
"remote_log_line_count": int(remote_log_snapshot.get("line_count", 0) or 0),
|
||||
|
||||
@@ -145,7 +145,8 @@ def _write_txt(path, rows: list[dict]) -> None:
|
||||
|
||||
|
||||
def _write_csv(path, rows: list[dict]) -> None:
|
||||
with path.open("w", encoding="utf-8", newline="") as handle:
|
||||
# Add BOM so Excel on Chinese Windows opens CSV without mojibake.
|
||||
with path.open("w", encoding="utf-8-sig", newline="") as handle:
|
||||
writer = csv.writer(handle)
|
||||
writer.writerow([label for _, label in EXPORT_HEADERS])
|
||||
for row in rows:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import hashlib
|
||||
from io import StringIO
|
||||
import pickle
|
||||
import re
|
||||
import sys
|
||||
@@ -35,6 +37,9 @@ LEGACY_JUMING_COOKIE_FILES = [
|
||||
DELETE_LIST_SOURCE_TYPE = 2
|
||||
FIXED_PRICE_SOURCE_TYPE = 1
|
||||
JUMING_PREFERENCES_FILE = "juming_preferences.json"
|
||||
JUMING_DELETE_IMPORT_STATE_FILE = "juming_delete_import_state.json"
|
||||
IMPORT_BATCH_SIZE = 50000
|
||||
IMPORT_PROGRESS_EVERY = 100000
|
||||
|
||||
|
||||
class TaskStoppedError(RuntimeError):
|
||||
@@ -125,6 +130,118 @@ def _persist_juming_cookie(cookie_jar: RequestsCookieJar) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _empty_import_stats() -> dict[str, int]:
|
||||
return {
|
||||
"total": 0,
|
||||
"valid": 0,
|
||||
"added": 0,
|
||||
"exists": 0,
|
||||
"invalid": 0,
|
||||
"failed": 0,
|
||||
}
|
||||
|
||||
|
||||
def _merge_import_stats(base: dict[str, int], delta: dict[str, int]) -> dict[str, int]:
|
||||
merged = dict(base or _empty_import_stats())
|
||||
for key in ("total", "valid", "added", "exists", "invalid", "failed"):
|
||||
merged[key] = int(merged.get(key, 0) or 0) + int((delta or {}).get(key, 0) or 0)
|
||||
return merged
|
||||
|
||||
|
||||
def _load_delete_import_state() -> dict[str, dict]:
|
||||
payload = read_runtime_json(JUMING_DELETE_IMPORT_STATE_FILE, default={})
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
def _save_delete_import_state(payload: dict[str, dict]) -> None:
|
||||
write_runtime_json(JUMING_DELETE_IMPORT_STATE_FILE, payload)
|
||||
|
||||
|
||||
def _compute_domains_signature(domains: list[str]) -> str:
|
||||
digest = hashlib.sha1()
|
||||
for domain in domains:
|
||||
digest.update(str(domain).strip().encode("utf-8", errors="ignore"))
|
||||
digest.update(b"\n")
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _looks_like_login_redirect(location: str) -> bool:
|
||||
normalized = str(location or "").strip().lower()
|
||||
if not normalized:
|
||||
return False
|
||||
return any(
|
||||
marker in normalized
|
||||
for marker in (
|
||||
"/login",
|
||||
"user_zh",
|
||||
"p_login",
|
||||
"passport",
|
||||
"sign",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _looks_like_login_body(body: str) -> bool:
|
||||
normalized = str(body or "").strip().lower()
|
||||
if not normalized:
|
||||
return False
|
||||
return any(
|
||||
marker in normalized
|
||||
for marker in (
|
||||
"账号登录",
|
||||
"请先登录",
|
||||
"登录后查看",
|
||||
"登录聚名",
|
||||
"user_zh",
|
||||
"p_login",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _validate_juming_cookie(
|
||||
cookie_jar: RequestsCookieJar | None,
|
||||
*,
|
||||
probe_date: str | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
if cookie_jar is None or not _cookie_jar_to_dict(cookie_jar):
|
||||
return False, "未检测到有效 Cookie"
|
||||
|
||||
jm = JM()
|
||||
jm.cookie = cookie_jar
|
||||
probe_date = str(probe_date or date.today().isoformat())
|
||||
url = f"{jm.base_url}/newcha/del_down?scsj={probe_date}"
|
||||
|
||||
try:
|
||||
response = jm.session.get(
|
||||
url,
|
||||
headers=jm.headers,
|
||||
cookies=jm.cookie,
|
||||
allow_redirects=False,
|
||||
timeout=10,
|
||||
)
|
||||
except Exception as exc:
|
||||
return False, f"登录态校验失败: {exc}"
|
||||
|
||||
location = str(response.headers.get("Location") or "").strip()
|
||||
if response.status_code in {301, 302, 303, 307, 308}:
|
||||
if _looks_like_login_redirect(location):
|
||||
return False, "聚名登录态已失效,请重新登录"
|
||||
if location:
|
||||
return True, f"删除列表下载链路校验通过: {probe_date}"
|
||||
|
||||
try:
|
||||
body = response.text
|
||||
except Exception:
|
||||
body = ""
|
||||
|
||||
if _looks_like_login_body(body):
|
||||
return False, "聚名登录态已失效,请重新登录"
|
||||
|
||||
if response.ok:
|
||||
return True, f"聚名 Cookie 已通过远端校验: {probe_date}"
|
||||
return False, f"聚名登录态校验失败,HTTP {response.status_code}"
|
||||
|
||||
|
||||
def _jucha_cookie_status() -> dict:
|
||||
if JUCHA_COOKIE_FILE.exists():
|
||||
return {
|
||||
@@ -186,11 +303,16 @@ def _load_juming_cookie() -> tuple[RequestsCookieJar | None, str]:
|
||||
|
||||
def get_juming_status() -> dict:
|
||||
cookie_jar, storage = _load_juming_cookie()
|
||||
cookie_valid, cookie_message = _validate_juming_cookie(cookie_jar)
|
||||
cookie_count = len(_cookie_jar_to_dict(cookie_jar)) if cookie_jar is not None else 0
|
||||
status = {
|
||||
"cookie_ready": cookie_jar is not None,
|
||||
"cookie_ready": bool(cookie_jar is not None and cookie_valid),
|
||||
"cookie_present": cookie_jar is not None,
|
||||
"cookie_valid": cookie_valid,
|
||||
"cookie_message": cookie_message,
|
||||
"cookie_storage": storage,
|
||||
"cookie_file": str(JUMING_COOKIE_FILE),
|
||||
"cookie_count": len(_cookie_jar_to_dict(cookie_jar)) if cookie_jar is not None else 0,
|
||||
"cookie_count": cookie_count,
|
||||
"jucha": _jucha_cookie_status(),
|
||||
"supported_modes": [
|
||||
{"label": "聚名一口价", "value": "fixed_price", "source_type": FIXED_PRICE_SOURCE_TYPE},
|
||||
@@ -308,80 +430,146 @@ def _insert_domains(
|
||||
source_type: int,
|
||||
log: Callable[[str], None] | None = None,
|
||||
should_stop: Callable[[], bool] | None = None,
|
||||
*,
|
||||
announce_total: bool = True,
|
||||
progress_label: str = "",
|
||||
) -> dict:
|
||||
total = len(domains)
|
||||
normalized_rows: list[tuple[str, str]] = []
|
||||
invalid = 0
|
||||
|
||||
_emit_log(log, f"开始入库处理,共收到 {total} 个原始域名")
|
||||
for value in domains:
|
||||
_check_stop(should_stop)
|
||||
normalized = normalize_domain(value)
|
||||
if not normalized:
|
||||
invalid += 1
|
||||
continue
|
||||
tld = normalized.rsplit(".", 1)[-1]
|
||||
normalized_rows.append((normalized, tld))
|
||||
|
||||
existing_set: set[str] = set()
|
||||
progress_prefix = f"{progress_label} " if str(progress_label or "").strip() else ""
|
||||
if announce_total:
|
||||
_emit_log(log, f"{progress_prefix}开始入库处理,共收到 {total} 个原始域名")
|
||||
inserted = 0
|
||||
existing = 0
|
||||
processed = 0
|
||||
valid = 0
|
||||
last_progress_at = 0
|
||||
pending_batch: list[tuple[str, str]] = []
|
||||
pending_seen: set[str] = set()
|
||||
stage_ready = False
|
||||
|
||||
def emit_progress(force: bool = False) -> None:
|
||||
nonlocal last_progress_at
|
||||
if not force and processed - last_progress_at < IMPORT_PROGRESS_EVERY:
|
||||
return
|
||||
last_progress_at = processed
|
||||
_emit_log(
|
||||
log,
|
||||
(
|
||||
f"{progress_prefix}入库进度:已处理 {processed}/{total},"
|
||||
f"有效 {valid},新增 {inserted},已存在 {existing},无效 {invalid}"
|
||||
),
|
||||
)
|
||||
|
||||
def ensure_stage_table(cur) -> None:
|
||||
nonlocal stage_ready
|
||||
if stage_ready:
|
||||
return
|
||||
cur.execute(
|
||||
"""
|
||||
create temporary table if not exists juming_import_stage (
|
||||
domain text primary key,
|
||||
tld text not null
|
||||
) on commit preserve rows
|
||||
"""
|
||||
)
|
||||
stage_ready = True
|
||||
|
||||
def stage_rows(cur, rows: list[tuple[str, str]]) -> None:
|
||||
buffer = StringIO()
|
||||
for domain, tld in rows:
|
||||
buffer.write(f"{domain}\t{tld}\n")
|
||||
buffer.seek(0)
|
||||
cur.copy_from(buffer, "juming_import_stage", columns=("domain", "tld"))
|
||||
|
||||
def flush_batch(cur, conn) -> None:
|
||||
nonlocal inserted, existing
|
||||
if not pending_batch:
|
||||
return
|
||||
ensure_stage_table(cur)
|
||||
cur.execute("set local synchronous_commit = off")
|
||||
cur.execute("truncate table juming_import_stage")
|
||||
stage_rows(cur, pending_batch)
|
||||
cur.execute(
|
||||
"""
|
||||
with existing_rows as (
|
||||
select count(*)
|
||||
from juming_import_stage stage
|
||||
join domains existing on existing.domain = stage.domain
|
||||
),
|
||||
inserted as (
|
||||
insert into domains (
|
||||
domain, tld, source_type, use_status, detect_status, register_status,
|
||||
has_beian, company_type, website_url, beian_year, snapshot_years,
|
||||
expire_date, create_time, update_time, review_status, detect_time,
|
||||
backlink_count, jucha_status, juziseo_status
|
||||
)
|
||||
select
|
||||
stage.domain,
|
||||
stage.tld,
|
||||
%s,
|
||||
0, 0, 0,
|
||||
1, null, null, null, null,
|
||||
null, now(), now(), 0, null,
|
||||
0, 0, 0
|
||||
from juming_import_stage stage
|
||||
left join domains existing on existing.domain = stage.domain
|
||||
where existing.id is null
|
||||
returning id
|
||||
),
|
||||
task_insert as (
|
||||
insert into detect_tasks (
|
||||
domain_id, task_type, status, priority, retry_count, create_time, update_time
|
||||
)
|
||||
select id, 1, 1, 5, 0, now(), now()
|
||||
from inserted
|
||||
returning 1
|
||||
)
|
||||
select
|
||||
(select count(*) from inserted),
|
||||
(select count(*) from task_insert),
|
||||
(select count(*) from existing_rows)
|
||||
""",
|
||||
(source_type,),
|
||||
)
|
||||
inserted_count, _task_count, existing_count = cur.fetchone()
|
||||
inserted += int(inserted_count or 0)
|
||||
existing += int(existing_count or 0)
|
||||
conn.commit()
|
||||
pending_batch.clear()
|
||||
pending_seen.clear()
|
||||
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
normalized_domains = [row[0] for row in normalized_rows]
|
||||
if normalized_domains:
|
||||
cur.execute("select domain from domains where domain = any(%s)", (normalized_domains,))
|
||||
existing_set = {row[0] for row in cur.fetchall()}
|
||||
if existing_set:
|
||||
_emit_log(log, f"检测到 {len(existing_set)} 个已存在域名,将自动跳过")
|
||||
|
||||
inserted_since_commit = 0
|
||||
for domain, tld in normalized_rows:
|
||||
for value in domains:
|
||||
_check_stop(should_stop)
|
||||
if domain in existing_set:
|
||||
processed += 1
|
||||
normalized = normalize_domain(value)
|
||||
if not normalized:
|
||||
invalid += 1
|
||||
emit_progress()
|
||||
continue
|
||||
cur.execute(
|
||||
"""
|
||||
insert into domains (
|
||||
domain, tld, source_type, use_status, detect_status, register_status,
|
||||
has_beian, company_type, website_url, beian_year, snapshot_years,
|
||||
expire_date, create_time, update_time, review_status, detect_time,
|
||||
backlink_count, jucha_status, juziseo_status
|
||||
) values (
|
||||
%s, %s, %s, 0, 0, 0,
|
||||
1, null, null, null, null,
|
||||
null, now(), now(), 0, null,
|
||||
0, 0, 0
|
||||
)
|
||||
returning id
|
||||
""",
|
||||
(domain, tld, source_type),
|
||||
)
|
||||
domain_id = cur.fetchone()[0]
|
||||
cur.execute(
|
||||
"""
|
||||
insert into detect_tasks (domain_id, task_type, status, priority, retry_count, create_time, update_time)
|
||||
values (%s, 1, 1, 5, 0, now(), now())
|
||||
""",
|
||||
(domain_id,),
|
||||
)
|
||||
inserted += 1
|
||||
inserted_since_commit += 1
|
||||
if inserted_since_commit >= 500:
|
||||
conn.commit()
|
||||
inserted_since_commit = 0
|
||||
conn.commit()
|
||||
valid += 1
|
||||
if normalized in pending_seen:
|
||||
existing += 1
|
||||
emit_progress()
|
||||
continue
|
||||
pending_seen.add(normalized)
|
||||
pending_batch.append((normalized, normalized.rsplit(".", 1)[-1]))
|
||||
if len(pending_batch) >= IMPORT_BATCH_SIZE:
|
||||
flush_batch(cur, conn)
|
||||
emit_progress(force=True)
|
||||
flush_batch(cur, conn)
|
||||
emit_progress(force=True)
|
||||
|
||||
valid = len(normalized_rows)
|
||||
exists = len(existing_set)
|
||||
_emit_log(log, f"入库完成:有效 {valid},新增 {inserted},已存在 {exists},无效 {invalid}")
|
||||
_emit_log(log, f"{progress_prefix}入库完成:有效 {valid},新增 {inserted},已存在 {existing},无效 {invalid}")
|
||||
return {
|
||||
"total": total,
|
||||
"valid": valid,
|
||||
"added": inserted,
|
||||
"exists": exists,
|
||||
"exists": existing,
|
||||
"invalid": invalid,
|
||||
"failed": max(valid - exists - inserted, 0),
|
||||
"failed": max(valid - existing - inserted, 0),
|
||||
}
|
||||
|
||||
|
||||
@@ -450,6 +638,98 @@ def _crawl_delete_list(
|
||||
return domains, dates
|
||||
|
||||
|
||||
def _crawl_delete_list_and_import(
|
||||
crawl_date: str,
|
||||
auto_date: bool,
|
||||
log: Callable[[str], None] | None = None,
|
||||
should_stop: Callable[[], bool] | None = None,
|
||||
) -> dict:
|
||||
cookie_jar, _ = _load_juming_cookie()
|
||||
jm = JM()
|
||||
jm.cookie = cookie_jar or RequestsCookieJar()
|
||||
|
||||
start_date = datetime.strptime(crawl_date, "%Y-%m-%d").date()
|
||||
end_date = date.today() + timedelta(days=4)
|
||||
current_date = start_date
|
||||
dates: list[dict[str, int]] = []
|
||||
domains_found = 0
|
||||
sample_domains: list[str] = []
|
||||
stats = _empty_import_stats()
|
||||
import_state = _load_delete_import_state()
|
||||
_emit_log(log, f"开始采集删除列表:起始日期 {crawl_date},自动追加日期 {'开启' if auto_date else '关闭'}")
|
||||
|
||||
while current_date <= end_date:
|
||||
_check_stop(should_stop)
|
||||
current_date_text = current_date.isoformat()
|
||||
_emit_log(log, f"正在抓取 {current_date_text} 的删除列表")
|
||||
domains_for_date = [item.strip() for item in jm.new_cha_del(current_date_text) if item.strip()]
|
||||
domains_found += len(domains_for_date)
|
||||
dates.append({"date": current_date_text, "count": len(domains_for_date)})
|
||||
_emit_log(log, f"{current_date_text} 抓取到 {len(domains_for_date)} 个域名,累计 {domains_found} 个")
|
||||
if domains_for_date:
|
||||
if len(sample_domains) < 20:
|
||||
sample_domains.extend(domains_for_date[: max(0, 20 - len(sample_domains))])
|
||||
signature = _compute_domains_signature(domains_for_date)
|
||||
cached = import_state.get(current_date_text) or {}
|
||||
if (
|
||||
cached.get("signature") == signature
|
||||
and int(cached.get("total", 0) or 0) == len(domains_for_date)
|
||||
):
|
||||
cached_valid = int(cached.get("valid", 0) or 0)
|
||||
cached_invalid = int(cached.get("invalid", 0) or 0)
|
||||
date_stats = {
|
||||
"total": len(domains_for_date),
|
||||
"valid": cached_valid,
|
||||
"added": 0,
|
||||
"exists": cached_valid,
|
||||
"invalid": cached_invalid,
|
||||
"failed": 0,
|
||||
}
|
||||
_emit_log(
|
||||
log,
|
||||
(
|
||||
f"{current_date_text} 删除列表内容未变化,跳过重复入库:"
|
||||
f"有效 {cached_valid},视为已存在 {cached_valid},无效 {cached_invalid}"
|
||||
),
|
||||
)
|
||||
else:
|
||||
date_stats = _insert_domains(
|
||||
domains_for_date,
|
||||
DELETE_LIST_SOURCE_TYPE,
|
||||
log=log,
|
||||
should_stop=should_stop,
|
||||
announce_total=False,
|
||||
progress_label=current_date_text,
|
||||
)
|
||||
import_state[current_date_text] = {
|
||||
"signature": signature,
|
||||
"total": int(date_stats.get("total", 0) or 0),
|
||||
"valid": int(date_stats.get("valid", 0) or 0),
|
||||
"invalid": int(date_stats.get("invalid", 0) or 0),
|
||||
"updated_at": datetime.now().isoformat(sep=" ", timespec="seconds"),
|
||||
}
|
||||
_save_delete_import_state(import_state)
|
||||
stats = _merge_import_stats(stats, date_stats)
|
||||
if not auto_date:
|
||||
break
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
_emit_log(
|
||||
log,
|
||||
(
|
||||
f"删除列表采集+入库完成:抓取 {domains_found} 个域名,"
|
||||
f"新增 {stats['added']},已存在 {stats['exists']},无效 {stats['invalid']}"
|
||||
),
|
||||
)
|
||||
return {
|
||||
"mode": "delete_list",
|
||||
"dates": dates,
|
||||
"domains_found": domains_found,
|
||||
"stats": stats,
|
||||
"sample_domains": sample_domains[:20],
|
||||
}
|
||||
|
||||
|
||||
def crawl_juming(
|
||||
payload: dict,
|
||||
log: Callable[[str], None] | None = None,
|
||||
@@ -462,7 +742,10 @@ def crawl_juming(
|
||||
cookie_jar, storage = _load_juming_cookie()
|
||||
if cookie_jar is None:
|
||||
raise ValueError("未找到聚名 Cookie,请先在桌面版系统设置完成聚名登录,或将 Cookie 同步到服务器")
|
||||
_emit_log(log, f"检测到聚名登录态,来源:{storage}")
|
||||
cookie_valid, cookie_message = _validate_juming_cookie(cookie_jar)
|
||||
if not cookie_valid:
|
||||
raise ValueError(cookie_message)
|
||||
_emit_log(log, f"检测到聚名登录态,来源:{storage},远端校验通过")
|
||||
_check_stop(should_stop)
|
||||
|
||||
if mode == "fixed_price":
|
||||
@@ -482,13 +765,6 @@ def crawl_juming(
|
||||
|
||||
crawl_date = str(payload.get("crawl_date") or date.today().isoformat())
|
||||
auto_date = bool(payload.get("auto_date", True))
|
||||
domains, dates = _crawl_delete_list(crawl_date, auto_date, log=log, should_stop=should_stop)
|
||||
stats = _insert_domains(domains, DELETE_LIST_SOURCE_TYPE, log=log, should_stop=should_stop)
|
||||
return {
|
||||
"mode": mode,
|
||||
"cookie_storage": storage,
|
||||
"dates": dates,
|
||||
"domains_found": len(domains),
|
||||
"stats": stats,
|
||||
"sample_domains": domains[:20],
|
||||
}
|
||||
result = _crawl_delete_list_and_import(crawl_date, auto_date, log=log, should_stop=should_stop)
|
||||
result["cookie_storage"] = storage
|
||||
return result
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.services.juming_service import TaskStoppedError, crawl_juming
|
||||
|
||||
_JUMING_TASK_LOCK = threading.Lock()
|
||||
_MAX_LOG_LINES = 400
|
||||
_ACTIVE_TASK_IDS: set[str] = set()
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
@@ -17,6 +18,7 @@ def _now() -> str:
|
||||
|
||||
|
||||
def list_juming_tasks() -> list[dict]:
|
||||
_cleanup_orphaned_tasks()
|
||||
return load_juming_records()
|
||||
|
||||
|
||||
@@ -55,6 +57,43 @@ def _is_stop_requested(task_id: str) -> bool:
|
||||
return bool(target and target.get("cancel_requested"))
|
||||
|
||||
|
||||
def _cleanup_orphaned_tasks() -> None:
|
||||
with _JUMING_TASK_LOCK:
|
||||
tasks = load_juming_records()
|
||||
changed = False
|
||||
for item in tasks:
|
||||
status = str(item.get("status") or "").strip()
|
||||
task_id = str(item.get("task_id") or "").strip()
|
||||
if status == "running" and task_id and task_id not in _ACTIVE_TASK_IDS:
|
||||
item["status"] = "failed"
|
||||
item["phase"] = "failed"
|
||||
item["phase_label"] = "失败"
|
||||
item["cancel_requested"] = False
|
||||
item["message"] = "任务因服务重启或进程中断而终止,请重新执行"
|
||||
item["updated_at"] = _now()
|
||||
logs = list(item.get("logs") or [])
|
||||
logs.append(f"[{_now()}] 任务因服务重启或进程中断而终止,请重新执行")
|
||||
item["logs"] = logs[-_MAX_LOG_LINES:]
|
||||
changed = True
|
||||
if changed:
|
||||
_save_tasks(tasks)
|
||||
|
||||
|
||||
def _ensure_no_active_task() -> None:
|
||||
_cleanup_orphaned_tasks()
|
||||
tasks = load_juming_records()
|
||||
active = next(
|
||||
(
|
||||
item
|
||||
for item in tasks
|
||||
if str(item.get("status") or "").strip() == "running"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if active:
|
||||
raise ValueError(f"已有聚名采集任务正在运行:{active.get('task_id')}")
|
||||
|
||||
|
||||
def _set_phase(task_id: str, phase: str, message: str | None = None) -> None:
|
||||
phase_labels = {
|
||||
"queued": "排队中",
|
||||
@@ -78,12 +117,18 @@ def _set_phase(task_id: str, phase: str, message: str | None = None) -> None:
|
||||
def _log_and_track_phase(task_id: str, message: str) -> None:
|
||||
if "开始采集" in message or "正在抓取" in message:
|
||||
_set_phase(task_id, "fetching", message)
|
||||
elif "开始入库处理" in message or "入库完成" in message or "已存在域名" in message:
|
||||
elif (
|
||||
"开始入库处理" in message
|
||||
or "入库进度" in message
|
||||
or "入库完成" in message
|
||||
or "已存在域名" in message
|
||||
):
|
||||
_set_phase(task_id, "importing", message)
|
||||
_append_log(task_id, message)
|
||||
|
||||
|
||||
def _run_juming_task(task_id: str, payload: dict) -> None:
|
||||
_ACTIVE_TASK_IDS.add(task_id)
|
||||
_update_task(task_id, status="running", started_at=_now(), message="聚名采集任务开始执行", cancel_requested=False)
|
||||
_set_phase(task_id, "starting", "聚名采集任务开始执行")
|
||||
_append_log(task_id, "任务已启动,正在准备读取聚名登录态")
|
||||
@@ -127,9 +172,12 @@ def _run_juming_task(task_id: str, payload: dict) -> None:
|
||||
)
|
||||
_set_phase(task_id, "failed")
|
||||
_append_log(task_id, f"任务执行失败:{exc}")
|
||||
finally:
|
||||
_ACTIVE_TASK_IDS.discard(task_id)
|
||||
|
||||
|
||||
def create_juming_task(payload: dict) -> dict:
|
||||
_ensure_no_active_task()
|
||||
task_id = uuid4().hex
|
||||
mode = str(payload.get("mode") or "delete_list").strip() or "delete_list"
|
||||
record = {
|
||||
@@ -154,12 +202,14 @@ def create_juming_task(payload: dict) -> dict:
|
||||
tasks.insert(0, record)
|
||||
_save_tasks(tasks)
|
||||
|
||||
_ACTIVE_TASK_IDS.add(task_id)
|
||||
worker = threading.Thread(target=_run_juming_task, args=(task_id, dict(payload or {})), daemon=True)
|
||||
worker.start()
|
||||
return record
|
||||
|
||||
|
||||
def retry_juming_task(task_id: str) -> dict:
|
||||
_ensure_no_active_task()
|
||||
with _JUMING_TASK_LOCK:
|
||||
tasks = load_juming_records()
|
||||
target = next((item for item in tasks if item["task_id"] == task_id), None)
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
import psycopg2
|
||||
import redis
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.files import runtime_root as api_runtime_root
|
||||
|
||||
|
||||
STRUCTURED_ACTIONS = {
|
||||
@@ -22,6 +28,7 @@ STRUCTURED_ACTIONS = {
|
||||
"runtime.restart_api",
|
||||
"runtime.start_sync_agent",
|
||||
"runtime.stop_sync_agent",
|
||||
"runtime.reset_lab_state",
|
||||
}
|
||||
|
||||
|
||||
@@ -140,6 +147,233 @@ def systemctl_action_name(action: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _safe_bool(value: object, default: bool) -> bool:
|
||||
if value is None:
|
||||
return bool(default)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
text = str(value).strip().lower()
|
||||
if not text:
|
||||
return bool(default)
|
||||
if text in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if text in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return bool(default)
|
||||
|
||||
|
||||
def _node_agent_queue_dir() -> Path:
|
||||
explicit = str(os.getenv("OPS_AGENT_QUEUE_DIR", "") or "").strip()
|
||||
if explicit:
|
||||
return Path(explicit)
|
||||
project_dir = Path(__file__).resolve().parents[2]
|
||||
return project_dir / "runtime" / "node-agent-queue" / (settings.node_code or "unbound")
|
||||
|
||||
|
||||
def _detect_install_root(base_path: Path) -> Path | None:
|
||||
normalized = str(base_path.resolve())
|
||||
for marker in (f"{os.sep}releases{os.sep}", f"{os.sep}current{os.sep}"):
|
||||
if marker in normalized:
|
||||
return Path(normalized.split(marker, 1)[0])
|
||||
return None
|
||||
|
||||
|
||||
def _domaincheck_runtime_root() -> Path:
|
||||
explicit = str(os.getenv("DOMAINCHECK_RUNTIME_ROOT", "") or "").strip()
|
||||
if explicit:
|
||||
return Path(explicit)
|
||||
domain_root = Path(settings.domain_root).resolve()
|
||||
install_root = _detect_install_root(domain_root)
|
||||
if install_root is not None:
|
||||
return install_root / "runtime" / "domainCheck"
|
||||
return domain_root
|
||||
|
||||
|
||||
def _clear_path_contents(path: Path, *, preserve_names: set[str] | None = None) -> list[str]:
|
||||
if not path.exists():
|
||||
return []
|
||||
cleared: list[str] = []
|
||||
preserved = preserve_names or set()
|
||||
for child in path.iterdir():
|
||||
if child.name in preserved:
|
||||
continue
|
||||
if child.is_dir() and not child.is_symlink():
|
||||
shutil.rmtree(child, ignore_errors=False)
|
||||
else:
|
||||
child.unlink(missing_ok=True)
|
||||
cleared.append(str(child))
|
||||
return cleared
|
||||
|
||||
|
||||
def _reset_runtime_files(
|
||||
*,
|
||||
include_api_runtime: bool,
|
||||
include_worker_runtime: bool,
|
||||
include_node_agent_queue: bool,
|
||||
) -> dict:
|
||||
cleared_targets: dict[str, list[str]] = {}
|
||||
if include_api_runtime:
|
||||
api_root = api_runtime_root()
|
||||
cleared_targets["api_runtime"] = _clear_path_contents(api_root, preserve_names={".env"})
|
||||
|
||||
if include_worker_runtime:
|
||||
worker_root = _domaincheck_runtime_root()
|
||||
worker_targets: dict[str, list[str]] = {}
|
||||
for name in ("data", "logs"):
|
||||
target = worker_root / name
|
||||
worker_targets[name] = _clear_path_contents(target, preserve_names={".env"}) if target.exists() else []
|
||||
detect_worker_log = Path(settings.domain_root) / "detect_worker.log"
|
||||
if detect_worker_log.exists():
|
||||
detect_worker_log.unlink(missing_ok=True)
|
||||
worker_targets["files"] = [str(detect_worker_log)]
|
||||
cleared_targets["worker_runtime"] = [
|
||||
item for values in worker_targets.values() for item in values
|
||||
]
|
||||
|
||||
if include_node_agent_queue:
|
||||
queue_dir = _node_agent_queue_dir()
|
||||
cleared_targets["node_agent_queue"] = _clear_path_contents(queue_dir) if queue_dir.exists() else []
|
||||
|
||||
return cleared_targets
|
||||
|
||||
|
||||
def _truncate_detect_runtime_tables(*, include_domains: bool) -> dict:
|
||||
table_names = [
|
||||
"detect_debug_events",
|
||||
"detect_run_events",
|
||||
"detect_job_items",
|
||||
"detect_jobs",
|
||||
"detect_sync_records",
|
||||
"detect_tasks",
|
||||
"detect_worker_nodes",
|
||||
"domain_detections",
|
||||
"domain_blacklist",
|
||||
]
|
||||
if include_domains:
|
||||
table_names.append("domains")
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=settings.db_host,
|
||||
port=settings.db_port,
|
||||
dbname=settings.db_database,
|
||||
user=settings.db_user,
|
||||
password=settings.db_password,
|
||||
)
|
||||
try:
|
||||
conn.autocommit = False
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"TRUNCATE TABLE {', '.join(table_names)} RESTART IDENTITY CASCADE"
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return {"tables": table_names, "include_domains": bool(include_domains)}
|
||||
|
||||
|
||||
def _flush_runtime_redis() -> dict:
|
||||
client = redis.Redis(
|
||||
host=settings.redis_host,
|
||||
port=settings.redis_port,
|
||||
password=settings.redis_password or None,
|
||||
db=settings.redis_db,
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=5,
|
||||
socket_timeout=5,
|
||||
)
|
||||
try:
|
||||
size_before = int(client.dbsize() or 0)
|
||||
client.flushdb()
|
||||
size_after = int(client.dbsize() or 0)
|
||||
finally:
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
return {"db": int(settings.redis_db), "size_before": size_before, "size_after": size_after}
|
||||
|
||||
|
||||
def _run_service_command(
|
||||
runner: CommandRunner,
|
||||
service_name: str,
|
||||
action: str,
|
||||
) -> dict:
|
||||
code, stdout, stderr = runner(["systemctl", action, service_name], timeout=60)
|
||||
return {
|
||||
"service_name": service_name,
|
||||
"action": action,
|
||||
"returncode": int(code or 0),
|
||||
"stdout": trim_output(stdout, 4000),
|
||||
"stderr": trim_output(stderr, 4000),
|
||||
"ok": int(code or 0) == 0,
|
||||
}
|
||||
|
||||
|
||||
def _reset_lab_state(
|
||||
payload: dict,
|
||||
*,
|
||||
runner: CommandRunner,
|
||||
) -> tuple[bool, str, dict]:
|
||||
normalized_payload = dict(payload or {})
|
||||
stop_services = [
|
||||
str(item).strip()
|
||||
for item in list(normalized_payload.get("stop_services") or [])
|
||||
if str(item).strip()
|
||||
]
|
||||
start_services = [
|
||||
str(item).strip()
|
||||
for item in list(normalized_payload.get("start_services") or [])
|
||||
if str(item).strip()
|
||||
]
|
||||
|
||||
clear_database = _safe_bool(normalized_payload.get("clear_database"), False)
|
||||
clear_domains = _safe_bool(normalized_payload.get("clear_domains"), False)
|
||||
clear_redis = _safe_bool(normalized_payload.get("clear_redis"), False)
|
||||
clear_api_runtime = _safe_bool(normalized_payload.get("clear_api_runtime"), False)
|
||||
clear_worker_runtime = _safe_bool(normalized_payload.get("clear_worker_runtime"), True)
|
||||
clear_node_agent_queue = _safe_bool(normalized_payload.get("clear_node_agent_queue"), True)
|
||||
|
||||
result: dict[str, object] = {
|
||||
"stop_services": [],
|
||||
"start_services": [],
|
||||
"database": {},
|
||||
"redis": {},
|
||||
"runtime": {},
|
||||
}
|
||||
errors: list[str] = []
|
||||
|
||||
for service_name in stop_services:
|
||||
service_result = _run_service_command(runner, service_name, "stop")
|
||||
result["stop_services"].append(service_result)
|
||||
if not service_result["ok"]:
|
||||
errors.append(f"stop {service_name} failed")
|
||||
|
||||
try:
|
||||
if clear_database:
|
||||
result["database"] = _truncate_detect_runtime_tables(include_domains=clear_domains)
|
||||
if clear_redis:
|
||||
result["redis"] = _flush_runtime_redis()
|
||||
result["runtime"] = _reset_runtime_files(
|
||||
include_api_runtime=clear_api_runtime,
|
||||
include_worker_runtime=clear_worker_runtime,
|
||||
include_node_agent_queue=clear_node_agent_queue,
|
||||
)
|
||||
except Exception as exc:
|
||||
errors.append(str(exc))
|
||||
|
||||
for service_name in start_services:
|
||||
service_result = _run_service_command(runner, service_name, "start")
|
||||
result["start_services"].append(service_result)
|
||||
if not service_result["ok"]:
|
||||
errors.append(f"start {service_name} failed")
|
||||
|
||||
ok = not errors
|
||||
message = "lab runtime reset completed" if ok else "lab runtime reset finished with errors"
|
||||
if errors:
|
||||
result["errors"] = errors
|
||||
return ok, message, result
|
||||
|
||||
|
||||
def execute_structured_action(
|
||||
action: str,
|
||||
payload: dict | None,
|
||||
@@ -291,4 +525,12 @@ def execute_structured_action(
|
||||
}
|
||||
return True, "diagnostics collected", diagnostics
|
||||
|
||||
if normalized_action == "runtime.reset_lab_state":
|
||||
ok, message, result = _reset_lab_state(
|
||||
normalized_payload,
|
||||
runner=runner,
|
||||
)
|
||||
result.update(host_details)
|
||||
return ok, message, result
|
||||
|
||||
return False, f"unsupported action: {normalized_action}", {"action": normalized_action}
|
||||
|
||||
@@ -12,6 +12,9 @@ from app.core.db import get_db
|
||||
from app.services.ops_command_service import build_bash_command
|
||||
from app.services.ops_job_service import create_ops_job, ensure_ops_schema, get_ops_job
|
||||
from app.services.ops_template_service import build_ops_template_payload, get_ops_action_template
|
||||
from app.services.runtime_settings_service import get_runtime_settings
|
||||
from app.services.sensitive_words_service import get_sensitive_words_payload
|
||||
from app.services.settings_service import get_settings_payload
|
||||
|
||||
|
||||
_AGENT_SCHEMA_SQL = """
|
||||
@@ -117,6 +120,48 @@ def _format_time(value: object) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _prefer_non_loopback_identity(primary: object, fallback: object) -> str:
|
||||
primary_value = str(primary or "").strip()
|
||||
fallback_value = str(fallback or "").strip()
|
||||
invalid_values = {"", "localhost", "localhost.localdomain", "127.0.0.1", "::1"}
|
||||
if primary_value and primary_value.lower() not in invalid_values:
|
||||
return primary_value
|
||||
return fallback_value
|
||||
|
||||
|
||||
def _merge_detect_runtime_snapshot(cluster_metadata: dict, metadata: dict, current_load: int) -> dict:
|
||||
active_threads = int(cluster_metadata.get("active_threads", metadata.get("active_threads", 0)) or 0)
|
||||
max_threads = int(cluster_metadata.get("max_threads", metadata.get("max_threads", 0)) or 0)
|
||||
inferred_worker_online = bool(
|
||||
cluster_metadata.get("worker_online", metadata.get("worker_online", False))
|
||||
or cluster_metadata.get("service_running", metadata.get("service_running", False))
|
||||
or current_load > 0
|
||||
or active_threads > 0
|
||||
)
|
||||
inferred_detect_participating = bool(
|
||||
cluster_metadata.get("detect_participating", metadata.get("detect_participating", False))
|
||||
or current_load > 0
|
||||
or active_threads > 0
|
||||
)
|
||||
return {
|
||||
"worker_online": inferred_worker_online,
|
||||
"detect_participating": inferred_detect_participating,
|
||||
"active_threads": active_threads,
|
||||
"max_threads": max_threads,
|
||||
"current_load": current_load,
|
||||
"phase_label": str(cluster_metadata.get("phase_label", metadata.get("phase_label", "")) or "").strip(),
|
||||
"phase_detail": str(cluster_metadata.get("phase_detail", metadata.get("phase_detail", "")) or "").strip(),
|
||||
"recent_warning": str(cluster_metadata.get("recent_warning", metadata.get("recent_warning", "")) or "").strip(),
|
||||
"proxy_runtime_label": str(
|
||||
cluster_metadata.get("proxy_runtime_label", metadata.get("proxy_runtime_label", "")) or ""
|
||||
).strip(),
|
||||
"proxy_runtime_reason": str(
|
||||
cluster_metadata.get("proxy_runtime_reason", metadata.get("proxy_runtime_reason", "")) or ""
|
||||
).strip(),
|
||||
"updated_at": str(cluster_metadata.get("updated_at", metadata.get("updated_at", "")) or "").strip(),
|
||||
}
|
||||
|
||||
|
||||
def _ops_job_event_level_label(level: object) -> str:
|
||||
normalized_level = str(level or "").strip().lower()
|
||||
mapping = {
|
||||
@@ -588,6 +633,7 @@ def list_managed_nodes_with_agent_state(*, participation_payload: dict | None =
|
||||
seen_node_codes.add(node_code)
|
||||
metadata = dict(node.get("metadata") or {})
|
||||
cluster_node = cluster_map.get(node_code, {})
|
||||
cluster_metadata = dict(cluster_node.get("metadata") or {})
|
||||
latest_token = latest_tokens.get(node_code, {})
|
||||
latest_job = latest_jobs.get(node_code, {})
|
||||
participation_row = dict(detect_participation_map.get(node_code) or {})
|
||||
@@ -607,6 +653,15 @@ def list_managed_nodes_with_agent_state(*, participation_payload: dict | None =
|
||||
current_load=current_load,
|
||||
)
|
||||
capabilities = list(metadata.get("capabilities") or [])
|
||||
detect_runtime = _merge_detect_runtime_snapshot(cluster_metadata, metadata, current_load)
|
||||
active_job_snapshot = {
|
||||
"job_code": str(cluster_metadata.get("active_job_code", metadata.get("active_job_code", "")) or "").strip(),
|
||||
"status": str(cluster_metadata.get("active_job_status", metadata.get("active_job_status", "")) or "").strip(),
|
||||
"items_total": int(cluster_metadata.get("job_items_total", metadata.get("job_items_total", 0)) or 0),
|
||||
"items_claimed": int(cluster_metadata.get("job_items_claimed", metadata.get("job_items_claimed", 0)) or 0),
|
||||
"items_running": int(cluster_metadata.get("job_items_running", metadata.get("job_items_running", 0)) or 0),
|
||||
"items_completed": int(cluster_metadata.get("job_items_completed", metadata.get("job_items_completed", 0)) or 0),
|
||||
}
|
||||
merged_node = {
|
||||
**node,
|
||||
"is_managed": True,
|
||||
@@ -620,10 +675,14 @@ def list_managed_nodes_with_agent_state(*, participation_payload: dict | None =
|
||||
"agent_version": str(metadata.get("agent_version") or "").strip(),
|
||||
"agent_hostname": str(metadata.get("hostname") or "").strip(),
|
||||
"agent_ip": str(metadata.get("ip") or "").strip(),
|
||||
"cluster_hostname": str(cluster_node.get("hostname") or "").strip(),
|
||||
"cluster_ip": str(cluster_node.get("ip") or "").strip(),
|
||||
"cluster_hostname": _prefer_non_loopback_identity(cluster_node.get("hostname"), metadata.get("hostname")),
|
||||
"cluster_ip": _prefer_non_loopback_identity(cluster_node.get("ip"), metadata.get("ip")),
|
||||
"cluster_status": cluster_status,
|
||||
"cluster_current_load": current_load,
|
||||
"current_load": current_load,
|
||||
"detect_runtime": detect_runtime,
|
||||
"runtime_state": detect_runtime,
|
||||
"active_job": active_job_snapshot,
|
||||
"cluster_last_heartbeat_at": str(cluster_node.get("last_heartbeat_at") or metadata.get("last_heartbeat_at") or "").strip(),
|
||||
"cluster_is_effective_worker": bool(cluster_node.get("is_effective_worker", metadata.get("is_effective_worker", False))),
|
||||
"cluster_detect_participating": bool(
|
||||
@@ -688,6 +747,7 @@ def list_managed_nodes_with_agent_state(*, participation_payload: dict | None =
|
||||
if not node_code or node_code in seen_node_codes:
|
||||
continue
|
||||
metadata = dict(cluster_node.get("metadata") or {})
|
||||
cluster_metadata = dict(cluster_node.get("metadata") or {})
|
||||
latest_token = latest_tokens.get(node_code, {})
|
||||
latest_job = latest_jobs.get(node_code, {})
|
||||
participation_row = dict(detect_participation_map.get(node_code) or {})
|
||||
@@ -706,6 +766,15 @@ def list_managed_nodes_with_agent_state(*, participation_payload: dict | None =
|
||||
current_load=current_load,
|
||||
)
|
||||
capabilities = list(metadata.get("capabilities") or [])
|
||||
detect_runtime = _merge_detect_runtime_snapshot(cluster_metadata, metadata, current_load)
|
||||
active_job_snapshot = {
|
||||
"job_code": str(cluster_metadata.get("active_job_code", metadata.get("active_job_code", "")) or "").strip(),
|
||||
"status": str(cluster_metadata.get("active_job_status", metadata.get("active_job_status", "")) or "").strip(),
|
||||
"items_total": int(cluster_metadata.get("job_items_total", metadata.get("job_items_total", 0)) or 0),
|
||||
"items_claimed": int(cluster_metadata.get("job_items_claimed", metadata.get("job_items_claimed", 0)) or 0),
|
||||
"items_running": int(cluster_metadata.get("job_items_running", metadata.get("job_items_running", 0)) or 0),
|
||||
"items_completed": int(cluster_metadata.get("job_items_completed", metadata.get("job_items_completed", 0)) or 0),
|
||||
}
|
||||
fallback_node = {
|
||||
"node_code": node_code,
|
||||
"region": str(cluster_node.get("region") or "").strip(),
|
||||
@@ -732,12 +801,16 @@ def list_managed_nodes_with_agent_state(*, participation_payload: dict | None =
|
||||
"capabilities": capabilities,
|
||||
"capabilities_count": len(capabilities),
|
||||
"agent_version": str(metadata.get("agent_version") or "").strip(),
|
||||
"agent_hostname": str(cluster_node.get("hostname") or metadata.get("hostname") or "").strip(),
|
||||
"agent_ip": str(cluster_node.get("ip") or metadata.get("ip") or "").strip(),
|
||||
"cluster_hostname": str(cluster_node.get("hostname") or "").strip(),
|
||||
"cluster_ip": str(cluster_node.get("ip") or "").strip(),
|
||||
"agent_hostname": _prefer_non_loopback_identity(cluster_node.get("hostname"), metadata.get("hostname")),
|
||||
"agent_ip": _prefer_non_loopback_identity(cluster_node.get("ip"), metadata.get("ip")),
|
||||
"cluster_hostname": _prefer_non_loopback_identity(cluster_node.get("hostname"), metadata.get("hostname")),
|
||||
"cluster_ip": _prefer_non_loopback_identity(cluster_node.get("ip"), metadata.get("ip")),
|
||||
"cluster_status": cluster_status,
|
||||
"cluster_current_load": current_load,
|
||||
"current_load": current_load,
|
||||
"detect_runtime": detect_runtime,
|
||||
"runtime_state": detect_runtime,
|
||||
"active_job": active_job_snapshot,
|
||||
"cluster_last_heartbeat_at": str(cluster_node.get("last_heartbeat_at") or "").strip(),
|
||||
"cluster_is_effective_worker": bool(cluster_node.get("is_effective_worker", False)),
|
||||
"cluster_detect_participating": bool(
|
||||
@@ -2359,7 +2432,7 @@ def build_node_agent_bootstrap_plan(
|
||||
f"SYNC_AGENT_SERVICE_NAME={settings.sync_agent_service_name}",
|
||||
"NODE_AGENT_SERVICE_NAME=domaincheck-node-agent",
|
||||
"",
|
||||
'OPS_AGENT_CAPABILITIES=["service.start","service.stop","service.restart","service.status","runtime.start_worker","runtime.stop_worker","runtime.restart_api","runtime.start_sync_agent","runtime.stop_sync_agent","health.snapshot","logs.collect","diagnostics.collect","deploy.release"]',
|
||||
'OPS_AGENT_CAPABILITIES=["service.start","service.stop","service.restart","service.status","runtime.start_worker","runtime.stop_worker","runtime.start_detection","runtime.stop_detection","runtime.pull_tasks","runtime.restart_api","runtime.start_sync_agent","runtime.stop_sync_agent","runtime.reset_lab_state","health.snapshot","logs.collect","diagnostics.collect","deploy.release"]',
|
||||
"OPS_AGENT_LABELS={}",
|
||||
]
|
||||
env_content = "\n".join(env_lines)
|
||||
@@ -2529,6 +2602,135 @@ def _upsert_agent_runtime(node_code: str, payload: dict) -> None:
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _load_existing_detect_node_runtime(node_code: str) -> dict:
|
||||
normalized_node_code = str(node_code or "").strip()
|
||||
if not normalized_node_code:
|
||||
return {}
|
||||
|
||||
try:
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT status, current_load, metadata_json, last_heartbeat_at
|
||||
FROM detect_worker_nodes
|
||||
WHERE node_code = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(normalized_node_code,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
if not row:
|
||||
return {}
|
||||
metadata = row[2] if isinstance(row[2], dict) else {}
|
||||
return {
|
||||
"status": str(row[0] or "").strip(),
|
||||
"current_load": int(row[1] or 0),
|
||||
"metadata": metadata,
|
||||
"last_heartbeat_at": row[3],
|
||||
}
|
||||
|
||||
|
||||
def _upsert_agent_detect_runtime(node_code: str, payload: dict) -> None:
|
||||
normalized_node_code = str(node_code or "").strip()
|
||||
if not normalized_node_code:
|
||||
return
|
||||
|
||||
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
|
||||
detect_runtime = metadata.get("detect_runtime") if isinstance(metadata.get("detect_runtime"), dict) else {}
|
||||
if not detect_runtime:
|
||||
return
|
||||
|
||||
try:
|
||||
from app.services.cluster_runtime_service import register_node_heartbeat
|
||||
|
||||
region = str(payload.get("region") or "unknown").strip() or "unknown"
|
||||
role = str(payload.get("role") or "worker").strip() or "worker"
|
||||
active_threads = max(0, int(detect_runtime.get("active_threads") or 0))
|
||||
max_threads = max(0, int(detect_runtime.get("max_threads") or 0))
|
||||
current_load = max(
|
||||
0,
|
||||
int(detect_runtime.get("current_load") or 0),
|
||||
active_threads,
|
||||
)
|
||||
worker_online = bool(detect_runtime.get("worker_online", False) or detect_runtime.get("service_running", False))
|
||||
detect_participating = bool(detect_runtime.get("detect_participating", False) or current_load > 0)
|
||||
phase_label = str(detect_runtime.get("phase_label") or "").strip()
|
||||
phase_detail = str(detect_runtime.get("phase_detail") or "").strip()
|
||||
recent_warning = str(detect_runtime.get("recent_warning") or "").strip()
|
||||
has_runtime_signal = any(
|
||||
[
|
||||
worker_online,
|
||||
detect_participating,
|
||||
current_load > 0,
|
||||
active_threads > 0,
|
||||
max_threads > 0,
|
||||
bool(phase_label),
|
||||
bool(phase_detail),
|
||||
bool(recent_warning),
|
||||
]
|
||||
)
|
||||
if not has_runtime_signal:
|
||||
return
|
||||
existing_runtime = _load_existing_detect_node_runtime(normalized_node_code)
|
||||
existing_metadata = existing_runtime.get("metadata") if isinstance(existing_runtime.get("metadata"), dict) else {}
|
||||
existing_active_threads = max(0, int(existing_metadata.get("active_threads", 0) or 0))
|
||||
existing_current_load = max(0, int(existing_runtime.get("current_load", 0) or 0), existing_active_threads)
|
||||
existing_status = str(existing_runtime.get("status") or "").strip()
|
||||
existing_last_heartbeat_at = existing_runtime.get("last_heartbeat_at")
|
||||
existing_is_recent = False
|
||||
if isinstance(existing_last_heartbeat_at, datetime):
|
||||
current_time = (
|
||||
datetime.now(existing_last_heartbeat_at.tzinfo)
|
||||
if existing_last_heartbeat_at.tzinfo
|
||||
else datetime.now()
|
||||
)
|
||||
existing_is_recent = (current_time - existing_last_heartbeat_at) <= timedelta(seconds=120)
|
||||
normalized_phase_detail = phase_detail.lower()
|
||||
generic_phase_detail = bool(normalized_phase_detail) and "/" in normalized_phase_detail and " " not in normalized_phase_detail
|
||||
weak_agent_snapshot = bool(
|
||||
worker_online
|
||||
and current_load <= 0
|
||||
and active_threads <= 0
|
||||
and max_threads > 0
|
||||
and not phase_label
|
||||
and (not phase_detail or generic_phase_detail)
|
||||
and not recent_warning
|
||||
)
|
||||
if weak_agent_snapshot and existing_is_recent and (
|
||||
existing_current_load > 0 or existing_status == "busy"
|
||||
):
|
||||
return
|
||||
status = "busy" if current_load > 0 else ("online" if worker_online else "unknown")
|
||||
heartbeat_metadata = {
|
||||
"service": "agent-heartbeat",
|
||||
"worker_online": worker_online,
|
||||
"detect_participating": detect_participating,
|
||||
"active_threads": active_threads,
|
||||
"max_threads": max_threads,
|
||||
"phase_label": phase_label,
|
||||
"phase_detail": phase_detail,
|
||||
"recent_warning": recent_warning,
|
||||
"updated_at": str(detect_runtime.get("updated_at") or "").strip(),
|
||||
"agent_heartbeat_at": datetime.now().isoformat(timespec="seconds"),
|
||||
}
|
||||
register_node_heartbeat(
|
||||
node_code=normalized_node_code,
|
||||
region=region,
|
||||
role=role,
|
||||
status=status,
|
||||
current_load=current_load,
|
||||
metadata=heartbeat_metadata,
|
||||
hostname_override=str(payload.get("hostname") or "").strip(),
|
||||
ip_override=str(payload.get("ip") or "").strip(),
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def agent_register(payload: dict, *, token: str) -> tuple[bool, str, dict]:
|
||||
node_code = str(payload.get("node_code") or "").strip()
|
||||
if not node_code:
|
||||
@@ -2537,6 +2739,7 @@ def agent_register(payload: dict, *, token: str) -> tuple[bool, str, dict]:
|
||||
if not ok:
|
||||
return False, message, auth
|
||||
_upsert_agent_runtime(node_code, payload)
|
||||
_upsert_agent_detect_runtime(node_code, payload)
|
||||
return True, "Agent 注册成功", {
|
||||
"node_code": node_code,
|
||||
"expires_at": auth.get("expires_at", ""),
|
||||
@@ -2552,6 +2755,7 @@ def agent_heartbeat(payload: dict, *, token: str) -> tuple[bool, str, dict]:
|
||||
if not ok:
|
||||
return False, message, auth
|
||||
_upsert_agent_runtime(node_code, payload)
|
||||
_upsert_agent_detect_runtime(node_code, payload)
|
||||
return True, "heartbeat ok", {
|
||||
"node_code": node_code,
|
||||
"server_time": _format_time(datetime.now()),
|
||||
@@ -2559,6 +2763,57 @@ def agent_heartbeat(payload: dict, *, token: str) -> tuple[bool, str, dict]:
|
||||
}
|
||||
|
||||
|
||||
def _runtime_config_bundle_hash_payload(bundle: dict | None) -> dict:
|
||||
normalized = dict(bundle or {})
|
||||
normalized.pop("config_hash", None)
|
||||
normalized.pop("generated_at", None)
|
||||
return normalized
|
||||
|
||||
|
||||
def _build_agent_runtime_config_bundle(node_code: str) -> dict:
|
||||
settings_payload = get_settings_payload()
|
||||
runtime_settings = get_runtime_settings()
|
||||
sensitive_words_payload = get_sensitive_words_payload()
|
||||
bundle = {
|
||||
"node_code": str(node_code or "").strip(),
|
||||
"detect_options": dict(settings_payload.get("detect_options") or {}),
|
||||
"proxy_config": dict(settings_payload.get("proxy_config") or {}),
|
||||
"thread_count": int(settings_payload.get("thread_count", 2) or 2),
|
||||
"node_thread_counts": dict(settings_payload.get("node_thread_counts") or {}),
|
||||
"runtime_settings": dict(runtime_settings or {}),
|
||||
"sensitive_words": {
|
||||
"text": str(sensitive_words_payload.get("text") or ""),
|
||||
"total": int(sensitive_words_payload.get("total", 0) or 0),
|
||||
"items": list(sensitive_words_payload.get("items") or []),
|
||||
},
|
||||
"generated_at": _format_time(datetime.now()),
|
||||
}
|
||||
bundle["config_hash"] = hashlib.sha256(
|
||||
json.dumps(
|
||||
_runtime_config_bundle_hash_payload(bundle),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
return bundle
|
||||
|
||||
|
||||
def agent_pull_runtime_config(payload: dict, *, token: str) -> tuple[bool, str, dict]:
|
||||
node_code = str(payload.get("node_code") or "").strip()
|
||||
if not node_code:
|
||||
return _agent_error("node_code 不能为空", "agent_node_code_required")
|
||||
ok, message, auth = _authenticate_agent_token(token, expected_node_code=node_code)
|
||||
if not ok:
|
||||
return False, message, auth
|
||||
bundle = _build_agent_runtime_config_bundle(node_code)
|
||||
return True, "runtime config ok", {
|
||||
"node_code": node_code,
|
||||
"server_time": _format_time(datetime.now()),
|
||||
"expires_at": auth.get("expires_at", ""),
|
||||
"bundle": bundle,
|
||||
}
|
||||
|
||||
|
||||
def agent_pull_jobs(payload: dict, *, token: str, limit: int = 1) -> tuple[bool, str, dict]:
|
||||
node_code = str(payload.get("node_code") or "").strip()
|
||||
if not node_code:
|
||||
@@ -2568,6 +2823,7 @@ def agent_pull_jobs(payload: dict, *, token: str, limit: int = 1) -> tuple[bool,
|
||||
return False, message, _auth
|
||||
|
||||
safe_limit = min(max(int(limit or 1), 1), 10)
|
||||
dispatched_events: list[dict] = []
|
||||
with get_db() as conn:
|
||||
conn.autocommit = False
|
||||
with conn.cursor() as cur:
|
||||
@@ -2604,15 +2860,19 @@ def agent_pull_jobs(payload: dict, *, token: str, limit: int = 1) -> tuple[bool,
|
||||
""",
|
||||
(job_id,),
|
||||
)
|
||||
append_ops_job_event(
|
||||
job_id=job_id,
|
||||
node_code=node_code,
|
||||
event_type="agent_dispatched",
|
||||
message=f"任务已派发给节点 {node_code}",
|
||||
payload={"node_code": node_code},
|
||||
dispatched_events.append(
|
||||
{
|
||||
"job_id": job_id,
|
||||
"node_code": node_code,
|
||||
"event_type": "agent_dispatched",
|
||||
"message": f"任务已派发给节点 {node_code}",
|
||||
"payload": {"node_code": node_code},
|
||||
}
|
||||
)
|
||||
jobs.append(_agent_job_envelope(get_ops_job(job_id)))
|
||||
conn.commit()
|
||||
for event in dispatched_events:
|
||||
append_ops_job_event(**event)
|
||||
return True, "ok", {
|
||||
"jobs": jobs,
|
||||
"count": len(jobs),
|
||||
@@ -2631,6 +2891,7 @@ def agent_mark_job_started(job_id: int, payload: dict, *, token: str) -> tuple[b
|
||||
if not ok:
|
||||
return False, message, _auth
|
||||
|
||||
started_event: dict | None = None
|
||||
with get_db() as conn:
|
||||
conn.autocommit = False
|
||||
with conn.cursor() as cur:
|
||||
@@ -2662,14 +2923,16 @@ def agent_mark_job_started(job_id: int, payload: dict, *, token: str) -> tuple[b
|
||||
)
|
||||
step_rows = cur.fetchall()
|
||||
step_ids = [int(item[0]) for item in step_rows]
|
||||
append_ops_job_event(
|
||||
job_id=int(job_id),
|
||||
node_code=node_code,
|
||||
event_type="agent_started",
|
||||
message=f"节点 {node_code} 已开始执行任务",
|
||||
payload={"step_ids": step_ids},
|
||||
)
|
||||
started_event = {
|
||||
"job_id": int(job_id),
|
||||
"node_code": node_code,
|
||||
"event_type": "agent_started",
|
||||
"message": f"节点 {node_code} 已开始执行任务",
|
||||
"payload": {"step_ids": step_ids},
|
||||
}
|
||||
conn.commit()
|
||||
if started_event:
|
||||
append_ops_job_event(**started_event)
|
||||
job = get_ops_job(int(job_id))
|
||||
return True, "任务已标记为运行中", {
|
||||
"job": job,
|
||||
@@ -2710,6 +2973,7 @@ def agent_complete_job(job_id: int, payload: dict, *, token: str) -> tuple[bool,
|
||||
result["focus_ref"] = focus_ref
|
||||
event_level = "info" if job_status == "success" else ("warning" if job_status == "partially_succeeded" else "error")
|
||||
|
||||
completed_event: dict | None = None
|
||||
with get_db() as conn:
|
||||
conn.autocommit = False
|
||||
with conn.cursor() as cur:
|
||||
@@ -2789,22 +3053,24 @@ def agent_complete_job(job_id: int, payload: dict, *, token: str) -> tuple[bool,
|
||||
)
|
||||
step_rows = cur.fetchall()
|
||||
step_ids = [int(item[0]) for item in step_rows]
|
||||
append_ops_job_event(
|
||||
job_id=int(job_id),
|
||||
node_code=node_code,
|
||||
client_event_id=(f"complete:{client_request_id}" if client_request_id else ""),
|
||||
event_type="agent_completed",
|
||||
message=f"节点 {node_code} 已完成任务,状态: {job_status}",
|
||||
level=event_level,
|
||||
payload={
|
||||
completed_event = {
|
||||
"job_id": int(job_id),
|
||||
"node_code": node_code,
|
||||
"client_event_id": (f"complete:{client_request_id}" if client_request_id else ""),
|
||||
"event_type": "agent_completed",
|
||||
"message": f"节点 {node_code} 已完成任务,状态: {job_status}",
|
||||
"level": event_level,
|
||||
"payload": {
|
||||
"step_ids": step_ids,
|
||||
"result": result,
|
||||
"duration_ms": duration_ms,
|
||||
"summary_text": str(result.get("summary_text") or result.get("summary") or summary_text).strip(),
|
||||
"focus_ref": focus_ref,
|
||||
},
|
||||
)
|
||||
}
|
||||
conn.commit()
|
||||
if completed_event:
|
||||
append_ops_job_event(**completed_event)
|
||||
from app.services.ops_release_service import refresh_release_rollout_for_job
|
||||
|
||||
refresh_release_rollout_for_job(int(job_id))
|
||||
|
||||
@@ -39,6 +39,13 @@ CREATE TABLE IF NOT EXISTS ops_managed_nodes (
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ops_managed_node_secrets (
|
||||
node_code VARCHAR(64) PRIMARY KEY REFERENCES ops_managed_nodes(node_code) ON DELETE CASCADE,
|
||||
ssh_password TEXT NOT NULL DEFAULT '',
|
||||
ssh_private_key TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ops_jobs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
job_code VARCHAR(64) NOT NULL UNIQUE,
|
||||
@@ -321,6 +328,107 @@ def _serialize_node_row(row: tuple) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _load_node_secret_flags(node_codes: list[str]) -> dict[str, dict]:
|
||||
normalized_codes = [str(item or "").strip() for item in node_codes if str(item or "").strip()]
|
||||
if not normalized_codes:
|
||||
return {}
|
||||
result: dict[str, dict] = {}
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT node_code, ssh_password, ssh_private_key
|
||||
FROM ops_managed_node_secrets
|
||||
WHERE node_code = ANY(%s)
|
||||
""",
|
||||
(normalized_codes,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
for row in rows:
|
||||
node_code = str(row[0] or "").strip()
|
||||
result[node_code] = {
|
||||
"ssh_password_configured": bool(str(row[1] or "").strip()),
|
||||
"ssh_private_key_configured": bool(str(row[2] or "").strip()),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _parse_ssh_entry(raw_value: object) -> dict:
|
||||
raw = str(raw_value or "").strip()
|
||||
if not raw:
|
||||
return {}
|
||||
parts = raw.split(maxsplit=2)
|
||||
if len(parts) < 2:
|
||||
return {}
|
||||
host_port = str(parts[0] or "").strip()
|
||||
ssh_user = str(parts[1] or "").strip()
|
||||
secret = str(parts[2] or "").strip() if len(parts) >= 3 else ""
|
||||
ssh_host = host_port
|
||||
ssh_port = 22
|
||||
if ":" in host_port:
|
||||
host_candidate, port_candidate = host_port.rsplit(":", 1)
|
||||
if host_candidate and port_candidate.isdigit():
|
||||
ssh_host = host_candidate
|
||||
ssh_port = max(int(port_candidate), 1)
|
||||
if secret.startswith("<") and secret.endswith(">") and len(secret) >= 2:
|
||||
secret = secret[1:-1].strip()
|
||||
payload = {
|
||||
"ssh_host": ssh_host,
|
||||
"ssh_port": ssh_port,
|
||||
"ssh_user": ssh_user,
|
||||
}
|
||||
if secret:
|
||||
payload["auth_mode"] = "password"
|
||||
payload["ssh_password"] = secret
|
||||
return payload
|
||||
|
||||
|
||||
def _upsert_managed_node_secret(
|
||||
*,
|
||||
node_code: str,
|
||||
ssh_password: str = "",
|
||||
ssh_private_key: str = "",
|
||||
clear_ssh_password: bool = False,
|
||||
clear_ssh_private_key: bool = False,
|
||||
) -> None:
|
||||
normalized_node_code = str(node_code or "").strip()
|
||||
if not normalized_node_code:
|
||||
return
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO ops_managed_node_secrets (
|
||||
node_code, ssh_password, ssh_private_key, updated_at
|
||||
) VALUES (%s, %s, %s, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (node_code) DO UPDATE SET
|
||||
ssh_password = CASE
|
||||
WHEN %s THEN ''
|
||||
WHEN %s <> '' THEN %s
|
||||
ELSE ops_managed_node_secrets.ssh_password
|
||||
END,
|
||||
ssh_private_key = CASE
|
||||
WHEN %s THEN ''
|
||||
WHEN %s <> '' THEN %s
|
||||
ELSE ops_managed_node_secrets.ssh_private_key
|
||||
END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(
|
||||
normalized_node_code,
|
||||
"" if clear_ssh_password else ssh_password,
|
||||
"" if clear_ssh_private_key else ssh_private_key,
|
||||
clear_ssh_password,
|
||||
ssh_password,
|
||||
ssh_password,
|
||||
clear_ssh_private_key,
|
||||
ssh_private_key,
|
||||
ssh_private_key,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _pick_text_value(payload: dict, key: str, fallback: str = "", *, default: str = "") -> str:
|
||||
if key in payload:
|
||||
normalized = str(payload.get(key) or "").strip()
|
||||
@@ -397,6 +505,15 @@ def upsert_managed_node(payload: dict) -> tuple[bool, str, dict]:
|
||||
node_code = str(payload.get("node_code") or "").strip()
|
||||
if not node_code:
|
||||
return False, "node_code 不能为空", {}
|
||||
parsed_ssh_entry = _parse_ssh_entry(payload.get("ssh_entry"))
|
||||
merged_payload = {
|
||||
**dict(payload or {}),
|
||||
**{key: value for key, value in parsed_ssh_entry.items() if value not in ("", None)},
|
||||
}
|
||||
ssh_password = str(merged_payload.get("ssh_password") or "").strip()
|
||||
ssh_private_key = str(merged_payload.get("ssh_private_key") or "")
|
||||
clear_ssh_password = bool(merged_payload.get("clear_ssh_password", False))
|
||||
clear_ssh_private_key = bool(merged_payload.get("clear_ssh_private_key", False))
|
||||
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
@@ -413,22 +530,22 @@ def upsert_managed_node(payload: dict) -> tuple[bool, str, dict]:
|
||||
existing_row = cur.fetchone()
|
||||
existing_node = _serialize_node_row(existing_row) if existing_row else {}
|
||||
existing_metadata = dict(existing_node.get("metadata") or {})
|
||||
incoming_metadata = dict(payload.get("metadata") or {})
|
||||
incoming_metadata = dict(merged_payload.get("metadata") or {})
|
||||
|
||||
region = _pick_text_value(payload, "region", str(existing_node.get("region") or ""), default="unknown") or "unknown"
|
||||
role = _pick_text_value(payload, "role", str(existing_node.get("role") or ""), default="worker") or "worker"
|
||||
title = _pick_text_value(payload, "title", str(existing_node.get("title") or ""), default=node_code) or node_code
|
||||
ssh_host = _pick_text_value(payload, "ssh_host", str(existing_node.get("ssh_host") or ""))
|
||||
ssh_port = _pick_int_value(payload, "ssh_port", int(existing_node.get("ssh_port") or 22), default=22, minimum=1)
|
||||
ssh_user = _pick_text_value(payload, "ssh_user", str(existing_node.get("ssh_user") or ""))
|
||||
auth_mode = _pick_text_value(payload, "auth_mode", str(existing_node.get("auth_mode") or ""), default="key") or "key"
|
||||
region = _pick_text_value(merged_payload, "region", str(existing_node.get("region") or ""), default="unknown") or "unknown"
|
||||
role = _pick_text_value(merged_payload, "role", str(existing_node.get("role") or ""), default="worker") or "worker"
|
||||
title = _pick_text_value(merged_payload, "title", str(existing_node.get("title") or ""), default=node_code) or node_code
|
||||
ssh_host = _pick_text_value(merged_payload, "ssh_host", str(existing_node.get("ssh_host") or ""))
|
||||
ssh_port = _pick_int_value(merged_payload, "ssh_port", int(existing_node.get("ssh_port") or 22), default=22, minimum=1)
|
||||
ssh_user = _pick_text_value(merged_payload, "ssh_user", str(existing_node.get("ssh_user") or ""))
|
||||
auth_mode = _pick_text_value(merged_payload, "auth_mode", str(existing_node.get("auth_mode") or ""), default="key") or "key"
|
||||
deploy_channel = _pick_text_value(
|
||||
payload,
|
||||
merged_payload,
|
||||
"deploy_channel",
|
||||
str(existing_node.get("deploy_channel") or ""),
|
||||
default="stable",
|
||||
) or "stable"
|
||||
is_enabled = bool(payload["is_enabled"]) if "is_enabled" in payload else bool(existing_node.get("is_enabled", True))
|
||||
is_enabled = bool(merged_payload["is_enabled"]) if "is_enabled" in merged_payload else bool(existing_node.get("is_enabled", True))
|
||||
metadata = {
|
||||
**existing_metadata,
|
||||
**incoming_metadata,
|
||||
@@ -469,7 +586,16 @@ def upsert_managed_node(payload: dict) -> tuple[bool, str, dict]:
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
return True, "托管节点已保存", {"node": _serialize_node_row(row)}
|
||||
_upsert_managed_node_secret(
|
||||
node_code=node_code,
|
||||
ssh_password=ssh_password,
|
||||
ssh_private_key=ssh_private_key,
|
||||
clear_ssh_password=clear_ssh_password,
|
||||
clear_ssh_private_key=clear_ssh_private_key,
|
||||
)
|
||||
node = _serialize_node_row(row)
|
||||
node.update(_load_node_secret_flags([node_code]).get(node_code, {}))
|
||||
return True, "托管节点已保存", {"node": node}
|
||||
|
||||
|
||||
def list_managed_nodes() -> list[dict]:
|
||||
@@ -485,7 +611,11 @@ def list_managed_nodes() -> list[dict]:
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [_serialize_node_row(row) for row in rows]
|
||||
items = [_serialize_node_row(row) for row in rows]
|
||||
secret_flags = _load_node_secret_flags([str(item.get("node_code") or "") for item in items])
|
||||
for item in items:
|
||||
item.update(secret_flags.get(str(item.get("node_code") or "").strip(), {}))
|
||||
return items
|
||||
|
||||
|
||||
def sync_managed_nodes_from_cluster(*, dry_run: bool = False) -> dict:
|
||||
|
||||
@@ -42,6 +42,7 @@ _CRITICAL_RISK_ACTIONS = {
|
||||
"deploy.rollback",
|
||||
"node.bootstrap",
|
||||
"cluster.reconfigure",
|
||||
"runtime.reset_lab_state",
|
||||
}
|
||||
|
||||
|
||||
@@ -166,11 +167,23 @@ def _preview_single_node_policy(
|
||||
node_detect_participating = bool(target_node.get("detect_participating", False))
|
||||
|
||||
if target_node:
|
||||
if node_status in {"busy"} and action in {"runtime.stop_worker", "runtime.restart_api", "deploy.release", "deploy.rollback", "service.restart"}:
|
||||
blocking_reasons.append("目标节点当前处于 busy 状态,不适合直接执行中断类动作。")
|
||||
interrupt_actions = {"runtime.stop_worker", "runtime.restart_api", "service.restart"}
|
||||
restart_like_actions = {"service.restart"}
|
||||
rolling_deploy_actions = {"deploy.release", "deploy.rollback"}
|
||||
|
||||
if node_detect_participating and action in {"runtime.stop_worker", "runtime.restart_api", "deploy.release", "deploy.rollback", "service.restart"}:
|
||||
if node_status in {"busy"} and action in interrupt_actions - restart_like_actions:
|
||||
blocking_reasons.append("目标节点当前处于 busy 状态,不适合直接执行中断类动作。")
|
||||
elif node_status in {"busy"} and action in restart_like_actions:
|
||||
warnings.append("目标节点当前处于 busy 状态,重启会带来瞬时抖动,请确认当前窗口可接受。")
|
||||
elif node_status in {"busy"} and action in rolling_deploy_actions:
|
||||
warnings.append("目标节点当前处于 busy 状态,滚动发布会触发服务重启,请确认当前窗口可接受短暂抖动。")
|
||||
|
||||
if node_detect_participating and action in interrupt_actions - restart_like_actions:
|
||||
blocking_reasons.append("目标节点正在参与检测,需先迁移负载或人工确认后再执行。")
|
||||
elif node_detect_participating and action in restart_like_actions:
|
||||
warnings.append("目标节点正在参与检测,重启会中断当前任务,请确认剩余节点仍可承接负载。")
|
||||
elif node_detect_participating and action in rolling_deploy_actions:
|
||||
warnings.append("目标节点正在参与检测,建议优先采用单节点滚动发布,并确认其余节点仍可承接负载。")
|
||||
|
||||
if node_role == "control" and action in {"runtime.restart_api", "deploy.release", "deploy.rollback", "service.restart"}:
|
||||
approval_reasons.append("目标节点是 control 节点,建议强制走审批或维护窗口。")
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import grp
|
||||
import hashlib
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import pwd
|
||||
import shutil
|
||||
import tarfile
|
||||
import textwrap
|
||||
@@ -13,6 +16,22 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_SYSTEMD_TEMPLATE_SPECS = {
|
||||
"domaincheck-api": {
|
||||
"template": Path("domain-api/deploy/systemd/domain-api.service"),
|
||||
},
|
||||
"domaincheck-worker": {
|
||||
"template": Path("domain-api/deploy/systemd/domain-worker.service"),
|
||||
},
|
||||
"domaincheck-sync-agent": {
|
||||
"template": Path("domain-api/deploy/systemd/domain-sync-agent.service"),
|
||||
},
|
||||
"domaincheck-node-agent": {
|
||||
"template": Path("domain-api/deploy/systemd/domain-node-agent.service"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def normalize_text_list(raw_value: object) -> list[str]:
|
||||
if isinstance(raw_value, list):
|
||||
return [str(item).strip() for item in raw_value if str(item).strip()]
|
||||
@@ -147,6 +166,219 @@ def run_release_health_checks(
|
||||
}
|
||||
|
||||
|
||||
def ensure_directory_ready(path: Path) -> tuple[bool, dict]:
|
||||
normalized_path = Path(path).resolve()
|
||||
try:
|
||||
normalized_path.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as exc:
|
||||
return False, {
|
||||
"path": str(normalized_path),
|
||||
"error": str(exc),
|
||||
"exception_type": exc.__class__.__name__,
|
||||
}
|
||||
return True, {"path": str(normalized_path)}
|
||||
|
||||
|
||||
def _resolve_path_owner_group(path: Path) -> tuple[str, str]:
|
||||
normalized_path = Path(path).resolve()
|
||||
stat_info = normalized_path.stat()
|
||||
owner_user = ""
|
||||
owner_group = ""
|
||||
try:
|
||||
owner_user = pwd.getpwuid(stat_info.st_uid).pw_name
|
||||
except Exception:
|
||||
owner_user = ""
|
||||
try:
|
||||
owner_group = grp.getgrgid(stat_info.st_gid).gr_name
|
||||
except Exception:
|
||||
owner_group = ""
|
||||
return owner_user, owner_group
|
||||
|
||||
|
||||
def collect_service_identity(run_command, service_name: str) -> dict:
|
||||
code, stdout, stderr = run_command(
|
||||
["systemctl", "show", service_name, "-p", "User", "-p", "Group", "--value"],
|
||||
timeout=15,
|
||||
)
|
||||
lines = [line.strip() for line in (stdout or stderr or "").splitlines()]
|
||||
user = lines[0] if len(lines) >= 1 else ""
|
||||
group = lines[1] if len(lines) >= 2 else ""
|
||||
return {
|
||||
"service_name": service_name,
|
||||
"returncode": int(code or 0),
|
||||
"user": user,
|
||||
"group": group,
|
||||
"ok": int(code or 0) == 0,
|
||||
}
|
||||
|
||||
|
||||
def _pick_release_owner_group(
|
||||
releases_dir: Path,
|
||||
service_identities: list[dict],
|
||||
) -> tuple[str, str]:
|
||||
for item in service_identities:
|
||||
user = str(item.get("user") or "").strip()
|
||||
group = str(item.get("group") or "").strip()
|
||||
if user or group:
|
||||
return user, group
|
||||
owner_user, owner_group = _resolve_path_owner_group(releases_dir)
|
||||
if owner_user or owner_group:
|
||||
return owner_user, owner_group
|
||||
return "", ""
|
||||
|
||||
|
||||
def apply_release_permissions(
|
||||
run_command,
|
||||
*,
|
||||
release_dir: Path,
|
||||
owner_user: str,
|
||||
owner_group: str,
|
||||
) -> dict:
|
||||
normalized_release_dir = Path(release_dir).resolve()
|
||||
normalized_user = str(owner_user or "").strip()
|
||||
normalized_group = str(owner_group or "").strip()
|
||||
if not normalized_user and not normalized_group:
|
||||
return {
|
||||
"attempted": False,
|
||||
"release_dir": str(normalized_release_dir),
|
||||
"owner_user": normalized_user,
|
||||
"owner_group": normalized_group,
|
||||
"returncode": 0,
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"ok": True,
|
||||
}
|
||||
owner_spec = f"{normalized_user}:{normalized_group}" if normalized_group else normalized_user
|
||||
code, stdout, stderr = run_command(
|
||||
["chown", "-R", owner_spec, str(normalized_release_dir)],
|
||||
timeout=180,
|
||||
)
|
||||
return {
|
||||
"attempted": True,
|
||||
"release_dir": str(normalized_release_dir),
|
||||
"owner_user": normalized_user,
|
||||
"owner_group": normalized_group,
|
||||
"owner_spec": owner_spec,
|
||||
"returncode": int(code or 0),
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"ok": int(code or 0) == 0,
|
||||
}
|
||||
|
||||
|
||||
def collect_service_execstart(run_command, service_name: str) -> dict:
|
||||
code, stdout, stderr = run_command(
|
||||
["systemctl", "show", service_name, "-p", "ExecStart", "--value"],
|
||||
timeout=15,
|
||||
)
|
||||
execstart_value = stdout or stderr
|
||||
return {
|
||||
"service_name": service_name,
|
||||
"returncode": int(code or 0),
|
||||
"execstart": execstart_value,
|
||||
"ok": int(code or 0) == 0,
|
||||
}
|
||||
|
||||
|
||||
def _write_text_file(path: Path, content: str) -> None:
|
||||
normalized_path = Path(path).resolve()
|
||||
normalized_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
normalized_path.write_text(str(content or "").rstrip() + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _systemd_dropin_content(service_name: str, install_root: str) -> str:
|
||||
normalized_service_name = str(service_name or "").strip()
|
||||
normalized_install_root = str(install_root or "").rstrip("/")
|
||||
if normalized_service_name == "domaincheck-api":
|
||||
return "\n".join(
|
||||
[
|
||||
"[Service]",
|
||||
f"WorkingDirectory={normalized_install_root}/current/domain-api",
|
||||
"ExecStart=",
|
||||
f"ExecStart={normalized_install_root}/domainCheck/.venv/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 8100",
|
||||
]
|
||||
)
|
||||
if normalized_service_name == "domaincheck-worker":
|
||||
return "\n".join(
|
||||
[
|
||||
"[Service]",
|
||||
f"WorkingDirectory={normalized_install_root}/current/domainCheck",
|
||||
"ExecStart=",
|
||||
f"ExecStart={normalized_install_root}/domainCheck/.venv/bin/python {normalized_install_root}/current/domainCheck/detect_worker.py",
|
||||
]
|
||||
)
|
||||
if normalized_service_name == "domaincheck-sync-agent":
|
||||
return "\n".join(
|
||||
[
|
||||
"[Service]",
|
||||
f"WorkingDirectory={normalized_install_root}/current/domain-api",
|
||||
"ExecStart=",
|
||||
f"ExecStart={normalized_install_root}/domainCheck/.venv/bin/python -m app.sync_agent",
|
||||
]
|
||||
)
|
||||
if normalized_service_name == "domaincheck-node-agent":
|
||||
return "\n".join(
|
||||
[
|
||||
"[Service]",
|
||||
"User=root",
|
||||
"Group=root",
|
||||
f"WorkingDirectory={normalized_install_root}/current/domain-api",
|
||||
"ExecStart=",
|
||||
f"ExecStart={normalized_install_root}/domainCheck/.venv/bin/python -m app.node_agent",
|
||||
]
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def _sync_release_systemd_units(
|
||||
*,
|
||||
release_dir: Path,
|
||||
install_root: Path,
|
||||
systemd_unit_root: Path,
|
||||
switch_current: bool,
|
||||
) -> dict:
|
||||
normalized_release_dir = Path(release_dir).resolve()
|
||||
normalized_systemd_root = Path(systemd_unit_root).resolve()
|
||||
results: list[dict] = []
|
||||
synced_units: list[str] = []
|
||||
|
||||
for service_name, spec in _SYSTEMD_TEMPLATE_SPECS.items():
|
||||
template_path = normalized_release_dir / Path(spec["template"])
|
||||
if not template_path.exists():
|
||||
continue
|
||||
|
||||
target_unit_path = normalized_systemd_root / f"{service_name}.service"
|
||||
target_unit_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(template_path, target_unit_path)
|
||||
os.chmod(target_unit_path, 0o644)
|
||||
|
||||
dropin_path = normalized_systemd_root / f"{service_name}.service.d" / "current-path.conf"
|
||||
dropin_written = False
|
||||
if switch_current:
|
||||
dropin_content = _systemd_dropin_content(service_name, str(install_root))
|
||||
if dropin_content:
|
||||
_write_text_file(dropin_path, dropin_content)
|
||||
dropin_written = True
|
||||
|
||||
results.append(
|
||||
{
|
||||
"service_name": service_name,
|
||||
"template_path": str(template_path),
|
||||
"target_unit_path": str(target_unit_path),
|
||||
"dropin_path": str(dropin_path) if dropin_written else "",
|
||||
"dropin_written": dropin_written,
|
||||
}
|
||||
)
|
||||
synced_units.append(service_name)
|
||||
|
||||
return {
|
||||
"systemd_unit_root": str(normalized_systemd_root),
|
||||
"synced_units": synced_units,
|
||||
"results": results,
|
||||
"daemon_reload_required": bool(synced_units),
|
||||
}
|
||||
|
||||
|
||||
def safe_extract_tar(archive: tarfile.TarFile, target_dir: Path) -> None:
|
||||
target_dir_resolved = target_dir.resolve()
|
||||
members = archive.getmembers()
|
||||
@@ -189,6 +421,7 @@ def execute_release_action(
|
||||
health_check_retries = max(0, int(normalized_payload.get("health_check_retries") or 2))
|
||||
health_check_interval_seconds = max(0, int(normalized_payload.get("health_check_interval_seconds") or 2))
|
||||
rollback_on_failure = coerce_bool(normalized_payload.get("rollback_on_failure", True), default=True)
|
||||
systemd_unit_root = Path(str(normalized_payload.get("systemd_unit_root") or "/etc/systemd/system")).resolve()
|
||||
|
||||
if not release_version:
|
||||
return False, "release_version missing", {}
|
||||
@@ -202,8 +435,57 @@ def execute_release_action(
|
||||
artifact_path = downloads_dir / f"{release_version}.tar.gz"
|
||||
current_link = install_root / "current"
|
||||
previous_current_target = ""
|
||||
downloads_dir.mkdir(parents=True, exist_ok=True)
|
||||
releases_dir.mkdir(parents=True, exist_ok=True)
|
||||
runtime_dirs = [downloads_dir, releases_dir]
|
||||
prepared_dirs: list[dict] = []
|
||||
for directory in runtime_dirs:
|
||||
ok, preparation = ensure_directory_ready(directory)
|
||||
prepared_dirs.append(preparation)
|
||||
if not ok:
|
||||
event_callback(
|
||||
"deploy_preflight_failed",
|
||||
f"发布目录不可写: {preparation.get('path') or directory}",
|
||||
level="error",
|
||||
payload={
|
||||
"release_version": release_version,
|
||||
"install_root": str(install_root),
|
||||
"prepared_dirs": prepared_dirs,
|
||||
},
|
||||
)
|
||||
return False, f"install_root not writable: {preparation.get('path') or directory}", {
|
||||
"release_version": release_version,
|
||||
"install_root": str(install_root),
|
||||
"prepared_dirs": prepared_dirs,
|
||||
}
|
||||
|
||||
service_execstarts = [
|
||||
collect_service_execstart(run_command, service_name)
|
||||
for service_name in restart_services
|
||||
if str(service_name or "").strip()
|
||||
]
|
||||
service_identities = [
|
||||
collect_service_identity(run_command, service_name)
|
||||
for service_name in restart_services
|
||||
if str(service_name or "").strip()
|
||||
]
|
||||
current_link_text = str(current_link)
|
||||
execstart_alignment = {
|
||||
"switch_current": switch_current,
|
||||
"current_link": current_link_text,
|
||||
"services": service_execstarts,
|
||||
"mismatched_services": [
|
||||
item.get("service_name")
|
||||
for item in service_execstarts
|
||||
if str(item.get("execstart") or "").strip()
|
||||
and current_link_text not in str(item.get("execstart") or "")
|
||||
],
|
||||
}
|
||||
if switch_current and execstart_alignment["mismatched_services"]:
|
||||
event_callback(
|
||||
"deploy_execstart_mismatch",
|
||||
"检测到目标服务 ExecStart 未引用 current 软链,发布后可能不会切到新版本",
|
||||
level="warning",
|
||||
payload=execstart_alignment,
|
||||
)
|
||||
|
||||
if current_link.exists():
|
||||
try:
|
||||
@@ -259,6 +541,38 @@ def execute_release_action(
|
||||
temp_dir.rename(target_dir)
|
||||
extracted_target = target_dir
|
||||
|
||||
release_owner_user, release_owner_group = _pick_release_owner_group(releases_dir, service_identities)
|
||||
permission_result = apply_release_permissions(
|
||||
run_command,
|
||||
release_dir=extracted_target,
|
||||
owner_user=release_owner_user,
|
||||
owner_group=release_owner_group,
|
||||
)
|
||||
if not permission_result.get("ok", False):
|
||||
event_callback(
|
||||
"deploy_permission_fix_failed",
|
||||
f"发布目录权限修正失败: {release_version}",
|
||||
level="error",
|
||||
payload=permission_result,
|
||||
)
|
||||
return False, "release permission fix failed", {
|
||||
"release_version": release_version,
|
||||
"release_dir": str(extracted_target),
|
||||
"artifact_path": str(artifact_path),
|
||||
"checksum": calculated_checksum,
|
||||
"previous_current_target": previous_current_target,
|
||||
"prepared_dirs": prepared_dirs,
|
||||
"execstart_alignment": execstart_alignment,
|
||||
"service_identities": service_identities,
|
||||
"permission_result": permission_result,
|
||||
}
|
||||
if permission_result.get("attempted"):
|
||||
event_callback(
|
||||
"deploy_permissions_aligned",
|
||||
f"发布目录权限已对齐: {release_version}",
|
||||
payload=permission_result,
|
||||
)
|
||||
|
||||
meta_path = extracted_target / ".release-meta.json"
|
||||
meta_path.write_text(
|
||||
json.dumps(
|
||||
@@ -274,6 +588,50 @@ def execute_release_action(
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
systemd_sync_result = _sync_release_systemd_units(
|
||||
release_dir=extracted_target,
|
||||
install_root=install_root,
|
||||
systemd_unit_root=systemd_unit_root,
|
||||
switch_current=switch_current,
|
||||
)
|
||||
if systemd_sync_result.get("synced_units"):
|
||||
event_callback(
|
||||
"deploy_systemd_units_synced",
|
||||
f"systemd 单元已同步: {', '.join(systemd_sync_result.get('synced_units') or [])}",
|
||||
payload=systemd_sync_result,
|
||||
)
|
||||
daemon_reload_result = {
|
||||
"returncode": 0,
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
}
|
||||
if systemd_sync_result.get("daemon_reload_required"):
|
||||
code, stdout, stderr = run_command(["systemctl", "daemon-reload"], timeout=45)
|
||||
daemon_reload_result = {
|
||||
"returncode": int(code or 0),
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
}
|
||||
if int(code or 0) != 0:
|
||||
return False, "systemd daemon-reload failed", {
|
||||
"release_version": release_version,
|
||||
"release_dir": str(extracted_target),
|
||||
"artifact_path": str(artifact_path),
|
||||
"checksum": calculated_checksum,
|
||||
"previous_current_target": previous_current_target,
|
||||
"prepared_dirs": prepared_dirs,
|
||||
"execstart_alignment": execstart_alignment,
|
||||
"service_identities": service_identities,
|
||||
"permission_result": permission_result,
|
||||
"systemd_sync": systemd_sync_result,
|
||||
"daemon_reload": daemon_reload_result,
|
||||
}
|
||||
event_callback(
|
||||
"deploy_systemd_reloaded",
|
||||
"systemd daemon-reload 完成",
|
||||
payload=daemon_reload_result,
|
||||
)
|
||||
|
||||
if switch_current:
|
||||
if current_link.is_symlink() or current_link.is_file():
|
||||
current_link.unlink(missing_ok=True)
|
||||
@@ -312,6 +670,12 @@ def execute_release_action(
|
||||
"artifact_path": str(artifact_path),
|
||||
"checksum": calculated_checksum,
|
||||
"previous_current_target": previous_current_target,
|
||||
"prepared_dirs": prepared_dirs,
|
||||
"execstart_alignment": execstart_alignment,
|
||||
"service_identities": service_identities,
|
||||
"permission_result": permission_result,
|
||||
"systemd_sync": systemd_sync_result,
|
||||
"daemon_reload": daemon_reload_result,
|
||||
"restart_results": restarted,
|
||||
}
|
||||
|
||||
@@ -380,6 +744,10 @@ def execute_release_action(
|
||||
"checksum": calculated_checksum,
|
||||
"current_link": str(current_link),
|
||||
"previous_current_target": previous_current_target,
|
||||
"prepared_dirs": prepared_dirs,
|
||||
"execstart_alignment": execstart_alignment,
|
||||
"systemd_sync": systemd_sync_result,
|
||||
"daemon_reload": daemon_reload_result,
|
||||
"restart_results": restarted,
|
||||
"health_check": health_result,
|
||||
"rollback": rollback_result,
|
||||
@@ -397,6 +765,10 @@ def execute_release_action(
|
||||
"checksum": calculated_checksum,
|
||||
"current_link": str(current_link),
|
||||
"previous_current_target": previous_current_target,
|
||||
"prepared_dirs": prepared_dirs,
|
||||
"execstart_alignment": execstart_alignment,
|
||||
"systemd_sync": systemd_sync_result,
|
||||
"daemon_reload": daemon_reload_result,
|
||||
"restart_results": restarted,
|
||||
"health_check": health_result,
|
||||
}
|
||||
@@ -415,6 +787,15 @@ def build_remote_release_action_script(
|
||||
collect_service_state,
|
||||
check_health_url,
|
||||
run_release_health_checks,
|
||||
ensure_directory_ready,
|
||||
_resolve_path_owner_group,
|
||||
collect_service_identity,
|
||||
_pick_release_owner_group,
|
||||
apply_release_permissions,
|
||||
collect_service_execstart,
|
||||
_write_text_file,
|
||||
_systemd_dropin_content,
|
||||
_sync_release_systemd_units,
|
||||
safe_extract_tar,
|
||||
execute_release_action,
|
||||
]
|
||||
@@ -422,6 +803,17 @@ def build_remote_release_action_script(
|
||||
textwrap.dedent(inspect.getsource(func)).strip("\n")
|
||||
for func in helper_functions
|
||||
)
|
||||
systemd_template_specs_source = (
|
||||
"_SYSTEMD_TEMPLATE_SPECS = "
|
||||
+ repr(
|
||||
{
|
||||
service_name: {
|
||||
"template": str(spec["template"]),
|
||||
}
|
||||
for service_name, spec in _SYSTEMD_TEMPLATE_SPECS.items()
|
||||
}
|
||||
)
|
||||
)
|
||||
return f"""from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
@@ -435,12 +827,15 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
{systemd_template_specs_source}
|
||||
|
||||
|
||||
{helper_source}
|
||||
|
||||
|
||||
PAYLOAD = {json.dumps(dict(payload or {{}}), ensure_ascii=False)}
|
||||
DEFAULT_API_SERVICE_NAME = {json.dumps(str(default_api_service_name or 'domaincheck-api'), ensure_ascii=False)}
|
||||
USER_AGENT = {json.dumps(str(user_agent or 'domaincheck-ssh/0.1'), ensure_ascii=False)}
|
||||
PAYLOAD = {repr(dict(payload or {}))}
|
||||
DEFAULT_API_SERVICE_NAME = {repr(str(default_api_service_name or 'domaincheck-api'))}
|
||||
USER_AGENT = {repr(str(user_agent or 'domaincheck-ssh/0.1'))}
|
||||
|
||||
|
||||
def _run(command, timeout=60):
|
||||
|
||||
@@ -3,11 +3,13 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from math import ceil
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
from uuid import uuid4
|
||||
|
||||
from app.core.db import get_db
|
||||
@@ -490,7 +492,119 @@ def _normalize_public_base_url(raw_value: str) -> str:
|
||||
normalized = str(raw_value or "").strip().rstrip("/")
|
||||
if normalized.endswith("/api/v1"):
|
||||
normalized = normalized[: -len("/api/v1")]
|
||||
return normalized
|
||||
return _rewrite_loopback_control_plane_url(normalized)
|
||||
|
||||
|
||||
def _is_loopback_hostname(hostname: str) -> bool:
|
||||
normalized = str(hostname or "").strip().lower().strip("[]")
|
||||
return normalized in {"127.0.0.1", "localhost", "0.0.0.0", "::1"}
|
||||
|
||||
|
||||
def _build_url_with_host(raw_url: str, *, host: str, scheme: str = "", port: int | None = None) -> str:
|
||||
normalized_url = str(raw_url or "").strip()
|
||||
if not normalized_url:
|
||||
return ""
|
||||
parsed = urlsplit(normalized_url)
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
return normalized_url
|
||||
normalized_host = str(host or "").strip().strip("[]")
|
||||
if not normalized_host:
|
||||
return normalized_url
|
||||
final_scheme = str(scheme or parsed.scheme or "http").strip() or "http"
|
||||
final_port = parsed.port if port is None else int(port)
|
||||
netloc = f"{normalized_host}:{final_port}" if final_port else normalized_host
|
||||
return urlunsplit((final_scheme, netloc, parsed.path, parsed.query, parsed.fragment))
|
||||
|
||||
|
||||
def _resolve_public_control_plane_origin(loopback_url: str) -> str:
|
||||
normalized_loopback_url = str(loopback_url or "").strip()
|
||||
if not normalized_loopback_url:
|
||||
return ""
|
||||
parsed_loopback = urlsplit(normalized_loopback_url)
|
||||
default_scheme = str(parsed_loopback.scheme or "http").strip() or "http"
|
||||
default_port = parsed_loopback.port
|
||||
|
||||
env_candidates = [
|
||||
os.getenv("OPS_CONTROL_PLANE_PUBLIC_BASE_URL", ""),
|
||||
os.getenv("CONTROL_PLANE_PUBLIC_BASE_URL", ""),
|
||||
os.getenv("OPS_CONTROL_PLANE_BASE_URL", ""),
|
||||
]
|
||||
for candidate in env_candidates:
|
||||
normalized_candidate = str(candidate or "").strip().rstrip("/")
|
||||
if not normalized_candidate:
|
||||
continue
|
||||
parsed_candidate = urlsplit(
|
||||
normalized_candidate if "://" in normalized_candidate else f"{default_scheme}://{normalized_candidate}"
|
||||
)
|
||||
candidate_host = str(parsed_candidate.hostname or "").strip()
|
||||
if candidate_host and not _is_loopback_hostname(candidate_host):
|
||||
return _build_url_with_host(
|
||||
normalized_loopback_url,
|
||||
host=candidate_host,
|
||||
scheme=str(parsed_candidate.scheme or default_scheme),
|
||||
port=parsed_candidate.port if parsed_candidate.port is not None else default_port,
|
||||
)
|
||||
|
||||
try:
|
||||
from app.services.cluster_runtime_service import get_cluster_snapshot
|
||||
|
||||
snapshot = get_cluster_snapshot()
|
||||
local_hostnames = {
|
||||
str(socket.gethostname() or "").strip().lower(),
|
||||
str(socket.getfqdn() or "").strip().lower(),
|
||||
}
|
||||
fallback_control_hosts: list[str] = []
|
||||
for item in list(snapshot.get("nodes") or []):
|
||||
if str(item.get("role") or "").strip() != "control":
|
||||
continue
|
||||
control_host = str(item.get("hostname") or "").strip().lower()
|
||||
control_ip = str(item.get("ip") or "").strip()
|
||||
if not control_ip or _is_loopback_hostname(control_ip):
|
||||
continue
|
||||
if control_host and control_host in local_hostnames:
|
||||
return _build_url_with_host(
|
||||
normalized_loopback_url,
|
||||
host=control_ip,
|
||||
scheme=default_scheme,
|
||||
port=default_port,
|
||||
)
|
||||
fallback_control_hosts.append(control_ip)
|
||||
for control_ip in fallback_control_hosts:
|
||||
if control_ip and not _is_loopback_hostname(control_ip):
|
||||
return _build_url_with_host(
|
||||
normalized_loopback_url,
|
||||
host=control_ip,
|
||||
scheme=default_scheme,
|
||||
port=default_port,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
resolved_host = str(socket.gethostbyname(socket.gethostname()) or "").strip()
|
||||
if resolved_host and not _is_loopback_hostname(resolved_host):
|
||||
return _build_url_with_host(
|
||||
normalized_loopback_url,
|
||||
host=resolved_host,
|
||||
scheme=default_scheme,
|
||||
port=default_port,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _rewrite_loopback_control_plane_url(raw_url: str) -> str:
|
||||
normalized = str(raw_url or "").strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
parsed = urlsplit(normalized)
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
return normalized
|
||||
if not _is_loopback_hostname(str(parsed.hostname or "").strip()):
|
||||
return normalized
|
||||
resolved = _resolve_public_control_plane_origin(normalized)
|
||||
return resolved or normalized
|
||||
|
||||
|
||||
def _build_absolute_release_package_url(base_url: str, raw_path: str) -> str:
|
||||
@@ -1684,7 +1798,7 @@ def _resolve_rollout_targets(selector: dict) -> list[dict]:
|
||||
continue
|
||||
if only_effective_workers and not bool(item.get("is_effective_worker", False)):
|
||||
continue
|
||||
if only_online and str(item.get("status") or "") != "online":
|
||||
if only_online and str(item.get("status") or "") not in {"online", "busy"}:
|
||||
continue
|
||||
targets.append(item)
|
||||
|
||||
@@ -2037,9 +2151,12 @@ def _build_smart_rollout_role_policy(mode: str, *, execution_mode: str = "remote
|
||||
"restart_services": ["domaincheck-api", "domaincheck-worker", "domaincheck-sync-agent"],
|
||||
"health_check_urls": ["http://127.0.0.1:8100/health"],
|
||||
"health_check_services": ["domaincheck-api", "domaincheck-worker", "domaincheck-sync-agent"],
|
||||
"health_check_timeout_seconds": 10,
|
||||
"health_check_retries": 2,
|
||||
"health_check_interval_seconds": 2,
|
||||
# Control 节点启动期间会先经历较长的 import / startup hook,
|
||||
# systemd 已经 active 但 /health 仍可能在 15-20 秒内拒绝连接。
|
||||
# 这里把健康检查窗口放宽到约 40 秒,避免被误回滚。
|
||||
"health_check_timeout_seconds": 20,
|
||||
"health_check_retries": 9,
|
||||
"health_check_interval_seconds": 4,
|
||||
"rollback_on_failure": True,
|
||||
"switch_current": True,
|
||||
}
|
||||
@@ -3254,18 +3371,56 @@ def refresh_release_rollout_for_job(job_id: int) -> dict:
|
||||
return refresh_release_rollout(rollout_id)
|
||||
|
||||
|
||||
def _build_release_job_payload(release: dict, rollout: dict) -> dict:
|
||||
def _default_release_deploy_payload_for_target(target: dict) -> dict:
|
||||
role = str((target or {}).get("role") or "").strip().lower()
|
||||
if role == "control":
|
||||
return {
|
||||
"restart_services": ["domaincheck-api", "domaincheck-worker", "domaincheck-sync-agent"],
|
||||
"health_check_urls": ["http://127.0.0.1:8100/health"],
|
||||
"health_check_services": ["domaincheck-api", "domaincheck-worker", "domaincheck-sync-agent"],
|
||||
"health_check_timeout_seconds": 20,
|
||||
"health_check_retries": 9,
|
||||
"health_check_interval_seconds": 4,
|
||||
}
|
||||
return {
|
||||
"restart_services": ["domaincheck-worker"],
|
||||
"health_check_urls": [],
|
||||
"health_check_services": ["domaincheck-worker"],
|
||||
"health_check_timeout_seconds": 10,
|
||||
"health_check_retries": 2,
|
||||
"health_check_interval_seconds": 2,
|
||||
}
|
||||
|
||||
|
||||
def _build_release_job_payload(release: dict, rollout: dict, *, target: dict | None = None) -> dict:
|
||||
policy = dict(rollout.get("policy") or {})
|
||||
deploy_payload = dict(policy.get("deploy_payload") or {})
|
||||
target_defaults = _default_release_deploy_payload_for_target(target or {})
|
||||
|
||||
if not [str(item).strip() for item in list(deploy_payload.get("restart_services") or []) if str(item).strip()]:
|
||||
deploy_payload["restart_services"] = list(target_defaults.get("restart_services") or [])
|
||||
if not [str(item).strip() for item in list(deploy_payload.get("health_check_services") or []) if str(item).strip()]:
|
||||
deploy_payload["health_check_services"] = list(target_defaults.get("health_check_services") or [])
|
||||
if not [str(item).strip() for item in list(deploy_payload.get("health_check_urls") or []) if str(item).strip()]:
|
||||
deploy_payload["health_check_urls"] = list(target_defaults.get("health_check_urls") or [])
|
||||
if deploy_payload.get("health_check_timeout_seconds") in (None, "", 0, "0"):
|
||||
deploy_payload["health_check_timeout_seconds"] = int(target_defaults.get("health_check_timeout_seconds") or 10)
|
||||
if deploy_payload.get("health_check_retries") in (None, "", 0, "0"):
|
||||
deploy_payload["health_check_retries"] = int(target_defaults.get("health_check_retries") or 2)
|
||||
if deploy_payload.get("health_check_interval_seconds") in (None, "", 0, "0"):
|
||||
deploy_payload["health_check_interval_seconds"] = int(target_defaults.get("health_check_interval_seconds") or 2)
|
||||
|
||||
artifact_url = _rewrite_loopback_control_plane_url(str(release.get("artifact_url") or "").strip())
|
||||
return {
|
||||
"release_id": int(release.get("id") or 0),
|
||||
"rollout_id": int(rollout.get("id") or 0),
|
||||
"release_version": str(release.get("release_version") or ""),
|
||||
"artifact_url": str(release.get("artifact_url") or ""),
|
||||
"artifact_url": artifact_url,
|
||||
"checksum": str(release.get("checksum") or ""),
|
||||
"channel": str(release.get("channel") or ""),
|
||||
"commit_sha": str(release.get("commit_sha") or ""),
|
||||
"notes": str(release.get("notes") or ""),
|
||||
"target_node_role": str((target or {}).get("role") or "").strip(),
|
||||
**deploy_payload,
|
||||
}
|
||||
|
||||
@@ -3308,12 +3463,11 @@ def _enqueue_rollout_batch(rollout_id: int, *, created_by: str, reason: str = "m
|
||||
auto_dispatch = bool(policy.get("auto_dispatch", False))
|
||||
auto_approve = bool(policy.get("auto_approve", False))
|
||||
execution_mode = str(policy.get("execution_mode") or "remote-agent").strip() or "remote-agent"
|
||||
job_payload = _build_release_job_payload(release, rollout)
|
||||
|
||||
for target in batch_targets:
|
||||
target_node_code = str(target.get("node_code") or "").strip()
|
||||
if not target_node_code:
|
||||
continue
|
||||
job_payload = _build_release_job_payload(release, rollout, target=target)
|
||||
job_ok, _job_message, job_data = create_ops_job(
|
||||
{
|
||||
"action": "deploy.release",
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shlex
|
||||
import subprocess
|
||||
from io import StringIO
|
||||
|
||||
try:
|
||||
import paramiko
|
||||
except ImportError: # pragma: no cover - exercised via graceful fallback tests
|
||||
paramiko = None
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.config import settings
|
||||
from app.services.ops_action_executor_core import (
|
||||
STRUCTURED_ACTIONS,
|
||||
@@ -76,6 +84,7 @@ def execute_ssh_action(node: dict, action: str, payload: dict | None = None) ->
|
||||
}
|
||||
|
||||
normalized_payload = dict(payload or {})
|
||||
node_secret = _load_ssh_secret(node_code)
|
||||
service_names = _service_name_map()
|
||||
if normalized_action == "deploy.release":
|
||||
remote_script = build_remote_release_action_script(
|
||||
@@ -105,28 +114,69 @@ def execute_ssh_action(node: dict, action: str, payload: dict | None = None) ->
|
||||
"PY",
|
||||
]
|
||||
)
|
||||
ssh_command = [
|
||||
"ssh",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"PreferredAuthentications=publickey",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=accept-new",
|
||||
"-o",
|
||||
f"ConnectTimeout={_SSH_CONNECT_TIMEOUT_SECONDS}",
|
||||
"-p",
|
||||
str(ssh_port),
|
||||
f"{ssh_user}@{ssh_host}",
|
||||
remote_command,
|
||||
]
|
||||
timeout_seconds = int(_SSH_REMOTE_TIMEOUT_SECONDS.get(normalized_action, 45) or 45) + _SSH_CONNECT_TIMEOUT_SECONDS
|
||||
completed = subprocess.run(
|
||||
ssh_command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
auth_mode = str(node.get("auth_mode") or "").strip() or ("password" if node_secret.get("ssh_password") else "key")
|
||||
ssh_password = str(node_secret.get("ssh_password") or "").strip()
|
||||
ssh_private_key = str(node_secret.get("ssh_private_key") or "").strip()
|
||||
if auth_mode == "key" and not ssh_private_key and ssh_password:
|
||||
auth_mode = "password"
|
||||
elif auth_mode == "password" and not ssh_password and ssh_private_key:
|
||||
auth_mode = "key"
|
||||
if (auth_mode == "password" and ssh_password) or ssh_private_key:
|
||||
if paramiko is None:
|
||||
return False, "当前环境未安装 paramiko,无法使用密码或私钥 SSH 执行", {
|
||||
"executor": "ssh",
|
||||
"action": normalized_action,
|
||||
"transport": {
|
||||
"executor": "ssh",
|
||||
"node_code": node_code,
|
||||
"ssh_host": ssh_host,
|
||||
"ssh_user": ssh_user,
|
||||
"ssh_port": ssh_port,
|
||||
"auth_mode": auth_mode,
|
||||
"action": normalized_action,
|
||||
},
|
||||
}
|
||||
if auth_mode == "password" and ssh_password:
|
||||
completed = _run_paramiko_command(
|
||||
ssh_host=ssh_host,
|
||||
ssh_port=ssh_port,
|
||||
ssh_user=ssh_user,
|
||||
remote_command=remote_command,
|
||||
timeout_seconds=timeout_seconds,
|
||||
ssh_password=ssh_password,
|
||||
)
|
||||
elif ssh_private_key:
|
||||
completed = _run_paramiko_command(
|
||||
ssh_host=ssh_host,
|
||||
ssh_port=ssh_port,
|
||||
ssh_user=ssh_user,
|
||||
remote_command=remote_command,
|
||||
timeout_seconds=timeout_seconds,
|
||||
ssh_private_key=ssh_private_key,
|
||||
)
|
||||
else:
|
||||
ssh_command = [
|
||||
"ssh",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"PreferredAuthentications=publickey",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=accept-new",
|
||||
"-o",
|
||||
f"ConnectTimeout={_SSH_CONNECT_TIMEOUT_SECONDS}",
|
||||
"-p",
|
||||
str(ssh_port),
|
||||
f"{ssh_user}@{ssh_host}",
|
||||
remote_command,
|
||||
]
|
||||
completed = subprocess.run(
|
||||
ssh_command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
|
||||
transport = {
|
||||
"executor": "ssh",
|
||||
@@ -134,6 +184,7 @@ def execute_ssh_action(node: dict, action: str, payload: dict | None = None) ->
|
||||
"ssh_host": ssh_host,
|
||||
"ssh_user": ssh_user,
|
||||
"ssh_port": ssh_port,
|
||||
"auth_mode": auth_mode,
|
||||
"action": normalized_action,
|
||||
"returncode": int(completed.returncode or 0),
|
||||
}
|
||||
@@ -166,6 +217,93 @@ def execute_ssh_action(node: dict, action: str, payload: dict | None = None) ->
|
||||
}
|
||||
|
||||
|
||||
def _load_ssh_secret(node_code: str) -> dict:
|
||||
normalized_node_code = str(node_code or "").strip()
|
||||
if not normalized_node_code:
|
||||
return {}
|
||||
try:
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT ssh_password, ssh_private_key
|
||||
FROM ops_managed_node_secrets
|
||||
WHERE node_code = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(normalized_node_code,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
except Exception:
|
||||
return {}
|
||||
if not row:
|
||||
return {}
|
||||
return {
|
||||
"ssh_password": str(row[0] or ""),
|
||||
"ssh_private_key": str(row[1] or ""),
|
||||
}
|
||||
|
||||
|
||||
def _load_private_key(private_key_text: str) -> paramiko.PKey:
|
||||
if paramiko is None:
|
||||
raise RuntimeError("paramiko is not installed")
|
||||
key_text = str(private_key_text or "")
|
||||
for key_cls in (paramiko.Ed25519Key, paramiko.RSAKey, paramiko.ECDSAKey, paramiko.DSSKey):
|
||||
try:
|
||||
return key_cls.from_private_key(StringIO(key_text))
|
||||
except Exception:
|
||||
continue
|
||||
raise ValueError("无法识别 SSH 私钥格式")
|
||||
|
||||
|
||||
def _run_paramiko_command(
|
||||
*,
|
||||
ssh_host: str,
|
||||
ssh_port: int,
|
||||
ssh_user: str,
|
||||
remote_command: str,
|
||||
timeout_seconds: int,
|
||||
ssh_password: str = "",
|
||||
ssh_private_key: str = "",
|
||||
) -> subprocess.CompletedProcess:
|
||||
if paramiko is None:
|
||||
raise RuntimeError("paramiko is not installed")
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
connect_kwargs = {
|
||||
"hostname": ssh_host,
|
||||
"port": int(ssh_port),
|
||||
"username": ssh_user,
|
||||
"timeout": _SSH_CONNECT_TIMEOUT_SECONDS,
|
||||
"banner_timeout": _SSH_CONNECT_TIMEOUT_SECONDS,
|
||||
"auth_timeout": _SSH_CONNECT_TIMEOUT_SECONDS,
|
||||
"look_for_keys": False,
|
||||
"allow_agent": False,
|
||||
}
|
||||
if ssh_password:
|
||||
connect_kwargs["password"] = ssh_password
|
||||
elif ssh_private_key:
|
||||
connect_kwargs["pkey"] = _load_private_key(ssh_private_key)
|
||||
else:
|
||||
connect_kwargs["look_for_keys"] = True
|
||||
connect_kwargs["allow_agent"] = True
|
||||
try:
|
||||
client.connect(**connect_kwargs)
|
||||
wrapped_command = f"bash -lc {shlex.quote(remote_command)}"
|
||||
_, stdout, stderr = client.exec_command(wrapped_command, timeout=timeout_seconds)
|
||||
returncode = int(stdout.channel.recv_exit_status())
|
||||
stdout_text = stdout.read().decode("utf-8", errors="replace")
|
||||
stderr_text = stderr.read().decode("utf-8", errors="replace")
|
||||
return subprocess.CompletedProcess(
|
||||
args=["paramiko", f"{ssh_user}@{ssh_host}"],
|
||||
returncode=returncode,
|
||||
stdout=stdout_text,
|
||||
stderr=stderr_text,
|
||||
)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def _service_name_map() -> dict[str, str]:
|
||||
return build_service_name_map(
|
||||
api_service_name=settings.api_service_name,
|
||||
@@ -199,6 +337,25 @@ def trim_output(text, limit):
|
||||
|
||||
def run(cmd, timeout=60):
|
||||
completed = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||
normalized_cmd = [str(part or "").strip() for part in cmd]
|
||||
combined_output = f"{{completed.stdout or ''}}\\n{{completed.stderr or ''}}".lower()
|
||||
needs_sudo_retry = (
|
||||
normalized_cmd
|
||||
and normalized_cmd[0] == "systemctl"
|
||||
and completed.returncode != 0
|
||||
and "sudo" not in normalized_cmd
|
||||
and any(
|
||||
marker in combined_output
|
||||
for marker in (
|
||||
"interactive authentication required",
|
||||
"authentication is required",
|
||||
"authorization not available",
|
||||
"polkit",
|
||||
)
|
||||
)
|
||||
)
|
||||
if needs_sudo_retry:
|
||||
completed = subprocess.run(["sudo", "-n", *normalized_cmd], capture_output=True, text=True, timeout=timeout)
|
||||
return int(completed.returncode or 0), str(completed.stdout or "").strip(), str(completed.stderr or "").strip()
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.debug_event_service import push_debug_event
|
||||
from app.services.detect_job_service import process_detect_pipeline_now
|
||||
from app.services.sync_push_service import pull_detect_task_batch_now, push_runtime_projection_now
|
||||
from app.services.runtime_settings_service import get_runtime_settings
|
||||
from app.services.worker_control_service import _run_systemctl, normalize_systemctl_error, send_worker_command, start_worker, stop_worker
|
||||
@@ -180,6 +181,16 @@ def runtime_action(action: str, payload: dict | None = None) -> tuple[bool, str,
|
||||
result = _build_runtime_action_result(action=normalized_action, poll_after_seconds=2, refresh_runtime=True, ok=ok, message=message, data=data)
|
||||
_emit_runtime_action_event(normalized_action, stage="finished", ok=ok, message=message, data=result)
|
||||
return ok, message, result
|
||||
if normalized_action == "process_pipeline":
|
||||
process_limit = normalized_payload.get("limit")
|
||||
process_job_id = normalized_payload.get("job_id")
|
||||
ok, message, data = process_detect_pipeline_now(
|
||||
limit=int(process_limit or 0) or None,
|
||||
job_id=int(process_job_id or 0) or None,
|
||||
)
|
||||
result = _build_runtime_action_result(action=normalized_action, poll_after_seconds=1, refresh_runtime=True, ok=ok, message=message, data=data)
|
||||
_emit_runtime_action_event(normalized_action, stage="finished", ok=ok, message=message, data=result)
|
||||
return ok, message, result
|
||||
if normalized_action == "start_detection":
|
||||
command_ok, command_message = send_worker_command(
|
||||
"start_detection",
|
||||
|
||||
@@ -10,12 +10,151 @@ from app.core.redis_client import get_redis
|
||||
from app.services.build_info_service import get_runtime_build_info
|
||||
from app.services.cluster_runtime_service import get_cluster_snapshot
|
||||
from app.services.detect_service import get_detect_status
|
||||
from app.services.detect_job_service import get_detect_capacity_plan, get_detect_queue_health
|
||||
from app.services.detect_job_service import (
|
||||
_load_latest_runtime_active_job_snapshot,
|
||||
get_detect_capacity_plan,
|
||||
get_detect_queue_health,
|
||||
)
|
||||
from app.services.sync_record_service import append_runtime_projection_if_changed, get_sync_summary
|
||||
from app.services.runtime_settings_service import get_runtime_settings
|
||||
from app.services.worker_control_service import detect_sync_agent_runtime, detect_worker_runtime
|
||||
|
||||
|
||||
def _align_queue_health_with_backlog(queue_health: dict | None, backlog_snapshot: dict | None) -> dict:
|
||||
normalized = dict(queue_health or {})
|
||||
queue = dict(normalized.get("queue") or {})
|
||||
backlog = dict(backlog_snapshot or {})
|
||||
|
||||
pending_total = max(int(queue.get("pending", 0) or 0), int(backlog.get("pending_total", 0) or 0))
|
||||
claimed_total = max(int(queue.get("claimed", 0) or 0), int(backlog.get("claimed_total", 0) or 0))
|
||||
running_total = max(int(queue.get("running", 0) or 0), int(backlog.get("running_total", 0) or 0))
|
||||
completed_total = max(int(queue.get("completed", 0) or 0), int(backlog.get("completed_total", 0) or 0))
|
||||
blacklisted_total = max(int(queue.get("blacklisted", 0) or 0), int(backlog.get("blacklisted_total", 0) or 0))
|
||||
failed_total = max(int(queue.get("failed", 0) or 0), int(backlog.get("failed_total", 0) or 0))
|
||||
terminal_total = max(
|
||||
int(queue.get("terminal", 0) or 0),
|
||||
completed_total + blacklisted_total + failed_total,
|
||||
)
|
||||
|
||||
normalized["has_active_job"] = bool(
|
||||
normalized.get("has_active_job")
|
||||
or pending_total > 0
|
||||
or claimed_total > 0
|
||||
or running_total > 0
|
||||
or terminal_total > 0
|
||||
)
|
||||
normalized["queue"] = {
|
||||
**queue,
|
||||
"items_total": pending_total + claimed_total + running_total + terminal_total,
|
||||
"pending": pending_total,
|
||||
"claimed": claimed_total,
|
||||
"running": running_total,
|
||||
"completed": completed_total,
|
||||
"blacklisted": blacklisted_total,
|
||||
"failed": failed_total,
|
||||
"terminal": terminal_total,
|
||||
}
|
||||
return normalized
|
||||
|
||||
|
||||
def _decode_projection_payload(value: object) -> dict:
|
||||
if isinstance(value, dict):
|
||||
return dict(value)
|
||||
if value in (None, ""):
|
||||
return {}
|
||||
try:
|
||||
import json
|
||||
|
||||
return dict(json.loads(value))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _load_detect_backlog_snapshot() -> dict:
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE item.status = 'pending') AS pending_total,
|
||||
COUNT(*) FILTER (WHERE item.status = 'claimed') AS claimed_total,
|
||||
COUNT(*) FILTER (WHERE item.status = 'running') AS running_total,
|
||||
COUNT(*) FILTER (WHERE item.status = 'completed') AS completed_total,
|
||||
COUNT(*) FILTER (WHERE item.status = 'blacklisted') AS blacklisted_total,
|
||||
COUNT(*) FILTER (WHERE item.status = 'failed') AS failed_total,
|
||||
COUNT(*) FILTER (WHERE item.status = 'pending' AND item.step_code = 'detect_register') AS register_pending,
|
||||
COUNT(*) FILTER (WHERE item.status = 'pending' AND item.step_code <> 'detect_register') AS downstream_pending
|
||||
FROM detect_job_items item
|
||||
JOIN detect_jobs job ON job.id = item.job_id
|
||||
WHERE job.status IN ('pending', 'running')
|
||||
"""
|
||||
)
|
||||
row = cur.fetchone() or (0, 0, 0, 0, 0, 0, 0, 0)
|
||||
return {
|
||||
"pending_total": int(row[0] or 0),
|
||||
"claimed_total": int(row[1] or 0),
|
||||
"running_total": int(row[2] or 0),
|
||||
"completed_total": int(row[3] or 0),
|
||||
"blacklisted_total": int(row[4] or 0),
|
||||
"failed_total": int(row[5] or 0),
|
||||
"register_pending": int(row[6] or 0),
|
||||
"downstream_pending": int(row[7] or 0),
|
||||
}
|
||||
|
||||
|
||||
def _load_latest_remote_runtime_projection_backlog() -> dict:
|
||||
if not (settings.node_region == "overseas" and settings.node_role == "control"):
|
||||
return {}
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT payload_json
|
||||
FROM detect_sync_records
|
||||
WHERE sync_type = 'runtime_projection'
|
||||
AND source_region = 'mainland'
|
||||
AND target_region = 'overseas'
|
||||
AND status IN ('projected', 'pushing', 'synced')
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return {}
|
||||
payload = _decode_projection_payload(row[0])
|
||||
projection = payload.get("projection") if isinstance(payload, dict) else {}
|
||||
backlog = projection.get("backlog") if isinstance(projection, dict) else {}
|
||||
if not isinstance(backlog, dict):
|
||||
return {}
|
||||
return {
|
||||
"pending_total": int(backlog.get("pending_total", 0) or 0),
|
||||
"claimed_total": int(backlog.get("claimed_total", 0) or 0),
|
||||
"running_total": int(backlog.get("running_total", 0) or 0),
|
||||
"completed_total": int(backlog.get("completed_total", 0) or 0),
|
||||
"blacklisted_total": int(backlog.get("blacklisted_total", 0) or 0),
|
||||
"failed_total": int(backlog.get("failed_total", 0) or 0),
|
||||
"register_pending": int(backlog.get("register_pending", 0) or 0),
|
||||
"downstream_pending": int(backlog.get("downstream_pending", 0) or 0),
|
||||
}
|
||||
|
||||
|
||||
def _merge_backlog_snapshots(primary: dict, secondary: dict) -> dict:
|
||||
merged = dict(primary or {})
|
||||
for key in (
|
||||
"pending_total",
|
||||
"claimed_total",
|
||||
"running_total",
|
||||
"completed_total",
|
||||
"blacklisted_total",
|
||||
"failed_total",
|
||||
"register_pending",
|
||||
"downstream_pending",
|
||||
):
|
||||
merged[key] = max(int(merged.get(key, 0) or 0), int((secondary or {}).get(key, 0) or 0))
|
||||
return merged
|
||||
|
||||
|
||||
def _runtime_log_path(filename: str) -> str:
|
||||
path = Path(__file__).resolve().parents[2] / "runtime" / "logs" / filename
|
||||
return str(path)
|
||||
@@ -191,6 +330,8 @@ def _build_multi_region_readiness(
|
||||
def _detect_participation_snapshot(*, row: dict) -> dict:
|
||||
items_running = int(row.get("items_running", 0) or 0)
|
||||
items_claimed = int(row.get("items_claimed", 0) or 0)
|
||||
active_threads = int(row.get("active_threads", 0) or 0)
|
||||
max_threads = int(row.get("max_threads", 0) or 0)
|
||||
processed_recent = int(row.get("processed_recent", 0) or 0)
|
||||
current_load = int(row.get("current_load", 0) or 0)
|
||||
status = str(row.get("status") or "").strip().lower()
|
||||
@@ -211,6 +352,17 @@ def _detect_participation_snapshot(*, row: dict) -> dict:
|
||||
"is_current_participant": True,
|
||||
"is_dispatch_active": True,
|
||||
}
|
||||
if active_threads > 0:
|
||||
detail = f"当前活跃线程 {active_threads}"
|
||||
if max_threads > 0:
|
||||
detail = f"{detail}/{max_threads}"
|
||||
return {
|
||||
"participation_state": "runtime_active",
|
||||
"participation_label": "执行中",
|
||||
"participation_reason": detail,
|
||||
"is_current_participant": True,
|
||||
"is_dispatch_active": True,
|
||||
}
|
||||
if processed_recent > 0:
|
||||
return {
|
||||
"participation_state": "recent_throughput",
|
||||
@@ -242,11 +394,52 @@ def _build_detect_node_row(*, node_code: str, cluster_node: dict, job_node: dict
|
||||
role = str(cluster_node.get("role") or job_node.get("role") or "worker")
|
||||
region = str(cluster_node.get("region") or settings.node_region)
|
||||
is_effective_worker = bool(cluster_node.get("is_effective_worker", False) or role == "worker")
|
||||
items_total = int(job_node.get("items_total", metadata.get("job_items_total", 0)) or 0)
|
||||
items_claimed = int(job_node.get("items_claimed", metadata.get("job_items_claimed", 0)) or 0)
|
||||
items_running = int(job_node.get("items_running", metadata.get("job_items_running", 0)) or 0)
|
||||
items_completed = int(job_node.get("items_completed", metadata.get("job_items_completed", 0)) or 0)
|
||||
items_failed = int(job_node.get("items_failed", metadata.get("job_items_failed", 0)) or 0)
|
||||
items_total = max(
|
||||
int(job_node.get("items_total", 0) or 0),
|
||||
int(queue_node.get("items_total", 0) or 0),
|
||||
int(metadata.get("job_items_total", 0) or 0),
|
||||
)
|
||||
items_claimed = max(
|
||||
int(job_node.get("items_claimed", 0) or 0),
|
||||
int(queue_node.get("items_claimed", 0) or 0),
|
||||
int(metadata.get("job_items_claimed", 0) or 0),
|
||||
)
|
||||
items_running = max(
|
||||
int(job_node.get("items_running", 0) or 0),
|
||||
int(queue_node.get("items_running", 0) or 0),
|
||||
int(metadata.get("job_items_running", 0) or 0),
|
||||
)
|
||||
items_completed = max(
|
||||
int(job_node.get("items_completed", 0) or 0),
|
||||
int(queue_node.get("items_completed", 0) or 0),
|
||||
int(metadata.get("job_items_completed", 0) or 0),
|
||||
)
|
||||
items_failed = max(
|
||||
int(job_node.get("items_failed", 0) or 0),
|
||||
int(queue_node.get("items_failed", 0) or 0),
|
||||
int(metadata.get("job_items_failed", 0) or 0),
|
||||
)
|
||||
items_blacklisted = max(
|
||||
int(job_node.get("items_blacklisted", 0) or 0),
|
||||
int(queue_node.get("items_blacklisted", 0) or 0),
|
||||
)
|
||||
active_threads = max(
|
||||
int((cluster_node.get("metadata") or {}).get("active_threads", 0) or 0),
|
||||
int(job_node.get("active_threads", 0) or 0),
|
||||
int(queue_node.get("active_threads", 0) or 0),
|
||||
)
|
||||
max_threads = max(
|
||||
int((cluster_node.get("metadata") or {}).get("max_threads", 0) or 0),
|
||||
int(job_node.get("max_threads", 0) or 0),
|
||||
int(queue_node.get("max_threads", 0) or 0),
|
||||
)
|
||||
current_load = max(items_running, active_threads, 0)
|
||||
derived_pending = max(items_total - items_claimed - items_running - items_completed - items_failed - items_blacklisted, 0)
|
||||
items_pending = max(
|
||||
int(job_node.get("items_pending", 0) or 0),
|
||||
int(queue_node.get("items_pending", 0) or 0),
|
||||
derived_pending,
|
||||
)
|
||||
row = {
|
||||
"node_code": node_code,
|
||||
"role": role,
|
||||
@@ -254,13 +447,16 @@ def _build_detect_node_row(*, node_code: str, cluster_node: dict, job_node: dict
|
||||
"status": str(cluster_node.get("status") or "unknown"),
|
||||
"is_effective_worker": is_effective_worker,
|
||||
"detect_participating": False,
|
||||
"current_load": int(cluster_node.get("current_load", 0) or 0),
|
||||
"current_load": current_load,
|
||||
"items_total": items_total,
|
||||
"items_pending": int(job_node.get("items_pending", max(items_total - items_claimed - items_completed - items_failed, 0)) or 0),
|
||||
"items_pending": items_pending,
|
||||
"items_claimed": items_claimed,
|
||||
"items_running": items_running,
|
||||
"items_completed": items_completed,
|
||||
"items_blacklisted": items_blacklisted,
|
||||
"items_failed": items_failed,
|
||||
"active_threads": active_threads,
|
||||
"max_threads": max_threads,
|
||||
"processed_recent": int(queue_node.get("processed_recent", 0) or 0),
|
||||
"processed_per_minute": float(queue_node.get("processed_per_minute", 0) or 0),
|
||||
"last_heartbeat_at": str(cluster_node.get("last_heartbeat_at") or ""),
|
||||
@@ -465,8 +661,13 @@ def get_runtime_status() -> dict:
|
||||
effective_online_worker_nodes = int((cluster_snapshot.get("summary") or {}).get("online_worker_nodes", 0) or 0)
|
||||
if effective_online_worker_nodes <= 0 and worker_runtime.get("running", False):
|
||||
effective_online_worker_nodes = max(1, worker_runtime.get("process_count", 1) or 1)
|
||||
backlog_snapshot = _load_detect_backlog_snapshot()
|
||||
remote_backlog_snapshot = _load_latest_remote_runtime_projection_backlog()
|
||||
runtime_snapshot_backlog = dict(_load_latest_runtime_active_job_snapshot(15).get("backlog") or {})
|
||||
backlog_snapshot = _merge_backlog_snapshots(backlog_snapshot, remote_backlog_snapshot)
|
||||
backlog_snapshot = _merge_backlog_snapshots(backlog_snapshot, runtime_snapshot_backlog)
|
||||
capacity_plan = get_detect_capacity_plan(
|
||||
queue_health=queue_health,
|
||||
queue_health=_align_queue_health_with_backlog(queue_health, backlog_snapshot),
|
||||
online_worker_nodes=effective_online_worker_nodes,
|
||||
target_finish_hours=6,
|
||||
)
|
||||
@@ -498,6 +699,7 @@ def get_runtime_status() -> dict:
|
||||
"worker_online": worker_runtime.get("running", False),
|
||||
"worker_mode": worker_runtime.get("mode", runtime_settings.get("worker_mode", "windows-local")),
|
||||
"queue_health": queue_health,
|
||||
"backlog": backlog_snapshot,
|
||||
"capacity_plan": capacity_plan,
|
||||
"log_sync": {
|
||||
"enabled": bool(runtime_settings.get("worker_log_sync_enabled", False)),
|
||||
@@ -565,8 +767,7 @@ def get_runtime_status() -> dict:
|
||||
)
|
||||
build_info = get_runtime_build_info()
|
||||
|
||||
return {
|
||||
"api": {
|
||||
api_payload = {
|
||||
"service": "domain-api",
|
||||
"version": "0.1.0",
|
||||
"api_prefix": settings.api_prefix,
|
||||
@@ -579,13 +780,13 @@ def get_runtime_status() -> dict:
|
||||
"stdout_log": _runtime_log_path("domain-api.stdout.log"),
|
||||
"stderr_log": _runtime_log_path("domain-api.stderr.log"),
|
||||
"build": build_info,
|
||||
},
|
||||
"node": {
|
||||
}
|
||||
node_payload = {
|
||||
"code": settings.node_code,
|
||||
"region": settings.node_region,
|
||||
"role": settings.node_role,
|
||||
},
|
||||
"worker": {
|
||||
}
|
||||
worker_payload = {
|
||||
"mode": worker_runtime.get("mode", runtime_settings.get("worker_mode", "windows-local")),
|
||||
"service_name": runtime_settings.get("worker_service_name", settings.worker_service_name),
|
||||
"running": worker_runtime.get("running", False),
|
||||
@@ -594,8 +795,8 @@ def get_runtime_status() -> dict:
|
||||
"latest_start_time": worker_runtime.get("latest_start_time", ""),
|
||||
"message": worker_runtime.get("message", ""),
|
||||
"log_path": str(Path(settings.domain_root) / "detect_worker.log"),
|
||||
},
|
||||
"sync_agent": {
|
||||
}
|
||||
sync_agent_payload = {
|
||||
"mode": sync_agent_runtime.get("mode", runtime_settings.get("worker_mode", "windows-local")),
|
||||
"service_name": runtime_settings.get("sync_agent_service_name", settings.sync_agent_service_name),
|
||||
"running": sync_agent_runtime.get("running", False),
|
||||
@@ -603,7 +804,44 @@ def get_runtime_status() -> dict:
|
||||
"latest_start_time": sync_agent_runtime.get("latest_start_time", ""),
|
||||
"message": sync_agent_runtime.get("message", ""),
|
||||
"expected_on_this_node": settings.node_region == "mainland" and settings.node_role == "control",
|
||||
},
|
||||
}
|
||||
compatibility_payload = {
|
||||
# Backward-compatible flat fields for older pages / stale built assets.
|
||||
"api_online": bool(api_payload.get("pid")),
|
||||
"api_service_name": api_payload.get("service_name", ""),
|
||||
"worker_online": bool(worker_payload.get("running", False)),
|
||||
"worker_mode": worker_payload.get("mode", ""),
|
||||
"worker_service_name": worker_payload.get("service_name", ""),
|
||||
"worker_process_count": worker_payload.get("process_count", 0),
|
||||
"worker_latest_start_time": worker_payload.get("latest_start_time", ""),
|
||||
"worker_runtime_message": worker_payload.get("message", ""),
|
||||
"thread_count": detect_snapshot.get("thread_count", 0),
|
||||
"thread_count_default": detect_snapshot.get("thread_count_default", 0),
|
||||
"thread_count_source": detect_snapshot.get("thread_count_source", ""),
|
||||
"thread_count_override": detect_snapshot.get("thread_count_override"),
|
||||
"active_thread_count": detect_payload.get("active_thread_count", 0),
|
||||
"max_thread_count": detect_payload.get("max_thread_count", 0),
|
||||
"progress": detect_payload.get("progress", {}),
|
||||
"backlog": detect_payload.get("backlog", {}),
|
||||
"progress_percent": detect_payload.get("progress_percent", 0),
|
||||
"available_proxy_count": detect_payload.get("available_proxy_count", 0),
|
||||
"proxy_pool_count": detect_payload.get("proxy_pool_count", 0),
|
||||
"proxy_runtime_label": detect_payload.get("proxy_runtime_label", ""),
|
||||
"proxy_runtime_detail": detect_payload.get("proxy_runtime_detail", ""),
|
||||
"proxy_runtime_reason": detect_payload.get("proxy_runtime_reason", ""),
|
||||
"proxy_last_refresh_time": detect_payload.get("proxy_last_refresh_time", ""),
|
||||
"recent_event": detect_payload.get("recent_event", ""),
|
||||
"recent_warning": detect_payload.get("recent_warning", ""),
|
||||
"runtime_state": worker_runtime.get("runtime_state") or {},
|
||||
"cluster_summary": cluster_snapshot.get("summary") or {},
|
||||
}
|
||||
|
||||
return {
|
||||
**compatibility_payload,
|
||||
"api": api_payload,
|
||||
"node": node_payload,
|
||||
"worker": worker_payload,
|
||||
"sync_agent": sync_agent_payload,
|
||||
"detect": detect_payload,
|
||||
"cluster": cluster_snapshot,
|
||||
"sync": sync_summary,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.redis_client import get_redis
|
||||
|
||||
|
||||
def get_sensitive_words_payload() -> dict:
|
||||
@@ -54,6 +57,13 @@ def save_sensitive_words_payload(payload: dict) -> dict:
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
redis_client = get_redis()
|
||||
redis_client.set("domain_tool:sensitive_words", json.dumps(words, ensure_ascii=False))
|
||||
redis_client.publish("domain_tool:config_update", "sensitive_words")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"total": len(words),
|
||||
"text": "\n".join(words),
|
||||
|
||||
@@ -35,8 +35,8 @@ def _normalize_thread_count(value: object, *, field_name: str = "thread_count")
|
||||
thread_count = int(value)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"{field_name} must be an integer") from exc
|
||||
if thread_count < 1 or thread_count > 256:
|
||||
raise ValueError(f"{field_name} out of range")
|
||||
if thread_count < 1:
|
||||
raise ValueError(f"{field_name} must be >= 1")
|
||||
return thread_count
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,16 @@ from uuid import uuid4
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
from app.services.cluster_runtime_service import cleanup_imported_runtime_nodes, register_node_heartbeat
|
||||
from app.services.cluster_runtime_service import (
|
||||
cleanup_imported_runtime_nodes,
|
||||
cleanup_imported_runtime_nodes_many,
|
||||
register_node_heartbeat,
|
||||
)
|
||||
from app.services.detect_job_service import (
|
||||
_load_domain_pipeline_snapshot,
|
||||
resolve_initial_domain_pipeline_item,
|
||||
)
|
||||
from app.services.settings_service import get_settings_payload
|
||||
from app.services.sync_record_service import _decode_json, _normalize_region
|
||||
|
||||
|
||||
@@ -80,10 +89,16 @@ def _refresh_remote_runtime_node(*, source_region: str, projection: dict, receiv
|
||||
if not node_code:
|
||||
node_code = f"{region}-{role}-imported"
|
||||
|
||||
controller_current_load = max(
|
||||
int(projection.get("active_thread_count", 0) or 0),
|
||||
int(((projection.get("active_job") or {}).get("items_running", 0) or 0)),
|
||||
)
|
||||
metadata = {
|
||||
"service": "runtime-ingest",
|
||||
"projection_source_region": source_region,
|
||||
"worker_mode": projection.get("worker_mode", ""),
|
||||
"active_threads": int(projection.get("active_thread_count", 0) or 0),
|
||||
"max_threads": int(projection.get("max_thread_count", 0) or 0),
|
||||
"phase_label": projection.get("phase_label", ""),
|
||||
"phase_detail": projection.get("phase_detail", ""),
|
||||
"proxy_runtime_label": projection.get("proxy_runtime_label", ""),
|
||||
@@ -102,8 +117,8 @@ def _refresh_remote_runtime_node(*, source_region: str, projection: dict, receiv
|
||||
node_code=node_code,
|
||||
region=region,
|
||||
role=role,
|
||||
status="online",
|
||||
current_load=int(((projection.get("progress") or {}).get("running", 0) or 0)),
|
||||
status="busy" if controller_current_load > 0 else "online",
|
||||
current_load=controller_current_load,
|
||||
metadata=metadata,
|
||||
hostname_override=hostname,
|
||||
ip_override=ip,
|
||||
@@ -111,15 +126,21 @@ def _refresh_remote_runtime_node(*, source_region: str, projection: dict, receiv
|
||||
cleanup_imported_runtime_nodes(region=region, role=role, keep_node_code=node_code)
|
||||
|
||||
active_job = projection.get("active_job") or {}
|
||||
worker_node_codes: list[str] = []
|
||||
for node_stat in list(active_job.get("node_stats") or []):
|
||||
worker_node_code = str(node_stat.get("node_code") or "").strip()
|
||||
if not worker_node_code or worker_node_code == "unassigned":
|
||||
continue
|
||||
if worker_node_code == node_code:
|
||||
continue
|
||||
items_running = int(node_stat.get("items_running", 0) or 0)
|
||||
items_claimed = int(node_stat.get("items_claimed", 0) or 0)
|
||||
items_total = int(node_stat.get("items_total", 0) or 0)
|
||||
worker_status = "busy" if (items_running > 0 or items_claimed > 0) else "online"
|
||||
worker_load = max(items_running, items_claimed, 0)
|
||||
worker_runtime_load = int(node_stat.get("current_load", 0) or 0)
|
||||
worker_active_threads = int(node_stat.get("active_threads", worker_runtime_load) or 0)
|
||||
worker_max_threads = int(node_stat.get("max_threads", 0) or 0)
|
||||
worker_load = max(worker_active_threads, items_running, 0)
|
||||
worker_status = "busy" if worker_load > 0 else "online"
|
||||
worker_metadata = {
|
||||
"service": "runtime-ingest",
|
||||
"projection_source_region": source_region,
|
||||
@@ -128,10 +149,18 @@ def _refresh_remote_runtime_node(*, source_region: str, projection: dict, receiv
|
||||
"phase_detail": projection.get("phase_detail", ""),
|
||||
"proxy_runtime_label": projection.get("proxy_runtime_label", ""),
|
||||
"proxy_runtime_reason": projection.get("proxy_runtime_reason", ""),
|
||||
"active_threads": worker_active_threads,
|
||||
"max_threads": worker_max_threads,
|
||||
"updated_at": _format_time(received_at or datetime.now()),
|
||||
"job_items_total": items_total,
|
||||
"job_items_running": items_running,
|
||||
"job_items_claimed": items_claimed,
|
||||
"job_items_completed": int(node_stat.get("items_completed", 0) or 0),
|
||||
"job_items_failed": int(node_stat.get("items_failed", 0) or 0),
|
||||
"job_items_blacklisted": int(node_stat.get("items_blacklisted", 0) or 0),
|
||||
"metrics_source": str(node_stat.get("metrics_source") or "runtime").strip() or "runtime",
|
||||
"source_status": str(node_stat.get("status") or "").strip(),
|
||||
"source_role": str(node_stat.get("role") or "worker").strip() or "worker",
|
||||
"derived_from": node_code,
|
||||
}
|
||||
register_node_heartbeat(
|
||||
@@ -144,7 +173,9 @@ def _refresh_remote_runtime_node(*, source_region: str, projection: dict, receiv
|
||||
hostname_override=hostname,
|
||||
ip_override=ip,
|
||||
)
|
||||
cleanup_imported_runtime_nodes(region=region, role="worker", keep_node_code=worker_node_code)
|
||||
worker_node_codes.append(worker_node_code)
|
||||
if worker_node_codes:
|
||||
cleanup_imported_runtime_nodes_many(region=region, role="worker", keep_node_codes=worker_node_codes)
|
||||
|
||||
|
||||
def _load_latest_projection(sync_type: str) -> dict | None:
|
||||
@@ -226,6 +257,88 @@ def _load_pushable_projections(sync_type: str, limit: int) -> list[dict]:
|
||||
return selected
|
||||
|
||||
|
||||
def _estimate_total_worker_threads(settings_payload: dict | None = None) -> int:
|
||||
payload = settings_payload if isinstance(settings_payload, dict) else get_settings_payload()
|
||||
default_threads = max(1, int(payload.get("thread_count", 100) or 100))
|
||||
node_thread_counts = payload.get("node_thread_counts") if isinstance(payload.get("node_thread_counts"), dict) else {}
|
||||
total_threads = 0
|
||||
for raw_value in node_thread_counts.values():
|
||||
try:
|
||||
total_threads += max(0, int(raw_value or 0))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return max(total_threads, default_threads)
|
||||
|
||||
|
||||
def _load_local_detect_backlog_snapshot() -> dict:
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE item.status = 'pending') AS pending_total,
|
||||
COUNT(*) FILTER (WHERE item.status = 'claimed') AS claimed_total,
|
||||
COUNT(*) FILTER (WHERE item.status = 'running') AS running_total,
|
||||
COUNT(*) FILTER (WHERE item.status = 'pending' AND item.step_code = 'detect_register') AS register_pending,
|
||||
COUNT(*) FILTER (WHERE item.status = 'pending' AND item.step_code <> 'detect_register') AS downstream_pending
|
||||
FROM detect_job_items item
|
||||
JOIN detect_jobs job ON job.id = item.job_id
|
||||
WHERE job.status IN ('pending', 'running')
|
||||
"""
|
||||
)
|
||||
row = cur.fetchone() or (0, 0, 0, 0, 0)
|
||||
return {
|
||||
"pending_total": int(row[0] or 0),
|
||||
"claimed_total": int(row[1] or 0),
|
||||
"running_total": int(row[2] or 0),
|
||||
"register_pending": int(row[3] or 0),
|
||||
"downstream_pending": int(row[4] or 0),
|
||||
}
|
||||
|
||||
|
||||
def _build_task_pull_backlog_limits(configured_limit: int, settings_payload: dict | None = None) -> dict:
|
||||
estimated_total_threads = _estimate_total_worker_threads(settings_payload)
|
||||
max_pending_total = int(settings.sync_pull_max_pending_items or 0)
|
||||
if max_pending_total <= 0:
|
||||
max_pending_total = max(int(configured_limit or 0), estimated_total_threads * 2)
|
||||
|
||||
max_register_pending = int(settings.sync_pull_max_register_pending_items or 0)
|
||||
if max_register_pending <= 0:
|
||||
max_register_pending = max(max(500, int(configured_limit or 0) // 2), estimated_total_threads)
|
||||
|
||||
max_downstream_pending = int(settings.sync_pull_max_downstream_pending_items or 0)
|
||||
if max_downstream_pending <= 0:
|
||||
max_downstream_pending = max(250, estimated_total_threads // 4)
|
||||
|
||||
return {
|
||||
"estimated_total_threads": estimated_total_threads,
|
||||
"max_pending_total": max_pending_total,
|
||||
"max_register_pending": max_register_pending,
|
||||
"max_downstream_pending": max_downstream_pending,
|
||||
}
|
||||
|
||||
|
||||
def _should_throttle_task_pull(backlog_snapshot: dict, backlog_limits: dict) -> tuple[bool, str]:
|
||||
pending_total = int(backlog_snapshot.get("pending_total", 0) or 0)
|
||||
register_pending = int(backlog_snapshot.get("register_pending", 0) or 0)
|
||||
downstream_pending = int(backlog_snapshot.get("downstream_pending", 0) or 0)
|
||||
max_pending_total = int(backlog_limits.get("max_pending_total", 0) or 0)
|
||||
max_register_pending = int(backlog_limits.get("max_register_pending", 0) or 0)
|
||||
max_downstream_pending = int(backlog_limits.get("max_downstream_pending", 0) or 0)
|
||||
|
||||
if max_pending_total > 0 and pending_total >= max_pending_total:
|
||||
return True, "pending_total"
|
||||
if (
|
||||
downstream_pending > 0
|
||||
and max_register_pending > 0
|
||||
and register_pending >= max_register_pending
|
||||
):
|
||||
return True, "register_pending"
|
||||
if max_downstream_pending > 0 and downstream_pending >= max_downstream_pending:
|
||||
return True, "downstream_pending"
|
||||
return False, ""
|
||||
|
||||
|
||||
def _latest_push_attempt(source_record_id: int, target_region: str, sync_type: str) -> dict | None:
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
@@ -308,10 +421,51 @@ def _task_selection_sql() -> str:
|
||||
"""
|
||||
|
||||
|
||||
def _task_projection_limit(limit: int | None) -> int:
|
||||
requested = max(1, int(limit or 5000))
|
||||
configured = max(5000, int(settings.sync_batch_size or 200))
|
||||
cap = max(10000, configured, 5000)
|
||||
return max(1, min(requested, cap))
|
||||
|
||||
|
||||
def _task_projection_items_total(projection: dict) -> int:
|
||||
payload = projection.get("payload") or {}
|
||||
projection_payload = payload.get("projection") or {}
|
||||
try:
|
||||
return int(projection_payload.get("items_total", 0) or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _task_projection_selection_limit(projection: dict) -> int:
|
||||
payload = projection.get("payload") or {}
|
||||
projection_payload = payload.get("projection") or {}
|
||||
try:
|
||||
return int(projection_payload.get("selection_limit", 0) or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _mark_task_projection_superseded(record_id: int, *, reason: str) -> None:
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE detect_sync_records
|
||||
SET status = 'superseded',
|
||||
error_message = %s,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
""",
|
||||
(str(reason or "").strip()[:500], int(record_id)),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _load_pending_task_projection(limit: int) -> dict | None:
|
||||
source_region = _normalize_region(settings.sync_source_region, settings.node_region)
|
||||
target_region = _normalize_region(settings.sync_target_region, "overseas")
|
||||
safe_limit = max(1, min(int(limit or 1000), max(1, int(settings.sync_batch_size or 200))))
|
||||
safe_limit = _task_projection_limit(limit)
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
@@ -340,6 +494,22 @@ def _load_pending_task_projection(limit: int) -> dict | None:
|
||||
latest_ingest = _latest_ingest_attempt(projection["id"], projection["target_region"], "detect_task_projection")
|
||||
if latest_ingest and latest_ingest["status"] == "received":
|
||||
continue
|
||||
items_total = _task_projection_items_total(projection)
|
||||
selection_limit = _task_projection_selection_limit(projection)
|
||||
created_at = projection.get("created_at")
|
||||
if (
|
||||
safe_limit >= 1000
|
||||
and max(items_total, selection_limit) > 0
|
||||
and max(items_total, selection_limit) < safe_limit
|
||||
and isinstance(created_at, datetime)
|
||||
):
|
||||
now = datetime.now(created_at.tzinfo) if created_at.tzinfo else datetime.now()
|
||||
if now - created_at >= timedelta(minutes=10):
|
||||
_mark_task_projection_superseded(
|
||||
projection["id"],
|
||||
reason=f"stale small task projection skipped: items_total={items_total}, selection_limit={selection_limit}, requested_limit={safe_limit}",
|
||||
)
|
||||
continue
|
||||
return projection
|
||||
return None
|
||||
|
||||
@@ -361,7 +531,7 @@ def export_detect_task_projection(limit: int = 1000, *, shared_token: str | None
|
||||
|
||||
source_region = _normalize_region(settings.sync_source_region, settings.node_region)
|
||||
target_region = _normalize_region(settings.sync_target_region, "overseas")
|
||||
safe_limit = max(1, min(int(limit or 1000), max(1, int(settings.sync_batch_size or 200))))
|
||||
safe_limit = _task_projection_limit(limit)
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(_task_selection_sql(), (safe_limit,))
|
||||
@@ -528,6 +698,7 @@ def ingest_detect_task_projection(payload: dict, *, shared_token: str | None = N
|
||||
|
||||
inserted_count = 0
|
||||
updated_count = 0
|
||||
domain_ids: list[int] = []
|
||||
for item in items:
|
||||
domain = str(item.get("domain") or "").strip().lower()
|
||||
if not domain:
|
||||
@@ -562,7 +733,7 @@ def ingest_detect_task_projection(payload: dict, *, shared_token: str | None = N
|
||||
ELSE EXCLUDED.detect_status
|
||||
END,
|
||||
update_time = CURRENT_TIMESTAMP
|
||||
RETURNING (xmax = 0) AS inserted
|
||||
RETURNING id, (xmax = 0) AS inserted
|
||||
""",
|
||||
(
|
||||
domain,
|
||||
@@ -576,12 +747,120 @@ def ingest_detect_task_projection(payload: dict, *, shared_token: str | None = N
|
||||
int(item.get("juziseo_status") or 0),
|
||||
),
|
||||
)
|
||||
inserted = bool((cur.fetchone() or [False])[0])
|
||||
row = cur.fetchone() or [0, False]
|
||||
domain_id = int(row[0] or 0)
|
||||
inserted = bool(row[1])
|
||||
if domain_id > 0:
|
||||
domain_ids.append(domain_id)
|
||||
if inserted:
|
||||
inserted_count += 1
|
||||
else:
|
||||
updated_count += 1
|
||||
|
||||
target_job_code = f"sync-{source_region}-{source_record_id}"
|
||||
target_job_remark = (
|
||||
f"同步拉取待检测批次 {str(projection.get('batch_code') or '').strip() or source_record_id},"
|
||||
f"共 {len(domain_ids)} 个域名"
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO detect_jobs (job_code, source, plan_hash, task_mode, step_code, status, remark, created_by)
|
||||
VALUES (%s, %s, %s, 'domain_pipeline', '', 'pending', %s, %s)
|
||||
ON CONFLICT (job_code) DO UPDATE SET
|
||||
source = EXCLUDED.source,
|
||||
plan_hash = EXCLUDED.plan_hash,
|
||||
task_mode = EXCLUDED.task_mode,
|
||||
step_code = EXCLUDED.step_code,
|
||||
remark = EXCLUDED.remark,
|
||||
created_by = EXCLUDED.created_by,
|
||||
status = CASE
|
||||
WHEN detect_jobs.status IN ('completed', 'failed', 'cancelled') THEN 'pending'
|
||||
ELSE detect_jobs.status
|
||||
END,
|
||||
started_at = CASE
|
||||
WHEN detect_jobs.status IN ('completed', 'failed', 'cancelled') THEN NULL
|
||||
ELSE detect_jobs.started_at
|
||||
END,
|
||||
finished_at = CASE
|
||||
WHEN detect_jobs.status IN ('completed', 'failed', 'cancelled') THEN NULL
|
||||
ELSE detect_jobs.finished_at
|
||||
END
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
target_job_code,
|
||||
"sync-pull",
|
||||
projection_hash,
|
||||
target_job_remark,
|
||||
"sync-agent",
|
||||
),
|
||||
)
|
||||
target_job_id = int((cur.fetchone() or [0])[0] or 0)
|
||||
|
||||
settings_payload = get_settings_payload()
|
||||
queued_count = 0
|
||||
deduplicated_job_items = 0
|
||||
skipped_job_items = 0
|
||||
if target_job_id > 0:
|
||||
for domain_id in domain_ids:
|
||||
domain_snapshot = _load_domain_pipeline_snapshot(cur, int(domain_id))
|
||||
if not domain_snapshot:
|
||||
skipped_job_items += 1
|
||||
continue
|
||||
item_step_code, step_payload = resolve_initial_domain_pipeline_item(
|
||||
domain_snapshot,
|
||||
settings_payload=settings_payload,
|
||||
)
|
||||
if not item_step_code or not step_payload:
|
||||
skipped_job_items += 1
|
||||
continue
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO detect_job_items (job_id, domain_id, step_code, status, step_payload_json)
|
||||
VALUES (%s, %s, %s, 'pending', %s::jsonb)
|
||||
ON CONFLICT (job_id, domain_id, step_code) DO NOTHING
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
target_job_id,
|
||||
domain_id,
|
||||
item_step_code,
|
||||
json.dumps(step_payload, ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
inserted_job_item = cur.fetchone()
|
||||
if inserted_job_item:
|
||||
queued_count += 1
|
||||
else:
|
||||
deduplicated_job_items += 1
|
||||
|
||||
if queued_count > 0:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO detect_run_events (job_id, node_code, event_type, level, message, payload_json)
|
||||
VALUES (%s, %s, %s, %s, %s, %s::jsonb)
|
||||
""",
|
||||
(
|
||||
target_job_id,
|
||||
settings.node_code,
|
||||
"job_created",
|
||||
"info",
|
||||
f"同步拉取待检测批次 {target_job_code},共 {queued_count} 个任务项",
|
||||
json.dumps(
|
||||
{
|
||||
"source_region": source_region,
|
||||
"source_record_id": source_record_id,
|
||||
"projection_hash": projection_hash,
|
||||
"batch_code": str(projection.get("batch_code") or "").strip(),
|
||||
"queued_count": queued_count,
|
||||
"deduplicated_job_items": deduplicated_job_items,
|
||||
"skipped_job_items": skipped_job_items,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO detect_sync_records (
|
||||
@@ -602,6 +881,11 @@ def ingest_detect_task_projection(payload: dict, *, shared_token: str | None = N
|
||||
"items_total": len(items),
|
||||
"inserted_count": inserted_count,
|
||||
"updated_count": updated_count,
|
||||
"target_job_id": target_job_id,
|
||||
"target_job_code": target_job_code,
|
||||
"queued_count": queued_count,
|
||||
"deduplicated_job_items": deduplicated_job_items,
|
||||
"skipped_job_items": skipped_job_items,
|
||||
"received_at": _format_time(received_at),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
@@ -617,6 +901,11 @@ def ingest_detect_task_projection(payload: dict, *, shared_token: str | None = N
|
||||
"items_total": len(items),
|
||||
"inserted_count": inserted_count,
|
||||
"updated_count": updated_count,
|
||||
"target_job_id": target_job_id,
|
||||
"target_job_code": target_job_code,
|
||||
"queued_count": queued_count,
|
||||
"deduplicated_job_items": deduplicated_job_items,
|
||||
"skipped_job_items": skipped_job_items,
|
||||
"deduplicated": False,
|
||||
}
|
||||
|
||||
@@ -742,7 +1031,27 @@ def _extract_detect_result_projection_events(
|
||||
return events
|
||||
|
||||
|
||||
def _resolve_detect_result_target_job_id() -> int:
|
||||
def _resolve_detect_result_target_job_id(*, projection: dict) -> int:
|
||||
source_job = projection.get("job") or {}
|
||||
source_job_code = str(source_job.get("job_code") or "").strip()
|
||||
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
if source_job_code:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM detect_jobs
|
||||
WHERE job_code = %s
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(source_job_code,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
return int(row[0] or 0)
|
||||
|
||||
from app.services.detect_job_service import get_active_detect_job_summary
|
||||
|
||||
active_job = get_active_detect_job_summary(event_limit=1) or {}
|
||||
@@ -759,6 +1068,163 @@ def _parse_event_created_at(value: str) -> datetime | None:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_event_domain(payload: dict, message: str) -> str:
|
||||
domain = str(payload.get("domain") or "").strip().lower()
|
||||
if domain:
|
||||
return domain
|
||||
text = str(message or "").strip()
|
||||
if ":" in text:
|
||||
candidate = text.rsplit(":", 1)[-1].strip().lower()
|
||||
if candidate:
|
||||
return candidate
|
||||
return ""
|
||||
|
||||
|
||||
def _apply_detect_result_event_to_domain(cur, event: dict) -> None:
|
||||
payload = _decode_json(event.get("payload"))
|
||||
domain = _extract_event_domain(payload, str(event.get("message") or ""))
|
||||
if not domain:
|
||||
return
|
||||
|
||||
event_type = str(event.get("event_type") or "").strip()
|
||||
created_at = _parse_event_created_at(str(event.get("created_at") or ""))
|
||||
effective_time = created_at or datetime.now()
|
||||
|
||||
if event_type == "domain_started":
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE domains
|
||||
SET detect_status = CASE
|
||||
WHEN detect_status IN (1, 3) THEN detect_status
|
||||
ELSE 2
|
||||
END,
|
||||
update_time = CURRENT_TIMESTAMP
|
||||
WHERE domain = %s
|
||||
""",
|
||||
(domain,),
|
||||
)
|
||||
return
|
||||
|
||||
if event_type == "domain_completed":
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE domains
|
||||
SET detect_status = 1,
|
||||
detect_time = COALESCE(detect_time, %s),
|
||||
update_time = CURRENT_TIMESTAMP
|
||||
WHERE domain = %s
|
||||
""",
|
||||
(effective_time, domain),
|
||||
)
|
||||
return
|
||||
|
||||
if event_type == "domain_blacklisted":
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE domains
|
||||
SET detect_status = 3,
|
||||
update_time = CURRENT_TIMESTAMP
|
||||
WHERE domain = %s
|
||||
""",
|
||||
(domain,),
|
||||
)
|
||||
return
|
||||
|
||||
if event_type == "domain_failed":
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE domains
|
||||
SET detect_status = CASE
|
||||
WHEN detect_status IN (1, 3) THEN detect_status
|
||||
ELSE 4
|
||||
END,
|
||||
update_time = CURRENT_TIMESTAMP
|
||||
WHERE domain = %s
|
||||
""",
|
||||
(domain,),
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
def _apply_detect_result_event_to_job_item(cur, *, target_job_id: int, event: dict) -> int:
|
||||
if int(target_job_id or 0) <= 0:
|
||||
return 0
|
||||
|
||||
payload = _decode_json(event.get("payload"))
|
||||
domain = _extract_event_domain(payload, str(event.get("message") or ""))
|
||||
if not domain:
|
||||
return 0
|
||||
|
||||
event_type = str(event.get("event_type") or "").strip()
|
||||
node_code = str(event.get("node_code") or "").strip()
|
||||
message = str(event.get("message") or "").strip()
|
||||
|
||||
if event_type == "domain_started":
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE detect_job_items AS item
|
||||
SET status = CASE
|
||||
WHEN item.status IN ('completed', 'blacklisted', 'failed') THEN item.status
|
||||
ELSE 'running'
|
||||
END,
|
||||
claimed_by = CASE
|
||||
WHEN %s <> '' THEN %s
|
||||
ELSE item.claimed_by
|
||||
END,
|
||||
started_at = COALESCE(item.started_at, CURRENT_TIMESTAMP),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
FROM domains AS d
|
||||
WHERE item.job_id = %s
|
||||
AND item.domain_id = d.id
|
||||
AND d.domain = %s
|
||||
AND item.status IN ('pending', 'claimed', 'running')
|
||||
""",
|
||||
(node_code, node_code, int(target_job_id), domain),
|
||||
)
|
||||
return int(cur.rowcount or 0)
|
||||
|
||||
if event_type not in {"domain_completed", "domain_blacklisted", "domain_failed"}:
|
||||
return 0
|
||||
|
||||
final_status = {
|
||||
"domain_completed": "completed",
|
||||
"domain_blacklisted": "blacklisted",
|
||||
"domain_failed": "failed",
|
||||
}[event_type]
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE detect_job_items AS item
|
||||
SET status = %s,
|
||||
claimed_by = CASE
|
||||
WHEN %s <> '' THEN %s
|
||||
ELSE item.claimed_by
|
||||
END,
|
||||
finished_at = COALESCE(item.finished_at, CURRENT_TIMESTAMP),
|
||||
updated_at = CURRENT_TIMESTAMP,
|
||||
lease_expires_at = NULL,
|
||||
last_error = CASE
|
||||
WHEN %s = 'failed' THEN LEFT(%s, 1000)
|
||||
ELSE item.last_error
|
||||
END
|
||||
FROM domains AS d
|
||||
WHERE item.job_id = %s
|
||||
AND item.domain_id = d.id
|
||||
AND d.domain = %s
|
||||
AND item.status IN ('pending', 'claimed', 'running')
|
||||
""",
|
||||
(
|
||||
final_status,
|
||||
node_code,
|
||||
node_code,
|
||||
final_status,
|
||||
message,
|
||||
int(target_job_id),
|
||||
domain,
|
||||
),
|
||||
)
|
||||
return int(cur.rowcount or 0)
|
||||
|
||||
|
||||
def _import_detect_result_projection_events(
|
||||
*,
|
||||
source_region: str,
|
||||
@@ -773,12 +1239,11 @@ def _import_detect_result_projection_events(
|
||||
if not events:
|
||||
return {"imported_count": 0, "deduplicated_count": 0, "target_job_id": 0}
|
||||
|
||||
target_job_id = _resolve_detect_result_target_job_id()
|
||||
if target_job_id <= 0:
|
||||
return {"imported_count": 0, "deduplicated_count": 0, "target_job_id": 0}
|
||||
target_job_id = _resolve_detect_result_target_job_id(projection=projection)
|
||||
|
||||
imported_count = 0
|
||||
deduplicated_count = 0
|
||||
updated_job_items = 0
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
for event in events:
|
||||
@@ -831,12 +1296,27 @@ def _import_detect_result_projection_events(
|
||||
json.dumps(event["payload"], ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
_apply_detect_result_event_to_domain(cur, event)
|
||||
if target_job_id > 0:
|
||||
updated_job_items += _apply_detect_result_event_to_job_item(
|
||||
cur,
|
||||
target_job_id=target_job_id,
|
||||
event=event,
|
||||
)
|
||||
imported_count += 1
|
||||
conn.commit()
|
||||
if target_job_id > 0:
|
||||
from app.services.detect_job_service import refresh_detect_job_status
|
||||
|
||||
try:
|
||||
refresh_detect_job_status(target_job_id)
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"imported_count": imported_count,
|
||||
"deduplicated_count": deduplicated_count,
|
||||
"target_job_id": target_job_id,
|
||||
"updated_job_items": updated_job_items,
|
||||
}
|
||||
|
||||
|
||||
@@ -941,6 +1421,16 @@ def ingest_runtime_projection(payload: dict, *, shared_token: str | None = None)
|
||||
|
||||
|
||||
def _push_projection_now(sync_type: str, ingest_url: str) -> tuple[bool, str, dict]:
|
||||
if sync_type == "runtime_projection":
|
||||
# Regenerate the runtime snapshot before every push so the sync agent
|
||||
# does not keep replaying a stale projection record while the worker
|
||||
# thread count / phase is still changing.
|
||||
from app.services.runtime_status_service import get_runtime_status
|
||||
|
||||
try:
|
||||
get_runtime_status()
|
||||
except Exception as exc:
|
||||
return False, f"刷新 runtime_projection 失败: {exc}", {"action": "push_sync", "sync_type": sync_type}
|
||||
source_record = _load_latest_projection(sync_type)
|
||||
if not source_record:
|
||||
return False, f"当前没有可推送的{sync_type}", {"action": "push_sync", "sync_type": sync_type}
|
||||
@@ -1177,15 +1667,33 @@ def pull_detect_task_batch_now(limit: int | None = None) -> tuple[bool, str, dic
|
||||
if not export_url or not ack_url:
|
||||
return False, "未配置任务拉取目标地址", {"action": "pull_tasks", "pull_state": "misconfigured", "ui_level": "warning", "poll_schedule_seconds": []}
|
||||
|
||||
safe_limit = max(1, min(int(limit or settings.sync_batch_size or 200), max(1, int(settings.sync_batch_size or 200))))
|
||||
configured_limit = max(5000, int(settings.sync_batch_size or 200))
|
||||
requested_limit = int(limit or configured_limit)
|
||||
safe_limit = max(1, min(requested_limit, max(10000, configured_limit)))
|
||||
settings_payload = get_settings_payload()
|
||||
backlog_snapshot = _load_local_detect_backlog_snapshot()
|
||||
backlog_limits = _build_task_pull_backlog_limits(configured_limit, settings_payload=settings_payload)
|
||||
should_throttle, throttle_reason = _should_throttle_task_pull(backlog_snapshot, backlog_limits)
|
||||
if should_throttle:
|
||||
return True, "本地待处理积压较高,暂停拉取新批次", {
|
||||
"action": "pull_tasks",
|
||||
"pull_state": "throttled",
|
||||
"ui_level": "info",
|
||||
"poll_schedule_seconds": [1, 3],
|
||||
"reason": throttle_reason,
|
||||
**backlog_snapshot,
|
||||
**backlog_limits,
|
||||
}
|
||||
|
||||
request_url = f"{export_url}?limit={safe_limit}"
|
||||
export_timeout = max(20, min(90, 15 + safe_limit // 40))
|
||||
request = urllib.request.Request(
|
||||
request_url,
|
||||
headers={**({"X-Domaincheck-Sync-Token": settings.sync_shared_token} if settings.sync_shared_token else {})},
|
||||
method="GET",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
with urllib.request.urlopen(request, timeout=export_timeout) as response:
|
||||
raw = response.read().decode("utf-8")
|
||||
data = json.loads(raw) if raw else {}
|
||||
except json.JSONDecodeError as exc:
|
||||
@@ -1280,7 +1788,7 @@ def pull_detect_task_batch_now(limit: int | None = None) -> tuple[bool, str, dic
|
||||
**(ingest_data or {}),
|
||||
}
|
||||
|
||||
return True, "待检测任务批次拉取并入库成功", {
|
||||
result = {
|
||||
"action": "pull_tasks",
|
||||
"pull_state": "success",
|
||||
"ui_level": "success",
|
||||
@@ -1291,3 +1799,27 @@ def pull_detect_task_batch_now(limit: int | None = None) -> tuple[bool, str, dic
|
||||
**(ingest_data or {}),
|
||||
"ack": ack_data,
|
||||
}
|
||||
queued_count = int(result.get("queued_count", 0) or 0)
|
||||
if queued_count > 0:
|
||||
try:
|
||||
from app.services.worker_control_service import send_worker_command
|
||||
|
||||
start_ok, start_message = send_worker_command(
|
||||
"start_detection",
|
||||
payload={
|
||||
"source": "sync-pull",
|
||||
"source_record_id": source_record_id,
|
||||
"target_job_id": int(result.get("target_job_id", 0) or 0),
|
||||
"target_job_code": str(result.get("target_job_code") or "").strip(),
|
||||
},
|
||||
)
|
||||
result["worker_start_ok"] = bool(start_ok)
|
||||
result["worker_start_message"] = str(start_message or "").strip()
|
||||
except Exception as exc:
|
||||
result["worker_start_ok"] = False
|
||||
result["worker_start_message"] = f"同步入库后自动唤起 Worker 失败: {exc}"
|
||||
result["ui_level"] = "warning"
|
||||
result["pull_state"] = "worker_start_warning"
|
||||
return True, "待检测任务批次拉取并入库成功;但自动唤起 Worker 失败", result
|
||||
|
||||
return True, "待检测任务批次拉取并入库成功", result
|
||||
|
||||
@@ -6,7 +6,7 @@ import socket
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
from app.core.db import db_read_retry, get_db
|
||||
|
||||
|
||||
def _format_time(value: datetime | None) -> str:
|
||||
@@ -45,28 +45,94 @@ _DETECT_RESULT_EVENT_TYPES = {
|
||||
"domain_blacklisted",
|
||||
}
|
||||
|
||||
_TERMINAL_DETECT_RESULT_EVENT_TYPES = {
|
||||
"domain_completed",
|
||||
"domain_failed",
|
||||
"domain_blacklisted",
|
||||
}
|
||||
|
||||
|
||||
def _collect_recent_domain_events(active_job: dict, limit: int = 30) -> list[dict]:
|
||||
events = list(active_job.get("current_cycle_events") or active_job.get("recent_events") or [])
|
||||
safe_limit = max(1, int(limit or 30))
|
||||
seen: set[tuple[str, str, str, str]] = set()
|
||||
normalized: list[dict] = []
|
||||
for event in reversed(events):
|
||||
event_type = str(event.get("event_type") or "").strip()
|
||||
|
||||
def _append_event(raw_event: dict) -> None:
|
||||
event_type = str(raw_event.get("event_type") or "").strip()
|
||||
if event_type not in _DETECT_RESULT_EVENT_TYPES:
|
||||
continue
|
||||
payload = _decode_json(event.get("payload"))
|
||||
normalized.append(
|
||||
{
|
||||
"node_code": str(event.get("node_code") or "").strip(),
|
||||
"event_type": event_type,
|
||||
"level": str(event.get("level") or "info").strip() or "info",
|
||||
"message": str(event.get("message") or "").strip(),
|
||||
"created_at": str(event.get("created_at") or "").strip(),
|
||||
"payload": payload,
|
||||
}
|
||||
return
|
||||
normalized_event = {
|
||||
"node_code": str(raw_event.get("node_code") or "").strip(),
|
||||
"event_type": event_type,
|
||||
"level": str(raw_event.get("level") or "info").strip() or "info",
|
||||
"message": str(raw_event.get("message") or "").strip(),
|
||||
"created_at": str(raw_event.get("created_at") or "").strip(),
|
||||
"payload": _decode_json(raw_event.get("payload")),
|
||||
}
|
||||
event_key = (
|
||||
normalized_event["node_code"],
|
||||
normalized_event["event_type"],
|
||||
normalized_event["message"],
|
||||
normalized_event["created_at"],
|
||||
)
|
||||
if limit <= 0:
|
||||
return normalized
|
||||
return normalized[-int(limit):]
|
||||
if event_key in seen:
|
||||
return
|
||||
seen.add(event_key)
|
||||
normalized.append(normalized_event)
|
||||
|
||||
# Keep a small slice of the current-cycle `domain_started` events so the
|
||||
# remote log / live activity view still reflects the node's latest work.
|
||||
for event in reversed(list(active_job.get("current_cycle_events") or active_job.get("recent_events") or [])):
|
||||
if str(event.get("event_type") or "").strip() != "domain_started":
|
||||
continue
|
||||
_append_event(event)
|
||||
if len(normalized) >= min(10, max(1, safe_limit // 3)):
|
||||
break
|
||||
|
||||
# Always pull the most recent terminal result events from the full job
|
||||
# history. Otherwise a flood of newer `domain_started` events can hide
|
||||
# terminal completions, and overseas will never advance completed counts.
|
||||
job_id = int(active_job.get("job_id") or 0)
|
||||
if job_id > 0:
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT node_code, event_type, level, message, payload_json, created_at
|
||||
FROM detect_run_events
|
||||
WHERE job_id = %s
|
||||
AND event_type IN ('domain_completed', 'domain_failed', 'domain_blacklisted')
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(job_id, max(safe_limit * 4, 60)),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
for row in reversed(rows):
|
||||
_append_event(
|
||||
{
|
||||
"node_code": row[0] or "",
|
||||
"event_type": row[1] or "",
|
||||
"level": row[2] or "info",
|
||||
"message": row[3] or "",
|
||||
"payload": _decode_json(row[4]),
|
||||
"created_at": _format_time(row[5]),
|
||||
}
|
||||
)
|
||||
else:
|
||||
for event in reversed(list(active_job.get("current_cycle_events") or active_job.get("recent_events") or [])):
|
||||
if str(event.get("event_type") or "").strip() in _TERMINAL_DETECT_RESULT_EVENT_TYPES:
|
||||
_append_event(event)
|
||||
|
||||
normalized.sort(
|
||||
key=lambda item: (
|
||||
str(item.get("created_at") or ""),
|
||||
str(item.get("node_code") or ""),
|
||||
str(item.get("event_type") or ""),
|
||||
str(item.get("message") or ""),
|
||||
)
|
||||
)
|
||||
return normalized[-safe_limit:]
|
||||
|
||||
|
||||
def _build_detect_result_batch_digest(batch: dict | None) -> dict:
|
||||
@@ -154,6 +220,7 @@ def _should_append_runtime_projection(previous_payload: dict, current_projection
|
||||
return now - previous_created_at >= timedelta(seconds=45)
|
||||
|
||||
|
||||
@db_read_retry()
|
||||
def list_sync_records(limit: int = 20) -> list[dict]:
|
||||
safe_limit = max(1, min(int(limit or 20), 200))
|
||||
with get_db() as conn:
|
||||
@@ -221,6 +288,7 @@ def _latest_sync_record_by_source(
|
||||
}
|
||||
|
||||
|
||||
@db_read_retry()
|
||||
def get_detect_result_sync_batches(limit: int = 5) -> dict:
|
||||
safe_limit = max(1, min(int(limit or 5), 20))
|
||||
source_region = _normalize_region(settings.sync_source_region, settings.node_region)
|
||||
@@ -364,6 +432,7 @@ def get_detect_result_sync_batches(limit: int = 5) -> dict:
|
||||
}
|
||||
|
||||
|
||||
@db_read_retry()
|
||||
def get_sync_summary(record_limit: int = 10) -> dict:
|
||||
source_region = _normalize_region(settings.sync_source_region, settings.node_region)
|
||||
target_region = _normalize_region(settings.sync_target_region, "overseas")
|
||||
@@ -465,56 +534,43 @@ def append_sync_record(
|
||||
return record_id
|
||||
|
||||
|
||||
def append_runtime_projection_if_changed(
|
||||
def _local_node_expected_to_execute_worker() -> bool:
|
||||
node_role = str(settings.node_role or "").strip()
|
||||
node_region = str(settings.node_region or "").strip()
|
||||
return node_role == "worker" or (node_region == "mainland" and node_role == "control")
|
||||
|
||||
|
||||
def _build_runtime_projection_payload(
|
||||
*,
|
||||
detect: dict,
|
||||
cluster: dict,
|
||||
source_region: str | None = None,
|
||||
target_region: str | None = None,
|
||||
) -> int | None:
|
||||
normalized_source_region = _normalize_region(source_region, _normalize_region(settings.sync_source_region, settings.node_region))
|
||||
normalized_target_region = _normalize_region(target_region, _normalize_region(settings.sync_target_region, "overseas"))
|
||||
source_region: str,
|
||||
target_region: str,
|
||||
) -> dict:
|
||||
active_job = detect.get("active_job") or {}
|
||||
local_worker_expected = _local_node_expected_to_execute_worker()
|
||||
local_participating = False
|
||||
for node in list(cluster.get("nodes") or []):
|
||||
if str(node.get("node_code") or "").strip() != settings.node_code:
|
||||
continue
|
||||
local_participating = bool(node.get("detect_participating", False) or node.get("current_load", 0))
|
||||
if local_worker_expected:
|
||||
local_participating = bool(node.get("detect_participating", False) or node.get("current_load", 0))
|
||||
break
|
||||
local_job_bucket = {}
|
||||
for item in list(active_job.get("node_stats") or []):
|
||||
if str(item.get("node_code") or "").strip() != settings.node_code:
|
||||
continue
|
||||
local_job_bucket = item
|
||||
break
|
||||
if not local_participating:
|
||||
if local_worker_expected:
|
||||
for item in list(active_job.get("node_stats") or []):
|
||||
if str(item.get("node_code") or "").strip() != settings.node_code:
|
||||
continue
|
||||
local_job_bucket = item
|
||||
break
|
||||
if local_worker_expected and not local_participating:
|
||||
local_participating = bool(
|
||||
int(local_job_bucket.get("items_running", 0) or 0) > 0
|
||||
or int(local_job_bucket.get("items_claimed", 0) or 0) > 0
|
||||
)
|
||||
projection = {
|
||||
"node": {
|
||||
"node_code": settings.node_code,
|
||||
"region": settings.node_region,
|
||||
"role": settings.node_role,
|
||||
"hostname": socket.gethostname(),
|
||||
"ip": _resolve_local_ip(),
|
||||
},
|
||||
"worker_online": bool(detect.get("worker_online", False)),
|
||||
"detect_participating": local_participating,
|
||||
"worker_mode": detect.get("worker_mode", ""),
|
||||
"phase_label": detect.get("phase_label", ""),
|
||||
"phase_detail": detect.get("phase_detail", ""),
|
||||
"proxy_runtime_label": detect.get("proxy_runtime_label", ""),
|
||||
"proxy_runtime_reason": detect.get("proxy_runtime_reason", ""),
|
||||
"progress": {
|
||||
"pending": int((detect.get("progress") or {}).get("pending", 0) or 0),
|
||||
"running": int((detect.get("progress") or {}).get("running", 0) or 0),
|
||||
"completed": int((detect.get("progress") or {}).get("completed", 0) or 0),
|
||||
"blacklisted": int((detect.get("progress") or {}).get("blacklisted", 0) or 0),
|
||||
"failed": int((detect.get("progress") or {}).get("failed", 0) or 0),
|
||||
},
|
||||
"active_job": {
|
||||
|
||||
projection_active_job = (
|
||||
{
|
||||
"job_id": active_job.get("job_id"),
|
||||
"job_code": active_job.get("job_code", ""),
|
||||
"status": active_job.get("status", ""),
|
||||
@@ -525,7 +581,58 @@ def append_runtime_projection_if_changed(
|
||||
"items_running": active_job.get("items_running", 0),
|
||||
"items_failed": active_job.get("items_failed", 0),
|
||||
"node_stats": list(active_job.get("node_stats") or []),
|
||||
}
|
||||
if local_worker_expected
|
||||
else {
|
||||
"job_id": None,
|
||||
"job_code": "",
|
||||
"status": "",
|
||||
"progress_percent": 0,
|
||||
"items_total": 0,
|
||||
"items_terminal": 0,
|
||||
"items_pending": 0,
|
||||
"items_running": 0,
|
||||
"items_failed": 0,
|
||||
"node_stats": [],
|
||||
}
|
||||
)
|
||||
progress_payload = (
|
||||
{
|
||||
"pending": int((detect.get("progress") or {}).get("pending", 0) or 0),
|
||||
"running": int((detect.get("progress") or {}).get("running", 0) or 0),
|
||||
"completed": int((detect.get("progress") or {}).get("completed", 0) or 0),
|
||||
"blacklisted": int((detect.get("progress") or {}).get("blacklisted", 0) or 0),
|
||||
"failed": int((detect.get("progress") or {}).get("failed", 0) or 0),
|
||||
}
|
||||
if local_worker_expected
|
||||
else {
|
||||
"pending": 0,
|
||||
"running": 0,
|
||||
"completed": 0,
|
||||
"blacklisted": 0,
|
||||
"failed": 0,
|
||||
}
|
||||
)
|
||||
projection = {
|
||||
"node": {
|
||||
"node_code": settings.node_code,
|
||||
"region": settings.node_region,
|
||||
"role": settings.node_role,
|
||||
"hostname": socket.gethostname(),
|
||||
"ip": _resolve_local_ip(),
|
||||
},
|
||||
"worker_online": bool(detect.get("worker_online", False)) if local_worker_expected else False,
|
||||
"detect_participating": local_participating if local_worker_expected else False,
|
||||
"worker_mode": detect.get("worker_mode", ""),
|
||||
"active_thread_count": int(detect.get("active_thread_count", 0) or 0) if local_worker_expected else 0,
|
||||
"max_thread_count": int(detect.get("max_thread_count", 0) or 0) if local_worker_expected else 0,
|
||||
"phase_label": detect.get("phase_label", ""),
|
||||
"phase_detail": detect.get("phase_detail", ""),
|
||||
"proxy_runtime_label": detect.get("proxy_runtime_label", ""),
|
||||
"proxy_runtime_reason": detect.get("proxy_runtime_reason", ""),
|
||||
"progress": progress_payload,
|
||||
"backlog": dict(detect.get("backlog") or {}) if local_worker_expected else {},
|
||||
"active_job": projection_active_job,
|
||||
"cluster_summary": {
|
||||
"nodes_total": int(cluster.get("nodes_total", 0) or 0),
|
||||
"online_worker_nodes": int((cluster.get("summary") or {}).get("online_worker_nodes", 0) or 0),
|
||||
@@ -544,13 +651,32 @@ def append_runtime_projection_if_changed(
|
||||
for item in (detect.get("dependency_alerts") or [])[:3]
|
||||
],
|
||||
}
|
||||
payload = {
|
||||
return {
|
||||
"projection": projection,
|
||||
"projection_hash": hashlib.sha1(
|
||||
json.dumps(projection, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
||||
).hexdigest(),
|
||||
"source_region": source_region,
|
||||
"target_region": target_region,
|
||||
}
|
||||
|
||||
|
||||
def append_runtime_projection_if_changed(
|
||||
*,
|
||||
detect: dict,
|
||||
cluster: dict,
|
||||
source_region: str | None = None,
|
||||
target_region: str | None = None,
|
||||
) -> int | None:
|
||||
normalized_source_region = _normalize_region(source_region, _normalize_region(settings.sync_source_region, settings.node_region))
|
||||
normalized_target_region = _normalize_region(target_region, _normalize_region(settings.sync_target_region, "overseas"))
|
||||
payload = _build_runtime_projection_payload(
|
||||
detect=detect,
|
||||
cluster=cluster,
|
||||
source_region=normalized_source_region,
|
||||
target_region=normalized_target_region,
|
||||
)
|
||||
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
@@ -570,7 +696,7 @@ def append_runtime_projection_if_changed(
|
||||
latest_created_at = latest[1] if latest else None
|
||||
if latest_payload.get("projection_hash") == payload["projection_hash"]:
|
||||
return None
|
||||
if not _should_append_runtime_projection(latest_payload, projection, latest_created_at):
|
||||
if not _should_append_runtime_projection(latest_payload, payload["projection"], latest_created_at):
|
||||
return None
|
||||
cur.execute(
|
||||
"""
|
||||
@@ -650,6 +776,7 @@ def append_detect_result_projection_if_changed(
|
||||
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
current_job_id = int((projection.get("job") or {}).get("job_id") or 0)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT payload_json, created_at
|
||||
@@ -657,10 +784,19 @@ def append_detect_result_projection_if_changed(
|
||||
WHERE sync_type = 'detect_result_projection'
|
||||
AND source_region = %s
|
||||
AND target_region = %s
|
||||
AND (
|
||||
%s <= 0
|
||||
OR (payload_json->'projection'->'job'->>'job_id') = %s
|
||||
)
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(normalized_source_region, normalized_target_region),
|
||||
(
|
||||
normalized_source_region,
|
||||
normalized_target_region,
|
||||
current_job_id,
|
||||
str(current_job_id),
|
||||
),
|
||||
)
|
||||
latest = cur.fetchone()
|
||||
latest_payload = _decode_json(latest[0]) if latest else {}
|
||||
|
||||
@@ -60,7 +60,12 @@ def normalize_systemctl_error(raw_message: str, *, service_name: str = "") -> st
|
||||
return f"{target} 控制失败,未返回可用错误信息"
|
||||
|
||||
lowered = message.lower()
|
||||
if "sudo: a password is required" in lowered or "authentication is required" in lowered:
|
||||
if (
|
||||
"sudo: a password is required" in lowered
|
||||
or "authentication is required" in lowered
|
||||
or "interactive authentication required" in lowered
|
||||
or "authorization not available" in lowered
|
||||
):
|
||||
target = normalized_service_name or "systemd 服务"
|
||||
return f"{target} 控制失败:当前运行用户没有免密 systemctl 权限,请为 API 进程授予对应 sudo/systemd 权限"
|
||||
if "unit " in lowered and " could not be found" in lowered:
|
||||
|
||||
@@ -5,13 +5,34 @@ import time
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.debug_event_service import push_debug_event
|
||||
from app.services.detect_job_service import get_active_detect_job_summary, get_detect_queue_health, list_recent_detect_run_events
|
||||
from app.services.detect_job_service import (
|
||||
get_active_detect_job_summary,
|
||||
get_detect_queue_health,
|
||||
get_latest_detect_job_summary,
|
||||
get_latest_unprojected_detect_job_summary,
|
||||
list_recent_detect_run_events,
|
||||
process_detect_pipeline_now,
|
||||
)
|
||||
from app.services.sync_record_service import append_detect_result_projection_if_changed
|
||||
from app.services.sync_push_service import pull_detect_task_batch_now, push_runtime_projection_now
|
||||
from app.services.sync_push_service import (
|
||||
_load_local_detect_backlog_snapshot,
|
||||
pull_detect_task_batch_now,
|
||||
push_runtime_projection_now,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger("domaincheck.sync_agent")
|
||||
|
||||
_IDLE_SYNC_KEYWORDS = (
|
||||
"当前没有可推送",
|
||||
"当前没有需要立即推送",
|
||||
"已全部同步完成",
|
||||
"无需重复发送",
|
||||
"进行中",
|
||||
"等待下个重试窗口",
|
||||
"暂停拉取",
|
||||
)
|
||||
|
||||
|
||||
def _append_detect_result_projection_snapshot(active_job: dict) -> None:
|
||||
if not active_job:
|
||||
@@ -32,6 +53,159 @@ def _append_detect_result_projection_snapshot(active_job: dict) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _is_idle_sync_message(message: str) -> bool:
|
||||
normalized = str(message or "").strip()
|
||||
return any(keyword in normalized for keyword in _IDLE_SYNC_KEYWORDS)
|
||||
|
||||
|
||||
def _filter_runtime_events_for_job(events: list[dict], *, job_code: str = "", job_id: int = 0, limit: int = 8) -> list[dict]:
|
||||
target_job_code = str(job_code or "").strip()
|
||||
target_job_id = int(job_id or 0)
|
||||
safe_limit = max(1, min(int(limit or 8), 50))
|
||||
filtered: list[dict] = []
|
||||
for raw_event in list(events or []):
|
||||
if not isinstance(raw_event, dict):
|
||||
continue
|
||||
payload = raw_event.get("payload") if isinstance(raw_event.get("payload"), dict) else {}
|
||||
event_job_code = str(payload.get("job_code") or "").strip()
|
||||
event_job_id = int(raw_event.get("job_id") or 0)
|
||||
if target_job_code and event_job_code != target_job_code and (target_job_id <= 0 or event_job_id != target_job_id):
|
||||
continue
|
||||
filtered.append(raw_event)
|
||||
if len(filtered) >= safe_limit:
|
||||
break
|
||||
return filtered
|
||||
|
||||
|
||||
def _build_aligned_queue_health_snapshot(active_job: dict, queue_health: dict | None) -> dict:
|
||||
snapshot = dict(queue_health or {})
|
||||
if not active_job:
|
||||
return snapshot
|
||||
|
||||
active_job_code = str(active_job.get("runtime_job_code") or active_job.get("job_code") or "").strip()
|
||||
queue_job = dict(snapshot.get("job") or {})
|
||||
queue_job_code = str(queue_job.get("runtime_job_code") or queue_job.get("job_code") or "").strip()
|
||||
|
||||
if active_job_code and queue_job_code and active_job_code == queue_job_code:
|
||||
return snapshot
|
||||
|
||||
active_job_items_total = int(active_job.get("items_total", 0) or 0)
|
||||
active_job_pending = int(active_job.get("items_pending", 0) or 0)
|
||||
active_job_claimed = int(active_job.get("items_claimed", 0) or 0)
|
||||
active_job_running = int(active_job.get("items_running", 0) or 0)
|
||||
active_job_completed = int(active_job.get("items_completed", 0) or 0)
|
||||
active_job_blacklisted = int(active_job.get("items_blacklisted", 0) or 0)
|
||||
active_job_failed = int(active_job.get("items_failed", 0) or 0)
|
||||
active_job_terminal = int(
|
||||
active_job.get("items_terminal", active_job_completed + active_job_blacklisted + active_job_failed) or 0
|
||||
)
|
||||
display_claimed = int(active_job.get("display_items_claimed", active_job_claimed) or active_job_claimed)
|
||||
display_running = int(active_job.get("display_items_running", active_job_running) or active_job_running)
|
||||
|
||||
node_entries: list[dict] = []
|
||||
for node in list(active_job.get("node_stats") or []):
|
||||
node_entries.append(
|
||||
{
|
||||
"node_code": str(node.get("node_code") or "").strip(),
|
||||
"items_total": int(node.get("items_total", 0) or 0),
|
||||
"items_pending": int(node.get("items_pending", 0) or 0),
|
||||
"items_claimed": int(node.get("items_claimed", 0) or 0),
|
||||
"items_running": int(node.get("items_running", 0) or 0),
|
||||
"items_completed": int(node.get("items_completed", 0) or 0),
|
||||
"items_blacklisted": int(node.get("items_blacklisted", 0) or 0),
|
||||
"items_failed": int(node.get("items_failed", 0) or 0),
|
||||
"processed_recent": int(node.get("processed_recent", 0) or 0),
|
||||
"processed_per_minute": float(node.get("processed_per_minute", 0) or 0),
|
||||
"completed_recent": int(node.get("completed_recent", 0) or 0),
|
||||
"blacklisted_recent": int(node.get("blacklisted_recent", 0) or 0),
|
||||
"failed_recent": int(node.get("failed_recent", 0) or 0),
|
||||
"metrics_source": str(node.get("metrics_source") or "runtime"),
|
||||
}
|
||||
)
|
||||
|
||||
assigned_total = sum(int(item.get("items_total", 0) or 0) for item in node_entries)
|
||||
unassigned_total = max(0, active_job_items_total - assigned_total)
|
||||
if unassigned_total > 0:
|
||||
node_entries.append(
|
||||
{
|
||||
"node_code": "unassigned",
|
||||
"items_total": unassigned_total,
|
||||
"items_pending": active_job_pending,
|
||||
"items_claimed": 0,
|
||||
"items_running": 0,
|
||||
"items_completed": 0,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
"processed_recent": 0,
|
||||
"processed_per_minute": 0.0,
|
||||
"completed_recent": 0,
|
||||
"blacklisted_recent": 0,
|
||||
"failed_recent": 0,
|
||||
"metrics_source": "central_queue",
|
||||
}
|
||||
)
|
||||
|
||||
snapshot["job"] = {
|
||||
"job_id": active_job.get("job_id"),
|
||||
"job_code": str(active_job.get("job_code") or "").strip(),
|
||||
"runtime_job_code": active_job_code,
|
||||
"status": str(active_job.get("status") or "").strip(),
|
||||
"progress_percent": float(active_job.get("progress_percent", 0) or 0),
|
||||
}
|
||||
snapshot["queue"] = {
|
||||
**dict(snapshot.get("queue") or {}),
|
||||
"items_total": active_job_items_total,
|
||||
"pending": active_job_pending,
|
||||
"claimed": active_job_claimed,
|
||||
"running": active_job_running,
|
||||
"display_claimed": display_claimed,
|
||||
"display_running": display_running,
|
||||
"completed": active_job_completed,
|
||||
"blacklisted": active_job_blacklisted,
|
||||
"failed": active_job_failed,
|
||||
"terminal": active_job_terminal,
|
||||
"terminal_percent": round((active_job_terminal / active_job_items_total) * 100, 2) if active_job_items_total else 0.0,
|
||||
}
|
||||
snapshot["nodes"] = node_entries
|
||||
return snapshot
|
||||
|
||||
|
||||
def _select_projection_job_snapshot() -> dict | None:
|
||||
active_job = get_active_detect_job_summary(event_limit=10)
|
||||
if active_job:
|
||||
return active_job
|
||||
return get_latest_detect_job_summary(
|
||||
event_limit=10,
|
||||
statuses=("completed", "partial_failed", "failed"),
|
||||
recent_minutes=20,
|
||||
)
|
||||
|
||||
|
||||
def _select_projection_job_snapshots() -> list[dict]:
|
||||
snapshots: list[dict] = []
|
||||
seen_job_ids: set[int] = set()
|
||||
|
||||
active_job = get_active_detect_job_summary(event_limit=10)
|
||||
if active_job:
|
||||
active_job_id = int(active_job.get("job_id") or 0)
|
||||
if active_job_id > 0 and active_job_id not in seen_job_ids:
|
||||
snapshots.append(active_job)
|
||||
seen_job_ids.add(active_job_id)
|
||||
|
||||
latest_finished_job = get_latest_unprojected_detect_job_summary(
|
||||
event_limit=10,
|
||||
statuses=("completed", "partial_failed", "failed"),
|
||||
recent_minutes=180,
|
||||
)
|
||||
if latest_finished_job:
|
||||
latest_finished_job_id = int(latest_finished_job.get("job_id") or 0)
|
||||
if latest_finished_job_id > 0 and latest_finished_job_id not in seen_job_ids:
|
||||
snapshots.append(latest_finished_job)
|
||||
seen_job_ids.add(latest_finished_job_id)
|
||||
|
||||
return snapshots
|
||||
|
||||
|
||||
def _emit_structured_tick(
|
||||
*,
|
||||
base_event_type: str,
|
||||
@@ -42,7 +216,13 @@ def _emit_structured_tick(
|
||||
payload = {"ok": ok, "data": data or {}}
|
||||
event_type = f"{base_event_type}_failed"
|
||||
level = "warning"
|
||||
if ok:
|
||||
if isinstance(data, dict) and str(data.get("pull_state") or "").strip() == "throttled":
|
||||
event_type = f"{base_event_type}_idle"
|
||||
level = "info"
|
||||
elif not ok and _is_idle_sync_message(message):
|
||||
event_type = f"{base_event_type}_idle"
|
||||
level = "info"
|
||||
elif ok:
|
||||
event_type = f"{base_event_type}_success"
|
||||
level = "info"
|
||||
if "但远端确认失败" in str(message or ""):
|
||||
@@ -70,7 +250,10 @@ def _emit_sync_result_breakdown(data: dict | None) -> None:
|
||||
result_data = item.get("data") or {}
|
||||
event_type = f"{sync_type}_sync_failed"
|
||||
level = "warning"
|
||||
if ok:
|
||||
if not ok and _is_idle_sync_message(message):
|
||||
event_type = f"{sync_type}_sync_idle"
|
||||
level = "info"
|
||||
elif ok:
|
||||
event_type = f"{sync_type}_sync_success"
|
||||
level = "info"
|
||||
if isinstance(result_data, dict) and result_data.get("success_count") is not None:
|
||||
@@ -95,12 +278,31 @@ def _emit_sync_result_breakdown(data: dict | None) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _run_pipeline_stage_processor() -> tuple[bool, str, dict]:
|
||||
process_limit = max(500, min(int(settings.sync_pipeline_process_limit or 5000), 5000))
|
||||
ok, message, data = process_detect_pipeline_now(limit=process_limit)
|
||||
push_debug_event(
|
||||
service="sync-agent",
|
||||
event_type="pipeline_tick_success" if ok else "pipeline_tick_failed",
|
||||
level="info" if ok else "warning",
|
||||
message=message,
|
||||
payload={
|
||||
"ok": ok,
|
||||
"limit": process_limit,
|
||||
"data": data or {},
|
||||
},
|
||||
)
|
||||
return ok, message, data
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
interval = max(10, int(settings.sync_poll_interval_seconds or 30))
|
||||
# Old env files still ship SYNC_POLL_INTERVAL_SECONDS=30. Cap the interval
|
||||
# so controller pull/pipeline ticks cannot be throttled into starvation.
|
||||
interval = max(2, min(int(settings.sync_poll_interval_seconds or 2), 5))
|
||||
logger.info(
|
||||
"sync agent started: node=%s source=%s target=%s interval=%ss enabled=%s",
|
||||
settings.node_code,
|
||||
@@ -111,9 +313,16 @@ def main() -> None:
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
pipeline_ok, pipeline_message, pipeline_data = _run_pipeline_stage_processor()
|
||||
logger.info(
|
||||
"pipeline tick: ok=%s message=%s data=%s",
|
||||
pipeline_ok,
|
||||
pipeline_message,
|
||||
pipeline_data,
|
||||
)
|
||||
active_job = get_active_detect_job_summary(event_limit=10)
|
||||
if active_job:
|
||||
_append_detect_result_projection_snapshot(active_job)
|
||||
for projection_job in _select_projection_job_snapshots():
|
||||
_append_detect_result_projection_snapshot(projection_job)
|
||||
ok, message, data = push_runtime_projection_now()
|
||||
logger.info("sync tick: ok=%s message=%s data=%s", ok, message, data)
|
||||
push_debug_event(
|
||||
@@ -136,8 +345,16 @@ def main() -> None:
|
||||
)
|
||||
_emit_structured_tick(base_event_type="task_pull", ok=pull_ok, message=pull_message, data=pull_data)
|
||||
if active_job:
|
||||
queue_health = get_detect_queue_health(window_minutes=15)
|
||||
recent_events = list_recent_detect_run_events(limit=8)
|
||||
queue_health = _build_aligned_queue_health_snapshot(
|
||||
active_job,
|
||||
get_detect_queue_health(window_minutes=15),
|
||||
)
|
||||
recent_events = _filter_runtime_events_for_job(
|
||||
list_recent_detect_run_events(limit=24),
|
||||
job_code=str(active_job.get("runtime_job_code") or active_job.get("job_code") or "").strip(),
|
||||
job_id=int(active_job.get("job_id", 0) or 0),
|
||||
limit=8,
|
||||
)
|
||||
push_debug_event(
|
||||
service="detect-runtime",
|
||||
event_type="active_job_snapshot",
|
||||
@@ -158,6 +375,7 @@ def main() -> None:
|
||||
"node_stats": list(active_job.get("node_stats") or []),
|
||||
},
|
||||
"queue_health": queue_health,
|
||||
"backlog": _load_local_detect_backlog_snapshot(),
|
||||
"recent_events": recent_events,
|
||||
},
|
||||
)
|
||||
|
||||
112
domain-api/deploy/multi-region/fix_mainland_release_base.sh
Executable file
112
domain-api/deploy/multi-region/fix_mainland_release_base.sh
Executable file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROLE="${1:-}"
|
||||
BASE_DIR="${2:-/opt/domaincheck}"
|
||||
WWW_USER="${WWW_USER:-www}"
|
||||
WWW_GROUP="${WWW_GROUP:-www}"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
usage:
|
||||
bash domain-api/deploy/multi-region/fix_mainland_release_base.sh <worker|control> [base_dir]
|
||||
|
||||
examples:
|
||||
bash domain-api/deploy/multi-region/fix_mainland_release_base.sh worker
|
||||
bash domain-api/deploy/multi-region/fix_mainland_release_base.sh control
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ "${ROLE}" != "worker" && "${ROLE}" != "control" ]]; then
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$(id -u)" != "0" ]]; then
|
||||
echo "please run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
require_path() {
|
||||
local path="$1"
|
||||
if [[ ! -e "${path}" ]]; then
|
||||
echo "required path missing: ${path}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
install_dropin() {
|
||||
local service_name="$1"
|
||||
local content="$2"
|
||||
local dropin_dir="/etc/systemd/system/${service_name}.d"
|
||||
local dropin_file="${dropin_dir}/current-path.conf"
|
||||
mkdir -p "${dropin_dir}"
|
||||
printf '%s\n' "${content}" > "${dropin_file}"
|
||||
echo "installed ${dropin_file}"
|
||||
}
|
||||
|
||||
echo "[1/6] validate existing runtime"
|
||||
require_path "${BASE_DIR}"
|
||||
require_path "${BASE_DIR}/domainCheck"
|
||||
require_path "${BASE_DIR}/domainCheck/.venv/bin/python"
|
||||
require_path "${BASE_DIR}/domainCheck/detect_worker.py"
|
||||
require_path "${BASE_DIR}/domain-api"
|
||||
|
||||
echo "[2/6] make release root writable for ${WWW_USER}:${WWW_GROUP}"
|
||||
chgrp "${WWW_GROUP}" "${BASE_DIR}"
|
||||
chmod 2775 "${BASE_DIR}"
|
||||
mkdir -p "${BASE_DIR}/downloads" "${BASE_DIR}/releases"
|
||||
chown -R "${WWW_USER}:${WWW_GROUP}" "${BASE_DIR}/downloads" "${BASE_DIR}/releases"
|
||||
|
||||
echo "[3/6] ensure current link exists"
|
||||
if [[ -L "${BASE_DIR}/current" ]]; then
|
||||
echo "keep existing symlink: ${BASE_DIR}/current -> $(readlink -f "${BASE_DIR}/current" || true)"
|
||||
elif [[ -e "${BASE_DIR}/current" ]]; then
|
||||
echo "path exists but is not a symlink: ${BASE_DIR}/current" >&2
|
||||
exit 1
|
||||
else
|
||||
ln -s "${BASE_DIR}" "${BASE_DIR}/current"
|
||||
echo "created symlink: ${BASE_DIR}/current -> ${BASE_DIR}"
|
||||
fi
|
||||
|
||||
echo "[4/6] install systemd drop-ins"
|
||||
install_dropin "domaincheck-worker" "[Service]
|
||||
WorkingDirectory=${BASE_DIR}/current/domainCheck
|
||||
ExecStart=
|
||||
ExecStart=${BASE_DIR}/domainCheck/.venv/bin/python ${BASE_DIR}/current/domainCheck/detect_worker.py"
|
||||
|
||||
install_dropin "domaincheck-node-agent" "[Service]
|
||||
User=root
|
||||
Group=root
|
||||
WorkingDirectory=${BASE_DIR}/current/domain-api
|
||||
ExecStart=
|
||||
ExecStart=${BASE_DIR}/domainCheck/.venv/bin/python -m app.node_agent"
|
||||
|
||||
if [[ "${ROLE}" == "control" ]]; then
|
||||
install_dropin "domaincheck-api" "[Service]
|
||||
WorkingDirectory=${BASE_DIR}/current/domain-api
|
||||
ExecStart=
|
||||
ExecStart=${BASE_DIR}/domainCheck/.venv/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 8100"
|
||||
|
||||
install_dropin "domaincheck-sync-agent" "[Service]
|
||||
WorkingDirectory=${BASE_DIR}/current/domain-api
|
||||
ExecStart=
|
||||
ExecStart=${BASE_DIR}/domainCheck/.venv/bin/python -m app.sync_agent"
|
||||
fi
|
||||
|
||||
echo "[5/6] reload systemd and restart node agent"
|
||||
systemctl daemon-reload
|
||||
systemctl restart domaincheck-node-agent
|
||||
|
||||
echo "[6/6] summary"
|
||||
echo "role=${ROLE}"
|
||||
echo "base_dir=${BASE_DIR}"
|
||||
echo "current_target=$(readlink -f "${BASE_DIR}/current" || true)"
|
||||
echo
|
||||
echo "recommended next checks:"
|
||||
echo " systemctl status domaincheck-node-agent --no-pager -l"
|
||||
echo " systemctl cat domaincheck-worker"
|
||||
if [[ "${ROLE}" == "control" ]]; then
|
||||
echo " systemctl cat domaincheck-api"
|
||||
echo " systemctl cat domaincheck-sync-agent"
|
||||
fi
|
||||
@@ -125,8 +125,8 @@ SYNC_SOURCE_REGION=mainland
|
||||
SYNC_TARGET_REGION=overseas
|
||||
SYNC_TARGET_API_BASE_URL=${TARGET_API_BASE_URL}
|
||||
SYNC_SHARED_TOKEN=${SYNC_SHARED_TOKEN}
|
||||
SYNC_BATCH_SIZE=200
|
||||
SYNC_POLL_INTERVAL_SECONDS=30
|
||||
SYNC_BATCH_SIZE=5000
|
||||
SYNC_POLL_INTERVAL_SECONDS=2
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
|
||||
@@ -126,8 +126,8 @@ SYNC_SOURCE_REGION=mainland
|
||||
SYNC_TARGET_REGION=overseas
|
||||
SYNC_TARGET_API_BASE_URL=${TARGET_API_BASE_URL}
|
||||
SYNC_SHARED_TOKEN=${SYNC_SHARED_TOKEN}
|
||||
SYNC_BATCH_SIZE=200
|
||||
SYNC_POLL_INTERVAL_SECONDS=30
|
||||
SYNC_BATCH_SIZE=5000
|
||||
SYNC_POLL_INTERVAL_SECONDS=2
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
|
||||
303
domain-api/deploy/multi-region/night_shift_until_20260420_1200.sh
Executable file
303
domain-api/deploy/multi-region/night_shift_until_20260420_1200.sh
Executable file
@@ -0,0 +1,303 @@
|
||||
#!/usr/bin/env bash
|
||||
set -u -o pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
|
||||
BASE_URL="${1:-http://127.0.0.1:8100}"
|
||||
DEADLINE_TEXT="${2:-2026-04-20 12:00:00 +0800}"
|
||||
RUNTIME_DIR="${REPO_ROOT}/docs/ops_center_runtime/night_runs"
|
||||
START_TS="$(date +%Y%m%d_%H%M%S)"
|
||||
RUN_ID="night_run_${START_TS}"
|
||||
LOG_FILE="${RUNTIME_DIR}/${RUN_ID}.log"
|
||||
STATE_DIR="${RUNTIME_DIR}/${RUN_ID}"
|
||||
PID_FILE="${RUNTIME_DIR}/${RUN_ID}.pid"
|
||||
REPORT_FILE="${RUNTIME_DIR}/${RUN_ID}_report.md"
|
||||
SUMMARY_JSON="${RUNTIME_DIR}/${RUN_ID}_summary.json"
|
||||
|
||||
mkdir -p "${RUNTIME_DIR}" "${STATE_DIR}"
|
||||
echo "$$" > "${PID_FILE}"
|
||||
|
||||
DEADLINE_EPOCH="$(python3 - <<'PY' "${DEADLINE_TEXT}"
|
||||
import datetime
|
||||
import sys
|
||||
|
||||
text = sys.argv[1]
|
||||
dt = datetime.datetime.strptime(text, "%Y-%m-%d %H:%M:%S %z")
|
||||
print(int(dt.timestamp()))
|
||||
PY
|
||||
)"
|
||||
|
||||
cycles=0
|
||||
log_sync_recover_runs=0
|
||||
inspection_runs=0
|
||||
inspection_churn_runs=0
|
||||
quick_rechecks=0
|
||||
stop_reason="deadline_reached"
|
||||
last_go_live_status=""
|
||||
last_publish_ready=""
|
||||
last_log_sync_state=""
|
||||
last_issue_total=""
|
||||
last_problem_runs_total=""
|
||||
last_launchpad_status=""
|
||||
last_launchpad_action=""
|
||||
last_problem_run_code=""
|
||||
|
||||
log() {
|
||||
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S %z')" "$*" | tee -a "${LOG_FILE}"
|
||||
}
|
||||
|
||||
run_cmd() {
|
||||
log "RUN $*"
|
||||
if "$@" >> "${LOG_FILE}" 2>&1; then
|
||||
log "OK $*"
|
||||
return 0
|
||||
fi
|
||||
log "ERR $*"
|
||||
return 1
|
||||
}
|
||||
|
||||
fetch_json() {
|
||||
local url="$1"
|
||||
local target="$2"
|
||||
if curl -m 20 -s "${url}" > "${target}.tmp"; then
|
||||
mv "${target}.tmp" "${target}"
|
||||
return 0
|
||||
fi
|
||||
rm -f "${target}.tmp"
|
||||
return 1
|
||||
}
|
||||
|
||||
snapshot_state() {
|
||||
local cycle_dir="$1"
|
||||
mkdir -p "${cycle_dir}"
|
||||
|
||||
fetch_json "${BASE_URL}/api/v1/ops/go-live-summary" "${cycle_dir}/go_live.json" || true
|
||||
fetch_json "${BASE_URL}/api/v1/ops/stack-diagnosis" "${cycle_dir}/stack.json" || true
|
||||
fetch_json "${BASE_URL}/api/v1/ops/releases/launchpad" "${cycle_dir}/launchpad.json" || true
|
||||
fetch_json "${BASE_URL}/api/v1/ops/playbook-runs" "${cycle_dir}/playbook_runs.json" || true
|
||||
fetch_json "${BASE_URL}/api/v1/ops/nodes/overseas-control-01/scene-log?limit=120&mode=full" "${cycle_dir}/scene_overseas_control_01.json" || true
|
||||
|
||||
python3 - <<'PY' \
|
||||
"${cycle_dir}/go_live.json" \
|
||||
"${cycle_dir}/stack.json" \
|
||||
"${cycle_dir}/launchpad.json" \
|
||||
"${cycle_dir}/playbook_runs.json" \
|
||||
"${cycle_dir}/scene_overseas_control_01.json" \
|
||||
"${SUMMARY_JSON}" \
|
||||
"${cycles}" \
|
||||
> "${cycle_dir}/summary.env"
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
go_path, stack_path, launch_path, runs_path, scene_path, summary_path, cycles = sys.argv[1:8]
|
||||
|
||||
def load_json(path):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
go_data = load_json(go_path).get("data", {})
|
||||
stack_data = load_json(stack_path).get("data", {}).get("diagnosis", {})
|
||||
launch_data = load_json(launch_path).get("data", {})
|
||||
runs_data = load_json(runs_path).get("data", {})
|
||||
scene_data = load_json(scene_path).get("data", {})
|
||||
|
||||
issues = stack_data.get("issues") or []
|
||||
issue_codes = [str(item.get("code") or "") for item in issues if item.get("code")]
|
||||
problem_runs = runs_data.get("problem_runs") or []
|
||||
problem_run_code = ""
|
||||
if problem_runs:
|
||||
problem_run_code = str(problem_runs[0].get("run_code") or "")
|
||||
|
||||
summary = {
|
||||
"cycles": int(cycles),
|
||||
"go_live_status": str(go_data.get("go_live_status") or ""),
|
||||
"publish_ready": bool(go_data.get("publish_ready")),
|
||||
"log_sync_state": str(go_data.get("log_sync_state") or ""),
|
||||
"log_sync_missing_node_codes": go_data.get("log_sync_missing_node_codes") or [],
|
||||
"stack_status": str(stack_data.get("stack_status") or ""),
|
||||
"issue_total": int(stack_data.get("issue_total") or 0),
|
||||
"blocking_issue_total": int(stack_data.get("blocking_issue_total") or 0),
|
||||
"issue_codes": issue_codes,
|
||||
"launchpad_status": str((launch_data.get("launchpad_status") or {}).get("status") or ""),
|
||||
"launchpad_recommended_action": str((launch_data.get("launchpad_status") or {}).get("recommended_action_code") or ""),
|
||||
"problem_runs_total": int(runs_data.get("problem_runs_total") or 0),
|
||||
"problem_run_code": problem_run_code,
|
||||
"scene_status": str(scene_data.get("status") or ""),
|
||||
"scene_line_count": int((scene_data.get("source_summary") or {}).get("line_count") or 0),
|
||||
"generated_at": str(go_data.get("generated_at") or stack_data.get("generated_at") or ""),
|
||||
}
|
||||
|
||||
with open(summary_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(summary, fh, ensure_ascii=False, indent=2)
|
||||
|
||||
def emit(key, value):
|
||||
if isinstance(value, bool):
|
||||
value = "true" if value else "false"
|
||||
elif isinstance(value, list):
|
||||
value = ",".join(str(item) for item in value)
|
||||
else:
|
||||
value = str(value)
|
||||
print(f'{key}="{value}"')
|
||||
|
||||
for key, value in summary.items():
|
||||
emit(key.upper(), value)
|
||||
PY
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
source "${cycle_dir}/summary.env"
|
||||
last_go_live_status="${GO_LIVE_STATUS}"
|
||||
last_publish_ready="${PUBLISH_READY}"
|
||||
last_log_sync_state="${LOG_SYNC_STATE}"
|
||||
last_issue_total="${ISSUE_TOTAL}"
|
||||
last_problem_runs_total="${PROBLEM_RUNS_TOTAL}"
|
||||
last_launchpad_status="${LAUNCHPAD_STATUS}"
|
||||
last_launchpad_action="${LAUNCHPAD_RECOMMENDED_ACTION}"
|
||||
last_problem_run_code="${PROBLEM_RUN_CODE}"
|
||||
}
|
||||
|
||||
write_report() {
|
||||
python3 - <<'PY' \
|
||||
"${REPORT_FILE}" \
|
||||
"${RUN_ID}" \
|
||||
"${BASE_URL}" \
|
||||
"${DEADLINE_TEXT}" \
|
||||
"${stop_reason}" \
|
||||
"${cycles}" \
|
||||
"${log_sync_recover_runs}" \
|
||||
"${inspection_runs}" \
|
||||
"${inspection_churn_runs}" \
|
||||
"${quick_rechecks}" \
|
||||
"${last_go_live_status}" \
|
||||
"${last_publish_ready}" \
|
||||
"${last_log_sync_state}" \
|
||||
"${last_issue_total}" \
|
||||
"${last_problem_runs_total}" \
|
||||
"${last_launchpad_status}" \
|
||||
"${last_launchpad_action}" \
|
||||
"${last_problem_run_code}" \
|
||||
"${SUMMARY_JSON}"
|
||||
import json
|
||||
import sys
|
||||
|
||||
(
|
||||
report_path,
|
||||
run_id,
|
||||
base_url,
|
||||
deadline_text,
|
||||
stop_reason,
|
||||
cycles,
|
||||
log_sync_recover_runs,
|
||||
inspection_runs,
|
||||
inspection_churn_runs,
|
||||
quick_rechecks,
|
||||
last_go_live_status,
|
||||
last_publish_ready,
|
||||
last_log_sync_state,
|
||||
last_issue_total,
|
||||
last_problem_runs_total,
|
||||
last_launchpad_status,
|
||||
last_launchpad_action,
|
||||
last_problem_run_code,
|
||||
summary_json_path,
|
||||
) = sys.argv[1:20]
|
||||
|
||||
summary = {}
|
||||
try:
|
||||
with open(summary_json_path, "r", encoding="utf-8") as fh:
|
||||
summary = json.load(fh)
|
||||
except Exception:
|
||||
summary = {}
|
||||
|
||||
lines = [
|
||||
f"# NIGHT RUN REPORT {run_id}",
|
||||
"",
|
||||
f"- Base URL: `{base_url}`",
|
||||
f"- Deadline: `{deadline_text}`",
|
||||
f"- Stop Reason: `{stop_reason}`",
|
||||
f"- Cycles: `{cycles}`",
|
||||
f"- Log Sync Recover Runs: `{log_sync_recover_runs}`",
|
||||
f"- Inspection Runs: `{inspection_runs}`",
|
||||
f"- Inspection Churn Runs: `{inspection_churn_runs}`",
|
||||
f"- Quick Rechecks: `{quick_rechecks}`",
|
||||
"",
|
||||
"## Final Snapshot",
|
||||
"",
|
||||
f"- `go_live_status = {last_go_live_status}`",
|
||||
f"- `publish_ready = {last_publish_ready}`",
|
||||
f"- `log_sync_state = {last_log_sync_state}`",
|
||||
f"- `issue_total = {last_issue_total}`",
|
||||
f"- `problem_runs_total = {last_problem_runs_total}`",
|
||||
f"- `launchpad_status = {last_launchpad_status}`",
|
||||
f"- `launchpad_recommended_action = {last_launchpad_action}`",
|
||||
f"- `problem_run_code = {last_problem_run_code}`",
|
||||
"",
|
||||
"## Summary JSON",
|
||||
"",
|
||||
"```json",
|
||||
json.dumps(summary, ensure_ascii=False, indent=2),
|
||||
"```",
|
||||
]
|
||||
|
||||
with open(report_path, "w", encoding="utf-8") as fh:
|
||||
fh.write("\n".join(lines) + "\n")
|
||||
PY
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
write_report
|
||||
log "night run stopped: reason=${stop_reason}"
|
||||
log "report: ${REPORT_FILE}"
|
||||
rm -f "${PID_FILE}"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
log "night run started: run_id=${RUN_ID}"
|
||||
log "base_url=${BASE_URL}"
|
||||
log "deadline=${DEADLINE_TEXT}"
|
||||
log "state_dir=${STATE_DIR}"
|
||||
|
||||
while true; do
|
||||
now_epoch="$(date +%s)"
|
||||
if [[ "${now_epoch}" -ge "${DEADLINE_EPOCH}" ]]; then
|
||||
stop_reason="deadline_reached"
|
||||
break
|
||||
fi
|
||||
|
||||
cycles=$((cycles + 1))
|
||||
cycle_dir="${STATE_DIR}/cycle_${cycles}"
|
||||
snapshot_state "${cycle_dir}"
|
||||
|
||||
log "cycle=${cycles} go_live=${last_go_live_status} log_sync=${last_log_sync_state} issue_total=${last_issue_total} problem_runs=${last_problem_runs_total} launchpad=${last_launchpad_status}/${last_launchpad_action} focus_run=${last_problem_run_code}"
|
||||
|
||||
if [[ "${last_log_sync_state}" != "full_capture" ]]; then
|
||||
log "action: recover log sync coverage"
|
||||
run_cmd bash "${SCRIPT_DIR}/drive_ops_center.sh" log-sync-recover "${BASE_URL}" full confirm cli/night-pack || true
|
||||
log_sync_recover_runs=$((log_sync_recover_runs + 1))
|
||||
run_cmd bash "${SCRIPT_DIR}/drive_ops_center.sh" driver-run "${BASE_URL}" run_inspection_participating '{}' confirm cli/night-pack || true
|
||||
inspection_runs=$((inspection_runs + 1))
|
||||
quick_rechecks=$((quick_rechecks + 1))
|
||||
sleep 45
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "${last_issue_total}" != "0" && "${last_problem_runs_total}" != "0" && "${inspection_churn_runs}" -lt 3 ]]; then
|
||||
log "action: create fresh safe inspection run to dilute stale problem run window"
|
||||
run_cmd bash "${SCRIPT_DIR}/drive_ops_center.sh" driver-run "${BASE_URL}" run_inspection_participating '{}' confirm cli/night-pack || true
|
||||
inspection_runs=$((inspection_runs + 1))
|
||||
inspection_churn_runs=$((inspection_churn_runs + 1))
|
||||
sleep 1800
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "${last_issue_total}" == "0" && "${last_log_sync_state}" == "full_capture" && "${last_problem_runs_total}" == "0" ]]; then
|
||||
stop_reason="signoff_ready_candidate"
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 3600
|
||||
done
|
||||
@@ -26,5 +26,5 @@ SYNC_SOURCE_REGION=overseas
|
||||
SYNC_TARGET_REGION=overseas
|
||||
SYNC_TARGET_API_BASE_URL=
|
||||
SYNC_SHARED_TOKEN=
|
||||
SYNC_BATCH_SIZE=200
|
||||
SYNC_POLL_INTERVAL_SECONDS=30
|
||||
SYNC_BATCH_SIZE=5000
|
||||
SYNC_POLL_INTERVAL_SECONDS=2
|
||||
|
||||
@@ -11,5 +11,5 @@ API_SERVICE_NAME=domaincheck-api
|
||||
SYNC_AGENT_SERVICE_NAME=domaincheck-sync-agent
|
||||
NODE_AGENT_SERVICE_NAME=domaincheck-node-agent
|
||||
|
||||
OPS_AGENT_CAPABILITIES=["service.start","service.stop","service.restart","service.status","runtime.start_worker","runtime.stop_worker","runtime.start_detection","runtime.stop_detection","runtime.pull_tasks","runtime.restart_api","runtime.start_sync_agent","runtime.stop_sync_agent","health.snapshot","logs.collect","diagnostics.collect","deploy.release"]
|
||||
OPS_AGENT_CAPABILITIES=["service.start","service.stop","service.restart","service.status","runtime.start_worker","runtime.stop_worker","runtime.start_detection","runtime.stop_detection","runtime.pull_tasks","runtime.restart_api","runtime.start_sync_agent","runtime.stop_sync_agent","runtime.reset_lab_state","health.snapshot","logs.collect","diagnostics.collect","deploy.release"]
|
||||
OPS_AGENT_LABELS={}
|
||||
|
||||
@@ -23,5 +23,5 @@ SYNC_SOURCE_REGION=mainland
|
||||
SYNC_TARGET_REGION=overseas
|
||||
SYNC_TARGET_API_BASE_URL=http://海外控制面IP:8100/api/v1
|
||||
SYNC_SHARED_TOKEN=
|
||||
SYNC_BATCH_SIZE=200
|
||||
SYNC_POLL_INTERVAL_SECONDS=30
|
||||
SYNC_BATCH_SIZE=5000
|
||||
SYNC_POLL_INTERVAL_SECONDS=2
|
||||
|
||||
@@ -23,5 +23,5 @@ SYNC_SOURCE_REGION=mainland
|
||||
SYNC_TARGET_REGION=overseas
|
||||
SYNC_TARGET_API_BASE_URL=http://海外控制面IP:8100/api/v1
|
||||
SYNC_SHARED_TOKEN=
|
||||
SYNC_BATCH_SIZE=200
|
||||
SYNC_POLL_INTERVAL_SECONDS=30
|
||||
SYNC_BATCH_SIZE=5000
|
||||
SYNC_POLL_INTERVAL_SECONDS=2
|
||||
|
||||
@@ -6,13 +6,14 @@ After=network.target
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/domaincheck/domain-api
|
||||
EnvironmentFile=-/etc/default/domaincheck-api
|
||||
EnvironmentFile=-/etc/default/domaincheck-worker
|
||||
EnvironmentFile=-/etc/default/domaincheck-node-agent
|
||||
Environment="PATH=/home/www/.nvm/versions/node/v20.20.2/bin:/home/www/.local/bin:/home/www/bin:/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin"
|
||||
ExecStart=/opt/domaincheck/domainCheck/.venv/bin/python -m app.node_agent
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
User=www
|
||||
Group=www
|
||||
User=root
|
||||
Group=root
|
||||
SupplementaryGroups=systemd-journal
|
||||
|
||||
[Install]
|
||||
|
||||
93
domain-api/tests/test_cluster_runtime_service.py
Normal file
93
domain-api/tests/test_cluster_runtime_service.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.services import cluster_runtime_service
|
||||
|
||||
|
||||
class RuntimeSchemaGuardTests(unittest.TestCase):
|
||||
def test_ensure_runtime_schema_executes_only_once_per_process(self) -> None:
|
||||
conn = MagicMock()
|
||||
cursor_cm = MagicMock()
|
||||
cursor = MagicMock()
|
||||
conn.cursor.return_value = cursor_cm
|
||||
cursor_cm.__enter__.return_value = cursor
|
||||
db_cm = MagicMock()
|
||||
db_cm.__enter__.return_value = conn
|
||||
|
||||
with patch.object(cluster_runtime_service, "_RUNTIME_SCHEMA_READY", False):
|
||||
with patch.object(cluster_runtime_service, "get_db", return_value=db_cm) as mocked_get_db:
|
||||
cluster_runtime_service.ensure_runtime_schema()
|
||||
cluster_runtime_service.ensure_runtime_schema()
|
||||
|
||||
mocked_get_db.assert_called_once()
|
||||
self.assertEqual(cursor.execute.call_count, 2)
|
||||
cursor.execute.assert_any_call(
|
||||
"SELECT pg_advisory_xact_lock(%s)",
|
||||
(cluster_runtime_service._RUNTIME_SCHEMA_ADVISORY_LOCK_ID,),
|
||||
)
|
||||
cursor.execute.assert_any_call(cluster_runtime_service._RUNTIME_SCHEMA_SQL)
|
||||
conn.commit.assert_called_once()
|
||||
|
||||
def test_control_node_supports_worker_only_on_mainland_with_worker_signals(self) -> None:
|
||||
self.assertFalse(
|
||||
cluster_runtime_service._control_node_supports_worker(
|
||||
region="overseas",
|
||||
metadata={
|
||||
"worker_online": False,
|
||||
"detect_participating": True,
|
||||
"active_threads": 0,
|
||||
"max_threads": 0,
|
||||
},
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
cluster_runtime_service._control_node_supports_worker(
|
||||
region="mainland",
|
||||
metadata={
|
||||
"worker_online": True,
|
||||
"detect_participating": False,
|
||||
"active_threads": 0,
|
||||
"max_threads": 0,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def test_metadata_idle_without_runtime_work_detects_stale_idle_heartbeat(self) -> None:
|
||||
self.assertTrue(
|
||||
cluster_runtime_service._metadata_idle_without_runtime_work(
|
||||
{
|
||||
"phase_label": "idle",
|
||||
"phase_detail": "Worker 已启动,等待检测指令",
|
||||
"active_threads": 323,
|
||||
"max_threads": 4,
|
||||
"active_job_code": "",
|
||||
"job_items_total": 0,
|
||||
"job_items_claimed": 0,
|
||||
"job_items_running": 0,
|
||||
"job_items_completed": 0,
|
||||
"job_items_failed": 0,
|
||||
}
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
cluster_runtime_service._metadata_idle_without_runtime_work(
|
||||
{
|
||||
"phase_label": "running",
|
||||
"active_threads": 12,
|
||||
}
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
cluster_runtime_service._metadata_idle_without_runtime_work(
|
||||
{
|
||||
"phase_label": "idle",
|
||||
"active_job_code": "sync-overseas-1",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
34
domain-api/tests/test_core_db_retry.py
Normal file
34
domain-api/tests/test_core_db_retry.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from psycopg2 import errors
|
||||
|
||||
from app.core.db import db_read_retry
|
||||
|
||||
|
||||
def test_db_read_retry_retries_retryable_error_once() -> None:
|
||||
attempts = {"count": 0}
|
||||
|
||||
@db_read_retry(attempts=3, initial_delay_seconds=0)
|
||||
def flaky() -> str:
|
||||
attempts["count"] += 1
|
||||
if attempts["count"] == 1:
|
||||
raise errors.DeadlockDetected()
|
||||
return "ok"
|
||||
|
||||
assert flaky() == "ok"
|
||||
assert attempts["count"] == 2
|
||||
|
||||
|
||||
def test_db_read_retry_does_not_swallow_non_retryable_error() -> None:
|
||||
attempts = {"count": 0}
|
||||
|
||||
@db_read_retry(attempts=3, initial_delay_seconds=0)
|
||||
def broken() -> str:
|
||||
attempts["count"] += 1
|
||||
raise ValueError("boom")
|
||||
|
||||
try:
|
||||
broken()
|
||||
except ValueError as exc:
|
||||
assert str(exc) == "boom"
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
assert attempts["count"] == 1
|
||||
32
domain-api/tests/test_core_files_runtime_root.py
Normal file
32
domain-api/tests/test_core_files_runtime_root.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.core import files
|
||||
|
||||
|
||||
class RuntimeRootTests(unittest.TestCase):
|
||||
def test_runtime_root_prefers_shared_runtime_outside_release_tree(self) -> None:
|
||||
fake_file = "/opt/domaincheck/releases/domaincheck_release_20260420_222047/domain-api/app/core/files.py"
|
||||
with patch.object(files, "__file__", fake_file):
|
||||
with patch.object(Path, "mkdir", autospec=True, return_value=None) as mocked_mkdir:
|
||||
runtime_root = files.runtime_root()
|
||||
|
||||
self.assertEqual(Path("/opt/domaincheck/runtime/domain-api"), runtime_root)
|
||||
mocked_mkdir.assert_called_once()
|
||||
|
||||
def test_runtime_root_respects_env_override(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
override = Path(tempdir) / "custom-runtime"
|
||||
with patch.dict(os.environ, {"DOMAIN_API_RUNTIME_ROOT": str(override)}, clear=False):
|
||||
runtime_root = files.runtime_root()
|
||||
self.assertEqual(override, runtime_root)
|
||||
self.assertTrue(runtime_root.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
237
domain-api/tests/test_dashboard_service.py
Normal file
237
domain-api/tests/test_dashboard_service.py
Normal file
@@ -0,0 +1,237 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.services.dashboard import fetch_overview
|
||||
|
||||
|
||||
class _FakeCursor:
|
||||
def __init__(self, responses):
|
||||
self._responses = list(responses)
|
||||
|
||||
def execute(self, sql, params=None):
|
||||
self._last_sql = sql
|
||||
self._last_params = params
|
||||
|
||||
def fetchone(self):
|
||||
if self._responses:
|
||||
return self._responses.pop(0)
|
||||
return (0,)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
class _FakeConnection:
|
||||
def __init__(self, responses):
|
||||
self._cursor = _FakeCursor(responses)
|
||||
|
||||
def cursor(self):
|
||||
return self._cursor
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
class DashboardServiceTests(unittest.TestCase):
|
||||
@patch("app.services.dashboard._fetch_active_jobs_aggregate")
|
||||
@patch("app.services.dashboard.get_detect_capacity_plan")
|
||||
@patch("app.services.dashboard.get_detect_queue_health")
|
||||
@patch("app.services.dashboard.get_runtime_status")
|
||||
@patch("app.services.dashboard.get_active_detect_job_summary")
|
||||
@patch("app.services.dashboard.get_db")
|
||||
def test_fetch_overview_includes_ops_metrics(
|
||||
self,
|
||||
mock_get_db,
|
||||
mock_get_active_detect_job_summary,
|
||||
mock_get_runtime_status,
|
||||
mock_get_detect_queue_health,
|
||||
mock_get_detect_capacity_plan,
|
||||
mock_fetch_active_jobs_aggregate,
|
||||
) -> None:
|
||||
mock_get_db.return_value = _FakeConnection(
|
||||
responses=[
|
||||
(1000,),
|
||||
(900,),
|
||||
(10,),
|
||||
(5,),
|
||||
(0,),
|
||||
(1,),
|
||||
(430,),
|
||||
(420,),
|
||||
(17,),
|
||||
]
|
||||
)
|
||||
mock_fetch_active_jobs_aggregate.return_value = {
|
||||
"active_jobs_total": 3,
|
||||
"queue": {
|
||||
"items_total": 5200,
|
||||
"pending": 4700,
|
||||
"claimed": 11,
|
||||
"running": 165,
|
||||
"completed": 300,
|
||||
"blacklisted": 12,
|
||||
"failed": 7,
|
||||
},
|
||||
"throughput": {
|
||||
"processed_recent": 30,
|
||||
"processed_per_minute": 2.0,
|
||||
"completed_recent": 20,
|
||||
"blacklisted_recent": 1,
|
||||
"failed_recent": 2,
|
||||
},
|
||||
"retry_total": 12,
|
||||
"steps": [
|
||||
{
|
||||
"step_code": "detect_register",
|
||||
"step_name": "注册状态检测",
|
||||
"items_pending": 185,
|
||||
"items_running": 3,
|
||||
"items_claimed": 0,
|
||||
"items_completed": 7,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
"processed_recent": 20,
|
||||
"processed_per_minute": 1.33,
|
||||
}
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"node_code": "mainland-controller-01",
|
||||
"items_running": 120,
|
||||
"items_claimed": 0,
|
||||
"processed_recent": 20,
|
||||
"processed_per_minute": 1.33,
|
||||
"completed_recent": 15,
|
||||
"failed_recent": 1,
|
||||
"blacklisted_recent": 0,
|
||||
}
|
||||
],
|
||||
}
|
||||
mock_get_active_detect_job_summary.return_value = {
|
||||
"job_id": 76,
|
||||
"job_code": "sync-overseas-3612",
|
||||
"items_pending": 185,
|
||||
"items_running": 8,
|
||||
"items_completed": 7,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
}
|
||||
mock_get_runtime_status.return_value = {
|
||||
"worker": {"running": True, "mode": "linux-systemd", "expected_on_this_node": True},
|
||||
"node": {"region": "overseas", "role": "control"},
|
||||
"cluster": {"summary": {"online_worker_nodes": 2, "dedicated_online_worker_nodes": 1, "online_control_nodes": 1}},
|
||||
"detect": {
|
||||
"backlog": {
|
||||
"pending_total": 9438,
|
||||
"claimed_total": 410,
|
||||
"running_total": 15,
|
||||
"completed_total": 951,
|
||||
"blacklisted_total": 0,
|
||||
"failed_total": 137,
|
||||
"register_pending": 8487,
|
||||
"downstream_pending": 951,
|
||||
}
|
||||
},
|
||||
}
|
||||
mock_get_detect_queue_health.return_value = {
|
||||
"has_active_job": True,
|
||||
"job": {"job_id": 76, "job_code": "sync-overseas-3612", "status": "running", "progress_percent": 3.5},
|
||||
"queue": {
|
||||
"items_total": 200,
|
||||
"pending": 185,
|
||||
"claimed": 0,
|
||||
"display_claimed": 0,
|
||||
"running": 8,
|
||||
"display_running": 165,
|
||||
"completed": 7,
|
||||
"blacklisted": 0,
|
||||
"failed": 0,
|
||||
},
|
||||
"throughput": {"processed_recent": 0, "processed_per_minute": 0},
|
||||
"steps": [
|
||||
{
|
||||
"step_code": "detect_register",
|
||||
"step_name": "注册状态检测",
|
||||
"items_pending": 185,
|
||||
"items_running": 165,
|
||||
"items_claimed": 0,
|
||||
"items_completed": 7,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
"processed_recent": 0,
|
||||
"processed_per_minute": 0.0,
|
||||
}
|
||||
],
|
||||
"runtime_activity": {
|
||||
"step_stats": {
|
||||
"detect_360_site": {
|
||||
"step_code": "detect_360_site",
|
||||
"started_recent": 269,
|
||||
"processed_recent": 6,
|
||||
"completed_recent": 6,
|
||||
"failed_recent": 0,
|
||||
"blacklisted_recent": 0,
|
||||
}
|
||||
}
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"node_code": "mainland-controller-01",
|
||||
"items_running": 165,
|
||||
"items_claimed": 0,
|
||||
"processed_recent": 0,
|
||||
"processed_per_minute": 0.0,
|
||||
"completed_recent": 0,
|
||||
"failed_recent": 0,
|
||||
"blacklisted_recent": 0,
|
||||
}
|
||||
],
|
||||
}
|
||||
mock_get_detect_capacity_plan.return_value = {
|
||||
"estimated_hours_remaining": 1.5,
|
||||
"remaining_items": 193,
|
||||
"recommended_additional_workers": 1,
|
||||
}
|
||||
|
||||
data = fetch_overview()
|
||||
|
||||
self.assertEqual("sync-overseas-3612", data["active_job"]["job_code"])
|
||||
self.assertEqual(3, data["active_jobs_aggregate"]["active_jobs_total"])
|
||||
self.assertEqual(9438, data["active_jobs_aggregate"]["queue"]["pending"])
|
||||
self.assertEqual(410, data["active_jobs_aggregate"]["queue"]["claimed"])
|
||||
self.assertEqual(15, data["active_jobs_aggregate"]["queue"]["running"])
|
||||
self.assertEqual(951, data["active_jobs_aggregate"]["queue"]["completed"])
|
||||
self.assertEqual(137, data["active_jobs_aggregate"]["queue"]["failed"])
|
||||
self.assertEqual("mainland-controller-01", data["active_jobs_aggregate"]["nodes"][0]["node_code"])
|
||||
self.assertEqual(900, data["pending_total"])
|
||||
self.assertEqual(10, data["completed_total"])
|
||||
self.assertEqual(430, data["registerable_total"])
|
||||
self.assertEqual(420, data["purchasable_total"])
|
||||
self.assertEqual(185, data["queue_pending_total"])
|
||||
self.assertEqual(8, data["queue_running_total"])
|
||||
self.assertEqual(165, data["queue_display_running_total"])
|
||||
self.assertEqual(7, data["queue_completed_total"])
|
||||
self.assertEqual(9438, data["backlog_pending_total"])
|
||||
self.assertEqual(8487, data["backlog_register_pending_total"])
|
||||
self.assertEqual(951, data["backlog_downstream_pending_total"])
|
||||
self.assertEqual(12, data["retry_total"])
|
||||
self.assertEqual("注册状态检测", data["bottleneck_step"]["step_name"])
|
||||
self.assertEqual(0.0, data["processed_per_minute"])
|
||||
self.assertEqual(0, data["processed_recent"])
|
||||
self.assertEqual(0.0, data["ops_summary"]["processed_per_minute"])
|
||||
self.assertEqual(1.5, data["ops_summary"]["estimated_hours_remaining"])
|
||||
self.assertEqual(1, data["active_execution_nodes"])
|
||||
self.assertEqual(1, data["ops_summary"]["active_execution_nodes"])
|
||||
self.assertEqual(2, len(data["step_queue"]))
|
||||
self.assertTrue(any(item["step_code"] == "detect_360_site" for item in data["step_queue"]))
|
||||
self.assertEqual(1, len(data["node_throughput"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
75
domain-api/tests/test_detect_api_routes.py
Normal file
75
domain-api/tests/test_detect_api_routes.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.api.routes import detect as detect_route
|
||||
|
||||
|
||||
class DetectApiRoutesTestCase(unittest.TestCase):
|
||||
@patch("app.api.routes.detect.create_detect_run_snapshot")
|
||||
@patch("app.api.routes.detect._dispatch_remote_detect_start")
|
||||
@patch("app.api.routes.detect.get_settings_payload")
|
||||
@patch("app.api.routes.detect.get_detect_status")
|
||||
@patch("app.api.routes.detect.send_worker_command")
|
||||
@patch("app.api.routes.detect.start_worker")
|
||||
@patch("app.api.routes.detect.append_detect_job_event")
|
||||
@patch("app.api.routes.detect.create_detect_job_if_needed")
|
||||
def test_start_detect_skips_local_worker_on_overseas_control(
|
||||
self,
|
||||
mock_create_job,
|
||||
mock_append_event,
|
||||
mock_start_worker,
|
||||
mock_send_worker_command,
|
||||
mock_get_detect_status,
|
||||
mock_get_settings_payload,
|
||||
mock_dispatch_remote,
|
||||
mock_create_snapshot,
|
||||
) -> None:
|
||||
mock_create_job.return_value = {
|
||||
"job_id": 29,
|
||||
"job_code": "sync-overseas-29",
|
||||
"status": "running",
|
||||
"items_pending": 100,
|
||||
"items_claimed": 0,
|
||||
"items_running": 0,
|
||||
"task_mode": "single_step",
|
||||
"step_code": "detect_register",
|
||||
}
|
||||
mock_get_detect_status.return_value = {
|
||||
"worker_mode": "linux-systemd",
|
||||
"worker_online": False,
|
||||
"worker_process_count": 0,
|
||||
"worker_latest_start_time": "",
|
||||
"worker_runtime_message": "not-applicable",
|
||||
"progress": {},
|
||||
}
|
||||
mock_get_settings_payload.return_value = {
|
||||
"runtime": {"thread_count": 2000},
|
||||
"proxy_config": {"proxy_enable": True, "allow_direct": False, "proxy_urls": ["a"]},
|
||||
}
|
||||
mock_dispatch_remote.return_value = {
|
||||
"queued_jobs": [{"node_code": "mainland-controller-01"}],
|
||||
"queued_total": 1,
|
||||
"failed_total": 0,
|
||||
"target_summary": {"controller_nodes": ["mainland-controller-01"], "worker_nodes": ["mainland-worker-01"]},
|
||||
}
|
||||
|
||||
with patch.object(detect_route.settings, "node_region", "overseas"), patch.object(
|
||||
detect_route.settings, "node_role", "control"
|
||||
):
|
||||
response = detect_route.start_detect()
|
||||
|
||||
self.assertEqual(0, response.code)
|
||||
self.assertIn("海外控制面", response.message)
|
||||
mock_start_worker.assert_not_called()
|
||||
mock_send_worker_command.assert_not_called()
|
||||
mock_dispatch_remote.assert_called_once()
|
||||
mock_create_snapshot.assert_called_once()
|
||||
event_types = [call.kwargs.get("event_type") for call in mock_append_event.call_args_list]
|
||||
self.assertIn("job_dispatch_requested", event_types)
|
||||
self.assertIn("job_dispatch_skipped_local", event_types)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,121 +1,791 @@
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.services.detect_job_service import _build_effective_node_stats, _build_effective_summary
|
||||
from psycopg2 import errors
|
||||
|
||||
from app.services.detect_job_service import (
|
||||
append_detect_job_event,
|
||||
_build_step_payload,
|
||||
_classify_pipeline_item_outcome,
|
||||
_classify_runtime_debug_event,
|
||||
_enrich_active_job_summary_with_runtime,
|
||||
_build_display_summary,
|
||||
_build_runtime_display_bucket,
|
||||
normalize_detect_step_code,
|
||||
get_detect_queue_health,
|
||||
process_detect_pipeline_now,
|
||||
resolve_initial_domain_pipeline_item,
|
||||
resolve_domain_pipeline_step,
|
||||
resolve_detect_job_definition,
|
||||
)
|
||||
|
||||
|
||||
class DetectJobServiceTests(unittest.TestCase):
|
||||
def test_build_effective_node_stats_keeps_runtime_nodes_and_backfills_unassigned(self) -> None:
|
||||
distributed_node_stats = [
|
||||
{
|
||||
"node_code": "mainland-controller-01",
|
||||
"items_total": 200,
|
||||
"items_pending": 195,
|
||||
"items_claimed": 0,
|
||||
"items_running": 5,
|
||||
"items_completed": 0,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
},
|
||||
{
|
||||
"node_code": "mainland-worker-01",
|
||||
"items_total": 70,
|
||||
"items_pending": 56,
|
||||
"items_claimed": 9,
|
||||
"items_running": 5,
|
||||
"items_completed": 0,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
},
|
||||
{
|
||||
"node_code": "overseas-control-01",
|
||||
"items_total": 50,
|
||||
"items_pending": 0,
|
||||
"items_claimed": 25,
|
||||
"items_running": 4,
|
||||
"items_completed": 21,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
},
|
||||
{
|
||||
"node_code": "unassigned",
|
||||
"items_total": 950,
|
||||
"items_pending": 950,
|
||||
"items_claimed": 0,
|
||||
"items_running": 0,
|
||||
"items_completed": 0,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
},
|
||||
]
|
||||
def test_append_detect_job_event_skips_missing_job_fk(self) -> None:
|
||||
class FakeCursor:
|
||||
def __init__(self) -> None:
|
||||
self.exec_calls = []
|
||||
self.selects = 0
|
||||
|
||||
node_stats = _build_effective_node_stats(
|
||||
distributed_node_stats=distributed_node_stats,
|
||||
raw_items_total=1000,
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def execute(self, sql, params=None):
|
||||
self.exec_calls.append((sql, params))
|
||||
if "INSERT INTO detect_run_events" in sql:
|
||||
raise AssertionError("should not insert detect_run_events when job row is missing")
|
||||
if "SELECT 1 FROM detect_jobs" in sql:
|
||||
self.selects += 1
|
||||
|
||||
def fetchone(self):
|
||||
return None
|
||||
|
||||
class FakeConn:
|
||||
def __init__(self) -> None:
|
||||
self.cursor_obj = FakeCursor()
|
||||
self.commit_calls = 0
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def cursor(self):
|
||||
return self.cursor_obj
|
||||
|
||||
def commit(self):
|
||||
self.commit_calls += 1
|
||||
|
||||
fake_conn = FakeConn()
|
||||
|
||||
with patch("app.services.detect_job_service.get_db", return_value=fake_conn):
|
||||
with patch("app.services.detect_job_service.push_debug_event") as mock_push:
|
||||
append_detect_job_event(
|
||||
5,
|
||||
event_type="job_dispatch_requested",
|
||||
message="控制面已发送检测启动请求",
|
||||
)
|
||||
|
||||
self.assertEqual(1, fake_conn.cursor_obj.selects)
|
||||
self.assertEqual(0, fake_conn.commit_calls)
|
||||
mock_push.assert_called_once()
|
||||
|
||||
def test_process_detect_pipeline_now_retries_deadlock_once(self) -> None:
|
||||
calls = {"count": 0}
|
||||
|
||||
def _run_pipeline(*, limit, job_id):
|
||||
calls["count"] += 1
|
||||
if calls["count"] == 1:
|
||||
raise errors.DeadlockDetected()
|
||||
return {
|
||||
"processed_items": 7,
|
||||
"advanced_items": 3,
|
||||
"retried_items": 1,
|
||||
}
|
||||
|
||||
with patch("app.services.detect_job_service.process_detect_pipeline", side_effect=_run_pipeline):
|
||||
with patch("app.services.detect_job_service.time.sleep") as mock_sleep:
|
||||
ok, message, data = process_detect_pipeline_now(limit=123, job_id=45)
|
||||
|
||||
self.assertTrue(ok)
|
||||
self.assertIn("deadlock 自动重试 1 次后成功", message)
|
||||
self.assertEqual(1, data["retry_attempts"])
|
||||
self.assertEqual(7, data["processed_items"])
|
||||
self.assertEqual(2, calls["count"])
|
||||
mock_sleep.assert_called_once()
|
||||
|
||||
def test_classify_runtime_debug_event_maps_single_step_finalized_completed(self) -> None:
|
||||
classified = _classify_runtime_debug_event(
|
||||
event_type="worker_log",
|
||||
message="检测步骤跟踪: domain=example.com | step=注册状态检测 | stage=single_step_finalized | elapsed_ms=2700 | ok=1 | detect_key=detect_register | final_status=completed | result_state=passed",
|
||||
payload={"job_code": "sync-overseas-27456", "node_code": "mainland-worker-01"},
|
||||
)
|
||||
|
||||
self.assertEqual(4, len(node_stats))
|
||||
unassigned = next(item for item in node_stats if item["node_code"] == "unassigned")
|
||||
self.assertEqual(680, unassigned["items_total"])
|
||||
self.assertEqual(680, unassigned["items_pending"])
|
||||
self.assertEqual("central_queue", unassigned["metrics_source"])
|
||||
self.assertEqual("sync-overseas-27456", classified["job_code"])
|
||||
self.assertTrue(classified["terminal"])
|
||||
self.assertEqual("completed", classified["terminal_status"])
|
||||
self.assertEqual("detect_register", classified["step_code"])
|
||||
|
||||
def test_build_effective_summary_uses_effective_node_stats_and_raw_blacklisted(self) -> None:
|
||||
node_stats = [
|
||||
{
|
||||
"node_code": "mainland-controller-01",
|
||||
"items_total": 200,
|
||||
"items_pending": 195,
|
||||
"items_claimed": 0,
|
||||
"items_running": 5,
|
||||
"items_completed": 0,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
},
|
||||
{
|
||||
"node_code": "mainland-worker-01",
|
||||
"items_total": 70,
|
||||
"items_pending": 56,
|
||||
"items_claimed": 9,
|
||||
"items_running": 5,
|
||||
"items_completed": 0,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
},
|
||||
{
|
||||
"node_code": "overseas-control-01",
|
||||
"items_total": 50,
|
||||
"items_pending": 0,
|
||||
"items_claimed": 25,
|
||||
"items_running": 4,
|
||||
"items_completed": 21,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
},
|
||||
{
|
||||
"node_code": "unassigned",
|
||||
"items_total": 680,
|
||||
"items_pending": 680,
|
||||
"items_claimed": 0,
|
||||
"items_running": 0,
|
||||
"items_completed": 0,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
},
|
||||
]
|
||||
|
||||
summary = _build_effective_summary(
|
||||
node_stats=node_stats,
|
||||
raw_items_total=1000,
|
||||
raw_items_blacklisted=3,
|
||||
def test_build_runtime_display_bucket_prefers_real_active_threads_over_raw_current_load(self) -> None:
|
||||
bucket = _build_runtime_display_bucket(
|
||||
(
|
||||
"mainland-worker-01",
|
||||
"mainland",
|
||||
"worker",
|
||||
"busy",
|
||||
753,
|
||||
{
|
||||
"job_items_total": 1000,
|
||||
"job_items_claimed": 0,
|
||||
"job_items_running": 0,
|
||||
"job_items_completed": 342,
|
||||
"active_threads": 61,
|
||||
"max_threads": 400,
|
||||
"detect_participating": True,
|
||||
},
|
||||
None,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertIsNotNone(bucket)
|
||||
self.assertEqual(61, bucket["current_load"])
|
||||
self.assertEqual(61, bucket["display_running"])
|
||||
self.assertEqual(61, bucket["active_threads"])
|
||||
|
||||
def test_build_runtime_display_bucket_skips_local_overseas_control_plane_load(self) -> None:
|
||||
with patch("app.services.detect_job_service.settings.node_region", "overseas"):
|
||||
with patch("app.services.detect_job_service.settings.node_role", "control"):
|
||||
with patch("app.services.detect_job_service.settings.node_code", "overseas-control-01"):
|
||||
bucket = _build_runtime_display_bucket(
|
||||
(
|
||||
"overseas-control-01",
|
||||
"overseas",
|
||||
"control",
|
||||
"busy",
|
||||
371,
|
||||
{
|
||||
"active_threads": 371,
|
||||
"max_threads": 3200,
|
||||
"detect_participating": False,
|
||||
},
|
||||
None,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertIsNone(bucket)
|
||||
|
||||
def test_build_runtime_display_bucket_skips_any_control_node_without_worker_support(self) -> None:
|
||||
bucket = _build_runtime_display_bucket(
|
||||
(
|
||||
"overseas-control-01",
|
||||
"overseas",
|
||||
"control",
|
||||
"busy",
|
||||
378,
|
||||
{
|
||||
"active_threads": 378,
|
||||
"max_threads": 3200,
|
||||
"detect_participating": False,
|
||||
"worker_online": False,
|
||||
},
|
||||
None,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertIsNone(bucket)
|
||||
|
||||
def test_build_display_summary_ignores_inflated_raw_current_load(self) -> None:
|
||||
summary = _build_display_summary(
|
||||
[
|
||||
{
|
||||
"node_code": "mainland-controller-01",
|
||||
"items_claimed": 0,
|
||||
"items_running": 0,
|
||||
"display_running": 729,
|
||||
"current_load": 729,
|
||||
"active_threads": 729,
|
||||
"max_threads": 800,
|
||||
"items_completed": 196,
|
||||
"items_failed": 0,
|
||||
},
|
||||
{
|
||||
"node_code": "mainland-worker-01",
|
||||
"items_claimed": 0,
|
||||
"items_running": 0,
|
||||
"display_running": 753,
|
||||
"current_load": 753,
|
||||
"active_threads": 61,
|
||||
"max_threads": 400,
|
||||
"items_completed": 146,
|
||||
"items_failed": 0,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(790, summary["display_running"])
|
||||
self.assertEqual(790, summary["current_load"])
|
||||
self.assertEqual(790, summary["active_threads"])
|
||||
|
||||
def test_enrich_active_job_summary_with_runtime_adds_runtime_job_code_and_recent_events(self) -> None:
|
||||
summary = _enrich_active_job_summary_with_runtime(
|
||||
{
|
||||
"job_id": 275,
|
||||
"job_code": "sync-overseas-5297",
|
||||
"processed_recent": 0,
|
||||
"processed_per_minute": 0,
|
||||
"completed_recent": 0,
|
||||
"failed_recent": 0,
|
||||
"blacklisted_recent": 0,
|
||||
"recent_domain_events": [],
|
||||
},
|
||||
event_limit=20,
|
||||
window_minutes=15,
|
||||
runtime_activity={
|
||||
"focus_job_code": "sync-overseas-9506",
|
||||
"job_codes": ["sync-overseas-9506", "sync-overseas-27456"],
|
||||
"processed_recent": 120,
|
||||
"completed_recent": 118,
|
||||
"failed_recent": 1,
|
||||
"blacklisted_recent": 1,
|
||||
},
|
||||
runtime_snapshot={
|
||||
"job": {
|
||||
"job_id": 1835,
|
||||
"job_code": "sync-overseas-9506",
|
||||
"progress_percent": 44.2,
|
||||
},
|
||||
"queue_health": {
|
||||
"queue": {
|
||||
"items_total": 1000,
|
||||
"pending": 176,
|
||||
"claimed": 223,
|
||||
"running": 159,
|
||||
"completed": 442,
|
||||
"display_running": 220,
|
||||
}
|
||||
},
|
||||
},
|
||||
recent_domain_events=[
|
||||
{
|
||||
"event_type": "domain_completed",
|
||||
"message": "域名检测完成: example.com",
|
||||
"created_at": "2026-04-21 04:10:00",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual("sync-overseas-9506", summary["runtime_job_code"])
|
||||
self.assertEqual(["sync-overseas-9506", "sync-overseas-27456"], summary["runtime_job_codes"])
|
||||
self.assertEqual(120, summary["processed_recent"])
|
||||
self.assertEqual(8.0, summary["processed_per_minute"])
|
||||
self.assertEqual(118, summary["completed_recent"])
|
||||
self.assertEqual(1, summary["failed_recent"])
|
||||
self.assertEqual(1, summary["blacklisted_recent"])
|
||||
self.assertEqual(1, len(summary["recent_domain_events"]))
|
||||
self.assertEqual("sync-overseas-9506", summary["runtime_snapshot_job_code"])
|
||||
self.assertEqual(1000, summary["runtime_snapshot_queue"]["items_total"])
|
||||
self.assertEqual(1000, summary["items_total"])
|
||||
self.assertEqual(931, summary["items_pending"])
|
||||
self.assertEqual(34, summary["items_claimed"])
|
||||
self.assertEqual(14, summary["items_running"])
|
||||
self.assertEqual(21, summary["items_completed"])
|
||||
self.assertEqual(3, summary["items_blacklisted"])
|
||||
self.assertEqual(24, summary["items_terminal"])
|
||||
self.assertEqual(176, summary["items_pending"])
|
||||
self.assertEqual(223, summary["items_claimed"])
|
||||
self.assertEqual(159, summary["items_running"])
|
||||
self.assertEqual(442, summary["items_completed"])
|
||||
self.assertEqual(220, summary["display_items_running"])
|
||||
|
||||
def test_enrich_active_job_summary_with_runtime_prefers_snapshot_identity_and_events(self) -> None:
|
||||
summary = _enrich_active_job_summary_with_runtime(
|
||||
{
|
||||
"job_id": 275,
|
||||
"job_code": "sync-overseas-5297",
|
||||
"status": "running",
|
||||
"recent_events": [
|
||||
{
|
||||
"node_code": "mainland-worker-01",
|
||||
"event_type": "domain_started",
|
||||
"message": "开始检测域名: stale.com",
|
||||
"payload": {"job_code": "sync-overseas-5297"},
|
||||
"created_at": "2026-04-21 11:58:00",
|
||||
}
|
||||
],
|
||||
"current_cycle_events": [],
|
||||
"latest_event": None,
|
||||
"display_items_running": 0,
|
||||
"display_current_load": 0,
|
||||
"display_active_threads": 0,
|
||||
"display_max_threads": 0,
|
||||
"display_active_node_codes": [],
|
||||
},
|
||||
event_limit=10,
|
||||
window_minutes=15,
|
||||
runtime_activity={
|
||||
"focus_job_code": "sync-overseas-31437",
|
||||
"job_codes": ["sync-overseas-31437"],
|
||||
"processed_recent": 10,
|
||||
"completed_recent": 10,
|
||||
"failed_recent": 0,
|
||||
"blacklisted_recent": 0,
|
||||
},
|
||||
runtime_snapshot={
|
||||
"job": {
|
||||
"job_id": 1902,
|
||||
"job_code": "sync-overseas-31437",
|
||||
"status": "running",
|
||||
"progress_percent": 34.3,
|
||||
"node_stats": [
|
||||
{
|
||||
"node_code": "mainland-controller-01",
|
||||
"items_claimed": 230,
|
||||
"items_running": 67,
|
||||
"items_completed": 343,
|
||||
"items_failed": 0,
|
||||
"active_threads": 170,
|
||||
"max_threads": 2000,
|
||||
},
|
||||
{
|
||||
"node_code": "mainland-worker-01",
|
||||
"items_claimed": 75,
|
||||
"items_running": 0,
|
||||
"items_completed": 0,
|
||||
"items_failed": 0,
|
||||
"active_threads": 19,
|
||||
"max_threads": 1200,
|
||||
},
|
||||
],
|
||||
},
|
||||
"queue_health": {
|
||||
"queue": {
|
||||
"items_total": 1000,
|
||||
"pending": 285,
|
||||
"claimed": 305,
|
||||
"running": 67,
|
||||
"completed": 343,
|
||||
"display_running": 189,
|
||||
}
|
||||
},
|
||||
"recent_events": [
|
||||
{
|
||||
"job_id": 2137,
|
||||
"node_code": "mainland-controller-01",
|
||||
"event_type": "job_created",
|
||||
"message": "同步拉取待检测批次 sync-overseas-35461,共 1000 个任务项",
|
||||
"payload": {"source_record_id": 35461},
|
||||
"created_at": "2026-04-22 01:21:29",
|
||||
},
|
||||
{
|
||||
"job_id": 1902,
|
||||
"node_code": "mainland-controller-01",
|
||||
"event_type": "worker_log",
|
||||
"message": "从任务队列获取到 125 个需要检测的域名",
|
||||
"payload": {"job_code": "sync-overseas-31437"},
|
||||
"created_at": "2026-04-22 01:19:25",
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(1902, summary["job_id"])
|
||||
self.assertEqual("sync-overseas-31437", summary["job_code"])
|
||||
self.assertEqual("sync-overseas-31437", summary["runtime_job_code"])
|
||||
self.assertEqual(189, summary["display_items_running"])
|
||||
self.assertEqual(189, summary["display_current_load"])
|
||||
self.assertEqual(189, summary["display_active_threads"])
|
||||
self.assertEqual(3200, summary["display_max_threads"])
|
||||
self.assertEqual(["mainland-controller-01", "mainland-worker-01"], summary["display_active_node_codes"])
|
||||
self.assertEqual(1, len(summary["recent_events"]))
|
||||
self.assertEqual("worker_log", summary["latest_event"]["event_type"])
|
||||
self.assertEqual("sync-overseas-31437", summary["recent_events"][0]["payload"]["job_code"])
|
||||
|
||||
def test_get_detect_queue_health_preserves_runtime_node_throughput_after_snapshot_override(self) -> None:
|
||||
lease_row = (None, None, 0, 0)
|
||||
throughput_rows = []
|
||||
step_throughput_rows = []
|
||||
runtime_display_rows = [
|
||||
(
|
||||
"mainland-controller-01",
|
||||
"mainland",
|
||||
"control",
|
||||
"busy",
|
||||
170,
|
||||
{
|
||||
"job_items_total": 265,
|
||||
"job_items_claimed": 85,
|
||||
"job_items_running": 0,
|
||||
"job_items_completed": 105,
|
||||
"active_threads": 170,
|
||||
"max_threads": 2000,
|
||||
"detect_participating": True,
|
||||
},
|
||||
None,
|
||||
),
|
||||
(
|
||||
"mainland-worker-01",
|
||||
"mainland",
|
||||
"worker",
|
||||
"busy",
|
||||
19,
|
||||
{
|
||||
"job_items_total": 139,
|
||||
"job_items_claimed": 64,
|
||||
"job_items_running": 0,
|
||||
"job_items_completed": 4,
|
||||
"active_threads": 19,
|
||||
"max_threads": 1200,
|
||||
"detect_participating": True,
|
||||
},
|
||||
None,
|
||||
),
|
||||
(
|
||||
"overseas-control-01",
|
||||
"overseas",
|
||||
"control",
|
||||
"busy",
|
||||
371,
|
||||
{
|
||||
"active_threads": 371,
|
||||
"max_threads": 3200,
|
||||
"detect_participating": False,
|
||||
},
|
||||
None,
|
||||
),
|
||||
]
|
||||
|
||||
conn = MagicMock()
|
||||
cursor_cm = MagicMock()
|
||||
cursor = MagicMock()
|
||||
conn.cursor.return_value = cursor_cm
|
||||
cursor_cm.__enter__.return_value = cursor
|
||||
db_cm = MagicMock()
|
||||
db_cm.__enter__.return_value = conn
|
||||
cursor.fetchone.return_value = lease_row
|
||||
cursor.fetchall.side_effect = [throughput_rows, step_throughput_rows, runtime_display_rows]
|
||||
|
||||
active_job = {
|
||||
"job_id": 1937,
|
||||
"job_code": "sync-overseas-31987",
|
||||
"status": "running",
|
||||
"items_total": 1000,
|
||||
"items_pending": 596,
|
||||
"items_claimed": 149,
|
||||
"items_running": 146,
|
||||
"items_completed": 109,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
"progress_percent": 10.9,
|
||||
"node_stats": [],
|
||||
"distributed_node_stats": [
|
||||
{
|
||||
"node_code": "mainland-controller-01",
|
||||
"items_total": 265,
|
||||
"items_pending": 0,
|
||||
"items_claimed": 85,
|
||||
"items_running": 75,
|
||||
"items_completed": 105,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
"metrics_source": "runtime",
|
||||
},
|
||||
{
|
||||
"node_code": "mainland-worker-01",
|
||||
"items_total": 139,
|
||||
"items_pending": 0,
|
||||
"items_claimed": 64,
|
||||
"items_running": 71,
|
||||
"items_completed": 4,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
"metrics_source": "runtime",
|
||||
},
|
||||
],
|
||||
"step_stats": [
|
||||
{
|
||||
"step_code": "detect_register",
|
||||
"items_total": 1000,
|
||||
"items_pending": 449,
|
||||
"items_claimed": 131,
|
||||
"items_running": 273,
|
||||
"items_completed": 147,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
}
|
||||
],
|
||||
}
|
||||
runtime_activity = {
|
||||
"processed_recent": 133,
|
||||
"completed_recent": 113,
|
||||
"failed_recent": 20,
|
||||
"blacklisted_recent": 0,
|
||||
"focus_job_code": "sync-overseas-31563",
|
||||
"job_codes": ["sync-overseas-31563"],
|
||||
"step_code": "detect_register",
|
||||
"nodes": {
|
||||
"mainland-controller-01": {
|
||||
"node_code": "mainland-controller-01",
|
||||
"processed_recent": 74,
|
||||
"completed_recent": 54,
|
||||
"failed_recent": 20,
|
||||
"blacklisted_recent": 0,
|
||||
},
|
||||
"mainland-worker-01": {
|
||||
"node_code": "mainland-worker-01",
|
||||
"processed_recent": 59,
|
||||
"completed_recent": 59,
|
||||
"failed_recent": 0,
|
||||
"blacklisted_recent": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
runtime_snapshot = {
|
||||
"job": {
|
||||
"job_id": 1937,
|
||||
"job_code": "sync-overseas-31987",
|
||||
"progress_percent": 10.9,
|
||||
},
|
||||
"queue_health": {
|
||||
"queue": {
|
||||
"items_total": 1000,
|
||||
"pending": 596,
|
||||
"claimed": 149,
|
||||
"running": 146,
|
||||
"completed": 109,
|
||||
"blacklisted": 0,
|
||||
"failed": 0,
|
||||
"display_claimed": 149,
|
||||
"display_running": 403,
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"node_code": "mainland-controller-01",
|
||||
"items_total": 265,
|
||||
"items_pending": 0,
|
||||
"items_claimed": 85,
|
||||
"items_running": 75,
|
||||
"items_completed": 105,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
"processed_recent": 0,
|
||||
"processed_per_minute": 0,
|
||||
"completed_recent": 0,
|
||||
"blacklisted_recent": 0,
|
||||
"failed_recent": 0,
|
||||
"metrics_source": "runtime",
|
||||
},
|
||||
{
|
||||
"node_code": "mainland-worker-01",
|
||||
"items_total": 139,
|
||||
"items_pending": 0,
|
||||
"items_claimed": 64,
|
||||
"items_running": 71,
|
||||
"items_completed": 4,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
"processed_recent": 0,
|
||||
"processed_per_minute": 0,
|
||||
"completed_recent": 0,
|
||||
"blacklisted_recent": 0,
|
||||
"failed_recent": 0,
|
||||
"metrics_source": "runtime",
|
||||
},
|
||||
],
|
||||
"steps": [],
|
||||
},
|
||||
}
|
||||
|
||||
with patch("app.services.detect_job_service.settings.node_region", "overseas"):
|
||||
with patch("app.services.detect_job_service.settings.node_role", "control"):
|
||||
with patch("app.services.detect_job_service.settings.node_code", "overseas-control-01"):
|
||||
with patch("app.services.detect_job_service.get_active_detect_job_summary", return_value=active_job):
|
||||
with patch("app.services.detect_job_service._load_runtime_activity_snapshot", return_value=runtime_activity):
|
||||
with patch("app.services.detect_job_service._load_latest_runtime_active_job_snapshot", return_value=runtime_snapshot):
|
||||
with patch("app.services.detect_job_service.get_db", return_value=db_cm):
|
||||
health = get_detect_queue_health(window_minutes=15)
|
||||
|
||||
node_map = {item["node_code"]: item for item in health["nodes"]}
|
||||
self.assertEqual(74, node_map["mainland-controller-01"]["processed_recent"])
|
||||
self.assertEqual(54, node_map["mainland-controller-01"]["completed_recent"])
|
||||
self.assertEqual(20, node_map["mainland-controller-01"]["failed_recent"])
|
||||
self.assertEqual(170, node_map["mainland-controller-01"]["items_running"])
|
||||
self.assertEqual(59, node_map["mainland-worker-01"]["processed_recent"])
|
||||
self.assertEqual(59, node_map["mainland-worker-01"]["completed_recent"])
|
||||
self.assertEqual(19, node_map["mainland-worker-01"]["items_running"])
|
||||
self.assertEqual(113, health["throughput"]["completed_recent"])
|
||||
self.assertEqual(189, health["queue"]["display_running"])
|
||||
self.assertNotIn("overseas-control-01", node_map)
|
||||
|
||||
def test_normalize_detect_step_code_accepts_supported_single_step(self) -> None:
|
||||
self.assertEqual("detect_baidu_site", normalize_detect_step_code("detect_baidu_site"))
|
||||
self.assertEqual("detect_wayback", normalize_detect_step_code("detect_wayback"))
|
||||
|
||||
def test_normalize_detect_step_code_rejects_unknown_step(self) -> None:
|
||||
self.assertEqual("", normalize_detect_step_code("detect_unknown"))
|
||||
|
||||
def test_resolve_detect_job_definition_builds_single_step_job(self) -> None:
|
||||
definition = resolve_detect_job_definition("detect_baidu_site")
|
||||
|
||||
self.assertTrue(definition["is_single_step"])
|
||||
self.assertEqual("single_step", definition["task_mode"])
|
||||
self.assertEqual("detect_baidu_site", definition["step_code"])
|
||||
|
||||
def test_resolve_detect_job_definition_builds_wayback_single_step_job(self) -> None:
|
||||
definition = resolve_detect_job_definition("detect_wayback")
|
||||
|
||||
self.assertTrue(definition["is_single_step"])
|
||||
self.assertEqual("single_step", definition["task_mode"])
|
||||
self.assertEqual("detect_wayback", definition["step_code"])
|
||||
|
||||
def test_build_step_payload_adds_wayback_recent_years_strategy(self) -> None:
|
||||
payload = _build_step_payload(
|
||||
step_code="detect_wayback",
|
||||
domain_snapshot={"domain": "example.com", "source_type": 2},
|
||||
settings_payload={"detect_options": {"detect_wayback": True}},
|
||||
)
|
||||
|
||||
self.assertEqual("detect_wayback", payload["step_code"])
|
||||
self.assertEqual("recent_years", payload["wayback_strategy"])
|
||||
self.assertEqual(5, payload["wayback_recent_years"])
|
||||
self.assertTrue(payload["wayback_stop_on_first_hit"])
|
||||
|
||||
def test_resolve_detect_job_definition_defaults_to_domain_pipeline(self) -> None:
|
||||
definition = resolve_detect_job_definition(None)
|
||||
|
||||
self.assertFalse(definition["is_single_step"])
|
||||
self.assertEqual("domain_pipeline", definition["task_mode"])
|
||||
self.assertEqual("", definition["step_code"])
|
||||
|
||||
def test_resolve_domain_pipeline_step_skips_yikoujia_register(self) -> None:
|
||||
step_code = resolve_domain_pipeline_step(
|
||||
{
|
||||
"source_type": 1,
|
||||
"register_status": 0,
|
||||
"baidu_site": {},
|
||||
"qihu360_site": {},
|
||||
"chinaz_info": {},
|
||||
"aizhan_info": {},
|
||||
"wayback_info": {},
|
||||
"jucha_info": {},
|
||||
"juziseo_info": {},
|
||||
},
|
||||
settings_payload={
|
||||
"detect_options": {
|
||||
"detect_register": True,
|
||||
"detect_baidu_site": True,
|
||||
"detect_360_site": False,
|
||||
"detect_chinaz": False,
|
||||
"detect_aizhan": False,
|
||||
"detect_wayback": False,
|
||||
"detect_jucha": False,
|
||||
"detect_juziseo": False,
|
||||
"detect_order": ["detect_register", "detect_baidu_site"],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual("detect_baidu_site", step_code)
|
||||
|
||||
def test_resolve_domain_pipeline_step_moves_to_next_incomplete_step(self) -> None:
|
||||
step_code = resolve_domain_pipeline_step(
|
||||
{
|
||||
"source_type": 2,
|
||||
"register_status": 3,
|
||||
"baidu_site": {"state": "passed"},
|
||||
"qihu360_site": {},
|
||||
"chinaz_info": {},
|
||||
"aizhan_info": {},
|
||||
"wayback_info": {},
|
||||
"jucha_info": {},
|
||||
"juziseo_info": {},
|
||||
},
|
||||
settings_payload={
|
||||
"detect_options": {
|
||||
"detect_register": True,
|
||||
"detect_baidu_site": True,
|
||||
"detect_360_site": True,
|
||||
"detect_chinaz": False,
|
||||
"detect_aizhan": False,
|
||||
"detect_wayback": False,
|
||||
"detect_jucha": False,
|
||||
"detect_juziseo": False,
|
||||
"detect_order": ["detect_register", "detect_baidu_site", "detect_360_site"],
|
||||
}
|
||||
},
|
||||
after_step_code="detect_baidu_site",
|
||||
)
|
||||
|
||||
self.assertEqual("detect_360_site", step_code)
|
||||
|
||||
def test_resolve_initial_domain_pipeline_item_builds_first_step_payload(self) -> None:
|
||||
step_code, payload = resolve_initial_domain_pipeline_item(
|
||||
{
|
||||
"id": 10,
|
||||
"domain": "example.com",
|
||||
"source_type": 2,
|
||||
"register_status": 0,
|
||||
"baidu_site": {},
|
||||
"qihu360_site": {},
|
||||
"chinaz_info": {},
|
||||
"aizhan_info": {},
|
||||
"wayback_info": {},
|
||||
"jucha_info": {},
|
||||
"juziseo_info": {},
|
||||
},
|
||||
settings_payload={
|
||||
"detect_options": {
|
||||
"detect_register": True,
|
||||
"detect_baidu_site": True,
|
||||
"detect_order": ["detect_register", "detect_baidu_site"],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual("detect_register", step_code)
|
||||
self.assertIsNotNone(payload)
|
||||
self.assertEqual("detect_register", payload["step_code"])
|
||||
self.assertEqual("example.com", payload["domain"])
|
||||
|
||||
def test_resolve_initial_domain_pipeline_item_returns_empty_when_pipeline_already_done(self) -> None:
|
||||
step_code, payload = resolve_initial_domain_pipeline_item(
|
||||
{
|
||||
"id": 11,
|
||||
"domain": "done.com",
|
||||
"source_type": 2,
|
||||
"register_status": 3,
|
||||
"baidu_site": {"state": "passed"},
|
||||
"qihu360_site": {},
|
||||
"chinaz_info": {},
|
||||
"aizhan_info": {},
|
||||
"wayback_info": {},
|
||||
"jucha_info": {},
|
||||
"juziseo_info": {},
|
||||
},
|
||||
settings_payload={
|
||||
"detect_options": {
|
||||
"detect_register": True,
|
||||
"detect_baidu_site": True,
|
||||
"detect_order": ["detect_register", "detect_baidu_site"],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual("", step_code)
|
||||
self.assertIsNone(payload)
|
||||
|
||||
def test_classify_pipeline_item_outcome_retries_external_failure(self) -> None:
|
||||
outcome = _classify_pipeline_item_outcome(
|
||||
item_status="failed",
|
||||
result_payload={"state": "degraded", "message": "timeout", "retry_recommended": True},
|
||||
step_code="detect_baidu_site",
|
||||
attempt_count=0,
|
||||
)
|
||||
|
||||
self.assertEqual("retry", outcome["action"])
|
||||
self.assertTrue(outcome["should_retry"])
|
||||
self.assertEqual("external_retry", outcome["reason_code"])
|
||||
|
||||
def test_classify_pipeline_item_outcome_rejects_business_failure(self) -> None:
|
||||
outcome = _classify_pipeline_item_outcome(
|
||||
item_status="failed",
|
||||
result_payload={"state": "rejected", "message": "title contains forbidden keyword"},
|
||||
step_code="detect_chinaz",
|
||||
attempt_count=0,
|
||||
)
|
||||
|
||||
self.assertEqual("reject", outcome["action"])
|
||||
self.assertFalse(outcome["should_retry"])
|
||||
self.assertEqual("business_reject", outcome["reason_code"])
|
||||
|
||||
def test_classify_pipeline_item_outcome_marks_blacklisted_terminal(self) -> None:
|
||||
outcome = _classify_pipeline_item_outcome(
|
||||
item_status="blacklisted",
|
||||
result_payload={"state": "blacklisted", "message": "risk hit"},
|
||||
step_code="detect_baidu_site",
|
||||
attempt_count=0,
|
||||
)
|
||||
|
||||
self.assertEqual("black_hit", outcome["action"])
|
||||
self.assertFalse(outcome["should_retry"])
|
||||
self.assertEqual("blacklisted", outcome["reason_code"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
69
domain-api/tests/test_detect_service_status_fallback.py
Normal file
69
domain-api/tests/test_detect_service_status_fallback.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.services import detect_service
|
||||
|
||||
|
||||
class DetectServiceStatusFallbackTests(unittest.TestCase):
|
||||
def test_get_detect_status_keeps_runtime_snapshot_when_db_is_unreachable(self) -> None:
|
||||
runtime_state = {
|
||||
"service_running": True,
|
||||
"detecting": True,
|
||||
"active_threads": 7,
|
||||
"max_threads": 120,
|
||||
"phase": "running",
|
||||
"detail": "Worker 正在处理 7 个检测任务",
|
||||
"updated_at": "2026-04-20 23:59:00",
|
||||
"available_proxy_count": 18,
|
||||
}
|
||||
|
||||
with patch("app.services.detect_service.ensure_runtime_schema"), \
|
||||
patch("app.services.detect_service.get_db", side_effect=RuntimeError("db down")), \
|
||||
patch("app.services.detect_service.get_settings_payload", return_value={"proxy_config": {"proxy_enable": True, "allow_direct": False, "proxy_urls": ["a"]}}), \
|
||||
patch("app.services.detect_service.get_runtime_settings", return_value={"worker_log_sync_enabled": False, "worker_log_sync_mode": "full"}), \
|
||||
patch("app.services.detect_service._load_recent_worker_lines", return_value=(True, "", [])), \
|
||||
patch("app.services.detect_service.detect_worker_runtime", return_value={"mode": "linux-systemd", "running": True, "process_count": 1, "latest_start_time": "2026-04-20 23:58:00", "message": "active/running"}), \
|
||||
patch("app.services.detect_service._load_runtime_state", return_value=runtime_state), \
|
||||
patch("app.services.detect_service._load_runtime_state_from_cluster_node", return_value={}), \
|
||||
patch("app.services.detect_service._extract_available_proxy_count", return_value=0), \
|
||||
patch("app.services.detect_service._extract_active_thread_snapshot", return_value={"active": 0, "max": 0}), \
|
||||
patch("app.services.detect_service._normalize_recent_warning", return_value=""), \
|
||||
patch("app.services.detect_service._build_proxy_runtime_snapshot", return_value={"state": "healthy", "label": "代理正常", "detail": "healthy", "direct_fallback_active": False, "reason": "healthy", "last_refresh_status": "ok", "last_refresh_time": "", "source_count": 2, "raw_items": 18, "validated_count": 18, "available_count": 18, "source_stats": [], "supplier_empty": False}), \
|
||||
patch("app.services.detect_service.resolve_thread_count", return_value={"effective_thread_count": 120, "default_thread_count": 5, "source": "node_override", "override_thread_count": 120, "node_code": "mainland-worker-01"}), \
|
||||
patch("app.services.detect_service.get_active_detect_job_summary", side_effect=RuntimeError("db down")), \
|
||||
patch("app.services.detect_service.sync_detect_runs", return_value=[]), \
|
||||
patch("app.services.detect_service._resolve_remote_log_snapshot", return_value={}), \
|
||||
patch("app.services.detect_service._extract_dependency_alerts", return_value=[]), \
|
||||
patch("app.services.detect_service.append_detect_result_projection_if_changed"):
|
||||
payload = detect_service.get_detect_status()
|
||||
|
||||
self.assertTrue(payload["worker_online"])
|
||||
self.assertTrue(payload["detecting"])
|
||||
self.assertEqual(7, payload["active_thread_count"])
|
||||
self.assertEqual(120, payload["max_thread_count"])
|
||||
self.assertEqual(0, payload["progress"]["pending"])
|
||||
self.assertEqual(0, payload["progress"]["completed"])
|
||||
|
||||
def test_filter_lines_since_supports_journalctl_syslog_timestamps(self) -> None:
|
||||
lines = [
|
||||
"Apr 21 20:12:42 mainland-controller python[1]: 当前实际线程数量: 323/4",
|
||||
"Apr 21 20:17:20 mainland-controller python[2]: Worker 已启动,等待检测指令",
|
||||
]
|
||||
|
||||
filtered = detect_service._filter_lines_since(lines, "2026-04-21 20:17:00")
|
||||
|
||||
self.assertEqual(
|
||||
["Apr 21 20:17:20 mainland-controller python[2]: Worker 已启动,等待检测指令"],
|
||||
filtered,
|
||||
)
|
||||
|
||||
def test_filter_lines_since_falls_back_when_no_timestamp_is_parseable(self) -> None:
|
||||
lines = ["no timestamp line 1", "no timestamp line 2"]
|
||||
|
||||
filtered = detect_service._filter_lines_since(lines, "2026-04-21 20:17:00")
|
||||
|
||||
self.assertEqual(lines, filtered)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
205
domain-api/tests/test_juming_services.py
Normal file
205
domain-api/tests/test_juming_services.py
Normal file
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import patch
|
||||
|
||||
from requests.cookies import RequestsCookieJar
|
||||
|
||||
from app.services import juming_service, juming_task_service
|
||||
|
||||
|
||||
class JumingServiceTests(unittest.TestCase):
|
||||
def test_crawl_juming_rejects_invalid_cookie(self) -> None:
|
||||
cookie_jar = RequestsCookieJar()
|
||||
cookie_jar.set("sid", "expired")
|
||||
|
||||
with patch.object(juming_service, "_load_juming_cookie", return_value=(cookie_jar, "local")):
|
||||
with patch.object(juming_service, "_validate_juming_cookie", return_value=(False, "聚名登录态已失效,请重新登录")):
|
||||
with self.assertRaisesRegex(ValueError, "已失效"):
|
||||
juming_service.crawl_juming({"mode": "delete_list"})
|
||||
|
||||
def test_get_juming_status_exposes_remote_validation(self) -> None:
|
||||
cookie_jar = RequestsCookieJar()
|
||||
cookie_jar.set("sid", "alive")
|
||||
|
||||
with patch.object(juming_service, "_load_juming_cookie", return_value=(cookie_jar, "local")):
|
||||
with patch.object(juming_service, "_validate_juming_cookie", return_value=(False, "聚名登录态已失效,请重新登录")):
|
||||
payload = juming_service.get_juming_status()
|
||||
|
||||
self.assertTrue(payload["cookie_present"])
|
||||
self.assertFalse(payload["cookie_valid"])
|
||||
self.assertFalse(payload["cookie_ready"])
|
||||
self.assertIn("已失效", payload["cookie_message"])
|
||||
|
||||
def test_insert_domains_uses_copy_stage_import_path(self) -> None:
|
||||
class FakeCursor:
|
||||
def __init__(self) -> None:
|
||||
self.executed: list[tuple[str, object]] = []
|
||||
self.copy_calls: list[tuple[str, tuple[str, ...], str]] = []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def execute(self, sql: str, params: object = None) -> None:
|
||||
self.executed.append((sql, params))
|
||||
|
||||
def copy_from(self, file_obj, table: str, columns: tuple[str, ...]) -> None:
|
||||
self.copy_calls.append((table, columns, file_obj.read()))
|
||||
|
||||
def fetchone(self):
|
||||
return (2, 2, 0)
|
||||
|
||||
class FakeConn:
|
||||
def __init__(self) -> None:
|
||||
self.cursor_obj = FakeCursor()
|
||||
self.commit_calls = 0
|
||||
|
||||
def cursor(self):
|
||||
return self.cursor_obj
|
||||
|
||||
def commit(self) -> None:
|
||||
self.commit_calls += 1
|
||||
|
||||
fake_conn = FakeConn()
|
||||
|
||||
@contextmanager
|
||||
def fake_get_db():
|
||||
yield fake_conn
|
||||
|
||||
with patch.object(juming_service, "get_db", fake_get_db):
|
||||
with patch.object(juming_service, "IMPORT_BATCH_SIZE", 2):
|
||||
stats = juming_service._insert_domains(
|
||||
["alpha.com", "beta.net"],
|
||||
juming_service.DELETE_LIST_SOURCE_TYPE,
|
||||
)
|
||||
|
||||
self.assertEqual(2, stats["added"])
|
||||
self.assertEqual(1, fake_conn.commit_calls)
|
||||
self.assertTrue(fake_conn.cursor_obj.copy_calls)
|
||||
table, columns, payload = fake_conn.cursor_obj.copy_calls[0]
|
||||
self.assertEqual("juming_import_stage", table)
|
||||
self.assertEqual(("domain", "tld"), columns)
|
||||
self.assertIn("alpha.com\tcom", payload)
|
||||
self.assertIn("beta.net\tnet", payload)
|
||||
executed_sql = "\n".join(sql for sql, _params in fake_conn.cursor_obj.executed)
|
||||
self.assertIn("create temporary table if not exists juming_import_stage", executed_sql.lower())
|
||||
self.assertIn("inserted as", executed_sql.lower())
|
||||
self.assertIn("left join domains existing", executed_sql.lower())
|
||||
|
||||
def test_delete_list_import_skips_already_imported_same_signature(self) -> None:
|
||||
cookie_jar = RequestsCookieJar()
|
||||
cookie_jar.set("sid", "alive")
|
||||
|
||||
class FakeJM:
|
||||
cookie = cookie_jar
|
||||
|
||||
def new_cha_del(self, current_date: str):
|
||||
if current_date == "2026-03-21":
|
||||
return ["alpha.com", "beta.net"]
|
||||
return []
|
||||
|
||||
logs: list[str] = []
|
||||
signature = juming_service._compute_domains_signature(["alpha.com", "beta.net"])
|
||||
cached_state = {
|
||||
"2026-03-21": {
|
||||
"signature": signature,
|
||||
"total": 2,
|
||||
"valid": 2,
|
||||
"invalid": 0,
|
||||
}
|
||||
}
|
||||
|
||||
with patch.object(juming_service, "_load_juming_cookie", return_value=(cookie_jar, "local")):
|
||||
with patch.object(juming_service, "JM", return_value=FakeJM()):
|
||||
with patch.object(juming_service, "_load_delete_import_state", return_value=cached_state):
|
||||
with patch.object(juming_service, "_insert_domains") as mock_insert:
|
||||
result = juming_service._crawl_delete_list_and_import(
|
||||
"2026-03-21",
|
||||
False,
|
||||
log=logs.append,
|
||||
)
|
||||
|
||||
mock_insert.assert_not_called()
|
||||
self.assertEqual(0, result["stats"]["added"])
|
||||
self.assertEqual(2, result["stats"]["exists"])
|
||||
self.assertTrue(any("跳过重复入库" in line for line in logs))
|
||||
|
||||
|
||||
class JumingTaskServiceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmpdir = tempfile.TemporaryDirectory()
|
||||
self._old_runtime_root = os.environ.get("DOMAIN_API_RUNTIME_ROOT")
|
||||
os.environ["DOMAIN_API_RUNTIME_ROOT"] = self._tmpdir.name
|
||||
juming_task_service._ACTIVE_TASK_IDS.clear()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
juming_task_service._ACTIVE_TASK_IDS.clear()
|
||||
if self._old_runtime_root is None:
|
||||
os.environ.pop("DOMAIN_API_RUNTIME_ROOT", None)
|
||||
else:
|
||||
os.environ["DOMAIN_API_RUNTIME_ROOT"] = self._old_runtime_root
|
||||
self._tmpdir.cleanup()
|
||||
|
||||
def _write_tasks(self, records: list[dict]) -> None:
|
||||
path = os.path.join(self._tmpdir.name, "juming_tasks.json")
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
json.dump(records, handle, ensure_ascii=False, indent=2)
|
||||
|
||||
def test_cleanup_orphaned_tasks_marks_running_task_failed(self) -> None:
|
||||
self._write_tasks(
|
||||
[
|
||||
{
|
||||
"task_id": "task-1",
|
||||
"status": "running",
|
||||
"phase": "importing",
|
||||
"phase_label": "入库中",
|
||||
"cancel_requested": False,
|
||||
"message": "开始入库处理",
|
||||
"created_at": "2026-04-21 21:00:00",
|
||||
"updated_at": "2026-04-21 21:00:00",
|
||||
"started_at": "2026-04-21 21:00:00",
|
||||
"completed_at": "",
|
||||
"result": None,
|
||||
"logs": [],
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
tasks = juming_task_service.list_juming_tasks()
|
||||
self.assertEqual("failed", tasks[0]["status"])
|
||||
self.assertIn("中断", tasks[0]["message"])
|
||||
|
||||
def test_create_task_rejects_parallel_active_task(self) -> None:
|
||||
self._write_tasks(
|
||||
[
|
||||
{
|
||||
"task_id": "task-1",
|
||||
"status": "running",
|
||||
"phase": "fetching",
|
||||
"phase_label": "抓取中",
|
||||
"cancel_requested": False,
|
||||
"message": "正在抓取",
|
||||
"created_at": "2026-04-21 21:00:00",
|
||||
"updated_at": "2026-04-21 21:00:00",
|
||||
"started_at": "2026-04-21 21:00:00",
|
||||
"completed_at": "",
|
||||
"result": None,
|
||||
"logs": [],
|
||||
}
|
||||
]
|
||||
)
|
||||
juming_task_service._ACTIVE_TASK_IDS.add("task-1")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "已有聚名采集任务正在运行"):
|
||||
juming_task_service.create_juming_task({"mode": "delete_list"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -20,6 +20,111 @@ class NodeAgentDeliveryQueueTests(unittest.TestCase):
|
||||
self.assertEqual(0, delivery_queue["pending_count"])
|
||||
self.assertEqual(0, delivery_queue["dead_letter_count"])
|
||||
|
||||
def test_base_payload_prefers_non_loopback_identity(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
with patch.object(node_agent, "AGENT_QUEUE_DIR", temp_dir), patch.object(
|
||||
node_agent,
|
||||
"CONTROL_PLANE_BASE_URL",
|
||||
"http://152.53.37.118:8100",
|
||||
), patch.object(
|
||||
node_agent.socket,
|
||||
"gethostname",
|
||||
return_value="localhost",
|
||||
), patch.object(
|
||||
node_agent.socket,
|
||||
"getfqdn",
|
||||
return_value="localhost.localdomain",
|
||||
), patch.object(
|
||||
node_agent.os,
|
||||
"uname",
|
||||
return_value=type("Uname", (), {"nodename": "localhost"})(),
|
||||
), patch.object(
|
||||
node_agent,
|
||||
"NODE_CODE",
|
||||
"mainland-controller-01",
|
||||
), patch.object(
|
||||
node_agent.socket,
|
||||
"getaddrinfo",
|
||||
return_value=[(None, None, None, None, ("127.0.0.1", 0))],
|
||||
), patch.object(
|
||||
node_agent.socket,
|
||||
"gethostbyname",
|
||||
return_value="127.0.0.1",
|
||||
):
|
||||
class FakeSocket:
|
||||
def connect(self, target):
|
||||
self.target = target
|
||||
|
||||
def getsockname(self):
|
||||
return ("121.204.244.188", 12345)
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
with patch.object(node_agent.socket, "socket", return_value=FakeSocket()):
|
||||
payload = node_agent._base_payload()
|
||||
|
||||
self.assertNotIn(payload["hostname"], {"", "localhost", "localhost.localdomain"})
|
||||
self.assertEqual("121.204.244.188", payload["ip"])
|
||||
|
||||
def test_detect_runtime_snapshot_degrades_to_worker_runtime_when_detect_status_fails(self) -> None:
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
with patch(
|
||||
"app.services.worker_control_service.detect_worker_runtime",
|
||||
return_value={
|
||||
"running": True,
|
||||
"process_count": 1,
|
||||
"latest_start_time": "2026-04-20 18:00:00",
|
||||
"message": "active/running",
|
||||
},
|
||||
), patch(
|
||||
"app.services.detect_service.get_detect_status",
|
||||
side_effect=RuntimeError('connection to server at "127.0.0.1", port 5432 failed'),
|
||||
):
|
||||
snapshot = node_agent._detect_runtime_snapshot()
|
||||
|
||||
self.assertTrue(snapshot["worker_online"])
|
||||
self.assertTrue(snapshot["service_running"])
|
||||
self.assertFalse(snapshot["detecting"])
|
||||
self.assertEqual("active/running", snapshot["phase_detail"])
|
||||
self.assertEqual("2026-04-20 18:00:00", snapshot["updated_at"])
|
||||
self.assertIn("127.0.0.1", snapshot["error"])
|
||||
|
||||
def test_detect_runtime_snapshot_infers_worker_online_from_active_threads(self) -> None:
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
with patch(
|
||||
"app.services.worker_control_service.detect_worker_runtime",
|
||||
return_value={
|
||||
"running": False,
|
||||
"process_count": 0,
|
||||
"latest_start_time": "",
|
||||
"message": "",
|
||||
},
|
||||
), patch(
|
||||
"app.services.detect_service.get_detect_status",
|
||||
return_value={
|
||||
"worker_online": False,
|
||||
"detecting": False,
|
||||
"active_thread_count": 19,
|
||||
"max_thread_count": 1200,
|
||||
"phase_label": "检测中",
|
||||
"phase_detail": "Worker 正在处理 162 个检测任务",
|
||||
"runtime_state": {
|
||||
"service_running": False,
|
||||
"updated_at": "2026-04-21 00:00:06",
|
||||
},
|
||||
"active_job": {"items_running": 0},
|
||||
},
|
||||
):
|
||||
snapshot = node_agent._detect_runtime_snapshot()
|
||||
|
||||
self.assertTrue(snapshot["worker_online"])
|
||||
self.assertTrue(snapshot["service_running"])
|
||||
self.assertTrue(snapshot["detecting"])
|
||||
self.assertTrue(snapshot["detect_participating"])
|
||||
self.assertEqual(19, snapshot["active_threads"])
|
||||
self.assertEqual(19, snapshot["current_load"])
|
||||
|
||||
def test_job_event_network_failure_is_queued_for_retry(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
with patch.object(node_agent, "AGENT_QUEUE_DIR", temp_dir), patch.object(
|
||||
@@ -279,6 +384,63 @@ class NodeAgentDeliveryQueueTests(unittest.TestCase):
|
||||
self.assertEqual("failed_local", event_payload["start_delivery_state"])
|
||||
self.assertIn("temporary offline", event_payload["start_delivery_error"])
|
||||
|
||||
def test_register_heartbeat_and_pull_use_configured_timeouts(self) -> None:
|
||||
with patch.object(node_agent, "_post", return_value={"code": 0, "message": "ok", "data": {"jobs": []}}) as mock_post, patch.object(
|
||||
node_agent,
|
||||
"AGENT_REGISTER_TIMEOUT_SECONDS",
|
||||
91,
|
||||
), patch.object(
|
||||
node_agent,
|
||||
"AGENT_HEARTBEAT_TIMEOUT_SECONDS",
|
||||
92,
|
||||
), patch.object(
|
||||
node_agent,
|
||||
"AGENT_PULL_TIMEOUT_SECONDS",
|
||||
93,
|
||||
):
|
||||
node_agent._register()
|
||||
node_agent._heartbeat()
|
||||
jobs = node_agent._pull_jobs()
|
||||
|
||||
self.assertEqual([], jobs)
|
||||
self.assertEqual(3, mock_post.call_count)
|
||||
self.assertEqual(91, mock_post.call_args_list[0].kwargs["timeout"])
|
||||
self.assertEqual(92, mock_post.call_args_list[1].kwargs["timeout"])
|
||||
self.assertEqual(93, mock_post.call_args_list[2].kwargs["timeout"])
|
||||
|
||||
def test_job_delivery_uses_configured_timeouts(self) -> None:
|
||||
with patch.object(
|
||||
node_agent,
|
||||
"_deliver_or_queue",
|
||||
side_effect=lambda **kwargs: {"state": "queued", "timeout": kwargs["timeout"]},
|
||||
) as mock_deliver, patch.object(
|
||||
node_agent,
|
||||
"AGENT_JOB_COMPLETE_TIMEOUT_SECONDS",
|
||||
94,
|
||||
), patch.object(
|
||||
node_agent,
|
||||
"AGENT_JOB_EVENT_TIMEOUT_SECONDS",
|
||||
47,
|
||||
):
|
||||
complete_result = node_agent._job_complete(
|
||||
11,
|
||||
status="success",
|
||||
stdout="ok",
|
||||
stderr="",
|
||||
result={"ok": True},
|
||||
)
|
||||
event_result = node_agent._job_event(
|
||||
11,
|
||||
event_type="executor_received",
|
||||
message="accepted",
|
||||
)
|
||||
|
||||
self.assertEqual("queued", complete_result["state"])
|
||||
self.assertEqual("queued", event_result["state"])
|
||||
self.assertEqual(2, mock_deliver.call_count)
|
||||
self.assertEqual(94, mock_deliver.call_args_list[0].kwargs["timeout"])
|
||||
self.assertEqual(47, mock_deliver.call_args_list[1].kwargs["timeout"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -110,6 +110,34 @@ class OpsActionExecutorCoreTests(unittest.TestCase):
|
||||
self.assertTrue(data["fallback_used"])
|
||||
self.assertIn("detect_worker.log", data["log_paths"][0])
|
||||
|
||||
@patch("app.services.ops_action_executor_core._reset_lab_state")
|
||||
def test_runtime_reset_lab_state_returns_helper_result_with_host_context(self, mock_reset_lab_state) -> None:
|
||||
runner = _FakeRunner({})
|
||||
mock_reset_lab_state.return_value = (
|
||||
True,
|
||||
"lab runtime reset completed",
|
||||
{"database": {"include_domains": True}},
|
||||
)
|
||||
|
||||
ok, message, data = execute_structured_action(
|
||||
"runtime.reset_lab_state",
|
||||
{"clear_database": True, "clear_domains": True},
|
||||
service_names=build_service_name_map(
|
||||
api_service_name="domaincheck-api",
|
||||
worker_service_name="domaincheck-worker",
|
||||
sync_agent_service_name="domaincheck-sync-agent",
|
||||
),
|
||||
runner=runner,
|
||||
host_context={"hostname": "mainland-controller-01", "ip": "121.204.244.188"},
|
||||
)
|
||||
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual("lab runtime reset completed", message)
|
||||
self.assertEqual("mainland-controller-01", data["hostname"])
|
||||
self.assertEqual("121.204.244.188", data["ip"])
|
||||
self.assertTrue(data["database"]["include_domains"])
|
||||
mock_reset_lab_state.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -4,8 +4,11 @@ from unittest.mock import patch
|
||||
|
||||
from app.api.routes.ops_agent import _build_agent_response
|
||||
from app.services.ops_agent_service import (
|
||||
_build_agent_runtime_config_bundle,
|
||||
_runtime_config_bundle_hash_payload,
|
||||
agent_append_job_event,
|
||||
agent_complete_job,
|
||||
agent_mark_job_started,
|
||||
agent_pull_jobs,
|
||||
agent_register,
|
||||
build_node_agent_bootstrap_plan,
|
||||
@@ -102,6 +105,64 @@ class _SequenceConnection:
|
||||
|
||||
|
||||
class OpsAgentServiceTests(unittest.TestCase):
|
||||
def test_runtime_config_bundle_hash_ignores_generated_at(self) -> None:
|
||||
bundle_a = {
|
||||
"node_code": "mainland-worker-01",
|
||||
"thread_count": 100,
|
||||
"node_thread_counts": {"mainland-worker-01": 100},
|
||||
"runtime_settings": {"worker_log_sync_enabled": True},
|
||||
"generated_at": "2026-04-19 17:30:00",
|
||||
"config_hash": "stale-hash",
|
||||
}
|
||||
bundle_b = {
|
||||
"node_code": "mainland-worker-01",
|
||||
"thread_count": 100,
|
||||
"node_thread_counts": {"mainland-worker-01": 100},
|
||||
"runtime_settings": {"worker_log_sync_enabled": True},
|
||||
"generated_at": "2026-04-19 17:35:00",
|
||||
"config_hash": "other-stale-hash",
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
_runtime_config_bundle_hash_payload(bundle_a),
|
||||
_runtime_config_bundle_hash_payload(bundle_b),
|
||||
)
|
||||
|
||||
@patch("app.services.ops_agent_service.get_sensitive_words_payload")
|
||||
@patch("app.services.ops_agent_service.get_runtime_settings")
|
||||
@patch("app.services.ops_agent_service.get_settings_payload")
|
||||
def test_build_agent_runtime_config_bundle_keeps_hash_stable_for_same_config(
|
||||
self,
|
||||
mock_get_settings_payload,
|
||||
mock_get_runtime_settings,
|
||||
mock_get_sensitive_words_payload,
|
||||
) -> None:
|
||||
mock_get_settings_payload.return_value = {
|
||||
"detect_options": {"detect_wayback": True},
|
||||
"proxy_config": {"enabled": True},
|
||||
"thread_count": 100,
|
||||
"node_thread_counts": {"mainland-worker-01": 100},
|
||||
}
|
||||
mock_get_runtime_settings.return_value = {
|
||||
"worker_log_sync_enabled": True,
|
||||
"worker_log_sync_mode": "full",
|
||||
}
|
||||
mock_get_sensitive_words_payload.return_value = {
|
||||
"text": "foo\nbar",
|
||||
"total": 2,
|
||||
"items": ["foo", "bar"],
|
||||
}
|
||||
|
||||
with patch(
|
||||
"app.services.ops_agent_service._format_time",
|
||||
side_effect=["2026-04-19 17:30:00", "2026-04-19 17:35:00"],
|
||||
):
|
||||
first_bundle = _build_agent_runtime_config_bundle("mainland-worker-01")
|
||||
second_bundle = _build_agent_runtime_config_bundle("mainland-worker-01")
|
||||
|
||||
self.assertNotEqual(first_bundle["generated_at"], second_bundle["generated_at"])
|
||||
self.assertEqual(first_bundle["config_hash"], second_bundle["config_hash"])
|
||||
|
||||
def test_build_agent_response_extracts_detail_code(self) -> None:
|
||||
response = _build_agent_response(False, "bad request", {"detail_code": "agent_token_invalid", "foo": "bar"})
|
||||
|
||||
@@ -730,6 +791,76 @@ class OpsAgentServiceTests(unittest.TestCase):
|
||||
self.assertEqual(0, payload["summary"]["agent_ready"])
|
||||
self.assertEqual(1, payload["summary"]["remote_access_ready"])
|
||||
|
||||
@patch("app.services.runtime_status_service.get_runtime_status")
|
||||
@patch("app.services.cluster_runtime_service.get_cluster_snapshot")
|
||||
@patch("app.services.ops_job_service.list_managed_nodes")
|
||||
@patch("app.services.ops_agent_service.get_db")
|
||||
@patch("app.services.ops_agent_service.ensure_ops_agent_schema")
|
||||
def test_list_managed_nodes_with_agent_state_prefers_non_loopback_cluster_identity_and_infers_worker_online(
|
||||
self,
|
||||
mock_ensure_ops_agent_schema,
|
||||
mock_get_db,
|
||||
mock_list_managed_nodes,
|
||||
mock_get_cluster_snapshot,
|
||||
mock_get_runtime_status,
|
||||
) -> None:
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
mock_ensure_ops_agent_schema.return_value = None
|
||||
mock_get_db.return_value = _EmptyConnection()
|
||||
mock_list_managed_nodes.return_value = [
|
||||
{
|
||||
"node_code": "mainland-worker-01",
|
||||
"region": "mainland",
|
||||
"role": "worker",
|
||||
"title": "Mainland Worker 01",
|
||||
"is_enabled": True,
|
||||
"metadata": {
|
||||
"hostname": "S244-248",
|
||||
"ip": "121.204.244.248",
|
||||
},
|
||||
"last_seen_at": now,
|
||||
}
|
||||
]
|
||||
mock_get_cluster_snapshot.return_value = {
|
||||
"nodes": [
|
||||
{
|
||||
"node_code": "mainland-worker-01",
|
||||
"region": "mainland",
|
||||
"role": "worker",
|
||||
"status": "busy",
|
||||
"current_load": 19,
|
||||
"hostname": "localhost",
|
||||
"ip": "127.0.0.1",
|
||||
"last_heartbeat_at": now,
|
||||
"is_effective_worker": True,
|
||||
"detect_participating": False,
|
||||
"metadata": {
|
||||
"worker_online": False,
|
||||
"active_threads": 19,
|
||||
"max_threads": 1200,
|
||||
"phase_label": "检测中",
|
||||
"phase_detail": "Worker 正在处理 162 个检测任务",
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
mock_get_runtime_status.return_value = {
|
||||
"detect": {
|
||||
"participating_nodes": [],
|
||||
"non_participating_nodes": [],
|
||||
}
|
||||
}
|
||||
|
||||
payload = list_managed_nodes_with_agent_state()
|
||||
|
||||
self.assertEqual(1, len(payload["nodes"]))
|
||||
row = payload["nodes"][0]
|
||||
self.assertEqual("S244-248", row["cluster_hostname"])
|
||||
self.assertEqual("121.204.244.248", row["cluster_ip"])
|
||||
self.assertTrue(row["detect_runtime"]["worker_online"])
|
||||
self.assertTrue(row["detect_runtime"]["detect_participating"])
|
||||
self.assertEqual(19, row["detect_runtime"]["active_threads"])
|
||||
|
||||
def test_agent_register_requires_node_code_with_detail_code(self) -> None:
|
||||
ok, message, data = agent_register({}, token="ignored")
|
||||
|
||||
@@ -1176,6 +1307,119 @@ class OpsAgentServiceTests(unittest.TestCase):
|
||||
self.assertEqual("当前控制面仅支持对可见头部记录执行单条动作", message)
|
||||
self.assertEqual("evt-hidden", data["record_id"])
|
||||
|
||||
@patch("app.services.ops_agent_service.get_ops_job")
|
||||
@patch("app.services.ops_agent_service.append_ops_job_event")
|
||||
@patch("app.services.ops_agent_service._authenticate_agent_token")
|
||||
@patch("app.services.ops_agent_service.get_db")
|
||||
def test_agent_pull_jobs_commits_before_appending_event(
|
||||
self,
|
||||
mock_get_db,
|
||||
mock_authenticate_agent_token,
|
||||
mock_append_ops_job_event,
|
||||
mock_get_ops_job,
|
||||
) -> None:
|
||||
cursor = _SequenceCursor(fetchall_values=[[(36,)]] )
|
||||
connection = _SequenceConnection(cursor)
|
||||
mock_get_db.return_value = connection
|
||||
mock_authenticate_agent_token.return_value = (True, "ok", {"node_code": "mainland-worker-01"})
|
||||
mock_get_ops_job.return_value = {"id": 36, "job_code": "ops-demo", "action": "health.snapshot"}
|
||||
|
||||
def _assert_after_commit(**kwargs):
|
||||
self.assertTrue(connection.committed)
|
||||
self.assertEqual(36, kwargs["job_id"])
|
||||
self.assertEqual("agent_dispatched", kwargs["event_type"])
|
||||
|
||||
mock_append_ops_job_event.side_effect = _assert_after_commit
|
||||
|
||||
ok, message, data = agent_pull_jobs({"node_code": "mainland-worker-01"}, token="agent-token", limit=1)
|
||||
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual("ok", message)
|
||||
self.assertEqual(1, data["count"])
|
||||
self.assertTrue(connection.committed)
|
||||
mock_append_ops_job_event.assert_called_once()
|
||||
|
||||
@patch("app.services.ops_agent_service.get_ops_job")
|
||||
@patch("app.services.ops_agent_service.append_ops_job_event")
|
||||
@patch("app.services.ops_agent_service._authenticate_agent_token")
|
||||
@patch("app.services.ops_agent_service.get_db")
|
||||
def test_agent_mark_job_started_commits_before_appending_event(
|
||||
self,
|
||||
mock_get_db,
|
||||
mock_authenticate_agent_token,
|
||||
mock_append_ops_job_event,
|
||||
mock_get_ops_job,
|
||||
) -> None:
|
||||
cursor = _SequenceCursor(fetchone_values=[(36,)], fetchall_values=[[(101,), (102,)]])
|
||||
connection = _SequenceConnection(cursor)
|
||||
mock_get_db.return_value = connection
|
||||
mock_authenticate_agent_token.return_value = (True, "ok", {"node_code": "mainland-worker-01"})
|
||||
mock_get_ops_job.return_value = {"id": 36, "job_code": "ops-demo", "action": "health.snapshot"}
|
||||
|
||||
def _assert_after_commit(**kwargs):
|
||||
self.assertTrue(connection.committed)
|
||||
self.assertEqual(36, kwargs["job_id"])
|
||||
self.assertEqual("agent_started", kwargs["event_type"])
|
||||
self.assertEqual({"step_ids": [101, 102]}, kwargs["payload"])
|
||||
|
||||
mock_append_ops_job_event.side_effect = _assert_after_commit
|
||||
|
||||
ok, message, data = agent_mark_job_started(36, {"node_code": "mainland-worker-01"}, token="agent-token")
|
||||
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual("任务已标记为运行中", message)
|
||||
self.assertEqual(36, data["job"]["id"])
|
||||
self.assertTrue(connection.committed)
|
||||
mock_append_ops_job_event.assert_called_once()
|
||||
|
||||
@patch("app.services.ops_release_service.refresh_release_rollout_for_job")
|
||||
@patch("app.services.ops_agent_service.get_ops_job")
|
||||
@patch("app.services.ops_agent_service.append_ops_job_event")
|
||||
@patch("app.services.ops_agent_service._authenticate_agent_token")
|
||||
@patch("app.services.ops_agent_service.get_db")
|
||||
def test_agent_complete_job_commits_before_appending_event(
|
||||
self,
|
||||
mock_get_db,
|
||||
mock_authenticate_agent_token,
|
||||
mock_append_ops_job_event,
|
||||
mock_get_ops_job,
|
||||
mock_refresh_release_rollout_for_job,
|
||||
) -> None:
|
||||
cursor = _SequenceCursor(fetchone_values=[(36, "")], fetchall_values=[[(101,)]] )
|
||||
connection = _SequenceConnection(cursor)
|
||||
mock_get_db.return_value = connection
|
||||
mock_authenticate_agent_token.return_value = (True, "ok", {"node_code": "mainland-worker-01"})
|
||||
mock_get_ops_job.return_value = {"id": 36, "job_code": "ops-demo", "status": "success"}
|
||||
|
||||
def _assert_after_commit(**kwargs):
|
||||
self.assertTrue(connection.committed)
|
||||
self.assertEqual(36, kwargs["job_id"])
|
||||
self.assertEqual("agent_completed", kwargs["event_type"])
|
||||
self.assertEqual("info", kwargs["level"])
|
||||
self.assertEqual([101], kwargs["payload"]["step_ids"])
|
||||
|
||||
mock_append_ops_job_event.side_effect = _assert_after_commit
|
||||
|
||||
ok, message, data = agent_complete_job(
|
||||
36,
|
||||
{
|
||||
"node_code": "mainland-worker-01",
|
||||
"status": "success",
|
||||
"stdout": "ok",
|
||||
"stderr": "",
|
||||
"result": {"summary": "done"},
|
||||
"duration_ms": 120,
|
||||
},
|
||||
token="agent-token",
|
||||
)
|
||||
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual("任务结果已回写", message)
|
||||
self.assertEqual(36, data["job"]["id"])
|
||||
self.assertTrue(connection.committed)
|
||||
mock_append_ops_job_event.assert_called_once()
|
||||
mock_refresh_release_rollout_for_job.assert_called_once_with(36)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -4,11 +4,13 @@ from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
from app.services.ops_job_service import (
|
||||
_execute_control_plane_job,
|
||||
_serialize_job_row,
|
||||
create_ops_job,
|
||||
dispatch_ops_job,
|
||||
list_managed_nodes,
|
||||
upsert_managed_node,
|
||||
)
|
||||
|
||||
@@ -109,6 +111,39 @@ class OpsJobServiceTests(unittest.TestCase):
|
||||
self.assertTrue(job["steps_loaded"])
|
||||
self.assertIn("mainland-worker-02", job["summary_text"])
|
||||
|
||||
def test_upsert_managed_node_supports_ssh_entry_and_secret_flags(self) -> None:
|
||||
node_code = "test-managed-node-secret"
|
||||
ok, message, data = upsert_managed_node(
|
||||
{
|
||||
"node_code": node_code,
|
||||
"region": "mainland",
|
||||
"role": "worker",
|
||||
"ssh_entry": "121.204.244.188 root <demo-password>",
|
||||
"is_enabled": True,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(ok, message)
|
||||
node = data["node"]
|
||||
self.assertEqual("121.204.244.188", node["ssh_host"])
|
||||
self.assertEqual("root", node["ssh_user"])
|
||||
self.assertEqual(22, node["ssh_port"])
|
||||
self.assertEqual("password", node["auth_mode"])
|
||||
self.assertTrue(node["ssh_password_configured"])
|
||||
|
||||
managed = next(item for item in list_managed_nodes() if item["node_code"] == node_code)
|
||||
self.assertTrue(managed["ssh_password_configured"])
|
||||
|
||||
with get_db() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT ssh_password FROM ops_managed_node_secrets WHERE node_code = %s",
|
||||
(node_code,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
self.assertIsNotNone(row)
|
||||
self.assertEqual("demo-password", str(row[0] or ""))
|
||||
|
||||
@patch("app.services.ops_job_service.list_managed_nodes")
|
||||
@patch("app.services.ops_agent_service.build_node_agent_bootstrap_plan")
|
||||
def test_execute_control_plane_bootstrap_uses_managed_node_defaults(
|
||||
@@ -237,7 +272,11 @@ class OpsJobServiceTests(unittest.TestCase):
|
||||
self.assertEqual("keep-me", node["metadata"]["operator_note"])
|
||||
self.assertEqual("online", node["metadata"]["cluster_status"])
|
||||
|
||||
insert_call = cursor.execute.call_args_list[-1]
|
||||
insert_call = next(
|
||||
call
|
||||
for call in cursor.execute.call_args_list
|
||||
if "INSERT INTO ops_managed_nodes" in str(call.args[0])
|
||||
)
|
||||
insert_params = insert_call.args[1]
|
||||
self.assertEqual("121.204.244.248", insert_params[4])
|
||||
self.assertEqual(22, insert_params[5])
|
||||
|
||||
@@ -248,6 +248,42 @@ class OpsPolicyServiceTests(unittest.TestCase):
|
||||
self.assertTrue(preview["blocked"])
|
||||
self.assertIn("local-runtime 仅支持当前控制面本机节点", " ".join(preview["blocking_reasons"]))
|
||||
|
||||
@patch("app.services.ops_policy_service.get_cluster_snapshot")
|
||||
def test_runtime_reset_lab_state_is_critical_and_requires_approval(self, mock_cluster_snapshot) -> None:
|
||||
mock_cluster_snapshot.return_value = {
|
||||
"nodes": [
|
||||
{
|
||||
"node_code": "mainland-controller-01",
|
||||
"region": "mainland",
|
||||
"role": "control",
|
||||
"status": "online",
|
||||
"current_load": 0,
|
||||
"is_effective_worker": True,
|
||||
"detect_participating": False,
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"online_worker_nodes": 2,
|
||||
"dedicated_online_worker_nodes": 1,
|
||||
"online_control_nodes": 2,
|
||||
"busy_nodes": [],
|
||||
},
|
||||
}
|
||||
|
||||
preview = preview_ops_job_policy(
|
||||
{
|
||||
"action": "runtime.reset_lab_state",
|
||||
"target_type": "node",
|
||||
"target_node_code": "mainland-controller-01",
|
||||
"execution_mode": "remote-agent",
|
||||
"payload": {"clear_database": True, "clear_redis": True},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual("critical", preview["risk_level"])
|
||||
self.assertTrue(preview["approval_required"])
|
||||
self.assertFalse(preview["blocked"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import io
|
||||
import grp
|
||||
import os
|
||||
import pwd
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.services.ops_release_executor_core import build_remote_release_action_script, execute_release_action
|
||||
from app.services.ops_release_executor_core import (
|
||||
_pick_release_owner_group,
|
||||
build_remote_release_action_script,
|
||||
execute_release_action,
|
||||
)
|
||||
|
||||
|
||||
class _BytesResponse:
|
||||
@@ -24,20 +32,45 @@ class _BytesResponse:
|
||||
def _build_release_archive() -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
|
||||
content = b"hello-release"
|
||||
info = tarfile.TarInfo(name="bundle/README.txt")
|
||||
info.size = len(content)
|
||||
archive.addfile(info, io.BytesIO(content))
|
||||
files = {
|
||||
"README.txt": b"hello-release",
|
||||
"domain-api/deploy/systemd/domain-node-agent.service": b"[Service]\nEnvironmentFile=-/etc/default/domaincheck-worker\n",
|
||||
"domain-api/deploy/systemd/domain-worker.service": b"[Service]\nEnvironmentFile=-/etc/default/domaincheck-worker\n",
|
||||
"domain-api/deploy/systemd/domain-api.service": b"[Service]\nEnvironmentFile=-/etc/default/domaincheck-api\n",
|
||||
"domain-api/deploy/systemd/domain-sync-agent.service": b"[Service]\nEnvironmentFile=-/etc/default/domaincheck-worker\n",
|
||||
}
|
||||
for name, content in files.items():
|
||||
info = tarfile.TarInfo(name=name)
|
||||
info.size = len(content)
|
||||
archive.addfile(info, io.BytesIO(content))
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
class OpsReleaseExecutorCoreTests(unittest.TestCase):
|
||||
def test_pick_release_owner_group_prefers_service_identity_over_path_owner(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with patch(
|
||||
"app.services.ops_release_executor_core._resolve_path_owner_group",
|
||||
return_value=("root", "root"),
|
||||
):
|
||||
owner_user, owner_group = _pick_release_owner_group(
|
||||
Path(tmpdir),
|
||||
[{"service_name": "domaincheck-worker", "user": "www", "group": "www"}],
|
||||
)
|
||||
self.assertEqual(("www", "www"), (owner_user, owner_group))
|
||||
|
||||
def test_execute_release_action_deploys_archive_and_switches_current(self) -> None:
|
||||
archive_bytes = _build_release_archive()
|
||||
commands: list[tuple[str, ...]] = []
|
||||
|
||||
def run_command(command: list[str], *, timeout: int = 60):
|
||||
commands.append(tuple(command))
|
||||
if command[:3] == ["systemctl", "show", "domaincheck-worker"] and "-p" in command:
|
||||
current_user = pwd.getpwuid(os.getuid()).pw_name
|
||||
current_group = grp.getgrgid(os.getgid()).gr_name
|
||||
return 0, f"{current_user}\n{current_group}\n", ""
|
||||
if command[:2] == ["chown", "-R"]:
|
||||
return 0, "", ""
|
||||
if command[:2] == ["systemctl", "restart"]:
|
||||
return 0, "", ""
|
||||
if command[:2] == ["systemctl", "is-active"]:
|
||||
@@ -45,11 +78,13 @@ class OpsReleaseExecutorCoreTests(unittest.TestCase):
|
||||
return 0, "", ""
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
systemd_root = Path(tmpdir) / "systemd"
|
||||
ok, message, data = execute_release_action(
|
||||
{
|
||||
"release_version": "2026.04.18-rc1",
|
||||
"artifact_url": "https://example.com/domaincheck.tar.gz",
|
||||
"install_root": tmpdir,
|
||||
"systemd_unit_root": str(systemd_root),
|
||||
"restart_services": ["domaincheck-worker"],
|
||||
"health_check_services": ["domaincheck-worker"],
|
||||
"health_check_urls": [],
|
||||
@@ -70,19 +105,29 @@ class OpsReleaseExecutorCoreTests(unittest.TestCase):
|
||||
release_dir = Path(data["release_dir"])
|
||||
self.assertTrue(release_dir.exists())
|
||||
self.assertTrue((release_dir / ".release-meta.json").exists())
|
||||
self.assertTrue(any(cmd[:2] == ("chown", "-R") for cmd in commands))
|
||||
self.assertIn(("systemctl", "daemon-reload"), commands)
|
||||
self.assertIn(("systemctl", "restart", "domaincheck-worker"), commands)
|
||||
self.assertIn(("systemctl", "is-active", "domaincheck-worker"), commands)
|
||||
self.assertTrue((systemd_root / "domaincheck-node-agent.service").exists())
|
||||
self.assertTrue((systemd_root / "domaincheck-node-agent.service.d" / "current-path.conf").exists())
|
||||
|
||||
def test_build_remote_release_action_script_is_valid_python(self) -> None:
|
||||
script = build_remote_release_action_script(
|
||||
{
|
||||
"release_version": "2026.04.18-rc1",
|
||||
"artifact_url": "https://example.com/domaincheck.tar.gz",
|
||||
"switch_current": True,
|
||||
"rollback_on_failure": False,
|
||||
}
|
||||
)
|
||||
|
||||
compile(script, "<remote-release-script>", "exec")
|
||||
self.assertIn("execute_release_action(", script)
|
||||
self.assertIn("True", script)
|
||||
self.assertIn("False", script)
|
||||
self.assertIn("def collect_service_identity(", script)
|
||||
self.assertIn("def apply_release_permissions(", script)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -2,14 +2,17 @@ import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.services import ops_runtime_executor_service
|
||||
from app.services.ops_runtime_executor_service import execute_local_support_action, execute_ssh_action
|
||||
|
||||
|
||||
class OpsRuntimeExecutorServiceTests(unittest.TestCase):
|
||||
@patch("app.services.ops_runtime_executor_service.subprocess.run")
|
||||
def test_execute_ssh_action_parses_structured_json_result(self, mock_run) -> None:
|
||||
@patch("app.services.ops_runtime_executor_service._load_ssh_secret")
|
||||
def test_execute_ssh_action_parses_structured_json_result(self, mock_load_secret, mock_run) -> None:
|
||||
mock_load_secret.return_value = {"ssh_password": "", "ssh_private_key": ""}
|
||||
mock_run.return_value = subprocess.CompletedProcess(
|
||||
args=["ssh"],
|
||||
returncode=0,
|
||||
@@ -113,6 +116,105 @@ class OpsRuntimeExecutorServiceTests(unittest.TestCase):
|
||||
issued_command = mock_run.call_args.args[0]
|
||||
self.assertEqual(["systemctl", "restart", "domaincheck-worker"], issued_command)
|
||||
|
||||
@patch("app.services.ops_runtime_executor_service.subprocess.run")
|
||||
@patch("app.services.ops_runtime_executor_service._load_ssh_secret")
|
||||
@patch("app.services.ops_runtime_executor_service.paramiko.SSHClient")
|
||||
def test_execute_ssh_action_uses_paramiko_for_password_auth(self, mock_ssh_client_cls, mock_load_secret, mock_run) -> None:
|
||||
mock_load_secret.return_value = {"ssh_password": "demo-pass", "ssh_private_key": ""}
|
||||
mock_client = MagicMock()
|
||||
stdout = MagicMock()
|
||||
stderr = MagicMock()
|
||||
stdout.channel.recv_exit_status.return_value = 0
|
||||
stdout.read.return_value = (
|
||||
b'{"ok": true, "message": "health snapshot collected", "result": {"checks": {"api": {"state": "active"}}}}\n'
|
||||
)
|
||||
stderr.read.return_value = b""
|
||||
mock_client.exec_command.return_value = (None, stdout, stderr)
|
||||
mock_ssh_client_cls.return_value = mock_client
|
||||
|
||||
ok, message, data = execute_ssh_action(
|
||||
{
|
||||
"node_code": "mainland-worker-01",
|
||||
"ssh_host": "121.204.244.248",
|
||||
"ssh_port": 22,
|
||||
"ssh_user": "root",
|
||||
"auth_mode": "password",
|
||||
},
|
||||
"health.snapshot",
|
||||
{},
|
||||
)
|
||||
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual("health snapshot collected", message)
|
||||
self.assertEqual("active", data["checks"]["api"]["state"])
|
||||
self.assertEqual("password", data["transport"]["auth_mode"])
|
||||
mock_client.connect.assert_called_once()
|
||||
self.assertFalse(mock_run.called)
|
||||
|
||||
@patch("app.services.ops_runtime_executor_service.subprocess.run")
|
||||
@patch("app.services.ops_runtime_executor_service._load_ssh_secret")
|
||||
@patch("app.services.ops_runtime_executor_service.paramiko.SSHClient")
|
||||
def test_execute_ssh_action_falls_back_to_password_when_node_auth_mode_is_key_but_only_password_exists(
|
||||
self,
|
||||
mock_ssh_client_cls,
|
||||
mock_load_secret,
|
||||
mock_run,
|
||||
) -> None:
|
||||
mock_load_secret.return_value = {"ssh_password": "demo-pass", "ssh_private_key": ""}
|
||||
mock_client = MagicMock()
|
||||
stdout = MagicMock()
|
||||
stderr = MagicMock()
|
||||
stdout.channel.recv_exit_status.return_value = 0
|
||||
stdout.read.return_value = (
|
||||
b'{"ok": true, "message": "health snapshot collected", "result": {"checks": {"api": {"state": "active"}}}}\n'
|
||||
)
|
||||
stderr.read.return_value = b""
|
||||
mock_client.exec_command.return_value = (None, stdout, stderr)
|
||||
mock_ssh_client_cls.return_value = mock_client
|
||||
|
||||
ok, message, data = execute_ssh_action(
|
||||
{
|
||||
"node_code": "mainland-controller-01",
|
||||
"ssh_host": "121.204.244.188",
|
||||
"ssh_port": 22,
|
||||
"ssh_user": "root",
|
||||
"auth_mode": "key",
|
||||
},
|
||||
"health.snapshot",
|
||||
{},
|
||||
)
|
||||
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual("health snapshot collected", message)
|
||||
self.assertEqual("password", data["transport"]["auth_mode"])
|
||||
mock_client.connect.assert_called_once()
|
||||
self.assertFalse(mock_run.called)
|
||||
|
||||
@patch("app.services.ops_runtime_executor_service._load_ssh_secret")
|
||||
def test_execute_ssh_action_returns_graceful_error_when_paramiko_missing_for_password_auth(
|
||||
self,
|
||||
mock_load_secret,
|
||||
) -> None:
|
||||
mock_load_secret.return_value = {"ssh_password": "demo-pass", "ssh_private_key": ""}
|
||||
|
||||
with patch.object(ops_runtime_executor_service, "paramiko", None):
|
||||
ok, message, data = execute_ssh_action(
|
||||
{
|
||||
"node_code": "mainland-controller-01",
|
||||
"ssh_host": "121.204.244.188",
|
||||
"ssh_port": 22,
|
||||
"ssh_user": "root",
|
||||
"auth_mode": "password",
|
||||
},
|
||||
"health.snapshot",
|
||||
{},
|
||||
)
|
||||
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("paramiko", message)
|
||||
self.assertEqual("password", data["transport"]["auth_mode"])
|
||||
self.assertEqual("121.204.244.188", data["transport"]["ssh_host"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
131
domain-api/tests/test_runtime_status_service.py
Normal file
131
domain-api/tests/test_runtime_status_service.py
Normal file
@@ -0,0 +1,131 @@
|
||||
import unittest
|
||||
|
||||
from app.services.detect_service import _slice_remote_log_lines_fairly
|
||||
from app.services.runtime_status_service import _align_queue_health_with_backlog, _build_detect_node_row
|
||||
|
||||
|
||||
class RuntimeStatusServiceTests(unittest.TestCase):
|
||||
def test_build_detect_node_row_merges_queue_running_metrics(self) -> None:
|
||||
row = _build_detect_node_row(
|
||||
node_code="mainland-worker-01",
|
||||
cluster_node={
|
||||
"node_code": "mainland-worker-01",
|
||||
"region": "mainland",
|
||||
"role": "worker",
|
||||
"status": "busy",
|
||||
"is_effective_worker": True,
|
||||
"current_load": 61,
|
||||
"metadata": {
|
||||
"job_items_total": 1000,
|
||||
"job_items_claimed": 0,
|
||||
"job_items_running": 61,
|
||||
},
|
||||
},
|
||||
job_node={
|
||||
"node_code": "mainland-worker-01",
|
||||
"items_total": 0,
|
||||
"items_pending": 0,
|
||||
"items_claimed": 0,
|
||||
"items_running": 0,
|
||||
"items_completed": 0,
|
||||
"items_failed": 0,
|
||||
},
|
||||
queue_node={
|
||||
"node_code": "mainland-worker-01",
|
||||
"items_total": 1000,
|
||||
"items_pending": 739,
|
||||
"items_claimed": 200,
|
||||
"items_running": 61,
|
||||
"items_completed": 191,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
"processed_recent": 632,
|
||||
"processed_per_minute": 42.13,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(1000, row["items_total"])
|
||||
self.assertEqual(200, row["items_claimed"])
|
||||
self.assertEqual(61, row["items_running"])
|
||||
self.assertEqual("running", row["participation_state"])
|
||||
self.assertTrue(row["is_current_participant"])
|
||||
self.assertTrue(row["is_dispatch_active"])
|
||||
|
||||
def test_build_detect_node_row_uses_active_threads_as_current_execution_signal(self) -> None:
|
||||
row = _build_detect_node_row(
|
||||
node_code="mainland-worker-01",
|
||||
cluster_node={
|
||||
"node_code": "mainland-worker-01",
|
||||
"region": "mainland",
|
||||
"role": "worker",
|
||||
"status": "busy",
|
||||
"is_effective_worker": True,
|
||||
"current_load": 61,
|
||||
"metadata": {
|
||||
"active_threads": 61,
|
||||
"max_threads": 400,
|
||||
},
|
||||
},
|
||||
job_node={},
|
||||
queue_node={
|
||||
"node_code": "mainland-worker-01",
|
||||
"processed_recent": 632,
|
||||
"processed_per_minute": 42.13,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(61, row["active_threads"])
|
||||
self.assertEqual(400, row["max_threads"])
|
||||
self.assertEqual("runtime_active", row["participation_state"])
|
||||
self.assertEqual("执行中", row["participation_label"])
|
||||
self.assertTrue(row["is_dispatch_active"])
|
||||
|
||||
def test_slice_remote_log_lines_fairly_keeps_secondary_node_visible(self) -> None:
|
||||
lines = [f"[2026-04-19 17:30:{i:02d}] [mainland-controller-01] controller-{i}" for i in range(20)]
|
||||
lines.extend(
|
||||
[f"[2026-04-19 17:31:{i:02d}] [mainland-worker-01] worker-{i}" for i in range(2)]
|
||||
)
|
||||
|
||||
sliced = _slice_remote_log_lines_fairly(lines, limit=6, min_per_node=2)
|
||||
|
||||
self.assertEqual(6, len(sliced))
|
||||
self.assertTrue(any("[mainland-controller-01]" in line for line in sliced))
|
||||
self.assertTrue(any("[mainland-worker-01]" in line for line in sliced))
|
||||
|
||||
def test_align_queue_health_with_backlog_prefers_larger_runtime_snapshot(self) -> None:
|
||||
aligned = _align_queue_health_with_backlog(
|
||||
{
|
||||
"has_active_job": True,
|
||||
"queue": {
|
||||
"items_total": 120,
|
||||
"pending": 100,
|
||||
"claimed": 10,
|
||||
"running": 5,
|
||||
"completed": 5,
|
||||
"blacklisted": 0,
|
||||
"failed": 0,
|
||||
"terminal": 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
"pending_total": 1200,
|
||||
"claimed_total": 230,
|
||||
"running_total": 40,
|
||||
"completed_total": 300,
|
||||
"blacklisted_total": 12,
|
||||
"failed_total": 8,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertTrue(aligned["has_active_job"])
|
||||
self.assertEqual(1200, aligned["queue"]["pending"])
|
||||
self.assertEqual(230, aligned["queue"]["claimed"])
|
||||
self.assertEqual(40, aligned["queue"]["running"])
|
||||
self.assertEqual(300, aligned["queue"]["completed"])
|
||||
self.assertEqual(12, aligned["queue"]["blacklisted"])
|
||||
self.assertEqual(8, aligned["queue"]["failed"])
|
||||
self.assertEqual(1790, aligned["queue"]["items_total"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
31
domain-api/tests/test_settings_service.py
Normal file
31
domain-api/tests/test_settings_service.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import unittest
|
||||
|
||||
from app.services.settings_service import _normalize_thread_count, resolve_thread_count
|
||||
|
||||
|
||||
class SettingsServiceTests(unittest.TestCase):
|
||||
def test_normalize_thread_count_allows_values_above_256(self) -> None:
|
||||
self.assertEqual(512, _normalize_thread_count(512))
|
||||
|
||||
def test_normalize_thread_count_rejects_non_positive_values(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
_normalize_thread_count(0)
|
||||
|
||||
def test_resolve_thread_count_uses_large_node_override(self) -> None:
|
||||
payload = {
|
||||
"thread_count": 8,
|
||||
"node_thread_counts": {
|
||||
"mainland-controller-01": 512,
|
||||
},
|
||||
}
|
||||
|
||||
resolved = resolve_thread_count(node_code="mainland-controller-01", settings_payload=payload)
|
||||
|
||||
self.assertEqual(8, resolved["default_thread_count"])
|
||||
self.assertEqual(512, resolved["override_thread_count"])
|
||||
self.assertEqual(512, resolved["effective_thread_count"])
|
||||
self.assertEqual("node_override", resolved["source"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,7 +1,15 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.sync_agent import _append_detect_result_projection_snapshot
|
||||
from app.sync_agent import (
|
||||
_append_detect_result_projection_snapshot,
|
||||
_build_aligned_queue_health_snapshot,
|
||||
_emit_structured_tick,
|
||||
_emit_sync_result_breakdown,
|
||||
_filter_runtime_events_for_job,
|
||||
_run_pipeline_stage_processor,
|
||||
_select_projection_job_snapshots,
|
||||
)
|
||||
|
||||
|
||||
class SyncAgentTests(unittest.TestCase):
|
||||
@@ -30,6 +38,155 @@ class SyncAgentTests(unittest.TestCase):
|
||||
self.assertEqual(1, payload["progress"]["blacklisted"])
|
||||
self.assertEqual(2, payload["progress"]["failed"])
|
||||
|
||||
@patch("app.sync_agent.get_latest_unprojected_detect_job_summary")
|
||||
@patch("app.sync_agent.get_active_detect_job_summary")
|
||||
def test_select_projection_job_snapshots_returns_active_and_latest_finished(
|
||||
self,
|
||||
mock_get_active_detect_job_summary,
|
||||
mock_get_latest_unprojected_detect_job_summary,
|
||||
) -> None:
|
||||
mock_get_active_detect_job_summary.return_value = {"job_id": 101, "job_code": "running-job"}
|
||||
mock_get_latest_unprojected_detect_job_summary.return_value = {"job_id": 102, "job_code": "finished-job"}
|
||||
|
||||
snapshots = _select_projection_job_snapshots()
|
||||
|
||||
self.assertEqual(["running-job", "finished-job"], [item["job_code"] for item in snapshots])
|
||||
|
||||
@patch("app.sync_agent.get_latest_unprojected_detect_job_summary")
|
||||
@patch("app.sync_agent.get_active_detect_job_summary")
|
||||
def test_select_projection_job_snapshots_deduplicates_same_job(
|
||||
self,
|
||||
mock_get_active_detect_job_summary,
|
||||
mock_get_latest_unprojected_detect_job_summary,
|
||||
) -> None:
|
||||
mock_get_active_detect_job_summary.return_value = {"job_id": 101, "job_code": "same-job"}
|
||||
mock_get_latest_unprojected_detect_job_summary.return_value = {"job_id": 101, "job_code": "same-job"}
|
||||
|
||||
snapshots = _select_projection_job_snapshots()
|
||||
|
||||
self.assertEqual(1, len(snapshots))
|
||||
self.assertEqual("same-job", snapshots[0]["job_code"])
|
||||
|
||||
def test_build_aligned_queue_health_snapshot_overrides_mismatched_job(self) -> None:
|
||||
active_job = {
|
||||
"job_id": 1891,
|
||||
"job_code": "sync-overseas-31230",
|
||||
"status": "running",
|
||||
"progress_percent": 75.9,
|
||||
"items_total": 1000,
|
||||
"items_pending": 155,
|
||||
"items_claimed": 45,
|
||||
"items_running": 41,
|
||||
"items_completed": 759,
|
||||
"items_blacklisted": 0,
|
||||
"items_failed": 0,
|
||||
"node_stats": [
|
||||
{
|
||||
"node_code": "mainland-worker-01",
|
||||
"items_total": 710,
|
||||
"items_claimed": 45,
|
||||
"items_running": 41,
|
||||
"items_completed": 624,
|
||||
}
|
||||
],
|
||||
}
|
||||
queue_health = {
|
||||
"job": {"job_code": "sync-overseas-31273"},
|
||||
"queue": {"items_total": 1000, "pending": 838, "claimed": 162, "running": 0, "completed": 0},
|
||||
"nodes": [{"node_code": "mainland-controller-01", "items_total": 162}],
|
||||
}
|
||||
|
||||
data = _build_aligned_queue_health_snapshot(active_job, queue_health)
|
||||
|
||||
self.assertEqual("sync-overseas-31230", data["job"]["job_code"])
|
||||
self.assertEqual(155, data["queue"]["pending"])
|
||||
self.assertEqual(45, data["queue"]["claimed"])
|
||||
self.assertEqual(41, data["queue"]["running"])
|
||||
self.assertEqual(759, data["queue"]["completed"])
|
||||
self.assertEqual("mainland-worker-01", data["nodes"][0]["node_code"])
|
||||
self.assertEqual("unassigned", data["nodes"][-1]["node_code"])
|
||||
|
||||
@patch("app.sync_agent.push_debug_event")
|
||||
def test_emit_sync_result_breakdown_marks_idle_projection_as_idle(self, mock_push_debug_event) -> None:
|
||||
_emit_sync_result_breakdown(
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"sync_type": "detect_result_projection",
|
||||
"ok": False,
|
||||
"message": "当前没有可推送的detect_result_projection",
|
||||
"data": {},
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
mock_push_debug_event.assert_called_once()
|
||||
self.assertEqual("detect_result_projection_sync_idle", mock_push_debug_event.call_args.kwargs["event_type"])
|
||||
self.assertEqual("info", mock_push_debug_event.call_args.kwargs["level"])
|
||||
|
||||
@patch("app.sync_agent.push_debug_event")
|
||||
def test_emit_structured_tick_marks_idle_sync_push_as_idle(self, mock_push_debug_event) -> None:
|
||||
_emit_structured_tick(
|
||||
base_event_type="sync_push",
|
||||
ok=False,
|
||||
message="当前没有需要立即推送的同步投影",
|
||||
data={"results": []},
|
||||
)
|
||||
|
||||
mock_push_debug_event.assert_called_once()
|
||||
self.assertEqual("sync_push_idle", mock_push_debug_event.call_args.kwargs["event_type"])
|
||||
self.assertEqual("info", mock_push_debug_event.call_args.kwargs["level"])
|
||||
|
||||
def test_filter_runtime_events_for_job_discards_mismatched_jobs(self) -> None:
|
||||
events = _filter_runtime_events_for_job(
|
||||
[
|
||||
{
|
||||
"job_id": 2137,
|
||||
"node_code": "mainland-controller-01",
|
||||
"event_type": "job_created",
|
||||
"message": "new job",
|
||||
"payload": {"source_record_id": 35461},
|
||||
"created_at": "2026-04-22 01:21:29",
|
||||
},
|
||||
{
|
||||
"job_id": 1902,
|
||||
"node_code": "mainland-controller-01",
|
||||
"event_type": "worker_log",
|
||||
"message": "claim batch",
|
||||
"payload": {"job_code": "sync-overseas-31437"},
|
||||
"created_at": "2026-04-22 01:19:25",
|
||||
},
|
||||
],
|
||||
job_code="sync-overseas-31437",
|
||||
job_id=1902,
|
||||
limit=8,
|
||||
)
|
||||
|
||||
self.assertEqual(1, len(events))
|
||||
self.assertEqual("worker_log", events[0]["event_type"])
|
||||
|
||||
@patch("app.sync_agent.push_debug_event")
|
||||
@patch("app.sync_agent.process_detect_pipeline_now")
|
||||
def test_run_pipeline_stage_processor_uses_high_default_limit(
|
||||
self,
|
||||
mock_process_detect_pipeline_now,
|
||||
mock_push_debug_event,
|
||||
) -> None:
|
||||
mock_process_detect_pipeline_now.return_value = (
|
||||
True,
|
||||
"pipeline ok",
|
||||
{"processed_items": 512},
|
||||
)
|
||||
|
||||
ok, message, data = _run_pipeline_stage_processor()
|
||||
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual("pipeline ok", message)
|
||||
self.assertEqual({"processed_items": 512}, data)
|
||||
mock_process_detect_pipeline_now.assert_called_once_with(limit=5000)
|
||||
self.assertEqual("pipeline_tick_success", mock_push_debug_event.call_args.kwargs["event_type"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -2,7 +2,10 @@ import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.services.sync_push_service import (
|
||||
_build_task_pull_backlog_limits,
|
||||
_extract_detect_result_projection_events,
|
||||
_resolve_detect_result_target_job_id,
|
||||
_should_throttle_task_pull,
|
||||
ingest_runtime_projection,
|
||||
)
|
||||
|
||||
@@ -46,6 +49,61 @@ class _FakeConnection:
|
||||
|
||||
|
||||
class SyncPushServiceTests(unittest.TestCase):
|
||||
def test_build_task_pull_backlog_limits_scales_with_thread_configuration(self) -> None:
|
||||
limits = _build_task_pull_backlog_limits(
|
||||
5000,
|
||||
settings_payload={
|
||||
"thread_count": 100,
|
||||
"node_thread_counts": {
|
||||
"mainland-controller-01": 2000,
|
||||
"mainland-worker-01": 1200,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(3200, limits["estimated_total_threads"])
|
||||
self.assertEqual(6400, limits["max_pending_total"])
|
||||
self.assertEqual(3200, limits["max_register_pending"])
|
||||
self.assertEqual(800, limits["max_downstream_pending"])
|
||||
|
||||
def test_should_throttle_task_pull_when_register_backlog_overwhelms_downstream(self) -> None:
|
||||
should_throttle, reason = _should_throttle_task_pull(
|
||||
{
|
||||
"pending_total": 9438,
|
||||
"claimed_total": 410,
|
||||
"running_total": 15,
|
||||
"register_pending": 8487,
|
||||
"downstream_pending": 951,
|
||||
},
|
||||
{
|
||||
"max_pending_total": 12000,
|
||||
"max_register_pending": 3200,
|
||||
"max_downstream_pending": 800,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertTrue(should_throttle)
|
||||
self.assertEqual("register_pending", reason)
|
||||
|
||||
def test_should_not_throttle_task_pull_when_backlog_is_within_limits(self) -> None:
|
||||
should_throttle, reason = _should_throttle_task_pull(
|
||||
{
|
||||
"pending_total": 1800,
|
||||
"claimed_total": 100,
|
||||
"running_total": 300,
|
||||
"register_pending": 600,
|
||||
"downstream_pending": 120,
|
||||
},
|
||||
{
|
||||
"max_pending_total": 6400,
|
||||
"max_register_pending": 3200,
|
||||
"max_downstream_pending": 800,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertFalse(should_throttle)
|
||||
self.assertEqual("", reason)
|
||||
|
||||
def test_extract_detect_result_projection_events_adds_import_metadata(self) -> None:
|
||||
projection = {
|
||||
"job": {
|
||||
@@ -79,6 +137,43 @@ class SyncPushServiceTests(unittest.TestCase):
|
||||
self.assertTrue(event["payload"]["imported_from_projection"])
|
||||
self.assertTrue(event["payload"]["import_fingerprint"])
|
||||
|
||||
@patch("app.services.sync_push_service.get_db")
|
||||
def test_resolve_detect_result_target_job_id_prefers_matching_job_code(self, mock_get_db) -> None:
|
||||
fake_conn = _FakeConnection(rows=[(456,)])
|
||||
mock_get_db.return_value = fake_conn
|
||||
|
||||
target_job_id = _resolve_detect_result_target_job_id(
|
||||
projection={
|
||||
"job": {
|
||||
"job_code": "sync-mainland-123",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(456, target_job_id)
|
||||
self.assertIn("WHERE job_code = %s", fake_conn.cursor_obj.executed[0][0])
|
||||
|
||||
@patch("app.services.detect_job_service.get_active_detect_job_summary")
|
||||
@patch("app.services.sync_push_service.get_db")
|
||||
def test_resolve_detect_result_target_job_id_falls_back_to_active_job(
|
||||
self,
|
||||
mock_get_db,
|
||||
mock_get_active_detect_job_summary,
|
||||
) -> None:
|
||||
fake_conn = _FakeConnection(rows=[None])
|
||||
mock_get_db.return_value = fake_conn
|
||||
mock_get_active_detect_job_summary.return_value = {"job_id": 789}
|
||||
|
||||
target_job_id = _resolve_detect_result_target_job_id(
|
||||
projection={
|
||||
"job": {
|
||||
"job_code": "sync-mainland-999",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(789, target_job_id)
|
||||
|
||||
@patch("app.services.sync_push_service._import_detect_result_projection_events")
|
||||
@patch("app.services.sync_push_service.get_db")
|
||||
def test_ingest_runtime_projection_imports_detect_result_events_on_new_record(
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.services.sync_record_service import _collect_recent_domain_events
|
||||
from app.services.sync_record_service import (
|
||||
_build_runtime_projection_payload,
|
||||
_collect_recent_domain_events,
|
||||
)
|
||||
|
||||
|
||||
class SyncRecordServiceTests(unittest.TestCase):
|
||||
@@ -50,6 +54,82 @@ class SyncRecordServiceTests(unittest.TestCase):
|
||||
self.assertEqual("domain_completed", events[1]["event_type"])
|
||||
self.assertEqual("a.com", events[1]["payload"]["domain"])
|
||||
|
||||
@patch("app.services.sync_record_service._resolve_local_ip", return_value="152.53.37.118")
|
||||
@patch("app.services.sync_record_service.socket.gethostname", return_value="overseas-control-01")
|
||||
@patch("app.services.sync_record_service.settings")
|
||||
def test_build_runtime_projection_payload_zeros_execution_for_overseas_control(
|
||||
self,
|
||||
mock_settings,
|
||||
_mock_hostname,
|
||||
_mock_resolve_ip,
|
||||
) -> None:
|
||||
mock_settings.node_code = "overseas-control-01"
|
||||
mock_settings.node_region = "overseas"
|
||||
mock_settings.node_role = "control"
|
||||
mock_settings.sync_source_region = "overseas"
|
||||
mock_settings.sync_target_region = "mainland"
|
||||
|
||||
payload = _build_runtime_projection_payload(
|
||||
detect={
|
||||
"worker_online": False,
|
||||
"worker_mode": "linux-systemd",
|
||||
"active_thread_count": 0,
|
||||
"max_thread_count": 0,
|
||||
"phase_label": "当前节点不承载",
|
||||
"phase_detail": "当前节点为海外控制面,仅承载 API 控制与同步接收,不执行本机检测任务。",
|
||||
"proxy_runtime_label": "不适用",
|
||||
"proxy_runtime_reason": "not_applicable",
|
||||
"progress": {
|
||||
"pending": 125,
|
||||
"running": 293,
|
||||
"completed": 71,
|
||||
"blacklisted": 0,
|
||||
"failed": 1,
|
||||
},
|
||||
"active_job": {
|
||||
"job_id": 2027,
|
||||
"job_code": "sync-overseas-33501",
|
||||
"status": "running",
|
||||
"progress_percent": 7.2,
|
||||
"items_total": 1000,
|
||||
"items_terminal": 72,
|
||||
"items_pending": 125,
|
||||
"items_running": 293,
|
||||
"items_failed": 1,
|
||||
"node_stats": [{"node_code": "mainland-controller-01", "items_running": 293}],
|
||||
},
|
||||
},
|
||||
cluster={
|
||||
"nodes_total": 3,
|
||||
"nodes": [
|
||||
{
|
||||
"node_code": "overseas-control-01",
|
||||
"current_load": 293,
|
||||
"detect_participating": True,
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"online_worker_nodes": 3,
|
||||
"dedicated_online_worker_nodes": 1,
|
||||
"online_control_nodes": 2,
|
||||
"busy_nodes": ["overseas-control-01"],
|
||||
"stale_nodes": [],
|
||||
"offline_nodes": [],
|
||||
},
|
||||
},
|
||||
source_region="overseas",
|
||||
target_region="mainland",
|
||||
)
|
||||
|
||||
projection = payload["projection"]
|
||||
self.assertFalse(projection["worker_online"])
|
||||
self.assertFalse(projection["detect_participating"])
|
||||
self.assertEqual(0, projection["active_thread_count"])
|
||||
self.assertEqual(0, projection["max_thread_count"])
|
||||
self.assertEqual(0, projection["progress"]["running"])
|
||||
self.assertEqual("", projection["active_job"]["job_code"])
|
||||
self.assertEqual([], projection["active_job"]["node_stats"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user