Add core modules (SSH args parser, cache, resolver, NetBox client) with tests
Release / release (push) Failing after 51s
Release / release (push) Failing after 51s
This commit is contained in:
Vendored
+134
@@ -0,0 +1,134 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
Name string `json:"name"`
|
||||
IP string `json:"ip"`
|
||||
Kind string `json:"kind"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
CachedAt time.Time `json:"cached_at"`
|
||||
}
|
||||
|
||||
type Cache struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]Entry
|
||||
path string
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
type diskFormat struct {
|
||||
Entries []Entry `json:"entries"`
|
||||
}
|
||||
|
||||
func New(path string, ttlSeconds int) *Cache {
|
||||
return &Cache{
|
||||
entries: make(map[string]Entry),
|
||||
path: path,
|
||||
ttl: time.Duration(ttlSeconds) * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) Load() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
data, err := os.ReadFile(c.path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var df diskFormat
|
||||
if err := json.Unmarshal(data, &df); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.entries = make(map[string]Entry, len(df.Entries))
|
||||
for _, e := range df.Entries {
|
||||
c.entries[e.Name] = e
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) Save() error {
|
||||
c.mu.RLock()
|
||||
df := diskFormat{Entries: make([]Entry, 0, len(c.entries))}
|
||||
for _, e := range c.entries {
|
||||
df.Entries = append(df.Entries, e)
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(df, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(c.path, data, 0o644)
|
||||
}
|
||||
|
||||
func (c *Cache) Upsert(e Entry) {
|
||||
e.CachedAt = time.Now()
|
||||
c.mu.Lock()
|
||||
c.entries[e.Name] = e
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Search returns all entries whose name starts with prefix (case-insensitive).
|
||||
// TTL is intentionally ignored — this is used for shell completion.
|
||||
func (c *Cache) Search(prefix string) []Entry {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
prefix = strings.ToLower(prefix)
|
||||
var out []Entry
|
||||
for name, e := range c.entries {
|
||||
if strings.HasPrefix(strings.ToLower(name), prefix) {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Get returns an entry and reports whether it is still within the TTL.
|
||||
func (c *Cache) Get(name string) (entry Entry, fresh bool) {
|
||||
c.mu.RLock()
|
||||
e, ok := c.entries[name]
|
||||
c.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return Entry{}, false
|
||||
}
|
||||
if c.ttl == 0 {
|
||||
return e, false
|
||||
}
|
||||
return e, time.Since(e.CachedAt) < c.ttl
|
||||
}
|
||||
|
||||
func (c *Cache) Clear() {
|
||||
c.mu.Lock()
|
||||
c.entries = make(map[string]Entry)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Cache) All() []Entry {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
out := make([]Entry, 0, len(c.entries))
|
||||
for _, e := range c.entries {
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Vendored
+235
@@ -0,0 +1,235 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
c := New("/tmp/test.json", 60)
|
||||
if c == nil {
|
||||
t.Fatal("New returned nil")
|
||||
}
|
||||
if c.ttl != 60*time.Second {
|
||||
t.Errorf("ttl: got %v, want %v", c.ttl, 60*time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MissingFile(t *testing.T) {
|
||||
c := New("/nonexistent/path/cache.json", 60)
|
||||
if err := c.Load(); err != nil {
|
||||
t.Errorf("Load on missing file should not error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_InvalidJSON(t *testing.T) {
|
||||
f := tempFile(t, []byte("not json"))
|
||||
c := New(f, 60)
|
||||
if err := c.Load(); err == nil {
|
||||
t.Error("Load on invalid JSON should return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAndLoad_Roundtrip(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "cache.json")
|
||||
c := New(path, 3600)
|
||||
|
||||
c.Upsert(Entry{Name: "host-a", IP: "10.0.0.1", Kind: "device"})
|
||||
c.Upsert(Entry{Name: "host-b", IP: "10.0.0.2", Kind: "vm", Tags: []string{"prod"}})
|
||||
|
||||
if err := c.Save(); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
c2 := New(path, 3600)
|
||||
if err := c2.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
e, _ := c2.Get("host-a")
|
||||
if e.IP != "10.0.0.1" {
|
||||
t.Errorf("host-a IP: got %q, want %q", e.IP, "10.0.0.1")
|
||||
}
|
||||
e2, _ := c2.Get("host-b")
|
||||
if len(e2.Tags) != 1 || e2.Tags[0] != "prod" {
|
||||
t.Errorf("host-b tags: got %v, want [prod]", e2.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSave_CreatesDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sub", "dir", "cache.json")
|
||||
c := New(path, 60)
|
||||
c.Upsert(Entry{Name: "x", IP: "1.2.3.4", Kind: "device"})
|
||||
if err := c.Save(); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Errorf("cache file not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsert_SetsTimestamp(t *testing.T) {
|
||||
c := New("", 60)
|
||||
before := time.Now()
|
||||
c.Upsert(Entry{Name: "h", IP: "1.1.1.1", Kind: "device"})
|
||||
e, _ := c.Get("h")
|
||||
if e.CachedAt.Before(before) {
|
||||
t.Error("CachedAt should be set to current time on Upsert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsert_Overwrites(t *testing.T) {
|
||||
c := New("", 60)
|
||||
c.Upsert(Entry{Name: "host", IP: "10.0.0.1", Kind: "device"})
|
||||
c.Upsert(Entry{Name: "host", IP: "10.0.0.2", Kind: "device"})
|
||||
e, _ := c.Get("host")
|
||||
if e.IP != "10.0.0.2" {
|
||||
t.Errorf("Upsert should overwrite: got %q, want %q", e.IP, "10.0.0.2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch_PrefixMatch(t *testing.T) {
|
||||
c := New("", 60)
|
||||
c.Upsert(Entry{Name: "app-server-01", IP: "10.0.0.1", Kind: "device"})
|
||||
c.Upsert(Entry{Name: "app-server-02", IP: "10.0.0.2", Kind: "vm"})
|
||||
c.Upsert(Entry{Name: "db-server-01", IP: "10.0.0.3", Kind: "device"})
|
||||
|
||||
results := c.Search("app")
|
||||
if len(results) != 2 {
|
||||
t.Errorf("Search(app): got %d results, want 2", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch_CaseInsensitive(t *testing.T) {
|
||||
c := New("", 60)
|
||||
c.Upsert(Entry{Name: "App-Server", IP: "10.0.0.1", Kind: "device"})
|
||||
|
||||
if len(c.Search("app")) != 1 {
|
||||
t.Error("Search should be case-insensitive")
|
||||
}
|
||||
if len(c.Search("APP")) != 1 {
|
||||
t.Error("Search should be case-insensitive for uppercase")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch_EmptyPrefix(t *testing.T) {
|
||||
c := New("", 60)
|
||||
c.Upsert(Entry{Name: "a", IP: "1.1.1.1", Kind: "device"})
|
||||
c.Upsert(Entry{Name: "b", IP: "2.2.2.2", Kind: "vm"})
|
||||
|
||||
if len(c.Search("")) != 2 {
|
||||
t.Error("Search('') should return all entries")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch_NoMatch(t *testing.T) {
|
||||
c := New("", 60)
|
||||
c.Upsert(Entry{Name: "host", IP: "1.1.1.1", Kind: "device"})
|
||||
|
||||
if len(c.Search("xyz")) != 0 {
|
||||
t.Error("Search should return empty slice when no match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_Fresh(t *testing.T) {
|
||||
c := New("", 3600)
|
||||
c.Upsert(Entry{Name: "host", IP: "10.0.0.1", Kind: "device"})
|
||||
|
||||
e, fresh := c.Get("host")
|
||||
if !fresh {
|
||||
t.Error("entry just inserted should be fresh")
|
||||
}
|
||||
if e.IP != "10.0.0.1" {
|
||||
t.Errorf("IP: got %q, want %q", e.IP, "10.0.0.1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_Expired(t *testing.T) {
|
||||
c := New("", 1) // 1 second TTL
|
||||
e := Entry{Name: "host", IP: "10.0.0.1", Kind: "device", CachedAt: time.Now().Add(-2 * time.Second)}
|
||||
c.mu.Lock()
|
||||
c.entries["host"] = e
|
||||
c.mu.Unlock()
|
||||
|
||||
_, fresh := c.Get("host")
|
||||
if fresh {
|
||||
t.Error("entry older than TTL should not be fresh")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_ZeroTTL_AlwaysStale(t *testing.T) {
|
||||
c := New("", 0) // TTL=0 means never fresh for connect mode
|
||||
c.Upsert(Entry{Name: "host", IP: "10.0.0.1", Kind: "device"})
|
||||
|
||||
_, fresh := c.Get("host")
|
||||
if fresh {
|
||||
t.Error("TTL=0 should always return fresh=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_Missing(t *testing.T) {
|
||||
c := New("", 60)
|
||||
_, fresh := c.Get("nonexistent")
|
||||
if fresh {
|
||||
t.Error("missing entry should not be fresh")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClear(t *testing.T) {
|
||||
c := New("", 60)
|
||||
c.Upsert(Entry{Name: "a", IP: "1.1.1.1", Kind: "device"})
|
||||
c.Upsert(Entry{Name: "b", IP: "2.2.2.2", Kind: "vm"})
|
||||
c.Clear()
|
||||
|
||||
if len(c.All()) != 0 {
|
||||
t.Error("Clear should remove all entries")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAll(t *testing.T) {
|
||||
c := New("", 60)
|
||||
c.Upsert(Entry{Name: "a", IP: "1.1.1.1", Kind: "device"})
|
||||
c.Upsert(Entry{Name: "b", IP: "2.2.2.2", Kind: "vm"})
|
||||
|
||||
all := c.All()
|
||||
if len(all) != 2 {
|
||||
t.Errorf("All: got %d entries, want 2", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSave_ProducesValidJSON(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "cache.json")
|
||||
c := New(path, 60)
|
||||
c.Upsert(Entry{Name: "host", IP: "10.0.0.1", Kind: "device", Tags: []string{"mgmt"}})
|
||||
|
||||
if err := c.Save(); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(path)
|
||||
var df diskFormat
|
||||
if err := json.Unmarshal(data, &df); err != nil {
|
||||
t.Fatalf("saved file is not valid JSON: %v", err)
|
||||
}
|
||||
if len(df.Entries) != 1 {
|
||||
t.Errorf("expected 1 entry in JSON, got %d", len(df.Entries))
|
||||
}
|
||||
}
|
||||
|
||||
// tempFile writes content to a temp file and returns its path.
|
||||
func tempFile(t *testing.T, content []byte) string {
|
||||
t.Helper()
|
||||
f, err := os.CreateTemp(t.TempDir(), "cache-*.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.Write(content); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
return f.Name()
|
||||
}
|
||||
Reference in New Issue
Block a user