任务执行1-19

This commit is contained in:
zxr
2026-06-26 12:51:50 +08:00
parent 175d9f8f94
commit 19908230f2
19 changed files with 2615 additions and 1260 deletions

View File

@@ -1,64 +1,139 @@
package ingest
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
"git.apinb.com/ops/logs/internal/config"
)
// AlertReceiveBody 与 alert ReceiveRequest 对齐(含必填 raw_data
type AlertReceiveBody struct {
AlertName string `json:"alert_name"`
Summary string `json:"summary"`
Description string `json:"description"`
SeverityCode string `json:"severity_code"`
Value string `json:"value"`
Threshold string `json:"threshold"`
Labels map[string]string `json:"labels"`
Agent string `json:"agent"`
PolicyID uint `json:"policy_id"`
RawData json.RawMessage `json:"raw_data"`
}
func forwardAlert(body AlertReceiveBody) error {
cfg := config.Spec.AlertForward
if cfg == nil || !cfg.Enabled || cfg.BaseURL == "" {
return nil
}
if len(body.RawData) == 0 {
return fmt.Errorf("raw_data 不能为空")
}
if body.AlertName == "" {
body.AlertName = "日志告警"
}
if body.PolicyID == 0 && cfg.DefaultPolicyID > 0 {
body.PolicyID = cfg.DefaultPolicyID
}
raw, err := json.Marshal(body)
if err != nil {
return err
}
url := cfg.BaseURL + "/Alert/v1/alerts/receive"
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(raw))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if cfg.InternalKey != "" {
req.Header.Set("X-Internal-Key", cfg.InternalKey)
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("alert returned HTTP %d", resp.StatusCode)
}
return nil
}
package ingest
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"git.apinb.com/ops/logs/internal/config"
)
// AlertReceiveBody 与 alert ReceiveRequest 对齐(含必填 raw_data
type AlertReceiveBody struct {
AlertName string `json:"alert_name"`
Summary string `json:"summary"`
Description string `json:"description"`
SeverityCode string `json:"severity_code"`
Value string `json:"value"`
Threshold string `json:"threshold"`
Labels map[string]string `json:"labels"`
Agent string `json:"agent"`
PolicyID uint `json:"policy_id"`
RawData json.RawMessage `json:"raw_data"`
}
type RawEventIngestBody struct {
SourceType string `json:"source_type"`
ResourceUID string `json:"resource_uid,omitempty"`
EventTime time.Time `json:"event_time"`
Severity string `json:"severity"`
Title string `json:"title"`
Message string `json:"message"`
Labels map[string]string `json:"labels,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
ParseStatus string `json:"parse_status"`
RawPayload json.RawMessage `json:"raw_payload"`
TraceID string `json:"trace_id,omitempty"`
}
func forwardAlert(body AlertReceiveBody) error {
cfg := config.Spec.AlertForward
if cfg == nil || !cfg.Enabled || cfg.BaseURL == "" {
return nil
}
if len(body.RawData) == 0 {
return fmt.Errorf("raw_data 不能为空")
}
if body.AlertName == "" {
body.AlertName = "日志告警"
}
if body.PolicyID == 0 && cfg.DefaultPolicyID > 0 {
body.PolicyID = cfg.DefaultPolicyID
}
rawEvent := buildRawEventIngestBody(body, "parsed")
raw, err := json.Marshal(rawEvent)
if err != nil {
return err
}
url := cfg.BaseURL + "/Alert/v1/raw-events/ingest"
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(raw))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if cfg.InternalKey != "" {
req.Header.Set("X-Internal-Key", cfg.InternalKey)
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("alert returned HTTP %d", resp.StatusCode)
}
return nil
}
func buildRawEventIngestBody(body AlertReceiveBody, parseStatus string) RawEventIngestBody {
sourceType := rawEventSourceType(body)
if parseStatus == "" {
parseStatus = "parsed"
}
annotations := map[string]string{
"description": body.Description,
"value": body.Value,
"threshold": body.Threshold,
"agent": body.Agent,
}
return RawEventIngestBody{
SourceType: sourceType,
ResourceUID: rawEventResourceUID(body.Labels),
EventTime: time.Now().UTC(),
Severity: body.SeverityCode,
Title: firstNonEmpty(body.AlertName, "日志事件"),
Message: firstNonEmpty(body.Summary, body.Description),
Labels: body.Labels,
Annotations: annotations,
ParseStatus: parseStatus,
RawPayload: body.RawData,
}
}
func rawEventSourceType(body AlertReceiveBody) string {
if body.Labels != nil {
switch strings.TrimSpace(body.Labels["source_subtype"]) {
case "syslog":
return "syslog"
case "snmp_trap":
return "trap"
}
}
switch body.Agent {
case "logs-syslog":
return "syslog"
case "logs-trap":
return "trap"
default:
return "syslog"
}
}
func rawEventResourceUID(labels map[string]string) string {
if labels == nil {
return ""
}
if uid := strings.TrimSpace(labels["resource_uid"]); uid != "" {
return uid
}
category := strings.TrimSpace(labels["resource_category"])
identity := strings.TrimSpace(labels["service_identity"])
if category != "" && identity != "" {
return category + ":" + identity
}
return ""
}

View File

@@ -0,0 +1,59 @@
package ingest
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.apinb.com/ops/logs/internal/config"
)
func TestForwardAlertPostsRawEventIngestPayload(t *testing.T) {
var gotPath string
var gotBody RawEventIngestBody
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode body: %v", err)
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
config.Spec.AlertForward = &config.AlertForwardConf{
Enabled: true,
BaseURL: server.URL,
InternalKey: "internal-key",
}
err := forwardAlert(AlertReceiveBody{
AlertName: "H3C Trap",
Summary: "Interface down",
Description: "Interface down",
SeverityCode: "major",
Labels: map[string]string{
"source_subtype": "snmp_trap",
"ip": "192.168.1.10",
},
Agent: "logs-trap",
RawData: json.RawMessage(`{"trap_oid":"1.3.6.1.4.1"}`),
})
if err != nil {
t.Fatalf("forwardAlert returned error: %v", err)
}
if gotPath != "/Alert/v1/raw-events/ingest" {
t.Fatalf("expected raw event ingest path, got %q", gotPath)
}
if gotBody.SourceType != "trap" {
t.Fatalf("expected trap source type, got %q", gotBody.SourceType)
}
if gotBody.ParseStatus != "parsed" {
t.Fatalf("expected parsed status, got %q", gotBody.ParseStatus)
}
if string(gotBody.RawPayload) != `{"trap_oid":"1.3.6.1.4.1"}` {
t.Fatalf("raw payload changed: %s", string(gotBody.RawPayload))
}
}

View File

@@ -1,125 +1,181 @@
package ingest
import (
"encoding/json"
"strings"
"time"
"git.apinb.com/ops/logs/internal/impl"
"git.apinb.com/ops/logs/internal/models"
)
const (
outboxStatusPending = "pending"
outboxStatusRetrying = "retrying"
outboxStatusSent = "sent"
outboxStatusDead = "dead"
)
func enqueueAlert(logEventID uint, body AlertReceiveBody) error {
payload, err := json.Marshal(body)
if err != nil {
return err
}
row := models.AlertOutbox{
LogEventID: logEventID,
PayloadJSON: string(payload),
Status: outboxStatusPending,
RetryCount: 0,
NextRetryAt: time.Now(),
LastError: "",
}
return impl.DBService.Create(&row).Error
}
func StartAlertDispatcher() {
go func() {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for range ticker.C {
processAlertOutboxBatch(20)
}
}()
}
func processAlertOutboxBatch(limit int) {
if limit <= 0 {
limit = 20
}
var rows []models.AlertOutbox
now := time.Now()
err := impl.DBService.
Where("status IN ? AND next_retry_at <= ?", []string{outboxStatusPending, outboxStatusRetrying}, now).
Order("id asc").
Limit(limit).
Find(&rows).Error
if err != nil || len(rows) == 0 {
return
}
for _, row := range rows {
processOneOutbox(row)
}
}
func processOneOutbox(row models.AlertOutbox) {
var body AlertReceiveBody
if err := json.Unmarshal([]byte(row.PayloadJSON), &body); err != nil {
markOutboxDead(row.ID, row.RetryCount, "invalid_payload: "+err.Error())
return
}
if err := forwardAlert(body); err != nil {
markOutboxRetry(row, err.Error())
return
}
_ = impl.DBService.Model(&models.AlertOutbox{}).Where("id = ?", row.ID).Updates(map[string]interface{}{
"status": outboxStatusSent,
"last_error": "",
"next_retry_at": time.Now(),
}).Error
_ = impl.DBService.Model(&models.LogEvent{}).Where("id = ?", row.LogEventID).Updates(map[string]interface{}{
"alert_sent": true,
"dispatch_status": "sent",
}).Error
}
func markOutboxRetry(row models.AlertOutbox, msg string) {
retry := row.RetryCount + 1
const maxRetry = 5
if retry > maxRetry {
markOutboxDead(row.ID, retry, msg)
return
}
backoff := time.Duration(retry*retry) * time.Second
if backoff > 60*time.Second {
backoff = 60 * time.Second
}
_ = impl.DBService.Model(&models.AlertOutbox{}).Where("id = ?", row.ID).Updates(map[string]interface{}{
"status": outboxStatusRetrying,
"retry_count": retry,
"next_retry_at": time.Now().Add(backoff),
"last_error": truncateError(msg, 1024),
}).Error
_ = impl.DBService.Model(&models.LogEvent{}).Where("id = ?", row.LogEventID).Update("dispatch_status", "retrying").Error
}
func markOutboxDead(id uint, retry int, msg string) {
_ = impl.DBService.Model(&models.AlertOutbox{}).Where("id = ?", id).Updates(map[string]interface{}{
"status": outboxStatusDead,
"retry_count": retry,
"next_retry_at": time.Now(),
"last_error": truncateError(msg, 1024),
}).Error
var row models.AlertOutbox
if err := impl.DBService.Select("log_event_id").First(&row, id).Error; err == nil && row.LogEventID > 0 {
_ = impl.DBService.Model(&models.LogEvent{}).Where("id = ?", row.LogEventID).Update("dispatch_status", "dead").Error
}
}
func truncateError(s string, n int) string {
s = strings.TrimSpace(s)
if len(s) <= n {
return s
}
return s[:n]
}
package ingest
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"git.apinb.com/ops/logs/internal/config"
"git.apinb.com/ops/logs/internal/impl"
"git.apinb.com/ops/logs/internal/models"
)
const (
outboxStatusPending = "pending"
outboxStatusRetrying = "retrying"
outboxStatusSent = "sent"
outboxStatusDead = "dead"
)
func enqueueAlert(logEventID uint, body AlertReceiveBody) error {
payload, err := json.Marshal(body)
if err != nil {
return err
}
return enqueuePayload(logEventID, string(payload))
}
func enqueueRawEvent(logEventID uint, body AlertReceiveBody, parseStatus string) error {
payload, err := json.Marshal(buildRawEventIngestBody(body, parseStatus))
if err != nil {
return err
}
return enqueuePayload(logEventID, string(payload))
}
func enqueuePayload(logEventID uint, payloadJSON string) error {
row := models.AlertOutbox{
LogEventID: logEventID,
PayloadJSON: payloadJSON,
Status: outboxStatusPending,
RetryCount: 0,
NextRetryAt: time.Now(),
LastError: "",
}
return impl.DBService.Create(&row).Error
}
func StartAlertDispatcher() {
go func() {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for range ticker.C {
processAlertOutboxBatch(20)
}
}()
}
func processAlertOutboxBatch(limit int) {
if limit <= 0 {
limit = 20
}
var rows []models.AlertOutbox
now := time.Now()
err := impl.DBService.
Where("status IN ? AND next_retry_at <= ?", []string{outboxStatusPending, outboxStatusRetrying}, now).
Order("id asc").
Limit(limit).
Find(&rows).Error
if err != nil || len(rows) == 0 {
return
}
for _, row := range rows {
processOneOutbox(row)
}
}
func processOneOutbox(row models.AlertOutbox) {
var body AlertReceiveBody
if err := json.Unmarshal([]byte(row.PayloadJSON), &body); err != nil {
markOutboxDead(row.ID, row.RetryCount, "invalid_payload: "+err.Error())
return
}
if err := forwardOutboxPayload(row.PayloadJSON, body); err != nil {
markOutboxRetry(row, err.Error())
return
}
_ = impl.DBService.Model(&models.AlertOutbox{}).Where("id = ?", row.ID).Updates(map[string]interface{}{
"status": outboxStatusSent,
"last_error": "",
"next_retry_at": time.Now(),
}).Error
_ = impl.DBService.Model(&models.LogEvent{}).Where("id = ?", row.LogEventID).Updates(map[string]interface{}{
"alert_sent": true,
"dispatch_status": "sent",
}).Error
}
func forwardOutboxPayload(payloadJSON string, legacyBody AlertReceiveBody) error {
var rawEvent RawEventIngestBody
if err := json.Unmarshal([]byte(payloadJSON), &rawEvent); err == nil && rawEvent.SourceType != "" && len(rawEvent.RawPayload) > 0 {
return forwardRawEvent(rawEvent)
}
return forwardAlert(legacyBody)
}
func forwardRawEvent(body RawEventIngestBody) error {
cfg := config.Spec.AlertForward
if cfg == nil || !cfg.Enabled || cfg.BaseURL == "" {
return nil
}
if len(body.RawPayload) == 0 {
return fmt.Errorf("raw_payload 不能为空")
}
raw, err := json.Marshal(body)
if err != nil {
return err
}
url := cfg.BaseURL + "/Alert/v1/raw-events/ingest"
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(raw))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if cfg.InternalKey != "" {
req.Header.Set("X-Internal-Key", cfg.InternalKey)
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("alert returned HTTP %d", resp.StatusCode)
}
return nil
}
func markOutboxRetry(row models.AlertOutbox, msg string) {
retry := row.RetryCount + 1
const maxRetry = 5
if retry > maxRetry {
markOutboxDead(row.ID, retry, msg)
return
}
backoff := time.Duration(retry*retry) * time.Second
if backoff > 60*time.Second {
backoff = 60 * time.Second
}
_ = impl.DBService.Model(&models.AlertOutbox{}).Where("id = ?", row.ID).Updates(map[string]interface{}{
"status": outboxStatusRetrying,
"retry_count": retry,
"next_retry_at": time.Now().Add(backoff),
"last_error": truncateError(msg, 1024),
}).Error
_ = impl.DBService.Model(&models.LogEvent{}).Where("id = ?", row.LogEventID).Update("dispatch_status", "retrying").Error
}
func markOutboxDead(id uint, retry int, msg string) {
_ = impl.DBService.Model(&models.AlertOutbox{}).Where("id = ?", id).Updates(map[string]interface{}{
"status": outboxStatusDead,
"retry_count": retry,
"next_retry_at": time.Now(),
"last_error": truncateError(msg, 1024),
}).Error
var row models.AlertOutbox
if err := impl.DBService.Select("log_event_id").First(&row, id).Error; err == nil && row.LogEventID > 0 {
_ = impl.DBService.Model(&models.LogEvent{}).Where("id = ?", row.LogEventID).Update("dispatch_status", "dead").Error
}
}
func truncateError(s string, n int) string {
s = strings.TrimSpace(s)
if len(s) <= n {
return s
}
return s[:n]
}

File diff suppressed because it is too large Load Diff

131
internal/ingest/replay.go Normal file
View File

@@ -0,0 +1,131 @@
package ingest
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"git.apinb.com/ops/logs/internal/impl"
"git.apinb.com/ops/logs/internal/models"
)
func BuildReplayRawEventPayload(ev models.LogEvent) (RawEventIngestBody, error) {
sourceType := replaySourceType(ev.SourceKind)
if sourceType == "" {
return RawEventIngestBody{}, fmt.Errorf("unsupported source kind %q", ev.SourceKind)
}
rawObj := map[string]interface{}{
"source": sourceType,
"replayed_at": time.Now().UTC().Format(time.RFC3339),
"log_entry_id": ev.ID,
"source_ip": ev.SourceIP,
"remote_addr": ev.RemoteAddr,
"raw_packet": ev.RawPayload,
}
if ev.TrapOID != "" {
rawObj["trap_oid"] = ev.TrapOID
}
if ev.NormalizedDetail != "" {
var detail interface{}
if err := json.Unmarshal([]byte(ev.NormalizedDetail), &detail); err == nil {
rawObj["parsed"] = detail
}
}
rawBytes, err := json.Marshal(rawObj)
if err != nil {
return RawEventIngestBody{}, err
}
labels := map[string]string{
"source_type": "log",
"source_subtype": replaySubtype(ev.SourceKind),
"replay_of_log_event_id": strconv.FormatUint(uint64(ev.ID), 10),
"ip": ev.SourceIP,
"remote_addr": ev.RemoteAddr,
"device": ev.DeviceName,
"job": "logs-replay",
}
if uid := replayResourceUID(ev); uid != "" {
labels["resource_uid"] = uid
}
return RawEventIngestBody{
SourceType: sourceType,
ResourceUID: replayResourceUID(ev),
EventTime: time.Now().UTC(),
Severity: firstNonEmpty(ev.SeverityCode, "warning"),
Title: replayTitle(ev),
Message: firstNonEmpty(ev.NormalizedSummary, ev.RawPayload),
Labels: labels,
Annotations: map[string]string{
"replay": "true",
"dispatch_status": ev.DispatchStatus,
},
ParseStatus: "replayed",
RawPayload: rawBytes,
}, nil
}
func EnqueueReplayLogEvent(ev models.LogEvent) (uint, error) {
body, err := BuildReplayRawEventPayload(ev)
if err != nil {
return 0, err
}
payload, err := json.Marshal(body)
if err != nil {
return 0, err
}
row := models.AlertOutbox{
LogEventID: ev.ID,
PayloadJSON: string(payload),
Status: outboxStatusPending,
RetryCount: 0,
NextRetryAt: time.Now(),
}
if err := enqueueOutboxRow(&row); err != nil {
return 0, err
}
return row.ID, nil
}
func enqueueOutboxRow(row *models.AlertOutbox) error {
return impl.DBService.Create(row).Error
}
func replaySourceType(kind string) string {
switch strings.TrimSpace(kind) {
case "syslog":
return "syslog"
case "snmp_trap", "trap":
return "trap"
default:
return ""
}
}
func replaySubtype(kind string) string {
if kind == "snmp_trap" {
return "snmp_trap"
}
return replaySourceType(kind)
}
func replayResourceUID(ev models.LogEvent) string {
if strings.Contains(ev.ResourceID, ":") {
return ev.ResourceID
}
if ev.ResourceType != "" && ev.ResourceID != "" {
return ev.ResourceType + ":" + ev.ResourceID
}
return ""
}
func replayTitle(ev models.LogEvent) string {
if ev.SourceKind == "snmp_trap" && ev.TrapOID != "" {
return "重放 SNMP Trap " + ev.TrapOID
}
if ev.SourceKind == "syslog" {
return "重放 Syslog"
}
return "重放日志事件"
}

View File

@@ -0,0 +1,61 @@
package ingest
import (
"encoding/json"
"testing"
"git.apinb.com/ops/logs/internal/models"
)
func TestSyslogRuleMatchDetailsExtractsResourceUID(t *testing.T) {
rule := models.SyslogRule{
Name: "H3C link down",
Enabled: true,
SourceMatch: "h3c-core",
MessageRegex: `Interface (?P<iface>GigabitEthernet[0-9/]+) is down`,
ResourceUIDExtractRegex: `Interface (?P<resource_uid>GigabitEthernet[0-9/]+) is down`,
}
match := syslogRuleMatchDetails(&rule, "h3c-core-01", "Interface GigabitEthernet1/0/1 is down", "")
if !match.Matched {
t.Fatal("expected rule to match")
}
if match.ResourceUID != "network:GigabitEthernet1/0/1" {
t.Fatalf("unexpected resource uid: %q", match.ResourceUID)
}
}
func TestBuildReplayRawEventPayloadMarksReplayed(t *testing.T) {
ev := models.LogEvent{
ID: 12,
SourceKind: "syslog",
SourceIP: "10.1.2.3",
RemoteAddr: "10.1.2.3:514",
DeviceName: "h3c-core-01",
RawPayload: "<189>Jun 24 10:00:01 h3c-core-01 IFNET/4/LINK_DOWN: Interface GigabitEthernet1/0/1 is down",
NormalizedSummary: "h3c-core-01: Interface GigabitEthernet1/0/1 is down",
SeverityCode: "warning",
DispatchStatus: "pending",
}
body, err := BuildReplayRawEventPayload(ev)
if err != nil {
t.Fatalf("BuildReplayRawEventPayload returned error: %v", err)
}
if body.SourceType != "syslog" {
t.Fatalf("unexpected source type: %q", body.SourceType)
}
if body.ParseStatus != "replayed" {
t.Fatalf("unexpected parse status: %q", body.ParseStatus)
}
if body.Labels["replay_of_log_event_id"] != "12" {
t.Fatalf("missing replay label: %#v", body.Labels)
}
var raw map[string]any
if err := json.Unmarshal(body.RawPayload, &raw); err != nil {
t.Fatalf("raw payload should be json: %v", err)
}
if raw["raw_packet"] == "" {
t.Fatalf("raw packet missing: %#v", raw)
}
}