M-5: the usage and audit logs kept in the bus's own storage

Every reading is appended under its licence's key with the vendor's answer whole (ADR
0054), and every act under its kind, never rewritten; an act whose audit entry the log
does not take fails and says so. The memory store goes: the tests run the bus store over
a bus kept in memory.

Builds: design 39 bus-and-switch §6, §7, B20

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
This commit is contained in:
2026-11-11 23:19:03 +01:00
parent 9770dab09e
commit 2b604e0dad
7 changed files with 313 additions and 98 deletions
@@ -8,6 +8,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"time"
@@ -43,8 +44,9 @@ type BusStore struct {
BindingsState keptState
LeasesState keptState
OfferedState keptState
UsageLog keptLog
AuditLog keptLog
Now func() time.Time
rest
}
func (s *BusStore) get(st keptState, key string, into any) (bool, error) {
@@ -249,3 +251,118 @@ func (s *BusStore) putOffered(fingerprint string, v offeredValue, ttl time.Durat
_, err := s.OfferedState.PutTTL(OfferedKey(fingerprint), v, ttl)
return err
}
// ---- the logs (§6, §7) ------------------------------------------------------------------------------------------
// keptLog is a log this module keeps, as mesh-sdk's KeptLog answers it.
type keptLog interface {
Append(key string, value any) (uint64, error)
Read(from uint64, keys string, limit int) (*stdio.LogPage, error)
Last(key string) (*stdio.LogEntry, error)
Info() (*stdio.LogInfo, error)
}
// UsageEntry is one entry of the log `usage` (§6): when, the flattened reading and the vendor's answer whole (ADR 0054).
type UsageEntry struct {
At string `json:"at"`
Reading UsageReading `json:"reading"`
Raw map[string]any `json:"raw,omitempty"`
}
// AuditKinds are the subjects of the log `audit` (§7); an act of another kind is kept under `drift`.
var AuditKinds = []string{"adopted", "refused", "failed", "rotated", "bound", "switched", "released", "drift", "carried",
"settings"}
func (s *BusStore) RecordUsage(_ context.Context, licence string, at int64, r UsageReading, raw map[string]any) error {
_, err := s.UsageLog.Append(KeyFor(licence), UsageEntry{At: time.UnixMilli(at).UTC().Format(time.RFC3339Nano),
Reading: r, Raw: raw})
return err
}
// Usage is the latest readings, newest first, of one licence or every one.
func (s *BusStore) Usage(ctx context.Context, licence string, limit int) ([]UsageRow, error) {
names := map[string]string{}
all, err := s.Licences(ctx)
if err != nil {
return nil, err
}
for _, l := range all {
names[KeyFor(l.Name)] = l.Name
}
keys := ""
if licence != "" {
keys = KeyFor(licence)
}
entries, err := s.newest(s.UsageLog, keys, limit)
if err != nil {
return nil, err
}
out := []UsageRow{}
for _, e := range entries {
var u UsageEntry
if json.Unmarshal(e.Value, &u) != nil {
continue
}
at, _ := time.Parse(time.RFC3339Nano, u.At)
name := names[e.Key]
if name == "" {
name = e.Key
}
out = append(out, UsageRow{Licence: name, At: at.UnixMilli(), Reading: u.Reading})
}
return out, nil
}
// newest is up to limit entries of keys, newest first: read back from the log's last sequence, a window that doubles
// until it holds limit entries or reaches the first.
func (s *BusStore) newest(l keptLog, keys string, limit int) ([]stdio.LogEntry, error) {
info, err := l.Info()
if err != nil || info.Last == 0 {
return nil, err
}
for span := uint64(max(limit, 1)); ; span *= 2 {
from := uint64(1)
if info.Last > span {
from = info.Last - span + 1
}
var got []stdio.LogEntry
for next := from; next != 0; {
page, err := l.Read(next, keys, 0)
if err != nil {
return nil, err
}
got = append(got, page.Entries...)
next = page.Next
}
if len(got) >= limit || from <= max(info.First, 1) {
if len(got) > limit {
got = got[len(got)-limit:]
}
for i, j := 0, len(got)-1; i < j; i, j = i+1, j-1 {
got[i], got[j] = got[j], got[i]
}
return got, nil
}
}
}
// Audit appends one entry under its kind, with when; a log that does not take it fails the act (B20).
func (s *BusStore) Audit(_ context.Context, what string, detail map[string]any) error {
kind := "drift"
for _, k := range AuditKinds {
if k == what {
kind = k
}
}
entry := map[string]any{"at": s.Now().UTC().Format(time.RFC3339Nano)}
if kind != what {
entry["what"] = what
}
for k, v := range detail {
entry[k] = v
}
if _, err := s.AuditLog.Append(kind, entry); err != nil {
return fmt.Errorf("the audit did not take the %s entry, so the act is not done: %w", what, err)
}
return nil
}
@@ -3,6 +3,7 @@ package main
import (
"context"
"encoding/json"
"errors"
"sort"
"strings"
"sync"
@@ -213,3 +214,128 @@ func TestALeaseAndAnOfferAreKeptByTheirKeysAndLifetimes(t *testing.T) {
t.Fatalf("the outcome lives until %v", u)
}
}
// memLog is a log of the bus as the runtime answers it, in memory; full, it refuses an entry.
type memLog struct {
mu sync.Mutex
now func() time.Time
entries []stdio.LogEntry
full bool
}
func newMemLog(now func() time.Time) *memLog { return &memLog{now: now} }
func (l *memLog) Append(key string, value any) (uint64, error) {
l.mu.Lock()
defer l.mu.Unlock()
if l.full {
return 0, errors.New("the log is full: maximum bytes exceeded")
}
raw, err := json.Marshal(value)
if err != nil {
return 0, err
}
seq := uint64(len(l.entries) + 1)
l.entries = append(l.entries, stdio.LogEntry{Sequence: seq, Key: key, Value: raw, At: l.now()})
return seq, nil
}
func (l *memLog) Read(from uint64, keys string, limit int) (*stdio.LogPage, error) {
l.mu.Lock()
defer l.mu.Unlock()
p := &stdio.LogPage{Entries: []stdio.LogEntry{}, Last: uint64(len(l.entries))}
for _, e := range l.entries {
if e.Sequence >= from && (keys == "" || e.Key == keys) {
if limit > 0 && len(p.Entries) == limit {
p.Next = e.Sequence
break
}
p.Entries = append(p.Entries, e)
}
}
return p, nil
}
func (l *memLog) Last(key string) (*stdio.LogEntry, error) {
l.mu.Lock()
defer l.mu.Unlock()
for i := len(l.entries) - 1; i >= 0; i-- {
if l.entries[i].Key == key {
e := l.entries[i]
return &e, nil
}
}
return nil, nil
}
func (l *memLog) Info() (*stdio.LogInfo, error) {
l.mu.Lock()
defer l.mu.Unlock()
i := &stdio.LogInfo{Entries: uint64(len(l.entries)), Last: uint64(len(l.entries))}
if len(l.entries) > 0 {
i.First = 1
}
return i, nil
}
// under is every entry of a log's subject, decoded.
func (l *memLog) under(key string) []map[string]any {
l.mu.Lock()
defer l.mu.Unlock()
var out []map[string]any
for _, e := range l.entries {
if key == "" || e.Key == key {
var v map[string]any
_ = json.Unmarshal(e.Value, &v)
v["what"] = e.Key
out = append(out, v)
}
}
return out
}
// design 39 bus-and-switch §6: a reading appends one entry under its licence's key, the reading beside the raw answer.
func TestAReadingAppendsOneUsageEntry(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
if err := mm.m.ReadUsage(ctx); err != nil {
t.Fatal(err)
}
got := mm.usage.under(KeyFor(licence1))
if len(got) != 1 || len(mm.usage.entries) != 1 {
t.Fatalf("a reading appended %v", mm.usage.entries)
}
if got[0]["raw"] == nil || got[0]["reading"] == nil || got[0]["at"] == nil {
t.Fatalf("the entry %v", got[0])
}
rows, _ := mm.store.Usage(ctx, licence1, 20)
if len(rows) != 1 || rows[0].Licence != licence1 || rows[0].Reading.SessionPct == nil {
t.Fatalf("usage answers %+v", rows)
}
}
// design 39 bus-and-switch §7, B20: each act appends one entry under its kind; a full log fails the act loudly.
func TestEachActAppendsOneAuditEntry(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
_, _ = mm.m.Rotate(ctx, licence1, true)
_ = mm.store.SaveLicence(ctx, Licence{Name: "api", Kind: "api-key", Generation: 1})
_, _ = mm.m.Bind(ctx, "laptop", "api")
_, _ = mm.m.Release(ctx, "laptop")
for kind, n := range map[string]int{"adopted": 1, "bound": 1, "rotated": 1, "switched": 1, "released": 1} {
if got := len(mm.audit.under(kind)); got != n {
t.Errorf("%s: %d entries, want %d", kind, got, n)
}
}
for _, e := range mm.audit.under("") {
if e["at"] == nil || e["by"] == nil && e["what"] != "adopted" {
t.Errorf("the entry %v has no at or by", e)
}
}
mm.audit.full = true
if _, err := mm.m.Bind(ctx, "laptop", "api"); err == nil || !strings.Contains(err.Error(), "the audit did not take") {
t.Fatalf("a bind with a full audit log: %v", err)
}
}
@@ -63,7 +63,7 @@ func assertRefused(t *testing.T, mm *miniMesh) {
said = said || strings.HasPrefix(e, "licence.refused ")
}
audited := false
for _, a := range mm.mem.Audits {
for _, a := range mm.audit.under("") {
audited = audited || a["what"] == "refused"
}
if !said || !audited {
@@ -63,17 +63,14 @@ func manager() (*Manager, error) {
if err != nil {
return nil, err
}
store, err := PgStoreFromEnv(context.Background())
if err != nil {
return nil, err
}
node, _ := os.Hostname()
if n := os.Getenv("MESH_NODE"); n != "" {
node = n
}
built = &Manager{
Store: &BusStore{LicencesState: stdio.State("licences"), BindingsState: stdio.State("bindings"),
LeasesState: stdio.State("leases"), OfferedState: stdio.State("offered"), Now: time.Now, rest: store},
LeasesState: stdio.State("leases"), OfferedState: stdio.State("offered"), UsageLog: stdio.Log("usage"),
AuditLog: stdio.Log("audit"), Now: time.Now},
Vendor: LiveVendor(),
Crypt: crypt,
Grants: VaultGrants{Keys: keys},
@@ -81,19 +81,23 @@ func CauseOf(ctx context.Context) Cause {
// record says one change of what a consumer holds, the same fields in three places (novox/hq issue 625): a line
// in the journal, a row in the audit table, and the event `licence.<what>` on the bus. Never a token.
func (m *Manager) record(ctx context.Context, what, line string, detail map[string]any) {
_ = m.Store.Audit(ctx, what, detail)
func (m *Manager) record(ctx context.Context, what, line string, detail map[string]any) error {
if err := m.Store.Audit(ctx, what, detail); err != nil {
m.Log("%s, but %v", line, err)
return err
}
m.Log("%s", line)
_ = m.Emit("licence."+what, detail)
return nil
}
// moved records a consumer bound or switched: from which licence to which, at which generation, why and by whom.
func (m *Manager) moved(ctx context.Context, consumer, from, licence string, generation int64, c Cause) {
func (m *Manager) moved(ctx context.Context, consumer, from, licence string, generation int64, c Cause) error {
what, line := "bound", fmt.Sprintf("bound %s to %s", consumer, licence)
if from != "" {
what, line = "switched", fmt.Sprintf("switched %s from %s to %s", consumer, from, licence)
}
m.record(ctx, what, fmt.Sprintf("%s (generation %d): %s; asked by %s", line, generation, c.Why, c.By),
return m.record(ctx, what, fmt.Sprintf("%s (generation %d): %s; asked by %s", line, generation, c.Why, c.By),
map[string]any{"consumer": consumer, "licence": licence, "from": from, "generation": generation,
"why": c.Why, "by": c.By})
}
@@ -299,7 +303,9 @@ func (m *Manager) bindReporters(ctx context.Context, reports []Holdings, floor f
if err != nil {
return err
}
m.moved(ctx, rep.Node, "", l.Name, b.Generation, c)
if err := m.moved(ctx, rep.Node, "", l.Name, b.Generation, c); err != nil {
return err
}
}
return nil
}
@@ -339,7 +345,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings,
if taken != "" {
why := "its key " + KeyFor(NameFor(c.Identity)) + " is the key of the licence " + taken
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused, why)
_ = m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "licence": NameFor(c.Identity), "reason": why})
if err := m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "licence": NameFor(c.Identity), "reason": why}); err != nil {
return "", err
}
_ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity), "reason": why})
return "", nil
}
@@ -350,7 +358,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings,
r := m.Vendor.Refresh(ctx, offered)
if !r.OK {
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Dead, fmt.Sprintf("%d %s", r.Status, r.Reason))
_ = m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "account": c.Identity.AccountUUID, "status": r.Status, "reason": r.Reason})
if err := m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "account": c.Identity.AccountUUID, "status": r.Status, "reason": r.Reason}); err != nil {
return "", err
}
_ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity),
"reason": fmt.Sprintf("the login's refresh token did not refresh (%d)", r.Status)})
m.Log("%s's login for %s did not refresh: %d %s", c.Node, NameFor(c.Identity), r.Status, r.Reason)
@@ -360,7 +370,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings,
if r.Account != "" && r.Account != c.Identity.AccountUUID {
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused,
"the node says "+c.Identity.AccountUUID+", the vendor says "+r.Account)
_ = m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "reported": c.Identity.AccountUUID, "vendor": r.Account})
if err := m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "reported": c.Identity.AccountUUID, "vendor": r.Account}); err != nil {
return "", err
}
_ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity),
"reason": "the account the node reported is not the one the vendor answered for"})
return "", nil
@@ -394,8 +406,10 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings,
why = "a newer login"
}
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Adopted, why)
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": l.Name, "node": c.Node, "account": c.Identity.AccountUUID,
"vendorNamedAccount": r.Account != ""})
if err := m.Store.Audit(ctx, "adopted", map[string]any{"licence": l.Name, "node": c.Node, "account": c.Identity.AccountUUID,
"vendorNamedAccount": r.Account != ""}); err != nil {
return "", err
}
// A login moves its node (ADR 0209): the node this login came from is bound to its licence —
// switched, if it was bound to another. Then every node reporting this account and bound to nothing.
@@ -413,7 +427,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings,
if b != nil {
from = b.Licence
}
m.moved(ctx, c.Node, from, l.Name, moved.Generation, cause)
if err := m.moved(ctx, c.Node, from, l.Name, moved.Generation, cause); err != nil {
return "", err
}
}
for _, rep := range reports {
if rep.Identity == nil || rep.Identity.AccountUUID != c.Identity.AccountUUID {
@@ -424,7 +440,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings,
}
bc := Cause{Why: rep.Node + " reports the account of " + l.Name + ", logged in on " + c.Node + ", and was bound to nothing", By: Itself}
if b, err := m.Store.Bind(ctx, rep.Node, l.Name, Move{Kind: l.Kind, Why: bc.Why, By: bc.By, Floor: floor}); err == nil {
m.moved(ctx, rep.Node, "", l.Name, b.Generation, bc)
if err := m.moved(ctx, rep.Node, "", l.Name, b.Generation, bc); err != nil {
return "", err
}
}
}
if err := m.publishAdvance(ctx, l, floor); err != nil {
@@ -553,7 +571,9 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri
if !r.OK {
l.Failures++
m.Log("%s did not refresh (%d in a row): %d %s", name, l.Failures, r.Status, r.Reason)
_ = m.Store.Audit(ctx, "failed", map[string]any{"licence": name, "status": r.Status, "reason": r.Reason, "failures": l.Failures})
if err := m.Store.Audit(ctx, "failed", map[string]any{"licence": name, "status": r.Status, "reason": r.Reason, "failures": l.Failures}); err != nil {
return nil, err
}
m.notify(l, fmt.Sprintf("refresh failed %d time(s) in a row: %d", l.Failures, r.Status), l.Failures >= m.Settings.FailuresToNotify)
if err := m.Store.SaveLicence(ctx, *l); err != nil {
return nil, err
@@ -581,9 +601,11 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri
}
}
ctx = WithCause(ctx, Cause{Why: "a rotation of " + name + " (" + c.Why + ")", By: c.By})
m.record(ctx, "rotated", fmt.Sprintf("rotated %s, its token now expiring %s: %s; asked by %s", name,
if err := m.record(ctx, "rotated", fmt.Sprintf("rotated %s, its token now expiring %s: %s; asked by %s", name,
time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339), c.Why, c.By),
map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt, "why": c.Why, "by": c.By})
map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt, "why": c.Why, "by": c.By}); err != nil {
return nil, err
}
if err := m.publishAdvance(ctx, *l, m.floorIn(nil)); err != nil {
return nil, err
}
@@ -732,7 +754,9 @@ func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[strin
if before != nil {
from = before.Licence
}
m.moved(ctx, consumer, from, licence, b.Generation, c)
if err := m.moved(ctx, consumer, from, licence, b.Generation, c); err != nil {
return nil, err
}
return map[string]any{"consumer": consumer, "licence": licence, "generation": b.Generation, "from": from}, nil
}
@@ -746,8 +770,10 @@ func (m *Manager) Release(ctx context.Context, consumer string) (map[string]any,
return map[string]any{"consumer": consumer, "released": false, "reason": "it was bound to nothing"}, err
}
c := CauseOf(ctx)
m.record(ctx, "released", fmt.Sprintf("released %s from %s: %s; asked by %s", consumer, was.Licence, c.Why, c.By),
map[string]any{"consumer": consumer, "licence": was.Licence, "generation": was.Generation, "why": c.Why, "by": c.By})
if err := m.record(ctx, "released", fmt.Sprintf("released %s from %s: %s; asked by %s", consumer, was.Licence, c.Why, c.By),
map[string]any{"consumer": consumer, "licence": was.Licence, "generation": was.Generation, "why": c.Why, "by": c.By}); err != nil {
return nil, err
}
return map[string]any{"consumer": consumer, "released": true, "was": was.Licence}, nil
}
@@ -800,7 +826,9 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str
}
if taken != "" {
why := "its key " + KeyFor(name) + " is the key of the licence " + taken
_ = m.Store.Audit(ctx, "refused", map[string]any{"licence": name, "kind": "api-key", "from": from, "reason": why})
if err := m.Store.Audit(ctx, "refused", map[string]any{"licence": name, "kind": "api-key", "from": from, "reason": why}); err != nil {
return nil, err
}
_ = m.Emit("licence.refused", map[string]any{"licence": name, "from": from, "reason": why})
return nil, fmt.Errorf("%s is refused: %s", name, why)
}
@@ -815,7 +843,9 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str
return nil, err
}
c := CauseOf(ctx)
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from, "why": c.Why, "by": c.By})
if err := m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from, "why": c.Why, "by": c.By}); err != nil {
return nil, err
}
m.Log("adopted the API key %s from %s; asked by %s", name, from, c.By)
ctx = WithCause(ctx, Cause{Why: "the API key " + name + " adopted from " + from, By: c.By})
if err := m.publishAdvance(ctx, l, m.floorIn(nil)); err != nil {
@@ -85,17 +85,18 @@ type miniMesh struct {
grants *memGrants
m *Manager
store *BusStore
mem *MemoryStore
usage *memLog
audit *memLog
licences *memState
bindings *memState
leases *memState
offered *memState
vendor *stubVendor
logins map[string]FullGrant // node → what its credentials file holds
events []string
now time.Time
keys KeyPair
askDown bool
vendor *stubVendor
logins map[string]FullGrant // node → what its credentials file holds
events []string
now time.Time
keys KeyPair
askDown bool
}
func newMesh(t *testing.T) *miniMesh {
@@ -103,11 +104,11 @@ func newMesh(t *testing.T) *miniMesh {
mm := &miniMesh{logins: map[string]FullGrant{}, now: t0,
grants: &memGrants{held: map[string]heldGrant{}}}
now := func() time.Time { return mm.now }
mm.mem = NewMemoryStore(now)
mm.usage, mm.audit = newMemLog(now), newMemLog(now)
mm.licences, mm.bindings = newMemState(now), newMemState(now)
mm.leases, mm.offered = newMemState(now), newMemState(now)
mm.store = &BusStore{LicencesState: mm.licences, BindingsState: mm.bindings, LeasesState: mm.leases,
OfferedState: mm.offered, Now: now, rest: mm.mem}
OfferedState: mm.offered, UsageLog: mm.usage, AuditLog: mm.audit, Now: now}
mm.vendor = &stubVendor{live: map[string]bool{}, now: now}
crypt, _ := NewCrypt("a key the vault made")
mm.keys, _ = GenerateKeyPair()
@@ -559,7 +560,8 @@ func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) {
t.Fatalf("the event %q lacks %s; events %v", switched, want, mm.events)
}
}
last := mm.mem.Audits[len(mm.mem.Audits)-1]
all := mm.audit.under("")
last := all[len(all)-1]
if raw, _ := json.Marshal(last); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), "switched") {
t.Fatalf("the audit kept %s", raw)
}
@@ -1,13 +1,11 @@
package main
// The manager's store (novox/hq ADR 0183, design 39 §1): licences with their grants encrypted, bindings
// with a generation, what became of each login it was offered, usage readings, and the audit. One
// interface, two implementations — postgres for the mesh (pgstore.go), memory for the tests — so every
// rule is tested without a database, and the database is asked only to keep rows.
// The manager's store (novox/hq ADR 0183, design 39 bus-and-switch §2 to §7): licences, bindings with a generation,
// leases, what became of each login it was offered, usage readings and the audit, all in the bus's own storage
// (busstore.go). Every rule is tested on the same store over a bus kept in memory.
import (
"context"
"sync"
"time"
)
@@ -96,63 +94,8 @@ type Store interface {
Unlease(ctx context.Context, key, holder string) error
Outcome(ctx context.Context, fingerprint string) (Outcome, error)
RecordOutcome(ctx context.Context, fingerprint, node, account string, o Outcome, why string) error
rest
}
// rest is what the store keeps beside its licences and bindings.
type rest interface {
RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error
Usage(ctx context.Context, licence string, limit int) ([]UsageRow, error)
// Audit appends one entry for an act; an error is the act failing (B20).
Audit(ctx context.Context, what string, detail map[string]any) error
Close()
}
// MemoryStore is the tests' store of what the bus store does not keep yet.
type MemoryStore struct {
mu sync.Mutex
now func() time.Time
leases map[string]struct {
holder string
until time.Time
}
outcomes map[string]Outcome
usage []UsageRow
Audits []map[string]any
}
// NewMemoryStore is an empty store whose leases age by now.
func NewMemoryStore(now func() time.Time) *MemoryStore {
return &MemoryStore{now: now}
}
func (m *MemoryStore) RecordUsage(_ context.Context, licence string, at int64, r UsageReading, _ map[string]any) error {
m.mu.Lock()
defer m.mu.Unlock()
m.usage = append(m.usage, UsageRow{Licence: licence, At: at, Reading: r})
return nil
}
func (m *MemoryStore) Usage(_ context.Context, licence string, limit int) ([]UsageRow, error) {
m.mu.Lock()
defer m.mu.Unlock()
var out []UsageRow
for i := len(m.usage) - 1; i >= 0 && len(out) < limit; i-- {
if licence == "" || m.usage[i].Licence == licence {
out = append(out, m.usage[i])
}
}
return out, nil
}
func (m *MemoryStore) Audit(_ context.Context, what string, detail map[string]any) error {
m.mu.Lock()
defer m.mu.Unlock()
d := map[string]any{"what": what}
for k, v := range detail {
d[k] = v
}
m.Audits = append(m.Audits, d)
return nil
}
func (m *MemoryStore) Close() {}