fleet-manager: checkpoint 1 — service skeleton + inventory store + admin API

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.
This commit is contained in:
Audiobuzz
2026-07-04 03:25:54 +00:00
parent 68dee79832
commit 915a15b6a1
7 changed files with 686 additions and 0 deletions
+167
View File
@@ -0,0 +1,167 @@
// 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
}