First slice of the server-side fleet control plane (serving/fleet-manager/), the
backend for the admin SPA (monitor / authorise / lifecycle). See
docs/fleet-management-design.md + tpm-enrolment-design.md §5.
This checkpoint (stdlib-only):
- Inventory record (identity/binding/enrolled/version/health/usage/desired),
keyed by TPM cert serial = hostnqn key, per the design's 'inventory record born
at enrolment'. JSON-file Store (atomic writes, mutex) behind an interface so a
DB swap is a drop-in.
- Admin plane: mTLS server verified by the DEDICATED admin CA (never the fleet CA,
so a host cert can't reach admin) + a plaintext dev mode. JSON API:
GET /api/hosts, GET /api/hosts/{serial}, PUT /api/hosts/{serial}/desired
(desired-state audited to the admin client-cert CN).
- Dev seed (FLEET_MGR_SEED) so the SPA has data before real enrolment/agent flow.
Smoke-tested: list/get/desired-PUT (incl. version-drift + drain), 404s, atomic
persistence. go build + go vet + gofmt clean.
Agent gRPC plane, broker-fold-in authorise, InfluxDB metrics, and the themed SPA
follow in checkpoints 2-6.
168 lines
4.1 KiB
Go
168 lines
4.1 KiB
Go
// 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 { "<cert_serial>": {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
|
|
}
|