Files
logs/internal/logic/controllers/resource_event.go
2026-04-27 19:26:57 +08:00

229 lines
6.7 KiB
Go

package controllers
import (
"crypto/hmac"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"git.apinb.com/bsm-sdk/core/infra"
"git.apinb.com/ops/logs/internal/config"
"git.apinb.com/ops/logs/internal/impl"
"git.apinb.com/ops/logs/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const (
resourceEventUpsert = "resource.upsert"
resourceEventDelete = "resource.delete"
)
type resourceEventRequest struct {
EventID string `json:"event_id"`
EventTime string `json:"event_time"`
EventType string `json:"event_type"`
ResourceType string `json:"resource_type"`
ResourceID string `json:"resource_id"`
ResourceName string `json:"resource_name"`
IPs []string `json:"ips"`
Hostnames []string `json:"hostnames"`
Labels map[string]string `json:"labels"`
Version int64 `json:"version"`
}
// ReceiveResourceEvent 接收 dc-control 推送的资源变更事件并落库。
func ReceiveResourceEvent(ctx *gin.Context) {
raw, err := ctx.GetRawData()
if err != nil {
infra.Response.Error(ctx, err)
return
}
if err := verifyResourceEventSignature(ctx.GetHeader("X-Event-Signature"), raw); err != nil {
infra.Response.Error(ctx, err)
return
}
var req resourceEventRequest
if err := json.Unmarshal(raw, &req); err != nil {
infra.Response.Error(ctx, err)
return
}
eventTime, err := validateResourceEventRequest(&req)
if err != nil {
infra.Response.Error(ctx, err)
return
}
if err := validateEventTimeSkew(eventTime); err != nil {
infra.Response.Error(ctx, err)
return
}
if ok, err := tryInsertResourceEventDedup(req.EventID, eventTime, req.ResourceType, req.ResourceID); err != nil {
infra.Response.Error(ctx, err)
return
} else if !ok {
infra.Response.Success(ctx, gin.H{
"ignored": true,
"reason": "duplicate_event_id",
"event_id": req.EventID,
})
return
}
var row models.ResourceMapping
err = impl.DBService.Where("resource_type = ? AND resource_id = ?", req.ResourceType, req.ResourceID).First(&row).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
infra.Response.Error(ctx, err)
return
}
// 已存在记录且版本回退时忽略该事件,避免乱序覆盖。
if err == nil && row.Version > req.Version {
infra.Response.Success(ctx, gin.H{
"ignored": true,
"reason": "stale_version",
"current": row.Version,
"incoming": req.Version,
})
return
}
ipsJSON, _ := json.Marshal(nonEmptyUnique(req.IPs))
hostnamesJSON, _ := json.Marshal(nonEmptyUnique(req.Hostnames))
labelsJSON, _ := json.Marshal(req.Labels)
row.ResourceType = req.ResourceType
row.ResourceID = req.ResourceID
row.ResourceName = req.ResourceName
row.IPsJSON = string(ipsJSON)
row.HostnamesJSON = string(hostnamesJSON)
row.LabelsJSON = string(labelsJSON)
row.Version = req.Version
row.LastEventID = req.EventID
row.EventTime = eventTime
row.IsDeleted = req.EventType == resourceEventDelete
if err := impl.DBService.Save(&row).Error; err != nil {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, gin.H{
"resource_type": row.ResourceType,
"resource_id": row.ResourceID,
"version": row.Version,
"is_deleted": row.IsDeleted,
})
}
func validateResourceEventRequest(req *resourceEventRequest) (time.Time, error) {
req.EventID = strings.TrimSpace(req.EventID)
req.EventType = strings.TrimSpace(req.EventType)
req.ResourceType = strings.TrimSpace(req.ResourceType)
req.ResourceID = strings.TrimSpace(req.ResourceID)
req.ResourceName = strings.TrimSpace(req.ResourceName)
req.EventTime = strings.TrimSpace(req.EventTime)
if req.EventID == "" {
return time.Time{}, errors.New("event_id is required")
}
if req.EventType != resourceEventUpsert && req.EventType != resourceEventDelete {
return time.Time{}, errors.New("event_type must be resource.upsert or resource.delete")
}
if req.ResourceType == "" {
return time.Time{}, errors.New("resource_type is required")
}
if req.ResourceID == "" {
return time.Time{}, errors.New("resource_id is required")
}
if req.Version <= 0 {
return time.Time{}, errors.New("version must be positive")
}
if req.EventTime == "" {
return time.Time{}, errors.New("event_time is required")
}
tm, err := time.Parse(time.RFC3339, req.EventTime)
if err != nil {
return time.Time{}, errors.New("event_time must be RFC3339")
}
return tm, nil
}
func nonEmptyUnique(items []string) []string {
if len(items) == 0 {
return nil
}
seen := make(map[string]struct{}, len(items))
out := make([]string, 0, len(items))
for _, item := range items {
v := strings.TrimSpace(item)
if v == "" {
continue
}
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
return out
}
func verifyResourceEventSignature(signature string, body []byte) error {
signature = strings.TrimSpace(signature)
signature = strings.TrimPrefix(strings.ToLower(signature), "sha256=")
secret := strings.TrimSpace(config.Spec.ResourceEvent.HMACSecret)
if secret == "" {
return errors.New("resource_event hmac_secret is not configured")
}
if signature == "" {
return errors.New("missing X-Event-Signature")
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := fmt.Sprintf("%x", mac.Sum(nil))
if !hmac.Equal([]byte(strings.ToLower(signature)), []byte(expected)) {
return errors.New("invalid X-Event-Signature")
}
return nil
}
func validateEventTimeSkew(eventTime time.Time) error {
maxSkew := config.Spec.ResourceEvent.MaxSkewSecs
if maxSkew <= 0 {
maxSkew = 300
}
diff := time.Since(eventTime)
if diff < 0 {
diff = -diff
}
if diff > time.Duration(maxSkew)*time.Second {
return errors.New("event_time out of allowed skew window")
}
return nil
}
func tryInsertResourceEventDedup(eventID string, eventTime time.Time, resourceType, resourceID string) (bool, error) {
// 先查询再插入,避免依赖数据库唯一索引存在与否。
var existed models.ResourceEventDedup
if err := impl.DBService.Where("event_id = ?", eventID).First(&existed).Error; err == nil {
return false, nil
}
row := models.ResourceEventDedup{
EventID: eventID,
EventTime: eventTime,
ResourceType: resourceType,
ResourceID: resourceID,
}
if err := impl.DBService.Create(&row).Error; err != nil {
if strings.Contains(strings.ToLower(err.Error()), "duplicate") || strings.Contains(strings.ToLower(err.Error()), "unique") {
return false, nil
}
return false, err
}
return true, nil
}