The owner asked to see the vendor of each MAC where the IEEE registry can be fetched, and never to depend on it. internal/oui downloads MA-L, MA-M and MA-S weekly through the outbound allow-list (timeouts, a size cap and a row-count sanity check, so a truncated or wrong download keeps the previous file), stores one checksummed, atomically written file in <data-dir>/oui that nmon backup includes, and looks MACs up by the longest prefix. A missing registry only leaves the vendor empty. `nmon oui import DIR|FILE` loads the CSVs offline, and the Ansible role can ship them next to the MIB sidecar. internal/hostaddr parses the MAC, prefix, IP and CIDR forms the lookup accepts and classifies a MAC as global, local or multicast. config gains the l2 and oui sections (validated here; the L2 job that reads l2 follows). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4PdJUPrakM8fHvN8jYv2x
138 lines
4.0 KiB
Go
138 lines
4.0 KiB
Go
package backup
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/v10networks/nmon/internal/clock"
|
|
"github.com/v10networks/nmon/internal/secrets"
|
|
"github.com/v10networks/nmon/internal/store"
|
|
)
|
|
|
|
type fixture struct {
|
|
t *testing.T
|
|
dir string
|
|
keyFile string
|
|
clk *clock.Fake
|
|
st store.Store
|
|
}
|
|
|
|
// newFixture builds a data dir with a migrated, populated SQLite inventory, a key file, config and a profile.
|
|
func newFixture(t *testing.T) *fixture {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
keyFile := filepath.Join(t.TempDir(), "secret.key")
|
|
if err := secrets.GenerateKeyFile(keyFile); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
kr, err := secrets.LoadKeyring(keyFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
clk := clock.NewFake(time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC))
|
|
ctx := context.Background()
|
|
st, err := store.Open(ctx, store.DriverSQLite, filepath.Join(dir, "inventory.db"), kr, clk)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = st.Close() })
|
|
if err := st.Migrate(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, n := range []string{"r1", "r2", "r3"} {
|
|
if err := st.CreateDevice(ctx, &store.Device{Name: n, Kind: store.KindNetwork, Enabled: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := st.PutSecret(ctx, "proxmox", "tok"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeFile(t, filepath.Join(dir, "config.yaml"), "timezone: UTC\n")
|
|
writeFile(t, filepath.Join(dir, "profiles", "custom.yaml"), "name: custom\n")
|
|
writeFile(t, filepath.Join(dir, "oui", "registry.gz"), "vendor registry bytes")
|
|
return &fixture{t: t, dir: dir, keyFile: keyFile, clk: clk, st: st}
|
|
}
|
|
|
|
func (f *fixture) options(out string) Options {
|
|
return Options{DataDir: f.dir, KeyFile: f.keyFile, OutPath: out, Store: f.st, Clock: f.clk}
|
|
}
|
|
|
|
func writeFile(t *testing.T, path, content string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// fakeVM answers the parts of the VictoriaMetrics API that backup uses and records snapshot lifecycle.
|
|
type fakeVM struct {
|
|
*httptest.Server
|
|
mu sync.Mutex
|
|
dataDir string
|
|
created int
|
|
deleted []string
|
|
failSnap bool
|
|
}
|
|
|
|
func newFakeVM(t *testing.T, dataDir string) *fakeVM {
|
|
t.Helper()
|
|
v := &fakeVM{dataDir: dataDir}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/snapshot/create", func(w http.ResponseWriter, _ *http.Request) {
|
|
v.mu.Lock()
|
|
defer v.mu.Unlock()
|
|
if v.failSnap {
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"status": "error", "msg": "boom"})
|
|
return
|
|
}
|
|
v.created++
|
|
name := "snap-1"
|
|
writeFile(t, filepath.Join(dataDir, "snapshots", name, "data", "part-1"), "samples")
|
|
writeFile(t, filepath.Join(dataDir, "snapshots", name, "indexdb", "idx-1"), "index")
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok", "snapshot": name})
|
|
})
|
|
mux.HandleFunc("/snapshot/delete", func(w http.ResponseWriter, r *http.Request) {
|
|
v.mu.Lock()
|
|
v.deleted = append(v.deleted, r.URL.Query().Get("snapshot"))
|
|
v.mu.Unlock()
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
|
})
|
|
mux.HandleFunc("/api/v1/series", func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(`{"status":"success","data":[{"__name__":"m","device":"d1"}]}`))
|
|
})
|
|
mux.HandleFunc("/api/v1/export", func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(`{"metric":{"__name__":"m"},"values":[1,2],"timestamps":[1000,2000]}` + "\n"))
|
|
})
|
|
v.Server = httptest.NewServer(mux)
|
|
t.Cleanup(v.Close)
|
|
return v
|
|
}
|
|
|
|
// stubVerifier records what Restore asked it to check.
|
|
type stubVerifier struct {
|
|
called bool
|
|
stores []StoreManifest
|
|
err error
|
|
seen map[string]bool
|
|
}
|
|
|
|
func (s *stubVerifier) VerifySeries(_ context.Context, dataDir string, _ time.Time, stores []StoreManifest) error {
|
|
s.called, s.stores = true, stores
|
|
s.seen = map[string]bool{}
|
|
for _, st := range stores {
|
|
_, err := os.Stat(filepath.Join(dataDir, "vm-"+st.Name, "data", "part-1"))
|
|
s.seen[st.Name] = err == nil
|
|
}
|
|
return s.err
|
|
}
|