// store.go — jsonStore: a mutex-guarded, JSON-file-backed inventory Store. // // Rationale for a file (not a DB) in v1: stdlib-only, no cgo, trivially // reproducible/inspectable, and a fleet of kiosks is small. Writes are atomic // (temp file + rename) so a crash mid-save can't corrupt the inventory. The Store // interface keeps a SQLite/Postgres swap a drop-in if scale ever demands it. // // On disk: a single JSON object { "": {Host...}, ... } at the // configured path (default /var/lib/fleet-manager/inventory.json), which in the // deployed container is a mounted volume so the record survives restarts (unlike // the broker's deliberately-ephemeral PENDING store). package main import ( "encoding/json" "fmt" "os" "path/filepath" "sort" "sync" "time" ) type jsonStore struct { mu sync.Mutex path string items map[string]*Host // keyed by cert serial } // newJSONStore loads the inventory file if present (absent = empty fleet). func newJSONStore(path string) (*jsonStore, error) { s := &jsonStore{path: path, items: map[string]*Host{}} b, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return s, nil // fresh fleet } return nil, fmt.Errorf("read inventory %s: %w", path, err) } if len(b) == 0 { return s, nil } if err := json.Unmarshal(b, &s.items); err != nil { return nil, fmt.Errorf("parse inventory %s: %w", path, err) } if s.items == nil { s.items = map[string]*Host{} } return s, nil } // save persists the current map atomically. Caller must hold s.mu. func (s *jsonStore) save() error { if s.path == "" { return nil // in-memory mode (tests / seed-only) } if dir := filepath.Dir(s.path); dir != "" && dir != "." { if err := os.MkdirAll(dir, 0o750); err != nil { return err } } b, err := json.MarshalIndent(s.items, "", " ") if err != nil { return err } tmp := s.path + ".tmp" if err := os.WriteFile(tmp, b, 0o640); err != nil { return err } return os.Rename(tmp, s.path) } func clone(h *Host) *Host { if h == nil { return nil } c := *h return &c } func (s *jsonStore) List() []*Host { s.mu.Lock() defer s.mu.Unlock() out := make([]*Host, 0, len(s.items)) for _, h := range s.items { out = append(out, clone(h)) } // Stable order: online first, then most-recently-seen, then serial. sort.Slice(out, func(i, j int) bool { if out[i].Online != out[j].Online { return out[i].Online } if !out[i].LastSeen.Equal(out[j].LastSeen) { return out[i].LastSeen.After(out[j].LastSeen) } return out[i].Identity.CertSerial < out[j].Identity.CertSerial }) return out } func (s *jsonStore) Get(serial string) (*Host, bool) { s.mu.Lock() defer s.mu.Unlock() h, ok := s.items[serial] return clone(h), ok } func (s *jsonStore) Upsert(h *Host) error { if h == nil || h.Identity.CertSerial == "" { return fmt.Errorf("upsert: host needs a cert serial") } s.mu.Lock() defer s.mu.Unlock() if existing, ok := s.items[h.Identity.CertSerial]; ok { h.FirstSeen = existing.FirstSeen // preserve birth time on re-approval } else if h.FirstSeen.IsZero() { h.FirstSeen = time.Now().UTC() } s.items[h.Identity.CertSerial] = clone(h) return s.save() } func (s *jsonStore) SetDesired(serial string, d Desired) (*Host, bool, error) { s.mu.Lock() defer s.mu.Unlock() h, ok := s.items[serial] if !ok { return nil, false, nil } d.UpdatedAt = time.Now().UTC() h.Desired = d if err := s.save(); err != nil { return nil, true, err } return clone(h), true, nil } func (s *jsonStore) ReportState(serial string, v Version, h Health, u Usage, at time.Time) (*Host, bool, error) { s.mu.Lock() defer s.mu.Unlock() host, ok := s.items[serial] if !ok { return nil, false, nil } host.Version = v host.Health = h host.Usage = u host.LastSeen = at if err := s.save(); err != nil { return nil, true, err } return clone(host), true, nil } func (s *jsonStore) SetOnline(serial string, online bool, at time.Time) { s.mu.Lock() defer s.mu.Unlock() h, ok := s.items[serial] if !ok { return } h.Online = online if online { h.LastSeen = at } _ = s.save() // presence is best-effort; a failed persist isn't fatal }