Files
getDomain/domain-api/app/node_agent.py

1709 lines
65 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import hashlib
import json
import os
import socket
import subprocess
import traceback
import time
import urllib.error
import urllib.request
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
from uuid import uuid4
from app.services.ops_action_executor_core import (
build_service_name_map,
execute_structured_action,
supports_structured_action,
)
from app.services.ops_release_executor_core import (
execute_release_action,
normalize_release_health_check_services as _release_normalize_health_check_services,
normalize_text_list as _release_normalize_text_list,
)
CONTROL_PLANE_BASE_URL = str(os.getenv("OPS_CONTROL_PLANE_BASE_URL", "")).strip().rstrip("/")
AGENT_TOKEN = str(os.getenv("OPS_AGENT_TOKEN", "")).strip()
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"
NODE_AGENT_SERVICE_NAME = str(os.getenv("NODE_AGENT_SERVICE_NAME", "domaincheck-node-agent")).strip() or "domaincheck-node-agent"
AGENT_VERSION = "0.1.0"
_APP_DIR = os.path.dirname(os.path.abspath(__file__))
_PROJECT_DIR = os.path.dirname(_APP_DIR)
_DEFAULT_QUEUE_DIR = os.path.join(_PROJECT_DIR, "runtime", "node-agent-queue", NODE_CODE or "unbound")
AGENT_QUEUE_DIR = str(os.getenv("OPS_AGENT_QUEUE_DIR", _DEFAULT_QUEUE_DIR)).strip() or _DEFAULT_QUEUE_DIR
AGENT_QUEUE_FLUSH_LIMIT = max(1, int(os.getenv("OPS_AGENT_QUEUE_FLUSH_LIMIT", "20") or 20))
_PERMANENT_DELIVERY_DETAIL_CODES = {
"agent_node_code_required",
"ops_job_not_owned_by_agent",
"ops_job_invalid_status",
}
_LAST_QUEUE_FLUSH_SUMMARY = {
"scanned": 0,
"delivered": 0,
"deferred": 0,
"dead_letter": 0,
"last_flush_at": "",
}
_LAST_RUNTIME_CONFIG_HASH = ""
def _normalize_text_list(raw_value: object) -> list[str]:
"""Keep node-agent payload normalization aligned with release executor helpers."""
return _release_normalize_text_list(raw_value)
def _normalize_release_health_check_services(
payload: dict | None,
restart_services: list[str] | None,
) -> list[str]:
"""Backward-compatible wrapper used by node-agent tests and payload shaping."""
return _release_normalize_health_check_services(
dict(payload or {}),
list(restart_services or []),
default_api_service_name=API_SERVICE_NAME,
)
def _log(message: str) -> None:
print(f"{datetime.now().isoformat(sep=' ', timespec='seconds')} [node-agent] {message}", flush=True)
def _json_env(name: str, fallback: object) -> object:
raw = str(os.getenv(name, "")).strip()
if not raw:
return fallback
try:
return json.loads(raw)
except Exception:
return fallback
AGENT_CAPABILITIES = _json_env(
"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",
"delivery.queue.flush",
"delivery.queue.replay",
"delivery.queue.discard",
"deploy.release",
],
)
AGENT_LABELS = _json_env("OPS_AGENT_LABELS", {})
class AgentResponseError(RuntimeError):
def __init__(self, message: str, *, detail_code: str = "", payload: dict | None = None):
super().__init__(message)
self.detail_code = str(detail_code or "").strip()
self.payload = dict(payload or {})
def _now_text() -> str:
return datetime.now().isoformat(sep=" ", timespec="seconds")
def _queue_pending_dir() -> str:
return os.path.join(AGENT_QUEUE_DIR, "pending")
def _queue_dead_letter_dir() -> str:
return os.path.join(AGENT_QUEUE_DIR, "dead-letter")
def _queue_discarded_dir() -> str:
return os.path.join(AGENT_QUEUE_DIR, "discarded")
def _ensure_queue_dirs() -> None:
for path in (AGENT_QUEUE_DIR, _queue_pending_dir(), _queue_dead_letter_dir(), _queue_discarded_dir()):
os.makedirs(path, exist_ok=True)
def _write_json_file(path: str, payload: dict) -> None:
temp_path = f"{path}.tmp"
with open(temp_path, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
os.replace(temp_path, path)
def _read_json_file(path: str) -> dict:
with open(path, "r", encoding="utf-8") as handle:
return json.load(handle)
def _new_request_id(prefix: str, job_id: int) -> str:
normalized_prefix = str(prefix or "request").strip() or "request"
return f"{normalized_prefix}-{int(job_id or 0)}-{int(time.time() * 1000)}-{uuid4().hex[:10]}"
def _queue_file_names(directory: str) -> list[str]:
_ensure_queue_dirs()
try:
return sorted(file_name for file_name in os.listdir(directory) if file_name.endswith(".json"))
except FileNotFoundError:
return []
def _queue_head_record(directory: str) -> dict:
file_names = _queue_file_names(directory)
if not file_names:
return {}
file_path = os.path.join(directory, file_names[0])
try:
return _read_json_file(file_path)
except Exception:
return {}
def _queue_record_id(record: dict, *, file_name: str = "") -> str:
request_id = str((record or {}).get("request_id") or "").strip()
if request_id:
return request_id
normalized_file_name = str(file_name or "").strip()
if normalized_file_name.endswith(".json"):
return normalized_file_name[:-5]
return normalized_file_name
def _queue_entries(state: str) -> list[dict]:
normalized_state = str(state or "").strip()
if normalized_state == "pending":
directory = _queue_pending_dir()
elif normalized_state == "dead_letter":
directory = _queue_dead_letter_dir()
else:
return []
entries: list[dict] = []
for file_name in _queue_file_names(directory):
file_path = os.path.join(directory, file_name)
try:
record = _read_json_file(file_path)
except Exception:
continue
entries.append(
{
"state": normalized_state,
"file_name": file_name,
"file_path": file_path,
"record_id": _queue_record_id(record, file_name=file_name),
"record": dict(record or {}),
}
)
return entries
def _matches_delivery_selector(entry: dict, selector: dict, *, allowed_states: set[str] | None = None) -> bool:
normalized_selector = dict(selector or {})
record = dict(entry.get("record") or {})
state = str(entry.get("state") or "").strip()
if allowed_states and state not in allowed_states:
return False
selected_state = str(normalized_selector.get("state") or "").strip()
if selected_state and state != selected_state:
return False
selected_record_id = str(
normalized_selector.get("record_id")
or normalized_selector.get("request_id")
or ""
).strip()
if selected_record_id and str(entry.get("record_id") or "").strip() != selected_record_id:
return False
selected_request_kind = str(normalized_selector.get("request_kind") or "").strip()
if selected_request_kind and str(record.get("kind") or "").strip() != selected_request_kind:
return False
selected_detail_code = str(normalized_selector.get("detail_code") or "").strip()
if selected_detail_code and str(record.get("last_detail_code") or "").strip() != selected_detail_code:
return False
return True
def _trim_queue_preview(records: list[dict], *, limit: int = 5) -> list[dict]:
safe_limit = max(1, int(limit or 5))
preview: list[dict] = []
for entry in list(records or [])[:safe_limit]:
record = dict(entry.get("record") or {})
preview.append(
{
"record_id": str(entry.get("record_id") or "").strip(),
"state": str(entry.get("state") or "").strip(),
"request_kind": str(record.get("kind") or "").strip(),
"request_id": str(record.get("request_id") or "").strip(),
"detail_code": str(record.get("last_detail_code") or "").strip(),
"created_at": str(record.get("created_at") or "").strip(),
"updated_at": str(record.get("updated_at") or "").strip(),
}
)
return preview
def _normalize_queue_limit(raw_value: object, *, default: int = 20, minimum: int = 1, maximum: int = 200) -> int:
try:
value = int(raw_value or default)
except Exception:
value = int(default)
return max(minimum, min(maximum, value))
def _replay_dead_letter_records(payload: dict | None = None) -> tuple[bool, str, dict]:
normalized_payload = dict(payload or {})
selector = dict(normalized_payload.get("selector") or {})
selector["state"] = str(selector.get("state") or "dead_letter").strip() or "dead_letter"
limit = _normalize_queue_limit(normalized_payload.get("limit"), default=20)
flush_after_replay = bool(normalized_payload.get("flush_after_replay", True))
reason = str(normalized_payload.get("reason") or "").strip()
matched_entries = [
entry
for entry in _queue_entries("dead_letter")
if _matches_delivery_selector(entry, selector, allowed_states={"dead_letter"})
][:limit]
if not matched_entries:
return False, "未找到符合条件的死信记录", {
"selector": selector,
"limit": limit,
"queue": _delivery_queue_snapshot(),
}
replayed_total = 0
for entry in matched_entries:
record = dict(entry.get("record") or {})
record["updated_at"] = _now_text()
record["replay_count"] = int(record.get("replay_count") or 0) + 1
record["last_replay_at"] = _now_text()
if reason:
record["last_replay_reason"] = reason
record.pop("dead_letter_at", None)
record.pop("dead_letter_reason", None)
_store_pending_delivery(record)
try:
os.remove(str(entry.get("file_path") or ""))
except FileNotFoundError:
pass
replayed_total += 1
flush_summary = {}
if flush_after_replay and replayed_total > 0:
flush_summary = _flush_delivery_queue(limit=replayed_total)
result = {
"selector": selector,
"limit": limit,
"replayed_total": replayed_total,
"flush_after_replay": flush_after_replay,
"flush_summary": flush_summary,
"matched_records": _trim_queue_preview(matched_entries),
"queue": _delivery_queue_snapshot(),
}
return True, f"已重放 {replayed_total} 条死信记录", result
def _store_discarded_delivery(record: dict, *, discarded_by: str = "", reason: str = "") -> str:
_ensure_queue_dirs()
discarded_record = dict(record or {})
discarded_record["discarded_at"] = _now_text()
discarded_record["discarded_by"] = str(discarded_by or "").strip()
discarded_record["discard_reason"] = str(reason or "").strip()
file_name = f"{int(time.time() * 1000)}-{uuid4().hex}.json"
file_path = os.path.join(_queue_discarded_dir(), file_name)
_write_json_file(file_path, discarded_record)
return file_path
def _discard_dead_letter_records(payload: dict | None = None) -> tuple[bool, str, dict]:
normalized_payload = dict(payload or {})
selector = dict(normalized_payload.get("selector") or {})
selector["state"] = str(selector.get("state") or "dead_letter").strip() or "dead_letter"
limit = _normalize_queue_limit(normalized_payload.get("limit"), default=20)
discarded_by = str(normalized_payload.get("discarded_by") or "").strip()
reason = str(normalized_payload.get("reason") or "").strip()
matched_entries = [
entry
for entry in _queue_entries("dead_letter")
if _matches_delivery_selector(entry, selector, allowed_states={"dead_letter"})
][:limit]
if not matched_entries:
return False, "未找到符合条件的死信记录", {
"selector": selector,
"limit": limit,
"queue": _delivery_queue_snapshot(),
}
discarded_total = 0
for entry in matched_entries:
_store_discarded_delivery(
dict(entry.get("record") or {}),
discarded_by=discarded_by,
reason=reason,
)
try:
os.remove(str(entry.get("file_path") or ""))
except FileNotFoundError:
pass
discarded_total += 1
result = {
"selector": selector,
"limit": limit,
"discarded_total": discarded_total,
"discarded_by": discarded_by,
"reason": reason,
"matched_records": _trim_queue_preview(matched_entries),
"queue": _delivery_queue_snapshot(),
}
return True, f"已丢弃 {discarded_total} 条死信记录", result
def _delivery_queue_snapshot() -> dict:
pending_dir = _queue_pending_dir()
dead_letter_dir = _queue_dead_letter_dir()
pending_files = _queue_file_names(pending_dir)
dead_letter_files = _queue_file_names(dead_letter_dir)
pending_head = _queue_head_record(pending_dir)
dead_letter_head = _queue_head_record(dead_letter_dir)
pending_count = len(pending_files)
dead_letter_count = len(dead_letter_files)
state = "healthy"
label = "正常"
reason = "当前没有待重试回执,也没有死信记录。"
if dead_letter_count > 0:
state = "dead_letter"
label = f"死信 {dead_letter_count}"
reason = "存在语义失败的回执/事件,自动重试已停止,建议人工查看。"
elif pending_count > 0:
state = "retrying"
label = f"待重试 {pending_count}"
reason = "存在待重试的回执/事件Node Agent 会在后续 heartbeat/poll 周期继续回放。"
elif not pending_head and not dead_letter_head:
label = "正常"
return {
"state": state,
"label": label,
"reason": reason,
"pending_count": pending_count,
"dead_letter_count": dead_letter_count,
"oldest_pending_at": str(pending_head.get("created_at") or "").strip(),
"oldest_pending_request_id": str(pending_head.get("request_id") or "").strip(),
"oldest_pending_kind": str(pending_head.get("kind") or "").strip(),
"oldest_dead_letter_at": str(
dead_letter_head.get("dead_letter_at") or dead_letter_head.get("created_at") or ""
).strip(),
"oldest_dead_letter_request_id": str(dead_letter_head.get("request_id") or "").strip(),
"oldest_dead_letter_kind": str(dead_letter_head.get("kind") or "").strip(),
"last_flush_at": str(_LAST_QUEUE_FLUSH_SUMMARY.get("last_flush_at") or "").strip(),
"last_flush_delivered": int(_LAST_QUEUE_FLUSH_SUMMARY.get("delivered") or 0),
"last_flush_deferred": int(_LAST_QUEUE_FLUSH_SUMMARY.get("deferred") or 0),
"last_flush_dead_letter": int(_LAST_QUEUE_FLUSH_SUMMARY.get("dead_letter") or 0),
}
def _response_detail_code(response: dict) -> str:
if not isinstance(response, dict):
return ""
top_level = str(response.get("detail_code") or "").strip()
if top_level:
return top_level
data = response.get("data")
if isinstance(data, dict):
return str(data.get("detail_code") or "").strip()
return ""
def _ensure_ok_response(response: dict, fallback_message: str) -> dict:
raw_code = response.get("code", 1)
try:
normalized_code = int(raw_code if raw_code not in (None, "") else 1)
except Exception:
normalized_code = 1
if normalized_code == 0:
return response
raise AgentResponseError(
str(response.get("message") or fallback_message),
detail_code=_response_detail_code(response),
payload=(response.get("data") if isinstance(response.get("data"), dict) else {}),
)
def _build_delivery_record(kind: str, path: str, payload: dict, request_id: str) -> dict:
return {
"kind": str(kind or "").strip() or "delivery",
"path": str(path or "").strip(),
"payload": dict(payload or {}),
"request_id": str(request_id or "").strip(),
"attempt_count": 0,
"created_at": _now_text(),
"updated_at": _now_text(),
"last_error": "",
"last_detail_code": "",
}
def _store_pending_delivery(record: dict) -> str:
_ensure_queue_dirs()
file_name = f"{int(time.time() * 1000)}-{uuid4().hex}.json"
file_path = os.path.join(_queue_pending_dir(), file_name)
_write_json_file(file_path, record)
return file_path
def _store_dead_letter_delivery(record: dict, *, reason: str, detail_code: str = "") -> str:
_ensure_queue_dirs()
dead_record = dict(record or {})
dead_record["dead_letter_reason"] = str(reason or "").strip()
dead_record["dead_letter_at"] = _now_text()
dead_record["last_error"] = str(reason or "").strip()
dead_record["last_detail_code"] = str(detail_code or "").strip()
file_name = f"{int(time.time() * 1000)}-{uuid4().hex}.json"
file_path = os.path.join(_queue_dead_letter_dir(), file_name)
_write_json_file(file_path, dead_record)
return file_path
def _dispatch_delivery_record(record: dict) -> dict:
kind = str(record.get("kind") or "delivery").strip() or "delivery"
path = str(record.get("path") or "").strip()
payload = record.get("payload") or {}
timeout = 15 if kind == "job_event" else 30
response = _post(path, payload, timeout=timeout)
return _ensure_ok_response(response, f"{kind} failed")
def _flush_delivery_queue(limit: int | None = None) -> dict:
global _LAST_QUEUE_FLUSH_SUMMARY
_ensure_queue_dirs()
safe_limit = max(1, int(limit or AGENT_QUEUE_FLUSH_LIMIT or 1))
summary = {
"scanned": 0,
"delivered": 0,
"deferred": 0,
"dead_letter": 0,
}
file_names = sorted(
file_name
for file_name in os.listdir(_queue_pending_dir())
if file_name.endswith(".json")
)[:safe_limit]
for file_name in file_names:
summary["scanned"] += 1
file_path = os.path.join(_queue_pending_dir(), file_name)
try:
record = _read_json_file(file_path)
except Exception as exc:
_store_dead_letter_delivery({"file_name": file_name}, reason=f"invalid queue record: {exc}")
try:
os.remove(file_path)
except FileNotFoundError:
pass
summary["dead_letter"] += 1
continue
try:
_dispatch_delivery_record(record)
try:
os.remove(file_path)
except FileNotFoundError:
pass
summary["delivered"] += 1
except AgentResponseError as exc:
record["attempt_count"] = int(record.get("attempt_count") or 0) + 1
record["updated_at"] = _now_text()
record["last_error"] = str(exc)
record["last_detail_code"] = exc.detail_code
if exc.detail_code in _PERMANENT_DELIVERY_DETAIL_CODES:
_store_dead_letter_delivery(record, reason=str(exc), detail_code=exc.detail_code)
try:
os.remove(file_path)
except FileNotFoundError:
pass
summary["dead_letter"] += 1
_log(
f"delivery moved to dead-letter: kind={record.get('kind')} request_id={record.get('request_id')} "
f"detail_code={exc.detail_code or '-'} message={exc}"
)
else:
_write_json_file(file_path, record)
summary["deferred"] += 1
except Exception as exc:
record["attempt_count"] = int(record.get("attempt_count") or 0) + 1
record["updated_at"] = _now_text()
record["last_error"] = str(exc)
_write_json_file(file_path, record)
summary["deferred"] += 1
summary["last_flush_at"] = _now_text()
_LAST_QUEUE_FLUSH_SUMMARY = dict(summary)
return summary
def _deliver_or_queue(
*,
kind: str,
path: str,
payload: dict,
request_id: str,
timeout: int = 30,
) -> dict:
record = _build_delivery_record(kind, path, payload, request_id)
try:
response = _post(path, payload, timeout=timeout)
_ensure_ok_response(response, f"{kind} failed")
return {"state": "delivered", "request_id": request_id, "detail_code": "", "response": response}
except AgentResponseError as exc:
if exc.detail_code in _PERMANENT_DELIVERY_DETAIL_CODES:
file_path = _store_dead_letter_delivery(record, reason=str(exc), detail_code=exc.detail_code)
_log(
f"{kind} dead-lettered: request_id={request_id} detail_code={exc.detail_code or '-'} "
f"message={exc} file={file_path}"
)
return {"state": "dead_letter", "request_id": request_id, "detail_code": exc.detail_code, "file_path": file_path}
file_path = _store_pending_delivery({**record, "last_error": str(exc), "last_detail_code": exc.detail_code})
_log(
f"{kind} queued for retry: request_id={request_id} detail_code={exc.detail_code or '-'} "
f"message={exc} file={file_path}"
)
return {"state": "queued", "request_id": request_id, "detail_code": exc.detail_code, "file_path": file_path}
except Exception as exc:
file_path = _store_pending_delivery({**record, "last_error": str(exc)})
_log(f"{kind} queued for retry: request_id={request_id} message={exc} file={file_path}")
return {"state": "queued", "request_id": request_id, "detail_code": "", "file_path": file_path}
def _headers() -> dict[str, str]:
return {
"Content-Type": "application/json",
"X-Domaincheck-Agent-Token": AGENT_TOKEN,
}
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:
raise RuntimeError("OPS_AGENT_TOKEN 未配置")
url = f"{CONTROL_PLANE_BASE_URL}{path}"
data = json.dumps(payload or {}, ensure_ascii=False).encode("utf-8")
request = urllib.request.Request(url=url, data=data, headers=_headers(), method=method.upper())
with urllib.request.urlopen(request, timeout=timeout) as response:
body = response.read().decode("utf-8", errors="ignore")
return json.loads(body or "{}")
def _post(path: str, payload: dict, timeout: int = AGENT_HTTP_TIMEOUT_SECONDS) -> dict:
return _request("POST", path, payload, timeout=timeout)
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:
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
def _normalize_process_count(value: object, default: int = 1) -> int:
try:
normalized = int(value)
except Exception:
return max(1, int(default or 1))
return max(1, normalized)
def _resolve_desired_process_count(bundle: dict) -> int:
normalized_bundle = dict(bundle or {})
node_process_counts = normalized_bundle.get("node_process_counts")
if isinstance(node_process_counts, dict):
override_value = node_process_counts.get(NODE_CODE)
if override_value not in (None, ""):
return _normalize_process_count(override_value, default=80)
return _normalize_process_count(normalized_bundle.get("process_count", 80), default=80)
def _worker_instance_suffixes(extra_count: int) -> list[str]:
alphabet = "abcdefghijklmnopqrstuvwxyz"
suffixes: list[str] = []
normalized_extra_count = max(0, int(extra_count or 0))
if normalized_extra_count <= 0:
return suffixes
for char in alphabet:
suffixes.append(char)
if len(suffixes) >= normalized_extra_count:
return suffixes
for first in alphabet:
for second in alphabet:
suffixes.append(f"{first}{second}")
if len(suffixes) >= normalized_extra_count:
return suffixes
return suffixes[:normalized_extra_count]
def _worker_env_path(service_name: str, suffix: str = "") -> Path:
normalized_service_name = str(service_name or "").strip() or WORKER_SERVICE_NAME
suffix_text = f"-{suffix}" if str(suffix or "").strip() else ""
return Path("/etc/default") / f"{normalized_service_name}{suffix_text}"
def _worker_instance_unit_path(service_name: str) -> Path:
normalized_service_name = str(service_name or "").strip() or WORKER_SERVICE_NAME
return Path("/etc/systemd/system") / f"{normalized_service_name}@.service"
def _worker_instance_unit_name(service_name: str, suffix: str) -> str:
normalized_service_name = str(service_name or "").strip() or WORKER_SERVICE_NAME
return f"{normalized_service_name}@{suffix}"
def _template_worker_unit_source_path() -> Path:
return Path(_PROJECT_DIR) / "deploy" / "systemd" / "domain-worker@.service"
def _render_worker_instance_env(base_env_text: str, *, instance_node_code: str, parent_node_code: str) -> str:
lines: list[str] = []
saw_node_code = False
saw_parent = False
for raw_line in str(base_env_text or "").splitlines():
if raw_line.startswith("NODE_CODE="):
lines.append(f"NODE_CODE={instance_node_code}")
saw_node_code = True
continue
if raw_line.startswith("WORKER_PARENT_NODE_CODE="):
lines.append(f"WORKER_PARENT_NODE_CODE={parent_node_code}")
saw_parent = True
continue
lines.append(raw_line)
if not saw_node_code:
lines.append(f"NODE_CODE={instance_node_code}")
if not saw_parent:
lines.append(f"WORKER_PARENT_NODE_CODE={parent_node_code}")
return "\n".join(lines).rstrip() + "\n"
def _ensure_worker_instance_unit_template(service_name: str) -> tuple[bool, str]:
unit_path = _worker_instance_unit_path(service_name)
source_path = _template_worker_unit_source_path()
if not source_path.exists():
return False, f"worker instance template missing: {source_path}"
try:
template_text = source_path.read_text(encoding="utf-8")
normalized_service_name = str(service_name or "").strip() or WORKER_SERVICE_NAME
rendered = (
template_text
.replace("domaincheck-worker-%i", f"{normalized_service_name}-%i")
.replace("domaincheck-worker@%i", f"{normalized_service_name}@%i")
)
if unit_path.exists():
existing = unit_path.read_text(encoding="utf-8")
if existing == rendered:
return True, str(unit_path)
unit_path.write_text(rendered, encoding="utf-8")
return True, str(unit_path)
except Exception as exc:
return False, f"write worker instance template failed: {exc}"
def _worker_instance_start_batch_size() -> int:
raw_value = str(os.getenv("NODE_AGENT_WORKER_RECONCILE_BATCH_SIZE", "") or "").strip()
try:
return max(1, min(32, int(raw_value or 6)))
except Exception:
return 6
def _worker_instance_start_batch_delay_seconds() -> float:
raw_value = str(os.getenv("NODE_AGENT_WORKER_RECONCILE_BATCH_DELAY_SECONDS", "") or "").strip()
try:
return max(0.0, min(30.0, float(raw_value or 1.0)))
except Exception:
return 1.0
def _chunked_units(units: list[str], size: int) -> list[list[str]]:
batch_size = max(1, int(size or 1))
return [units[index:index + batch_size] for index in range(0, len(units), batch_size)]
def _reconcile_worker_instances(bundle: dict) -> dict:
runtime_settings = dict(bundle.get("runtime_settings") or {})
worker_mode = str(runtime_settings.get("worker_mode") or "").strip() or "windows-local"
worker_service_name = str(runtime_settings.get("worker_service_name") or WORKER_SERVICE_NAME).strip() or WORKER_SERVICE_NAME
desired_process_count = _resolve_desired_process_count(bundle)
if worker_mode != "linux-systemd":
return {
"applied": False,
"reason": f"worker_mode={worker_mode}",
"desired_process_count": desired_process_count,
}
if NODE_REGION != "mainland":
return {
"applied": False,
"reason": f"region={NODE_REGION}",
"desired_process_count": desired_process_count,
}
base_env_path = _worker_env_path(worker_service_name)
if not base_env_path.exists():
return {
"applied": False,
"reason": f"base env missing: {base_env_path}",
"desired_process_count": desired_process_count,
}
ok, template_message = _ensure_worker_instance_unit_template(worker_service_name)
if not ok:
return {
"applied": False,
"reason": template_message,
"desired_process_count": desired_process_count,
}
desired_suffixes = _worker_instance_suffixes(max(0, desired_process_count - 1))
desired_units = [_worker_instance_unit_name(worker_service_name, suffix) for suffix in desired_suffixes]
desired_env_paths = {_worker_env_path(worker_service_name, suffix) for suffix in desired_suffixes}
managed_prefix = f"{worker_service_name}-"
try:
base_env_text = base_env_path.read_text(encoding="utf-8")
for suffix in desired_suffixes:
env_path = _worker_env_path(worker_service_name, suffix)
env_path.write_text(
_render_worker_instance_env(
base_env_text,
instance_node_code=f"{NODE_CODE}-{suffix}",
parent_node_code=NODE_CODE,
),
encoding="utf-8",
)
except Exception as exc:
return {
"applied": False,
"reason": f"write worker env failed: {exc}",
"desired_process_count": desired_process_count,
}
existing_env_paths: list[Path] = []
try:
for candidate in Path("/etc/default").iterdir():
if not candidate.is_file():
continue
if not candidate.name.startswith(managed_prefix):
continue
existing_env_paths.append(candidate)
except Exception:
existing_env_paths = []
stale_env_paths = [
candidate
for candidate in existing_env_paths
if candidate not in desired_env_paths
]
rc, stdout, stderr = _run(["systemctl", "daemon-reload"], timeout=90)
if rc != 0:
return {
"applied": False,
"reason": stderr or stdout or "systemctl daemon-reload failed",
"desired_process_count": desired_process_count,
}
if desired_units:
rc, stdout, stderr = _run(["systemctl", "enable", *desired_units], timeout=180)
if rc != 0:
return {
"applied": False,
"reason": stderr or stdout or "systemctl enable worker instances failed",
"desired_process_count": desired_process_count,
}
batch_size = _worker_instance_start_batch_size()
batch_delay_seconds = _worker_instance_start_batch_delay_seconds()
for batch_index, unit_batch in enumerate(_chunked_units(desired_units, batch_size), start=1):
rc, stdout, stderr = _run(
["systemctl", "start", *unit_batch],
timeout=max(120, 30 * len(unit_batch)),
)
if rc != 0:
return {
"applied": False,
"reason": (
stderr
or stdout
or f"systemctl start worker instances failed at batch {batch_index}"
),
"desired_process_count": desired_process_count,
}
if batch_delay_seconds > 0 and batch_index * batch_size < len(desired_units):
time.sleep(batch_delay_seconds)
stale_units = [
f"{worker_service_name}@{candidate.name[len(managed_prefix):]}"
for candidate in stale_env_paths
if candidate.name[len(managed_prefix):]
]
if stale_units:
_run(["systemctl", "stop", *stale_units], timeout=180)
_run(["systemctl", "disable", *stale_units], timeout=180)
for candidate in stale_env_paths:
try:
candidate.unlink()
except Exception:
continue
return {
"applied": True,
"reason": "reconciled",
"desired_process_count": desired_process_count,
"instance_service_name": f"{worker_service_name}@.service",
"extra_instances": len(desired_suffixes),
"stale_instances_removed": len(stale_units),
"template_path": template_message,
}
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:
reconcile_summary = _reconcile_worker_instances(normalized_bundle)
if reconcile_summary.get("applied"):
_log(
"worker instance reconcile refreshed: "
f"desired={reconcile_summary.get('desired_process_count', 1)} "
f"extra={reconcile_summary.get('extra_instances', 0)}"
)
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", 1000) or 1000)
node_thread_counts = dict(normalized_bundle.get("node_thread_counts") or {})
process_count = int(normalized_bundle.get("process_count", 80) or 80)
node_process_counts = dict(normalized_bundle.get("node_process_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("process_count.json", {"process_count": str(process_count)})
write_json("node_process_counts.json", node_process_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:process_count", process_count)
redis_client.set("domain_tool:node_process_counts", json.dumps(node_process_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", "process_count")
redis_client.publish("domain_tool:config_update", "node_process_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("process_count")
_publish_local_config_update("node_process_counts")
_publish_local_config_update("runtime_settings")
_publish_local_config_update("sensitive_words")
reconcile_summary = _reconcile_worker_instances(normalized_bundle)
_LAST_RUNTIME_CONFIG_HASH = bundle_hash
_log(
"runtime config applied: "
f"thread_count={thread_count} "
f"process_count={process_count} "
f"node_override={node_thread_counts.get(NODE_CODE)} "
f"process_override={node_process_counts.get(NODE_CODE)} "
f"sensitive_words={int(sensitive_words.get('total', 0) or 0)}"
)
if reconcile_summary.get("applied"):
_log(
"worker instance reconcile: "
f"desired={reconcile_summary.get('desired_process_count', 1)} "
f"extra={reconcile_summary.get('extra_instances', 0)} "
f"removed={reconcile_summary.get('stale_instances_removed', 0)}"
)
elif reconcile_summary.get("reason"):
_log(f"worker instance reconcile skipped: {reconcile_summary.get('reason')}")
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:
fallback_ip = str(socket.gethostbyname(socket.gethostname()) or "").strip()
if fallback_ip and not fallback_ip.startswith("127."):
return fallback_ip
except Exception:
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(),
"available_proxy_count": int(detect_status.get("available_proxy_count", 0) or 0),
"proxy_runtime_label": str(detect_status.get("proxy_runtime_label") or "").strip(),
"proxy_runtime_reason": str(detect_status.get("proxy_runtime_reason") or "").strip(),
"proxy_last_refresh_status": str(detect_status.get("proxy_last_refresh_status") or "").strip(),
"proxy_last_refresh_time": str(detect_status.get("proxy_last_refresh_time") or "").strip(),
"proxy_last_refresh_source_count": int(detect_status.get("proxy_last_refresh_source_count", 0) or 0),
"proxy_last_refresh_total_items": int(detect_status.get("proxy_last_refresh_total_items", 0) or 0),
"proxy_last_validated_count": int(detect_status.get("proxy_last_validated_count", 0) or 0),
"proxy_last_available_count": int(detect_status.get("proxy_last_available_count", 0) or 0),
"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(),
"available_proxy_count": 0,
"proxy_runtime_label": "",
"proxy_runtime_reason": "",
"proxy_last_refresh_status": "",
"proxy_last_refresh_time": "",
"proxy_last_refresh_source_count": 0,
"proxy_last_refresh_total_items": 0,
"proxy_last_validated_count": 0,
"proxy_last_available_count": 0,
"detect_participating": False,
"error": str(exc),
}
def _base_payload() -> dict:
return {
"node_code": NODE_CODE,
"region": NODE_REGION,
"role": NODE_ROLE,
"title": NODE_CODE,
"hostname": _hostname(),
"ip": _ip(),
"agent_version": AGENT_VERSION,
"capabilities": AGENT_CAPABILITIES,
"labels": AGENT_LABELS,
"metadata": {
"service_names": {
"api": API_SERVICE_NAME,
"worker": WORKER_SERVICE_NAME,
"sync_agent": SYNC_AGENT_SERVICE_NAME,
"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()
def _execute_action(
action: str,
payload: dict,
*,
job_id: int | None = None,
job_context: dict | None = None,
) -> tuple[bool, str, dict]:
normalized_action = str(action or "").strip()
if normalized_action == "delivery.queue.flush":
limit = _normalize_queue_limit((payload or {}).get("limit"), default=20)
summary = _flush_delivery_queue(limit=limit)
return True, "Delivery Queue 已执行冲刷", {
"action": normalized_action,
"limit": limit,
"flush_summary": summary,
"queue": _delivery_queue_snapshot(),
}
if normalized_action == "delivery.queue.replay":
return _replay_dead_letter_records(payload)
if normalized_action == "delivery.queue.discard":
return _discard_dead_letter_records(payload)
if supports_structured_action(normalized_action):
return execute_structured_action(
normalized_action,
payload,
service_names=build_service_name_map(
api_service_name=API_SERVICE_NAME,
worker_service_name=WORKER_SERVICE_NAME,
sync_agent_service_name=SYNC_AGENT_SERVICE_NAME,
node_agent_service_name=NODE_AGENT_SERVICE_NAME,
),
runner=_run,
host_context={
"hostname": _hostname(),
"ip": _ip(),
},
)
if normalized_action == "deploy.release":
return execute_release_action(
dict(payload or {}),
run_command=_run,
default_api_service_name=API_SERVICE_NAME,
event_callback=(
(lambda event_type, message, level="info", payload=None: _job_event(
int(job_id),
event_type=event_type,
message=message,
level=level,
payload={
**dict(payload or {}),
"release_context": dict((job_context or {}).get("release_context") or {}),
"step_key": str((job_context or {}).get("step_key") or "").strip(),
},
summary_text=message,
focus_ref=dict((job_context or {}).get("focus_ref") or {}),
occurred_at=_now_text(),
))
if job_id
else None
),
urlopen_func=urllib.request.urlopen,
user_agent=f"domaincheck-node-agent/{AGENT_VERSION}",
)
return False, f"unsupported action: {normalized_action}", {"action": normalized_action}
def _register() -> None:
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(), 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},
timeout=AGENT_PULL_TIMEOUT_SECONDS,
)
_ensure_ok_response(response, "agent pull failed")
data = response.get("data") or {}
return list(data.get("jobs") or [])
def _normalize_agent_job(job: dict) -> dict:
normalized_job = dict(job or {})
focus_ref = normalized_job.get("focus_ref") if isinstance(normalized_job.get("focus_ref"), dict) else {}
release_context = (
normalized_job.get("release_context") if isinstance(normalized_job.get("release_context"), dict) else {}
)
step_ref = normalized_job.get("step_ref") if isinstance(normalized_job.get("step_ref"), dict) else {}
job_id = int(normalized_job.get("job_id") or normalized_job.get("id") or 0)
step_key = str(
normalized_job.get("step_key")
or step_ref.get("step_key")
or "dispatch"
).strip() or "dispatch"
step_title = str(
normalized_job.get("step_title")
or step_ref.get("step_title")
or normalized_job.get("summary")
or normalized_job.get("action")
or step_key
).strip() or step_key
if not focus_ref:
focus_ref = {
"kind": "ops_job",
"job_id": job_id,
"job_code": str(normalized_job.get("job_code") or "").strip(),
"action": str(normalized_job.get("action") or "").strip(),
"target_node_code": str(normalized_job.get("target_node_code") or "").strip(),
}
return {
**normalized_job,
"job_id": job_id,
"job_code": str(normalized_job.get("job_code") or "").strip(),
"job_type": str(normalized_job.get("job_type") or "ops_action").strip() or "ops_action",
"action": str(normalized_job.get("action") or "").strip(),
"payload": dict(normalized_job.get("payload") or {}),
"policy": dict(normalized_job.get("policy") or {}),
"focus_ref": focus_ref,
"release_context": dict(release_context or {}),
"step_key": step_key,
"step_title": step_title,
"step_ref": {
"step_id": int(step_ref.get("step_id") or 0),
"step_key": step_key,
"step_title": step_title,
},
}
def _job_start(job_id: int, *, job: dict | None = None) -> None:
normalized_job = _normalize_agent_job(job or {"job_id": job_id})
response = _post(
f"/api/v1/ops/agent/jobs/{job_id}/start",
{
"node_code": NODE_CODE,
"job_code": normalized_job.get("job_code") or "",
"action": normalized_job.get("action") or "",
"step_key": normalized_job.get("step_key") or "",
"focus_ref": normalized_job.get("focus_ref") or {},
},
)
_ensure_ok_response(response, "job start failed")
def _job_complete(
job_id: int,
*,
status: str,
stdout: str,
stderr: str,
result: dict,
error_message: str = "",
client_request_id: str | None = None,
duration_ms: int | None = None,
summary_text: str = "",
focus_ref: dict | None = None,
step_ref: dict | None = None,
release_context: dict | None = None,
) -> dict:
request_id = str(client_request_id or "").strip() or _new_request_id("complete", job_id)
payload = {
"node_code": NODE_CODE,
"status": status,
"stdout": stdout,
"stderr": stderr,
"result": result,
"error_message": error_message,
"client_request_id": request_id,
}
if duration_ms is not None:
payload["duration_ms"] = max(0, int(duration_ms or 0))
if str(summary_text or "").strip():
payload["summary_text"] = str(summary_text).strip()
if isinstance(focus_ref, dict) and focus_ref:
payload["focus_ref"] = dict(focus_ref)
if isinstance(step_ref, dict) and step_ref:
payload["step_ref"] = dict(step_ref)
if isinstance(release_context, dict) and release_context:
payload["release_context"] = dict(release_context)
return _deliver_or_queue(
kind="job_complete",
path=f"/api/v1/ops/agent/jobs/{job_id}/complete",
payload=payload,
request_id=request_id,
timeout=AGENT_JOB_COMPLETE_TIMEOUT_SECONDS,
)
def _job_event(
job_id: int,
*,
event_type: str,
message: str,
level: str = "info",
payload: dict | None = None,
client_event_id: str | None = None,
summary_text: str = "",
focus_ref: dict | None = None,
occurred_at: str = "",
) -> dict:
request_id = str(client_event_id or "").strip() or _new_request_id("event", job_id)
delivery_payload = {
"node_code": NODE_CODE,
"event_type": event_type,
"message": message,
"level": level,
"payload": payload or {},
"client_event_id": request_id,
}
if str(summary_text or "").strip():
delivery_payload["summary_text"] = str(summary_text).strip()
if isinstance(focus_ref, dict) and focus_ref:
delivery_payload["focus_ref"] = dict(focus_ref)
if str(occurred_at or "").strip():
delivery_payload["occurred_at"] = str(occurred_at).strip()
return _deliver_or_queue(
kind="job_event",
path=f"/api/v1/ops/agent/jobs/{job_id}/events",
payload=delivery_payload,
request_id=request_id,
timeout=AGENT_JOB_EVENT_TIMEOUT_SECONDS,
)
def _process_job(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()
payload = dict(normalized_job.get("payload") or {})
if job_id <= 0 or not action:
return
started_at = time.monotonic()
_log(
"job start: "
f"id={job_id} code={normalized_job.get('job_code') or '-'} "
f"type={normalized_job.get('job_type') or '-'} "
f"step={normalized_job.get('step_key') or '-'} action={action}"
)
start_delivery_state = "delivered"
start_delivery_error = ""
try:
_job_start(job_id, job=normalized_job)
except Exception as exc:
start_delivery_state = "failed_local"
start_delivery_error = str(exc)
_log(
"job start delivery failed, continue locally: "
f"id={job_id} code={normalized_job.get('job_code') or '-'} action={action} error={exc}"
)
_job_event(
job_id,
event_type="executor_received",
message=f"node agent accepted action {action}",
summary_text=f"已接单 {action}",
focus_ref=dict(normalized_job.get("focus_ref") or {}),
occurred_at=_now_text(),
payload={
"action": action,
"job_code": normalized_job.get("job_code") or "",
"job_type": normalized_job.get("job_type") or "",
"step_key": normalized_job.get("step_key") or "",
"step_title": normalized_job.get("step_title") or "",
"release_context": dict(normalized_job.get("release_context") or {}),
"start_delivery_state": start_delivery_state,
"start_delivery_error": start_delivery_error,
},
)
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))
summary_text = str(result.get("summary_text") or result.get("summary") or message).strip()
delivery = _job_complete(
job_id,
status="success" if ok else "failed",
stdout=stdout,
stderr=stderr,
result=result,
error_message="" if ok else message,
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 complete: "
f"id={job_id} code={normalized_job.get('job_code') or '-'} "
f"action={action} ok={ok} duration_ms={duration_ms} delivery={delivery.get('state')}"
)
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 未配置")
_log(f"starting node agent: node={NODE_CODE} role={NODE_ROLE} region={NODE_REGION}")
_ensure_queue_dirs()
_register()
last_heartbeat_at = 0.0
last_runtime_config_sync_at = 0.0
while True:
now = time.time()
try:
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_with_guard(job)
else:
time.sleep(AGENT_POLL_INTERVAL_SECONDS)
except urllib.error.HTTPError as exc:
_log(f"http error: {exc.code}")
time.sleep(AGENT_POLL_INTERVAL_SECONDS)
except urllib.error.URLError as exc:
_log(f"url error: {exc}")
time.sleep(AGENT_POLL_INTERVAL_SECONDS)
except Exception as exc:
_log(f"loop error: {exc}")
time.sleep(AGENT_POLL_INTERVAL_SECONDS)
if __name__ == "__main__":
main()