diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/busstore.go b/modules/claude-licence-manager/cmd/claude-licence-manager/busstore.go new file mode 100644 index 000000000..079592c88 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/busstore.go @@ -0,0 +1,180 @@ +package main + +// The manager's kept data on the bus (design 39 bus-and-switch §2, §3, B11, B22): the state `licences`, one key per +// licence by its key form (§10), and the state `bindings`, one key per consumer, its value the form the consumers +// have always read. Written by the manager alone. + +import ( + "context" + "encoding/json" + "errors" + "sort" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// keptState is a state this module keeps, as mesh-sdk's KeptState answers it. +type keptState interface { + Get(key string) (*stdio.StateEntry, error) + Put(key string, value any) (uint64, error) + PutTTL(key string, value any, ttl time.Duration) (uint64, error) + Create(key string, value any) (uint64, error) + CreateTTL(key string, value any, ttl time.Duration) (uint64, error) + Delete(key string) error + Keys() ([]string, error) + History(key string) ([]stdio.StateRevision, error) +} + +// BindingState is the value of a consumer's key in `bindings` (§3, B22: the form 0 its readers decode): no secret, +// only what it should hold, which generation, since when, why and by whom. +type BindingState struct { + Licence string `json:"licence"` + Kind string `json:"kind"` + Generation int64 `json:"generation"` + Since string `json:"since,omitempty"` + Why string `json:"why,omitempty"` + By string `json:"by,omitempty"` +} + +// BusStore keeps the manager's data in the bus's own storage. +type BusStore struct { + LicencesState keptState + BindingsState keptState + Now func() time.Time + rest +} + +func (s *BusStore) get(st keptState, key string, into any) (bool, error) { + e, err := st.Get(key) + if err != nil || e == nil || len(e.Value) == 0 || string(e.Value) == "null" { + return false, err + } + return true, json.Unmarshal(e.Value, into) +} + +func (s *BusStore) Licences(context.Context) ([]Licence, error) { + keys, err := s.LicencesState.Keys() + if err != nil { + return nil, err + } + out := []Licence{} + for _, k := range keys { + var l Licence + if ok, err := s.get(s.LicencesState, k, &l); err != nil { + return nil, err + } else if ok { + out = append(out, l) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + +func (s *BusStore) Licence(_ context.Context, name string) (*Licence, error) { + var l Licence + ok, err := s.get(s.LicencesState, KeyFor(name), &l) + if err != nil || !ok || l.Name != name { + return nil, err + } + return &l, nil +} + +func (s *BusStore) LicenceForAccount(ctx context.Context, account string) (*Licence, error) { + all, err := s.Licences(ctx) + if err != nil { + return nil, err + } + for _, l := range all { + if l.AccountUUID == account { + return &l, nil + } + } + return nil, nil +} + +func (s *BusStore) SaveLicence(_ context.Context, l Licence) error { + if l.Name == "" { + return errors.New("a licence has a name") + } + _, err := s.LicencesState.Put(KeyFor(l.Name), l) + return err +} + +func (s *BusStore) Bindings(context.Context) ([]Binding, error) { + keys, err := s.BindingsState.Keys() + if err != nil { + return nil, err + } + out := []Binding{} + for _, k := range keys { + if b, err := s.binding(k); err != nil { + return nil, err + } else if b != nil { + out = append(out, *b) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Consumer < out[j].Consumer }) + return out, nil +} + +func (s *BusStore) binding(consumer string) (*Binding, error) { + var v BindingState + ok, err := s.get(s.BindingsState, consumer, &v) + if err != nil || !ok { + return nil, err + } + return &Binding{Consumer: consumer, Licence: v.Licence, Kind: v.Kind, Generation: v.Generation, Since: v.Since, + Why: v.Why, By: v.By}, nil +} + +func (s *BusStore) Binding(_ context.Context, consumer string) (*Binding, error) { + return s.binding(consumer) +} + +// Bind writes a consumer's key at its next generation: one more than the higher of its own and the floor (B11). +func (s *BusStore) Bind(_ context.Context, consumer, licence string, m Move) (Binding, error) { + before, err := s.binding(consumer) + if err != nil { + return Binding{}, err + } + generation := m.floor(consumer) + if before != nil { + generation = max(generation, before.Generation) + } + b := Binding{Consumer: consumer, Licence: licence, Kind: m.Kind, Generation: generation + 1, + Since: s.Now().UTC().Format(time.RFC3339), Why: m.Why, By: m.By} + _, err = s.BindingsState.Put(consumer, BindingState{Licence: b.Licence, Kind: b.Kind, Generation: b.Generation, + Since: b.Since, Why: b.Why, By: b.By}) + return b, err +} + +func (s *BusStore) Unbind(_ context.Context, consumer string) (bool, error) { + before, err := s.binding(consumer) + if err != nil || before == nil { + return false, err + } + return true, s.BindingsState.Delete(consumer) +} + +// Advance gives every consumer bound to licence its next generation, keeping its licence and since. +func (s *BusStore) Advance(ctx context.Context, licence string, m Move) ([]Binding, error) { + all, err := s.Bindings(ctx) + if err != nil { + return nil, err + } + var out []Binding + for _, b := range all { + if b.Licence != licence { + continue + } + b.Generation = max(b.Generation, m.floor(b.Consumer)) + 1 + b.Kind, b.Why, b.By = m.Kind, m.Why, m.By + if _, err := s.BindingsState.Put(b.Consumer, BindingState{Licence: b.Licence, Kind: b.Kind, + Generation: b.Generation, Since: b.Since, Why: b.Why, By: b.By}); err != nil { + return out, err + } + out = append(out, b) + } + return out, nil +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/busstore_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/busstore_test.go new file mode 100644 index 000000000..b6875fb61 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/busstore_test.go @@ -0,0 +1,178 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "sort" + "strings" + "sync" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// memState is a state of the bus as the runtime answers it, in memory: values, their past values, keys' lifetimes. +type memState struct { + mu sync.Mutex + now func() time.Time + values map[string]json.RawMessage + until map[string]time.Time + history map[string][]stdio.StateRevision + rev uint64 + writes int +} + +func newMemState(now func() time.Time) *memState { + return &memState{now: now, values: map[string]json.RawMessage{}, until: map[string]time.Time{}, + history: map[string][]stdio.StateRevision{}} +} + +func (s *memState) live(key string) bool { + if u, ok := s.until[key]; ok && !s.now().Before(u) { + delete(s.values, key) + delete(s.until, key) + } + _, ok := s.values[key] + return ok +} + +func (s *memState) Get(key string) (*stdio.StateEntry, error) { + s.mu.Lock() + defer s.mu.Unlock() + if !s.live(key) { + return nil, nil + } + return &stdio.StateEntry{Key: key, Value: s.values[key]}, nil +} + +func (s *memState) write(key string, value any, ttl time.Duration, create bool) (uint64, error) { + s.mu.Lock() + defer s.mu.Unlock() + if create && s.live(key) { + return 0, errors.New("the key has a value") + } + raw, err := json.Marshal(value) + if err != nil { + return 0, err + } + s.rev++ + s.writes++ + s.values[key] = raw + delete(s.until, key) + if ttl > 0 { + s.until[key] = s.now().Add(ttl) + } + s.history[key] = append(s.history[key], stdio.StateRevision{Key: key, Value: raw, Revision: s.rev, Op: "put", At: s.now()}) + return s.rev, nil +} + +func (s *memState) Put(key string, value any) (uint64, error) { return s.write(key, value, 0, false) } +func (s *memState) PutTTL(key string, value any, ttl time.Duration) (uint64, error) { + return s.write(key, value, ttl, false) +} +func (s *memState) Create(key string, value any) (uint64, error) { return s.write(key, value, 0, true) } +func (s *memState) CreateTTL(key string, value any, ttl time.Duration) (uint64, error) { + return s.write(key, value, ttl, true) +} + +func (s *memState) Delete(key string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.rev++ + s.writes++ + delete(s.values, key) + delete(s.until, key) + s.history[key] = append(s.history[key], stdio.StateRevision{Key: key, Revision: s.rev, Op: "delete", At: s.now()}) + return nil +} + +func (s *memState) Keys() ([]string, error) { + s.mu.Lock() + defer s.mu.Unlock() + out := []string{} + for k := range s.values { + if s.live(k) { + out = append(out, k) + } + } + sort.Strings(out) + return out, nil +} + +func (s *memState) History(key string) ([]stdio.StateRevision, error) { + s.mu.Lock() + defer s.mu.Unlock() + return append([]stdio.StateRevision(nil), s.history[key]...), nil +} + +// design 39 bus-and-switch §2: a rotation and a reading each write the licence's key, by its key form, and no field +// holds a token. +func TestARotationAndAReadingWriteTheLicenceKey(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) + key := KeyFor(licence1) + before := len(mm.licences.history[key]) + if _, err := mm.m.Rotate(ctx, licence1, true); err != nil { + t.Fatal(err) + } + if err := mm.m.ReadUsage(ctx); err != nil { + t.Fatal(err) + } + if got := len(mm.licences.history[key]); got < before+2 { + t.Fatalf("the key %s was written %d times by a rotation and a reading, want 2", key, got-before) + } + for _, r := range mm.licences.history[key] { + for _, token := range []string{"rt-", "at-"} { + if strings.Contains(string(r.Value), token) { + t.Fatalf("a value of %s holds %s: %s", key, token, r.Value) + } + } + } + var l Licence + _ = json.Unmarshal(mm.licences.values[key], &l) + if l.Name != licence1 || l.AccountUUID != "acct-1" || !strings.Contains(string(mm.licences.values[key]), `"account":"acct-1"`) { + t.Fatalf("the key holds %+v", l) + } +} + +// design 39 bus-and-switch §3, B11: bind, switch, release and a rotation each write or delete the consumer's key with +// its why and by; each consumer counts its own generations, past what its node reported. +func TestEachMoveWritesTheBindingWithWhyAndBy(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) + _ = mm.store.SaveLicence(ctx, Licence{Name: "api", Kind: "api-key", Generation: 1}) + mm.m.Reported = func(node string) int64 { + if node == "server" { + return 40 + } + return 0 + } + asked := WithCause(ctx, Cause{Why: "the switch verb", By: "jane"}) + if _, err := mm.m.Bind(asked, "server", "api"); err != nil { + t.Fatal(err) + } + if b := mm.bound("server"); b.Licence != "api" || b.Generation != 41 || b.Why != "the switch verb" || b.By != "jane" || + b.Since == "" || b.Kind != "api-key" { + t.Fatalf("server's binding %+v", b) + } + laptop := mm.bound("laptop").Generation + if _, err := mm.m.Bind(asked, "laptop", "api"); err != nil { + t.Fatal(err) + } + if b := mm.bound("laptop"); b.Generation != laptop+1 || b.Licence != "api" { + t.Fatalf("laptop's switch %+v, from generation %d", b, laptop) + } + if _, err := mm.m.Release(WithCause(ctx, Cause{Why: "the release verb", By: "jane"}), "server"); err != nil { + t.Fatal(err) + } + if _, ok := mm.bindings.values["server"]; ok { + t.Fatal("release left the key") + } + if h, _ := mm.bindings.History("server"); len(h) != 2 || h[1].Op != "delete" { + t.Fatalf("server's history %v", h) + } +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/keyform_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/keyform_test.go index 29e0e36c3..573ff76d9 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/keyform_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/keyform_test.go @@ -63,7 +63,7 @@ func assertRefused(t *testing.T, mm *miniMesh) { said = said || strings.HasPrefix(e, "licence.refused ") } audited := false - for _, a := range mm.store.Audits { + for _, a := range mm.mem.Audits { audited = audited || a["what"] == "refused" } if !said || !audited { diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go index 4b0dd45dd..49402da9b 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -71,9 +71,9 @@ func manager() (*Manager, error) { if n := os.Getenv("MESH_NODE"); n != "" { node = n } - bindings := stdio.State("bindings") built = &Manager{ - Store: store, + Store: &BusStore{LicencesState: stdio.State("licences"), BindingsState: stdio.State("bindings"), Now: time.Now, + rest: store}, Vendor: LiveVendor(), Crypt: crypt, Grants: VaultGrants{Keys: keys}, @@ -86,16 +86,16 @@ func manager() (*Manager, error) { } return a, json.Unmarshal(raw, &a) }, - PutBinding: func(_ context.Context, consumer string, b BindingState) error { - _, err := bindings.Put(consumer, b) - return err + Reported: func(node string) int64 { + heardMu.Lock() + defer heardMu.Unlock() + return heard[node].Generation }, - DeleteBinding: func(_ context.Context, consumer string) error { return bindings.Delete(consumer) }, - Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) }, - Now: time.Now, - Log: say, - Holder: fmt.Sprintf("%s:%d", node, os.Getpid()), - Settings: SettingsFrom(os.Getenv("MESH_LICENCE_SETTINGS")), + Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) }, + Now: time.Now, + Log: say, + Holder: fmt.Sprintf("%s:%d", node, os.Getpid()), + Settings: SettingsFrom(os.Getenv("MESH_LICENCE_SETTINGS")), } return built, nil } @@ -127,9 +127,14 @@ func keypair(dir string) (KeyPair, error) { // ---- the long-running half --------------------------------------------------------------------------- +// heard is what every node last reported holding (ADR 0206), by node. +var ( + heardMu sync.Mutex + heard = map[string]Holdings{} +) + func daemon() { - var mu sync.Mutex - reports := map[string]Holdings{} + mu, reports := &heardMu, heard var passing sync.Mutex pass := func() { passing.Lock() // one pass at a time: each account is leased, and a pass is cheap diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go index 2d35d6220..8bf20922d 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -53,17 +53,6 @@ type Holdings struct { Generation int64 `json:"generation"` } -// BindingState is what `bindings` holds for one consumer: no secret, only what it should hold and which -// generation. -type BindingState struct { - Licence string `json:"licence"` - Kind string `json:"kind"` - Generation int64 `json:"generation"` - // Why and By are what moved it and who asked (novox/hq issue 625), so the node can say so when it applies it. - Why string `json:"why,omitempty"` - By string `json:"by,omitempty"` -} - // Cause is why a consumer's binding changed and who or what asked for it (novox/hq issue 625): Why is the verb // called or the rule that fired, By the caller the runtime proved (stdio.CallerOf) or this module itself. type Cause struct { @@ -168,12 +157,11 @@ type Manager struct { // AskGrant asks a node's agent module for the grant a login left there, sealed to publicKey. An error // is the node not answering; a nil Sealed is no login waiting. AskGrant func(ctx context.Context, node, publicKey string) (GrantAnswer, error) - // PutBinding and DeleteBinding are this module's `bindings` state. - PutBinding func(ctx context.Context, consumer string, b BindingState) error - DeleteBinding func(ctx context.Context, consumer string) error - Emit func(event string, body map[string]any) error - Now func() time.Time - Log func(format string, args ...any) + // Reported is the binding generation a node last reported applying (B11), 0 when none is known. + Reported func(node string) int64 + Emit func(event string, body map[string]any) error + Now func() time.Time + Log func(format string, args ...any) // Holder names this process in a lease, so a second run is told apart. Holder string Settings Settings @@ -242,17 +230,9 @@ func (m *Manager) CandidatesIn(ctx context.Context, reports []Holdings) (map[str // newest first; the first that refreshes is adopted and the rest are settled as skipped without being // exchanged. Answers what was adopted. func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, error) { - // **Never a generation a node has already passed** (novox/hq issue 243). A store rebuilt after a loss - // counts from one again, while every node still holds the number it last applied. The nodes no longer - // wait for a higher number, but a binding numbered exactly as the one a node applied would still be - // skipped. So the count is moved past every number reported, before anything here binds. - var highest int64 - for _, r := range reports { - highest = max(highest, r.Generation) - } - if err := m.Store.GenerationsAbove(ctx, highest); err != nil { - return nil, err - } + // **Never a generation a node has already passed** (novox/hq issue 243, B11): each consumer's count is its own, + // and moves past the number its node reported applying. + floor := m.floorIn(reports) byAccount, err := m.CandidatesIn(ctx, reports) if err != nil { return nil, err @@ -265,7 +245,7 @@ func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, e // A node reporting an account the manager already holds, and bound to nothing, is bound to it // (ADR 0206 §7) — whenever its report arrives, not only when the licence is adopted: a node whose own // login is older than the one adopted is never a candidate, and would otherwise never be bound. - if err := m.bindReporters(ctx, reports); err != nil { + if err := m.bindReporters(ctx, reports, floor); err != nil { return nil, err } var adopted []string @@ -277,7 +257,7 @@ func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, e continue } for i, c := range list { - name, err := m.adoptOne(ctx, c, reports) + name, err := m.adoptOne(ctx, c, reports, floor) if err != nil { m.Log("adopting %s's login failed: %v", c.Node, err) continue @@ -296,7 +276,7 @@ func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, e } // bindReporters binds each reporting node that is bound to nothing to the licence its account already has. -func (m *Manager) bindReporters(ctx context.Context, reports []Holdings) error { +func (m *Manager) bindReporters(ctx context.Context, reports []Holdings, floor func(string) int64) error { for _, rep := range reports { if rep.Identity == nil || rep.Identity.AccountUUID == "" { continue @@ -314,21 +294,17 @@ func (m *Manager) bindReporters(ctx context.Context, reports []Holdings) error { if l == nil { continue } - b, err := m.Store.Bind(ctx, rep.Node, l.Name) + c := Cause{Why: rep.Node + " reported the account of " + l.Name + " and was bound to nothing", By: Itself} + b, err := m.Store.Bind(ctx, rep.Node, l.Name, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: floor}) if err != nil { return err } - c := Cause{Why: rep.Node + " reported the account of " + l.Name + " and was bound to nothing", By: Itself} m.moved(ctx, rep.Node, "", l.Name, b.Generation, c) - if err := m.PutBinding(ctx, rep.Node, BindingState{Licence: l.Name, Kind: l.Kind, Generation: b.Generation, - Why: c.Why, By: c.By}); err != nil { - return err - } } return nil } -func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings) (string, error) { +func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings, floor func(string) int64) (string, error) { answer, err := m.AskGrant(ctx, c.Node, m.Keys.PublicKey) if err != nil { // Not answering is not an answer: asked again on the next pass. @@ -429,7 +405,7 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings) if b, err := m.Store.Binding(ctx, c.Node); err != nil { return "", err } else if b == nil || b.Licence != l.Name { - moved, err := m.Store.Bind(ctx, c.Node, l.Name) + moved, err := m.Store.Bind(ctx, c.Node, l.Name, Move{Kind: l.Kind, Why: cause.Why, By: cause.By, Floor: floor}) if err != nil { return "", err } @@ -446,12 +422,12 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings) if b, err := m.Store.Binding(ctx, rep.Node); err != nil || b != nil { continue } - if b, err := m.Store.Bind(ctx, rep.Node, l.Name); err == nil { - m.moved(ctx, rep.Node, "", l.Name, b.Generation, - Cause{Why: rep.Node + " reports the account of " + l.Name + ", logged in on " + c.Node + ", and was bound to nothing", By: Itself}) + 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.publishAdvance(ctx, l); err != nil { + if err := m.publishAdvance(ctx, l, floor); err != nil { return "", err } _ = m.Emit("licence.adopted", map[string]any{"licence": l.Name, "from": c.Node, "replaced": held != nil}) @@ -501,21 +477,30 @@ func (m *Manager) grantOf(ctx context.Context, l Licence) (FullGrant, error) { return g, json.Unmarshal([]byte(plain), &g) } -// publishAdvance gives every consumer of a licence a new generation, and tells each in the state with the -// cause ctx carries, so the node says why it fetched again (novox/hq issue 625). -func (m *Manager) publishAdvance(ctx context.Context, l Licence) error { - bindings, err := m.Store.Advance(ctx, l.Name) - if err != nil { - return err - } +// publishAdvance gives every consumer of a licence a new generation, with the cause ctx carries, so the node says why +// it fetched again (novox/hq issue 625). +func (m *Manager) publishAdvance(ctx context.Context, l Licence, floor func(string) int64) error { c := CauseOf(ctx) - for _, b := range bindings { - if err := m.PutBinding(ctx, b.Consumer, BindingState{Licence: b.Licence, Kind: l.Kind, Generation: b.Generation, - Why: c.Why, By: c.By}); err != nil { - return err + _, err := m.Store.Advance(ctx, l.Name, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: floor}) + return err +} + +// floorIn is the floor of each consumer's generation (B11): the higher of what its node reported in reports and what +// Reported says it last applied. A worker's node is the part of its name before the first dot (ADR 0183 §19). +func (m *Manager) floorIn(reports []Holdings) func(string) int64 { + return func(consumer string) int64 { + node, _, _ := strings.Cut(consumer, ".") + var f int64 + for _, r := range reports { + if r.Node == node { + f = max(f, r.Generation) + } } + if m.Reported != nil { + f = max(f, m.Reported(node)) + } + return f } - return nil } // ---- keeping grants alive ---------------------------------------------------------------------------- @@ -599,7 +584,7 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri 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}) - if err := m.publishAdvance(ctx, *l); err != nil { + if err := m.publishAdvance(ctx, *l, m.floorIn(nil)); err != nil { return nil, err } return map[string]any{"licence": name, "refreshed": true, "expiresAt": time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339)}, nil @@ -660,6 +645,11 @@ func (m *Manager) ReadUsage(ctx context.Context) error { if err := m.Store.RecordUsage(ctx, l.Name, m.Now().UnixMilli(), reading, raw); err != nil { return err } + // The latest reading beside its licence, so licences answers from one key (§2). + l.Usage = &reading + if err := m.Store.SaveLicence(ctx, l); err != nil { + return err + } _ = m.Emit("usage.read", map[string]any{"licence": l.Name, "sessionPct": reading.SessionPct, "weeklyPct": reading.WeeklyPct, "sonnetPct": reading.SonnetPct}) } @@ -733,7 +723,8 @@ func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[strin if err != nil { return nil, err } - b, err := m.Store.Bind(ctx, consumer, licence) + c := CauseOf(ctx) + b, err := m.Store.Bind(ctx, consumer, licence, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: m.floorIn(nil)}) if err != nil { return nil, err } @@ -741,12 +732,7 @@ func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[strin if before != nil { from = before.Licence } - c := CauseOf(ctx) m.moved(ctx, consumer, from, licence, b.Generation, c) - if err := m.PutBinding(ctx, consumer, BindingState{Licence: licence, Kind: l.Kind, Generation: b.Generation, - Why: c.Why, By: c.By}); err != nil { - return nil, err - } return map[string]any{"consumer": consumer, "licence": licence, "generation": b.Generation, "from": from}, nil } @@ -759,9 +745,6 @@ func (m *Manager) Release(ctx context.Context, consumer string) (map[string]any, if ok, err := m.Store.Unbind(ctx, consumer); err != nil || !ok { return map[string]any{"consumer": consumer, "released": false, "reason": "it was bound to nothing"}, err } - if err := m.DeleteBinding(ctx, consumer); err != nil { - return nil, 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}) @@ -835,7 +818,7 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str _ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from, "why": c.Why, "by": c.By}) 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); err != nil { + if err := m.publishAdvance(ctx, l, m.floorIn(nil)); err != nil { return nil, err } _ = m.Emit("licence.adopted", map[string]any{"licence": name, "from": from, "replaced": held != nil}) diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go index ae6f11d9a..a9be95903 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go @@ -82,12 +82,14 @@ func (g *memGrants) Get(_ context.Context, key, part string) (string, error) { } type miniMesh struct { - grants *memGrants - m *Manager - store *MemoryStore + grants *memGrants + m *Manager + store *BusStore + mem *MemoryStore + licences *memState + bindings *memState vendor *stubVendor logins map[string]FullGrant // node → what its credentials file holds - state map[string]BindingState events []string now time.Time keys KeyPair @@ -96,10 +98,12 @@ type miniMesh struct { func newMesh(t *testing.T) *miniMesh { t.Helper() - mm := &miniMesh{logins: map[string]FullGrant{}, state: map[string]BindingState{}, now: t0, + mm := &miniMesh{logins: map[string]FullGrant{}, now: t0, grants: &memGrants{held: map[string]heldGrant{}}} now := func() time.Time { return mm.now } - mm.store = NewMemoryStore(now) + mm.mem = NewMemoryStore(now) + mm.licences, mm.bindings = newMemState(now), newMemState(now) + mm.store = &BusStore{LicencesState: mm.licences, BindingsState: mm.bindings, Now: now, rest: mm.mem} mm.vendor = &stubVendor{live: map[string]bool{}, now: now} crypt, _ := NewCrypt("a key the vault made") mm.keys, _ = GenerateKeyPair() @@ -117,8 +121,6 @@ func newMesh(t *testing.T) *miniMesh { box, err := Seal(string(raw), publicKey) return GrantAnswer{Sealed: &box, Fingerprint: Fingerprint(g.RefreshToken)}, err }, - PutBinding: func(_ context.Context, c string, b BindingState) error { mm.state[c] = b; return nil }, - DeleteBinding: func(_ context.Context, c string) error { delete(mm.state, c); return nil }, Emit: func(event string, body map[string]any) error { raw, _ := json.Marshal(body) mm.events = append(mm.events, event+" "+string(raw)) @@ -129,6 +131,15 @@ func newMesh(t *testing.T) *miniMesh { return mm } +// bound is a consumer's value in the state bindings, as its readers decode it. +func (mm *miniMesh) bound(consumer string) BindingState { + var b BindingState + if e, _ := mm.bindings.Get(consumer); e != nil { + _ = json.Unmarshal(e.Value, &b) + } + return b +} + func (mm *miniMesh) report(node, rt string, at time.Time, account string) Holdings { h := Holdings{Node: node, Identity: &Identity{AccountUUID: account, EmailAddress: account + "@example.org"}, Kind: "subscription", ChangedAt: at.Format(time.RFC3339Nano)} @@ -165,8 +176,8 @@ func TestAManagerWithNoLicenceAdoptsTheNewestLoginThatRefreshesAndNeverExchanges } // A first binding follows the login: both nodes reported this account and were bound to nothing. bs, _ := mm.store.Bindings(ctx) - if len(bs) != 2 || mm.state["laptop"].Licence != licence1 || mm.state["server"].Licence != licence1 { - t.Fatalf("bindings %v, state %v", bs, mm.state) + if len(bs) != 2 || mm.bound("laptop").Licence != licence1 || mm.bound("server").Licence != licence1 { + t.Fatalf("bindings %v, state %v", bs, mm.bindings.values) } // The adoption is said, and so is each binding it made (novox/hq issue 625). events := strings.Join(mm.events, "|") @@ -264,9 +275,9 @@ func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) { mm := newMesh(t) ctx := context.Background() _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) - g0 := mm.state["laptop"].Generation + g0 := mm.bound("laptop").Generation _, _ = mm.m.Rotate(ctx, licence1, true) - g1 := mm.state["laptop"].Generation + g1 := mm.bound("laptop").Generation if g1 <= g0 { t.Fatalf("a rotation went from %d to %d", g0, g1) } @@ -278,13 +289,13 @@ func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) { if _, err := mm.m.Bind(ctx, "laptop", "api"); err != nil { t.Fatal(err) } - if mm.state["laptop"].Licence != "api" || mm.state["laptop"].Generation <= g1 { - t.Fatalf("a switch to a licence rotated less often went backwards: %v", mm.state["laptop"]) + if mm.bound("laptop").Licence != "api" || mm.bound("laptop").Generation <= g1 { + t.Fatalf("a switch to a licence rotated less often went backwards: %v", mm.bound("laptop")) } if _, err := mm.m.Release(ctx, "laptop"); err != nil { t.Fatal(err) } - if _, ok := mm.state["laptop"]; ok { + if _, ok := mm.bindings.values["laptop"]; ok { t.Fatal("a released consumer still has a binding in the state") } } @@ -333,7 +344,7 @@ func TestNothingTheManagerPublishesKeepsOrListsCarriesAToken(t *testing.T) { _ = mm.m.ReadUsage(ctx) ls, _ := mm.m.Licences(ctx) bs, _ := mm.store.Bindings(ctx) - everything, _ := json.Marshal([]any{mm.events, mm.state, ls, bs}) + everything, _ := json.Marshal([]any{mm.events, mm.bindings.values, mm.licences.values, ls, bs}) for _, token := range []string{"rt-secret", "rt-1", "rt-2", "at-1", "at-2", "local-"} { if strings.Contains(string(everything), token) { t.Fatalf("%s was published: %s", token, everything) @@ -393,15 +404,15 @@ func TestANodeReportingAnAdoptedAccountLaterIsBoundToIt(t *testing.T) { _, _ = mm.m.Consider(ctx, []Holdings{mm.login("novox", "rt-new", true, t0)}) late := mm.login("laptop", "rt-older", true, t0.Add(-24*time.Hour)) _, _ = mm.m.Consider(ctx, []Holdings{late}) - if mm.state["laptop"].Licence != licence1 { - t.Fatalf("a node reporting the adopted account later was not bound: %v", mm.state) + if mm.bound("laptop").Licence != licence1 { + t.Fatalf("a node reporting the adopted account later was not bound: %v", mm.bindings.values) } if strings.Contains(strings.Join(mm.vendor.exchanged, ","), "rt-older") { t.Fatal("the older login was exchanged") } - g := mm.state["laptop"].Generation + g := mm.bound("laptop").Generation _, _ = mm.m.Consider(ctx, []Holdings{late}) - if mm.state["laptop"].Generation != g { + if mm.bound("laptop").Generation != g { t.Fatal("a node already bound was bound again") } } @@ -411,8 +422,8 @@ func TestALoginToAnotherAccountMovesItsNodeAndNoOther(t *testing.T) { mm := newMesh(t) ctx := context.Background() _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0), mm.report("server", "", t0, "acct-1")}) - if mm.state["laptop"].Licence != licence1 || mm.state["server"].Licence != licence1 { - t.Fatalf("%v", mm.state) + if mm.bound("laptop").Licence != licence1 || mm.bound("server").Licence != licence1 { + t.Fatalf("%v", mm.bindings.values) } mm.now = t0.Add(time.Hour) mm.logins["laptop"] = FullGrant{AccessToken: "local", RefreshToken: "rt-b", ExpiresAt: 1} @@ -422,11 +433,11 @@ func TestALoginToAnotherAccountMovesItsNodeAndNoOther(t *testing.T) { if len(adopted) != 1 || adopted[0] != "acct-2@example.org" { t.Fatalf("adopted %v", adopted) } - if mm.state["laptop"].Licence != "acct-2@example.org" { - t.Fatalf("the node a login was made on stayed bound to %v", mm.state["laptop"]) + if mm.bound("laptop").Licence != "acct-2@example.org" { + t.Fatalf("the node a login was made on stayed bound to %v", mm.bound("laptop")) } - if mm.state["server"].Licence != licence1 { - t.Fatalf("another node moved: %v", mm.state["server"]) + if mm.bound("server").Licence != licence1 { + t.Fatalf("another node moved: %v", mm.bound("server")) } ls, _ := mm.store.Licences(ctx) if len(ls) != 2 { @@ -439,11 +450,11 @@ func TestALoginThatDoesNotRefreshMovesNothing(t *testing.T) { mm := newMesh(t) ctx := context.Background() _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) - g := mm.state["laptop"] + g := mm.bound("laptop") mm.logins["laptop"] = FullGrant{AccessToken: "local", RefreshToken: "rt-dead", ExpiresAt: 1} _, _ = mm.m.Consider(ctx, []Holdings{mm.report("laptop", "rt-dead", t0.Add(time.Hour), "acct-2")}) - if mm.state["laptop"] != g { - t.Fatalf("a dead login moved its node: %v", mm.state["laptop"]) + if mm.bound("laptop") != g { + t.Fatalf("a dead login moved its node: %v", mm.bound("laptop")) } } @@ -483,7 +494,7 @@ func TestARebuiltStoreNeverGivesAGenerationANodeHasPassed(t *testing.T) { if _, err := mm.m.Consider(ctx, []Holdings{laptop}); err != nil { t.Fatal(err) } - if g := mm.state["laptop"].Generation; g <= 16 { + if g := mm.bound("laptop").Generation; g <= 16 { t.Fatalf("the laptop applied generation 16 and was given %d", g) } } @@ -512,7 +523,7 @@ func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) { mm.m.Log = func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) } ctx := context.Background() _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) - if s := mm.state["laptop"]; !strings.Contains(s.Why, "a login to "+licence1+" on laptop") || s.By != Itself { + if s := mm.bound("laptop"); !strings.Contains(s.Why, "a login to "+licence1+" on laptop") || s.By != Itself { t.Fatalf("a login's binding said %+v", s) } file := filepath.Join(t.TempDir(), "key") @@ -525,7 +536,7 @@ func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) { if _, err := mm.m.Bind(asked, "laptop", "api"); err != nil { t.Fatal(err) } - if s := mm.state["laptop"]; s.Why != "the switch verb" || s.By != "g14.node-tools" { + if s := mm.bound("laptop"); s.Why != "the switch verb" || s.By != "g14.node-tools" { t.Fatalf("the node is told %+v", s) } said := strings.Join(lines, "\n") @@ -544,7 +555,7 @@ func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) { t.Fatalf("the event %q lacks %s; events %v", switched, want, mm.events) } } - last := mm.store.Audits[len(mm.store.Audits)-1] + last := mm.mem.Audits[len(mm.mem.Audits)-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) } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/store.go b/modules/claude-licence-manager/cmd/claude-licence-manager/store.go index 5436c5d38..be18e0f78 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/store.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/store.go @@ -7,34 +7,57 @@ package main import ( "context" - "sort" "sync" "time" ) -// Licence is one licence: an account, or an API key. +// Licence is one licence: an account, or an API key. Its form is the value of the state `licences` (design 39 +// bus-and-switch §2): never a secret, which the vault holds (§15). type Licence struct { Name string `json:"name"` Kind string `json:"kind"` // subscription | api-key - AccountUUID string `json:"accountUuid,omitempty"` - Email string `json:"email,omitempty"` - OrganizationUUID string `json:"organizationUuid,omitempty"` - RefreshFingerprint string `json:"-"` + AccountUUID string `json:"account,omitempty"` + Email string `json:"address,omitempty"` + OrganizationUUID string `json:"organisation,omitempty"` + RefreshFingerprint string `json:"refreshFingerprint,omitempty"` AccessExpiresAt int64 `json:"accessExpiresAt,omitempty"` RefreshExpiresAt int64 `json:"refreshExpiresAt,omitempty"` Failures int `json:"failures"` - NotifiedAt int64 `json:"-"` + NotifiedAt int64 `json:"notifiedAt,omitempty"` AdoptedAt int64 `json:"adoptedAt"` + AdoptedFrom string `json:"adoptedFrom,omitempty"` RotatedAt int64 `json:"rotatedAt,omitempty"` - // Generation is the generation of the licence's secrets in the vault (design 39 bus-and-switch §2, §15). + // Generation is the generation of the licence's secrets in the vault (§2, §15). Generation int64 `json:"generation,omitempty"` + // Usage is the latest reading of the licence's usage (§2, §24). + Usage *UsageReading `json:"usage,omitempty"` } -// Binding is one consumer's binding and the generation it was last given (ADR 0206). +// Binding is one consumer's binding (ADR 0206, §3): the licence, kind and generation it should hold, since when, and +// why and by whom it was moved last. type Binding struct { Consumer string `json:"consumer"` Licence string `json:"licence"` + Kind string `json:"kind,omitempty"` Generation int64 `json:"generation"` + Since string `json:"since,omitempty"` + Why string `json:"why,omitempty"` + By string `json:"by,omitempty"` +} + +// Move is what moves a binding: the licence's kind, why and by whom, and the floor a consumer's generation must pass +// (B11: the generation its node last reported); a nil Floor is none. +type Move struct { + Kind, Why, By string + Floor func(consumer string) int64 +} + +// floor is the floor m gives consumer. +func (m Move) floor(consumer string) int64 { + if m.Floor == nil { + return 0 + } + return m.Floor(consumer) } // Outcome is what became of a login the manager was offered, by its refresh token's fingerprint. @@ -61,18 +84,21 @@ type Store interface { Licence(ctx context.Context, name string) (*Licence, error) LicenceForAccount(ctx context.Context, account string) (*Licence, error) SaveLicence(ctx context.Context, l Licence) error + Bindings(ctx context.Context) ([]Binding, error) + Binding(ctx context.Context, consumer string) (*Binding, error) + // Bind binds (or switches) a consumer at its next generation. + Bind(ctx context.Context, consumer, licence string, m Move) (Binding, error) + Unbind(ctx context.Context, consumer string) (bool, error) + // Advance gives every consumer of a licence its next generation: what a rotation is to them. + Advance(ctx context.Context, licence string, m Move) ([]Binding, error) + rest +} + +// rest is what the store keeps beside its licences and bindings. +type rest interface { // Lease takes a lease for d, or answers false while another holder's is live. Lease(ctx context.Context, key, holder string, d time.Duration) (bool, error) Unlease(ctx context.Context, key, holder string) error - Bindings(ctx context.Context) ([]Binding, error) - Binding(ctx context.Context, consumer string) (*Binding, error) - // Bind binds (or switches) a consumer at the next generation. - Bind(ctx context.Context, consumer, licence string) (Binding, error) - Unbind(ctx context.Context, consumer string) (bool, error) - // Advance gives every consumer of a licence a new generation: what a rotation is to them. - Advance(ctx context.Context, licence string) ([]Binding, error) - // GenerationsAbove makes every generation given from now on greater than this one. - GenerationsAbove(ctx context.Context, generation int64) error Outcome(ctx context.Context, fingerprint string) (Outcome, error) RecordOutcome(ctx context.Context, fingerprint, node, account string, o Outcome, why string) error RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error @@ -81,69 +107,28 @@ type Store interface { Close() } -// MemoryStore is the tests' store. +// MemoryStore is the tests' store of what the bus store does not keep yet. type MemoryStore struct { mu sync.Mutex now func() time.Time - rows map[string]Licence - binds map[string]Binding leases map[string]struct { holder string until time.Time } - outcomes map[string]Outcome - usage []UsageRow - Audits []map[string]any - generation int64 + 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, rows: map[string]Licence{}, binds: map[string]Binding{}, + return &MemoryStore{now: now, leases: map[string]struct { holder string until time.Time }{}, outcomes: map[string]Outcome{}} } -func (m *MemoryStore) Licences(context.Context) ([]Licence, error) { - m.mu.Lock() - defer m.mu.Unlock() - out := make([]Licence, 0, len(m.rows)) - for _, l := range m.rows { - out = append(out, l) - } - sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) - return out, nil -} - -func (m *MemoryStore) Licence(_ context.Context, name string) (*Licence, error) { - m.mu.Lock() - defer m.mu.Unlock() - if l, ok := m.rows[name]; ok { - return &l, nil - } - return nil, nil -} - -func (m *MemoryStore) LicenceForAccount(_ context.Context, account string) (*Licence, error) { - m.mu.Lock() - defer m.mu.Unlock() - for _, l := range m.rows { - if l.AccountUUID == account { - return &l, nil - } - } - return nil, nil -} - -func (m *MemoryStore) SaveLicence(_ context.Context, l Licence) error { - m.mu.Lock() - defer m.mu.Unlock() - m.rows[l.Name] = l - return nil -} - func (m *MemoryStore) Lease(_ context.Context, key, holder string, d time.Duration) (bool, error) { m.mu.Lock() defer m.mu.Unlock() @@ -166,67 +151,6 @@ func (m *MemoryStore) Unlease(_ context.Context, key, holder string) error { return nil } -func (m *MemoryStore) Bindings(context.Context) ([]Binding, error) { - m.mu.Lock() - defer m.mu.Unlock() - out := make([]Binding, 0, len(m.binds)) - for _, b := range m.binds { - out = append(out, b) - } - sort.Slice(out, func(i, j int) bool { return out[i].Consumer < out[j].Consumer }) - return out, nil -} - -func (m *MemoryStore) Binding(_ context.Context, consumer string) (*Binding, error) { - m.mu.Lock() - defer m.mu.Unlock() - if b, ok := m.binds[consumer]; ok { - return &b, nil - } - return nil, nil -} - -func (m *MemoryStore) Bind(_ context.Context, consumer, licence string) (Binding, error) { - m.mu.Lock() - defer m.mu.Unlock() - m.generation++ - b := Binding{Consumer: consumer, Licence: licence, Generation: m.generation} - m.binds[consumer] = b - return b, nil -} - -func (m *MemoryStore) Unbind(_ context.Context, consumer string) (bool, error) { - m.mu.Lock() - defer m.mu.Unlock() - _, ok := m.binds[consumer] - delete(m.binds, consumer) - return ok, nil -} - -func (m *MemoryStore) GenerationsAbove(_ context.Context, generation int64) error { - m.mu.Lock() - defer m.mu.Unlock() - m.generation = max(m.generation, generation) - return nil -} - -func (m *MemoryStore) Advance(_ context.Context, licence string) ([]Binding, error) { - m.mu.Lock() - defer m.mu.Unlock() - var out []Binding - for c, b := range m.binds { - if b.Licence != licence { - continue - } - m.generation++ - b.Generation = m.generation - m.binds[c] = b - out = append(out, b) - } - sort.Slice(out, func(i, j int) bool { return out[i].Consumer < out[j].Consumer }) - return out, nil -} - func (m *MemoryStore) Outcome(_ context.Context, fp string) (Outcome, error) { m.mu.Lock() defer m.mu.Unlock() diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go index 4ff3b785b..498935b83 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go @@ -1,9 +1,9 @@ package main import ( + "context" "crypto/rand" "encoding/base64" - "context" "os" "strings" "testing"