feat(state): add runtime state snapshot
This commit is contained in:
@@ -0,0 +1,78 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// Package state persists the runtime snapshot prx needs to describe and undo
|
||||||
|
// the currently applied routing configuration.
|
||||||
|
package state
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"prx/internal/fsx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// State is the on-disk snapshot.
|
||||||
|
type State struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
WANInterface string `json:"wan_interface"`
|
||||||
|
Gateway4 string `json:"gateway_v4,omitempty"`
|
||||||
|
Gateway6 string `json:"gateway_v6,omitempty"`
|
||||||
|
Mark uint32 `json:"mark"`
|
||||||
|
TableV4 int `json:"table_v4"`
|
||||||
|
TableV6 int `json:"table_v6"`
|
||||||
|
PriorityV4 int `json:"priority_v4"`
|
||||||
|
PriorityV6 int `json:"priority_v6"`
|
||||||
|
BypassTableV4 int `json:"bypass_table_v4"`
|
||||||
|
BypassTableV6 int `json:"bypass_table_v6"`
|
||||||
|
BypassPrioV4 int `json:"bypass_priority_v4"`
|
||||||
|
BypassPrioV6 int `json:"bypass_priority_v6"`
|
||||||
|
BypassCIDRs []string `json:"bypass_cidrs,omitempty"`
|
||||||
|
EngineManaged bool `json:"engine_managed"`
|
||||||
|
EngineWasRunning bool `json:"engine_was_running"`
|
||||||
|
EnabledAt time.Time `json:"enabled_at"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads the state. A missing file yields a zero (disabled) state.
|
||||||
|
//
|
||||||
|
// The returned state is never nil, even alongside an error, so callers cannot
|
||||||
|
// dereference a nil pointer after ignoring a read failure.
|
||||||
|
func Load(path string) (*State, error) {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return &State{}, nil
|
||||||
|
}
|
||||||
|
return &State{}, fmt.Errorf("read state %s: %w", path, err)
|
||||||
|
}
|
||||||
|
var s State
|
||||||
|
if err := json.Unmarshal(raw, &s); err != nil {
|
||||||
|
return &State{}, fmt.Errorf("parse state %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save writes the state atomically, creating the parent directory.
|
||||||
|
func Save(path string, s *State) error {
|
||||||
|
raw, err := json.MarshalIndent(s, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode state: %w", err)
|
||||||
|
}
|
||||||
|
raw = append(raw, '\n')
|
||||||
|
if err := fsx.WriteFileAtomic(path, raw, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write state %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove deletes the state file. A missing file is not an error.
|
||||||
|
func Remove(path string) error {
|
||||||
|
err := os.Remove(path)
|
||||||
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package state
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRoundTrip(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "run", "prx", "state.json")
|
||||||
|
s := &State{
|
||||||
|
Enabled: true,
|
||||||
|
WANInterface: "wlan0",
|
||||||
|
Gateway4: "192.168.1.1",
|
||||||
|
Mark: 0x100,
|
||||||
|
TableV4: 100,
|
||||||
|
TableV6: 101,
|
||||||
|
PriorityV4: 100,
|
||||||
|
PriorityV6: 100,
|
||||||
|
}
|
||||||
|
if err := Save(path, s); err != nil {
|
||||||
|
t.Fatalf("Save: %v", err)
|
||||||
|
}
|
||||||
|
got, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if !got.Enabled || got.WANInterface != "wlan0" || got.Mark != 0x100 {
|
||||||
|
t.Fatalf("round trip mismatch: %+v", got)
|
||||||
|
}
|
||||||
|
if err := Remove(path); err != nil {
|
||||||
|
t.Fatalf("Remove: %v", err)
|
||||||
|
}
|
||||||
|
empty, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load after remove: %v", err)
|
||||||
|
}
|
||||||
|
if empty.Enabled {
|
||||||
|
t.Fatal("expected disabled state after removal")
|
||||||
|
}
|
||||||
|
if err := Remove(path); err != nil {
|
||||||
|
t.Fatalf("second Remove should be a no-op: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadCorruptNeverReturnsNil guards against the nil-pointer panic that a
|
||||||
|
// caller ignoring the error would otherwise hit on a corrupt state file.
|
||||||
|
func TestLoadCorruptNeverReturnsNil(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "state.json")
|
||||||
|
if err := os.WriteFile(path, []byte("{ not json"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
st, err := Load(path)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected a parse error for a corrupt state file")
|
||||||
|
}
|
||||||
|
if st == nil {
|
||||||
|
t.Fatal("Load must never return a nil state alongside an error")
|
||||||
|
}
|
||||||
|
if st.Enabled {
|
||||||
|
t.Fatal("expected a zero-value state")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user