f-46: dhcpapi: add the control socket, its protocol and its client #46
Le contrat et le listener vont dans internal/api/dhcp (package dhcpapi), sur la forme de internal/api/agent, et le client dans internal/client/dhcp. Chaînage des imports : statefile <- dhcpd <- dhcpapi <- dhcpclient, sans cycle. dhcpd parle net.IP et net.IPNet et garde ses structs disque privées ; dhcpapi parle chaînes JSON et convertit à la frontière. Un même type portait jusqu'ici le format du fil, la signature du Store et le format du .state — ce qui couplait le fichier au protocole alors que le ticket le décrit comme un détail interne. Le digest est calculé sur une forme canonique partagée par les deux côtés : MAC, IP et CIDR normalisés, hôtes triés, doublon de MAC refusé. Un écart de digest signale donc une vraie divergence, pas une différence d'écriture. Le listener pose un recover par connexion, plafonne les lignes à 64 Kio, refuse une ligne malformée sans fermer la connexion, écoute en 0600 et supprime une socket résiduelle avant le bind. Le client pose une deadline. 101 tests au total, -race propre, treize mutations toutes détectées. Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
parent
8f15e4d383
commit
37a9353dc0
13 changed files with 1482 additions and 445 deletions
|
|
@ -36,10 +36,9 @@ func (s *Store) Handle(req *dhcpv4.DHCPv4) (*dhcpv4.DHCPv4, error) {
|
|||
return BuildReply(subnet, host, req)
|
||||
}
|
||||
|
||||
func (s *Store) Probe(mac string) (*dhcpv4.DHCPv4, error) {
|
||||
key, err := normalizeMAC(mac)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func (s *Store) Probe(mac net.HardwareAddr) (*dhcpv4.DHCPv4, error) {
|
||||
if len(mac) == 0 {
|
||||
return nil, ErrNoMAC
|
||||
}
|
||||
|
||||
subnet, configured := s.Subnet()
|
||||
|
|
@ -47,17 +46,12 @@ func (s *Store) Probe(mac string) (*dhcpv4.DHCPv4, error) {
|
|||
return nil, ErrNotConfigured
|
||||
}
|
||||
|
||||
parsed, err := net.ParseMAC(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
host, known := s.Lookup(parsed)
|
||||
host, known := s.Lookup(mac)
|
||||
if !known {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
req, err := dhcpv4.New(dhcpv4.WithMessageType(dhcpv4.MessageTypeRequest), dhcpv4.WithHwAddr(parsed))
|
||||
req, err := dhcpv4.New(dhcpv4.WithMessageType(dhcpv4.MessageTypeRequest), dhcpv4.WithHwAddr(mac))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,24 +11,15 @@ import (
|
|||
func configuredStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, _ := loadedStore(t)
|
||||
if err := s.SetSubnet(testSubnetSnapshot()); err != nil {
|
||||
if err := s.SetSubnet(fullConfig(t)); err != nil {
|
||||
t.Fatalf("SetSubnet: %v", err)
|
||||
}
|
||||
if err := s.SetHost(testHostSnapshot()); err != nil {
|
||||
if err := s.SetHost(testHost(t)); err != nil {
|
||||
t.Fatalf("SetHost: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func mac(t *testing.T, s string) net.HardwareAddr {
|
||||
t.Helper()
|
||||
m, err := net.ParseMAC(s)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMAC(%q): %v", s, err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestHandle_KnownMACGetsAnOfferOnDiscover(t *testing.T) {
|
||||
s := configuredStore(t)
|
||||
|
||||
|
|
@ -61,7 +52,7 @@ func TestHandle_UnknownMACIsAnsweredWithSilence(t *testing.T) {
|
|||
|
||||
func TestHandle_UnconfiguredSubnetIsAnsweredWithSilence(t *testing.T) {
|
||||
s, _ := loadedStore(t)
|
||||
if err := s.SetHost(testHostSnapshot()); err != nil {
|
||||
if err := s.SetHost(testHost(t)); err != nil {
|
||||
t.Fatalf("SetHost: %v", err)
|
||||
}
|
||||
|
||||
|
|
@ -120,7 +111,7 @@ func TestHandle_NilRequestIsRejected(t *testing.T) {
|
|||
|
||||
func TestHandle_DeletedHostStopsBeingAnswered(t *testing.T) {
|
||||
s := configuredStore(t)
|
||||
if err := s.DelHost("00:22:33:00:00:0a"); err != nil {
|
||||
if err := s.DelHost(mac(t, "00:22:33:00:00:0a")); err != nil {
|
||||
t.Fatalf("DelHost: %v", err)
|
||||
}
|
||||
|
||||
|
|
@ -136,7 +127,7 @@ func TestHandle_DeletedHostStopsBeingAnswered(t *testing.T) {
|
|||
func TestProbe_ReturnsWhatWouldBeSentToTheMAC(t *testing.T) {
|
||||
s := configuredStore(t)
|
||||
|
||||
reply, err := s.Probe("00:22:33:00:00:0A")
|
||||
reply, err := s.Probe(mac(t, "00:22:33:00:00:0A"))
|
||||
if err != nil {
|
||||
t.Fatalf("Probe: %v", err)
|
||||
}
|
||||
|
|
@ -154,7 +145,7 @@ func TestProbe_ReturnsWhatWouldBeSentToTheMAC(t *testing.T) {
|
|||
func TestProbe_UnknownMACReturnsNothing(t *testing.T) {
|
||||
s := configuredStore(t)
|
||||
|
||||
reply, err := s.Probe("00:22:33:ff:ff:ff")
|
||||
reply, err := s.Probe(mac(t, "00:22:33:ff:ff:ff"))
|
||||
if err != nil {
|
||||
t.Fatalf("Probe: %v", err)
|
||||
}
|
||||
|
|
@ -166,26 +157,26 @@ func TestProbe_UnknownMACReturnsNothing(t *testing.T) {
|
|||
func TestProbe_WithoutSubnetConfigurationIsRejected(t *testing.T) {
|
||||
s, _ := loadedStore(t)
|
||||
|
||||
if _, err := s.Probe("00:22:33:00:00:0a"); !errors.Is(err, ErrNotConfigured) {
|
||||
if _, err := s.Probe(mac(t, "00:22:33:00:00:0a")); !errors.Is(err, ErrNotConfigured) {
|
||||
t.Fatalf("error = %v, want ErrNotConfigured", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbe_InvalidMACIsRejected(t *testing.T) {
|
||||
func TestProbe_EmptyMACIsRejected(t *testing.T) {
|
||||
s := configuredStore(t)
|
||||
|
||||
if _, err := s.Probe("nope"); err == nil {
|
||||
t.Fatal("an invalid mac must be reported")
|
||||
if _, err := s.Probe(nil); !errors.Is(err, ErrNoMAC) {
|
||||
t.Fatalf("error = %v, want ErrNoMAC", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandle_SecondaryInterfaceGetsNoDefaultRoute(t *testing.T) {
|
||||
s := configuredStore(t)
|
||||
snap := testHostSnapshot()
|
||||
snap.MAC = "00:22:33:00:00:0b"
|
||||
snap.IP = "10.0.5.11"
|
||||
snap.DefaultRoute = false
|
||||
if err := s.SetHost(snap); err != nil {
|
||||
h := testHost(t)
|
||||
h.MAC = mac(t, "00:22:33:00:00:0b")
|
||||
h.IP = net.ParseIP("10.0.5.11")
|
||||
h.DefaultRoute = false
|
||||
if err := s.SetHost(h); err != nil {
|
||||
t.Fatalf("SetHost: %v", err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,13 +28,26 @@ func testConfig(t *testing.T) SubnetConfig {
|
|||
}
|
||||
}
|
||||
|
||||
func fullConfig(t *testing.T) SubnetConfig {
|
||||
t.Helper()
|
||||
c := testConfig(t)
|
||||
c.VPCRoute = cidr(t, "10.0.0.0/16")
|
||||
c.DefaultGateway = net.ParseIP("10.0.5.254")
|
||||
return c
|
||||
}
|
||||
|
||||
func mac(t *testing.T, s string) net.HardwareAddr {
|
||||
t.Helper()
|
||||
m, err := net.ParseMAC(s)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMAC(%q): %v", s, err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func testHost(t *testing.T) Host {
|
||||
t.Helper()
|
||||
mac, err := net.ParseMAC("00:22:33:00:00:0a")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMAC: %v", err)
|
||||
}
|
||||
return Host{MAC: mac, IP: net.ParseIP("10.0.5.10"), VM: "vm-test", DefaultRoute: true}
|
||||
return Host{MAC: mac(t, "00:22:33:00:00:0a"), IP: net.ParseIP("10.0.5.10"), VM: "vm-test", DefaultRoute: true}
|
||||
}
|
||||
|
||||
func request(t *testing.T, kind dhcpv4.MessageType, mac net.HardwareAddr) *dhcpv4.DHCPv4 {
|
||||
|
|
|
|||
|
|
@ -1,285 +0,0 @@
|
|||
package dhcpd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
stateFileMode = 0o600
|
||||
stateDirMode = 0o700
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoMAC = errors.New("host mac is required")
|
||||
ErrNotConfigured = errors.New("subnet is not configured")
|
||||
)
|
||||
|
||||
type SubnetSnapshot struct {
|
||||
Network string `json:"network"`
|
||||
InterfaceIP string `json:"interface_ip"`
|
||||
VPCRoute string `json:"vpc_route,omitempty"`
|
||||
DefaultGateway string `json:"default_gateway,omitempty"`
|
||||
}
|
||||
|
||||
type HostSnapshot struct {
|
||||
MAC string `json:"mac"`
|
||||
IP string `json:"ip"`
|
||||
VM string `json:"vm,omitempty"`
|
||||
DefaultRoute bool `json:"default_route"`
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Subnet *SubnetSnapshot `json:"subnet,omitempty"`
|
||||
Hosts []HostSnapshot `json:"hosts"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
subnet SubnetConfig
|
||||
configured bool
|
||||
hosts map[string]Host
|
||||
}
|
||||
|
||||
func NewStore(path string) *Store {
|
||||
return &Store{path: path, hosts: make(map[string]Host)}
|
||||
}
|
||||
|
||||
func normalizeMAC(s string) (string, error) {
|
||||
mac, err := net.ParseMAC(s)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid mac %q: %w", s, err)
|
||||
}
|
||||
return mac.String(), nil
|
||||
}
|
||||
|
||||
func (s *Store) Load() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
raw, err := os.ReadFile(s.path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return s.persist()
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", s.path, err)
|
||||
}
|
||||
|
||||
var snap Snapshot
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &snap); err != nil {
|
||||
return fmt.Errorf("parse %s: %w", s.path, err)
|
||||
}
|
||||
}
|
||||
return s.apply(snap)
|
||||
}
|
||||
|
||||
func (s *Store) apply(snap Snapshot) error {
|
||||
subnet := SubnetConfig{}
|
||||
configured := false
|
||||
if snap.Subnet != nil {
|
||||
parsed, err := parseSubnet(*snap.Subnet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
subnet = parsed
|
||||
configured = true
|
||||
}
|
||||
|
||||
hosts := make(map[string]Host, len(snap.Hosts))
|
||||
for _, h := range snap.Hosts {
|
||||
host, err := parseHost(h)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hosts[host.MAC.String()] = host
|
||||
}
|
||||
|
||||
s.subnet = subnet
|
||||
s.configured = configured
|
||||
s.hosts = hosts
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseSubnet(s SubnetSnapshot) (SubnetConfig, error) {
|
||||
_, network, err := net.ParseCIDR(s.Network)
|
||||
if err != nil {
|
||||
return SubnetConfig{}, fmt.Errorf("invalid network %q: %w", s.Network, err)
|
||||
}
|
||||
|
||||
interfaceIP := net.ParseIP(s.InterfaceIP)
|
||||
if interfaceIP == nil {
|
||||
return SubnetConfig{}, ErrNoInterfaceIP
|
||||
}
|
||||
|
||||
c := SubnetConfig{Network: network, InterfaceIP: interfaceIP}
|
||||
|
||||
if s.VPCRoute != "" {
|
||||
_, vpcRoute, err := net.ParseCIDR(s.VPCRoute)
|
||||
if err != nil {
|
||||
return SubnetConfig{}, fmt.Errorf("invalid vpc route %q: %w", s.VPCRoute, err)
|
||||
}
|
||||
c.VPCRoute = vpcRoute
|
||||
}
|
||||
if s.DefaultGateway != "" {
|
||||
gw := net.ParseIP(s.DefaultGateway)
|
||||
if gw == nil {
|
||||
return SubnetConfig{}, fmt.Errorf("invalid default gateway %q", s.DefaultGateway)
|
||||
}
|
||||
c.DefaultGateway = gw
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func parseHost(h HostSnapshot) (Host, error) {
|
||||
mac, err := net.ParseMAC(h.MAC)
|
||||
if err != nil {
|
||||
return Host{}, fmt.Errorf("invalid mac %q: %w", h.MAC, err)
|
||||
}
|
||||
ip := net.ParseIP(h.IP)
|
||||
if ip == nil {
|
||||
return Host{}, fmt.Errorf("invalid host ip %q", h.IP)
|
||||
}
|
||||
return Host{MAC: mac, IP: ip, VM: h.VM, DefaultRoute: h.DefaultRoute}, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetSubnet(snap SubnetSnapshot) error {
|
||||
c, err := parseSubnet(snap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.subnet = c
|
||||
s.configured = true
|
||||
return s.persist()
|
||||
}
|
||||
|
||||
func (s *Store) SetHost(snap HostSnapshot) error {
|
||||
if snap.MAC == "" {
|
||||
return ErrNoMAC
|
||||
}
|
||||
host, err := parseHost(snap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.hosts[host.MAC.String()] = host
|
||||
return s.persist()
|
||||
}
|
||||
|
||||
func (s *Store) DelHost(mac string) error {
|
||||
key, err := normalizeMAC(mac)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
delete(s.hosts, key)
|
||||
return s.persist()
|
||||
}
|
||||
|
||||
func (s *Store) Lookup(mac net.HardwareAddr) (Host, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
h, ok := s.hosts[mac.String()]
|
||||
return h, ok
|
||||
}
|
||||
|
||||
func (s *Store) Subnet() (SubnetConfig, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return s.subnet, s.configured
|
||||
}
|
||||
|
||||
func (s *Store) Snapshot() Snapshot {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return s.snapshot()
|
||||
}
|
||||
|
||||
func (s *Store) snapshot() Snapshot {
|
||||
snap := Snapshot{Hosts: make([]HostSnapshot, 0, len(s.hosts))}
|
||||
|
||||
if s.configured {
|
||||
sub := SubnetSnapshot{
|
||||
Network: s.subnet.Network.String(),
|
||||
InterfaceIP: s.subnet.InterfaceIP.String(),
|
||||
}
|
||||
if s.subnet.VPCRoute != nil {
|
||||
sub.VPCRoute = s.subnet.VPCRoute.String()
|
||||
}
|
||||
if s.subnet.DefaultGateway != nil {
|
||||
sub.DefaultGateway = s.subnet.DefaultGateway.String()
|
||||
}
|
||||
snap.Subnet = &sub
|
||||
}
|
||||
|
||||
for _, h := range s.hosts {
|
||||
snap.Hosts = append(snap.Hosts, HostSnapshot{
|
||||
MAC: h.MAC.String(),
|
||||
IP: h.IP.String(),
|
||||
VM: h.VM,
|
||||
DefaultRoute: h.DefaultRoute,
|
||||
})
|
||||
}
|
||||
sort.Slice(snap.Hosts, func(i, j int) bool { return snap.Hosts[i].MAC < snap.Hosts[j].MAC })
|
||||
|
||||
return snap
|
||||
}
|
||||
|
||||
func (s *Store) persist() error {
|
||||
raw, err := json.Marshal(s.snapshot())
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode state: %w", err)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(s.path)
|
||||
if err := os.MkdirAll(dir, stateDirMode); err != nil {
|
||||
return fmt.Errorf("create %s: %w", dir, err)
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp(dir, filepath.Base(s.path)+".tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp state in %s: %w", dir, err)
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
|
||||
if err := tmp.Chmod(stateFileMode); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("chmod %s: %w", tmp.Name(), err)
|
||||
}
|
||||
if _, err := tmp.Write(raw); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("write %s: %w", tmp.Name(), err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("sync %s: %w", tmp.Name(), err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close %s: %w", tmp.Name(), err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmp.Name(), s.path); err != nil {
|
||||
return fmt.Errorf("rename %s to %s: %w", tmp.Name(), s.path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
255
internal/dhcpd/store.go
Normal file
255
internal/dhcpd/store.go
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
package dhcpd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"git.g3e.fr/syonad/two/pkg/db/statefile"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoMAC = errors.New("host mac is required")
|
||||
ErrNotConfigured = errors.New("subnet is not configured")
|
||||
)
|
||||
|
||||
type diskSubnet struct {
|
||||
Network string `json:"network"`
|
||||
InterfaceIP string `json:"interface_ip"`
|
||||
VPCRoute string `json:"vpc_route,omitempty"`
|
||||
DefaultGateway string `json:"default_gateway,omitempty"`
|
||||
}
|
||||
|
||||
type diskHost struct {
|
||||
MAC string `json:"mac"`
|
||||
IP string `json:"ip"`
|
||||
VM string `json:"vm,omitempty"`
|
||||
DefaultRoute bool `json:"default_route"`
|
||||
}
|
||||
|
||||
type diskState struct {
|
||||
Subnet *diskSubnet `json:"subnet,omitempty"`
|
||||
Hosts []diskHost `json:"hosts"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
file *statefile.File[diskState]
|
||||
subnet SubnetConfig
|
||||
configured bool
|
||||
hosts map[string]Host
|
||||
}
|
||||
|
||||
func NewStore(path string) *Store {
|
||||
return &Store{
|
||||
file: statefile.New[diskState](path),
|
||||
hosts: make(map[string]Host),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) Path() string {
|
||||
return s.file.Path()
|
||||
}
|
||||
|
||||
func (s *Store) Load() error {
|
||||
state, err := s.file.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
subnet := SubnetConfig{}
|
||||
configured := false
|
||||
if state.Subnet != nil {
|
||||
parsed, err := subnetFromDisk(*state.Subnet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
subnet = parsed
|
||||
configured = true
|
||||
}
|
||||
|
||||
hosts := make(map[string]Host, len(state.Hosts))
|
||||
for _, h := range state.Hosts {
|
||||
host, err := hostFromDisk(h)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hosts[host.MAC.String()] = host
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.subnet = subnet
|
||||
s.configured = configured
|
||||
s.hosts = hosts
|
||||
return nil
|
||||
}
|
||||
|
||||
func subnetFromDisk(d diskSubnet) (SubnetConfig, error) {
|
||||
_, network, err := net.ParseCIDR(d.Network)
|
||||
if err != nil {
|
||||
return SubnetConfig{}, fmt.Errorf("invalid network %q: %w", d.Network, err)
|
||||
}
|
||||
interfaceIP := net.ParseIP(d.InterfaceIP)
|
||||
if interfaceIP == nil {
|
||||
return SubnetConfig{}, ErrNoInterfaceIP
|
||||
}
|
||||
|
||||
c := SubnetConfig{Network: network, InterfaceIP: interfaceIP}
|
||||
|
||||
if d.VPCRoute != "" {
|
||||
if _, c.VPCRoute, err = net.ParseCIDR(d.VPCRoute); err != nil {
|
||||
return SubnetConfig{}, fmt.Errorf("invalid vpc route %q: %w", d.VPCRoute, err)
|
||||
}
|
||||
}
|
||||
if d.DefaultGateway != "" {
|
||||
if c.DefaultGateway = net.ParseIP(d.DefaultGateway); c.DefaultGateway == nil {
|
||||
return SubnetConfig{}, fmt.Errorf("invalid default gateway %q", d.DefaultGateway)
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func hostFromDisk(d diskHost) (Host, error) {
|
||||
mac, err := net.ParseMAC(d.MAC)
|
||||
if err != nil {
|
||||
return Host{}, fmt.Errorf("invalid mac %q: %w", d.MAC, err)
|
||||
}
|
||||
ip := net.ParseIP(d.IP)
|
||||
if ip == nil {
|
||||
return Host{}, fmt.Errorf("invalid host ip %q", d.IP)
|
||||
}
|
||||
return Host{MAC: mac, IP: ip, VM: d.VM, DefaultRoute: d.DefaultRoute}, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetSubnet(c SubnetConfig) error {
|
||||
if c.Network == nil {
|
||||
return ErrNoNetwork
|
||||
}
|
||||
if c.InterfaceIP == nil {
|
||||
return ErrNoInterfaceIP
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
previous, wasConfigured := s.subnet, s.configured
|
||||
s.subnet, s.configured = c, true
|
||||
|
||||
if err := s.persist(); err != nil {
|
||||
s.subnet, s.configured = previous, wasConfigured
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SetHost(h Host) error {
|
||||
if len(h.MAC) == 0 {
|
||||
return ErrNoMAC
|
||||
}
|
||||
if h.IP == nil {
|
||||
return ErrNoHostIP
|
||||
}
|
||||
|
||||
key := h.MAC.String()
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
previous, existed := s.hosts[key]
|
||||
s.hosts[key] = h
|
||||
|
||||
if err := s.persist(); err != nil {
|
||||
if existed {
|
||||
s.hosts[key] = previous
|
||||
} else {
|
||||
delete(s.hosts, key)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DelHost(mac net.HardwareAddr) error {
|
||||
if len(mac) == 0 {
|
||||
return ErrNoMAC
|
||||
}
|
||||
key := mac.String()
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
previous, existed := s.hosts[key]
|
||||
delete(s.hosts, key)
|
||||
|
||||
if err := s.persist(); err != nil {
|
||||
if existed {
|
||||
s.hosts[key] = previous
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Lookup(mac net.HardwareAddr) (Host, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
h, ok := s.hosts[mac.String()]
|
||||
return h, ok
|
||||
}
|
||||
|
||||
func (s *Store) Subnet() (SubnetConfig, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return s.subnet, s.configured
|
||||
}
|
||||
|
||||
func (s *Store) Hosts() []Host {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return s.sortedHosts()
|
||||
}
|
||||
|
||||
func (s *Store) sortedHosts() []Host {
|
||||
hosts := make([]Host, 0, len(s.hosts))
|
||||
for _, h := range s.hosts {
|
||||
hosts = append(hosts, h)
|
||||
}
|
||||
sort.Slice(hosts, func(i, j int) bool { return hosts[i].MAC.String() < hosts[j].MAC.String() })
|
||||
return hosts
|
||||
}
|
||||
|
||||
func (s *Store) persist() error {
|
||||
state := diskState{Hosts: make([]diskHost, 0, len(s.hosts))}
|
||||
|
||||
if s.configured {
|
||||
sub := diskSubnet{
|
||||
Network: s.subnet.Network.String(),
|
||||
InterfaceIP: s.subnet.InterfaceIP.String(),
|
||||
}
|
||||
if s.subnet.VPCRoute != nil {
|
||||
sub.VPCRoute = s.subnet.VPCRoute.String()
|
||||
}
|
||||
if s.subnet.DefaultGateway != nil {
|
||||
sub.DefaultGateway = s.subnet.DefaultGateway.String()
|
||||
}
|
||||
state.Subnet = &sub
|
||||
}
|
||||
|
||||
for _, h := range s.sortedHosts() {
|
||||
state.Hosts = append(state.Hosts, diskHost{
|
||||
MAC: h.MAC.String(),
|
||||
IP: h.IP.String(),
|
||||
VM: h.VM,
|
||||
DefaultRoute: h.DefaultRoute,
|
||||
})
|
||||
}
|
||||
|
||||
return s.file.Save(state)
|
||||
}
|
||||
|
|
@ -14,19 +14,6 @@ func statePath(t *testing.T) string {
|
|||
return filepath.Join(t.TempDir(), "vp-admin_br-000001.state")
|
||||
}
|
||||
|
||||
func testSubnetSnapshot() SubnetSnapshot {
|
||||
return SubnetSnapshot{
|
||||
Network: "10.0.5.0/24",
|
||||
InterfaceIP: "10.0.5.1",
|
||||
VPCRoute: "10.0.0.0/16",
|
||||
DefaultGateway: "10.0.5.254",
|
||||
}
|
||||
}
|
||||
|
||||
func testHostSnapshot() HostSnapshot {
|
||||
return HostSnapshot{MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", VM: "vm-test", DefaultRoute: true}
|
||||
}
|
||||
|
||||
func loadedStore(t *testing.T) (*Store, string) {
|
||||
t.Helper()
|
||||
path := statePath(t)
|
||||
|
|
@ -49,28 +36,12 @@ func TestStore_LoadCreatesTheStateFileWhenAbsent(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestStore_PersistRestoresTheModeAfterAnExternalChmod(t *testing.T) {
|
||||
s, path := loadedStore(t)
|
||||
if err := os.Chmod(path, 0o644); err != nil {
|
||||
t.Fatalf("Chmod: %v", err)
|
||||
}
|
||||
|
||||
if err := s.SetHost(testHostSnapshot()); err != nil {
|
||||
t.Fatalf("SetHost: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat: %v", err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != 0o600 {
|
||||
t.Errorf("mode = %o, want 600: each write must replace the file, not edit it in place", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_LoadOnEmptyFileYieldsNoSubnet(t *testing.T) {
|
||||
path := statePath(t)
|
||||
if err := os.WriteFile(path, nil, stateFileMode); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, nil, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
|
|
@ -85,7 +56,7 @@ func TestStore_LoadOnEmptyFileYieldsNoSubnet(t *testing.T) {
|
|||
|
||||
func TestStore_LoadRejectsCorruptedState(t *testing.T) {
|
||||
path := statePath(t)
|
||||
if err := os.WriteFile(path, []byte("{not json"), stateFileMode); err != nil {
|
||||
if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
|
|
@ -94,9 +65,21 @@ func TestStore_LoadRejectsCorruptedState(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestStore_LoadRejectsAnInvalidStoredMAC(t *testing.T) {
|
||||
path := statePath(t)
|
||||
raw := []byte(`{"hosts":[{"mac":"nope","ip":"10.0.5.10","default_route":true}]}`)
|
||||
if err := os.WriteFile(path, raw, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
if err := NewStore(path).Load(); err == nil {
|
||||
t.Fatal("an unparseable stored mac must be reported")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_SetSubnetIsPersisted(t *testing.T) {
|
||||
s, path := loadedStore(t)
|
||||
if err := s.SetSubnet(testSubnetSnapshot()); err != nil {
|
||||
if err := s.SetSubnet(fullConfig(t)); err != nil {
|
||||
t.Fatalf("SetSubnet: %v", err)
|
||||
}
|
||||
|
||||
|
|
@ -125,27 +108,27 @@ func TestStore_SetSubnetIsPersisted(t *testing.T) {
|
|||
|
||||
func TestStore_SetSubnetRejectsAMissingInterfaceIP(t *testing.T) {
|
||||
s, _ := loadedStore(t)
|
||||
snap := testSubnetSnapshot()
|
||||
snap.InterfaceIP = ""
|
||||
c := fullConfig(t)
|
||||
c.InterfaceIP = nil
|
||||
|
||||
if err := s.SetSubnet(snap); !errors.Is(err, ErrNoInterfaceIP) {
|
||||
if err := s.SetSubnet(c); !errors.Is(err, ErrNoInterfaceIP) {
|
||||
t.Fatalf("error = %v, want ErrNoInterfaceIP", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_SetSubnetRejectsAnInvalidNetwork(t *testing.T) {
|
||||
func TestStore_SetSubnetRejectsAMissingNetwork(t *testing.T) {
|
||||
s, _ := loadedStore(t)
|
||||
snap := testSubnetSnapshot()
|
||||
snap.Network = "10.0.5.0"
|
||||
c := fullConfig(t)
|
||||
c.Network = nil
|
||||
|
||||
if err := s.SetSubnet(snap); err == nil {
|
||||
t.Fatal("a network without a prefix length must be rejected")
|
||||
if err := s.SetSubnet(c); !errors.Is(err, ErrNoNetwork) {
|
||||
t.Fatalf("error = %v, want ErrNoNetwork", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_SetHostIsPersistedAndFound(t *testing.T) {
|
||||
s, path := loadedStore(t)
|
||||
if err := s.SetHost(testHostSnapshot()); err != nil {
|
||||
if err := s.SetHost(testHost(t)); err != nil {
|
||||
t.Fatalf("SetHost: %v", err)
|
||||
}
|
||||
|
||||
|
|
@ -154,11 +137,7 @@ func TestStore_SetHostIsPersistedAndFound(t *testing.T) {
|
|||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
mac, err := net.ParseMAC("00:22:33:00:00:0a")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMAC: %v", err)
|
||||
}
|
||||
h, known := reloaded.Lookup(mac)
|
||||
h, known := reloaded.Lookup(mac(t, "00:22:33:00:00:0a"))
|
||||
if !known {
|
||||
t.Fatal("host lost across a restart")
|
||||
}
|
||||
|
|
@ -175,52 +154,43 @@ func TestStore_SetHostIsPersistedAndFound(t *testing.T) {
|
|||
|
||||
func TestStore_SetHostIsIdempotentOnTheSameMAC(t *testing.T) {
|
||||
s, _ := loadedStore(t)
|
||||
snap := testHostSnapshot()
|
||||
if err := s.SetHost(snap); err != nil {
|
||||
h := testHost(t)
|
||||
if err := s.SetHost(h); err != nil {
|
||||
t.Fatalf("SetHost: %v", err)
|
||||
}
|
||||
|
||||
snap.IP = "10.0.5.11"
|
||||
snap.DefaultRoute = false
|
||||
if err := s.SetHost(snap); err != nil {
|
||||
h.IP = net.ParseIP("10.0.5.11")
|
||||
h.DefaultRoute = false
|
||||
if err := s.SetHost(h); err != nil {
|
||||
t.Fatalf("SetHost: %v", err)
|
||||
}
|
||||
|
||||
got := s.Snapshot()
|
||||
if len(got.Hosts) != 1 {
|
||||
t.Fatalf("hosts = %d, want 1: the mac is the key", len(got.Hosts))
|
||||
hosts := s.Hosts()
|
||||
if len(hosts) != 1 {
|
||||
t.Fatalf("hosts = %d, want 1: the mac is the key", len(hosts))
|
||||
}
|
||||
if got.Hosts[0].IP != "10.0.5.11" || got.Hosts[0].DefaultRoute {
|
||||
t.Errorf("entry = %+v, want the second order to have replaced the first", got.Hosts[0])
|
||||
if !hosts[0].IP.Equal(net.ParseIP("10.0.5.11")) || hosts[0].DefaultRoute {
|
||||
t.Errorf("entry = %+v, want the second order to have replaced the first", hosts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_SetHostNormalizesTheMACCase(t *testing.T) {
|
||||
func TestStore_LookupNormalizesTheMACCase(t *testing.T) {
|
||||
s, _ := loadedStore(t)
|
||||
snap := testHostSnapshot()
|
||||
snap.MAC = "00:22:33:AA:BB:CC"
|
||||
if err := s.SetHost(snap); err != nil {
|
||||
h := testHost(t)
|
||||
h.MAC = mac(t, "00:22:33:AA:BB:CC")
|
||||
if err := s.SetHost(h); err != nil {
|
||||
t.Fatalf("SetHost: %v", err)
|
||||
}
|
||||
|
||||
mac, err := net.ParseMAC("00:22:33:aa:bb:cc")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMAC: %v", err)
|
||||
}
|
||||
if _, known := s.Lookup(mac); !known {
|
||||
if _, known := s.Lookup(mac(t, "00:22:33:aa:bb:cc")); !known {
|
||||
t.Error("an uppercase mac must be found in lowercase: the key would diverge")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_LoadNormalizesTheMACCase(t *testing.T) {
|
||||
path := statePath(t)
|
||||
raw, err := json.Marshal(Snapshot{Hosts: []HostSnapshot{{
|
||||
MAC: "00:22:33:AA:BB:CC", IP: "10.0.5.12", VM: "vm-test", DefaultRoute: true,
|
||||
}}})
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, raw, stateFileMode); err != nil {
|
||||
raw := []byte(`{"hosts":[{"mac":"00:22:33:AA:BB:CC","ip":"10.0.5.12","default_route":true}]}`)
|
||||
if err := os.WriteFile(path, raw, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
|
|
@ -228,42 +198,37 @@ func TestStore_LoadNormalizesTheMACCase(t *testing.T) {
|
|||
if err := s.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
m, err := net.ParseMAC("00:22:33:aa:bb:cc")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMAC: %v", err)
|
||||
}
|
||||
if _, known := s.Lookup(m); !known {
|
||||
if _, known := s.Lookup(mac(t, "00:22:33:aa:bb:cc")); !known {
|
||||
t.Error("a reloaded uppercase mac must be keyed in lowercase: the host would silently stop being served")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_SetHostRejectsAMissingMAC(t *testing.T) {
|
||||
s, _ := loadedStore(t)
|
||||
snap := testHostSnapshot()
|
||||
snap.MAC = ""
|
||||
h := testHost(t)
|
||||
h.MAC = nil
|
||||
|
||||
if err := s.SetHost(snap); !errors.Is(err, ErrNoMAC) {
|
||||
if err := s.SetHost(h); !errors.Is(err, ErrNoMAC) {
|
||||
t.Fatalf("error = %v, want ErrNoMAC", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_SetHostRejectsAnInvalidIP(t *testing.T) {
|
||||
func TestStore_SetHostRejectsAMissingIP(t *testing.T) {
|
||||
s, _ := loadedStore(t)
|
||||
snap := testHostSnapshot()
|
||||
snap.IP = "10.0.5.300"
|
||||
h := testHost(t)
|
||||
h.IP = nil
|
||||
|
||||
if err := s.SetHost(snap); err == nil {
|
||||
t.Fatal("an invalid host ip must be rejected")
|
||||
if err := s.SetHost(h); !errors.Is(err, ErrNoHostIP) {
|
||||
t.Fatalf("error = %v, want ErrNoHostIP", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_DelHostRemovesTheEntry(t *testing.T) {
|
||||
s, path := loadedStore(t)
|
||||
if err := s.SetHost(testHostSnapshot()); err != nil {
|
||||
if err := s.SetHost(testHost(t)); err != nil {
|
||||
t.Fatalf("SetHost: %v", err)
|
||||
}
|
||||
if err := s.DelHost("00:22:33:00:00:0A"); err != nil {
|
||||
if err := s.DelHost(mac(t, "00:22:33:00:00:0A")); err != nil {
|
||||
t.Fatalf("DelHost: %v", err)
|
||||
}
|
||||
|
||||
|
|
@ -271,76 +236,92 @@ func TestStore_DelHostRemovesTheEntry(t *testing.T) {
|
|||
if err := reloaded.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if got := len(reloaded.Snapshot().Hosts); got != 0 {
|
||||
if got := len(reloaded.Hosts()); got != 0 {
|
||||
t.Errorf("hosts = %d, want 0 after deletion", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_DelHostOnAnUnknownMACIsNotAnError(t *testing.T) {
|
||||
s, _ := loadedStore(t)
|
||||
if err := s.DelHost("00:22:33:ff:ff:ff"); err != nil {
|
||||
if err := s.DelHost(mac(t, "00:22:33:ff:ff:ff")); err != nil {
|
||||
t.Errorf("deleting an absent entry must be idempotent, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_DelHostRejectsAnInvalidMAC(t *testing.T) {
|
||||
func TestStore_DelHostRejectsAnEmptyMAC(t *testing.T) {
|
||||
s, _ := loadedStore(t)
|
||||
if err := s.DelHost("not-a-mac"); err == nil {
|
||||
t.Fatal("an invalid mac must be reported")
|
||||
if err := s.DelHost(nil); !errors.Is(err, ErrNoMAC) {
|
||||
t.Fatalf("error = %v, want ErrNoMAC", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_SnapshotSortsHostsByMAC(t *testing.T) {
|
||||
func TestStore_HostsAreSortedByMAC(t *testing.T) {
|
||||
s, _ := loadedStore(t)
|
||||
for _, mac := range []string{"00:22:33:00:00:0c", "00:22:33:00:00:0a", "00:22:33:00:00:0b"} {
|
||||
snap := testHostSnapshot()
|
||||
snap.MAC = mac
|
||||
if err := s.SetHost(snap); err != nil {
|
||||
for _, m := range []string{"00:22:33:00:00:0c", "00:22:33:00:00:0a", "00:22:33:00:00:0b"} {
|
||||
h := testHost(t)
|
||||
h.MAC = mac(t, m)
|
||||
if err := s.SetHost(h); err != nil {
|
||||
t.Fatalf("SetHost: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
hosts := s.Snapshot().Hosts
|
||||
hosts := s.Hosts()
|
||||
for i := 1; i < len(hosts); i++ {
|
||||
if hosts[i-1].MAC >= hosts[i].MAC {
|
||||
if hosts[i-1].MAC.String() >= hosts[i].MAC.String() {
|
||||
t.Fatalf("hosts are not sorted: %v", hosts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_PersistLeavesNoTemporaryFileBehind(t *testing.T) {
|
||||
func TestStore_PersistedStateIsSortedOnDisk(t *testing.T) {
|
||||
s, path := loadedStore(t)
|
||||
if err := s.SetHost(testHostSnapshot()); err != nil {
|
||||
t.Fatalf("SetHost: %v", err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(filepath.Dir(path))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDir: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("directory holds %d entries, want only the state file: %v", len(entries), entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_PersistedStateIsValidJSON(t *testing.T) {
|
||||
s, path := loadedStore(t)
|
||||
if err := s.SetSubnet(testSubnetSnapshot()); err != nil {
|
||||
t.Fatalf("SetSubnet: %v", err)
|
||||
}
|
||||
if err := s.SetHost(testHostSnapshot()); err != nil {
|
||||
t.Fatalf("SetHost: %v", err)
|
||||
for _, m := range []string{"00:22:33:00:00:0c", "00:22:33:00:00:0a"} {
|
||||
h := testHost(t)
|
||||
h.MAC = mac(t, m)
|
||||
if err := s.SetHost(h); err != nil {
|
||||
t.Fatalf("SetHost: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
var snap Snapshot
|
||||
if err := json.Unmarshal(raw, &snap); err != nil {
|
||||
var state struct {
|
||||
Hosts []struct {
|
||||
MAC string `json:"mac"`
|
||||
} `json:"hosts"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &state); err != nil {
|
||||
t.Fatalf("the state file must stay parseable: %v", err)
|
||||
}
|
||||
if snap.Subnet == nil || len(snap.Hosts) != 1 {
|
||||
t.Errorf("snapshot = %+v, want one subnet and one host", snap)
|
||||
if len(state.Hosts) != 2 || state.Hosts[0].MAC != "00:22:33:00:00:0a" {
|
||||
t.Errorf("hosts on disk = %+v, want sorted by mac", state.Hosts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_PersistRestoresTheModeAfterAnExternalChmod(t *testing.T) {
|
||||
s, path := loadedStore(t)
|
||||
if err := os.Chmod(path, 0o644); err != nil {
|
||||
t.Fatalf("Chmod: %v", err)
|
||||
}
|
||||
|
||||
if err := s.SetHost(testHost(t)); err != nil {
|
||||
t.Fatalf("SetHost: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat: %v", err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != 0o600 {
|
||||
t.Errorf("mode = %o, want 600: each write must replace the file, not edit it in place", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_PathReportsTheStateFile(t *testing.T) {
|
||||
s, path := loadedStore(t)
|
||||
if got := s.Path(); got != path {
|
||||
t.Errorf("Path = %s, want %s", got, path)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue