docs(runtime-logs): implement runtime log management features

Добавлены новые возможности для работы с файловыми логами Docker-сервисов:
- Эндпоинты для получения списка логов и хвоста лог-файла.
- Очистка лог-файлов с возможностью выбора режима (truncate или delete) и запись в аудит очистки.
- Обновлена документация и конфигурация для поддержки новых функций.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-12 19:04:53 +07:00
co-authored by Cursor
parent 1c39c65fc5
commit 3f0dd6c234
25 changed files with 1235 additions and 66 deletions
+4
View File
@@ -125,6 +125,10 @@ type Backend interface {
TouchMaintenancePolicyRun(id, status, errMsg string) error
AppendMaintenancePolicyConfigAudit(actor, policyID, action string, before, after map[string]any) error
ListMaintenancePolicyConfigAudit(cursor string, limit int) ([]*MaintenancePolicyConfigAudit, string, bool, error)
// Runtime log cleanup audit (filesystem ops logged per tenant).
AppendRuntimeLogCleanupAudit(tenantID, actor, filename, action string, sizeBefore int64, sizeAfter *int64, detail map[string]any) (string, error)
ListRuntimeLogCleanupAudit(tenantID, cursor string, limit int) ([]*RuntimeLogCleanupAudit, string, bool, error)
}
// ASNPrefixCacheEntry is a cached RIPEstat response for one ASN.
+34 -32
View File
@@ -33,19 +33,20 @@ type Memory struct {
peers map[string]*BGPPeer
dohProfiles map[string]*DohProfile
communities map[string]*Community
cdnSources map[string]*CDNSource
asEntries map[string]*ASEntry
domainEnt map[string]*DomainEntry
ipRanges map[string]*IPRangeEntry
settings map[string]map[string]any // tenantID -> key -> JSON-compatible value
revPrefixes map[string][]PrefixRow
moduleSnapshots map[string]*moduleSnapshotRec
asnPrefixCache map[int64]*ASNPrefixCacheEntry
apiKeys map[string]*apiKeyRec
maintenancePolicies map[string]*MaintenancePolicy
maintConfigAudit []*MaintenancePolicyConfigAudit
dohProfiles map[string]*DohProfile
communities map[string]*Community
cdnSources map[string]*CDNSource
asEntries map[string]*ASEntry
domainEnt map[string]*DomainEntry
ipRanges map[string]*IPRangeEntry
settings map[string]map[string]any // tenantID -> key -> JSON-compatible value
revPrefixes map[string][]PrefixRow
moduleSnapshots map[string]*moduleSnapshotRec
asnPrefixCache map[int64]*ASNPrefixCacheEntry
apiKeys map[string]*apiKeyRec
maintenancePolicies map[string]*MaintenancePolicy
maintConfigAudit []*MaintenancePolicyConfigAudit
runtimeLogCleanupAudit []*RuntimeLogCleanupAudit
// DemoIDs valid after SeedDemo()
demoTenantID string
@@ -125,25 +126,26 @@ type Speaker struct {
func NewMemory() *Memory {
return &Memory{
tenants: make(map[string]*Tenant),
modules: make(map[string]*Module),
revisions: make(map[string]*Revision),
speakers: make(map[string]*Speaker),
publishedRevision: make(map[string]publishedInfo),
peers: make(map[string]*BGPPeer),
dohProfiles: make(map[string]*DohProfile),
communities: make(map[string]*Community),
cdnSources: make(map[string]*CDNSource),
asEntries: make(map[string]*ASEntry),
domainEnt: make(map[string]*DomainEntry),
ipRanges: make(map[string]*IPRangeEntry),
settings: make(map[string]map[string]any),
revPrefixes: make(map[string][]PrefixRow),
moduleSnapshots: make(map[string]*moduleSnapshotRec),
asnPrefixCache: make(map[int64]*ASNPrefixCacheEntry),
apiKeys: make(map[string]*apiKeyRec),
maintenancePolicies: make(map[string]*MaintenancePolicy),
maintConfigAudit: nil,
tenants: make(map[string]*Tenant),
modules: make(map[string]*Module),
revisions: make(map[string]*Revision),
speakers: make(map[string]*Speaker),
publishedRevision: make(map[string]publishedInfo),
peers: make(map[string]*BGPPeer),
dohProfiles: make(map[string]*DohProfile),
communities: make(map[string]*Community),
cdnSources: make(map[string]*CDNSource),
asEntries: make(map[string]*ASEntry),
domainEnt: make(map[string]*DomainEntry),
ipRanges: make(map[string]*IPRangeEntry),
settings: make(map[string]map[string]any),
revPrefixes: make(map[string][]PrefixRow),
moduleSnapshots: make(map[string]*moduleSnapshotRec),
asnPrefixCache: make(map[int64]*ASNPrefixCacheEntry),
apiKeys: make(map[string]*apiKeyRec),
maintenancePolicies: make(map[string]*MaintenancePolicy),
maintConfigAudit: nil,
runtimeLogCleanupAudit: nil,
}
}
+74
View File
@@ -0,0 +1,74 @@
package store
import (
"sort"
"strings"
"time"
"github.com/google/uuid"
)
func (m *Memory) AppendRuntimeLogCleanupAudit(tenantID, actor, filename, action string, sizeBefore int64, sizeAfter *int64, detail map[string]any) (string, error) {
if strings.TrimSpace(tenantID) == "" {
return "", ErrNotFound
}
if !ValidRuntimeLogCleanupAction(action) {
return "", ErrInvalidInput
}
name := strings.TrimSpace(filename)
if name == "" {
return "", ErrInvalidInput
}
m.mu.Lock()
defer m.mu.Unlock()
id := uuid.NewString()
row := &RuntimeLogCleanupAudit{
ID: id,
TenantID: tenantID,
ActorPrefix: strings.TrimSpace(actor),
Filename: name,
Action: action,
SizeBefore: sizeBefore,
SizeAfter: sizeAfter,
Detail: detail,
CreatedAt: time.Now().UTC(),
}
m.runtimeLogCleanupAudit = append(m.runtimeLogCleanupAudit, row)
return id, nil
}
func (m *Memory) ListRuntimeLogCleanupAudit(tenantID, cursor string, limit int) ([]*RuntimeLogCleanupAudit, string, bool, error) {
if limit <= 0 {
limit = 50
}
m.mu.RLock()
defer m.mu.RUnlock()
var filtered []*RuntimeLogCleanupAudit
for _, row := range m.runtimeLogCleanupAudit {
if row.TenantID == tenantID {
filtered = append(filtered, row)
}
}
sort.Slice(filtered, func(i, j int) bool {
if filtered[i].CreatedAt.Equal(filtered[j].CreatedAt) {
return filtered[i].ID > filtered[j].ID
}
return filtered[i].CreatedAt.After(filtered[j].CreatedAt)
})
off := parseMaintCursor(cursor)
end := off + limit
next := ""
hasMore := false
if end > len(filtered) {
end = len(filtered)
} else if end < len(filtered) {
hasMore = true
next = formatMaintCursor(end)
}
if off >= len(filtered) {
return nil, "", false, nil
}
out := make([]*RuntimeLogCleanupAudit, end-off)
copy(out, filtered[off:end])
return out, next, hasMore, nil
}
@@ -0,0 +1,68 @@
package store
import "testing"
func TestMemoryRuntimeLogCleanupAudit(t *testing.T) {
m := NewMemory()
tenantA := "tenant-a"
tenantB := "tenant-b"
after := int64(0)
id, err := m.AppendRuntimeLogCleanupAudit(tenantA, "op:alice", "evobgp-all.log", RuntimeLogCleanupTruncate, 1024, &after, nil)
if err != nil {
t.Fatal(err)
}
if id == "" {
t.Fatal("expected audit id")
}
if _, err := m.AppendRuntimeLogCleanupAudit(tenantA, "op:alice", "postgres.log", RuntimeLogCleanupDelete, 512, nil, map[string]any{"note": "removed"}); err != nil {
t.Fatal(err)
}
if _, err := m.AppendRuntimeLogCleanupAudit(tenantB, "op:bob", "bird2.log", RuntimeLogCleanupTruncate, 256, &after, nil); err != nil {
t.Fatal(err)
}
items, next, hasMore, err := m.ListRuntimeLogCleanupAudit(tenantA, "", 10)
if err != nil {
t.Fatal(err)
}
if len(items) != 2 || hasMore || next != "" {
t.Fatalf("tenantA list: len=%d hasMore=%v next=%q", len(items), hasMore, next)
}
if items[0].Filename == items[1].Filename {
t.Fatalf("expected desc order by created_at: %+v", items)
}
page, next, hasMore, err := m.ListRuntimeLogCleanupAudit(tenantA, "", 1)
if err != nil {
t.Fatal(err)
}
if len(page) != 1 || !hasMore || next == "" {
t.Fatalf("page1: len=%d hasMore=%v next=%q", len(page), hasMore, next)
}
page2, next2, hasMore2, err := m.ListRuntimeLogCleanupAudit(tenantA, next, 1)
if err != nil {
t.Fatal(err)
}
if len(page2) != 1 || hasMore2 || next2 != "" {
t.Fatalf("page2: len=%d hasMore=%v next=%q", len(page2), hasMore2, next2)
}
if page[0].ID == page2[0].ID {
t.Fatal("expected different audit rows across pages")
}
if _, err := m.AppendRuntimeLogCleanupAudit(tenantA, "op:x", "bad.log", "wipe", 1, nil, nil); err != ErrInvalidInput {
t.Fatalf("invalid action: %v", err)
}
}
func TestValidRuntimeLogCleanupAction(t *testing.T) {
if !ValidRuntimeLogCleanupAction(RuntimeLogCleanupTruncate) {
t.Fatal("truncate")
}
if ValidRuntimeLogCleanupAction("rotate") {
t.Fatal("unexpected valid")
}
}
+50
View File
@@ -0,0 +1,50 @@
package store
import (
"strings"
"time"
)
// Runtime log cleanup actions (runtime_log_cleanup_audit.action).
const (
RuntimeLogCleanupTruncate = "truncate"
RuntimeLogCleanupDelete = "delete"
)
// RuntimeLogFile describes a file in EVOBGP_RUNTIME_LOGS_DIR (API DTO).
type RuntimeLogFile struct {
Name string `json:"name"`
SizeBytes int64 `json:"size_bytes"`
ModifiedAt time.Time `json:"modified_at"`
}
// RuntimeLogTail is a tail/preview fragment of a runtime log file.
type RuntimeLogTail struct {
Filename string `json:"filename"`
Content string `json:"content"`
Truncated bool `json:"truncated"`
LinesReturned int `json:"lines_returned"`
}
// RuntimeLogCleanupAudit is a persisted cleanup operation log entry.
type RuntimeLogCleanupAudit struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
ActorPrefix string `json:"actor_prefix"`
Filename string `json:"filename"`
Action string `json:"action"`
SizeBefore int64 `json:"size_before"`
SizeAfter *int64 `json:"size_after,omitempty"`
Detail map[string]any `json:"detail,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ValidRuntimeLogCleanupAction reports whether action is truncate or delete.
func ValidRuntimeLogCleanupAction(action string) bool {
switch strings.TrimSpace(action) {
case RuntimeLogCleanupTruncate, RuntimeLogCleanupDelete:
return true
default:
return false
}
}