This commit is contained in:
zxr
2026-04-27 19:26:57 +08:00
parent 01c807b953
commit 694893eea3
26 changed files with 1901 additions and 15 deletions

View File

@@ -0,0 +1,104 @@
#!/usr/bin/env python3
import argparse
import json
from pathlib import Path
from typing import Any, Dict
import psycopg2
import yaml
def load_yaml(path: Path) -> Dict[str, Any]:
return yaml.safe_load(path.read_text(encoding="utf-8"))
def parse_pg_dsn(dsn: str) -> str:
parts = dsn.split()
kept = []
timezone = None
for part in parts:
if "=" not in part:
kept.append(part)
continue
k, v = part.split("=", 1)
if k.lower() == "timezone":
timezone = v
continue
kept.append(part)
if timezone:
kept.append(f"options='-c timezone={timezone}'")
return " ".join(kept)
def main() -> int:
parser = argparse.ArgumentParser(description="日志管理 E2E 测试数据准备脚本")
parser.add_argument(
"--config",
default="d:/work/ops/logs/etc/logs_dev.yaml",
help="logs 配置文件路径",
)
parser.add_argument("--run-id", required=True, help="本次测试 run id")
parser.add_argument("--cleanup-only", action="store_true", help="仅清理历史测试数据")
args = parser.parse_args()
cfg = load_yaml(Path(args.config))
dsn = parse_pg_dsn(cfg["Databases"]["Source"][0])
run_id = args.run_id
marker = f"%[E2E:{run_id}]%"
summary: Dict[str, Any] = {"run_id": run_id, "cleanup": {}, "seed": {}}
with psycopg2.connect(dsn) as conn:
with conn.cursor() as cur:
cur.execute("DELETE FROM logs_alert_outbox WHERE id IN (SELECT id FROM logs_alert_outbox ORDER BY id DESC LIMIT 0)")
cur.execute("DELETE FROM logs_syslog_rules WHERE name LIKE %s", (marker,))
summary["cleanup"]["logs_syslog_rules"] = cur.rowcount
cur.execute("DELETE FROM logs_trap_rules WHERE name LIKE %s", (marker,))
summary["cleanup"]["logs_trap_rules"] = cur.rowcount
cur.execute("DELETE FROM logs_trap_dictionary WHERE title LIKE %s", (marker,))
summary["cleanup"]["logs_trap_dictionary"] = cur.rowcount
cur.execute("DELETE FROM logs_trap_shields WHERE name LIKE %s", (marker,))
summary["cleanup"]["logs_trap_shields"] = cur.rowcount
cur.execute(
"DELETE FROM logs_resource_event_dedup WHERE event_id LIKE %s",
(f"e2e-{run_id}-%",),
)
summary["cleanup"]["logs_resource_event_dedup"] = cur.rowcount
if not args.cleanup_only:
cur.execute(
"""
INSERT INTO logs_resource_mappings(
resource_type, resource_id, resource_name, ips_json, hostnames_json, labels_json,
version, is_deleted, last_event_id, event_time, created_at, updated_at
) VALUES (%s,%s,%s,%s,%s,%s,%s,false,%s,now(),now(),now())
ON CONFLICT (resource_type, resource_id)
DO UPDATE SET
resource_name=EXCLUDED.resource_name,
ips_json=EXCLUDED.ips_json,
hostnames_json=EXCLUDED.hostnames_json,
labels_json=EXCLUDED.labels_json,
version=EXCLUDED.version,
is_deleted=false,
last_event_id=EXCLUDED.last_event_id,
event_time=now(),
updated_at=now()
""",
(
"server",
f"seed-{run_id}",
f"E2E-Seed-{run_id}",
json.dumps(["127.0.0.1"]),
json.dumps([f"e2e-host-{run_id}"]),
json.dumps({"source": "prepare_script"}),
1,
f"e2e-{run_id}-seed",
),
)
summary["seed"]["resource_mapping"] = {"resource_type": "server", "resource_id": f"seed-{run_id}"}
conn.commit()
print(json.dumps(summary, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())

108
scripts/run_e2e.ps1 Normal file
View File

@@ -0,0 +1,108 @@
# 日志管理全链路测试一键脚本:
# 1) 准备测试数据
# 2) 运行 E2E 主测试
# 3) 输出报告路径
param(
# 一键模式:本地全量测试
[switch]$Local,
# 一键模式:线上接口可控测试(自动跳过易受环境影响项)
[switch]$Online,
# 可选:指定本次测试唯一标识;不传则自动按时间生成
[string]$RunId = "",
# 可选:接口鉴权 token本服务要求 Authorization 头直接传 token
[string]$Token = "",
# 可选logs 配置文件路径
[string]$Config = "d:/work/ops/logs/etc/logs_dev.yaml",
# 可选logs 服务主机名(例如 127.0.0.1
[string]$ApiHost = "127.0.0.1",
# 可选syslog/trap 发送目标主机(默认跟随 ApiHost
[string]$IngestHost = "",
# 可选logs 完整 API 前缀(例如 https://ops-api.apinb.com/Logs/v1优先级高于 ApiHost
[string]$BaseUrl = "",
# 可选:前端入口地址(用于入口联调检测)
[string]$FrontUrl = "http://127.0.0.1:5173/log-mgmt/entries"
,
# 可选:跳过前端入口检测(仅测后端链路)
[switch]$NoFront,
# 可选:跳过 resource-events 用例(线上未配置 hmac_secret 时可用)
[switch]$SkipResourceEvent,
# 可选:跳过 trap 接收用例(线上 trap 端口不可达时可用)
[switch]$SkipTrap
)
$ErrorActionPreference = "Stop"
# 便捷模式参数展开:
# -Online: 默认走线上 API可控跳过前端/resource-events/trap
# -Local : 默认走本地全量
if ($Online -and $Local) {
throw "不能同时指定 -Online 和 -Local"
}
if ($Online) {
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {
$BaseUrl = "https://ops-api.apinb.com/Logs/v1"
}
$NoFront = $true
$SkipResourceEvent = $true
$SkipTrap = $true
}
# 未传 RunId 时,按当前时间生成,便于报告文件唯一化
if ([string]::IsNullOrWhiteSpace($RunId)) {
$RunId = Get-Date -Format "yyyyMMddHHmmss"
}
# 先准备测试数据(清理+初始化)
python "d:/work/ops/logs/scripts/prepare_logs_e2e_data.py" --run-id $RunId --config $Config
if ($LASTEXITCODE -ne 0) {
throw "prepare_logs_e2e_data.py 执行失败,退出码: $LASTEXITCODE"
}
# 组装主测试命令参数;按需跳过前端入口检查
$args = @(
"d:/work/ops/logs/scripts/run_logs_e2e.py",
"--run-id", $RunId,
"--config", $Config,
"--front-url", $FrontUrl
)
if (-not [string]::IsNullOrWhiteSpace($BaseUrl)) {
$args += @("--base-url", $BaseUrl)
} elseif ($ApiHost -match "^https?://") {
$normalized = $ApiHost.TrimEnd("/")
if ($normalized.EndsWith("/Logs/v1")) {
$args += @("--base-url", $normalized)
} else {
$args += @("--base-url", "$normalized/Logs/v1")
}
} else {
$args += @("--host", $ApiHost)
}
if (-not [string]::IsNullOrWhiteSpace($Token)) {
$args += @("--token", $Token)
}
if ($NoFront) {
$args += @("--skip-front")
}
if (-not [string]::IsNullOrWhiteSpace($IngestHost)) {
$ingestTarget = $IngestHost
if ($ingestTarget -match "^https?://") {
try {
$ingestTarget = ([System.Uri]$ingestTarget).Host
} catch {
throw "IngestHost 格式无效: $IngestHost"
}
}
$args += @("--ingest-host", $ingestTarget)
}
if ($SkipResourceEvent) {
$args += @("--skip-resource-event")
}
if ($SkipTrap) {
$args += @("--skip-trap")
}
python @args
if ($LASTEXITCODE -ne 0) {
throw "run_logs_e2e.py 执行失败,退出码: $LASTEXITCODE"
}
Write-Host "E2E报告: d:/work/ops/artifacts/logs_e2e_report_$RunId.md"

523
scripts/run_logs_e2e.py Normal file
View File

@@ -0,0 +1,523 @@
#!/usr/bin/env python3
import argparse
import asyncio
import hashlib
import hmac
import json
import socket
import subprocess
import time
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from urllib import error, request
import psycopg2
import yaml
from pysnmp.hlapi.v3arch.asyncio import CommunityData, ContextData, NotificationType, ObjectIdentity, ObjectType, OctetString, SnmpEngine, UdpTransportTarget, send_notification
def now_utc() -> datetime:
return datetime.now(timezone.utc)
def rfc3339(dt: datetime) -> str:
return dt.replace(microsecond=0).isoformat().replace("+00:00", "Z")
def parse_pg_dsn(dsn: str) -> str:
parts = dsn.split()
kept = []
timezone_value = None
for p in parts:
if "=" not in p:
kept.append(p)
continue
k, v = p.split("=", 1)
if k.lower() == "timezone":
timezone_value = v
continue
kept.append(p)
if timezone_value:
kept.append(f"options='-c timezone={timezone_value}'")
return " ".join(kept)
def load_token(default_path: Path) -> str:
if default_path.exists():
for raw in default_path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if line.startswith("JWT_TOKEN="):
token = line.split("=", 1)[1].strip()
if token:
return token.replace("Bearer ", "")
return ""
def http_json(method: str, url: str, token: str = "", body: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None) -> Tuple[int, Dict[str, Any]]:
req_headers = {"Content-Type": "application/json"}
if token:
req_headers["Authorization"] = token
if headers:
req_headers.update(headers)
data = None
if body is not None:
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
req = request.Request(url, data=data, method=method.upper(), headers=req_headers)
try:
with request.urlopen(req, timeout=12) as resp:
text = resp.read().decode("utf-8")
return resp.status, json.loads(text) if text else {}
except error.HTTPError as e:
text = e.read().decode("utf-8", errors="ignore")
try:
return e.code, json.loads(text) if text else {}
except json.JSONDecodeError:
return e.code, {"raw": text}
def payload_obj(p: Dict[str, Any]) -> Dict[str, Any]:
if isinstance(p.get("details"), dict):
return p["details"]
if isinstance(p.get("data"), dict):
return p["data"]
return {}
async def send_trap_async(addr: Tuple[str, int], run_id: str) -> None:
await send_notification(
SnmpEngine(),
CommunityData("public", mpModel=1),
await UdpTransportTarget.create(addr),
ContextData(),
"trap",
NotificationType(ObjectIdentity("1.3.6.1.4.1.8072.2.3.0.1")).add_varbinds(
ObjectType(ObjectIdentity("1.3.6.1.2.1.1.1.0"), OctetString(f"E2E-TRAP-{run_id}"))
),
)
@dataclass
class Config:
base_url: str
syslog_addr: Tuple[str, int]
trap_addr: Tuple[str, int]
db_dsn: str
hmac_secret: str
token: str
run_id: str
front_url: str
skip_front: bool
skip_resource_event: bool
skip_trap: bool
class Runner:
def __init__(self, cfg: Config) -> None:
self.cfg = cfg
self.results: List[Dict[str, Any]] = []
self.ctx: Dict[str, Any] = {}
self.failed = False
def add(self, case_id: str, title: str, expected: str, actual: str, ok: bool, steps: List[str], severity: str = "none") -> None:
self.results.append(
{
"id": case_id,
"title": title,
"steps": steps,
"expected": expected,
"actual": actual,
"result": "PASS" if ok else "FAIL",
"severity": severity if not ok else "none",
}
)
if not ok:
self.failed = True
print(f"[{'PASS' if ok else 'FAIL'}] {case_id} {title}")
def query_one(self, sql: str, params: Tuple[Any, ...]) -> Optional[Dict[str, Any]]:
with psycopg2.connect(self.cfg.db_dsn) as conn:
with conn.cursor() as cur:
cur.execute(sql, params)
row = cur.fetchone()
if not row:
return None
cols = [x[0] for x in cur.description]
return {k: row[i] for i, k in enumerate(cols)}
def query_all(self, sql: str, params: Tuple[Any, ...]) -> List[Dict[str, Any]]:
with psycopg2.connect(self.cfg.db_dsn) as conn:
with conn.cursor() as cur:
cur.execute(sql, params)
cols = [x[0] for x in cur.description]
out = []
for row in cur.fetchall():
out.append({k: row[i] for i, k in enumerate(cols)})
return out
def run(self) -> int:
self.case_health()
if self.cfg.skip_front:
self.add("TC-002", "前端关键入口服务可访问", "可按需跳过", "skip(--skip-front)", True, [f"GET {self.cfg.front_url}"], "major")
else:
self.case_front_smoke()
self.case_crud_rules()
if self.cfg.skip_resource_event:
self.add("TC-004", "resource-events 签名/时间窗/幂等", "可按需跳过", "skip(--skip-resource-event)", True, ["POST /resource-events"], "critical")
else:
self.case_resource_events()
self.case_syslog_ingest_and_entries()
if self.cfg.skip_trap:
self.add("TC-007", "Trap 接收与入库", "可按需跳过", "skip(--skip-trap)", True, [f"SNMP trap -> {self.cfg.trap_addr}"], "critical")
else:
self.case_trap_ingest()
self.case_outbox_flow()
self.write_report()
return 1 if self.failed else 0
def case_health(self) -> None:
status, payload = http_json("GET", f"{self.cfg.base_url}/ping/hello")
ok = status == 200 and payload.get("code") == 0
self.add("TC-001", "logs 健康检查", "服务返回 code=0", f"status={status}, payload={payload}", ok, [f"GET {self.cfg.base_url}/ping/hello"], "critical")
def case_front_smoke(self) -> None:
try:
with request.urlopen(self.cfg.front_url, timeout=8) as resp:
text = resp.read().decode("utf-8", errors="ignore")
ok = resp.status == 200 and "<!doctype html" in text.lower()
self.add("TC-002", "前端关键入口服务可访问", "日志页/告警队列入口所在前端可打开", f"http={resp.status}", ok, [f"GET {self.cfg.front_url}"], "major")
except Exception as e:
self.add("TC-002", "前端关键入口服务可访问", "HTTP 200", str(e), False, [f"GET {self.cfg.front_url}"], "major")
def auth_ready(self) -> bool:
if not self.cfg.token:
return False
status, payload = http_json("GET", f"{self.cfg.base_url}/syslog-rules", token=self.cfg.token)
return status == 200 and payload.get("code") == 0
def case_crud_rules(self) -> None:
if not self.auth_ready():
self.add(
"TC-003",
"规则 CRUDsyslog/trap/dictionary/suppression",
"四类规则均可增删改查",
"鉴权失败(缺少有效 JWT 或 token 过期)",
False,
["GET /syslog-rules 验证鉴权", "跳过后续 CRUD"],
"critical",
)
return
suffix = f"[E2E:{self.cfg.run_id}]"
syslog_body = {
"name": f"{suffix}-syslog",
"enabled": True,
"priority": 999,
"device_name_contains": "127.0.0.1",
"keyword_regex": "E2E-SYSLOG",
"alert_name": f"{suffix}-syslog-alert",
"severity_code": "warning",
"policy_id": 0,
}
trap_rule_body = {
"name": f"{suffix}-trap-rule",
"enabled": True,
"priority": 998,
"oid_prefix": "1.3.6.1.4.1.8072",
"varbind_match_regex": "E2E-TRAP",
"alert_name": f"{suffix}-trap-alert",
"severity_code": "warning",
"policy_id": 0,
}
dict_body = {
"oid_prefix": f"1.3.6.1.4.1.8072.{int(time.time()) % 100000}",
"title": f"{suffix}-dict",
"description": "dict for e2e",
"severity_code": "warning",
"recovery_message": "recover",
"enabled": True,
}
suppression_body = {
"name": f"{suffix}-suppress",
"enabled": True,
"source_ip_cidr": "127.0.0.1/32",
"oid_prefix": "1.3.6.1.4.1.8072",
"interface_hint": "no-match",
"time_windows_json": "[]",
}
created_ids: List[Tuple[str, int]] = []
try:
s1, p1 = http_json("POST", f"{self.cfg.base_url}/syslog-rules", token=self.cfg.token, body=syslog_body)
s2, p2 = http_json("POST", f"{self.cfg.base_url}/trap-rules", token=self.cfg.token, body=trap_rule_body)
s3, p3 = http_json("POST", f"{self.cfg.base_url}/trap-dictionary", token=self.cfg.token, body=dict_body)
s4, p4 = http_json("POST", f"{self.cfg.base_url}/trap-suppressions", token=self.cfg.token, body=suppression_body)
objs = [payload_obj(x) for x in [p1, p2, p3, p4]]
statuses_ok = all(x == 200 for x in [s1, s2, s3, s4])
for ep, obj in zip(["syslog-rules", "trap-rules", "trap-dictionary", "trap-suppressions"], objs):
if obj.get("id"):
created_ids.append((ep, int(obj["id"])))
ok = statuses_ok and len(created_ids) == 4
self.add("TC-003", "规则 CRUDsyslog/trap/dictionary/suppression", "四类规则创建成功", f"created={created_ids}", ok, ["POST 4类规则"])
finally:
for ep, rid in created_ids:
http_json("DELETE", f"{self.cfg.base_url}/{ep}/{rid}", token=self.cfg.token)
def case_resource_events(self) -> None:
if not self.auth_ready():
self.add("TC-004", "resource-events 签名/时间窗/幂等", "签名和幂等校验生效", "鉴权失败,无法执行", False, ["POST /resource-events"], "critical")
return
base_event = {
"event_id": f"e2e-{self.cfg.run_id}-{uuid.uuid4().hex[:8]}",
"event_time": rfc3339(now_utc()),
"event_type": "resource.upsert",
"resource_type": "server",
"resource_id": f"res-{self.cfg.run_id}",
"resource_name": f"E2E Resource {self.cfg.run_id}",
"ips": ["127.0.0.1"],
"hostnames": [f"e2e-host-{self.cfg.run_id}"],
"labels": {"run_id": self.cfg.run_id},
"version": 2,
}
raw = json.dumps(base_event, ensure_ascii=False).encode("utf-8")
sig = hmac.new(self.cfg.hmac_secret.encode("utf-8"), raw, hashlib.sha256).hexdigest()
s_ok, p_ok = http_json("POST", f"{self.cfg.base_url}/resource-events", token=self.cfg.token, body=base_event, headers={"X-Event-Signature": sig})
s_dup, p_dup = http_json("POST", f"{self.cfg.base_url}/resource-events", token=self.cfg.token, body=base_event, headers={"X-Event-Signature": sig})
bad = dict(base_event)
bad["event_id"] = f"{base_event['event_id']}-bad"
old_dt = now_utc() - timedelta(seconds=1000)
bad["event_time"] = rfc3339(old_dt)
raw_bad = json.dumps(bad, ensure_ascii=False).encode("utf-8")
sig_bad = hmac.new(self.cfg.hmac_secret.encode("utf-8"), raw_bad, hashlib.sha256).hexdigest()
s_old, p_old = http_json("POST", f"{self.cfg.base_url}/resource-events", token=self.cfg.token, body=bad, headers={"X-Event-Signature": sig_bad})
invalid_sig_status, p_bad_sig = http_json("POST", f"{self.cfg.base_url}/resource-events", token=self.cfg.token, body=base_event, headers={"X-Event-Signature": "bad-sign"})
base_ok = s_ok == 200 and payload_obj(p_ok).get("resource_id") == base_event["resource_id"] and payload_obj(p_dup).get("ignored") is True
# bsm-sdk 通常以 HTTP 200 + code!=0 返回错误,这里兼容两种语义。
stale_rejected = s_old != 200 or p_old.get("code", 0) != 0
bad_sig_rejected = invalid_sig_status != 200 or p_bad_sig.get("code", 0) != 0
ok = base_ok and stale_rejected and bad_sig_rejected
self.ctx["resource_id"] = base_event["resource_id"]
self.add("TC-004", "resource-events 签名/时间窗/幂等", "首次成功、重复忽略、旧时间窗拒绝、坏签名拒绝", f"ok={s_ok}/{p_ok}, dup={p_dup}, stale={s_old}/{p_old}, bad_sig={invalid_sig_status}/{p_bad_sig}", ok, ["POST 正常事件", "POST 重复事件", "POST 超时事件", "POST 错签名事件"], "critical")
def case_syslog_ingest_and_entries(self) -> None:
msg = f"<34>Apr 27 17:30:00 e2e-host-{self.cfg.run_id} app: E2E-SYSLOG-{self.cfg.run_id}"
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.sendto(msg.encode("utf-8"), self.cfg.syslog_addr)
sock.close()
time.sleep(2)
row = self.query_one(
"""
SELECT id, source_kind, source_ip, resource_type, resource_id, match_method, dispatch_status
FROM logs_events
WHERE raw_payload LIKE %s
ORDER BY id DESC
LIMIT 1
""",
(f"%E2E-SYSLOG-{self.cfg.run_id}%",),
)
ok = row is not None and row.get("source_kind") == "syslog"
if row:
self.ctx["syslog_log_event_id"] = row["id"]
self.add("TC-005", "Syslog 接收与入库 + 资源关联写入", "syslog 事件入库且带 source_ip/resource/match_method", f"row={row}", ok, [f"UDP sendto {self.cfg.syslog_addr}"])
if not self.auth_ready():
self.add("TC-006", "entries 查询筛选", "source_kind/resource/dispatch_status/log_event_id 可筛选", "鉴权失败,无法执行 API 筛选验证", False, ["GET /entries"], "major")
return
params = [
f"source_kind=syslog",
f"resource_id={self.ctx.get('resource_id', '')}",
"dispatch_status=not_applicable",
f"log_event_id={self.ctx.get('syslog_log_event_id', 0)}",
"page=1&page_size=20",
]
s, p = http_json("GET", f"{self.cfg.base_url}/entries?{'&'.join(params)}", token=self.cfg.token)
items = payload_obj(p).get("items", [])
ok2 = s == 200 and isinstance(items, list)
self.add("TC-006", "entries 查询筛选", "按组合条件可返回列表", f"status={s}, items={len(items) if isinstance(items,list) else 'n/a'}", ok2, [f"GET /entries?{'&'.join(params)}"])
def case_trap_ingest(self) -> None:
restored: List[Tuple[int, Dict[str, Any]]] = []
try:
# 预处理:若存在“全量屏蔽 trap”的规则会导致任何 trap 都不入库;测试期间暂时关闭并在结束后恢复。
if self.auth_ready():
s0, p0 = http_json("GET", f"{self.cfg.base_url}/trap-suppressions", token=self.cfg.token)
if s0 == 200:
for row in payload_obj(p0).get("items", []):
if not row.get("enabled", False):
continue
if str(row.get("source_ip_cidr", "")).strip() == "" and str(row.get("oid_prefix", "")).strip() == "" and str(row.get("interface_hint", "")).strip() == "" and str(row.get("time_windows_json", "")).strip() == "":
rid = int(row.get("id", 0))
if rid > 0:
body = dict(row)
body["enabled"] = False
http_json("PUT", f"{self.cfg.base_url}/trap-suppressions/{rid}", token=self.cfg.token, body=body)
restored.append((rid, row))
before = self.query_one(
"SELECT COUNT(1) AS cnt FROM logs_events WHERE source_kind='snmp_trap'",
(),
)
# 先用 gosnmp 发送,保证与服务端 TrapListener 编码兼容;再发一份 pysnmp。
subprocess.run(
["go", "run", "./scripts/send_trap.go", self.cfg.trap_addr[0], f"E2E-TRAP-{self.cfg.run_id}"],
check=True,
capture_output=True,
text=True,
cwd="d:/work/ops/logs",
)
asyncio.run(send_trap_async(self.cfg.trap_addr, self.cfg.run_id))
time.sleep(3)
row = self.query_one(
"""
SELECT id, source_kind, trap_o_id, raw_payload, created_at
FROM logs_events
WHERE source_kind='snmp_trap'
ORDER BY id DESC LIMIT 1
""",
(),
)
after = self.query_one(
"SELECT COUNT(1) AS cnt FROM logs_events WHERE source_kind='snmp_trap'",
(),
)
before_cnt = int((before or {}).get("cnt", 0))
after_cnt = int((after or {}).get("cnt", 0))
ok = row is not None and after_cnt > before_cnt
self.add(
"TC-007",
"Trap 接收与入库",
"snmp_trap 事件写入 logs_events",
f"before={before_cnt}, after={after_cnt}, latest={row}",
ok,
[f"SNMP trap -> {self.cfg.trap_addr}"],
"critical",
)
except Exception as e:
self.add("TC-007", "Trap 接收与入库", "snmp_trap 事件写入", str(e), False, [f"SNMP trap -> {self.cfg.trap_addr}"], "critical")
finally:
for rid, row in restored:
http_json("PUT", f"{self.cfg.base_url}/trap-suppressions/{rid}", token=self.cfg.token, body=row)
def case_outbox_flow(self) -> None:
rows = self.query_all(
"""
SELECT o.id, o.status, o.retry_count, o.log_event_id, e.dispatch_status
FROM logs_alert_outbox o
LEFT JOIN logs_events e ON e.id = o.log_event_id
ORDER BY o.id DESC
LIMIT 10
""",
(),
)
has_chain = any(r["status"] in ("pending", "retrying", "sent", "dead") for r in rows)
manual_retry_ok = False
detail = {"rows": rows}
if self.auth_ready() and rows:
target = rows[0]["id"]
s, p = http_json("POST", f"{self.cfg.base_url}/alert-outbox/{target}/retry", token=self.cfg.token)
manual_retry_ok = s == 200 and payload_obj(p).get("status") == "pending"
detail["manual_retry"] = {"status": s, "payload": p}
ok = has_chain and (manual_retry_ok or not self.auth_ready())
if not self.auth_ready():
detail["manual_retry"] = "skip(鉴权失败)"
self.add("TC-008", "outbox 链路(入队/worker/状态流转/手动重试)", "存在 outbox 状态流转,手动重试可重置 pending", json.dumps(detail, ensure_ascii=False), ok, ["查 logs_alert_outbox", "POST /alert-outbox/:id/retry"], "major")
def write_report(self) -> None:
start = now_utc()
end = now_utc()
report_path = Path(f"d:/work/ops/artifacts/logs_e2e_report_{self.cfg.run_id}.md")
report_path.parent.mkdir(parents=True, exist_ok=True)
passed = sum(1 for x in self.results if x["result"] == "PASS")
failed = len(self.results) - passed
issues = [x for x in self.results if x["result"] == "FAIL"]
lines: List[str] = []
lines.append("# 日志管理全链路测试报告")
lines.append("")
lines.append("## 测试范围")
lines.append("- Syslog/Trap 接收与入库")
lines.append("- 规则 CRUDsyslog/trap/dictionary/suppression")
lines.append("- resource-events签名、时间窗、幂等")
lines.append("- 资源关联字段落库resource_type/resource_id/match_method/source_ip")
lines.append("- entries 筛选source_kind/resource_type/resource_id/dispatch_status/log_event_id")
lines.append("- outbox入队、worker、状态、手动重试")
lines.append("- 前端关键入口联调(日志页、告警队列入口)")
lines.append("")
lines.append("## 环境信息")
lines.append(f"- 执行时间: {rfc3339(start)} ~ {rfc3339(end)}")
lines.append(f"- logs API: `{self.cfg.base_url}`")
lines.append(f"- syslog: `{self.cfg.syslog_addr[0]}:{self.cfg.syslog_addr[1]}`")
lines.append(f"- trap: `{self.cfg.trap_addr[0]}:{self.cfg.trap_addr[1]}`")
lines.append(f"- front: `{self.cfg.front_url}`")
lines.append(f"- run_id: `{self.cfg.run_id}`")
lines.append("")
lines.append("## 用例清单(编号、步骤、预期、实际、结论)")
for r in self.results:
lines.append(f"- **{r['id']} {r['title']}**")
lines.append(f" - 步骤: {'; '.join(r['steps'])}")
lines.append(f" - 预期: {r['expected']}")
lines.append(f" - 实际: {r['actual']}")
lines.append(f" - 结论: {r['result']}")
lines.append("")
lines.append("## 问题清单(严重级别)")
if not issues:
lines.append("- 无失败项。")
else:
for i in issues:
lines.append(f"- [{i['severity'].upper()}] {i['id']} {i['title']}{i['actual']}")
lines.append("")
lines.append("## 链路结论(是否可上线联调)")
if failed == 0:
lines.append(f"- 结论:可上线联调({passed} 通过 / {failed} 失败)。")
else:
lines.append(f"- 结论:暂不建议上线联调({passed} 通过 / {failed} 失败)。")
lines.append("- 建议先修复高优先级失败项后再回归。")
report_path.write_text("\n".join(lines), encoding="utf-8")
print(f"REPORT_PATH={report_path}")
def build_config(args: argparse.Namespace) -> Config:
data = yaml.safe_load(Path(args.config).read_text(encoding="utf-8"))
host = args.host
if args.base_url:
base_url = args.base_url.rstrip("/")
else:
base_url = f"http://{host}:{data['Port']}/Logs/v1"
syslog_port = int(str(data["Ingest"]["syslog_listen_addr"]).split(":")[-1])
trap_port = int(str(data["Ingest"]["trap_listen_addr"]).split(":")[-1])
token = args.token or load_token(Path("d:/work/ops/scripts/test_alert_dispatch.env"))
run_id = args.run_id or datetime.now().strftime("%Y%m%d%H%M%S")
return Config(
base_url=base_url,
syslog_addr=(args.ingest_host or host, syslog_port),
trap_addr=(args.ingest_host or host, trap_port),
db_dsn=parse_pg_dsn(data["Databases"]["Source"][0]),
hmac_secret=data["ResourceEvent"]["hmac_secret"],
token=token,
run_id=run_id,
front_url=args.front_url,
skip_front=args.skip_front,
skip_resource_event=args.skip_resource_event,
skip_trap=args.skip_trap,
)
def main() -> int:
parser = argparse.ArgumentParser(description="日志管理全链路测试脚本(真实执行)")
parser.add_argument("--config", default="d:/work/ops/logs/etc/logs_dev.yaml")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--ingest-host", default="", help="syslog/trap 发送目标主机,默认与 --host 相同")
parser.add_argument("--base-url", default="", help="完整 API 前缀,例如 https://ops-api.apinb.com/Logs/v1")
parser.add_argument("--token", default="", help="Authorization 值(例如 Bearer xxx")
parser.add_argument("--run-id", default="")
parser.add_argument("--front-url", default="http://127.0.0.1:5173/log-mgmt/entries")
parser.add_argument("--skip-front", action="store_true", help="跳过前端入口检查")
parser.add_argument("--skip-resource-event", action="store_true", help="跳过 resource-events 用例")
parser.add_argument("--skip-trap", action="store_true", help="跳过 trap 接收用例")
args = parser.parse_args()
cfg = build_config(args)
runner = Runner(cfg)
return runner.run()
if __name__ == "__main__":
raise SystemExit(main())

47
scripts/send_trap.go Normal file
View File

@@ -0,0 +1,47 @@
package main
import (
"fmt"
"os"
"time"
"github.com/gosnmp/gosnmp"
)
func main() {
host := "127.0.0.1"
port := uint16(9162)
msg := "E2E-TRAP-GO"
if len(os.Args) > 1 && os.Args[1] != "" {
host = os.Args[1]
}
if len(os.Args) > 2 && os.Args[2] != "" {
msg = os.Args[2]
}
g := &gosnmp.GoSNMP{
Target: host,
Port: port,
Version: gosnmp.Version2c,
Community: "public",
Timeout: 2 * time.Second,
Retries: 1,
}
if err := g.Connect(); err != nil {
panic(err)
}
defer g.Conn.Close()
trap := gosnmp.SnmpTrap{
Variables: []gosnmp.SnmpPDU{
{
Name: "1.3.6.1.2.1.1.1.0",
Type: gosnmp.OctetString,
Value: msg,
},
},
}
if _, err := g.SendTrap(trap); err != nil {
panic(err)
}
fmt.Println("trap_sent")
}