mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 23:56:37 +08:00
refactor(backend): rename OpenFlare directory to lowercase openflare
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package state persists agent runtime state and observability snapshots.
|
||||
package state
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"Wavelet/openflare/plugins/agent/protocol"
|
||||
)
|
||||
|
||||
const observabilityBufferWindowSeconds = 60
|
||||
|
||||
// ObservabilityBufferRecord stores observability facts for a single time window.
|
||||
// Disk JSON is schema-v2 only: host_metrics / edge_health / access_logs.
|
||||
// Pre-v2 buffers are discarded on load (binary upgrade without data-dir wipe).
|
||||
type ObservabilityBufferRecord struct {
|
||||
WindowStartedAtUnix int64 `json:"window_started_at_unix"`
|
||||
HostMetrics *protocol.NodeMetricSnapshot `json:"host_metrics,omitempty"`
|
||||
EdgeHealth *protocol.NodeEdgeHealth `json:"edge_health,omitempty"`
|
||||
AccessLogs []protocol.NodeAccessLog `json:"access_logs,omitempty"`
|
||||
QueuedAtUnix int64 `json:"queued_at_unix"`
|
||||
}
|
||||
|
||||
// ObservabilityBufferStore persists observability records to disk for replay on heartbeat.
|
||||
type ObservabilityBufferStore struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
cache []ObservabilityBufferRecord
|
||||
cacheLoaded bool
|
||||
}
|
||||
|
||||
// NewObservabilityBufferStore creates a store backed by the file at path.
|
||||
func NewObservabilityBufferStore(path string) *ObservabilityBufferStore {
|
||||
return &ObservabilityBufferStore{path: filepath.Clean(path)}
|
||||
}
|
||||
|
||||
// Upsert inserts or merges an observability record and prunes entries older than retainAfterUnix.
|
||||
func (s *ObservabilityBufferStore) Upsert(record ObservabilityBufferRecord, retainAfterUnix int64) error {
|
||||
if s == nil || record.WindowStartedAtUnix <= 0 || (record.HostMetrics == nil && record.EdgeHealth == nil && len(record.AccessLogs) == 0) {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
records, err := s.loadUnlocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
records = pruneObservabilityBufferRecords(records, retainAfterUnix)
|
||||
replaced := false
|
||||
for index := range records {
|
||||
if records[index].WindowStartedAtUnix != record.WindowStartedAtUnix {
|
||||
continue
|
||||
}
|
||||
records[index] = mergeObservabilityBufferRecord(records[index], record)
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
if !replaced {
|
||||
records = append(records, record)
|
||||
}
|
||||
sort.Slice(records, func(i int, j int) bool {
|
||||
return records[i].WindowStartedAtUnix < records[j].WindowStartedAtUnix
|
||||
})
|
||||
return s.saveUnlocked(records)
|
||||
}
|
||||
|
||||
func mergeObservabilityBufferRecord(existing ObservabilityBufferRecord, incoming ObservabilityBufferRecord) ObservabilityBufferRecord {
|
||||
merged := existing
|
||||
if incoming.HostMetrics != nil {
|
||||
merged.HostMetrics = incoming.HostMetrics
|
||||
}
|
||||
if incoming.EdgeHealth != nil {
|
||||
merged.EdgeHealth = incoming.EdgeHealth
|
||||
}
|
||||
merged.AccessLogs = mergeAccessLogs(existing.AccessLogs, incoming.AccessLogs)
|
||||
if incoming.QueuedAtUnix > 0 {
|
||||
merged.QueuedAtUnix = incoming.QueuedAtUnix
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func mergeAccessLogs(existing []protocol.NodeAccessLog, incoming []protocol.NodeAccessLog) []protocol.NodeAccessLog {
|
||||
if len(existing) == 0 && len(incoming) == 0 {
|
||||
return nil
|
||||
}
|
||||
merged := make([]protocol.NodeAccessLog, 0, len(existing)+len(incoming))
|
||||
seen := make(map[string]struct{}, len(existing)+len(incoming))
|
||||
appendIfNeeded := func(items []protocol.NodeAccessLog) {
|
||||
for _, item := range items {
|
||||
key := accessLogKey(item)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
merged = append(merged, item)
|
||||
}
|
||||
}
|
||||
appendIfNeeded(existing)
|
||||
appendIfNeeded(incoming)
|
||||
sort.Slice(merged, func(i int, j int) bool {
|
||||
if merged[i].LoggedAtUnix == merged[j].LoggedAtUnix {
|
||||
return accessLogKey(merged[i]) < accessLogKey(merged[j])
|
||||
}
|
||||
return merged[i].LoggedAtUnix < merged[j].LoggedAtUnix
|
||||
})
|
||||
return merged
|
||||
}
|
||||
|
||||
func accessLogKey(item protocol.NodeAccessLog) string {
|
||||
return strconv.FormatInt(item.LoggedAtUnix, 10) + "|" + item.RemoteAddr + "|" + item.Host + "|" + item.Path + "|" + item.UserAgent + "|" + item.CacheStatus + "|" + strconv.Itoa(item.StatusCode)
|
||||
}
|
||||
|
||||
// Replayable returns buffered records from windows before currentWindowStartedAtUnix.
|
||||
func (s *ObservabilityBufferStore) Replayable(currentWindowStartedAtUnix int64, retainAfterUnix int64) ([]ObservabilityBufferRecord, error) {
|
||||
if s == nil {
|
||||
return nil, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
records, err := s.loadUnlocked()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = pruneObservabilityBufferRecords(records, retainAfterUnix)
|
||||
if err = s.saveUnlocked(records); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]ObservabilityBufferRecord, 0, len(records))
|
||||
for _, record := range records {
|
||||
if currentWindowStartedAtUnix > 0 && record.WindowStartedAtUnix >= currentWindowStartedAtUnix {
|
||||
continue
|
||||
}
|
||||
result = append(result, record)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Ack removes acknowledged observability windows and prunes entries older than retainAfterUnix.
|
||||
func (s *ObservabilityBufferStore) Ack(windowStartedAtUnix []int64, retainAfterUnix int64) error {
|
||||
if s == nil || len(windowStartedAtUnix) == 0 {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
records, err := s.loadUnlocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
acked := make(map[int64]struct{}, len(windowStartedAtUnix))
|
||||
for _, value := range windowStartedAtUnix {
|
||||
if value > 0 {
|
||||
acked[value] = struct{}{}
|
||||
}
|
||||
}
|
||||
filtered := make([]ObservabilityBufferRecord, 0, len(records))
|
||||
for _, record := range records {
|
||||
if _, ok := acked[record.WindowStartedAtUnix]; ok {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, record)
|
||||
}
|
||||
filtered = pruneObservabilityBufferRecords(filtered, retainAfterUnix)
|
||||
return s.saveUnlocked(filtered)
|
||||
}
|
||||
|
||||
func (s *ObservabilityBufferStore) loadUnlocked() ([]ObservabilityBufferRecord, error) {
|
||||
if s.cacheLoaded {
|
||||
copied := make([]ObservabilityBufferRecord, len(s.cache))
|
||||
copy(copied, s.cache)
|
||||
return copied, nil
|
||||
}
|
||||
data, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
s.cache = []ObservabilityBufferRecord{}
|
||||
s.cacheLoaded = true
|
||||
return []ObservabilityBufferRecord{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
s.cache = []ObservabilityBufferRecord{}
|
||||
s.cacheLoaded = true
|
||||
return []ObservabilityBufferRecord{}, nil
|
||||
}
|
||||
|
||||
// Binary upgrade: drop pre-v2 or corrupt buffer entirely; agent rebuilds on subsequent heartbeats.
|
||||
records, reason, ok := parseObservabilityBufferDisk(data)
|
||||
if !ok {
|
||||
s.discardBufferFile(reason)
|
||||
return []ObservabilityBufferRecord{}, nil
|
||||
}
|
||||
|
||||
s.cache = records
|
||||
s.cacheLoaded = true
|
||||
copied := make([]ObservabilityBufferRecord, len(s.cache))
|
||||
copy(copied, s.cache)
|
||||
return copied, nil
|
||||
}
|
||||
|
||||
// parseObservabilityBufferDisk returns v2 records, or ok=false when the on-disk file should be wiped.
|
||||
func parseObservabilityBufferDisk(data []byte) (records []ObservabilityBufferRecord, reason string, ok bool) {
|
||||
raw := strings.TrimSpace(string(data))
|
||||
if raw == "" {
|
||||
return []ObservabilityBufferRecord{}, "", true
|
||||
}
|
||||
// Valid buffer is a JSON array of window records.
|
||||
if !strings.HasPrefix(raw, "[") {
|
||||
return nil, "legacy or unreadable observability buffer", false
|
||||
}
|
||||
// Pre-v2 keys: discard whole file (no field migration).
|
||||
if strings.Contains(raw, `"snapshot"`) ||
|
||||
strings.Contains(raw, `"openresty_observation"`) ||
|
||||
strings.Contains(raw, `"traffic_report"`) {
|
||||
return nil, "legacy observability buffer format", false
|
||||
}
|
||||
if err := json.Unmarshal(data, &records); err != nil {
|
||||
return nil, "observability buffer JSON decode failed", false
|
||||
}
|
||||
return records, "", true
|
||||
}
|
||||
|
||||
func (s *ObservabilityBufferStore) discardBufferFile(reason string) {
|
||||
if err := os.Remove(s.path); err != nil && !os.IsNotExist(err) {
|
||||
slog.Warn("remove observability buffer failed", "path", s.path, "reason", reason, "error", err)
|
||||
} else {
|
||||
slog.Info("discarded observability buffer; will rebuild on run", "path", s.path, "reason", reason)
|
||||
}
|
||||
s.cache = []ObservabilityBufferRecord{}
|
||||
s.cacheLoaded = true
|
||||
}
|
||||
|
||||
func (s *ObservabilityBufferStore) saveUnlocked(records []ObservabilityBufferRecord) error {
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), stateDirPerm); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(records, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(s.path, data, stateFilePerm); err != nil { //nolint:gosec // path is agent-local buffer path from config
|
||||
return err
|
||||
}
|
||||
s.cache = records
|
||||
s.cacheLoaded = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// ObservabilityWindowStartedAt returns the 60s window start for host metrics or edge health.
|
||||
func ObservabilityWindowStartedAt(hostMetrics *protocol.NodeMetricSnapshot, edgeHealth *protocol.NodeEdgeHealth) int64 {
|
||||
if edgeHealth != nil && edgeHealth.CapturedAtUnix > 0 {
|
||||
return edgeHealth.CapturedAtUnix - (edgeHealth.CapturedAtUnix % observabilityBufferWindowSeconds)
|
||||
}
|
||||
if hostMetrics == nil || hostMetrics.CapturedAtUnix <= 0 {
|
||||
return 0
|
||||
}
|
||||
return hostMetrics.CapturedAtUnix - (hostMetrics.CapturedAtUnix % observabilityBufferWindowSeconds)
|
||||
}
|
||||
|
||||
func pruneObservabilityBufferRecords(records []ObservabilityBufferRecord, retainAfterUnix int64) []ObservabilityBufferRecord {
|
||||
if len(records) == 0 {
|
||||
return []ObservabilityBufferRecord{}
|
||||
}
|
||||
filtered := make([]ObservabilityBufferRecord, 0, len(records))
|
||||
for _, record := range records {
|
||||
if record.WindowStartedAtUnix <= 0 {
|
||||
continue
|
||||
}
|
||||
if retainAfterUnix > 0 && record.WindowStartedAtUnix < retainAfterUnix {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, record)
|
||||
}
|
||||
sort.Slice(filtered, func(i int, j int) bool {
|
||||
return filtered[i].WindowStartedAtUnix < filtered[j].WindowStartedAtUnix
|
||||
})
|
||||
return filtered
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package state
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"Wavelet/openflare/plugins/agent/protocol"
|
||||
)
|
||||
|
||||
func TestObservabilityBufferStoreUpsertReplayAndAck(t *testing.T) {
|
||||
store := NewObservabilityBufferStore(filepath.Join(t.TempDir(), "observability-buffer.json"))
|
||||
|
||||
if err := store.Upsert(ObservabilityBufferRecord{
|
||||
WindowStartedAtUnix: 1710403200,
|
||||
HostMetrics: &protocol.NodeMetricSnapshot{CapturedAtUnix: 1710403205},
|
||||
EdgeHealth: &protocol.NodeEdgeHealth{CapturedAtUnix: 1710403205, Connections: 5},
|
||||
QueuedAtUnix: 1710403205,
|
||||
}, 1710403000); err != nil {
|
||||
t.Fatalf("first upsert failed: %v", err)
|
||||
}
|
||||
if err := store.Upsert(ObservabilityBufferRecord{
|
||||
WindowStartedAtUnix: 1710403200,
|
||||
HostMetrics: &protocol.NodeMetricSnapshot{CapturedAtUnix: 1710403255, CPUUsagePercent: 40},
|
||||
EdgeHealth: &protocol.NodeEdgeHealth{CapturedAtUnix: 1710403255, Connections: 12},
|
||||
QueuedAtUnix: 1710403255,
|
||||
}, 1710403000); err != nil {
|
||||
t.Fatalf("second upsert failed: %v", err)
|
||||
}
|
||||
if err := store.Upsert(ObservabilityBufferRecord{
|
||||
WindowStartedAtUnix: 1710403260,
|
||||
HostMetrics: &protocol.NodeMetricSnapshot{CapturedAtUnix: 1710403265},
|
||||
QueuedAtUnix: 1710403265,
|
||||
}, 1710403000); err != nil {
|
||||
t.Fatalf("third upsert failed: %v", err)
|
||||
}
|
||||
|
||||
records, err := store.Replayable(1710403260, 1710403000)
|
||||
if err != nil {
|
||||
t.Fatalf("Replayable failed: %v", err)
|
||||
}
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("expected one replayable record before current window, got %d", len(records))
|
||||
}
|
||||
if records[0].EdgeHealth == nil || records[0].EdgeHealth.Connections != 12 {
|
||||
t.Fatalf("expected replayable record to keep latest upsert, got %+v", records[0])
|
||||
}
|
||||
|
||||
if err = store.Ack([]int64{1710403200}, 1710403000); err != nil {
|
||||
t.Fatalf("Ack failed: %v", err)
|
||||
}
|
||||
records, err = store.Replayable(0, 1710403000)
|
||||
if err != nil {
|
||||
t.Fatalf("Replayable after ack failed: %v", err)
|
||||
}
|
||||
if len(records) != 1 || records[0].WindowStartedAtUnix != 1710403260 {
|
||||
t.Fatalf("unexpected records after ack: %+v", records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObservabilityBufferStoreMergesAccessLogsWithinWindow(t *testing.T) {
|
||||
store := NewObservabilityBufferStore(filepath.Join(t.TempDir(), "observability-buffer.json"))
|
||||
|
||||
if err := store.Upsert(ObservabilityBufferRecord{
|
||||
WindowStartedAtUnix: 1710403200,
|
||||
AccessLogs: []protocol.NodeAccessLog{
|
||||
{LoggedAtUnix: 1710403201, RemoteAddr: "10.0.0.1", Host: "app.example.com", Path: "/a", StatusCode: 200, CacheStatus: "HIT"},
|
||||
},
|
||||
}, 1710403000); err != nil {
|
||||
t.Fatalf("first upsert failed: %v", err)
|
||||
}
|
||||
if err := store.Upsert(ObservabilityBufferRecord{
|
||||
WindowStartedAtUnix: 1710403200,
|
||||
AccessLogs: []protocol.NodeAccessLog{
|
||||
// Same identity except cache status — must not collapse HIT/MISS.
|
||||
{LoggedAtUnix: 1710403201, RemoteAddr: "10.0.0.1", Host: "app.example.com", Path: "/a", StatusCode: 200, CacheStatus: "HIT"},
|
||||
{LoggedAtUnix: 1710403201, RemoteAddr: "10.0.0.1", Host: "app.example.com", Path: "/a", StatusCode: 200, CacheStatus: "MISS"},
|
||||
{LoggedAtUnix: 1710403205, RemoteAddr: "10.0.0.2", Host: "app.example.com", Path: "/b", StatusCode: 502},
|
||||
},
|
||||
}, 1710403000); err != nil {
|
||||
t.Fatalf("second upsert failed: %v", err)
|
||||
}
|
||||
|
||||
records, err := store.Replayable(0, 1710403000)
|
||||
if err != nil {
|
||||
t.Fatalf("Replayable failed: %v", err)
|
||||
}
|
||||
if len(records) != 1 || len(records[0].AccessLogs) != 3 {
|
||||
t.Fatalf("expected merged access logs with distinct cache_status, got %+v", records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObservabilityWindowStartedAt(t *testing.T) {
|
||||
if value := ObservabilityWindowStartedAt(nil, &protocol.NodeEdgeHealth{CapturedAtUnix: 1710403259}); value != 1710403200 {
|
||||
t.Fatalf("unexpected edge-health window start: %d", value)
|
||||
}
|
||||
if value := ObservabilityWindowStartedAt(&protocol.NodeMetricSnapshot{CapturedAtUnix: 1710403259}, nil); value != 1710403200 {
|
||||
t.Fatalf("unexpected host-metrics window start: %d", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObservabilityBufferStoreDiscardsLegacyDiskJSON(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "observability-buffer.json")
|
||||
legacy := `[{
|
||||
"window_started_at_unix": 1710403200,
|
||||
"snapshot": {"captured_at_unix": 1710403205, "cpu_usage_percent": 11.5},
|
||||
"openresty_observation": {"captured_at_unix": 1710403206, "openresty_connections": 7},
|
||||
"traffic_report": {"request_count": 42},
|
||||
"access_logs": [{"logged_at_unix": 1710403201, "path": "/", "status_code": 200}]
|
||||
}]`
|
||||
if err := os.WriteFile(path, []byte(legacy), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
store := NewObservabilityBufferStore(path)
|
||||
records, err := store.Replayable(0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Replayable: %v", err)
|
||||
}
|
||||
if len(records) != 0 {
|
||||
t.Fatalf("expected legacy buffer discarded, got %+v", records)
|
||||
}
|
||||
// Replayable may rewrite an empty v2 array; legacy keys must be gone.
|
||||
if body, err := os.ReadFile(path); err == nil {
|
||||
raw := string(body)
|
||||
for _, key := range []string{`"snapshot"`, `"openresty_observation"`, `"traffic_report"`} {
|
||||
if strings.Contains(raw, key) {
|
||||
t.Fatalf("legacy key %s still present: %s", key, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fresh upsert after discard should create a clean v2 file.
|
||||
if err := store.Upsert(ObservabilityBufferRecord{
|
||||
WindowStartedAtUnix: 1710403200,
|
||||
HostMetrics: &protocol.NodeMetricSnapshot{CapturedAtUnix: 1710403205},
|
||||
}, 0); err != nil {
|
||||
t.Fatalf("Upsert after discard: %v", err)
|
||||
}
|
||||
records, err = store.Replayable(0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Replayable after rebuild: %v", err)
|
||||
}
|
||||
if len(records) != 1 || records[0].HostMetrics == nil {
|
||||
t.Fatalf("expected rebuilt buffer, got %+v", records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObservabilityBufferStoreDiscardsCorruptJSON(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "observability-buffer.json")
|
||||
if err := os.WriteFile(path, []byte(`{not-json`), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
store := NewObservabilityBufferStore(path)
|
||||
records, err := store.Replayable(0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Replayable should not fail: %v", err)
|
||||
}
|
||||
if len(records) != 0 {
|
||||
t.Fatalf("expected empty after discard, got %+v", records)
|
||||
}
|
||||
// Corrupt payload must not remain; empty rewrite is fine.
|
||||
if body, err := os.ReadFile(path); err == nil && strings.Contains(string(body), "not-json") {
|
||||
t.Fatalf("corrupt content still on disk: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObservabilityBufferStoreKeepsModernJSON(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "observability-buffer.json")
|
||||
modern := `[{
|
||||
"window_started_at_unix": 1710403200,
|
||||
"host_metrics": {"captured_at_unix": 1710403205, "cpu_usage_percent": 3},
|
||||
"edge_health": {"captured_at_unix": 1710403205, "status": "healthy", "connections": 2},
|
||||
"access_logs": []
|
||||
}]`
|
||||
if err := os.WriteFile(path, []byte(modern), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
store := NewObservabilityBufferStore(path)
|
||||
records, err := store.Replayable(0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Replayable: %v", err)
|
||||
}
|
||||
if len(records) != 1 || records[0].HostMetrics == nil || records[0].HostMetrics.CPUUsagePercent != 3 {
|
||||
t.Fatalf("modern buffer should be kept: %+v", records)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("modern buffer file should remain: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package state
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
stateDirPerm = 0o750
|
||||
stateFilePerm = 0o600
|
||||
nodeIDRandomBytes = 8
|
||||
)
|
||||
|
||||
// PagesDeployment records a Pages project tracked by the agent and the last
|
||||
// applied package hash for that project's active deployment.
|
||||
// ProjectID is the stable identity; DeploymentID/Hash follow control-plane "latest".
|
||||
type PagesDeployment struct {
|
||||
ProjectID uint `json:"project_id"`
|
||||
DeploymentID uint `json:"deployment_id,omitempty"`
|
||||
Hash string `json:"hash"`
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
}
|
||||
|
||||
// Snapshot represents the state of the agent at a given point in time.
|
||||
type Snapshot struct {
|
||||
NodeID string `json:"node_id"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
CurrentChecksum string `json:"current_checksum"`
|
||||
PagesDeployments []PagesDeployment `json:"pages_deployments"`
|
||||
BlockedVersion string `json:"blocked_version"`
|
||||
BlockedChecksum string `json:"blocked_checksum"`
|
||||
BlockedReason string `json:"blocked_reason"`
|
||||
LastError string `json:"last_error"`
|
||||
OpenrestyStatus string `json:"openresty_status"`
|
||||
OpenrestyMessage string `json:"openresty_message"`
|
||||
LastProfileFingerprint string `json:"last_profile_fingerprint"`
|
||||
LastCPUStatTotal uint64 `json:"last_cpu_stat_total"`
|
||||
LastCPUStatIdle uint64 `json:"last_cpu_stat_idle"`
|
||||
LastMetricAtUnix int64 `json:"last_metric_at_unix"`
|
||||
AccessLogOffset int64 `json:"access_log_offset"`
|
||||
}
|
||||
|
||||
// Store manages the storage and retrieval of the agent state snapshot.
|
||||
type Store struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewStore creates a new Store instance at the given path.
|
||||
func NewStore(path string) *Store {
|
||||
return &Store{path: filepath.Clean(path)}
|
||||
}
|
||||
|
||||
// Load loads the snapshot from the store.
|
||||
func (s *Store) Load() (*Snapshot, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.loadUnlocked()
|
||||
}
|
||||
|
||||
// EnsureNodeID returns the existing node ID, or generates and saves a new one if it does not exist.
|
||||
func (s *Store) EnsureNodeID() (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
snapshot, err := s.loadUnlocked()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if snapshot.NodeID != "" {
|
||||
return snapshot.NodeID, nil
|
||||
}
|
||||
snapshot.NodeID, err = newNodeID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err = s.saveUnlocked(snapshot); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return snapshot.NodeID, nil
|
||||
}
|
||||
|
||||
// Save saves the given snapshot to the store.
|
||||
func (s *Store) Save(snapshot *Snapshot) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.saveUnlocked(snapshot)
|
||||
}
|
||||
|
||||
func (s *Store) loadUnlocked() (*Snapshot, error) {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &Snapshot{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
snapshot := &Snapshot{}
|
||||
if len(data) == 0 {
|
||||
return snapshot, nil
|
||||
}
|
||||
if err = json.Unmarshal(data, snapshot); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func (s *Store) saveUnlocked(snapshot *Snapshot) error {
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), stateDirPerm); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(snapshot, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(s.path, data, stateFilePerm)
|
||||
}
|
||||
|
||||
func newNodeID() (string, error) {
|
||||
buf := make([]byte, nodeIDRandomBytes)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "node-" + hex.EncodeToString(buf), nil
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package state
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnsureNodeIDPersists(t *testing.T) {
|
||||
store := NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
nodeID1, err := store.EnsureNodeID()
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureNodeID failed: %v", err)
|
||||
}
|
||||
nodeID2, err := store.EnsureNodeID()
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureNodeID second call failed: %v", err)
|
||||
}
|
||||
if nodeID1 == "" || nodeID1 != nodeID2 {
|
||||
t.Fatal("expected node id to persist across calls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_Load_NonExistentFile(t *testing.T) {
|
||||
// Loading from a non-existent path should succeed and return an empty Snapshot
|
||||
tempFile := filepath.Join(t.TempDir(), "nonexistent.json")
|
||||
store := NewStore(tempFile)
|
||||
|
||||
snap, err := store.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("expected Load to succeed for non-existent file, got err: %v", err)
|
||||
}
|
||||
if snap == nil {
|
||||
t.Fatal("expected non-nil snapshot")
|
||||
}
|
||||
if snap.NodeID != "" || snap.CurrentVersion != "" {
|
||||
t.Errorf("expected empty snapshot, got: %+v", snap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_Load_EmptyFile(t *testing.T) {
|
||||
// Loading from an empty file should succeed and return an empty Snapshot
|
||||
tempFile := filepath.Join(t.TempDir(), "empty.json")
|
||||
if err := os.WriteFile(tempFile, []byte(""), 0644); err != nil {
|
||||
t.Fatalf("failed to create empty file: %v", err)
|
||||
}
|
||||
|
||||
store := NewStore(tempFile)
|
||||
snap, err := store.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("expected Load to succeed for empty file, got err: %v", err)
|
||||
}
|
||||
if snap == nil {
|
||||
t.Fatal("expected non-nil snapshot")
|
||||
}
|
||||
if snap.NodeID != "" {
|
||||
t.Errorf("expected empty snapshot, got: %+v", snap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_Load_InvalidJSON(t *testing.T) {
|
||||
// Loading from a corrupted file with invalid JSON should fail with parsing error
|
||||
tempFile := filepath.Join(t.TempDir(), "corrupted.json")
|
||||
if err := os.WriteFile(tempFile, []byte("{invalid-json"), 0644); err != nil {
|
||||
t.Fatalf("failed to create corrupted file: %v", err)
|
||||
}
|
||||
|
||||
store := NewStore(tempFile)
|
||||
_, err := store.Load()
|
||||
if err == nil {
|
||||
t.Fatal("expected Load to fail for corrupted JSON file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_SaveAndLoad(t *testing.T) {
|
||||
tempFile := filepath.Join(t.TempDir(), "state.json")
|
||||
store := NewStore(tempFile)
|
||||
|
||||
original := &Snapshot{
|
||||
NodeID: "node-test-123",
|
||||
CurrentVersion: "20260531-001",
|
||||
CurrentChecksum: "chk-active-xyz",
|
||||
BlockedVersion: "20260531-002",
|
||||
BlockedChecksum: "chk-blocked-abc",
|
||||
BlockedReason: "invalid upstream domain name",
|
||||
LastError: "configuration reload timeout",
|
||||
OpenrestyStatus: "unhealthy",
|
||||
}
|
||||
|
||||
if err := store.Save(original); err != nil {
|
||||
t.Fatalf("expected Save to succeed, got: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := store.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("expected Load to succeed, got: %v", err)
|
||||
}
|
||||
|
||||
if loaded.NodeID != original.NodeID ||
|
||||
loaded.CurrentVersion != original.CurrentVersion ||
|
||||
loaded.CurrentChecksum != original.CurrentChecksum ||
|
||||
loaded.BlockedVersion != original.BlockedVersion ||
|
||||
loaded.BlockedChecksum != original.BlockedChecksum ||
|
||||
loaded.BlockedReason != original.BlockedReason ||
|
||||
loaded.LastError != original.LastError ||
|
||||
loaded.OpenrestyStatus != original.OpenrestyStatus {
|
||||
t.Errorf("loaded snapshot does not match original: %+v vs %+v", loaded, original)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_ConcurrencySafety(t *testing.T) {
|
||||
tempFile := filepath.Join(t.TempDir(), "state.json")
|
||||
store := NewStore(tempFile)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
workers := 20
|
||||
iterations := 50
|
||||
|
||||
// Run concurrent writers and readers
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func(workerID int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
// Concurrently save
|
||||
snap := &Snapshot{
|
||||
NodeID: fmt.Sprintf("node-%d", workerID),
|
||||
CurrentVersion: fmt.Sprintf("v-%d", j),
|
||||
}
|
||||
if err := store.Save(snap); err != nil {
|
||||
t.Errorf("Save failed under concurrency: %v", err)
|
||||
}
|
||||
|
||||
// Concurrently load
|
||||
if _, err := store.Load(); err != nil {
|
||||
t.Errorf("Load failed under concurrency: %v", err)
|
||||
}
|
||||
|
||||
// Concurrently ensure ID
|
||||
if _, err := store.EnsureNodeID(); err != nil {
|
||||
t.Errorf("EnsureNodeID failed under concurrency: %v", err)
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
Reference in New Issue
Block a user