Files
ssh-netbox-wrapper/internal/cache/cache.go
T
Sebastian Unterschütz 7c902cab3a
Release / release (push) Successful in 50s
feat: introduce shortcuts and shell hook support
- **Shortcuts**: Add hostname normalization with domain stripping and hyphen folding. Include alias generation for cached hosts.
- **Shell Hook**: Automate 24h cache refresh trigger with shell startup hook. Add install/uninstall commands for bash, zsh, and fish.
- **Wizard**: Extend setup wizard to configure shortcuts (domains, hyphen stripping) and default SSH port.
- **Cache**: Add `GetByShortcut` for resolving hosts via normalized shortcuts. Implement `NeedsRefresh` / `SetRefreshed` logic for refresh timestamps.
- **Tests**: Comprehensive unit tests for shortcuts, hook installation, cache refresh, and alias generation.
- **Docs**: Update README with shortcuts, shell hook, and default SSH port configuration.
2026-05-27 22:53:24 +02:00

223 lines
4.9 KiB
Go

package cache
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"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"`
LastUsed time.Time `json:"last_used,omitempty"`
}
type Cache struct {
mu sync.RWMutex
entries map[string]Entry
path string
ttl time.Duration
refreshStamp string // path to last_refresh timestamp file
}
type diskFormat struct {
Entries []Entry `json:"entries"`
}
func New(path string, ttlSeconds int) *Cache {
stamp := ""
if path != "" {
stamp = filepath.Join(filepath.Dir(path), "last_refresh")
}
return &Cache{
entries: make(map[string]Entry),
path: path,
ttl: time.Duration(ttlSeconds) * time.Second,
refreshStamp: stamp,
}
}
// NeedsRefresh reports whether the last full cache refresh (via `cache refresh`)
// is older than d, or has never happened. Always returns false when no path is set.
func (c *Cache) NeedsRefresh(d time.Duration) bool {
if c.refreshStamp == "" {
return false
}
data, err := os.ReadFile(c.refreshStamp)
if err != nil {
return true // file missing → never refreshed
}
var t time.Time
if err := t.UnmarshalText(data); err != nil {
return true
}
return time.Since(t) >= d
}
// SetRefreshed records the current time as the last successful full refresh.
func (c *Cache) SetRefreshed() error {
if c.refreshStamp == "" {
return nil
}
data, err := time.Now().MarshalText()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(c.refreshStamp), 0o755); err != nil {
return err
}
return os.WriteFile(c.refreshStamp, data, 0o644)
}
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()
}
// MarkUsed records the current time as LastUsed for the named entry.
// It is a no-op if the entry does not exist.
func (c *Cache) MarkUsed(name string) {
c.mu.Lock()
defer c.mu.Unlock()
if e, ok := c.entries[name]; ok {
e.LastUsed = time.Now()
c.entries[name] = e
}
}
// RecentlyUsed returns the n most recently used entries, sorted by LastUsed desc.
// Entries that have never been used (LastUsed zero) are excluded.
// If n <= 0, all used entries are returned.
func (c *Cache) RecentlyUsed(n int) []Entry {
c.mu.RLock()
defer c.mu.RUnlock()
var used []Entry
for _, e := range c.entries {
if !e.LastUsed.IsZero() {
used = append(used, e)
}
}
sort.Slice(used, func(i, j int) bool {
return used[i].LastUsed.After(used[j].LastUsed)
})
if n > 0 && len(used) > n {
used = used[:n]
}
return used
}
// 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
}
// GetByShortcut scans all entries and returns the first whose normalized name matches
// the normalized shortcut. Returns (entry, found, fresh).
func (c *Cache) GetByShortcut(shortcut string, normalize func(string) string) (entry Entry, found bool, fresh bool) {
c.mu.RLock()
defer c.mu.RUnlock()
norm := normalize(shortcut)
for _, e := range c.entries {
if normalize(e.Name) == norm {
isFresh := c.ttl > 0 && time.Since(e.CachedAt) < c.ttl
return e, true, isFresh
}
}
return Entry{}, false, false
}
// 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
}