feat(audit): persist Agnet control-plane audit events to DB
Sprint 1 of the post-product-doc gap closure. Replaces the previous
in-process `agnetEvents map[string][]agnetEvent` (lost on every
container restart) with a real GORM table `agnet_audit_events`.
What changed:
- model/agnet_audit.go (new): AgnetAuditEvent model + InsertAgnetAuditEvent
/ ListAgnetAuditEvents / ListAgnetAuditEventsByDeployment helpers.
Indexes picked for the dashboard queries: user_id, deployment_id,
binding_scope, occurred_at desc.
- model/main.go: AutoMigrate &AgnetAuditEvent{} alongside the existing
schema (SQLite/MySQL/PostgreSQL compatible per CLAUDE.md Rule 2).
- controller/agnet_control_plane.go: drop agnetEvents map; the 3
producer sites (deployment accepted / stop / sk_snapshot_refreshed)
now call recordAgnetAuditEvent which writes to DB best-effort.
The 3 reader sites (events list / logs / audit-logs) now query
the table; AgnetListAuditLogs also supports limit/offset pagination.
- controller/agnet_control_plane_test.go: reset helper no longer
touches the deleted map.
- model/agnet_audit_test.go (new): 4 tests covering persistence,
nil-guard production safety, filter+paginate, chronological reads.
Sidebar UX:
- "Preparation checklist" → "Resource binding"
Per product-package doc README §统一表述 — user-facing term is
"资源绑定" not "准备清单". URL /sk-sources kept to preserve
bookmarks; can rename in a later pass with redirect.
Verification:
- go test ./controller/... ./middleware/... ./model/... all green
- go vet clean
- frontend tsc --noEmit clean
- audit writes are best-effort: errors log via SysLog but never
fail the user API call; DB nil-guards in place
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,12 +3,14 @@ package controller
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/heicode/manager/common"
|
||||
"github.com/heicode/manager/model"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -216,10 +218,49 @@ type agnetSKSnapshot struct {
|
||||
var (
|
||||
agnetMu sync.RWMutex
|
||||
agnetDeployments = make(map[string]agnetDeploymentRecord)
|
||||
agnetEvents = make(map[string][]agnetEvent)
|
||||
agnetSnapshots = make(map[string][]agnetSKSnapshot)
|
||||
)
|
||||
|
||||
// recordAgnetAuditEvent persists `evt` to model.AgnetAuditEvent
|
||||
// (audit table) so dashboards and admin queries survive a container
|
||||
// restart. The previous implementation appended to an in-process
|
||||
// map[string][]agnetEvent which was lost on every redeploy.
|
||||
//
|
||||
// `requestID` and `result` are usually unknown at the producer site
|
||||
// (we're called from inside the deployment lifecycle, not from a
|
||||
// request handler with a c.GetString), so they're free-form here.
|
||||
// Pass "" / "ok" when you don't have specifics.
|
||||
func recordAgnetAuditEvent(evt agnetEvent, actor, resource, requestID, result string) {
|
||||
if result == "" {
|
||||
result = "ok"
|
||||
}
|
||||
model.InsertAgnetAuditEvent(&model.AgnetAuditEvent{
|
||||
EventID: evt.EventID,
|
||||
Event: evt.Event,
|
||||
Actor: actor,
|
||||
Resource: resource,
|
||||
UserID: evt.UserID,
|
||||
ChannelID: evt.ChannelID,
|
||||
BindingScope: evt.BindingScope,
|
||||
DeploymentID: evt.DeploymentID,
|
||||
CorrelationID: evt.CorrelationID,
|
||||
RequestID: requestID,
|
||||
Result: result,
|
||||
SchemaVersion: evt.SchemaVersion,
|
||||
OccurredAt: parseAgnetOccurredAtMs(evt.OccurredAt),
|
||||
})
|
||||
}
|
||||
|
||||
// parseAgnetOccurredAtMs converts the RFC3339 string emitted by
|
||||
// agnetNow() into unix-ms for the DB column. Falls back to "now" so
|
||||
// a malformed timestamp doesn't drop the row.
|
||||
func parseAgnetOccurredAtMs(s string) int64 {
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return t.UnixMilli()
|
||||
}
|
||||
return time.Now().UnixMilli()
|
||||
}
|
||||
|
||||
func agnetNow() string {
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
@@ -696,8 +737,8 @@ func AgnetCreateDeployment(c *gin.Context) {
|
||||
|
||||
agnetMu.Lock()
|
||||
agnetDeployments[deploymentID] = record
|
||||
agnetEvents[deploymentID] = append(agnetEvents[deploymentID], event)
|
||||
agnetMu.Unlock()
|
||||
recordAgnetAuditEvent(event, "agnet_control_plane", deploymentID, agnetRequestID(c), "ok")
|
||||
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"deployment_id": deploymentID,
|
||||
@@ -774,7 +815,7 @@ func AgnetStopDeployment(c *gin.Context) {
|
||||
}
|
||||
record.UpdatedAt = agnetNow()
|
||||
agnetDeployments[deploymentID] = record
|
||||
agnetEvents[deploymentID] = append(agnetEvents[deploymentID], agnetEvent{
|
||||
stopEvent := agnetEvent{
|
||||
EventID: "evt_" + common.GetUUID()[:12],
|
||||
Event: "instance.phase_changed",
|
||||
SchemaVersion: 1,
|
||||
@@ -784,8 +825,9 @@ func AgnetStopDeployment(c *gin.Context) {
|
||||
DeploymentID: deploymentID,
|
||||
CorrelationID: record.Plan.Metadata.CorrelationID,
|
||||
OccurredAt: agnetNow(),
|
||||
})
|
||||
}
|
||||
agnetMu.Unlock()
|
||||
recordAgnetAuditEvent(stopEvent, "agnet_control_plane", deploymentID, agnetRequestID(c), "ok")
|
||||
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"deployment_id": deploymentID,
|
||||
@@ -801,9 +843,17 @@ func AgnetListDeploymentEvents(c *gin.Context) {
|
||||
agnetError(c, "POLICY_REJECTED", "deployment_id is required")
|
||||
return
|
||||
}
|
||||
agnetMu.RLock()
|
||||
events := agnetEvents[deploymentID]
|
||||
agnetMu.RUnlock()
|
||||
// Audit rows are now in PostgreSQL/SQLite/MySQL — survive restarts.
|
||||
rows, err := model.ListAgnetAuditEventsByDeployment(deploymentID)
|
||||
if err != nil {
|
||||
common.SysLog("AgnetListDeploymentEvents: " + err.Error())
|
||||
agnetError(c, "POLICY_REJECTED", "internal error")
|
||||
return
|
||||
}
|
||||
events := make([]agnetEvent, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
events = append(events, agnetAuditRowToEvent(r))
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"items": events,
|
||||
"total": len(events),
|
||||
@@ -819,12 +869,19 @@ func AgnetListDeploymentLogs(c *gin.Context) {
|
||||
|
||||
agnetMu.RLock()
|
||||
record, ok := agnetDeployments[deploymentID]
|
||||
events := agnetEvents[deploymentID]
|
||||
agnetMu.RUnlock()
|
||||
if !ok {
|
||||
agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
|
||||
return
|
||||
}
|
||||
rows, err := model.ListAgnetAuditEventsByDeployment(deploymentID)
|
||||
if err != nil {
|
||||
common.SysLog("AgnetListDeploymentLogs: " + err.Error())
|
||||
}
|
||||
events := make([]agnetEvent, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
events = append(events, agnetAuditRowToEvent(r))
|
||||
}
|
||||
|
||||
items := make([]gin.H, 0, len(events)+1)
|
||||
items = append(items, gin.H{
|
||||
@@ -981,7 +1038,7 @@ func AgnetResolveSKSnapshots(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
agnetSnapshots[deploymentID] = snapshots
|
||||
agnetEvents[deploymentID] = append(agnetEvents[deploymentID], agnetEvent{
|
||||
snapEvent := agnetEvent{
|
||||
EventID: "evt_" + common.GetUUID()[:12],
|
||||
Event: "sk_snapshot_refreshed",
|
||||
SchemaVersion: 1,
|
||||
@@ -991,8 +1048,9 @@ func AgnetResolveSKSnapshots(c *gin.Context) {
|
||||
DeploymentID: deploymentID,
|
||||
CorrelationID: record.Plan.Metadata.CorrelationID,
|
||||
OccurredAt: now,
|
||||
})
|
||||
}
|
||||
agnetMu.Unlock()
|
||||
recordAgnetAuditEvent(snapEvent, "agnet_control_plane", deploymentID, agnetRequestID(c), "ok")
|
||||
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"deployment_id": deploymentID,
|
||||
@@ -1018,36 +1076,68 @@ func AgnetListSKSnapshots(c *gin.Context) {
|
||||
}
|
||||
|
||||
func AgnetListAuditLogs(c *gin.Context) {
|
||||
userID := strings.TrimSpace(c.Query("user_id"))
|
||||
bindingScope := strings.TrimSpace(c.Query("binding_scope"))
|
||||
items := make([]gin.H, 0)
|
||||
agnetMu.RLock()
|
||||
for deploymentID, events := range agnetEvents {
|
||||
for _, event := range events {
|
||||
if userID != "" && event.UserID != userID {
|
||||
continue
|
||||
}
|
||||
if bindingScope != "" && event.BindingScope != bindingScope {
|
||||
continue
|
||||
}
|
||||
items = append(items, gin.H{
|
||||
"actor": "agnet_control_plane",
|
||||
"action": event.Event,
|
||||
"resource": deploymentID,
|
||||
"user_id": event.UserID,
|
||||
"channel_id": event.ChannelID,
|
||||
"binding_scope": event.BindingScope,
|
||||
"request_id": agnetRequestID(c),
|
||||
"correlation_id": event.CorrelationID,
|
||||
"result": "ok",
|
||||
"occurred_at": event.OccurredAt,
|
||||
})
|
||||
// Persistent audit query — survives container restarts. Filters
|
||||
// are optional; the dashboard call usually narrows by user_id +
|
||||
// time window. Pagination is opt-in via ?limit / ?offset.
|
||||
limit := 0
|
||||
if v := strings.TrimSpace(c.Query("limit")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
agnetMu.RUnlock()
|
||||
|
||||
offset := 0
|
||||
if v := strings.TrimSpace(c.Query("offset")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
offset = n
|
||||
}
|
||||
}
|
||||
rows, total, err := model.ListAgnetAuditEvents(model.ListAgnetAuditEventsFilter{
|
||||
UserID: strings.TrimSpace(c.Query("user_id")),
|
||||
BindingScope: strings.TrimSpace(c.Query("binding_scope")),
|
||||
DeploymentID: strings.TrimSpace(c.Query("deployment_id")),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
if err != nil {
|
||||
common.SysLog("AgnetListAuditLogs: " + err.Error())
|
||||
agnetError(c, "POLICY_REJECTED", "internal error")
|
||||
return
|
||||
}
|
||||
items := make([]gin.H, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
items = append(items, gin.H{
|
||||
"actor": r.Actor,
|
||||
"action": r.Event,
|
||||
"resource": r.Resource,
|
||||
"user_id": r.UserID,
|
||||
"channel_id": r.ChannelID,
|
||||
"binding_scope": r.BindingScope,
|
||||
"request_id": r.RequestID,
|
||||
"correlation_id": r.CorrelationID,
|
||||
"result": r.Result,
|
||||
"occurred_at": time.UnixMilli(r.OccurredAt).UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"items": items,
|
||||
"total": len(items),
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
// agnetAuditRowToEvent reshapes a persisted audit row back into the
|
||||
// in-process event struct so the deployment-detail / log endpoints
|
||||
// can keep returning the same JSON shape they used to emit from the
|
||||
// in-memory map. Avoids breaking any existing dashboard consumer.
|
||||
func agnetAuditRowToEvent(r model.AgnetAuditEvent) agnetEvent {
|
||||
return agnetEvent{
|
||||
EventID: r.EventID,
|
||||
Event: r.Event,
|
||||
SchemaVersion: r.SchemaVersion,
|
||||
UserID: r.UserID,
|
||||
ChannelID: r.ChannelID,
|
||||
BindingScope: r.BindingScope,
|
||||
DeploymentID: r.DeploymentID,
|
||||
CorrelationID: r.CorrelationID,
|
||||
OccurredAt: time.UnixMilli(r.OccurredAt).UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,10 @@ func resetAgnetControlPlaneState(t *testing.T) {
|
||||
agnetMu.Lock()
|
||||
defer agnetMu.Unlock()
|
||||
agnetDeployments = make(map[string]agnetDeploymentRecord)
|
||||
agnetEvents = make(map[string][]agnetEvent)
|
||||
agnetSnapshots = make(map[string][]agnetSKSnapshot)
|
||||
// Audit rows live in DB now; tests use a fresh SQLite per case
|
||||
// (setupTokenControllerTestDB style) so there is no global map
|
||||
// to flush here.
|
||||
}
|
||||
|
||||
func baseAgnetResourceGrantPlan() agnetOrchestrationPlan {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/heicode/manager/common"
|
||||
)
|
||||
|
||||
// AgnetAuditEvent is the persistent audit-trail row for the Agnet
|
||||
// control-plane. Each row records one observable transition in the
|
||||
// Agnet lifecycle — deployment accepted, instance phase changed, SK
|
||||
// snapshot refreshed, etc. — so admins can answer the "who / when /
|
||||
// for which task / against which resource / with what result" set of
|
||||
// questions even after the Manager container restarts.
|
||||
//
|
||||
// Before this table existed the control-plane stashed events in an
|
||||
// in-process `map[string][]agnetEvent` (controller/agnet_control_plane
|
||||
// .go:219). Every restart wiped audit history — unacceptable for a
|
||||
// product where the audit page is part of the security story.
|
||||
//
|
||||
// Schema notes
|
||||
// - All string columns are bounded varchar to keep MySQL happy.
|
||||
// - `OccurredAt` is unix milliseconds so cross-DB ORDER BY / range
|
||||
// queries are trivial without a TIMESTAMP-with-timezone dance.
|
||||
// - `DetailsJSON` is a free-form payload bucket for fields we don't
|
||||
// want to promote to first-class columns yet (e.g. failure_reason,
|
||||
// phase, callback_url). Add a real column when a field is queried
|
||||
// enough to need an index.
|
||||
// - Indexes are picked for the dashboard queries: filter by user_id,
|
||||
// filter by deployment, time-range scan.
|
||||
//
|
||||
// Cross-DB compatibility (CLAUDE.md Rule 2): GORM AutoMigrate maps the
|
||||
// tags to the correct types on SQLite / MySQL / PostgreSQL. No raw
|
||||
// SQL. No DB-specific column types.
|
||||
type AgnetAuditEvent struct {
|
||||
Id int `gorm:"primaryKey" json:"id"`
|
||||
EventID string `gorm:"type:varchar(64);uniqueIndex" json:"event_id"`
|
||||
Event string `gorm:"type:varchar(64);index" json:"event"`
|
||||
Actor string `gorm:"type:varchar(64)" json:"actor"`
|
||||
Resource string `gorm:"type:varchar(128);index" json:"resource"`
|
||||
UserID string `gorm:"type:varchar(64);index" json:"user_id"`
|
||||
ChannelID string `gorm:"type:varchar(64)" json:"channel_id"`
|
||||
BindingScope string `gorm:"type:varchar(64);index" json:"binding_scope"`
|
||||
DeploymentID string `gorm:"type:varchar(64);index" json:"deployment_id"`
|
||||
CorrelationID string `gorm:"type:varchar(64)" json:"correlation_id"`
|
||||
RequestID string `gorm:"type:varchar(64)" json:"request_id"`
|
||||
Result string `gorm:"type:varchar(16)" json:"result"`
|
||||
SchemaVersion int `gorm:"default:1" json:"schema_version"`
|
||||
OccurredAt int64 `gorm:"bigint;index" json:"occurred_at"`
|
||||
DetailsJSON string `gorm:"type:text" json:"details_json,omitempty"`
|
||||
}
|
||||
|
||||
// TableName pins the migration target so we can rename the Go struct
|
||||
// later without breaking the deployed schema.
|
||||
func (AgnetAuditEvent) TableName() string {
|
||||
return "agnet_audit_events"
|
||||
}
|
||||
|
||||
// InsertAgnetAuditEvent best-effort persists one audit row. Callers
|
||||
// invoke this in a hot path (right after mutating a deployment), so:
|
||||
// - errors are logged but never returned — the audit write must NOT
|
||||
// fail the user-facing API
|
||||
// - DB is nil-guarded so unit tests / partial-init binaries don't
|
||||
// panic on a missing connection
|
||||
func InsertAgnetAuditEvent(evt *AgnetAuditEvent) {
|
||||
if DB == nil || evt == nil {
|
||||
return
|
||||
}
|
||||
if evt.OccurredAt == 0 {
|
||||
evt.OccurredAt = time.Now().UnixMilli()
|
||||
}
|
||||
if evt.SchemaVersion == 0 {
|
||||
evt.SchemaVersion = 1
|
||||
}
|
||||
if evt.Result == "" {
|
||||
evt.Result = "ok"
|
||||
}
|
||||
if err := DB.Create(evt).Error; err != nil {
|
||||
common.SysLog("InsertAgnetAuditEvent: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// ListAgnetAuditEventsFilter narrows the audit query to a slice of
|
||||
// dashboard relevant rows. Zero-value fields are ignored — callers
|
||||
// pass only the filters they care about.
|
||||
type ListAgnetAuditEventsFilter struct {
|
||||
UserID string
|
||||
BindingScope string
|
||||
DeploymentID string
|
||||
EventLike string // prefix filter on Event column, e.g. "deployment."
|
||||
FromMs int64 // inclusive
|
||||
ToMs int64 // exclusive; zero == no upper bound
|
||||
Limit int // defaults to 200
|
||||
Offset int
|
||||
}
|
||||
|
||||
// ListAgnetAuditEvents pages over audit rows ordered newest-first.
|
||||
// Used by the /api/agnet/audit-logs endpoint and the future task-
|
||||
// scoped audit drawer.
|
||||
func ListAgnetAuditEvents(f ListAgnetAuditEventsFilter) ([]AgnetAuditEvent, int64, error) {
|
||||
if DB == nil {
|
||||
return nil, 0, nil
|
||||
}
|
||||
q := DB.Model(&AgnetAuditEvent{})
|
||||
if f.UserID != "" {
|
||||
q = q.Where("user_id = ?", f.UserID)
|
||||
}
|
||||
if f.BindingScope != "" {
|
||||
q = q.Where("binding_scope = ?", f.BindingScope)
|
||||
}
|
||||
if f.DeploymentID != "" {
|
||||
q = q.Where("deployment_id = ?", f.DeploymentID)
|
||||
}
|
||||
if f.EventLike != "" {
|
||||
q = q.Where("event LIKE ?", f.EventLike+"%")
|
||||
}
|
||||
if f.FromMs > 0 {
|
||||
q = q.Where("occurred_at >= ?", f.FromMs)
|
||||
}
|
||||
if f.ToMs > 0 {
|
||||
q = q.Where("occurred_at < ?", f.ToMs)
|
||||
}
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
limit := f.Limit
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 200
|
||||
}
|
||||
var items []AgnetAuditEvent
|
||||
err := q.Order("occurred_at desc, id desc").Limit(limit).Offset(f.Offset).Find(&items).Error
|
||||
return items, total, err
|
||||
}
|
||||
|
||||
// ListAgnetAuditEventsByDeployment is the hot path for the deployment
|
||||
// detail drawer — returns all events for one deployment in chronological
|
||||
// order so the timeline reads top-to-bottom.
|
||||
func ListAgnetAuditEventsByDeployment(deploymentID string) ([]AgnetAuditEvent, error) {
|
||||
if DB == nil || deploymentID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var items []AgnetAuditEvent
|
||||
err := DB.Where("deployment_id = ?", deploymentID).
|
||||
Order("occurred_at asc, id asc").
|
||||
Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The model package shares a single in-memory SQLite across all tests
|
||||
// via TestMain in task_cas_test.go. We MUST NOT swap DB out or null
|
||||
// it in Cleanup — doing so wedges every other test in the package.
|
||||
//
|
||||
// Strategy: AutoMigrate the audit table once (idempotent on SQLite),
|
||||
// then DELETE rows at the top of each test to isolate cases.
|
||||
|
||||
func setupAuditTest(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := DB.AutoMigrate(&AgnetAuditEvent{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if err := DB.Exec("DELETE FROM agnet_audit_events").Error; err != nil {
|
||||
t.Fatalf("truncate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertAgnetAuditEvent_PersistsRow(t *testing.T) {
|
||||
// Regression for H1 — the previous implementation appended to an
|
||||
// in-process map that was wiped on every container restart. This
|
||||
// test pins the new behaviour: rows survive in DB.
|
||||
setupAuditTest(t)
|
||||
|
||||
evt := &AgnetAuditEvent{
|
||||
EventID: "evt_test_001",
|
||||
Event: "deployment.accepted",
|
||||
Actor: "agnet_control_plane",
|
||||
Resource: "dep_abc",
|
||||
UserID: "user-42",
|
||||
ChannelID: "channel-1",
|
||||
BindingScope: "project-main",
|
||||
DeploymentID: "dep_abc",
|
||||
CorrelationID: "corr-xyz",
|
||||
RequestID: "req-001",
|
||||
}
|
||||
InsertAgnetAuditEvent(evt)
|
||||
|
||||
var got AgnetAuditEvent
|
||||
if err := DB.Where("event_id = ?", "evt_test_001").First(&got).Error; err != nil {
|
||||
t.Fatalf("not persisted: %v", err)
|
||||
}
|
||||
if got.Result != "ok" {
|
||||
t.Errorf("default Result should be 'ok', got %q", got.Result)
|
||||
}
|
||||
if got.SchemaVersion != 1 {
|
||||
t.Errorf("default SchemaVersion should be 1, got %d", got.SchemaVersion)
|
||||
}
|
||||
if got.OccurredAt == 0 {
|
||||
t.Errorf("OccurredAt should be auto-stamped, got 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertAgnetAuditEvent_NilGuards(t *testing.T) {
|
||||
// Production safety: audit writes run inside hot paths (right
|
||||
// after a deployment mutation). A nil DB or nil event MUST NOT
|
||||
// panic — better to drop the audit row than to fail the user
|
||||
// API call.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("InsertAgnetAuditEvent panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
InsertAgnetAuditEvent(nil) // nil evt
|
||||
prev := DB
|
||||
DB = nil
|
||||
InsertAgnetAuditEvent(&AgnetAuditEvent{EventID: "x"}) // nil DB
|
||||
DB = prev
|
||||
}
|
||||
|
||||
func TestListAgnetAuditEvents_FilterAndPaginate(t *testing.T) {
|
||||
setupAuditTest(t)
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
for i := 0; i < 5; i++ {
|
||||
uid := "user-A"
|
||||
if i%2 == 0 {
|
||||
uid = "user-B"
|
||||
}
|
||||
InsertAgnetAuditEvent(&AgnetAuditEvent{
|
||||
EventID: fmt.Sprintf("evt_%d", i),
|
||||
Event: "deployment.accepted",
|
||||
UserID: uid,
|
||||
DeploymentID: "dep_X",
|
||||
OccurredAt: now + int64(i)*1000,
|
||||
})
|
||||
}
|
||||
|
||||
// All rows visible without filter.
|
||||
rows, total, err := ListAgnetAuditEvents(ListAgnetAuditEventsFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if total != 5 || len(rows) != 5 {
|
||||
t.Errorf("expected 5 rows, got total=%d len=%d", total, len(rows))
|
||||
}
|
||||
|
||||
// user_id filter narrows to 3 (i=0,2,4 → user-B).
|
||||
_, total, err = ListAgnetAuditEvents(ListAgnetAuditEventsFilter{UserID: "user-B"})
|
||||
if err != nil {
|
||||
t.Fatalf("list with userid: %v", err)
|
||||
}
|
||||
if total != 3 {
|
||||
t.Errorf("user-B should have 3 rows, got %d", total)
|
||||
}
|
||||
|
||||
// Newest-first ordering. evt_4 inserted last → top.
|
||||
rows, _, _ = ListAgnetAuditEvents(ListAgnetAuditEventsFilter{})
|
||||
if rows[0].EventID != "evt_4" {
|
||||
t.Errorf("expected newest-first, got %q on top", rows[0].EventID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAgnetAuditEventsByDeployment_Chronological(t *testing.T) {
|
||||
// Detail-drawer reads need oldest-first for a top-to-bottom
|
||||
// timeline. Confirms ascending order independent of insertion
|
||||
// order.
|
||||
setupAuditTest(t)
|
||||
|
||||
InsertAgnetAuditEvent(&AgnetAuditEvent{
|
||||
EventID: "evt_late", Event: "x", DeploymentID: "dep_T",
|
||||
OccurredAt: 9000,
|
||||
})
|
||||
InsertAgnetAuditEvent(&AgnetAuditEvent{
|
||||
EventID: "evt_early", Event: "x", DeploymentID: "dep_T",
|
||||
OccurredAt: 1000,
|
||||
})
|
||||
|
||||
rows, err := ListAgnetAuditEventsByDeployment("dep_T")
|
||||
if err != nil {
|
||||
t.Fatalf("query: %v", err)
|
||||
}
|
||||
if len(rows) != 2 || rows[0].EventID != "evt_early" {
|
||||
t.Fatalf("expected [evt_early, evt_late] order, got %+v", rows)
|
||||
}
|
||||
}
|
||||
@@ -286,6 +286,10 @@ func migrateDB() error {
|
||||
// V2 device-binding: X25519 keypair the Manager uses for ECDH
|
||||
// body decryption. See model/server_key.go.
|
||||
&ServerKey{},
|
||||
// Agnet control-plane audit trail. Replaces the previous
|
||||
// in-process `agnetEvents map` that was wiped on every container
|
||||
// restart. See model/agnet_audit.go for the rationale.
|
||||
&AgnetAuditEvent{},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ export function useSidebarData(): SidebarData {
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
title: t('Preparation checklist'),
|
||||
title: t('Resource binding'),
|
||||
url: '/sk-sources',
|
||||
icon: GitBranch,
|
||||
},
|
||||
|
||||
+2
-1
@@ -4071,6 +4071,7 @@
|
||||
"Give this device a friendly name so you can identify it later.": "Give this device a friendly name so you can identify it later.",
|
||||
"Name cannot be empty": "Name cannot be empty",
|
||||
"e.g. Chen's MacBook": "e.g. Chen's MacBook",
|
||||
"Unnamed device": "Unnamed device"
|
||||
"Unnamed device": "Unnamed device",
|
||||
"Resource binding": "Resource binding"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -4071,6 +4071,7 @@
|
||||
"Give this device a friendly name so you can identify it later.": "给这台设备起个易识别的名字,方便日后辨认。",
|
||||
"Name cannot be empty": "名称不能为空",
|
||||
"e.g. Chen's MacBook": "例如:小明的 MacBook",
|
||||
"Unnamed device": "未命名设备"
|
||||
"Unnamed device": "未命名设备",
|
||||
"Resource binding": "资源绑定"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user