fix
This commit is contained in:
@@ -273,6 +273,10 @@ func DeleteTrapShield(ctx *gin.Context) {
|
||||
|
||||
func ListLogEvents(ctx *gin.Context) {
|
||||
kind := ctx.Query("source_kind")
|
||||
resourceType := ctx.Query("resource_type")
|
||||
resourceID := ctx.Query("resource_id")
|
||||
dispatchStatus := ctx.Query("dispatch_status")
|
||||
logEventID, _ := strconv.ParseUint(ctx.DefaultQuery("log_event_id", "0"), 10, 64)
|
||||
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(ctx.DefaultQuery("page_size", "50"))
|
||||
if page < 1 {
|
||||
@@ -286,6 +290,18 @@ func ListLogEvents(ctx *gin.Context) {
|
||||
if kind != "" {
|
||||
q = q.Where("source_kind = ?", kind)
|
||||
}
|
||||
if resourceType != "" {
|
||||
q = q.Where("resource_type = ?", resourceType)
|
||||
}
|
||||
if resourceID != "" {
|
||||
q = q.Where("resource_id = ?", resourceID)
|
||||
}
|
||||
if dispatchStatus != "" {
|
||||
q = q.Where("dispatch_status = ?", dispatchStatus)
|
||||
}
|
||||
if logEventID > 0 {
|
||||
q = q.Where("id = ?", uint(logEventID))
|
||||
}
|
||||
var total int64
|
||||
_ = q.Count(&total).Error
|
||||
var rows []models.LogEvent
|
||||
|
||||
73
internal/logic/controllers/outbox.go
Normal file
73
internal/logic/controllers/outbox.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.apinb.com/bsm-sdk/core/infra"
|
||||
"git.apinb.com/ops/logs/internal/impl"
|
||||
"git.apinb.com/ops/logs/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func ListAlertOutbox(ctx *gin.Context) {
|
||||
status := strings.TrimSpace(ctx.Query("status"))
|
||||
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(ctx.DefaultQuery("page_size", "50"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 500 {
|
||||
size = 50
|
||||
}
|
||||
offset := (page - 1) * size
|
||||
|
||||
q := impl.DBService.Model(&models.AlertOutbox{})
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
var total int64
|
||||
_ = q.Count(&total).Error
|
||||
|
||||
var rows []models.AlertOutbox
|
||||
if err := q.Order("id desc").Offset(offset).Limit(size).Find(&rows).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, gin.H{
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": size,
|
||||
"items": rows,
|
||||
})
|
||||
}
|
||||
|
||||
func RetryAlertOutbox(ctx *gin.Context) {
|
||||
id, err := parseID(ctx)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, errors.New("invalid id"))
|
||||
return
|
||||
}
|
||||
var row models.AlertOutbox
|
||||
if err := impl.DBService.First(&row, id).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 手工重试时,无论失败原因如何都重置为 pending 并立即可被 worker 消费。
|
||||
if err := impl.DBService.Model(&models.AlertOutbox{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"status": "pending",
|
||||
"next_retry_at": time.Now(),
|
||||
"last_error": "",
|
||||
}).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, gin.H{
|
||||
"id": id,
|
||||
"status": "pending",
|
||||
})
|
||||
}
|
||||
|
||||
228
internal/logic/controllers/resource_event.go
Normal file
228
internal/logic/controllers/resource_event.go
Normal file
@@ -0,0 +1,228 @@
|
||||
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
|
||||
}
|
||||
|
||||
85
internal/logic/controllers/resource_event_test.go
Normal file
85
internal/logic/controllers/resource_event_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.apinb.com/ops/logs/internal/config"
|
||||
)
|
||||
|
||||
func TestValidateResourceEventRequest(t *testing.T) {
|
||||
req := &resourceEventRequest{
|
||||
EventID: "evt-1",
|
||||
EventTime: "2026-04-27T08:00:00Z",
|
||||
EventType: resourceEventUpsert,
|
||||
ResourceType: "server",
|
||||
ResourceID: "srv-1",
|
||||
ResourceName: "server-1",
|
||||
Version: 1,
|
||||
}
|
||||
if _, err := validateResourceEventRequest(req); err != nil {
|
||||
t.Fatalf("expected valid request, got error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateResourceEventRequestInvalidTime(t *testing.T) {
|
||||
req := &resourceEventRequest{
|
||||
EventID: "evt-1",
|
||||
EventTime: "bad-time",
|
||||
EventType: resourceEventUpsert,
|
||||
ResourceType: "server",
|
||||
ResourceID: "srv-1",
|
||||
Version: 1,
|
||||
}
|
||||
if _, err := validateResourceEventRequest(req); err == nil {
|
||||
t.Fatal("expected invalid time error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonEmptyUnique(t *testing.T) {
|
||||
got := nonEmptyUnique([]string{" 10.0.0.1 ", "", "10.0.0.1", "host-a", "host-a"})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("unexpected unique size: %d", len(got))
|
||||
}
|
||||
if got[0] != "10.0.0.1" || got[1] != "host-a" {
|
||||
t.Fatalf("unexpected output: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyResourceEventSignature(t *testing.T) {
|
||||
old := config.Spec.ResourceEvent.HMACSecret
|
||||
config.Spec.ResourceEvent.HMACSecret = "abc123"
|
||||
defer func() {
|
||||
config.Spec.ResourceEvent.HMACSecret = old
|
||||
}()
|
||||
|
||||
body := []byte(`{"event_id":"evt-1"}`)
|
||||
mac := hmac.New(sha256.New, []byte("abc123"))
|
||||
mac.Write(body)
|
||||
signature := fmt.Sprintf("%x", mac.Sum(nil))
|
||||
if err := verifyResourceEventSignature(signature, body); err != nil {
|
||||
t.Fatalf("expected signature to pass: %v", err)
|
||||
}
|
||||
if err := verifyResourceEventSignature("bad", body); err == nil {
|
||||
t.Fatal("expected invalid signature error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEventTimeSkew(t *testing.T) {
|
||||
old := config.Spec.ResourceEvent.MaxSkewSecs
|
||||
config.Spec.ResourceEvent.MaxSkewSecs = 60
|
||||
defer func() {
|
||||
config.Spec.ResourceEvent.MaxSkewSecs = old
|
||||
}()
|
||||
|
||||
if err := validateEventTimeSkew(time.Now()); err != nil {
|
||||
t.Fatalf("expected current time to pass: %v", err)
|
||||
}
|
||||
if err := validateEventTimeSkew(time.Now().Add(-2 * time.Minute)); err == nil {
|
||||
t.Fatal("expected skew validation to fail for old timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user