refactor(backend): rename OpenFlare directory to lowercase openflare

This commit is contained in:
ryan
2026-08-30 17:43:23 +08:00
parent 06d5fedbfc
commit c93ff6674f
543 changed files with 819 additions and 819 deletions
@@ -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()
}